From da5ca18627682872fa97c97b650f45b5c44203c0 Mon Sep 17 00:00:00 2001 From: Cameron Cooke Date: Tue, 17 Mar 2026 08:22:28 +0000 Subject: [PATCH 01/50] WIP --- docs/TOOLS-CLI.md | 2 +- docs/TOOLS.md | 2 +- .../__tests__/register-tool-commands.test.ts | 95 ++++++++++- src/cli/register-tool-commands.ts | 70 +++++++- .../device/__tests__/test_device.test.ts | 2 +- src/mcp/tools/device/test_device.ts | 7 + .../tools/macos/__tests__/test_macos.test.ts | 2 +- src/mcp/tools/macos/test_macos.ts | 7 + .../simulator/__tests__/test_sim.test.ts | 2 +- src/mcp/tools/simulator/test_sim.ts | 5 + src/test-utils/mock-executors.ts | 8 +- src/types/common.ts | 1 + src/utils/CommandExecutor.ts | 2 + src/utils/__tests__/build-utils.test.ts | 158 +++++++++++++++++- src/utils/__tests__/test-common.test.ts | 39 +++++ src/utils/build-utils.ts | 78 +++++++++ src/utils/command.ts | 8 +- src/utils/test-common.ts | 12 ++ 18 files changed, 472 insertions(+), 28 deletions(-) create mode 100644 src/utils/__tests__/test-common.test.ts diff --git a/docs/TOOLS-CLI.md b/docs/TOOLS-CLI.md index 89ceaf3f..af055252 100644 --- a/docs/TOOLS-CLI.md +++ b/docs/TOOLS-CLI.md @@ -206,4 +206,4 @@ XcodeBuildMCP provides 76 canonical tools organized into 14 workflow groups. --- -*This documentation is automatically generated by `scripts/update-tools-docs.ts` from the tools manifest. Last updated: 2026-03-16T20:47:13.697Z UTC* +*This documentation is automatically generated by `scripts/update-tools-docs.ts` from the tools manifest. Last updated: 2026-03-16T22:17:55.782Z UTC* diff --git a/docs/TOOLS.md b/docs/TOOLS.md index fd054466..4631bf87 100644 --- a/docs/TOOLS.md +++ b/docs/TOOLS.md @@ -222,4 +222,4 @@ This document lists MCP tool names as exposed to MCP clients. XcodeBuildMCP prov --- -*This documentation is automatically generated by `scripts/update-tools-docs.ts` from the tools manifest. Last updated: 2026-03-16T20:47:13.697Z UTC* +*This documentation is automatically generated by `scripts/update-tools-docs.ts` from the tools manifest. Last updated: 2026-03-16T22:17:55.782Z UTC* diff --git a/src/cli/__tests__/register-tool-commands.test.ts b/src/cli/__tests__/register-tool-commands.test.ts index 8099f94a..64d8bbb6 100644 --- a/src/cli/__tests__/register-tool-commands.test.ts +++ b/src/cli/__tests__/register-tool-commands.test.ts @@ -159,6 +159,8 @@ describe('registerToolCommands', () => { }); it('keeps the normal missing-argument error when no hydrated default exists', async () => { + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}); + const tool = createTool(); const app = createApp(createCatalog([tool]), { ...baseRuntimeConfig, @@ -167,15 +169,10 @@ describe('registerToolCommands', () => { activeSessionDefaultsProfile: undefined, }); - let error: Error | undefined; - try { - await app.parseAsync(['simulator', 'run-tool']); - } catch (thrown) { - error = thrown as Error; - } + await expect(app.parseAsync(['simulator', 'run-tool'])).resolves.toBeDefined(); - expect(error?.message).toContain('Missing required argument: workspace-path'); - expect(error?.message).not.toMatch(/session defaults/i); + expect(consoleError).toHaveBeenCalledWith('Missing required argument: workspace-path'); + expect(process.exitCode).toBe(1); }); it('hydrates args before daemon-routed invocation', async () => { @@ -281,4 +278,86 @@ describe('registerToolCommands', () => { stdoutWrite.mockRestore(); }); + + it('allows --json to satisfy required arguments', async () => { + const invokeDirect = vi.spyOn(DefaultToolInvoker.prototype, 'invokeDirect').mockResolvedValue({ + content: [createTextContent('ok')], + isError: false, + }); + const stdoutWrite = vi.spyOn(process.stdout, 'write').mockImplementation(() => true); + + const tool = createTool(); + const app = createApp(createCatalog([tool]), { + ...baseRuntimeConfig, + sessionDefaults: undefined, + sessionDefaultsProfiles: undefined, + activeSessionDefaultsProfile: undefined, + }); + + await expect( + app.parseAsync([ + 'simulator', + 'run-tool', + '--json', + JSON.stringify({ workspacePath: 'FromJson.xcworkspace' }), + ]), + ).resolves.toBeDefined(); + + expect(invokeDirect).toHaveBeenCalledWith( + tool, + { + workspacePath: 'FromJson.xcworkspace', + }, + expect.any(Object), + ); + + stdoutWrite.mockRestore(); + }); + + it('allows array args that begin with a dash', async () => { + const invokeDirect = vi.spyOn(DefaultToolInvoker.prototype, 'invokeDirect').mockResolvedValue({ + content: [createTextContent('ok')], + isError: false, + }); + const stdoutWrite = vi.spyOn(process.stdout, 'write').mockImplementation(() => true); + + const tool = createTool({ + cliSchema: { + workspacePath: z.string().describe('Workspace path'), + extraArgs: z.array(z.string()).optional().describe('Extra args'), + }, + mcpSchema: { + workspacePath: z.string().describe('Workspace path'), + extraArgs: z.array(z.string()).optional().describe('Extra args'), + }, + }); + const app = createApp(createCatalog([tool]), { + ...baseRuntimeConfig, + sessionDefaults: undefined, + sessionDefaultsProfiles: undefined, + activeSessionDefaultsProfile: undefined, + }); + + await expect( + app.parseAsync([ + 'simulator', + 'run-tool', + '--workspace-path', + 'App.xcworkspace', + '--extra-args', + '-only-testing:AppTests', + ]), + ).resolves.toBeDefined(); + + expect(invokeDirect).toHaveBeenCalledWith( + tool, + { + workspacePath: 'App.xcworkspace', + extraArgs: ['-only-testing:AppTests'], + }, + expect.any(Object), + ); + + stdoutWrite.mockRestore(); + }); }); diff --git a/src/cli/register-tool-commands.ts b/src/cli/register-tool-commands.ts index 1e776513..2bd98dd4 100644 --- a/src/cli/register-tool-commands.ts +++ b/src/cli/register-tool-commands.ts @@ -46,6 +46,14 @@ function readProfileOverrideFromProcessArgv(): string | undefined { return typeof profile === 'string' ? profile : undefined; } +function formatMissingRequiredError(missingFlags: string[]): string { + if (missingFlags.length === 1) { + return `Missing required argument: ${missingFlags[0]}`; + } + + return `Missing required arguments: ${missingFlags.join(', ')}`; +} + /** * Register all tool commands from the catalog with yargs, grouped by workflow. */ @@ -124,6 +132,9 @@ function registerToolSubcommand( const unsupportedKeys = getUnsupportedSchemaKeys(tool.cliSchema); const commandName = tool.cliName; + const requiredFlagNames = [...yargsOptions.entries()] + .filter(([, config]) => Boolean(config.demandOption)) + .map(([flagName]) => flagName); yargs.command( commandName, @@ -132,10 +143,15 @@ function registerToolSubcommand( // Hide root-level options from tool help subYargs.option('log-level', { hidden: true }).option('style', { hidden: true }); + // Parse option-like values as arguments (e.g. --extra-args "-only-testing:...") + subYargs.parserConfiguration({ + 'unknown-options-as-args': true, + }); + // Register schema-derived options (tool arguments) const toolArgNames: string[] = []; for (const [flagName, config] of yargsOptions) { - subYargs.option(flagName, config); + subYargs.option(flagName, { ...config, demandOption: false }); toolArgNames.push(flagName); } @@ -176,6 +192,18 @@ function registerToolSubcommand( return subYargs; }, async (argv) => { + const unexpectedArgs = (argv._ as unknown[]) + .slice(2) + .filter((value): value is string => typeof value === 'string' && value.startsWith('-')); + + if (unexpectedArgs.length > 0) { + console.error( + `Unknown argument${unexpectedArgs.length === 1 ? '' : 's'}: ${unexpectedArgs.join(', ')}`, + ); + process.exitCode = 1; + return; + } + // Extract our options const jsonArg = argv.json as string | undefined; const profileOverride = argv.profile as string | undefined; @@ -237,16 +265,40 @@ function registerToolSubcommand( explicitArgs, }); - // Invoke the tool - const response = await invoker.invokeDirect(tool, args, { - runtime: 'cli', - cliExposedWorkflowIds, - socketPath, - workspaceRoot: opts.workspaceRoot, - logLevel, + const missingRequiredFlags = requiredFlagNames.filter((flagName) => { + const camelKey = convertArgvToToolParams({ [flagName]: true }); + const [toolKey] = Object.keys(camelKey); + const value = args[toolKey]; + return value === undefined || value === null || value === ''; }); - printToolResponse(response, { format: outputFormat, style: outputStyle }); + if (missingRequiredFlags.length > 0) { + console.error(formatMissingRequiredError(missingRequiredFlags)); + process.exitCode = 1; + return; + } + + const previousCliOutputFormat = process.env.XCODEBUILDMCP_CLI_OUTPUT_FORMAT; + process.env.XCODEBUILDMCP_CLI_OUTPUT_FORMAT = outputFormat; + + try { + // Invoke the tool + const response = await invoker.invokeDirect(tool, args, { + runtime: 'cli', + cliExposedWorkflowIds, + socketPath, + workspaceRoot: opts.workspaceRoot, + logLevel, + }); + + printToolResponse(response, { format: outputFormat, style: outputStyle }); + } finally { + if (previousCliOutputFormat === undefined) { + delete process.env.XCODEBUILDMCP_CLI_OUTPUT_FORMAT; + } else { + process.env.XCODEBUILDMCP_CLI_OUTPUT_FORMAT = previousCliOutputFormat; + } + } }, ); } diff --git a/src/mcp/tools/device/__tests__/test_device.test.ts b/src/mcp/tools/device/__tests__/test_device.test.ts index 9c46ac7b..3248002a 100644 --- a/src/mcp/tools/device/__tests__/test_device.test.ts +++ b/src/mcp/tools/device/__tests__/test_device.test.ts @@ -41,7 +41,7 @@ describe('test_device plugin', () => { ); const schemaKeys = Object.keys(schema).sort(); - expect(schemaKeys).toEqual(['extraArgs', 'testRunnerEnv']); + expect(schemaKeys).toEqual(['extraArgs', 'progress', 'testRunnerEnv']); }); it('should validate XOR between projectPath and workspacePath', async () => { diff --git a/src/mcp/tools/device/test_device.ts b/src/mcp/tools/device/test_device.ts index 7d9732fb..4406f8de 100644 --- a/src/mcp/tools/device/test_device.ts +++ b/src/mcp/tools/device/test_device.ts @@ -28,6 +28,7 @@ import { } from '../../../utils/typed-tool-factory.ts'; import { nullifyEmptyStrings } from '../../../utils/schema-helpers.ts'; import { filterStderrContent, type XcresultSummary } from '../../../utils/test-result-content.ts'; +import { resolveTestProgressEnabled } from '../../../utils/test-common.ts'; // Unified schema: XOR between projectPath and workspacePath const baseSchemaObject = z.object({ @@ -46,6 +47,10 @@ const baseSchemaObject = z.object({ .describe( 'Environment variables to pass to the test runner (TEST_RUNNER_ prefix added automatically)', ), + progress: z + .boolean() + .optional() + .describe('Show detailed test progress output (MCP defaults to true, CLI defaults to false)'), }); const testDeviceSchema = z.preprocess( @@ -212,6 +217,7 @@ export async function testDeviceLogic( const execOpts: CommandExecOptions | undefined = params.testRunnerEnv ? { env: normalizeTestRunnerEnv(params.testRunnerEnv) } : undefined; + const progress = resolveTestProgressEnabled(params.progress); // Run the test command const testResult = await executeXcodeBuildCommand( @@ -230,6 +236,7 @@ export async function testDeviceLogic( deviceId: params.deviceId, useLatestOS: false, logPrefix: 'Test Run', + showTestProgress: progress, }, params.preferXcodebuild, 'test', diff --git a/src/mcp/tools/macos/__tests__/test_macos.test.ts b/src/mcp/tools/macos/__tests__/test_macos.test.ts index 75463ba5..f327ee40 100644 --- a/src/mcp/tools/macos/__tests__/test_macos.test.ts +++ b/src/mcp/tools/macos/__tests__/test_macos.test.ts @@ -51,7 +51,7 @@ describe('test_macos plugin (unified)', () => { expect(zodSchema.safeParse({ testRunnerEnv: { FOO: 123 } }).success).toBe(false); const schemaKeys = Object.keys(schema).sort(); - expect(schemaKeys).toEqual(['extraArgs', 'testRunnerEnv'].sort()); + expect(schemaKeys).toEqual(['extraArgs', 'progress', 'testRunnerEnv'].sort()); }); }); diff --git a/src/mcp/tools/macos/test_macos.ts b/src/mcp/tools/macos/test_macos.ts index 1dbdf7ba..4159e6d8 100644 --- a/src/mcp/tools/macos/test_macos.ts +++ b/src/mcp/tools/macos/test_macos.ts @@ -28,6 +28,7 @@ import { } from '../../../utils/typed-tool-factory.ts'; import { nullifyEmptyStrings } from '../../../utils/schema-helpers.ts'; import { filterStderrContent, type XcresultSummary } from '../../../utils/test-result-content.ts'; +import { resolveTestProgressEnabled } from '../../../utils/test-common.ts'; // Unified schema: XOR between projectPath and workspacePath const baseSchemaObject = z.object({ @@ -44,6 +45,10 @@ const baseSchemaObject = z.object({ .describe( 'Environment variables to pass to the test runner (TEST_RUNNER_ prefix added automatically)', ), + progress: z + .boolean() + .optional() + .describe('Show detailed test progress output (MCP defaults to true, CLI defaults to false)'), }); const publicSchemaObject = baseSchemaObject.omit({ @@ -193,6 +198,7 @@ export async function testMacosLogic( const execOpts: CommandExecOptions | undefined = params.testRunnerEnv ? { env: normalizeTestRunnerEnv(params.testRunnerEnv) } : undefined; + const progress = resolveTestProgressEnabled(params.progress); // Run the test command const testResult = await executeXcodeBuildCommand( @@ -207,6 +213,7 @@ export async function testMacosLogic( { platform: XcodePlatform.macOS, logPrefix: 'Test Run', + showTestProgress: progress, }, params.preferXcodebuild ?? false, 'test', diff --git a/src/mcp/tools/simulator/__tests__/test_sim.test.ts b/src/mcp/tools/simulator/__tests__/test_sim.test.ts index 4b398fe7..bdb9b619 100644 --- a/src/mcp/tools/simulator/__tests__/test_sim.test.ts +++ b/src/mcp/tools/simulator/__tests__/test_sim.test.ts @@ -35,7 +35,7 @@ describe('test_sim tool', () => { expect(schemaObj.safeParse({ testRunnerEnv: { FOO: 123 } }).success).toBe(false); const schemaKeys = Object.keys(schema).sort(); - expect(schemaKeys).toEqual(['extraArgs', 'testRunnerEnv'].sort()); + expect(schemaKeys).toEqual(['extraArgs', 'progress', 'testRunnerEnv'].sort()); }); }); diff --git a/src/mcp/tools/simulator/test_sim.ts b/src/mcp/tools/simulator/test_sim.ts index e3c5ecf4..4857187c 100644 --- a/src/mcp/tools/simulator/test_sim.ts +++ b/src/mcp/tools/simulator/test_sim.ts @@ -56,6 +56,10 @@ const baseSchemaObject = z.object({ .describe( 'Environment variables to pass to the test runner (TEST_RUNNER_ prefix added automatically)', ), + progress: z + .boolean() + .optional() + .describe('Show detailed test progress output (MCP defaults to true, CLI defaults to false)'), }); // Apply XOR validation: exactly one of projectPath OR workspacePath, and exactly one of simulatorId OR simulatorName required @@ -120,6 +124,7 @@ export async function test_simLogic( preferXcodebuild: params.preferXcodebuild ?? false, platform: inferred.platform, testRunnerEnv: params.testRunnerEnv, + progress: params.progress, }, executor, ); diff --git a/src/test-utils/mock-executors.ts b/src/test-utils/mock-executors.ts index 03b09000..ab157a0c 100644 --- a/src/test-utils/mock-executors.ts +++ b/src/test-utils/mock-executors.ts @@ -19,7 +19,11 @@ import { ChildProcess } from 'child_process'; import type { WriteStream } from 'fs'; import { EventEmitter } from 'node:events'; import { PassThrough } from 'node:stream'; -import type { CommandExecutor, CommandResponse } from '../utils/CommandExecutor.ts'; +import type { + CommandExecutor, + CommandResponse, + CommandExecOptions, +} from '../utils/CommandExecutor.ts'; import type { FileSystemExecutor } from '../utils/FileSystemExecutor.ts'; import type { InteractiveProcess, InteractiveSpawner } from '../utils/execution/index.ts'; @@ -57,7 +61,7 @@ export function createMockExecutor( command: string[], logPrefix?: string, useShell?: boolean, - opts?: { env?: Record; cwd?: string }, + opts?: CommandExecOptions, detached?: boolean, ) => void; } diff --git a/src/types/common.ts b/src/types/common.ts index 8d49bccf..44851f18 100644 --- a/src/types/common.ts +++ b/src/types/common.ts @@ -139,4 +139,5 @@ export interface PlatformBuildOptions { useLatestOS?: boolean; arch?: string; logPrefix: string; + showTestProgress?: boolean; } diff --git a/src/utils/CommandExecutor.ts b/src/utils/CommandExecutor.ts index 94e65b0d..b618b88c 100644 --- a/src/utils/CommandExecutor.ts +++ b/src/utils/CommandExecutor.ts @@ -6,6 +6,8 @@ export const _typeModule = true as const; export interface CommandExecOptions { env?: Record; cwd?: string; + onStdout?: (chunk: string) => void; + onStderr?: (chunk: string) => void; } /** diff --git a/src/utils/__tests__/build-utils.test.ts b/src/utils/__tests__/build-utils.test.ts index a7fd73e9..b08a8134 100644 --- a/src/utils/__tests__/build-utils.test.ts +++ b/src/utils/__tests__/build-utils.test.ts @@ -2,7 +2,7 @@ * Tests for build-utils Sentry classification logic */ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, vi } from 'vitest'; import path from 'node:path'; import { createMockExecutor } from '../../test-utils/mock-executors.ts'; import { executeXcodeBuildCommand } from '../build-utils.ts'; @@ -262,6 +262,158 @@ describe('build-utils Sentry Classification', () => { }); }); + describe('Test Progress Output', () => { + it('should include per-test progress lines when showTestProgress is enabled', async () => { + const mockExecutor = createMockExecutor({ + success: true, + output: + "Test Case '-[Suite testA]' passed (0.001 seconds)\n" + + "Test Suite 'Suite' failed at 2026-01-01 00:00:00.000\n" + + 'Executed 2 tests, with 1 failures (0 unexpected) in 0.123 (0.124) seconds', + exitCode: 0, + }); + + const result = await executeXcodeBuildCommand( + mockParams, + { + ...mockPlatformOptions, + showTestProgress: true, + }, + false, + 'test', + mockExecutor, + ); + + const text = result.content.map((item) => item.text).join('\n'); + expect(text).toContain("๐Ÿงช Test Case '-[Suite testA]' passed (0.001 seconds)"); + expect(text).toContain("๐Ÿงช Test Suite 'Suite' failed at 2026-01-01 00:00:00.000"); + expect(text).toContain( + '๐Ÿงช Executed 2 tests, with 1 failures (0 unexpected) in 0.123 (0.124) seconds', + ); + }); + + it('should omit per-test progress lines when showTestProgress is disabled', async () => { + const mockExecutor = createMockExecutor({ + success: true, + output: "Test Case '-[Suite testA]' passed (0.001 seconds)", + exitCode: 0, + }); + + const result = await executeXcodeBuildCommand( + mockParams, + { + ...mockPlatformOptions, + showTestProgress: false, + }, + false, + 'test', + mockExecutor, + ); + + const text = result.content.map((item) => item.text).join('\n'); + expect(text).not.toContain('๐Ÿงช Test Case'); + }); + + it('should stream test progress immediately in CLI text output mode', async () => { + const originalRuntime = process.env.XCODEBUILDMCP_RUNTIME; + const originalOutputFormat = process.env.XCODEBUILDMCP_CLI_OUTPUT_FORMAT; + process.env.XCODEBUILDMCP_RUNTIME = 'cli'; + process.env.XCODEBUILDMCP_CLI_OUTPUT_FORMAT = 'text'; + + const stdoutWrite = vi.spyOn(process.stdout, 'write').mockImplementation(() => true); + + const mockExecutor = createMockExecutor({ + success: true, + output: "Test Case '-[Suite streamed]' passed (0.010 seconds)", + exitCode: 0, + onExecute: (_command, _logPrefix, _useShell, opts) => { + opts?.onStdout?.("Test Case '-[Suite streamed]' passed (0.010 seconds)\\n"); + }, + }); + + try { + const result = await executeXcodeBuildCommand( + mockParams, + { + ...mockPlatformOptions, + showTestProgress: true, + }, + false, + 'test', + mockExecutor, + ); + + const streamedOutput = stdoutWrite.mock.calls.flat().join(''); + expect(streamedOutput).toContain('๐Ÿงช Test configuration: scheme=TestScheme'); + expect(streamedOutput).toContain("๐Ÿงช Test Case '-[Suite streamed]' passed (0.010 seconds)"); + + const responseText = result.content.map((item) => item.text).join('\n'); + expect(responseText).not.toContain('๐Ÿงช Test Case'); + } finally { + stdoutWrite.mockRestore(); + if (originalRuntime === undefined) { + delete process.env.XCODEBUILDMCP_RUNTIME; + } else { + process.env.XCODEBUILDMCP_RUNTIME = originalRuntime; + } + if (originalOutputFormat === undefined) { + delete process.env.XCODEBUILDMCP_CLI_OUTPUT_FORMAT; + } else { + process.env.XCODEBUILDMCP_CLI_OUTPUT_FORMAT = originalOutputFormat; + } + } + }); + + it('should not stream progress in CLI JSON output mode', async () => { + const originalRuntime = process.env.XCODEBUILDMCP_RUNTIME; + const originalOutputFormat = process.env.XCODEBUILDMCP_CLI_OUTPUT_FORMAT; + process.env.XCODEBUILDMCP_RUNTIME = 'cli'; + process.env.XCODEBUILDMCP_CLI_OUTPUT_FORMAT = 'json'; + + const stdoutWrite = vi.spyOn(process.stdout, 'write').mockImplementation(() => true); + + const mockExecutor = createMockExecutor({ + success: true, + output: "Test Case '-[Suite json]' passed (0.020 seconds)", + exitCode: 0, + onExecute: (_command, _logPrefix, _useShell, opts) => { + opts?.onStdout?.("Test Case '-[Suite json]' passed (0.020 seconds)\\n"); + }, + }); + + try { + const result = await executeXcodeBuildCommand( + mockParams, + { + ...mockPlatformOptions, + showTestProgress: true, + }, + false, + 'test', + mockExecutor, + ); + + const streamedOutput = stdoutWrite.mock.calls.flat().join(''); + expect(streamedOutput).not.toContain("๐Ÿงช Test Case '-[Suite json]' passed (0.020 seconds)"); + + const responseText = result.content.map((item) => item.text).join('\n'); + expect(responseText).toContain("๐Ÿงช Test Case '-[Suite json]' passed (0.020 seconds)"); + } finally { + stdoutWrite.mockRestore(); + if (originalRuntime === undefined) { + delete process.env.XCODEBUILDMCP_RUNTIME; + } else { + process.env.XCODEBUILDMCP_RUNTIME = originalRuntime; + } + if (originalOutputFormat === undefined) { + delete process.env.XCODEBUILDMCP_CLI_OUTPUT_FORMAT; + } else { + process.env.XCODEBUILDMCP_CLI_OUTPUT_FORMAT = originalOutputFormat; + } + } + }); + }); + describe('Working Directory (cwd) Handling', () => { it('should pass project directory as cwd for workspace builds', async () => { let capturedOptions: any; @@ -385,7 +537,9 @@ describe('build-utils Sentry Classification', () => { 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__/test-common.test.ts b/src/utils/__tests__/test-common.test.ts new file mode 100644 index 00000000..ed26cc63 --- /dev/null +++ b/src/utils/__tests__/test-common.test.ts @@ -0,0 +1,39 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { resolveTestProgressEnabled } from '../test-common.ts'; + +describe('resolveTestProgressEnabled', () => { + const originalRuntime = process.env.XCODEBUILDMCP_RUNTIME; + + afterEach(() => { + 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); + }); +}); diff --git a/src/utils/build-utils.ts b/src/utils/build-utils.ts index 41cb486a..c7fa5071 100644 --- a/src/utils/build-utils.ts +++ b/src/utils/build-utils.ts @@ -75,6 +75,22 @@ export async function executeXcodeBuildCommand( .filter(Boolean) as { type: 'warning' | 'error'; content: string }[]; } + function isTestProgressLine(line: string): boolean { + return ( + /^Test Case '.+' (passed|failed) \(/.test(line) || + /^Test Suite '.+' (passed|failed) at /.test(line) || + /^Executed \d+ tests?, with \d+ failures?/.test(line) + ); + } + + function extractTestProgressLines(text: string): string[] { + return text + .split('\n') + .map((line) => line.trim()) + .filter(Boolean) + .filter(isTestProgressLine); + } + log('info', `Starting ${platformOptions.logPrefix} ${buildAction} for scheme ${params.scheme}`); // Check if xcodemake is enabled and available @@ -209,6 +225,54 @@ export async function executeXcodeBuildCommand( command.push(buildAction); // Execute the command using xcodemake or xcodebuild + const shouldShowTestProgress = buildAction === 'test' && platformOptions.showTestProgress; + const shouldStreamCliTestProgress = + shouldShowTestProgress && + process.env.XCODEBUILDMCP_RUNTIME === 'cli' && + process.env.XCODEBUILDMCP_CLI_OUTPUT_FORMAT !== 'json'; + + let testProgressStreamBuffer = ''; + const streamTestProgressChunk = (chunk: string): void => { + if (!shouldStreamCliTestProgress) { + return; + } + + testProgressStreamBuffer += chunk; + const lines = testProgressStreamBuffer.split(/\r?\n/); + testProgressStreamBuffer = lines.pop() ?? ''; + + for (const rawLine of lines) { + const line = rawLine.trim(); + if (!line || !isTestProgressLine(line)) { + continue; + } + process.stdout.write(`๐Ÿงช ${line}\n`); + } + }; + + const flushTestProgressStream = (): void => { + if (!shouldStreamCliTestProgress) { + return; + } + + const line = testProgressStreamBuffer.trim(); + testProgressStreamBuffer = ''; + if (line && isTestProgressLine(line)) { + process.stdout.write(`๐Ÿงช ${line}\n`); + } + }; + + if (shouldStreamCliTestProgress) { + const sourceLabel = workspacePath + ? `workspace=${workspacePath}` + : projectPath + ? `project=${projectPath}` + : 'source=unknown'; + process.stdout.write( + `๐Ÿงช Test configuration: scheme=${params.scheme}, configuration=${params.configuration}, destination=${destinationString}, ${sourceLabel}\n`, + ); + } + let result; if ( isXcodemakeEnabledFlag && @@ -250,6 +314,20 @@ export async function executeXcodeBuildCommand( result = await executor(command, platformOptions.logPrefix, false, { ...execOpts, cwd: projectDir, + ...(shouldShowTestProgress ? { onStdout: streamTestProgressChunk } : {}), + }); + } + + flushTestProgressStream(); + + // Optional test progress output (per-test pass/fail and suite totals) + if (shouldShowTestProgress && !shouldStreamCliTestProgress) { + const progressLines = extractTestProgressLines(result.output); + progressLines.forEach((line) => { + buildMessages.push({ + type: 'text', + text: `๐Ÿงช ${line}`, + }); }); } diff --git a/src/utils/command.ts b/src/utils/command.ts index 9d85fb3d..d23edb04 100644 --- a/src/utils/command.ts +++ b/src/utils/command.ts @@ -99,11 +99,15 @@ async function defaultExecutor( let stderr = ''; childProcess.stdout?.on('data', (data: Buffer) => { - stdout += data.toString(); + const chunk = data.toString(); + stdout += chunk; + opts?.onStdout?.(chunk); }); childProcess.stderr?.on('data', (data: Buffer) => { - stderr += data.toString(); + const chunk = data.toString(); + stderr += chunk; + opts?.onStderr?.(chunk); }); // For detached processes, handle differently to avoid race conditions diff --git a/src/utils/test-common.ts b/src/utils/test-common.ts index 90411e6a..59aa6653 100644 --- a/src/utils/test-common.ts +++ b/src/utils/test-common.ts @@ -54,6 +54,14 @@ interface TestSummary { }>; } +export function resolveTestProgressEnabled(progress: boolean | undefined): boolean { + if (typeof progress === 'boolean') { + return progress; + } + + return process.env.XCODEBUILDMCP_RUNTIME === 'mcp'; +} + /** * Parse xcresult bundle using xcrun xcresulttool */ @@ -169,6 +177,7 @@ export async function handleTestLogic( preferXcodebuild?: boolean; platform: XcodePlatform; testRunnerEnv?: Record; + progress?: boolean; }, executor: CommandExecutor = getDefaultCommandExecutor(), fileSystemExecutor: FileSystemExecutor = getDefaultFileSystemExecutor(), @@ -185,6 +194,8 @@ export async function handleTestLogic( ); const resultBundlePath = join(tempDir, 'TestResults.xcresult'); + const progress = resolveTestProgressEnabled(params.progress); + // Add resultBundlePath to extraArgs const extraArgs = [...(params.extraArgs ?? []), `-resultBundlePath`, resultBundlePath]; @@ -206,6 +217,7 @@ export async function handleTestLogic( deviceId: params.deviceId, useLatestOS: params.useLatestOS, logPrefix: 'Test Run', + showTestProgress: progress, }, params.preferXcodebuild, 'test', From 278b1ca1b9b0e5873d5078f57118bd84d4d3f2f7 Mon Sep 17 00:00:00 2001 From: Cameron Cooke Date: Sat, 21 Mar 2026 20:54:43 +0000 Subject: [PATCH 02/50] refactor: Unify pipeline architecture, rendering, and output formatting - Migrate all test tools to canonical single-pipeline pattern with createPendingXcodebuildResponse (test_sim, test_device, test_macos) - Add test discovery preflight to device and macOS test tools - Migrate 5 query tools to consistent formatting with front matter, structured error blocks, and clean output (list_schemes, show_build_settings, get_sim_app_path, get_mac_app_path, get_device_app_path) - Add manifest-level when field for next step templates (success/failure/always) to control when next steps appear - Group warnings before summary with yellow triangle symbol - Fix compiler error rendering for multi-line errors - Fix test failure rendering to use consistent indented format with relative file paths - Fix linker warning rendering to use structured format - Remove dead code (legacy finalization helpers, dead modules, unused DI patterns) - Extract duplicate deriveDiagnosticBaseDir to shared module - Create shared xcodebuild-error-utils for query tool error parsing --- .../flowdeck-run-1.stderr.txt | 0 .../flowdeck-run-1.stdout.txt | 51 + .../flowdeck-run-2.stderr.txt | 0 .../flowdeck-run-2.stdout.txt | 51 + .../flowdeck-run-3.stderr.txt | 0 .../flowdeck-run-3.stdout.txt | 51 + .../2026-03-17T14-18-19-390Z/summary.json | 74 + .../xcodebuildmcp-run-1.stderr.txt | 0 .../xcodebuildmcp-run-1.stdout.txt | 669 ++++++++ .../xcodebuildmcp-run-2.stderr.txt | 0 .../xcodebuildmcp-run-2.stdout.txt | 461 ++++++ .../xcodebuildmcp-run-3.stderr.txt | 0 .../xcodebuildmcp-run-3.stdout.txt | 495 ++++++ .../flowdeck-run-1.stderr.txt | 0 .../flowdeck-run-1.stdout.txt | 51 + .../2026-03-17T14-22-25-469Z/summary.json | 30 + .../xcodebuildmcp-run-1.stderr.txt | 0 .../xcodebuildmcp-run-1.stdout.txt | 669 ++++++++ .../flowdeck-run-1.stderr.txt | 0 .../flowdeck-run-1.stdout.txt | 51 + .../2026-03-17T14-57-22-646Z/summary.json | 30 + .../xcodebuildmcp-run-1.stderr.txt | 0 .../xcodebuildmcp-run-1.stdout.txt | 635 ++++++++ docs/CLI.md | 35 + docs/TOOLS-CLI.md | 2 +- docs/TOOLS.md | 2 +- docs/dev/QUERY_TOOL_FORMAT_SPEC.md | 123 ++ docs/dev/STRUCTURED_XCODEBUILD_EVENTS_PLAN.md | 1165 ++++++++++++++ docs/dev/simulator-test-benchmark.md | 83 + .../CompileError.fixture.swift | 3 + knip.json | 26 + manifests/tools/build_device.yaml | 4 + manifests/tools/build_macos.yaml | 4 + manifests/tools/build_run_macos.yaml | 3 + manifests/tools/build_sim.yaml | 4 + manifests/tools/get_device_app_path.yaml | 3 + manifests/tools/get_mac_app_path.yaml | 2 + manifests/tools/get_sim_app_path.yaml | 4 + manifests/tools/list_schemes.yaml | 4 + manifests/tools/show_build_settings.yaml | 3 + package-lock.json | 1395 +++++++++-------- package.json | 16 +- scripts/benchmark-simulator-test.ts | 264 ++++ scripts/capture-xcodebuild-wrapper.ts | 128 ++ scripts/copy-build-assets.js | 33 - src/cli/__tests__/output.test.ts | 162 ++ src/cli/output.ts | 95 +- src/core/manifest/schema.ts | 1 + src/daemon.ts | 6 +- .../device/__tests__/build_device.test.ts | 174 +- .../device/__tests__/build_run_device.test.ts | 688 +++++--- .../__tests__/get_device_app_path.test.ts | 93 +- .../device/__tests__/test_device.test.ts | 306 +--- src/mcp/tools/device/build-settings.ts | 93 +- src/mcp/tools/device/build_device.ts | 56 +- src/mcp/tools/device/build_run_device.ts | 380 +++-- src/mcp/tools/device/get_device_app_path.ts | 38 +- src/mcp/tools/device/test_device.ts | 275 +--- .../tools/macos/__tests__/build_macos.test.ts | 97 +- .../macos/__tests__/build_run_macos.test.ts | 300 ++-- .../macos/__tests__/get_mac_app_path.test.ts | 118 +- .../tools/macos/__tests__/test_macos.test.ts | 493 ++---- src/mcp/tools/macos/build_macos.ts | 68 +- src/mcp/tools/macos/build_run_macos.ts | 240 ++- src/mcp/tools/macos/get_mac_app_path.ts | 50 +- src/mcp/tools/macos/test_macos.ts | 249 +-- .../__tests__/list_schemes.test.ts | 155 +- .../__tests__/show_build_settings.test.ts | 223 +-- .../tools/project-discovery/list_schemes.ts | 63 +- .../project-discovery/show_build_settings.ts | 75 +- .../simulator/__tests__/build_run_sim.test.ts | 364 ++++- .../simulator/__tests__/build_sim.test.ts | 159 +- .../__tests__/get_sim_app_path.test.ts | 22 +- .../simulator/__tests__/test_sim.test.ts | 142 +- src/mcp/tools/simulator/build_run_sim.ts | 534 +++---- src/mcp/tools/simulator/build_sim.ts | 69 +- src/mcp/tools/simulator/get_sim_app_path.ts | 101 +- src/mcp/tools/simulator/test_sim.ts | 52 +- src/mcp/tools/utilities/clean.ts | 32 +- src/runtime/__tests__/tool-invoker.test.ts | 70 + src/runtime/tool-invoker.ts | 101 +- src/runtime/types.ts | 1 + src/types/common.ts | 4 +- src/types/xcodebuild-events.ts | 159 ++ src/utils/__tests__/build-preflight.test.ts | 228 +++ src/utils/__tests__/build-utils.test.ts | 168 +- src/utils/__tests__/test-common.test.ts | 270 +++- src/utils/__tests__/test-preflight.test.ts | 192 +++ .../__tests__/xcodebuild-event-parser.test.ts | 247 +++ src/utils/__tests__/xcodebuild-output.test.ts | 182 +++ .../__tests__/xcodebuild-pipeline.test.ts | 182 +++ .../__tests__/xcodebuild-run-state.test.ts | 318 ++++ src/utils/app-path-resolver.ts | 100 ++ src/utils/build-preflight.ts | 80 + src/utils/build-utils.ts | 243 +-- src/utils/cli-progress-reporter.ts | 31 + .../__tests__/cli-text-renderer.test.ts | 376 +++++ .../__tests__/event-formatting.test.ts | 274 ++++ .../renderers/__tests__/mcp-renderer.test.ts | 150 ++ src/utils/renderers/cli-jsonl-renderer.ts | 13 + src/utils/renderers/cli-text-renderer.ts | 164 ++ src/utils/renderers/event-formatting.ts | 421 +++++ src/utils/renderers/index.ts | 31 + src/utils/renderers/mcp-renderer.ts | 115 ++ .../__tests__/next-steps-renderer.test.ts | 4 +- src/utils/responses/next-steps-renderer.ts | 6 +- src/utils/simulator-test-execution.ts | 62 + src/utils/simulator-utils.ts | 43 + src/utils/swift-test-discovery.ts | 248 +++ src/utils/terminal-output.ts | 60 + src/utils/test-common.ts | 341 ++-- src/utils/test-preflight.ts | 528 +++++++ src/utils/test-result-content.ts | 32 - src/utils/xcodebuild-error-utils.ts | 46 + src/utils/xcodebuild-event-parser.ts | 258 +++ src/utils/xcodebuild-line-parsers.ts | 128 ++ src/utils/xcodebuild-output.ts | 227 +++ src/utils/xcodebuild-pipeline.ts | 154 ++ src/utils/xcodebuild-run-state.ts | 201 +++ 119 files changed, 14812 insertions(+), 4243 deletions(-) create mode 100644 benchmarks/simulator-test/2026-03-17T14-18-19-390Z/flowdeck-run-1.stderr.txt create mode 100644 benchmarks/simulator-test/2026-03-17T14-18-19-390Z/flowdeck-run-1.stdout.txt create mode 100644 benchmarks/simulator-test/2026-03-17T14-18-19-390Z/flowdeck-run-2.stderr.txt create mode 100644 benchmarks/simulator-test/2026-03-17T14-18-19-390Z/flowdeck-run-2.stdout.txt create mode 100644 benchmarks/simulator-test/2026-03-17T14-18-19-390Z/flowdeck-run-3.stderr.txt create mode 100644 benchmarks/simulator-test/2026-03-17T14-18-19-390Z/flowdeck-run-3.stdout.txt create mode 100644 benchmarks/simulator-test/2026-03-17T14-18-19-390Z/summary.json create mode 100644 benchmarks/simulator-test/2026-03-17T14-18-19-390Z/xcodebuildmcp-run-1.stderr.txt create mode 100644 benchmarks/simulator-test/2026-03-17T14-18-19-390Z/xcodebuildmcp-run-1.stdout.txt create mode 100644 benchmarks/simulator-test/2026-03-17T14-18-19-390Z/xcodebuildmcp-run-2.stderr.txt create mode 100644 benchmarks/simulator-test/2026-03-17T14-18-19-390Z/xcodebuildmcp-run-2.stdout.txt create mode 100644 benchmarks/simulator-test/2026-03-17T14-18-19-390Z/xcodebuildmcp-run-3.stderr.txt create mode 100644 benchmarks/simulator-test/2026-03-17T14-18-19-390Z/xcodebuildmcp-run-3.stdout.txt create mode 100644 benchmarks/simulator-test/2026-03-17T14-22-25-469Z/flowdeck-run-1.stderr.txt create mode 100644 benchmarks/simulator-test/2026-03-17T14-22-25-469Z/flowdeck-run-1.stdout.txt create mode 100644 benchmarks/simulator-test/2026-03-17T14-22-25-469Z/summary.json create mode 100644 benchmarks/simulator-test/2026-03-17T14-22-25-469Z/xcodebuildmcp-run-1.stderr.txt create mode 100644 benchmarks/simulator-test/2026-03-17T14-22-25-469Z/xcodebuildmcp-run-1.stdout.txt create mode 100644 benchmarks/simulator-test/2026-03-17T14-57-22-646Z/flowdeck-run-1.stderr.txt create mode 100644 benchmarks/simulator-test/2026-03-17T14-57-22-646Z/flowdeck-run-1.stdout.txt create mode 100644 benchmarks/simulator-test/2026-03-17T14-57-22-646Z/summary.json create mode 100644 benchmarks/simulator-test/2026-03-17T14-57-22-646Z/xcodebuildmcp-run-1.stderr.txt create mode 100644 benchmarks/simulator-test/2026-03-17T14-57-22-646Z/xcodebuildmcp-run-1.stdout.txt create mode 100644 docs/dev/QUERY_TOOL_FORMAT_SPEC.md create mode 100644 docs/dev/STRUCTURED_XCODEBUILD_EVENTS_PLAN.md create mode 100644 docs/dev/simulator-test-benchmark.md create mode 100644 example_projects/iOS_Calculator/manual-fixtures/CalculatorAppTests/CompileError.fixture.swift create mode 100644 knip.json create mode 100644 scripts/benchmark-simulator-test.ts create mode 100644 scripts/capture-xcodebuild-wrapper.ts delete mode 100644 scripts/copy-build-assets.js create mode 100644 src/cli/__tests__/output.test.ts create mode 100644 src/types/xcodebuild-events.ts create mode 100644 src/utils/__tests__/build-preflight.test.ts create mode 100644 src/utils/__tests__/test-preflight.test.ts create mode 100644 src/utils/__tests__/xcodebuild-event-parser.test.ts create mode 100644 src/utils/__tests__/xcodebuild-output.test.ts create mode 100644 src/utils/__tests__/xcodebuild-pipeline.test.ts create mode 100644 src/utils/__tests__/xcodebuild-run-state.test.ts create mode 100644 src/utils/app-path-resolver.ts create mode 100644 src/utils/build-preflight.ts create mode 100644 src/utils/cli-progress-reporter.ts create mode 100644 src/utils/renderers/__tests__/cli-text-renderer.test.ts create mode 100644 src/utils/renderers/__tests__/event-formatting.test.ts create mode 100644 src/utils/renderers/__tests__/mcp-renderer.test.ts create mode 100644 src/utils/renderers/cli-jsonl-renderer.ts create mode 100644 src/utils/renderers/cli-text-renderer.ts create mode 100644 src/utils/renderers/event-formatting.ts create mode 100644 src/utils/renderers/index.ts create mode 100644 src/utils/renderers/mcp-renderer.ts create mode 100644 src/utils/simulator-test-execution.ts create mode 100644 src/utils/swift-test-discovery.ts create mode 100644 src/utils/terminal-output.ts create mode 100644 src/utils/test-preflight.ts delete mode 100644 src/utils/test-result-content.ts create mode 100644 src/utils/xcodebuild-error-utils.ts create mode 100644 src/utils/xcodebuild-event-parser.ts create mode 100644 src/utils/xcodebuild-line-parsers.ts create mode 100644 src/utils/xcodebuild-output.ts create mode 100644 src/utils/xcodebuild-pipeline.ts create mode 100644 src/utils/xcodebuild-run-state.ts diff --git a/benchmarks/simulator-test/2026-03-17T14-18-19-390Z/flowdeck-run-1.stderr.txt b/benchmarks/simulator-test/2026-03-17T14-18-19-390Z/flowdeck-run-1.stderr.txt new file mode 100644 index 00000000..e69de29b diff --git a/benchmarks/simulator-test/2026-03-17T14-18-19-390Z/flowdeck-run-1.stdout.txt b/benchmarks/simulator-test/2026-03-17T14-18-19-390Z/flowdeck-run-1.stdout.txt new file mode 100644 index 00000000..d865f0d2 --- /dev/null +++ b/benchmarks/simulator-test/2026-03-17T14-18-19-390Z/flowdeck-run-1.stdout.txt @@ -0,0 +1,51 @@ +^DUsing scheme test configuration: Debug +๐Ÿงช Test: CalculatorApp +Workspace: /Users/cameroncooke/.codex/worktrees/43f4/XcodeBuildMCP/example_projects/iOS_Calculator/CalculatorApp.xcworkspace +Configuration: Debug +Target: iPhone 17 Pro +๐Ÿงช Finding available tests... +Resolved to 21 test(s): +- CalculatorAppTests/CalculatorAppTests/testAppLaunch +- CalculatorAppTests/CalculatorAppTests/testCalculationPerformance +- CalculatorAppTests/CalculatorAppTests/testCalculatorOperationsEnum +- CalculatorAppTests/CalculatorAppTests/testCalculatorServiceBasicOperation +- CalculatorAppTests/CalculatorAppTests/testCalculatorServiceChainedOperations +... and 16 more +๐Ÿงช Running tests (0, 0 failures) +๐Ÿงช Running tests (1, 0 failures) +๐Ÿงช Running tests (2, 0 failures) +๐Ÿงช Running tests (3, 0 failures) +๐Ÿงช Running tests (4, 0 failures) +๐Ÿงช Running tests (5, 0 failures) +๐Ÿงช Running tests (6, 0 failures) +๐Ÿงช Running tests (7, 0 failures)/Users/cameroncooke/.codex/worktrees/43f4/XcodeBuildMCP/example_projects/iOS_Calculator/CalculatorAppTests/CalculatorAppTests.swift:52: error: -[CalculatorAppTests.CalculatorAppTests testCalculatorServiceFailure] : XCTAssertEqual failed: ("0") is not equal to ("999") - This test should fail - display should be 0, not 999 +๐Ÿงช Running tests (8, 1 failure) +๐Ÿงช Running tests (9, 1 failure) +๐Ÿงช Running tests (10, 1 failure) +๐Ÿงช Running tests (11, 1 failure) +๐Ÿงช Running tests (12, 1 failure) +๐Ÿงช Running tests (13, 1 failure) +๐Ÿงช Running tests (14, 1 failure) +๐Ÿงช Running tests (15, 1 failure) +๐Ÿงช Running tests (16, 1 failure) +๐Ÿงช Running tests (17, 1 failure) +๐Ÿงช Running tests (18, 1 failure) +๐Ÿงช Running tests (19, 1 failure) +๐Ÿงช Running tests (20, 1 failure) +๐Ÿงช Running tests (21, 1 failure) +Failed Tests +CalculatorAppTests +โœ— testCalculatorServiceFailure (0.003s) +โ””โ”€ XCTAssertEqual failed: ("0") is not +equal to ("999") - This test should fail +- display should be 0, not 999 +(CalculatorAppTests.swift:52) +Test Summary +โ•ญโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎ +โ”‚ Total: 21 โ”‚ +โ”‚ Passed: 20 โ”‚ +โ”‚ Failed: 1 โ”‚ +โ”‚ Skipped: 0 โ”‚ +โ”‚ Duration: 27.96s โ”‚ +โ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏ +โœ— 1 test(s) failed. \ No newline at end of file diff --git a/benchmarks/simulator-test/2026-03-17T14-18-19-390Z/flowdeck-run-2.stderr.txt b/benchmarks/simulator-test/2026-03-17T14-18-19-390Z/flowdeck-run-2.stderr.txt new file mode 100644 index 00000000..e69de29b diff --git a/benchmarks/simulator-test/2026-03-17T14-18-19-390Z/flowdeck-run-2.stdout.txt b/benchmarks/simulator-test/2026-03-17T14-18-19-390Z/flowdeck-run-2.stdout.txt new file mode 100644 index 00000000..6b6e2197 --- /dev/null +++ b/benchmarks/simulator-test/2026-03-17T14-18-19-390Z/flowdeck-run-2.stdout.txt @@ -0,0 +1,51 @@ +^DUsing scheme test configuration: Debug +๐Ÿงช Test: CalculatorApp +Workspace: /Users/cameroncooke/.codex/worktrees/43f4/XcodeBuildMCP/example_projects/iOS_Calculator/CalculatorApp.xcworkspace +Configuration: Debug +Target: iPhone 17 Pro +๐Ÿงช Finding available tests... +Resolved to 21 test(s): +- CalculatorAppTests/CalculatorAppTests/testAppLaunch +- CalculatorAppTests/CalculatorAppTests/testCalculationPerformance +- CalculatorAppTests/CalculatorAppTests/testCalculatorOperationsEnum +- CalculatorAppTests/CalculatorAppTests/testCalculatorServiceBasicOperation +- CalculatorAppTests/CalculatorAppTests/testCalculatorServiceChainedOperations +... and 16 more +๐Ÿงช Running tests (0, 0 failures) +๐Ÿงช Running tests (1, 0 failures) +๐Ÿงช Running tests (2, 0 failures) +๐Ÿงช Running tests (3, 0 failures) +๐Ÿงช Running tests (4, 0 failures) +๐Ÿงช Running tests (5, 0 failures) +๐Ÿงช Running tests (6, 0 failures) +๐Ÿงช Running tests (7, 0 failures)/Users/cameroncooke/.codex/worktrees/43f4/XcodeBuildMCP/example_projects/iOS_Calculator/CalculatorAppTests/CalculatorAppTests.swift:52: error: -[CalculatorAppTests.CalculatorAppTests testCalculatorServiceFailure] : XCTAssertEqual failed: ("0") is not equal to ("999") - This test should fail - display should be 0, not 999 +๐Ÿงช Running tests (8, 1 failure) +๐Ÿงช Running tests (9, 1 failure) +๐Ÿงช Running tests (10, 1 failure) +๐Ÿงช Running tests (11, 1 failure) +๐Ÿงช Running tests (12, 1 failure) +๐Ÿงช Running tests (13, 1 failure) +๐Ÿงช Running tests (14, 1 failure) +๐Ÿงช Running tests (15, 1 failure) +๐Ÿงช Running tests (16, 1 failure) +๐Ÿงช Running tests (17, 1 failure) +๐Ÿงช Running tests (18, 1 failure) +๐Ÿงช Running tests (19, 1 failure) +๐Ÿงช Running tests (20, 1 failure) +๐Ÿงช Running tests (21, 1 failure) +Failed Tests +CalculatorAppTests +โœ— testCalculatorServiceFailure (0.009s) +โ””โ”€ XCTAssertEqual failed: ("0") is not +equal to ("999") - This test should fail +- display should be 0, not 999 +(CalculatorAppTests.swift:52) +Test Summary +โ•ญโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎ +โ”‚ Total: 21 โ”‚ +โ”‚ Passed: 20 โ”‚ +โ”‚ Failed: 1 โ”‚ +โ”‚ Skipped: 0 โ”‚ +โ”‚ Duration: 20.31s โ”‚ +โ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏ +โœ— 1 test(s) failed. \ No newline at end of file diff --git a/benchmarks/simulator-test/2026-03-17T14-18-19-390Z/flowdeck-run-3.stderr.txt b/benchmarks/simulator-test/2026-03-17T14-18-19-390Z/flowdeck-run-3.stderr.txt new file mode 100644 index 00000000..e69de29b diff --git a/benchmarks/simulator-test/2026-03-17T14-18-19-390Z/flowdeck-run-3.stdout.txt b/benchmarks/simulator-test/2026-03-17T14-18-19-390Z/flowdeck-run-3.stdout.txt new file mode 100644 index 00000000..68c8fcf7 --- /dev/null +++ b/benchmarks/simulator-test/2026-03-17T14-18-19-390Z/flowdeck-run-3.stdout.txt @@ -0,0 +1,51 @@ +^DUsing scheme test configuration: Debug +๐Ÿงช Test: CalculatorApp +Workspace: /Users/cameroncooke/.codex/worktrees/43f4/XcodeBuildMCP/example_projects/iOS_Calculator/CalculatorApp.xcworkspace +Configuration: Debug +Target: iPhone 17 Pro +๐Ÿงช Finding available tests... +Resolved to 21 test(s): +- CalculatorAppTests/CalculatorAppTests/testAppLaunch +- CalculatorAppTests/CalculatorAppTests/testCalculationPerformance +- CalculatorAppTests/CalculatorAppTests/testCalculatorOperationsEnum +- CalculatorAppTests/CalculatorAppTests/testCalculatorServiceBasicOperation +- CalculatorAppTests/CalculatorAppTests/testCalculatorServiceChainedOperations +... and 16 more +๐Ÿงช Running tests (0, 0 failures) +๐Ÿงช Running tests (1, 0 failures) +๐Ÿงช Running tests (2, 0 failures) +๐Ÿงช Running tests (3, 0 failures) +๐Ÿงช Running tests (4, 0 failures) +๐Ÿงช Running tests (5, 0 failures) +๐Ÿงช Running tests (6, 0 failures) +๐Ÿงช Running tests (7, 0 failures)/Users/cameroncooke/.codex/worktrees/43f4/XcodeBuildMCP/example_projects/iOS_Calculator/CalculatorAppTests/CalculatorAppTests.swift:52: error: -[CalculatorAppTests.CalculatorAppTests testCalculatorServiceFailure] : XCTAssertEqual failed: ("0") is not equal to ("999") - This test should fail - display should be 0, not 999 +๐Ÿงช Running tests (8, 1 failure) +๐Ÿงช Running tests (9, 1 failure) +๐Ÿงช Running tests (10, 1 failure) +๐Ÿงช Running tests (11, 1 failure) +๐Ÿงช Running tests (12, 1 failure) +๐Ÿงช Running tests (13, 1 failure) +๐Ÿงช Running tests (14, 1 failure) +๐Ÿงช Running tests (15, 1 failure) +๐Ÿงช Running tests (16, 1 failure) +๐Ÿงช Running tests (17, 1 failure) +๐Ÿงช Running tests (18, 1 failure) +๐Ÿงช Running tests (19, 1 failure) +๐Ÿงช Running tests (20, 1 failure) +๐Ÿงช Running tests (21, 1 failure) +Failed Tests +CalculatorAppTests +โœ— testCalculatorServiceFailure (0.009s) +โ””โ”€ XCTAssertEqual failed: ("0") is not +equal to ("999") - This test should fail +- display should be 0, not 999 +(CalculatorAppTests.swift:52) +Test Summary +โ•ญโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎ +โ”‚ Total: 21 โ”‚ +โ”‚ Passed: 20 โ”‚ +โ”‚ Failed: 1 โ”‚ +โ”‚ Skipped: 0 โ”‚ +โ”‚ Duration: 16.44s โ”‚ +โ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏ +โœ— 1 test(s) failed. \ No newline at end of file diff --git a/benchmarks/simulator-test/2026-03-17T14-18-19-390Z/summary.json b/benchmarks/simulator-test/2026-03-17T14-18-19-390Z/summary.json new file mode 100644 index 00000000..24c3a209 --- /dev/null +++ b/benchmarks/simulator-test/2026-03-17T14-18-19-390Z/summary.json @@ -0,0 +1,74 @@ +{ + "generatedAt": "2026-03-17T14:20:36.285Z", + "mode": "warm", + "iterations": 3, + "workspacePath": "/Users/cameroncooke/.codex/worktrees/43f4/XcodeBuildMCP/example_projects/iOS_Calculator/CalculatorApp.xcworkspace", + "results": [ + { + "tool": "xcodebuildmcp", + "iteration": 1, + "exitCode": 1, + "wallClockMs": 29067.379917000002, + "firstStdoutMs": 2.148291999999998, + "firstMilestoneMs": 2152.612708, + "startupToFirstStreamedTestProgressMs": 13020.933500000001, + "stdoutPath": "/Users/cameroncooke/.codex/worktrees/43f4/XcodeBuildMCP/benchmarks/simulator-test/2026-03-17T14-18-19-390Z/xcodebuildmcp-run-1.stdout.txt", + "stderrPath": "/Users/cameroncooke/.codex/worktrees/43f4/XcodeBuildMCP/benchmarks/simulator-test/2026-03-17T14-18-19-390Z/xcodebuildmcp-run-1.stderr.txt" + }, + { + "tool": "flowdeck", + "iteration": 1, + "exitCode": 1, + "wallClockMs": 28296.29575, + "firstStdoutMs": 3.5727919999990263, + "firstMilestoneMs": 12480.404625, + "startupToFirstStreamedTestProgressMs": 12480.409000000003, + "stdoutPath": "/Users/cameroncooke/.codex/worktrees/43f4/XcodeBuildMCP/benchmarks/simulator-test/2026-03-17T14-18-19-390Z/flowdeck-run-1.stdout.txt", + "stderrPath": "/Users/cameroncooke/.codex/worktrees/43f4/XcodeBuildMCP/benchmarks/simulator-test/2026-03-17T14-18-19-390Z/flowdeck-run-1.stderr.txt" + }, + { + "tool": "xcodebuildmcp", + "iteration": 2, + "exitCode": 1, + "wallClockMs": 20358.631999999998, + "firstStdoutMs": 3.855666999996174, + "firstMilestoneMs": 1894.4525829999984, + "startupToFirstStreamedTestProgressMs": 6474.262499999997, + "stdoutPath": "/Users/cameroncooke/.codex/worktrees/43f4/XcodeBuildMCP/benchmarks/simulator-test/2026-03-17T14-18-19-390Z/xcodebuildmcp-run-2.stdout.txt", + "stderrPath": "/Users/cameroncooke/.codex/worktrees/43f4/XcodeBuildMCP/benchmarks/simulator-test/2026-03-17T14-18-19-390Z/xcodebuildmcp-run-2.stderr.txt" + }, + { + "tool": "flowdeck", + "iteration": 2, + "exitCode": 1, + "wallClockMs": 20567.050875, + "firstStdoutMs": 3.934166000006371, + "firstMilestoneMs": 5885.525833000007, + "startupToFirstStreamedTestProgressMs": 5885.5267500000045, + "stdoutPath": "/Users/cameroncooke/.codex/worktrees/43f4/XcodeBuildMCP/benchmarks/simulator-test/2026-03-17T14-18-19-390Z/flowdeck-run-2.stdout.txt", + "stderrPath": "/Users/cameroncooke/.codex/worktrees/43f4/XcodeBuildMCP/benchmarks/simulator-test/2026-03-17T14-18-19-390Z/flowdeck-run-2.stderr.txt" + }, + { + "tool": "xcodebuildmcp", + "iteration": 3, + "exitCode": 1, + "wallClockMs": 21910.729708, + "firstStdoutMs": 3.3832499999989523, + "firstMilestoneMs": 2140.4143329999933, + "startupToFirstStreamedTestProgressMs": 6239.000874999998, + "stdoutPath": "/Users/cameroncooke/.codex/worktrees/43f4/XcodeBuildMCP/benchmarks/simulator-test/2026-03-17T14-18-19-390Z/xcodebuildmcp-run-3.stdout.txt", + "stderrPath": "/Users/cameroncooke/.codex/worktrees/43f4/XcodeBuildMCP/benchmarks/simulator-test/2026-03-17T14-18-19-390Z/xcodebuildmcp-run-3.stderr.txt" + }, + { + "tool": "flowdeck", + "iteration": 3, + "exitCode": 1, + "wallClockMs": 16693.48666699999, + "firstStdoutMs": 3.411791999998968, + "firstMilestoneMs": 5152.938666999995, + "startupToFirstStreamedTestProgressMs": 5152.9394579999935, + "stdoutPath": "/Users/cameroncooke/.codex/worktrees/43f4/XcodeBuildMCP/benchmarks/simulator-test/2026-03-17T14-18-19-390Z/flowdeck-run-3.stdout.txt", + "stderrPath": "/Users/cameroncooke/.codex/worktrees/43f4/XcodeBuildMCP/benchmarks/simulator-test/2026-03-17T14-18-19-390Z/flowdeck-run-3.stderr.txt" + } + ] +} \ No newline at end of file diff --git a/benchmarks/simulator-test/2026-03-17T14-18-19-390Z/xcodebuildmcp-run-1.stderr.txt b/benchmarks/simulator-test/2026-03-17T14-18-19-390Z/xcodebuildmcp-run-1.stderr.txt new file mode 100644 index 00000000..e69de29b diff --git a/benchmarks/simulator-test/2026-03-17T14-18-19-390Z/xcodebuildmcp-run-1.stdout.txt b/benchmarks/simulator-test/2026-03-17T14-18-19-390Z/xcodebuildmcp-run-1.stdout.txt new file mode 100644 index 00000000..8a8d44cc --- /dev/null +++ b/benchmarks/simulator-test/2026-03-17T14-18-19-390Z/xcodebuildmcp-run-1.stdout.txt @@ -0,0 +1,669 @@ +^DUsing scheme test configuration: Debug +๐Ÿงช Test: CalculatorApp +Workspace: /Users/cameroncooke/.codex/worktrees/43f4/XcodeBuildMCP/example_projects/iOS_Calculator/CalculatorApp.xcworkspace +Configuration: Debug +Target: iPhone 17 Pro +๐Ÿงช Finding available tests... +Resolved to 21 test(s): +- CalculatorAppTests/CalculatorAppTests/testAppLaunch +- CalculatorAppTests/CalculatorAppTests/testCalculationPerformance +- CalculatorAppTests/CalculatorAppTests/testCalculatorOperationsEnum +- CalculatorAppTests/CalculatorAppTests/testCalculatorServiceBasicOperation +- CalculatorAppTests/CalculatorAppTests/testCalculatorServiceChainedOperations +... and 16 more +[?25lโ”‚ +๐Ÿ“ฆ +Resolvingpackages +๐Ÿ“ฆ +Resolvingpackages +๐Ÿ“ฆ +Resolvingpackages +๐Ÿ“ฆ +Resolvingpackages +๐Ÿ“ฆ +Resolvingpackages +๐Ÿ“ฆ +Resolvingpackages +๐Ÿ“ฆ +Resolvingpackages +๐Ÿ“ฆ +Resolvingpackages +๐Ÿ“ฆ +Resolvingpackages. +๐Ÿ“ฆ +Resolvingpackages. +๐Ÿ“ฆ +Resolvingpackages. +๐Ÿ“ฆ +Resolvingpackages. +๐Ÿ“ฆ +Resolvingpackages. +๐Ÿ“ฆ +Resolvingpackages. +๐Ÿ“ฆ +Resolvingpackages. +๐Ÿ“ฆ +Resolvingpackages. +๐Ÿ“ฆ +Resolvingpackages.. +๐Ÿ“ฆ +Resolvingpackages.. +๐Ÿ“ฆ +Resolvingpackages.. +๐Ÿ“ฆ +Resolvingpackages.. +๐Ÿ“ฆ +Resolvingpackages.. +๐Ÿ“ฆ +Resolvingpackages.. +๐Ÿ“ฆ +Resolvingpackages.. +๐Ÿ“ฆ +Resolvingpackages.. +๐Ÿ“ฆ +Resolvingpackages... +๐Ÿ“ฆ +Resolvingpackages... +๐Ÿ“ฆ +Resolvingpackages... +๐Ÿ“ฆ +Resolvingpackages... +๐Ÿ“ฆ +Resolvingpackages... +๐Ÿ“ฆ +Resolvingpackages... +๐Ÿ“ฆ +Resolvingpackages... +๐Ÿ“ฆ +Resolvingpackages... +๐Ÿ“ฆ +Resolvingpackages... +๐Ÿ“ฆ +Resolvingpackages +๐Ÿ“ฆ +Resolvingpackages +๐Ÿ“ฆ +Resolvingpackages +๐Ÿ“ฆ +Resolvingpackages +๐Ÿ“ฆ +Resolvingpackages +๐Ÿ“ฆ +Resolvingpackages +๐Ÿ“ฆ +Resolvingpackages +๐Ÿ“ฆ +Resolvingpackages +๐Ÿ“ฆ +Resolvingpackages. +๐Ÿ“ฆ +Resolvingpackages. +๐Ÿ“ฆ +Resolvingpackages. +๐Ÿ“ฆ +Resolvingpackages. +๐Ÿ“ฆ +Resolvingpackages. +๐Ÿ“ฆ +Resolvingpackages. +๐Ÿ“ฆ +Resolvingpackages. +๐Ÿ“ฆ +Resolvingpackages. +๐Ÿ“ฆ +Resolvingpackages.. +๐Ÿ“ฆ +Resolvingpackages.. +๐Ÿ“ฆ +Resolvingpackages.. +๐Ÿ“ฆ +Resolvingpackages.. +๐Ÿ“ฆ +Resolvingpackages.. +๐Ÿ› ๏ธ +Compiling.. +๐Ÿ› ๏ธ +Compiling.. +๐Ÿ› ๏ธ +Compiling.. +๐Ÿ› ๏ธ +Compiling... +๐Ÿ› ๏ธ +Compiling... +๐Ÿ› ๏ธ +Compiling... +๐Ÿ› ๏ธ +Compiling... +๐Ÿ› ๏ธ +Compiling... +๐Ÿ› ๏ธ +Compiling... +๐Ÿ› ๏ธ +Compiling... +๐Ÿ› ๏ธ +Compiling... +๐Ÿ› ๏ธ +Compiling... +๐Ÿ› ๏ธ +Compiling +๐Ÿ› ๏ธ +Compiling +๐Ÿ› ๏ธ +Compiling +๐Ÿ› ๏ธ +Compiling +๐Ÿ› ๏ธ +Compiling +๐Ÿ› ๏ธ +Compiling +๐Ÿ› ๏ธ +Compiling +๐Ÿ› ๏ธ +Compiling +๐Ÿ› ๏ธ +Compiling. +๐Ÿ› ๏ธ +Compiling. +๐Ÿ› ๏ธ +Compiling. +๐Ÿ› ๏ธ +Compiling. +๐Ÿ› ๏ธ +Compiling. +๐Ÿ› ๏ธ +Compiling. +๐Ÿ› ๏ธ +Compiling. +๐Ÿ› ๏ธ +Compiling. +๐Ÿ› ๏ธ +Compiling.. +๐Ÿ› ๏ธ +Compiling.. +๐Ÿ› ๏ธ +Compiling.. +๐Ÿ› ๏ธ +Compiling.. +๐Ÿ› ๏ธ +Compiling.. +๐Ÿ› ๏ธ +Compiling.. +๐Ÿ› ๏ธ +Compiling.. +๐Ÿ› ๏ธ +Compiling.. +๐Ÿ› ๏ธ +Compiling... +๐Ÿ› ๏ธ +Compiling... +๐Ÿ› ๏ธ +Compiling.. +.[?25h[?25lโ”‚ +๐Ÿ“ฆ +Resolvingpackages +๐Ÿ“ฆ +Resolvingpackages +๐Ÿ“ฆ +Resolvingpackages +๐Ÿ“ฆ +Resolvingpackages +๐Ÿ“ฆ +Resolvingpackages +๐Ÿ“ฆ +Resolvingpackages +๐Ÿ“ฆ +Resolvingpackages +๐Ÿ“ฆ +Resolvingpackages +๐Ÿ“ฆ +Resolvingpackages. +๐Ÿ“ฆ +Resolvingpackages. +๐Ÿ“ฆ +Resolvingpackages. +๐Ÿ“ฆ +Resolvingpackages. +๐Ÿ“ฆ +Resolvingpackages. +๐Ÿ“ฆ +Resolvingpackages. +๐Ÿ“ฆ +Resolvingpackages. +๐Ÿ“ฆ +Resolvingpackages. +๐Ÿ“ฆ +Resolvingpackages.. +๐Ÿ“ฆ +Resolvingpackages.. +๐Ÿ“ฆ +Resolvingpackages.. +๐Ÿ“ฆ +Resolvingpackages.. +๐Ÿ“ฆ +Resolvingpackages.. +๐Ÿ“ฆ +Resolvingpackages.. +๐Ÿ“ฆ +Resolvingpackages.. +๐Ÿ“ฆ +Resolvingpackages.. +๐Ÿ“ฆ +Resolvingpackages... +๐Ÿ“ฆ +Resolvingpackages... +๐Ÿ“ฆ +Resolvingpackages... +๐Ÿ“ฆ +Resolvingpackages... +๐Ÿ“ฆ +Resolvingpackages... +๐Ÿ“ฆ +Resolvingpackages... +๐Ÿ“ฆ +Resolvingpackages... +๐Ÿ“ฆ +Resolvingpackages... +๐Ÿ“ฆ +Resolvingpackages... +๐Ÿงช +Runningtests(1,0failures) +๐Ÿงช +Runningtests(1,0failures) +๐Ÿงช +Runningtests(1,0failures) +๐Ÿงช +Runningtests(1,0failures) +๐Ÿงช +Runningtests(1,0failures) +๐Ÿงช +Runningtests(1,0failures +)[?25h/Users/cameroncooke/.codex/worktrees/43f4/XcodeBuildMCP/example_projects/iOS_Calculator/CalculatorAppTests/CalculatorAppTests.swift:52: error: XCTAssertEqual failed: ("0") is not equal to ("999") - This test should fail - display should be 0, not 999 +[?25lโ”‚ +๐Ÿงช +Runningtests(13,1failure) +๐Ÿงช +Runningtests(13,1failure) +๐Ÿงช +Runningtests(13,1failure) +๐Ÿงช +Runningtests(21,1failure) +๐Ÿงช +Runningtests(21,1failure) +๐Ÿงช +Runningtests(21,1failure) +๐Ÿงช +Runningtests(21,1failure) +๐Ÿงช +Runningtests(21,1failure) +๐Ÿงช +Runningtests(21,1failure). +๐Ÿงช +Runningtests(21,1failure). +๐Ÿงช +Runningtests(21,1failure). +๐Ÿงช +Runningtests(21,1failure). +๐Ÿงช +Runningtests(21,1failure). +๐Ÿงช +Runningtests(21,1failure). +๐Ÿงช +Runningtests(21,1failure). +๐Ÿงช +Runningtests(21,1failure). +๐Ÿงช +Runningtests(21,1failure).. +๐Ÿงช +Runningtests(21,1failure).. +๐Ÿงช +Runningtests(21,1failure).. +๐Ÿงช +Runningtests(21,1failure).. +๐Ÿงช +Runningtests(21,1failure).. +๐Ÿงช +Runningtests(21,1failure).. +๐Ÿงช +Runningtests(21,1failure).. +๐Ÿงช +Runningtests(21,1failure).. +๐Ÿงช +Runningtests(21,1failure)... +๐Ÿงช +Runningtests(21,1failure)... +๐Ÿงช +Runningtests(21,1failure)... +๐Ÿงช +Runningtests(21,1failure)... +๐Ÿงช +Runningtests(21,1failure)... +๐Ÿงช +Runningtests(21,1failure)... +๐Ÿงช +Runningtests(21,1failure)... +๐Ÿงช +Runningtests(21,1failure)... +๐Ÿงช +Runningtests(21,1failure)... +๐Ÿงช +Runningtests(21,1failure) +๐Ÿงช +Runningtests(21,1failure) +๐Ÿงช +Runningtests(21,1failure) +๐Ÿงช +Runningtests(21,1failure) +๐Ÿงช +Runningtests(21,1failure) +๐Ÿงช +Runningtests(21,1failure) +๐Ÿงช +Runningtests(21,1failure) +๐Ÿงช +Runningtests(21,1failure) +๐Ÿงช +Runningtests(21,1failure). +๐Ÿงช +Runningtests(21,1failure). +๐Ÿงช +Runningtests(21,1failure). +๐Ÿงช +Runningtests(21,1failure). +๐Ÿงช +Runningtests(21,1failure). +๐Ÿงช +Runningtests(21,1failure). +๐Ÿงช +Runningtests(21,1failure). +๐Ÿงช +Runningtests(21,1failure). +๐Ÿงช +Runningtests(21,1failure).. +๐Ÿงช +Runningtests(21,1failure).. +๐Ÿงช +Runningtests(21,1failure).. +๐Ÿงช +Runningtests(21,1failure).. +๐Ÿงช +Runningtests(21,1failure).. +๐Ÿงช +Runningtests(21,1failure).. +๐Ÿงช +Runningtests(21,1failure).. +๐Ÿงช +Runningtests(21,1failure).. +๐Ÿงช +Runningtests(21,1failure)... +๐Ÿงช +Runningtests(21,1failure)... +๐Ÿงช +Runningtests(21,1failure)... +๐Ÿงช +Runningtests(21,1failure)... +๐Ÿงช +Runningtests(21,1failure)... +๐Ÿงช +Runningtests(21,1failure)... +๐Ÿงช +Runningtests(21,1failure)... +๐Ÿงช +Runningtests(21,1failure)... +๐Ÿงช +Runningtests(21,1failure)... +๐Ÿงช +Runningtests(21,1failure) +๐Ÿงช +Runningtests(21,1failure) +๐Ÿงช +Runningtests(21,1failure) +๐Ÿงช +Runningtests(21,1failure) +๐Ÿงช +Runningtests(21,1failure) +๐Ÿงช +Runningtests(21,1failure) +๐Ÿงช +Runningtests(21,1failure) +๐Ÿงช +Runningtests(21,1failure) +๐Ÿงช +Runningtests(21,1failure). +๐Ÿงช +Runningtests(21,1failure). +๐Ÿงช +Runningtests(21,1failure). +๐Ÿงช +Runningtests(21,1failure). +๐Ÿงช +Runningtests(21,1failure). +๐Ÿงช +Runningtests(21,1failure). +๐Ÿงช +Runningtests(21,1failure). +๐Ÿงช +Runningtests(21,1failure). +๐Ÿงช +Runningtests(21,1failure).. +๐Ÿงช +Runningtests(21,1failure).. +๐Ÿงช +Runningtests(21,1failure).. +๐Ÿงช +Runningtests(21,1failure).. +๐Ÿงช +Runningtests(21,1failure).. +๐Ÿงช +Runningtests(21,1failure).. +๐Ÿงช +Runningtests(21,1failure).. +๐Ÿงช +Runningtests(21,1failure).. +๐Ÿงช +Runningtests(21,1failure)... +๐Ÿงช +Runningtests(21,1failure)... +๐Ÿงช +Runningtests(21,1failure)... +๐Ÿงช +Runningtests(21,1failure)... +๐Ÿงช +Runningtests(21,1failure)... +๐Ÿงช +Runningtests(21,1failure)... +๐Ÿงช +Runningtests(21,1failure)... +๐Ÿงช +Runningtests(21,1failure)... +๐Ÿงช +Runningtests(21,1failure)... +๐Ÿงช +Runningtests(21,1failure) +๐Ÿงช +Runningtests(21,1failure) +๐Ÿงช +Runningtests(21,1failure) +๐Ÿงช +Runningtests(21,1failure) +๐Ÿงช +Runningtests(21,1failure) +๐Ÿงช +Runningtests(21,1failure) +๐Ÿงช +Runningtests(21,1failure) +๐Ÿงช +Runningtests(21,1failure) +๐Ÿงช +Runningtests(21,1failure). +๐Ÿงช +Runningtests(21,1failure). +๐Ÿงช +Runningtests(21,1failure). +๐Ÿงช +Runningtests(21,1failure). +๐Ÿงช +Runningtests(21,1failure). +๐Ÿงช +Runningtests(21,1failure). +๐Ÿงช +Runningtests(21,1failure). +๐Ÿงช +Runningtests(21,1failure). +๐Ÿงช +Runningtests(21,1failure).. +๐Ÿงช +Runningtests(21,1failure).. +๐Ÿงช +Runningtests(21,1failure).. +๐Ÿงช +Runningtests(21,1failure).. +๐Ÿงช +Runningtests(21,1failure).. +๐Ÿงช +Runningtests(21,1failure).. +๐Ÿงช +Runningtests(21,1failure).. +๐Ÿงช +Runningtests(21,1failure).. +๐Ÿงช +Runningtests(21,1failure)... +๐Ÿงช +Runningtests(21,1failure)... +๐Ÿงช +Runningtests(21,1failure)... +๐Ÿงช +Runningtests(21,1failure)... +๐Ÿงช +Runningtests(21,1failure)... +๐Ÿงช +Runningtests(21,1failure)... +๐Ÿงช +Runningtests(21,1failure)... +๐Ÿงช +Runningtests(21,1failure)... +๐Ÿงช +Runningtests(21,1failure)... +๐Ÿงช +Runningtests(21,1failure) +๐Ÿงช +Runningtests(21,1failure) +๐Ÿงช +Runningtests(21,1failure) +๐Ÿงช +Runningtests(21,1failure) +๐Ÿงช +Runningtests(21,1failure) +๐Ÿงช +Runningtests(21,1failure) +๐Ÿงช +Runningtests(21,1failure) +๐Ÿงช +Runningtests(21,1failure) +๐Ÿงช +Runningtests(21,1failure). +๐Ÿงช +Runningtests(21,1failure). +๐Ÿงช +Runningtests(21,1failure). +๐Ÿงช +Runningtests(21,1failure). +๐Ÿงช +Runningtests(21,1failure). +๐Ÿงช +Runningtests(21,1failure). +๐Ÿงช +Runningtests(21,1failure). +๐Ÿงช +Runningtests(21,1failure). +๐Ÿงช +Runningtests(21,1failure).. +๐Ÿงช +Runningtests(21,1failure).. +๐Ÿงช +Runningtests(21,1failure).. +๐Ÿงช +Runningtests(21,1failure).. +๐Ÿงช +Runningtests(21,1failure).. +๐Ÿงช +Runningtests(21,1failure).. +๐Ÿงช +Runningtests(21,1failure).. +๐Ÿงช +Runningtests(21,1failure).. +๐Ÿงช +Runningtests(21,1failure)... +๐Ÿงช +Runningtests(21,1failure)... +๐Ÿงช +Runningtests(21,1failure)... +๐Ÿงช +Runningtests(21,1failure)... +๐Ÿงช +Runningtests(21,1failure)... +๐Ÿงช +Runningtests(21,1failure)... +๐Ÿงช +Runningtests(21,1failure)... +๐Ÿงช +Runningtests(21,1failure)... +๐Ÿงช +Runningtests(21,1failure)... +๐Ÿงช +Runningtests(21,1failure) +๐Ÿงช +Runningtests(21,1failure) +๐Ÿงช +Runningtests(21,1failure) +๐Ÿงช +Runningtests(21,1failure) +๐Ÿงช +Runningtests(21,1failure) +๐Ÿงช +Runningtests(21,1failure) +๐Ÿงช +Runningtests(21,1failure) +๐Ÿงช +Runningtests(21,1failure) +๐Ÿงช +Runningtests(21,1failure). +๐Ÿงช +Runningtests(21,1failure). +๐Ÿงช +Runningtests(21,1failure). +๐Ÿงช +Runningtests(21,1failure). +๐Ÿงช +Runningtests(21,1failure). +๐Ÿงช +Runningtests(21,1failure). +๐Ÿงช +Runningtests(21,1failure). +๐Ÿงช +Runningtests(21,1failure). +๐Ÿงช +Runningtests(21,1failure).. +๐Ÿงช +Runningtests(21,1failure).. +๐Ÿงช +Runningtests(21,1failure).. +๐Ÿงช +Runningtests(21,1failure).. +๐Ÿงช +Runningtests(21,1failure).. +๐Ÿงช +Runningtests(21,1failure).. +๐Ÿงช +Runningtests(21,1failure).. +๐Ÿงช +Runningtests(21,1failure).. +๐Ÿงช +Runningtests(21,1failure)... +๐Ÿงช +Runningtests(21,1failure).. +.[?25hFailed Tests +CalculatorAppTests +โœ— testCalculatorServiceFailure (0.004 seconds) +โ””โ”€ XCTAssertEqual failed: ("0") is not equal to ("999") - This test should fail - display should be 0, not 999 (/Users/cameroncooke/.codex/worktrees/43f4/XcodeBuildMCP/example_projects/iOS_Calculator/CalculatorAppTests/CalculatorAppTests.swift:52) +Test Summary +Total: 21 Passed: 20 Failed: 1 Skipped: 0 Duration: 27.98s \ No newline at end of file diff --git a/benchmarks/simulator-test/2026-03-17T14-18-19-390Z/xcodebuildmcp-run-2.stderr.txt b/benchmarks/simulator-test/2026-03-17T14-18-19-390Z/xcodebuildmcp-run-2.stderr.txt new file mode 100644 index 00000000..e69de29b diff --git a/benchmarks/simulator-test/2026-03-17T14-18-19-390Z/xcodebuildmcp-run-2.stdout.txt b/benchmarks/simulator-test/2026-03-17T14-18-19-390Z/xcodebuildmcp-run-2.stdout.txt new file mode 100644 index 00000000..bdc1537c --- /dev/null +++ b/benchmarks/simulator-test/2026-03-17T14-18-19-390Z/xcodebuildmcp-run-2.stdout.txt @@ -0,0 +1,461 @@ +^DUsing scheme test configuration: Debug +๐Ÿงช Test: CalculatorApp +Workspace: /Users/cameroncooke/.codex/worktrees/43f4/XcodeBuildMCP/example_projects/iOS_Calculator/CalculatorApp.xcworkspace +Configuration: Debug +Target: iPhone 17 Pro +๐Ÿงช Finding available tests... +Resolved to 21 test(s): +- CalculatorAppTests/CalculatorAppTests/testAppLaunch +- CalculatorAppTests/CalculatorAppTests/testCalculationPerformance +- CalculatorAppTests/CalculatorAppTests/testCalculatorOperationsEnum +- CalculatorAppTests/CalculatorAppTests/testCalculatorServiceBasicOperation +- CalculatorAppTests/CalculatorAppTests/testCalculatorServiceChainedOperations +... and 16 more +[?25lโ”‚ +๐Ÿ“ฆ +Resolvingpackages +๐Ÿ“ฆ +Resolvingpackages +๐Ÿ“ฆ +Resolvingpackages +๐Ÿ“ฆ +Resolvingpackages +๐Ÿ“ฆ +Resolvingpackages +๐Ÿ“ฆ +Resolvingpackages +๐Ÿ“ฆ +Resolvingpackages +๐Ÿ“ฆ +Resolvingpackages +๐Ÿ“ฆ +Resolvingpackages. +๐Ÿ“ฆ +Resolvingpackages. +๐Ÿ“ฆ +Resolvingpackages. +๐Ÿ“ฆ +Resolvingpackages. +๐Ÿ“ฆ +Resolvingpackages. +๐Ÿ“ฆ +Resolvingpackages. +๐Ÿ› ๏ธ +Compiling. +๐Ÿ› ๏ธ +Compiling +.[?25h[?25lโ”‚ +๐Ÿ“ฆ +Resolvingpackages +๐Ÿ“ฆ +Resolvingpackages +๐Ÿ“ฆ +Resolvingpackages +๐Ÿ“ฆ +Resolvingpackages +๐Ÿ“ฆ +Resolvingpackages +๐Ÿ“ฆ +Resolvingpackages +๐Ÿ“ฆ +Resolvingpackages +๐Ÿ“ฆ +Resolvingpackages +๐Ÿ“ฆ +Resolvingpackages. +๐Ÿ“ฆ +Resolvingpackages. +๐Ÿ“ฆ +Resolvingpackages. +๐Ÿ“ฆ +Resolvingpackages. +๐Ÿ“ฆ +Resolvingpackages. +๐Ÿ“ฆ +Resolvingpackages. +๐Ÿ“ฆ +Resolvingpackages. +๐Ÿ“ฆ +Resolvingpackages. +๐Ÿ“ฆ +Resolvingpackages.. +๐Ÿ“ฆ +Resolvingpackages.. +๐Ÿ“ฆ +Resolvingpackages.. +๐Ÿ“ฆ +Resolvingpackages.. +๐Ÿ“ฆ +Resolvingpackages.. +๐Ÿ“ฆ +Resolvingpackages.. +๐Ÿ“ฆ +Resolvingpackages.. +๐Ÿ“ฆ +Resolvingpackages.. +๐Ÿ“ฆ +Resolvingpackages... +๐Ÿ“ฆ +Resolvingpackages... +๐Ÿ“ฆ +Resolvingpackages... +๐Ÿ“ฆ +Resolvingpackages... +๐Ÿ“ฆ +Resolvingpackages... +๐Ÿ“ฆ +Resolvingpackages... +๐Ÿ“ฆ +Resolvingpackages... +๐Ÿ“ฆ +Resolvingpackages... +๐Ÿ“ฆ +Resolvingpackages... +๐Ÿงช +Runningtests(1,0failures) +๐Ÿงช +Runningtests(1,0failures) +๐Ÿงช +Runningtests(1,0failures) +๐Ÿงช +Runningtests(1,0failures) +๐Ÿงช +Runningtests(1,0failures) +๐Ÿงช +Runningtests(1,0failures +)[?25h/Users/cameroncooke/.codex/worktrees/43f4/XcodeBuildMCP/example_projects/iOS_Calculator/CalculatorAppTests/CalculatorAppTests.swift:52: error: XCTAssertEqual failed: ("0") is not equal to ("999") - This test should fail - display should be 0, not 999 +[?25lโ”‚ +๐Ÿงช +Runningtests(13,1failure) +๐Ÿงช +Runningtests(13,1failure) +๐Ÿงช +Runningtests(13,1failure) +๐Ÿงช +Runningtests(21,1failure) +๐Ÿงช +Runningtests(21,1failure) +๐Ÿงช +Runningtests(21,1failure) +๐Ÿงช +Runningtests(21,1failure) +๐Ÿงช +Runningtests(21,1failure) +๐Ÿงช +Runningtests(21,1failure). +๐Ÿงช +Runningtests(21,1failure). +๐Ÿงช +Runningtests(21,1failure). +๐Ÿงช +Runningtests(21,1failure). +๐Ÿงช +Runningtests(21,1failure). +๐Ÿงช +Runningtests(21,1failure). +๐Ÿงช +Runningtests(21,1failure). +๐Ÿงช +Runningtests(21,1failure). +๐Ÿงช +Runningtests(21,1failure).. +๐Ÿงช +Runningtests(21,1failure).. +๐Ÿงช +Runningtests(21,1failure).. +๐Ÿงช +Runningtests(21,1failure).. +๐Ÿงช +Runningtests(21,1failure).. +๐Ÿงช +Runningtests(21,1failure).. +๐Ÿงช +Runningtests(21,1failure).. +๐Ÿงช +Runningtests(21,1failure).. +๐Ÿงช +Runningtests(21,1failure)... +๐Ÿงช +Runningtests(21,1failure)... +๐Ÿงช +Runningtests(21,1failure)... +๐Ÿงช +Runningtests(21,1failure)... +๐Ÿงช +Runningtests(21,1failure)... +๐Ÿงช +Runningtests(21,1failure)... +๐Ÿงช +Runningtests(21,1failure)... +๐Ÿงช +Runningtests(21,1failure)... +๐Ÿงช +Runningtests(21,1failure)... +๐Ÿงช +Runningtests(21,1failure) +๐Ÿงช +Runningtests(21,1failure) +๐Ÿงช +Runningtests(21,1failure) +๐Ÿงช +Runningtests(21,1failure) +๐Ÿงช +Runningtests(21,1failure) +๐Ÿงช +Runningtests(21,1failure) +๐Ÿงช +Runningtests(21,1failure) +๐Ÿงช +Runningtests(21,1failure) +๐Ÿงช +Runningtests(21,1failure). +๐Ÿงช +Runningtests(21,1failure). +๐Ÿงช +Runningtests(21,1failure). +๐Ÿงช +Runningtests(21,1failure). +๐Ÿงช +Runningtests(21,1failure). +๐Ÿงช +Runningtests(21,1failure). +๐Ÿงช +Runningtests(21,1failure). +๐Ÿงช +Runningtests(21,1failure). +๐Ÿงช +Runningtests(21,1failure).. +๐Ÿงช +Runningtests(21,1failure).. +๐Ÿงช +Runningtests(21,1failure).. +๐Ÿงช +Runningtests(21,1failure).. +๐Ÿงช +Runningtests(21,1failure).. +๐Ÿงช +Runningtests(21,1failure).. +๐Ÿงช +Runningtests(21,1failure).. +๐Ÿงช +Runningtests(21,1failure).. +๐Ÿงช +Runningtests(21,1failure)... +๐Ÿงช +Runningtests(21,1failure)... +๐Ÿงช +Runningtests(21,1failure)... +๐Ÿงช +Runningtests(21,1failure)... +๐Ÿงช +Runningtests(21,1failure)... +๐Ÿงช +Runningtests(21,1failure)... +๐Ÿงช +Runningtests(21,1failure)... +๐Ÿงช +Runningtests(21,1failure)... +๐Ÿงช +Runningtests(21,1failure)... +๐Ÿงช +Runningtests(21,1failure) +๐Ÿงช +Runningtests(21,1failure) +๐Ÿงช +Runningtests(21,1failure) +๐Ÿงช +Runningtests(21,1failure) +๐Ÿงช +Runningtests(21,1failure) +๐Ÿงช +Runningtests(21,1failure) +๐Ÿงช +Runningtests(21,1failure) +๐Ÿงช +Runningtests(21,1failure) +๐Ÿงช +Runningtests(21,1failure). +๐Ÿงช +Runningtests(21,1failure). +๐Ÿงช +Runningtests(21,1failure). +๐Ÿงช +Runningtests(21,1failure). +๐Ÿงช +Runningtests(21,1failure). +๐Ÿงช +Runningtests(21,1failure). +๐Ÿงช +Runningtests(21,1failure). +๐Ÿงช +Runningtests(21,1failure). +๐Ÿงช +Runningtests(21,1failure).. +๐Ÿงช +Runningtests(21,1failure).. +๐Ÿงช +Runningtests(21,1failure).. +๐Ÿงช +Runningtests(21,1failure).. +๐Ÿงช +Runningtests(21,1failure).. +๐Ÿงช +Runningtests(21,1failure).. +๐Ÿงช +Runningtests(21,1failure).. +๐Ÿงช +Runningtests(21,1failure).. +๐Ÿงช +Runningtests(21,1failure)... +๐Ÿงช +Runningtests(21,1failure)... +๐Ÿงช +Runningtests(21,1failure)... +๐Ÿงช +Runningtests(21,1failure)... +๐Ÿงช +Runningtests(21,1failure)... +๐Ÿงช +Runningtests(21,1failure)... +๐Ÿงช +Runningtests(21,1failure)... +๐Ÿงช +Runningtests(21,1failure)... +๐Ÿงช +Runningtests(21,1failure)... +๐Ÿงช +Runningtests(21,1failure) +๐Ÿงช +Runningtests(21,1failure) +๐Ÿงช +Runningtests(21,1failure) +๐Ÿงช +Runningtests(21,1failure) +๐Ÿงช +Runningtests(21,1failure) +๐Ÿงช +Runningtests(21,1failure) +๐Ÿงช +Runningtests(21,1failure) +๐Ÿงช +Runningtests(21,1failure) +๐Ÿงช +Runningtests(21,1failure). +๐Ÿงช +Runningtests(21,1failure). +๐Ÿงช +Runningtests(21,1failure). +๐Ÿงช +Runningtests(21,1failure). +๐Ÿงช +Runningtests(21,1failure). +๐Ÿงช +Runningtests(21,1failure). +๐Ÿงช +Runningtests(21,1failure). +๐Ÿงช +Runningtests(21,1failure). +๐Ÿงช +Runningtests(21,1failure).. +๐Ÿงช +Runningtests(21,1failure).. +๐Ÿงช +Runningtests(21,1failure).. +๐Ÿงช +Runningtests(21,1failure).. +๐Ÿงช +Runningtests(21,1failure).. +๐Ÿงช +Runningtests(21,1failure).. +๐Ÿงช +Runningtests(21,1failure).. +๐Ÿงช +Runningtests(21,1failure).. +๐Ÿงช +Runningtests(21,1failure)... +๐Ÿงช +Runningtests(21,1failure)... +๐Ÿงช +Runningtests(21,1failure)... +๐Ÿงช +Runningtests(21,1failure)... +๐Ÿงช +Runningtests(21,1failure)... +๐Ÿงช +Runningtests(21,1failure)... +๐Ÿงช +Runningtests(21,1failure)... +๐Ÿงช +Runningtests(21,1failure)... +๐Ÿงช +Runningtests(21,1failure)... +๐Ÿงช +Runningtests(21,1failure) +๐Ÿงช +Runningtests(21,1failure) +๐Ÿงช +Runningtests(21,1failure) +๐Ÿงช +Runningtests(21,1failure) +๐Ÿงช +Runningtests(21,1failure) +๐Ÿงช +Runningtests(21,1failure) +๐Ÿงช +Runningtests(21,1failure) +๐Ÿงช +Runningtests(21,1failure) +๐Ÿงช +Runningtests(21,1failure). +๐Ÿงช +Runningtests(21,1failure). +๐Ÿงช +Runningtests(21,1failure). +๐Ÿงช +Runningtests(21,1failure). +๐Ÿงช +Runningtests(21,1failure). +๐Ÿงช +Runningtests(21,1failure). +๐Ÿงช +Runningtests(21,1failure). +๐Ÿงช +Runningtests(21,1failure). +๐Ÿงช +Runningtests(21,1failure).. +๐Ÿงช +Runningtests(21,1failure).. +๐Ÿงช +Runningtests(21,1failure).. +๐Ÿงช +Runningtests(21,1failure).. +๐Ÿงช +Runningtests(21,1failure).. +๐Ÿงช +Runningtests(21,1failure).. +๐Ÿงช +Runningtests(21,1failure).. +๐Ÿงช +Runningtests(21,1failure).. +๐Ÿงช +Runningtests(21,1failure)... +๐Ÿงช +Runningtests(21,1failure)... +๐Ÿงช +Runningtests(21,1failure)... +๐Ÿงช +Runningtests(21,1failure)... +๐Ÿงช +Runningtests(21,1failure)... +๐Ÿงช +Runningtests(21,1failure)... +๐Ÿงช +Runningtests(21,1failure)... +๐Ÿงช +Runningtests(21,1failure).. +.[?25hFailed Tests +CalculatorAppTests +โœ— testCalculatorServiceFailure (0.004 seconds) +โ””โ”€ XCTAssertEqual failed: ("0") is not equal to ("999") - This test should fail - display should be 0, not 999 (/Users/cameroncooke/.codex/worktrees/43f4/XcodeBuildMCP/example_projects/iOS_Calculator/CalculatorAppTests/CalculatorAppTests.swift:52) +Test Summary +Total: 21 Passed: 20 Failed: 1 Skipped: 0 Duration: 19.26s \ No newline at end of file diff --git a/benchmarks/simulator-test/2026-03-17T14-18-19-390Z/xcodebuildmcp-run-3.stderr.txt b/benchmarks/simulator-test/2026-03-17T14-18-19-390Z/xcodebuildmcp-run-3.stderr.txt new file mode 100644 index 00000000..e69de29b diff --git a/benchmarks/simulator-test/2026-03-17T14-18-19-390Z/xcodebuildmcp-run-3.stdout.txt b/benchmarks/simulator-test/2026-03-17T14-18-19-390Z/xcodebuildmcp-run-3.stdout.txt new file mode 100644 index 00000000..3f4d5b30 --- /dev/null +++ b/benchmarks/simulator-test/2026-03-17T14-18-19-390Z/xcodebuildmcp-run-3.stdout.txt @@ -0,0 +1,495 @@ +^DUsing scheme test configuration: Debug +๐Ÿงช Test: CalculatorApp +Workspace: /Users/cameroncooke/.codex/worktrees/43f4/XcodeBuildMCP/example_projects/iOS_Calculator/CalculatorApp.xcworkspace +Configuration: Debug +Target: iPhone 17 Pro +๐Ÿงช Finding available tests... +Resolved to 21 test(s): +- CalculatorAppTests/CalculatorAppTests/testAppLaunch +- CalculatorAppTests/CalculatorAppTests/testCalculationPerformance +- CalculatorAppTests/CalculatorAppTests/testCalculatorOperationsEnum +- CalculatorAppTests/CalculatorAppTests/testCalculatorServiceBasicOperation +- CalculatorAppTests/CalculatorAppTests/testCalculatorServiceChainedOperations +... and 16 more +[?25lโ”‚ +๐Ÿ“ฆ +Resolvingpackages +๐Ÿ“ฆ +Resolvingpackages +๐Ÿ“ฆ +Resolvingpackages +๐Ÿ“ฆ +Resolvingpackages +๐Ÿ“ฆ +Resolvingpackages +๐Ÿ“ฆ +Resolvingpackages +๐Ÿ“ฆ +Resolvingpackages +๐Ÿ“ฆ +Resolvingpackages +๐Ÿ“ฆ +Resolvingpackages. +๐Ÿ› ๏ธ +Compiling. +๐Ÿ› ๏ธ +Compiling +.[?25h[?25lโ”‚ +๐Ÿ“ฆ +Resolvingpackages +๐Ÿ“ฆ +Resolvingpackages +๐Ÿ“ฆ +Resolvingpackages +๐Ÿ“ฆ +Resolvingpackages +๐Ÿ“ฆ +Resolvingpackages +๐Ÿ“ฆ +Resolvingpackages +๐Ÿ“ฆ +Resolvingpackages +๐Ÿ“ฆ +Resolvingpackages +๐Ÿ“ฆ +Resolvingpackages. +๐Ÿ“ฆ +Resolvingpackages. +๐Ÿ“ฆ +Resolvingpackages. +๐Ÿ“ฆ +Resolvingpackages. +๐Ÿ“ฆ +Resolvingpackages. +๐Ÿ“ฆ +Resolvingpackages. +๐Ÿ“ฆ +Resolvingpackages. +๐Ÿ“ฆ +Resolvingpackages. +๐Ÿ“ฆ +Resolvingpackages.. +๐Ÿ“ฆ +Resolvingpackages.. +๐Ÿ“ฆ +Resolvingpackages.. +๐Ÿ“ฆ +Resolvingpackages.. +๐Ÿ“ฆ +Resolvingpackages.. +๐Ÿ“ฆ +Resolvingpackages.. +๐Ÿ“ฆ +Resolvingpackages.. +๐Ÿ“ฆ +Resolvingpackages.. +๐Ÿ“ฆ +Resolvingpackages... +๐Ÿ“ฆ +Resolvingpackages... +๐Ÿ“ฆ +Resolvingpackages... +๐Ÿ“ฆ +Resolvingpackages... +๐Ÿ“ฆ +Resolvingpackages... +๐Ÿ“ฆ +Resolvingpackages... +๐Ÿ“ฆ +Resolvingpackages... +๐Ÿ“ฆ +Resolvingpackages... +๐Ÿงช +Runningtests(1,0failures)... +๐Ÿงช +Runningtests(1,0failures) +๐Ÿงช +Runningtests(1,0failures) +๐Ÿงช +Runningtests(1,0failures) +๐Ÿงช +Runningtests(1,0failures) +๐Ÿงช +Runningtests(1,0failures +)[?25h/Users/cameroncooke/.codex/worktrees/43f4/XcodeBuildMCP/example_projects/iOS_Calculator/CalculatorAppTests/CalculatorAppTests.swift:52: error: XCTAssertEqual failed: ("0") is not equal to ("999") - This test should fail - display should be 0, not 999 +[?25lโ”‚ +๐Ÿงช +Runningtests(13,1failure) +๐Ÿงช +Runningtests(13,1failure) +๐Ÿงช +Runningtests(13,1failure) +๐Ÿงช +Runningtests(21,1failure) +๐Ÿงช +Runningtests(21,1failure) +๐Ÿงช +Runningtests(21,1failure) +๐Ÿงช +Runningtests(21,1failure) +๐Ÿงช +Runningtests(21,1failure) +๐Ÿงช +Runningtests(21,1failure). +๐Ÿงช +Runningtests(21,1failure). +๐Ÿงช +Runningtests(21,1failure). +๐Ÿงช +Runningtests(21,1failure). +๐Ÿงช +Runningtests(21,1failure). +๐Ÿงช +Runningtests(21,1failure). +๐Ÿงช +Runningtests(21,1failure). +๐Ÿงช +Runningtests(21,1failure). +๐Ÿงช +Runningtests(21,1failure).. +๐Ÿงช +Runningtests(21,1failure).. +๐Ÿงช +Runningtests(21,1failure).. +๐Ÿงช +Runningtests(21,1failure).. +๐Ÿงช +Runningtests(21,1failure).. +๐Ÿงช +Runningtests(21,1failure).. +๐Ÿงช +Runningtests(21,1failure).. +๐Ÿงช +Runningtests(21,1failure).. +๐Ÿงช +Runningtests(21,1failure)... +๐Ÿงช +Runningtests(21,1failure)... +๐Ÿงช +Runningtests(21,1failure)... +๐Ÿงช +Runningtests(21,1failure)... +๐Ÿงช +Runningtests(21,1failure)... +๐Ÿงช +Runningtests(21,1failure)... +๐Ÿงช +Runningtests(21,1failure)... +๐Ÿงช +Runningtests(21,1failure)... +๐Ÿงช +Runningtests(21,1failure)... +๐Ÿงช +Runningtests(21,1failure) +๐Ÿงช +Runningtests(21,1failure) +๐Ÿงช +Runningtests(21,1failure) +๐Ÿงช +Runningtests(21,1failure) +๐Ÿงช +Runningtests(21,1failure) +๐Ÿงช +Runningtests(21,1failure) +๐Ÿงช +Runningtests(21,1failure) +๐Ÿงช +Runningtests(21,1failure) +๐Ÿงช +Runningtests(21,1failure). +๐Ÿงช +Runningtests(21,1failure). +๐Ÿงช +Runningtests(21,1failure). +๐Ÿงช +Runningtests(21,1failure). +๐Ÿงช +Runningtests(21,1failure). +๐Ÿงช +Runningtests(21,1failure). +๐Ÿงช +Runningtests(21,1failure). +๐Ÿงช +Runningtests(21,1failure). +๐Ÿงช +Runningtests(21,1failure).. +๐Ÿงช +Runningtests(21,1failure).. +๐Ÿงช +Runningtests(21,1failure).. +๐Ÿงช +Runningtests(21,1failure).. +๐Ÿงช +Runningtests(21,1failure).. +๐Ÿงช +Runningtests(21,1failure).. +๐Ÿงช +Runningtests(21,1failure).. +๐Ÿงช +Runningtests(21,1failure).. +๐Ÿงช +Runningtests(21,1failure)... +๐Ÿงช +Runningtests(21,1failure)... +๐Ÿงช +Runningtests(21,1failure)... +๐Ÿงช +Runningtests(21,1failure)... +๐Ÿงช +Runningtests(21,1failure)... +๐Ÿงช +Runningtests(21,1failure)... +๐Ÿงช +Runningtests(21,1failure)... +๐Ÿงช +Runningtests(21,1failure)... +๐Ÿงช +Runningtests(21,1failure)... +๐Ÿงช +Runningtests(21,1failure) +๐Ÿงช +Runningtests(21,1failure) +๐Ÿงช +Runningtests(21,1failure) +๐Ÿงช +Runningtests(21,1failure) +๐Ÿงช +Runningtests(21,1failure) +๐Ÿงช +Runningtests(21,1failure) +๐Ÿงช +Runningtests(21,1failure) +๐Ÿงช +Runningtests(21,1failure) +๐Ÿงช +Runningtests(21,1failure). +๐Ÿงช +Runningtests(21,1failure). +๐Ÿงช +Runningtests(21,1failure). +๐Ÿงช +Runningtests(21,1failure). +๐Ÿงช +Runningtests(21,1failure). +๐Ÿงช +Runningtests(21,1failure). +๐Ÿงช +Runningtests(21,1failure). +๐Ÿงช +Runningtests(21,1failure). +๐Ÿงช +Runningtests(21,1failure).. +๐Ÿงช +Runningtests(21,1failure).. +๐Ÿงช +Runningtests(21,1failure).. +๐Ÿงช +Runningtests(21,1failure).. +๐Ÿงช +Runningtests(21,1failure).. +๐Ÿงช +Runningtests(21,1failure).. +๐Ÿงช +Runningtests(21,1failure).. +๐Ÿงช +Runningtests(21,1failure).. +๐Ÿงช +Runningtests(21,1failure)... +๐Ÿงช +Runningtests(21,1failure)... +๐Ÿงช +Runningtests(21,1failure)... +๐Ÿงช +Runningtests(21,1failure)... +๐Ÿงช +Runningtests(21,1failure)... +๐Ÿงช +Runningtests(21,1failure)... +๐Ÿงช +Runningtests(21,1failure)... +๐Ÿงช +Runningtests(21,1failure)... +๐Ÿงช +Runningtests(21,1failure)... +๐Ÿงช +Runningtests(21,1failure) +๐Ÿงช +Runningtests(21,1failure) +๐Ÿงช +Runningtests(21,1failure) +๐Ÿงช +Runningtests(21,1failure) +๐Ÿงช +Runningtests(21,1failure) +๐Ÿงช +Runningtests(21,1failure) +๐Ÿงช +Runningtests(21,1failure) +๐Ÿงช +Runningtests(21,1failure) +๐Ÿงช +Runningtests(21,1failure). +๐Ÿงช +Runningtests(21,1failure). +๐Ÿงช +Runningtests(21,1failure). +๐Ÿงช +Runningtests(21,1failure). +๐Ÿงช +Runningtests(21,1failure). +๐Ÿงช +Runningtests(21,1failure). +๐Ÿงช +Runningtests(21,1failure). +๐Ÿงช +Runningtests(21,1failure). +๐Ÿงช +Runningtests(21,1failure).. +๐Ÿงช +Runningtests(21,1failure).. +๐Ÿงช +Runningtests(21,1failure).. +๐Ÿงช +Runningtests(21,1failure).. +๐Ÿงช +Runningtests(21,1failure).. +๐Ÿงช +Runningtests(21,1failure).. +๐Ÿงช +Runningtests(21,1failure).. +๐Ÿงช +Runningtests(21,1failure).. +๐Ÿงช +Runningtests(21,1failure)... +๐Ÿงช +Runningtests(21,1failure)... +๐Ÿงช +Runningtests(21,1failure)... +๐Ÿงช +Runningtests(21,1failure)... +๐Ÿงช +Runningtests(21,1failure)... +๐Ÿงช +Runningtests(21,1failure)... +๐Ÿงช +Runningtests(21,1failure)... +๐Ÿงช +Runningtests(21,1failure)... +๐Ÿงช +Runningtests(21,1failure)... +๐Ÿงช +Runningtests(21,1failure) +๐Ÿงช +Runningtests(21,1failure) +๐Ÿงช +Runningtests(21,1failure) +๐Ÿงช +Runningtests(21,1failure) +๐Ÿงช +Runningtests(21,1failure) +๐Ÿงช +Runningtests(21,1failure) +๐Ÿงช +Runningtests(21,1failure) +๐Ÿงช +Runningtests(21,1failure) +๐Ÿงช +Runningtests(21,1failure). +๐Ÿงช +Runningtests(21,1failure). +๐Ÿงช +Runningtests(21,1failure). +๐Ÿงช +Runningtests(21,1failure). +๐Ÿงช +Runningtests(21,1failure). +๐Ÿงช +Runningtests(21,1failure). +๐Ÿงช +Runningtests(21,1failure). +๐Ÿงช +Runningtests(21,1failure). +๐Ÿงช +Runningtests(21,1failure).. +๐Ÿงช +Runningtests(21,1failure).. +๐Ÿงช +Runningtests(21,1failure).. +๐Ÿงช +Runningtests(21,1failure).. +๐Ÿงช +Runningtests(21,1failure).. +๐Ÿงช +Runningtests(21,1failure).. +๐Ÿงช +Runningtests(21,1failure).. +๐Ÿงช +Runningtests(21,1failure).. +๐Ÿงช +Runningtests(21,1failure)... +๐Ÿงช +Runningtests(21,1failure)... +๐Ÿงช +Runningtests(21,1failure)... +๐Ÿงช +Runningtests(21,1failure)... +๐Ÿงช +Runningtests(21,1failure)... +๐Ÿงช +Runningtests(21,1failure)... +๐Ÿงช +Runningtests(21,1failure)... +๐Ÿงช +Runningtests(21,1failure)... +๐Ÿงช +Runningtests(21,1failure)... +๐Ÿงช +Runningtests(21,1failure) +๐Ÿงช +Runningtests(21,1failure) +๐Ÿงช +Runningtests(21,1failure) +๐Ÿงช +Runningtests(21,1failure) +๐Ÿงช +Runningtests(21,1failure) +๐Ÿงช +Runningtests(21,1failure) +๐Ÿงช +Runningtests(21,1failure) +๐Ÿงช +Runningtests(21,1failure) +๐Ÿงช +Runningtests(21,1failure). +๐Ÿงช +Runningtests(21,1failure). +๐Ÿงช +Runningtests(21,1failure). +๐Ÿงช +Runningtests(21,1failure). +๐Ÿงช +Runningtests(21,1failure). +๐Ÿงช +Runningtests(21,1failure). +๐Ÿงช +Runningtests(21,1failure). +๐Ÿงช +Runningtests(21,1failure). +๐Ÿงช +Runningtests(21,1failure).. +๐Ÿงช +Runningtests(21,1failure).. +๐Ÿงช +Runningtests(21,1failure).. +๐Ÿงช +Runningtests(21,1failure).. +๐Ÿงช +Runningtests(21,1failure).. +๐Ÿงช +Runningtests(21,1failure). +.[?25hFailed Tests +CalculatorAppTests +โœ— testCalculatorServiceFailure (0.009 seconds) +โ””โ”€ XCTAssertEqual failed: ("0") is not equal to ("999") - This test should fail - display should be 0, not 999 (/Users/cameroncooke/.codex/worktrees/43f4/XcodeBuildMCP/example_projects/iOS_Calculator/CalculatorAppTests/CalculatorAppTests.swift:52) +Test Summary +Total: 21 Passed: 20 Failed: 1 Skipped: 0 Duration: 20.84s \ No newline at end of file diff --git a/benchmarks/simulator-test/2026-03-17T14-22-25-469Z/flowdeck-run-1.stderr.txt b/benchmarks/simulator-test/2026-03-17T14-22-25-469Z/flowdeck-run-1.stderr.txt new file mode 100644 index 00000000..e69de29b diff --git a/benchmarks/simulator-test/2026-03-17T14-22-25-469Z/flowdeck-run-1.stdout.txt b/benchmarks/simulator-test/2026-03-17T14-22-25-469Z/flowdeck-run-1.stdout.txt new file mode 100644 index 00000000..1fdd1bac --- /dev/null +++ b/benchmarks/simulator-test/2026-03-17T14-22-25-469Z/flowdeck-run-1.stdout.txt @@ -0,0 +1,51 @@ +^DUsing scheme test configuration: Debug +๐Ÿงช Test: CalculatorApp +Workspace: /Users/cameroncooke/.codex/worktrees/43f4/XcodeBuildMCP/example_projects/iOS_Calculator/CalculatorApp.xcworkspace +Configuration: Debug +Target: iPhone 17 Pro +๐Ÿงช Finding available tests... +Resolved to 21 test(s): +- CalculatorAppTests/CalculatorAppTests/testAppLaunch +- CalculatorAppTests/CalculatorAppTests/testCalculationPerformance +- CalculatorAppTests/CalculatorAppTests/testCalculatorOperationsEnum +- CalculatorAppTests/CalculatorAppTests/testCalculatorServiceBasicOperation +- CalculatorAppTests/CalculatorAppTests/testCalculatorServiceChainedOperations +... and 16 more +๐Ÿงช Running tests (0, 0 failures) +๐Ÿงช Running tests (1, 0 failures) +๐Ÿงช Running tests (2, 0 failures) +๐Ÿงช Running tests (3, 0 failures) +๐Ÿงช Running tests (4, 0 failures) +๐Ÿงช Running tests (5, 0 failures) +๐Ÿงช Running tests (6, 0 failures) +๐Ÿงช Running tests (7, 0 failures)/Users/cameroncooke/.codex/worktrees/43f4/XcodeBuildMCP/example_projects/iOS_Calculator/CalculatorAppTests/CalculatorAppTests.swift:52: error: -[CalculatorAppTests.CalculatorAppTests testCalculatorServiceFailure] : XCTAssertEqual failed: ("0") is not equal to ("999") - This test should fail - display should be 0, not 999 +๐Ÿงช Running tests (8, 1 failure) +๐Ÿงช Running tests (9, 1 failure) +๐Ÿงช Running tests (10, 1 failure) +๐Ÿงช Running tests (11, 1 failure) +๐Ÿงช Running tests (12, 1 failure) +๐Ÿงช Running tests (13, 1 failure) +๐Ÿงช Running tests (14, 1 failure) +๐Ÿงช Running tests (15, 1 failure) +๐Ÿงช Running tests (16, 1 failure) +๐Ÿงช Running tests (17, 1 failure) +๐Ÿงช Running tests (18, 1 failure) +๐Ÿงช Running tests (19, 1 failure) +๐Ÿงช Running tests (20, 1 failure) +๐Ÿงช Running tests (21, 1 failure) +Failed Tests +CalculatorAppTests +โœ— testCalculatorServiceFailure (0.009s) +โ””โ”€ XCTAssertEqual failed: ("0") is not +equal to ("999") - This test should fail +- display should be 0, not 999 +(CalculatorAppTests.swift:52) +Test Summary +โ•ญโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎ +โ”‚ Total: 21 โ”‚ +โ”‚ Passed: 20 โ”‚ +โ”‚ Failed: 1 โ”‚ +โ”‚ Skipped: 0 โ”‚ +โ”‚ Duration: 27.52s โ”‚ +โ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏ +โœ— 1 test(s) failed. \ No newline at end of file diff --git a/benchmarks/simulator-test/2026-03-17T14-22-25-469Z/summary.json b/benchmarks/simulator-test/2026-03-17T14-22-25-469Z/summary.json new file mode 100644 index 00000000..dfb3878e --- /dev/null +++ b/benchmarks/simulator-test/2026-03-17T14-22-25-469Z/summary.json @@ -0,0 +1,30 @@ +{ + "generatedAt": "2026-03-17T14:23:22.406Z", + "mode": "warm", + "iterations": 1, + "workspacePath": "/Users/cameroncooke/.codex/worktrees/43f4/XcodeBuildMCP/example_projects/iOS_Calculator/CalculatorApp.xcworkspace", + "results": [ + { + "tool": "xcodebuildmcp", + "iteration": 1, + "exitCode": 1, + "wallClockMs": 29005.319917, + "firstStdoutMs": 1.7743750000000063, + "firstMilestoneMs": 2082.7132079999997, + "startupToFirstStreamedTestProgressMs": 13278.247792, + "stdoutPath": "/Users/cameroncooke/.codex/worktrees/43f4/XcodeBuildMCP/benchmarks/simulator-test/2026-03-17T14-22-25-469Z/xcodebuildmcp-run-1.stdout.txt", + "stderrPath": "/Users/cameroncooke/.codex/worktrees/43f4/XcodeBuildMCP/benchmarks/simulator-test/2026-03-17T14-22-25-469Z/xcodebuildmcp-run-1.stderr.txt" + }, + { + "tool": "flowdeck", + "iteration": 1, + "exitCode": 1, + "wallClockMs": 27930.502958999998, + "firstStdoutMs": 3.876625000000786, + "firstMilestoneMs": 11681.301833999998, + "startupToFirstStreamedTestProgressMs": 11681.326125, + "stdoutPath": "/Users/cameroncooke/.codex/worktrees/43f4/XcodeBuildMCP/benchmarks/simulator-test/2026-03-17T14-22-25-469Z/flowdeck-run-1.stdout.txt", + "stderrPath": "/Users/cameroncooke/.codex/worktrees/43f4/XcodeBuildMCP/benchmarks/simulator-test/2026-03-17T14-22-25-469Z/flowdeck-run-1.stderr.txt" + } + ] +} \ No newline at end of file diff --git a/benchmarks/simulator-test/2026-03-17T14-22-25-469Z/xcodebuildmcp-run-1.stderr.txt b/benchmarks/simulator-test/2026-03-17T14-22-25-469Z/xcodebuildmcp-run-1.stderr.txt new file mode 100644 index 00000000..e69de29b diff --git a/benchmarks/simulator-test/2026-03-17T14-22-25-469Z/xcodebuildmcp-run-1.stdout.txt b/benchmarks/simulator-test/2026-03-17T14-22-25-469Z/xcodebuildmcp-run-1.stdout.txt new file mode 100644 index 00000000..5f95bb32 --- /dev/null +++ b/benchmarks/simulator-test/2026-03-17T14-22-25-469Z/xcodebuildmcp-run-1.stdout.txt @@ -0,0 +1,669 @@ +^DUsing scheme test configuration: Debug +๐Ÿงช Test: CalculatorApp +Workspace: /Users/cameroncooke/.codex/worktrees/43f4/XcodeBuildMCP/example_projects/iOS_Calculator/CalculatorApp.xcworkspace +Configuration: Debug +Target: iPhone 17 Pro +๐Ÿงช Finding available tests... +Resolved to 21 test(s): +- CalculatorAppTests/CalculatorAppTests/testAppLaunch +- CalculatorAppTests/CalculatorAppTests/testCalculationPerformance +- CalculatorAppTests/CalculatorAppTests/testCalculatorOperationsEnum +- CalculatorAppTests/CalculatorAppTests/testCalculatorServiceBasicOperation +- CalculatorAppTests/CalculatorAppTests/testCalculatorServiceChainedOperations +... and 16 more +[?25lโ”‚ +๐Ÿ“ฆ +Resolvingpackages +๐Ÿ“ฆ +Resolvingpackages +๐Ÿ“ฆ +Resolvingpackages +๐Ÿ“ฆ +Resolvingpackages +๐Ÿ“ฆ +Resolvingpackages +๐Ÿ“ฆ +Resolvingpackages +๐Ÿ“ฆ +Resolvingpackages +๐Ÿ“ฆ +Resolvingpackages +๐Ÿ“ฆ +Resolvingpackages. +๐Ÿ“ฆ +Resolvingpackages. +๐Ÿ“ฆ +Resolvingpackages. +๐Ÿ“ฆ +Resolvingpackages. +๐Ÿ“ฆ +Resolvingpackages. +๐Ÿ“ฆ +Resolvingpackages. +๐Ÿ“ฆ +Resolvingpackages. +๐Ÿ“ฆ +Resolvingpackages. +๐Ÿ“ฆ +Resolvingpackages.. +๐Ÿ“ฆ +Resolvingpackages.. +๐Ÿ“ฆ +Resolvingpackages.. +๐Ÿ“ฆ +Resolvingpackages.. +๐Ÿ“ฆ +Resolvingpackages.. +๐Ÿ“ฆ +Resolvingpackages.. +๐Ÿ“ฆ +Resolvingpackages.. +๐Ÿ“ฆ +Resolvingpackages.. +๐Ÿ“ฆ +Resolvingpackages... +๐Ÿ“ฆ +Resolvingpackages... +๐Ÿ“ฆ +Resolvingpackages... +๐Ÿ“ฆ +Resolvingpackages... +๐Ÿ“ฆ +Resolvingpackages... +๐Ÿ“ฆ +Resolvingpackages... +๐Ÿ“ฆ +Resolvingpackages... +๐Ÿ“ฆ +Resolvingpackages... +๐Ÿ“ฆ +Resolvingpackages... +๐Ÿ“ฆ +Resolvingpackages +๐Ÿ“ฆ +Resolvingpackages +๐Ÿ“ฆ +Resolvingpackages +๐Ÿ“ฆ +Resolvingpackages +๐Ÿ“ฆ +Resolvingpackages +๐Ÿ“ฆ +Resolvingpackages +๐Ÿ“ฆ +Resolvingpackages +๐Ÿ“ฆ +Resolvingpackages +๐Ÿ“ฆ +Resolvingpackages. +๐Ÿ“ฆ +Resolvingpackages. +๐Ÿ“ฆ +Resolvingpackages. +๐Ÿ“ฆ +Resolvingpackages. +๐Ÿ“ฆ +Resolvingpackages. +๐Ÿ“ฆ +Resolvingpackages. +๐Ÿ“ฆ +Resolvingpackages. +๐Ÿ“ฆ +Resolvingpackages. +๐Ÿ“ฆ +Resolvingpackages.. +๐Ÿ“ฆ +Resolvingpackages.. +๐Ÿ“ฆ +Resolvingpackages.. +๐Ÿ“ฆ +Resolvingpackages.. +๐Ÿ“ฆ +Resolvingpackages.. +๐Ÿ“ฆ +Resolvingpackages.. +๐Ÿ› ๏ธ +Compiling.. +๐Ÿ› ๏ธ +Compiling.. +๐Ÿ› ๏ธ +Compiling... +๐Ÿ› ๏ธ +Compiling... +๐Ÿ› ๏ธ +Compiling... +๐Ÿ› ๏ธ +Compiling... +๐Ÿ› ๏ธ +Compiling... +๐Ÿ› ๏ธ +Compiling... +๐Ÿ› ๏ธ +Compiling... +๐Ÿ› ๏ธ +Compiling... +๐Ÿ› ๏ธ +Compiling... +๐Ÿ› ๏ธ +Compiling +๐Ÿ› ๏ธ +Compiling +๐Ÿ› ๏ธ +Compiling +๐Ÿ› ๏ธ +Compiling +๐Ÿ› ๏ธ +Compiling +๐Ÿ› ๏ธ +Compiling +๐Ÿ› ๏ธ +Compiling +๐Ÿ› ๏ธ +Compiling +๐Ÿ› ๏ธ +Compiling. +๐Ÿ› ๏ธ +Compiling. +๐Ÿ› ๏ธ +Compiling. +๐Ÿ› ๏ธ +Compiling. +๐Ÿ› ๏ธ +Compiling. +๐Ÿ› ๏ธ +Compiling. +๐Ÿ› ๏ธ +Compiling. +๐Ÿ› ๏ธ +Compiling. +๐Ÿ› ๏ธ +Compiling.. +๐Ÿ› ๏ธ +Compiling.. +๐Ÿ› ๏ธ +Compiling.. +๐Ÿ› ๏ธ +Compiling.. +๐Ÿ› ๏ธ +Compiling.. +๐Ÿ› ๏ธ +Compiling.. +๐Ÿ› ๏ธ +Compiling.. +๐Ÿ› ๏ธ +Compiling.. +๐Ÿ› ๏ธ +Compiling... +๐Ÿ› ๏ธ +Compiling... +๐Ÿ› ๏ธ +Compiling... +๐Ÿ› ๏ธ +Compiling... +๐Ÿ› ๏ธ +Compiling... +๐Ÿ› ๏ธ +Compiling.. +.[?25h[?25lโ”‚ +๐Ÿ“ฆ +Resolvingpackages +๐Ÿ“ฆ +Resolvingpackages +๐Ÿ“ฆ +Resolvingpackages +๐Ÿ“ฆ +Resolvingpackages +๐Ÿ“ฆ +Resolvingpackages +๐Ÿ“ฆ +Resolvingpackages +๐Ÿ“ฆ +Resolvingpackages +๐Ÿ“ฆ +Resolvingpackages +๐Ÿ“ฆ +Resolvingpackages. +๐Ÿ“ฆ +Resolvingpackages. +๐Ÿ“ฆ +Resolvingpackages. +๐Ÿ“ฆ +Resolvingpackages. +๐Ÿ“ฆ +Resolvingpackages. +๐Ÿ“ฆ +Resolvingpackages. +๐Ÿ“ฆ +Resolvingpackages. +๐Ÿ“ฆ +Resolvingpackages. +๐Ÿ“ฆ +Resolvingpackages.. +๐Ÿ“ฆ +Resolvingpackages.. +๐Ÿ“ฆ +Resolvingpackages.. +๐Ÿ“ฆ +Resolvingpackages.. +๐Ÿ“ฆ +Resolvingpackages.. +๐Ÿ“ฆ +Resolvingpackages.. +๐Ÿ“ฆ +Resolvingpackages.. +๐Ÿ“ฆ +Resolvingpackages.. +๐Ÿ“ฆ +Resolvingpackages... +๐Ÿ“ฆ +Resolvingpackages... +๐Ÿ“ฆ +Resolvingpackages... +๐Ÿ“ฆ +Resolvingpackages... +๐Ÿ“ฆ +Resolvingpackages... +๐Ÿ“ฆ +Resolvingpackages... +๐Ÿ“ฆ +Resolvingpackages... +๐Ÿ“ฆ +Resolvingpackages... +๐Ÿ“ฆ +Resolvingpackages... +๐Ÿ“ฆ +Resolvingpackages +๐Ÿงช +Runningtests(1,0failures) +๐Ÿงช +Runningtests(1,0failures) +๐Ÿงช +Runningtests(1,0failures) +๐Ÿงช +Runningtests(1,0failures) +๐Ÿงช +Runningtests(1,0failures) +๐Ÿงช +Runningtests(1,0failures +)[?25h/Users/cameroncooke/.codex/worktrees/43f4/XcodeBuildMCP/example_projects/iOS_Calculator/CalculatorAppTests/CalculatorAppTests.swift:52: error: XCTAssertEqual failed: ("0") is not equal to ("999") - This test should fail - display should be 0, not 999 +[?25lโ”‚ +๐Ÿงช +Runningtests(13,1failure) +๐Ÿงช +Runningtests(13,1failure) +๐Ÿงช +Runningtests(13,1failure) +๐Ÿงช +Runningtests(21,1failure) +๐Ÿงช +Runningtests(21,1failure) +๐Ÿงช +Runningtests(21,1failure) +๐Ÿงช +Runningtests(21,1failure) +๐Ÿงช +Runningtests(21,1failure) +๐Ÿงช +Runningtests(21,1failure). +๐Ÿงช +Runningtests(21,1failure). +๐Ÿงช +Runningtests(21,1failure). +๐Ÿงช +Runningtests(21,1failure). +๐Ÿงช +Runningtests(21,1failure). +๐Ÿงช +Runningtests(21,1failure). +๐Ÿงช +Runningtests(21,1failure). +๐Ÿงช +Runningtests(21,1failure). +๐Ÿงช +Runningtests(21,1failure).. +๐Ÿงช +Runningtests(21,1failure).. +๐Ÿงช +Runningtests(21,1failure).. +๐Ÿงช +Runningtests(21,1failure).. +๐Ÿงช +Runningtests(21,1failure).. +๐Ÿงช +Runningtests(21,1failure).. +๐Ÿงช +Runningtests(21,1failure).. +๐Ÿงช +Runningtests(21,1failure).. +๐Ÿงช +Runningtests(21,1failure)... +๐Ÿงช +Runningtests(21,1failure)... +๐Ÿงช +Runningtests(21,1failure)... +๐Ÿงช +Runningtests(21,1failure)... +๐Ÿงช +Runningtests(21,1failure)... +๐Ÿงช +Runningtests(21,1failure)... +๐Ÿงช +Runningtests(21,1failure)... +๐Ÿงช +Runningtests(21,1failure)... +๐Ÿงช +Runningtests(21,1failure)... +๐Ÿงช +Runningtests(21,1failure) +๐Ÿงช +Runningtests(21,1failure) +๐Ÿงช +Runningtests(21,1failure) +๐Ÿงช +Runningtests(21,1failure) +๐Ÿงช +Runningtests(21,1failure) +๐Ÿงช +Runningtests(21,1failure) +๐Ÿงช +Runningtests(21,1failure) +๐Ÿงช +Runningtests(21,1failure) +๐Ÿงช +Runningtests(21,1failure). +๐Ÿงช +Runningtests(21,1failure). +๐Ÿงช +Runningtests(21,1failure). +๐Ÿงช +Runningtests(21,1failure). +๐Ÿงช +Runningtests(21,1failure). +๐Ÿงช +Runningtests(21,1failure). +๐Ÿงช +Runningtests(21,1failure). +๐Ÿงช +Runningtests(21,1failure). +๐Ÿงช +Runningtests(21,1failure).. +๐Ÿงช +Runningtests(21,1failure).. +๐Ÿงช +Runningtests(21,1failure).. +๐Ÿงช +Runningtests(21,1failure).. +๐Ÿงช +Runningtests(21,1failure).. +๐Ÿงช +Runningtests(21,1failure).. +๐Ÿงช +Runningtests(21,1failure).. +๐Ÿงช +Runningtests(21,1failure).. +๐Ÿงช +Runningtests(21,1failure)... +๐Ÿงช +Runningtests(21,1failure)... +๐Ÿงช +Runningtests(21,1failure)... +๐Ÿงช +Runningtests(21,1failure)... +๐Ÿงช +Runningtests(21,1failure)... +๐Ÿงช +Runningtests(21,1failure)... +๐Ÿงช +Runningtests(21,1failure)... +๐Ÿงช +Runningtests(21,1failure)... +๐Ÿงช +Runningtests(21,1failure)... +๐Ÿงช +Runningtests(21,1failure) +๐Ÿงช +Runningtests(21,1failure) +๐Ÿงช +Runningtests(21,1failure) +๐Ÿงช +Runningtests(21,1failure) +๐Ÿงช +Runningtests(21,1failure) +๐Ÿงช +Runningtests(21,1failure) +๐Ÿงช +Runningtests(21,1failure) +๐Ÿงช +Runningtests(21,1failure) +๐Ÿงช +Runningtests(21,1failure). +๐Ÿงช +Runningtests(21,1failure). +๐Ÿงช +Runningtests(21,1failure). +๐Ÿงช +Runningtests(21,1failure). +๐Ÿงช +Runningtests(21,1failure). +๐Ÿงช +Runningtests(21,1failure). +๐Ÿงช +Runningtests(21,1failure). +๐Ÿงช +Runningtests(21,1failure). +๐Ÿงช +Runningtests(21,1failure).. +๐Ÿงช +Runningtests(21,1failure).. +๐Ÿงช +Runningtests(21,1failure).. +๐Ÿงช +Runningtests(21,1failure).. +๐Ÿงช +Runningtests(21,1failure).. +๐Ÿงช +Runningtests(21,1failure).. +๐Ÿงช +Runningtests(21,1failure).. +๐Ÿงช +Runningtests(21,1failure).. +๐Ÿงช +Runningtests(21,1failure)... +๐Ÿงช +Runningtests(21,1failure)... +๐Ÿงช +Runningtests(21,1failure)... +๐Ÿงช +Runningtests(21,1failure)... +๐Ÿงช +Runningtests(21,1failure)... +๐Ÿงช +Runningtests(21,1failure)... +๐Ÿงช +Runningtests(21,1failure)... +๐Ÿงช +Runningtests(21,1failure)... +๐Ÿงช +Runningtests(21,1failure)... +๐Ÿงช +Runningtests(21,1failure) +๐Ÿงช +Runningtests(21,1failure) +๐Ÿงช +Runningtests(21,1failure) +๐Ÿงช +Runningtests(21,1failure) +๐Ÿงช +Runningtests(21,1failure) +๐Ÿงช +Runningtests(21,1failure) +๐Ÿงช +Runningtests(21,1failure) +๐Ÿงช +Runningtests(21,1failure) +๐Ÿงช +Runningtests(21,1failure). +๐Ÿงช +Runningtests(21,1failure). +๐Ÿงช +Runningtests(21,1failure). +๐Ÿงช +Runningtests(21,1failure). +๐Ÿงช +Runningtests(21,1failure). +๐Ÿงช +Runningtests(21,1failure). +๐Ÿงช +Runningtests(21,1failure). +๐Ÿงช +Runningtests(21,1failure). +๐Ÿงช +Runningtests(21,1failure).. +๐Ÿงช +Runningtests(21,1failure).. +๐Ÿงช +Runningtests(21,1failure).. +๐Ÿงช +Runningtests(21,1failure).. +๐Ÿงช +Runningtests(21,1failure).. +๐Ÿงช +Runningtests(21,1failure).. +๐Ÿงช +Runningtests(21,1failure).. +๐Ÿงช +Runningtests(21,1failure).. +๐Ÿงช +Runningtests(21,1failure)... +๐Ÿงช +Runningtests(21,1failure)... +๐Ÿงช +Runningtests(21,1failure)... +๐Ÿงช +Runningtests(21,1failure)... +๐Ÿงช +Runningtests(21,1failure)... +๐Ÿงช +Runningtests(21,1failure)... +๐Ÿงช +Runningtests(21,1failure)... +๐Ÿงช +Runningtests(21,1failure)... +๐Ÿงช +Runningtests(21,1failure)... +๐Ÿงช +Runningtests(21,1failure) +๐Ÿงช +Runningtests(21,1failure) +๐Ÿงช +Runningtests(21,1failure) +๐Ÿงช +Runningtests(21,1failure) +๐Ÿงช +Runningtests(21,1failure) +๐Ÿงช +Runningtests(21,1failure) +๐Ÿงช +Runningtests(21,1failure) +๐Ÿงช +Runningtests(21,1failure) +๐Ÿงช +Runningtests(21,1failure). +๐Ÿงช +Runningtests(21,1failure). +๐Ÿงช +Runningtests(21,1failure). +๐Ÿงช +Runningtests(21,1failure). +๐Ÿงช +Runningtests(21,1failure). +๐Ÿงช +Runningtests(21,1failure). +๐Ÿงช +Runningtests(21,1failure). +๐Ÿงช +Runningtests(21,1failure). +๐Ÿงช +Runningtests(21,1failure).. +๐Ÿงช +Runningtests(21,1failure).. +๐Ÿงช +Runningtests(21,1failure).. +๐Ÿงช +Runningtests(21,1failure).. +๐Ÿงช +Runningtests(21,1failure).. +๐Ÿงช +Runningtests(21,1failure).. +๐Ÿงช +Runningtests(21,1failure).. +๐Ÿงช +Runningtests(21,1failure).. +๐Ÿงช +Runningtests(21,1failure)... +๐Ÿงช +Runningtests(21,1failure)... +๐Ÿงช +Runningtests(21,1failure)... +๐Ÿงช +Runningtests(21,1failure)... +๐Ÿงช +Runningtests(21,1failure)... +๐Ÿงช +Runningtests(21,1failure)... +๐Ÿงช +Runningtests(21,1failure)... +๐Ÿงช +Runningtests(21,1failure)... +๐Ÿงช +Runningtests(21,1failure)... +๐Ÿงช +Runningtests(21,1failure) +๐Ÿงช +Runningtests(21,1failure) +๐Ÿงช +Runningtests(21,1failure) +๐Ÿงช +Runningtests(21,1failure) +๐Ÿงช +Runningtests(21,1failure) +๐Ÿงช +Runningtests(21,1failure) +๐Ÿงช +Runningtests(21,1failure) +๐Ÿงช +Runningtests(21,1failure) +๐Ÿงช +Runningtests(21,1failure). +๐Ÿงช +Runningtests(21,1failure). +๐Ÿงช +Runningtests(21,1failure). +๐Ÿงช +Runningtests(21,1failure). +๐Ÿงช +Runningtests(21,1failure). +๐Ÿงช +Runningtests(21,1failure). +๐Ÿงช +Runningtests(21,1failure). +๐Ÿงช +Runningtests(21,1failure). +๐Ÿงช +Runningtests(21,1failure).. +๐Ÿงช +Runningtests(21,1failure).. +๐Ÿงช +Runningtests(21,1failure).. +๐Ÿงช +Runningtests(21,1failure).. +๐Ÿงช +Runningtests(21,1failure).. +๐Ÿงช +Runningtests(21,1failure). +.[?25hFailed Tests +CalculatorAppTests +โœ— testCalculatorServiceFailure (0.004 seconds) +โ””โ”€ XCTAssertEqual failed: ("0") is not equal to ("999") - This test should fail - display should be 0, not 999 (/Users/cameroncooke/.codex/worktrees/43f4/XcodeBuildMCP/example_projects/iOS_Calculator/CalculatorAppTests/CalculatorAppTests.swift:52) +Test Summary +Total: 21 Passed: 20 Failed: 1 Skipped: 0 Duration: 27.98s \ No newline at end of file diff --git a/benchmarks/simulator-test/2026-03-17T14-57-22-646Z/flowdeck-run-1.stderr.txt b/benchmarks/simulator-test/2026-03-17T14-57-22-646Z/flowdeck-run-1.stderr.txt new file mode 100644 index 00000000..e69de29b diff --git a/benchmarks/simulator-test/2026-03-17T14-57-22-646Z/flowdeck-run-1.stdout.txt b/benchmarks/simulator-test/2026-03-17T14-57-22-646Z/flowdeck-run-1.stdout.txt new file mode 100644 index 00000000..affbd101 --- /dev/null +++ b/benchmarks/simulator-test/2026-03-17T14-57-22-646Z/flowdeck-run-1.stdout.txt @@ -0,0 +1,51 @@ +^DUsing scheme test configuration: Debug +๐Ÿงช Test: CalculatorApp +Workspace: /Users/cameroncooke/.codex/worktrees/43f4/XcodeBuildMCP/example_projects/iOS_Calculator/CalculatorApp.xcworkspace +Configuration: Debug +Target: iPhone 17 Pro +๐Ÿงช Finding available tests... +Resolved to 21 test(s): +- CalculatorAppTests/CalculatorAppTests/testAppLaunch +- CalculatorAppTests/CalculatorAppTests/testCalculationPerformance +- CalculatorAppTests/CalculatorAppTests/testCalculatorOperationsEnum +- CalculatorAppTests/CalculatorAppTests/testCalculatorServiceBasicOperation +- CalculatorAppTests/CalculatorAppTests/testCalculatorServiceChainedOperations +... and 16 more +๐Ÿงช Running tests (0, 0 failures) +๐Ÿงช Running tests (1, 0 failures) +๐Ÿงช Running tests (2, 0 failures) +๐Ÿงช Running tests (3, 0 failures) +๐Ÿงช Running tests (4, 0 failures) +๐Ÿงช Running tests (5, 0 failures) +๐Ÿงช Running tests (6, 0 failures) +๐Ÿงช Running tests (7, 0 failures)/Users/cameroncooke/.codex/worktrees/43f4/XcodeBuildMCP/example_projects/iOS_Calculator/CalculatorAppTests/CalculatorAppTests.swift:52: error: -[CalculatorAppTests.CalculatorAppTests testCalculatorServiceFailure] : XCTAssertEqual failed: ("0") is not equal to ("999") - This test should fail - display should be 0, not 999 +๐Ÿงช Running tests (8, 1 failure) +๐Ÿงช Running tests (9, 1 failure) +๐Ÿงช Running tests (10, 1 failure) +๐Ÿงช Running tests (11, 1 failure) +๐Ÿงช Running tests (12, 1 failure) +๐Ÿงช Running tests (13, 1 failure) +๐Ÿงช Running tests (14, 1 failure) +๐Ÿงช Running tests (15, 1 failure) +๐Ÿงช Running tests (16, 1 failure) +๐Ÿงช Running tests (17, 1 failure) +๐Ÿงช Running tests (18, 1 failure) +๐Ÿงช Running tests (19, 1 failure) +๐Ÿงช Running tests (20, 1 failure) +๐Ÿงช Running tests (21, 1 failure) +Failed Tests +CalculatorAppTests +โœ— testCalculatorServiceFailure (0.003s) +โ””โ”€ XCTAssertEqual failed: ("0") is not +equal to ("999") - This test should fail +- display should be 0, not 999 +(CalculatorAppTests.swift:52) +Test Summary +โ•ญโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎ +โ”‚ Total: 21 โ”‚ +โ”‚ Passed: 20 โ”‚ +โ”‚ Failed: 1 โ”‚ +โ”‚ Skipped: 0 โ”‚ +โ”‚ Duration: 28.08s โ”‚ +โ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏ +โœ— 1 test(s) failed. \ No newline at end of file diff --git a/benchmarks/simulator-test/2026-03-17T14-57-22-646Z/summary.json b/benchmarks/simulator-test/2026-03-17T14-57-22-646Z/summary.json new file mode 100644 index 00000000..a0fdb6b1 --- /dev/null +++ b/benchmarks/simulator-test/2026-03-17T14-57-22-646Z/summary.json @@ -0,0 +1,30 @@ +{ + "generatedAt": "2026-03-17T14:58:22.020Z", + "mode": "warm", + "iterations": 1, + "workspacePath": "/Users/cameroncooke/.codex/worktrees/43f4/XcodeBuildMCP/example_projects/iOS_Calculator/CalculatorApp.xcworkspace", + "results": [ + { + "tool": "xcodebuildmcp", + "iteration": 1, + "exitCode": 1, + "wallClockMs": 30882.488, + "firstStdoutMs": 2.211042000000006, + "firstMilestoneMs": 2160.745125, + "startupToFirstStreamedTestProgressMs": 14674.5665, + "stdoutPath": "/Users/cameroncooke/.codex/worktrees/43f4/XcodeBuildMCP/benchmarks/simulator-test/2026-03-17T14-57-22-646Z/xcodebuildmcp-run-1.stdout.txt", + "stderrPath": "/Users/cameroncooke/.codex/worktrees/43f4/XcodeBuildMCP/benchmarks/simulator-test/2026-03-17T14-57-22-646Z/xcodebuildmcp-run-1.stderr.txt" + }, + { + "tool": "flowdeck", + "iteration": 1, + "exitCode": 1, + "wallClockMs": 28490.114, + "firstStdoutMs": 5.035500000001775, + "firstMilestoneMs": 12153.824459000003, + "startupToFirstStreamedTestProgressMs": 12153.8315, + "stdoutPath": "/Users/cameroncooke/.codex/worktrees/43f4/XcodeBuildMCP/benchmarks/simulator-test/2026-03-17T14-57-22-646Z/flowdeck-run-1.stdout.txt", + "stderrPath": "/Users/cameroncooke/.codex/worktrees/43f4/XcodeBuildMCP/benchmarks/simulator-test/2026-03-17T14-57-22-646Z/flowdeck-run-1.stderr.txt" + } + ] +} \ No newline at end of file diff --git a/benchmarks/simulator-test/2026-03-17T14-57-22-646Z/xcodebuildmcp-run-1.stderr.txt b/benchmarks/simulator-test/2026-03-17T14-57-22-646Z/xcodebuildmcp-run-1.stderr.txt new file mode 100644 index 00000000..e69de29b diff --git a/benchmarks/simulator-test/2026-03-17T14-57-22-646Z/xcodebuildmcp-run-1.stdout.txt b/benchmarks/simulator-test/2026-03-17T14-57-22-646Z/xcodebuildmcp-run-1.stdout.txt new file mode 100644 index 00000000..ff6d6262 --- /dev/null +++ b/benchmarks/simulator-test/2026-03-17T14-57-22-646Z/xcodebuildmcp-run-1.stdout.txt @@ -0,0 +1,635 @@ +^DUsing scheme test configuration: Debug +๐Ÿงช Test: CalculatorApp +Workspace: /Users/cameroncooke/.codex/worktrees/43f4/XcodeBuildMCP/example_projects/iOS_Calculator/CalculatorApp.xcworkspace +Configuration: Debug +Target: iPhone 17 Pro +๐Ÿงช Finding available tests... +Resolved to 21 test(s): +- CalculatorAppTests/CalculatorAppTests/testAppLaunch +- CalculatorAppTests/CalculatorAppTests/testCalculationPerformance +- CalculatorAppTests/CalculatorAppTests/testCalculatorOperationsEnum +- CalculatorAppTests/CalculatorAppTests/testCalculatorServiceBasicOperation +- CalculatorAppTests/CalculatorAppTests/testCalculatorServiceChainedOperations +... and 16 more +[?25lโ”‚ +๐Ÿ“ฆ +Resolvingpackages +๐Ÿ“ฆ +Resolvingpackages +๐Ÿ“ฆ +Resolvingpackages +๐Ÿ“ฆ +Resolvingpackages +๐Ÿ“ฆ +Resolvingpackages +๐Ÿ“ฆ +Resolvingpackages +๐Ÿ“ฆ +Resolvingpackages +๐Ÿ“ฆ +Resolvingpackages +๐Ÿ“ฆ +Resolvingpackages. +๐Ÿ“ฆ +Resolvingpackages. +๐Ÿ“ฆ +Resolvingpackages. +๐Ÿ“ฆ +Resolvingpackages. +๐Ÿ“ฆ +Resolvingpackages. +๐Ÿ“ฆ +Resolvingpackages. +๐Ÿ“ฆ +Resolvingpackages. +๐Ÿ“ฆ +Resolvingpackages. +๐Ÿ“ฆ +Resolvingpackages.. +๐Ÿ“ฆ +Resolvingpackages.. +๐Ÿ“ฆ +Resolvingpackages.. +๐Ÿ“ฆ +Resolvingpackages.. +๐Ÿ“ฆ +Resolvingpackages.. +๐Ÿ“ฆ +Resolvingpackages.. +๐Ÿ“ฆ +Resolvingpackages.. +๐Ÿ“ฆ +Resolvingpackages.. +๐Ÿ“ฆ +Resolvingpackages... +๐Ÿ“ฆ +Resolvingpackages... +๐Ÿ“ฆ +Resolvingpackages... +๐Ÿ“ฆ +Resolvingpackages... +๐Ÿ“ฆ +Resolvingpackages... +๐Ÿ“ฆ +Resolvingpackages... +๐Ÿ“ฆ +Resolvingpackages... +๐Ÿ“ฆ +Resolvingpackages... +๐Ÿ“ฆ +Resolvingpackages... +๐Ÿ“ฆ +Resolvingpackages +๐Ÿ“ฆ +Resolvingpackages +๐Ÿ“ฆ +Resolvingpackages +๐Ÿ“ฆ +Resolvingpackages +๐Ÿ“ฆ +Resolvingpackages +๐Ÿ“ฆ +Resolvingpackages +๐Ÿ“ฆ +Resolvingpackages +๐Ÿ“ฆ +Resolvingpackages +๐Ÿ“ฆ +Resolvingpackages. +๐Ÿ“ฆ +Resolvingpackages. +๐Ÿ“ฆ +Resolvingpackages. +๐Ÿ“ฆ +Resolvingpackages. +๐Ÿ“ฆ +Resolvingpackages. +๐Ÿ“ฆ +Resolvingpackages. +๐Ÿ“ฆ +Resolvingpackages. +๐Ÿ“ฆ +Resolvingpackages. +๐Ÿ“ฆ +Resolvingpackages.. +๐Ÿ“ฆ +Resolvingpackages.. +๐Ÿ“ฆ +Resolvingpackages.. +๐Ÿ“ฆ +Resolvingpackages.. +๐Ÿ“ฆ +Resolvingpackages.. +๐Ÿ“ฆ +Resolvingpackages.. +๐Ÿ“ฆ +Resolvingpackages.. +๐Ÿ“ฆ +Resolvingpackages.. +๐Ÿ“ฆ +Resolvingpackages... +๐Ÿ“ฆ +Resolvingpackages... +๐Ÿ“ฆ +Resolvingpackages... +๐Ÿ“ฆ +Resolvingpackages... +๐Ÿ“ฆ +Resolvingpackages... +๐Ÿ“ฆ +Resolvingpackages... +๐Ÿ“ฆ +Resolvingpackages... +๐Ÿ“ฆ +Resolvingpackages... +๐Ÿ“ฆ +Resolvingpackages... +๐Ÿ“ฆ +Resolvingpackages +๐Ÿ› ๏ธ +Compiling +๐Ÿ› ๏ธ +Compiling +๐Ÿ› ๏ธ +Compiling +๐Ÿ› ๏ธ +Compiling +๐Ÿ› ๏ธ +Compiling +๐Ÿ› ๏ธ +Compiling +๐Ÿ› ๏ธ +Compiling +๐Ÿ› ๏ธ +Compiling. +๐Ÿ› ๏ธ +Compiling. +๐Ÿ› ๏ธ +Compiling. +๐Ÿ› ๏ธ +Compiling. +๐Ÿ› ๏ธ +Compiling. +๐Ÿ› ๏ธ +Compiling. +๐Ÿ› ๏ธ +Compiling. +๐Ÿ› ๏ธ +Compiling. +๐Ÿ› ๏ธ +Compiling.. +๐Ÿ› ๏ธ +Compiling.. +๐Ÿ› ๏ธ +Compiling.. +๐Ÿ› ๏ธ +Compiling.. +๐Ÿ› ๏ธ +Compiling.. +๐Ÿ› ๏ธ +Compiling.. +๐Ÿ› ๏ธ +Compiling.. +๐Ÿ› ๏ธ +Compiling.. +๐Ÿ› ๏ธ +Compiling... +๐Ÿ› ๏ธ +Compiling... +๐Ÿ› ๏ธ +Compiling... +๐Ÿ› ๏ธ +Compiling... +๐Ÿ› ๏ธ +Compiling... +๐Ÿ› ๏ธ +Compiling... +๐Ÿ› ๏ธ +Compiling... +๐Ÿ› ๏ธ +Compiling... +๐Ÿ› ๏ธ +Compiling... +๐Ÿ› ๏ธ +Compiling +๐Ÿ› ๏ธ +Compiling +๐Ÿ› ๏ธ +Compiling +๐Ÿ› ๏ธ +Compiling +๐Ÿ› ๏ธ +Compiling +๐Ÿ› ๏ธ +Compiling +๐Ÿ› ๏ธ +Compiling +๐Ÿ› ๏ธ +Compiling +๐Ÿ› ๏ธ +Compiling. +๐Ÿ› ๏ธ +Compiling +.[?25h[?25lโ”‚ +๐Ÿงช +Runningtests(1,0failures) +๐Ÿงช +Runningtests(1,0failures) +๐Ÿงช +Runningtests(1,0failures) +๐Ÿงช +Runningtests(1,0failures) +๐Ÿงช +Runningtests(1,0failures +)[?25h/Users/cameroncooke/.codex/worktrees/43f4/XcodeBuildMCP/example_projects/iOS_Calculator/CalculatorAppTests/CalculatorAppTests.swift:52: error: XCTAssertEqual failed: ("0") is not equal to ("999") - This test should fail - display should be 0, not 999 +[?25lโ”‚ +๐Ÿงช +Runningtests(13,1failure) +๐Ÿงช +Runningtests(13,1failure) +๐Ÿงช +Runningtests(13,1failure) +๐Ÿงช +Runningtests(21,1failure) +๐Ÿงช +Runningtests(21,1failure) +๐Ÿงช +Runningtests(21,1failure) +๐Ÿงช +Runningtests(21,1failure) +๐Ÿงช +Runningtests(21,1failure) +๐Ÿงช +Runningtests(21,1failure). +๐Ÿงช +Runningtests(21,1failure). +๐Ÿงช +Runningtests(21,1failure). +๐Ÿงช +Runningtests(21,1failure). +๐Ÿงช +Runningtests(21,1failure). +๐Ÿงช +Runningtests(21,1failure). +๐Ÿงช +Runningtests(21,1failure). +๐Ÿงช +Runningtests(21,1failure). +๐Ÿงช +Runningtests(21,1failure).. +๐Ÿงช +Runningtests(21,1failure).. +๐Ÿงช +Runningtests(21,1failure).. +๐Ÿงช +Runningtests(21,1failure).. +๐Ÿงช +Runningtests(21,1failure).. +๐Ÿงช +Runningtests(21,1failure).. +๐Ÿงช +Runningtests(21,1failure).. +๐Ÿงช +Runningtests(21,1failure).. +๐Ÿงช +Runningtests(21,1failure)... +๐Ÿงช +Runningtests(21,1failure)... +๐Ÿงช +Runningtests(21,1failure)... +๐Ÿงช +Runningtests(21,1failure)... +๐Ÿงช +Runningtests(21,1failure)... +๐Ÿงช +Runningtests(21,1failure)... +๐Ÿงช +Runningtests(21,1failure)... +๐Ÿงช +Runningtests(21,1failure)... +๐Ÿงช +Runningtests(21,1failure)... +๐Ÿงช +Runningtests(21,1failure) +๐Ÿงช +Runningtests(21,1failure) +๐Ÿงช +Runningtests(21,1failure) +๐Ÿงช +Runningtests(21,1failure) +๐Ÿงช +Runningtests(21,1failure) +๐Ÿงช +Runningtests(21,1failure) +๐Ÿงช +Runningtests(21,1failure) +๐Ÿงช +Runningtests(21,1failure) +๐Ÿงช +Runningtests(21,1failure). +๐Ÿงช +Runningtests(21,1failure). +๐Ÿงช +Runningtests(21,1failure). +๐Ÿงช +Runningtests(21,1failure). +๐Ÿงช +Runningtests(21,1failure). +๐Ÿงช +Runningtests(21,1failure). +๐Ÿงช +Runningtests(21,1failure). +๐Ÿงช +Runningtests(21,1failure). +๐Ÿงช +Runningtests(21,1failure).. +๐Ÿงช +Runningtests(21,1failure).. +๐Ÿงช +Runningtests(21,1failure).. +๐Ÿงช +Runningtests(21,1failure).. +๐Ÿงช +Runningtests(21,1failure).. +๐Ÿงช +Runningtests(21,1failure).. +๐Ÿงช +Runningtests(21,1failure).. +๐Ÿงช +Runningtests(21,1failure).. +๐Ÿงช +Runningtests(21,1failure)... +๐Ÿงช +Runningtests(21,1failure)... +๐Ÿงช +Runningtests(21,1failure)... +๐Ÿงช +Runningtests(21,1failure)... +๐Ÿงช +Runningtests(21,1failure)... +๐Ÿงช +Runningtests(21,1failure)... +๐Ÿงช +Runningtests(21,1failure)... +๐Ÿงช +Runningtests(21,1failure)... +๐Ÿงช +Runningtests(21,1failure)... +๐Ÿงช +Runningtests(21,1failure) +๐Ÿงช +Runningtests(21,1failure) +๐Ÿงช +Runningtests(21,1failure) +๐Ÿงช +Runningtests(21,1failure) +๐Ÿงช +Runningtests(21,1failure) +๐Ÿงช +Runningtests(21,1failure) +๐Ÿงช +Runningtests(21,1failure) +๐Ÿงช +Runningtests(21,1failure) +๐Ÿงช +Runningtests(21,1failure). +๐Ÿงช +Runningtests(21,1failure). +๐Ÿงช +Runningtests(21,1failure). +๐Ÿงช +Runningtests(21,1failure). +๐Ÿงช +Runningtests(21,1failure). +๐Ÿงช +Runningtests(21,1failure). +๐Ÿงช +Runningtests(21,1failure). +๐Ÿงช +Runningtests(21,1failure). +๐Ÿงช +Runningtests(21,1failure).. +๐Ÿงช +Runningtests(21,1failure).. +๐Ÿงช +Runningtests(21,1failure).. +๐Ÿงช +Runningtests(21,1failure).. +๐Ÿงช +Runningtests(21,1failure).. +๐Ÿงช +Runningtests(21,1failure).. +๐Ÿงช +Runningtests(21,1failure).. +๐Ÿงช +Runningtests(21,1failure).. +๐Ÿงช +Runningtests(21,1failure)... +๐Ÿงช +Runningtests(21,1failure)... +๐Ÿงช +Runningtests(21,1failure)... +๐Ÿงช +Runningtests(21,1failure)... +๐Ÿงช +Runningtests(21,1failure)... +๐Ÿงช +Runningtests(21,1failure)... +๐Ÿงช +Runningtests(21,1failure)... +๐Ÿงช +Runningtests(21,1failure)... +๐Ÿงช +Runningtests(21,1failure)... +๐Ÿงช +Runningtests(21,1failure) +๐Ÿงช +Runningtests(21,1failure) +๐Ÿงช +Runningtests(21,1failure) +๐Ÿงช +Runningtests(21,1failure) +๐Ÿงช +Runningtests(21,1failure) +๐Ÿงช +Runningtests(21,1failure) +๐Ÿงช +Runningtests(21,1failure) +๐Ÿงช +Runningtests(21,1failure) +๐Ÿงช +Runningtests(21,1failure). +๐Ÿงช +Runningtests(21,1failure). +๐Ÿงช +Runningtests(21,1failure). +๐Ÿงช +Runningtests(21,1failure). +๐Ÿงช +Runningtests(21,1failure). +๐Ÿงช +Runningtests(21,1failure). +๐Ÿงช +Runningtests(21,1failure). +๐Ÿงช +Runningtests(21,1failure). +๐Ÿงช +Runningtests(21,1failure).. +๐Ÿงช +Runningtests(21,1failure).. +๐Ÿงช +Runningtests(21,1failure).. +๐Ÿงช +Runningtests(21,1failure).. +๐Ÿงช +Runningtests(21,1failure).. +๐Ÿงช +Runningtests(21,1failure).. +๐Ÿงช +Runningtests(21,1failure).. +๐Ÿงช +Runningtests(21,1failure).. +๐Ÿงช +Runningtests(21,1failure)... +๐Ÿงช +Runningtests(21,1failure)... +๐Ÿงช +Runningtests(21,1failure)... +๐Ÿงช +Runningtests(21,1failure)... +๐Ÿงช +Runningtests(21,1failure)... +๐Ÿงช +Runningtests(21,1failure)... +๐Ÿงช +Runningtests(21,1failure)... +๐Ÿงช +Runningtests(21,1failure)... +๐Ÿงช +Runningtests(21,1failure)... +๐Ÿงช +Runningtests(21,1failure) +๐Ÿงช +Runningtests(21,1failure) +๐Ÿงช +Runningtests(21,1failure) +๐Ÿงช +Runningtests(21,1failure) +๐Ÿงช +Runningtests(21,1failure) +๐Ÿงช +Runningtests(21,1failure) +๐Ÿงช +Runningtests(21,1failure) +๐Ÿงช +Runningtests(21,1failure) +๐Ÿงช +Runningtests(21,1failure). +๐Ÿงช +Runningtests(21,1failure). +๐Ÿงช +Runningtests(21,1failure). +๐Ÿงช +Runningtests(21,1failure). +๐Ÿงช +Runningtests(21,1failure). +๐Ÿงช +Runningtests(21,1failure). +๐Ÿงช +Runningtests(21,1failure). +๐Ÿงช +Runningtests(21,1failure). +๐Ÿงช +Runningtests(21,1failure).. +๐Ÿงช +Runningtests(21,1failure).. +๐Ÿงช +Runningtests(21,1failure).. +๐Ÿงช +Runningtests(21,1failure).. +๐Ÿงช +Runningtests(21,1failure).. +๐Ÿงช +Runningtests(21,1failure).. +๐Ÿงช +Runningtests(21,1failure).. +๐Ÿงช +Runningtests(21,1failure).. +๐Ÿงช +Runningtests(21,1failure)... +๐Ÿงช +Runningtests(21,1failure)... +๐Ÿงช +Runningtests(21,1failure)... +๐Ÿงช +Runningtests(21,1failure)... +๐Ÿงช +Runningtests(21,1failure)... +๐Ÿงช +Runningtests(21,1failure)... +๐Ÿงช +Runningtests(21,1failure)... +๐Ÿงช +Runningtests(21,1failure)... +๐Ÿงช +Runningtests(21,1failure)... +๐Ÿงช +Runningtests(21,1failure) +๐Ÿงช +Runningtests(21,1failure) +๐Ÿงช +Runningtests(21,1failure) +๐Ÿงช +Runningtests(21,1failure) +๐Ÿงช +Runningtests(21,1failure) +๐Ÿงช +Runningtests(21,1failure) +๐Ÿงช +Runningtests(21,1failure) +๐Ÿงช +Runningtests(21,1failure) +๐Ÿงช +Runningtests(21,1failure). +๐Ÿงช +Runningtests(21,1failure). +๐Ÿงช +Runningtests(21,1failure). +๐Ÿงช +Runningtests(21,1failure). +๐Ÿงช +Runningtests(21,1failure). +๐Ÿงช +Runningtests(21,1failure). +๐Ÿงช +Runningtests(21,1failure). +๐Ÿงช +Runningtests(21,1failure). +๐Ÿงช +Runningtests(21,1failure).. +๐Ÿงช +Runningtests(21,1failure).. +๐Ÿงช +Runningtests(21,1failure).. +๐Ÿงช +Runningtests(21,1failure).. +๐Ÿงช +Runningtests(21,1failure).. +๐Ÿงช +Runningtests(21,1failure).. +๐Ÿงช +Runningtests(21,1failure).. +๐Ÿงช +Runningtests(21,1failure).. +๐Ÿงช +Runningtests(21,1failure)... +๐Ÿงช +Runningtests(21,1failure)... +๐Ÿงช +Runningtests(21,1failure).. +.[?25hFailed Tests +CalculatorAppTests +โœ— testCalculatorServiceFailure (0.009 seconds) +โ””โ”€ XCTAssertEqual failed: ("0") is not equal to ("999") - This test should fail - display should be 0, not 999 (/Users/cameroncooke/.codex/worktrees/43f4/XcodeBuildMCP/example_projects/iOS_Calculator/CalculatorAppTests/CalculatorAppTests.swift:52) +Test Summary +Total: 21 Passed: 20 Failed: 1 Skipped: 0 Duration: 29.76s \ No newline at end of file diff --git a/docs/CLI.md b/docs/CLI.md index 576ff47a..73e10607 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -77,6 +77,34 @@ xcodebuildmcp simulator launch-app --simulator-id --bundle-id io.sentry.M xcodebuildmcp simulator build-and-run --scheme MyApp --project-path ./MyApp.xcodeproj ``` +### Human-readable build-and-run output + +For xcodebuild-backed build-and-run tools: + +- CLI text mode prints a durable preflight block first +- interactive terminals then show active phases as live replace-in-place updates +- warnings, errors, failures, summaries, and next steps are durable output +- success output order is: front matter -> runtime state/diagnostics -> summary -> execution-derived footer -> next steps +- failed structured xcodebuild runs do not render next steps +- compiler/build diagnostics should be grouped into a readable failure block before the failed summary +- the final footer should only contain execution-derived values such as app path, bundle ID, app ID, or process ID +- requested values like scheme, project/workspace, configuration, and platform stay in front matter and should not be repeated later +- when the tool computes a concrete value during execution, prefer showing it directly in the footer instead of relegating it to a hint or redundant next step + +For example, a successful build-and-run footer should prefer: + +```text +โœ… Build & Run complete + + โ”” App Path: /tmp/.../MyApp.app +``` + +rather than forcing the user to run another command just to retrieve a value the tool already knows. + +MCP uses the same human-readable formatting semantics, but buffers the rendered output instead of streaming it to stdout live. It is the same section model and ordering, just a different sink. + +`--output json` is still streamed JSONL events, not the human-readable section format. + ### Log Capture Workflow ```bash @@ -97,8 +125,15 @@ xcodebuildmcp simulator test --scheme MyAppTests --project-path ./MyApp.xcodepro # Run with specific simulator xcodebuildmcp simulator test --scheme MyAppTests --simulator-name "iPhone 17 Pro" + +# Run with pre-resolved test discovery and live progress +xcodebuildmcp simulator test --json '{"workspacePath":"./MyApp.xcworkspace","scheme":"MyApp","simulatorName":"iPhone 17 Pro","progress":true,"extraArgs":["-only-testing:MyAppTests"]}' ``` +Simulator test output now pre-resolves concrete Swift XCTest and Swift Testing cases when it can, then streams filtered milestones for package resolution, compilation, and test execution plus a grouped failure summary instead of raw `xcodebuild` noise. + +For reproducible performance comparisons against Flowdeck CLI, see [dev/simulator-test-benchmark.md](dev/simulator-test-benchmark.md). + For a full list of workflows and tools, see [TOOLS-CLI.md](TOOLS-CLI.md). ## Configuration diff --git a/docs/TOOLS-CLI.md b/docs/TOOLS-CLI.md index af055252..89834f49 100644 --- a/docs/TOOLS-CLI.md +++ b/docs/TOOLS-CLI.md @@ -206,4 +206,4 @@ XcodeBuildMCP provides 76 canonical tools organized into 14 workflow groups. --- -*This documentation is automatically generated by `scripts/update-tools-docs.ts` from the tools manifest. Last updated: 2026-03-16T22:17:55.782Z UTC* +*This documentation is automatically generated by `scripts/update-tools-docs.ts` from the tools manifest. Last updated: 2026-03-20T16:33:04.757Z UTC* diff --git a/docs/TOOLS.md b/docs/TOOLS.md index 4631bf87..2f84f477 100644 --- a/docs/TOOLS.md +++ b/docs/TOOLS.md @@ -222,4 +222,4 @@ This document lists MCP tool names as exposed to MCP clients. XcodeBuildMCP prov --- -*This documentation is automatically generated by `scripts/update-tools-docs.ts` from the tools manifest. Last updated: 2026-03-16T22:17:55.782Z UTC* +*This documentation is automatically generated by `scripts/update-tools-docs.ts` from the tools manifest. Last updated: 2026-03-20T16:33:04.757Z UTC* diff --git a/docs/dev/QUERY_TOOL_FORMAT_SPEC.md b/docs/dev/QUERY_TOOL_FORMAT_SPEC.md new file mode 100644 index 00000000..2f60e18a --- /dev/null +++ b/docs/dev/QUERY_TOOL_FORMAT_SPEC.md @@ -0,0 +1,123 @@ +# Query Tool Formatting Spec + +## Goal + +Make all xcodebuild query tools (list-schemes, show-build-settings, get-app-path variants) use the same visual UX as pipeline-backed build/test tools: front matter, structured errors, clean results, manifest-driven next steps. + +These tools do NOT need the full streaming pipeline (no parser, no run-state, no renderers). They run a single short-lived xcodebuild command and return a result. But they must share the same visual language. + +## Target output format + +### Happy path + +``` +๐Ÿ” List Schemes + + Workspace: example_projects/iOS_Calculator/CalculatorApp.xcworkspace + +Schemes: + - CalculatorApp + - CalculatorAppFeature + +Next steps: +1. Build for simulator: xcodebuildmcp simulator build ... +``` + +``` +๐Ÿ” Show Build Settings + + Scheme: CalculatorApp + Workspace: example_projects/iOS_Calculator/CalculatorApp.xcworkspace + + + +Next steps: +1. Build for macOS: ... +``` + +``` +๐Ÿ” Get App Path + + Scheme: CalculatorApp + Workspace: example_projects/iOS_Calculator/CalculatorApp.xcworkspace + Platform: iOS Simulator + Simulator: iPhone 17 + + โ”” App Path: /path/to/CalculatorApp.app + +Next steps: +1. Get bundle ID: ... +``` + +### Sad path + +``` +๐Ÿ” Get App Path + + Scheme: NONEXISTENT + Workspace: example_projects/iOS_Calculator/CalculatorApp.xcworkspace + Platform: iOS Simulator + +Errors (1): + + โœ— The workspace named "CalculatorApp" does not contain a scheme named "NONEXISTENT". + +โŒ Query failed. +``` + +No raw xcodebuild noise (timestamps, PIDs, result bundle paths). No next steps on failure. + +## Implementation approach + +### Shared helper: `formatQueryPreflight` + +Extend `formatToolPreflight` in `src/utils/build-preflight.ts` to support query operations. Add operation types: `'List Schemes'`, `'Show Build Settings'`, `'Get App Path'`. Make `configuration` and `platform` optional (query tools may not have them). + +Use emoji `๐Ÿ”` (U+1F50D) for all query operations. + +### Shared helper: `parseXcodebuildError` + +Create a small utility to extract clean error messages from raw xcodebuild stderr/output. Strip: +- Timestamp lines (`2026-03-21 13:42:...`) +- Result bundle lines (`Writing error result bundle to ...`) +- PID noise + +Keep only `xcodebuild: error: ` lines, cleaned to just ``. + +### Error formatting + +Use the same `Errors (N):` grouped block format with `โœ—` prefix. Reuse `formatGroupedCompilerErrors` or a lightweight equivalent. + +### Result formatting + +- `list_schemes`: List schemes as ` - SchemeName` lines under a `Schemes:` heading +- `show_build_settings`: Raw build settings output (already structured) +- `get_*_app_path`: Use the tree format (`โ”” App Path: /path/to/app`) matching the build-run-result footer + +### Next steps + +Continue using `nextStepParams` and let `postProcessToolResponse` resolve manifest templates. No change needed. + +### Error response + +On failure, return `isError: true` with no next steps (consistent with pipeline tools). + +## Tools to migrate + +1. `src/mcp/tools/project-discovery/list_schemes.ts` +2. `src/mcp/tools/project-discovery/show_build_settings.ts` +3. `src/mcp/tools/simulator/get_sim_app_path.ts` +4. `src/mcp/tools/macos/get_mac_app_path.ts` +5. `src/mcp/tools/device/get_device_app_path.ts` + +## Rules + +- No full pipeline (no startBuildPipeline, no createPendingXcodebuildResponse) +- Use formatToolPreflight (extended) for front matter +- Parse xcodebuild errors cleanly +- Strip raw xcodebuild noise from error output +- Use `โœ—` grouped error block for failures +- Use `โŒ Query failed.` as the failure summary (not tool-specific messages) +- Next steps only on success +- Update existing tests to match new output format +- All tests must pass, no regressions diff --git a/docs/dev/STRUCTURED_XCODEBUILD_EVENTS_PLAN.md b/docs/dev/STRUCTURED_XCODEBUILD_EVENTS_PLAN.md new file mode 100644 index 00000000..3c271b82 --- /dev/null +++ b/docs/dev/STRUCTURED_XCODEBUILD_EVENTS_PLAN.md @@ -0,0 +1,1165 @@ +# Structured xcodebuild events plan + +## Goal + +Move every xcodebuild-backed tool in XcodeBuildMCP to a single structured streaming pipeline. + +That pipeline must: + +- parse xcodebuild output into structured events in real time +- stream those events immediately instead of waiting for command completion +- drive human-readable streaming output for MCP and CLI text mode +- drive streamed JSONL output for CLI JSON mode +- support manifest-driven next steps at the end of the stream + +The source of truth is the structured event stream, not formatted text. + +## Current status + +As of March 20, 2026: + +- shared xcodebuild event types, parser, run-state layer, and renderer set exist +- simulator, device, and macOS pure build tools use the pending pipeline model +- `build_run_macos` and `build_run_sim` are fully migrated to the canonical single-pipeline pattern +- CLI JSONL streaming exists for pipeline-backed tools +- MCP human-readable output is buffered from the same renderer family +- all error events (file-located and non-file) are grouped and rendered consistently + +The remaining migration work is `build_run_device` and any test tool cleanup. + +## Architecture principle + +One model, one pipeline. Tools do not own rendering or formatting. They emit structured events into a shared pipeline, and the shared renderer family produces all user-visible output. + +- CLI is a pure stream consumer +- MCP buffers the same streamed human-readable output and returns it in one final chunk +- there is no second presentation path that re-renders or replays final text after the stream + +The only runtime difference is the output sink: + +- CLI sink: stdout/stderr +- MCP sink: in-memory buffer returned as `ToolResponse.content` + +Not the rendering logic. + +## Requirements + +These are requirements, not nice-to-haves. + +### 1. All xcodebuild-derived tools use the same model + +This applies to all tools whose primary execution path is xcodebuild-derived, including: + +- simulator build tools +- simulator test tools +- device build tools +- device test tools +- macOS build tools +- macOS test tools +- any other tool that surfaces xcodebuild phases, warnings, errors, or summaries + +### 2. Streaming is required + +Tools must emit output as soon as they have meaningful state to report. + +They should also emit an immediate startup event/output block before xcodebuild produces its first meaningful line. In most cases that startup output should echo the important input parameters so the caller can immediately see what is being attempted. + +We do not want to wait until the end of execution and then dump a final result. + +This matters for: + +- agent responsiveness +- long-running builds +- package resolution visibility +- compile phase visibility +- test execution visibility +- reducing timeouts and dead-air during execution + +### 3. MCP output changes too + +The plan does require changing the output shape used by xcodebuild-backed MCP tools. + +We do not need to preserve the current human-readable MCP output contract for these tools. + +The MCP API contract is still the same: + +- same tools +- same arguments +- same MCP transport + +But the streamed output content from xcodebuild-backed tools can and should change. + +### 4. CLI JSON output is streamed JSONL + +CLI JSON output should be one mode only: + +- streamed JSONL + +We do not need multiple JSON modes like: + +- final JSON blob +- JSON events +- NDJSON as a separate concept + +For this plan, CLI JSON output means line-delimited streamed JSON events. + +### 5. MCP remains human-readable + +MCP should keep receiving human-readable streamed output. + +It does not need a JSON mode. + +The important point is that the human-readable MCP output must now be rendered from the same structured event stream that powers CLI JSONL and CLI text mode. + +### 6. We are not building a full-screen UI + +We are not building a full-screen terminal application. + +The target is the hybrid streaming approach we already used successfully for the simulator test tool: + +- live transient status updates where appropriate +- durable streamed lines for warnings, errors, failures, and summaries +- clear final summary output + +Visual parity with Flowdeck matters less than data-model parity and responsiveness. + +## Desired behavior + +All xcodebuild-backed tools should expose the same kind of live milestones and diagnostics. + +Examples: + +- tool starts -> stream an immediate scoped start event with the key input params +- package resolution starts -> stream a structured status event +- compiling starts -> stream a structured status event +- warning found -> stream a structured warning event +- compiler error found -> stream a structured error event +- tests begin -> stream a structured status event +- test progress changes -> stream a structured progress event +- test failure found -> stream a structured failure event +- run completes -> stream a structured summary event +- next steps available -> stream a final next-steps event or final rendered next-steps block + +## Output modes + +## MCP mode + +MCP mode should receive streamed human-readable output rendered from the structured event stream. + +That includes: + +- milestones +- warnings +- errors +- test progress +- summaries +- final next steps + +## CLI text mode + +CLI text mode should render the same stream into terminal-friendly output. + +That includes: + +- Clack-driven transient progress updates where useful +- durable diagnostics and summaries +- final next steps + +## CLI JSON mode + +CLI JSON mode should emit structured JSONL. + +Each line is one event. + +Example: + +```json +{"type":"status","operation":"TEST","stage":"RESOLVING_PACKAGES","message":"Resolving Package Graph...","timestamp":"2026-03-17T08:27:34.175Z"} +{"type":"status","operation":"TEST","stage":"COMPILING","message":"Compiling...","timestamp":"2026-03-17T08:27:39.834Z"} +{"type":"status","operation":"TEST","stage":"RUN_TESTS","message":"Running tests...","timestamp":"2026-03-17T08:28:41.875Z"} +{"type":"test-progress","operation":"TEST","completed":7,"failed":0,"skipped":0,"timestamp":"2026-03-17T08:28:50.101Z"} +{"type":"summary","operation":"TEST","status":"FAILED","totalTests":21,"passedTests":20,"failedTests":1,"skippedTests":0,"durationMs":28080,"timestamp":"2026-03-17T08:28:59.000Z"} +``` + +## Architecture design + +The architecture should be explicitly layered. + +## Concrete architecture flow + +This section describes the architecture using the actual components that exist in the codebase today. + +The important shape is: + +- one ordered event stream +- one run-state / aggregation layer +- one renderer family +- different sinks only at the end + +The flow is linear until rendering: + +```text +tool logic +-> startBuildPipeline(...) +-> XcodebuildPipeline +-> parser + run-state +-> ordered structured events +-> renderer fork +-> MCP buffer or CLI stdout +``` + +More concretely: + +1. tool logic creates the pipeline with `startBuildPipeline(...)` from `src/utils/xcodebuild-pipeline.ts` +2. `startBuildPipeline(...)` creates an `XcodebuildPipeline` and emits the initial `start` event +3. raw `xcodebuild` stdout/stderr chunks are sent into `createXcodebuildEventParser(...)` from `src/utils/xcodebuild-event-parser.ts` +4. the parser emits structured events into `createXcodebuildRunState(...)` from `src/utils/xcodebuild-run-state.ts` +5. tool-owned events such as preflight, app-path, install, launch, and post-build errors also enter that same run-state through `pipeline.emitEvent(...)` +6. run-state dedupes, orders, aggregates, and forwards each accepted event to the configured renderers +7. renderers consume the same event stream: + - `src/utils/renderers/mcp-renderer.ts` + - `src/utils/renderers/cli-text-renderer.ts` + - `src/utils/renderers/cli-jsonl-renderer.ts` +8. at finalize time, the pipeline emits the final summary and final next-steps event in the same stream order + +### Mermaid diagram + +```mermaid +flowchart LR + A[Tool logic
build_sim / build_run_sim / test_sim / etc.] --> B[startBuildPipeline
src/utils/xcodebuild-pipeline.ts] + + B --> C[XcodebuildPipeline
src/utils/xcodebuild-pipeline.ts] + + C --> D[createXcodebuildEventParser
src/utils/xcodebuild-event-parser.ts] + C --> E[createXcodebuildRunState
src/utils/xcodebuild-run-state.ts] + + F[xcodebuild stdout/stderr] --> D + G[tool-emitted events
pipeline.emitEvent(...)] --> E + + D --> E + + E --> H[ordered structured event stream] + + H --> I[MCP renderer
src/utils/renderers/mcp-renderer.ts] + H --> J[CLI text renderer
src/utils/renderers/cli-text-renderer.ts] + H --> K[CLI JSONL renderer
src/utils/renderers/cli-jsonl-renderer.ts] + + I --> L[mcpRenderer.getContent()] + L --> M[ToolResponse.content] + + J --> N[process.stdout text stream] + K --> O[process.stdout JSONL stream] +``` + +### Event order within one run + +Within a single xcodebuild-backed tool run, the desired event order is: + +```text +start +-> parsed xcodebuild milestones / diagnostics / progress +-> tool-emitted post-build notices or errors +-> summary +-> next-steps +``` + +That ordering matters because: + +- summaries must describe the final known run state +- next steps must be rendered from the same shared stream +- MCP and CLI should differ only in sink behavior, not in event order or formatting ownership + +## 1. xcodebuild execution layer + +Responsibility: + +- launch xcodebuild +- stream stdout/stderr chunks as they arrive +- support single-phase and multi-phase execution +- attach command context such as operation type and phase + +Examples: + +- `build` +- `test` +- `build-for-testing` +- `test-without-building` + +This layer should not format user-facing output. + +It should only execute commands and feed chunks into the parser pipeline. + +## 2. structured event parser layer + +Responsibility: + +- consume stdout/stderr incrementally +- parse lines into semantic events +- emit events immediately when they are recognized +- combine parser-derived events with tool-emitted startup/context events + +This is the core of the system. + +It should understand: + +- package resolution milestones +- compile/link milestones +- build warnings/errors +- test start +- test case progress +- test failures +- totals and summaries +- multi-phase continuation rules + +This is where phase-aware behavior belongs. + +For example, in a two-phase simulator test run: + +- phase 1 may emit `RESOLVING_PACKAGES` and `COMPILING` +- phase 2 may continue directly into `RUN_TESTS` +- the parser/state model should avoid regressing the visible timeline back to an earlier stage unless the new run genuinely restarted + +## 3. shared run-state / aggregation layer + +Responsibility: + +- maintain the current known state of the run +- dedupe and order milestones +- aggregate progress counts +- group failures and diagnostics +- compute final summary information +- retain enough state for end-of-run rendering + +This layer exists so that renderers do not need to reconstruct state themselves. + +Examples of tracked state: + +- current operation +- echoed input params / initial tool context +- latest stage +- seen milestones +- warnings/errors +- discovered tests +- completed/failed/skipped counts +- failure details by target/test +- wall-clock duration +- final success/failure state + +## 4. renderer layer + +Responsibility: + +- consume structured events plus shared run-state +- produce mode-specific output + +Renderers required: + +### MCP human-readable renderer + +- turns events into streamed text blocks/items for MCP responses +- remains human-readable +- appends manifest-driven next steps at the end +- buffers the rendered stream so the final `ToolResponse.content` is just the captured stream output +- does not maintain a separate final formatting path + +### CLI text renderer + +- turns events into streamed terminal output +- uses Clack where transient updates help +- writes durable diagnostics as normal lines +- appends next steps at the end +- is the only text presentation path for CLI xcodebuild-backed tools +- does not rely on final `ToolResponse.content` replay + +### CLI JSONL renderer + +- serializes each structured event as one JSON line +- does not invent a separate event model +- appends next steps as structured final events or final rendered line events, depending on the chosen schema + +## 5. tool integration layer + +Responsibility: + +- tool decides what command(s) to run +- tool provides context such as platform, build vs test, selectors, preflight data +- tool selects the shared xcodebuild execution pipeline +- tool does not own custom raw parsing logic + +This is the layer where simulator/device/macOS differences belong. + +## Canonical reference pattern to copy + +The canonical reference implementations are: + +- `src/mcp/tools/macos/build_run_macos.ts` โ€” simplest build-and-run (no simulator/device steps) +- `src/mcp/tools/simulator/build_run_sim.ts` โ€” build-and-run with simulator post-build steps (boot, install, launch) + +Use these files as templates for remaining build-and-run migrations. Do not invent new patterns. + +### Concrete API reference + +#### Functions to use + +| Function | Module | Purpose | +|---|---|---| +| `startBuildPipeline` | `src/utils/xcodebuild-pipeline.ts` | Create pipeline, emit start event | +| `executeXcodeBuildCommand` | `src/utils/build/index.ts` | Run xcodebuild with pipeline attached | +| `createPendingXcodebuildResponse` | `src/utils/xcodebuild-output.ts` | Return a pending response (ALL return paths) | +| `emitPipelineNotice` | `src/utils/xcodebuild-output.ts` | Emit post-build progress into pipeline | +| `emitPipelineError` | `src/utils/xcodebuild-output.ts` | Emit post-build failure into pipeline | +| `formatToolPreflight` | `src/utils/build-preflight.ts` | Format the front-matter preflight block | + +#### Functions NOT to use in migrated tools + +These are transitional helpers from the old architecture. Do not use them in newly migrated tools: + +| Function | Why not | +|---|---| +| `finalizeBuildPhase` | Finalizes pipeline too early; build-and-run tools must keep the pipeline open through post-build steps | +| `createPostBuildError` | Appends content after pipeline finalization; use `emitPipelineError` + `createPendingXcodebuildResponse` instead | +| `appendStructuredEvents` | Appends events after finalization; emit events into the pipeline before finalization instead | +| `createCompletionStatusEvent` | Creates a status event outside the pipeline; use `tailEvents` in `createPendingXcodebuildResponse` instead | +| `finalizeBuildPipelineResult` | Old finalization path; use `createPendingXcodebuildResponse` which defers finalization to `postProcessToolResponse` | + +### Canonical shape + +For a normal build-and-run tool, the pattern to copy is: + +1. call `startBuildPipeline(...)` +2. run `executeXcodeBuildCommand(..., started.pipeline)` +3. if build fails, return `createPendingXcodebuildResponse(started, buildResult, { errorFallbackPolicy: 'if-no-structured-diagnostics' })` +4. keep the same pipeline open for post-build steps +5. emit post-build progress with `emitPipelineNotice(...)` using `code: 'build-run-step'` +6. emit post-build failures with `emitPipelineError(...)` using `Failed to : ` message format +7. do not append success/error text after pipeline finalization +8. do not create a second status/completion event path outside the pipeline +9. return one `createPendingXcodebuildResponse(...)` with `tailEvents` for the success footer +10. let the shared finalization path own summary and final next-steps ordering + +### Pending response lifecycle + +The tool never finalizes the pipeline directly. Instead: + +1. tool returns `createPendingXcodebuildResponse(started, response, options)` โ€” this stores the pipeline in `_meta.pendingXcodebuild` +2. `postProcessToolResponse` in `src/runtime/tool-invoker.ts` detects the pending state via `isPendingXcodebuildResponse` +3. it resolves manifest-driven next-step templates against the response's `nextStepParams` +4. it calls `finalizePendingXcodebuildResponse` which finalizes the pipeline, emitting summary + tail events + next-steps in correct order +5. the finalized pipeline content becomes the final `ToolResponse.content` + +Key options on `createPendingXcodebuildResponse`: + +- `errorFallbackPolicy: 'if-no-structured-diagnostics'` โ€” for build failures, only include raw xcodebuild output if the parser found no structured errors (avoids duplicating errors that are already in the grouped block) +- `tailEvents` โ€” events emitted after the summary but before next-steps (used for the `build-run-result` footer notice) + +### Minimal pseudocode pattern + +```ts +const started = startBuildPipeline({ + operation: 'BUILD', + toolName: 'build_run_', + params: { scheme, configuration, platform, preflight: preflightText }, + message: preflightText, +}); + +const buildResult = await executeXcodeBuildCommand(..., started.pipeline); +if (buildResult.isError) { + return createPendingXcodebuildResponse(started, buildResult, { + errorFallbackPolicy: 'if-no-structured-diagnostics', + }); +} + +// Post-build steps: emit notices for progress, errors for failures +emitPipelineNotice(started, 'BUILD', 'Resolving app path', 'info', { + code: 'build-run-step', + data: { step: 'resolve-app-path', status: 'started' }, +}); +// ... resolve ... +emitPipelineNotice(started, 'BUILD', 'App path resolved', 'success', { + code: 'build-run-step', + data: { step: 'resolve-app-path', status: 'succeeded', appPath }, +}); + +emitPipelineNotice(started, 'BUILD', 'Launching app', 'info', { + code: 'build-run-step', + data: { step: 'launch-app', status: 'started', appPath }, +}); +// ... launch ... + +if (!launchResult.success) { + emitPipelineError(started, 'BUILD', `Failed to launch app ${appPath}: ${launchResult.error}`); + return createPendingXcodebuildResponse(started, { content: [], isError: true }); +} + +return createPendingXcodebuildResponse( + started, + { content: [], isError: false, nextStepParams: { ... } }, + { + tailEvents: [{ + type: 'notice', + timestamp: new Date().toISOString(), + operation: 'BUILD', + level: 'success', + message: 'Build & Run complete', + code: 'build-run-result', + data: { scheme, platform, target, appPath, bundleId, launchState: 'requested' }, + }], + }, +); +``` + +### Post-build step notices + +Post-build workflow steps use structured `notice` events with specific codes: + +**`build-run-step` notices** โ€” drive transient CLI progress and durable MCP output: + +```ts +emitPipelineNotice(started, 'BUILD', 'Resolving app path', 'info', { + code: 'build-run-step', + data: { step: 'resolve-app-path', status: 'started' }, +}); +``` + +Available step names are defined in `BuildRunStepName` in `src/types/xcodebuild-events.ts`: + +- `resolve-app-path` โ€” resolving the built app bundle path +- `resolve-simulator` โ€” resolving simulator UUID from name +- `boot-simulator` โ€” booting the simulator +- `install-app` โ€” installing the app on simulator/device +- `extract-bundle-id` โ€” extracting the bundle ID from the app +- `launch-app` โ€” launching the app + +To add new step names: extend `BuildRunStepName` in `src/types/xcodebuild-events.ts` and add the label in `formatBuildRunStepLabel` in `src/utils/renderers/event-formatting.ts`. + +**`build-run-result` notice** โ€” drives the execution-derived footer: + +```ts +{ + type: 'notice', + code: 'build-run-result', + data: { scheme, platform, target, appPath, bundleId, launchState: 'requested' }, +} +``` + +This renders as the tree-formatted footer after the summary. Only include execution-derived values (appPath, bundleId, processId). Do not repeat front-matter values (scheme, platform, configuration). + +### Error message format convention + +All post-build error messages emitted via `emitPipelineError` must use the format: + +``` +Failed to : +``` + +Examples: + +- `Failed to get app path to launch: Could not extract app path from build settings.` +- `Failed to boot simulator: Device not found` +- `Failed to install app on simulator: Permission denied` +- `Failed to launch app /path/to/MyApp.app: App crashed on launch` + +Do not use `Error :` or other ad-hoc formats. + +### Rules to preserve when copying this pattern + +- keep the pipeline open until the tool genuinely knows the final state +- all user-visible post-build progress must become structured events +- use the pipeline as the only user-visible output path +- do not preserve legacy append/replay helpers โ€œjust in caseโ€ +- if a tool needs extra context, emit it as an event instead of formatting text later +- the tool function signature is `(params, executor) => Promise` โ€” no `executeXcodeBuildCommandFn` injection parameter + +## Locked human-readable output contract + +The current `build_run_macos` CLI/MCP presentation is now the formatting contract to preserve. + +This is not a suggestion. Future xcodebuild-backed build-and-run tools should copy this output structure unless there is a clear, user-approved reason to differ. + +### Canonical success and failure flows + +For xcodebuild-backed tools that follow the canonical human-readable contract, the output order is now locked. + +Successful runs must render: + +1. front matter +2. runtime state and durable diagnostics +3. summary +4. execution-derived footer +5. next steps + +Failed runs must render: + +1. front matter +2. runtime state and/or grouped diagnostics +3. summary + +Failed structured xcodebuild runs must not render next steps. + +### Canonical `build_run_macos` example + +Happy path shape: + +```text +๐Ÿš€ Build & Run + + Scheme: MCPTest + Project: example_projects/macOS/MCPTest.xcodeproj + Configuration: Debug + Platform: macOS + +โ€บ Linking + +โœ… Build succeeded. (โฑ๏ธ 6.8s) +โœ… Build & Run complete + + โ”” App Path: /tmp/xcodebuildmcp-macos-cli/Build/Products/Debug/MCPTest.app + +Next steps: +1. Interact with the launched app in the foreground +``` + +Sad path โ€” compiler error: + +```text +๐Ÿš€ Build & Run + + Scheme: MCPTest + Project: example_projects/macOS/MCPTest.xcodeproj + Configuration: Debug + Platform: macOS + +โ€บ Linking + +Compiler Errors (1): + + โœ— unterminated string literal + example_projects/macOS/MCPTest/ContentView.swift:16:18 + +โŒ Build failed. (โฑ๏ธ 4.0s) +``` + +Sad path โ€” non-file error (e.g. wrong scheme name, destination not found): + +```text +๐Ÿš€ Build & Run + + Scheme: CalculatorAPp + Workspace: example_projects/iOS_Calculator/CalculatorApp.xcworkspace + Configuration: Debug + Platform: iOS Simulator + Simulator: iPhone 17 + +Errors (1): + + โœ— The workspace named "CalculatorApp" does not contain a scheme named "CalculatorAPp". + +โŒ Build failed. (โฑ๏ธ 2.7s) +``` + +Sad path โ€” multi-line error (e.g. destination specifier not found): + +```text +๐Ÿš€ Build & Run + + Scheme: CalculatorApp + Workspace: example_projects/iOS_Calculator/CalculatorApp.xcworkspace + Configuration: Debug + Platform: iOS Simulator + Simulator: iPhone 22 + +Errors (1): + + โœ— Unable to find a device matching the provided destination specifier: + { platform:iOS Simulator, name:iPhone 22, OS:latest } + +โŒ Build failed. (โฑ๏ธ 60.7s) +``` + +These examples are the template for future xcodebuild-backed tool UX. + +### 1. Front matter is a durable section + +The start/preflight block is durable and emitted once at the beginning of the run. + +Its shape is: + +1. one blank line before the heading +2. a heading line such as `๐Ÿš€ Build & Run` +3. one blank line after the heading +4. indented detail lines such as scheme, project/workspace, configuration, platform + +Those values are request/preflight values. + +They belong in front matter, not in the final footer. + +### 2. There is one visual boundary before runtime state + +After front matter, there is one blank-line boundary before the runtime state begins. + +For CLI text mode, that means the first active phase update must not be butted directly against the last front-matter detail line. + +For MCP, the same sections are buffered in the same order. MCP does not get a different formatting model; it just buffers the rendered sections instead of streaming them live to stdout. + +### 3. Interactive CLI runtime state is transient + +In interactive CLI text mode: + +- active phases use Clack-driven replace-in-place updates +- active build/test steps should not be emitted as a sequence of durable milestone lines while they are still in progress + +Examples: + +- `Compiling...` +- `Linking...` +- `Resolving app path...` +- `Launching app...` + +This is the runtime-state area of the UI, not the durable log area. + +### 4. Durable lines are reserved for lasting information + +Durable streamed lines are appropriate for: + +- warnings +- errors +- test failures +- completed workflow checkpoints when we want the final stream to retain them +- final summary +- final footer +- next steps + +They are not the default for active phase updates in interactive CLI mode. + +### 5. The final footer is execution-derived only + +The footer after the summary should contain only values learned or confirmed during execution. + +Examples of acceptable footer fields: + +- app path +- bundle ID +- app ID +- process ID +- other runtime identifiers only if they were genuinely discovered during the run + +Examples of fields that must not be repeated in the footer if they were already shown in front matter: + +- scheme +- project/workspace path +- configuration +- platform +- target labels that are just restating the selected platform/context + +This also means we should prefer showing concrete derived values directly in the footer instead of relegating them to hints or next steps when the tool already knows them. + +For example: + +- if the tool resolved the built app path, show it in the footer +- do not keep a redundant "get app path" next step just to restate a value we already computed + +In other words: + +- front matter = requested configuration +- runtime state = currently active work +- footer = execution-derived result data +- next steps = remaining actions the user may want to take next + +### 6. Next steps are always last + +The human-readable order for a completed run is: + +1. front matter +2. runtime state / diagnostics +3. summary +4. execution-derived footer +5. next steps + +Nothing should render after next steps for that run. + +### 7. MCP uses the same semantics + +MCP does not get a different presentation contract. + +The only difference is the sink: + +- CLI text writes live to stdout +- MCP buffers the same rendered sections into `ToolResponse.content` + +That means formatting decisions should still be made once in the shared formatter/renderer family. + +MCP is downstream of the same human-readable event stream contract. It does not justify different section ordering, different footer contents, or different sad-path formatting. + +### 8. All errors get the same grouped rendering + +ALL error events are grouped and rendered as a structured block before the summary, regardless of whether they are file-located compiler errors or non-file errors (toolchain, scheme-not-found, tool-emitted). + +The renderers batch ALL error events and flush them as a single grouped section when the summary event arrives. There is no separate "immediate render" path for non-file errors. + +Heading rules: + +- If any error in the group has a file location: `Compiler Errors (N):` +- Otherwise: `Errors (N):` + +Each error renders as: + +- ` โœ— ` (first line) +- ` ` (if file-located) +- ` ` (if multi-line message, each subsequent line indented) + +### 9. Error event `message` field must not include severity prefix + +The `message` field on error events must contain the diagnostic text only, without `error:` or `fatal error:` prefix. The renderer adds the appropriate prefix when needed. + +- Correct: `message: "unterminated string literal"` +- Wrong: `message: "error: unterminated string literal"` + +The `rawLine` field preserves the original xcodebuild output verbatim. The parser (`parseBuildErrorDiagnostic` in `src/utils/xcodebuild-line-parsers.ts`) strips the severity prefix from `message` but keeps `rawLine` intact. + +The parser also accumulates indented continuation lines after a build error into the same error event's `message` (newline-separated). This handles multi-line xcodebuild errors like destination-not-found. + +### 10. JSON mode is not part of this contract + +CLI JSON mode remains streamed JSONL of the structured event stream. + +It should not be changed to mirror the human-readable section formatting. + +## Expected deviations for tools that do more than build-and-run + +Not every xcodebuild-backed tool is identical. Some tools need controlled deviations from the canonical build-and-run pattern. + +### Test tools + +Primary example: + +- `src/utils/test-common.ts` + +Test tools differ because they often need: + +- `operation: 'TEST'` instead of `BUILD` +- test discovery events +- test progress events +- test failure events +- multi-phase execution such as `build-for-testing` then `test-without-building` +- minimum-stage continuation rules between phases + +Those are valid deviations, but they should still preserve the same architectural rules: + +- same parser layer +- same run-state layer +- same renderer family +- same single finalization ownership +- same summary -> next-steps ordering + +What should differ for tests is event content and execution shape, not presentation ownership. + +### Pure build tools + +Examples: + +- `src/mcp/tools/simulator/build_sim.ts` +- `src/mcp/tools/device/build_device.ts` +- `src/mcp/tools/macos/build_macos.ts` +- `src/mcp/tools/utilities/clean.ts` + +These are simpler than the canonical build-and-run tool because they do not have install/launch steps. + +Their valid simplification is: + +- xcodebuild phase only +- no post-build notices beyond what is needed +- return pending response with manifest-driven next-step params + +They still follow the same finalization contract. + +### More complex build-and-run tools + +Migrated examples: + +- `src/mcp/tools/simulator/build_run_sim.ts` โ€” simulator build-and-run (fully migrated) + +Remaining: + +- `build_run_device` + +These need extra steps compared with `build_run_macos`, such as: + +- simulator/device lookup +- boot/install/launch sequencing +- bundle ID extraction +- platform-specific next-step params + +Those are valid workflow differences. They are handled by emitting more `build-run-step` notices into the same pipeline. They are not valid reasons to introduce a second output path, late content append logic, tool-specific final rendering, or replay of already-streamed output. + +The correct adaptation is: + +- keep the same pipeline structure +- emit more `notice` events with `code: 'build-run-step'` for each post-build step +- emit `error` events via `emitPipelineError` for post-build failures +- include execution-derived values in the `build-run-result` tail event +- finalize once at the end + +## Event model + +We need one shared event model that works for build and test tools. + +Example direction: + +```ts +type XcodebuildEvent = + | { + type: 'start'; + operation: 'BUILD' | 'TEST'; + toolName: string; + params: Record; + message: string; + timestamp: string; + } + | { + type: 'status'; + operation: 'BUILD' | 'TEST'; + stage: + | 'RESOLVING_PACKAGES' + | 'COMPILING' + | 'LINKING' + | 'RUN_TESTS' + | 'PREPARING_TESTS' + | 'ARCHIVING' + | 'COMPLETED' + | 'UNKNOWN'; + message: string; + timestamp: string; + } + | { + type: 'warning'; + operation: 'BUILD' | 'TEST'; + message: string; + location?: string; + rawLine: string; + timestamp: string; + } + | { + type: 'error'; + operation: 'BUILD' | 'TEST'; + message: string; + location?: string; + rawLine: string; + timestamp: string; + } + | { + type: 'test-discovery'; + operation: 'TEST'; + total: number; + tests: string[]; + truncated: boolean; + timestamp: string; + } + | { + type: 'test-progress'; + operation: 'TEST'; + completed: number; + failed: number; + skipped: number; + timestamp: string; + } + | { + type: 'test-failure'; + operation: 'TEST'; + target?: string; + suite?: string; + test?: string; + message: string; + location?: string; + durationMs?: number; + timestamp: string; + } + | { + type: 'summary'; + operation: 'BUILD' | 'TEST'; + status: 'SUCCEEDED' | 'FAILED'; + totalTests?: number; + passedTests?: number; + failedTests?: number; + skippedTests?: number; + durationMs?: number; + timestamp: string; + } + | { + type: 'next-steps'; + steps: Array<{ + label?: string; + tool?: string; + workflow?: string; + cliTool?: string; + params?: Record; + }>; + timestamp: string; + }; +``` + +The exact names can change, but the model needs these properties: + +- shared across tools +- streamable +- usable by both human-readable and JSONL renderers +- expressive enough for current simulator test behavior and future build behavior + +## Rollout plan + +## Phase 1: define the shared event and run-state model + +Deliverables: + +- shared event types +- shared aggregated run-state/report types +- shared emitter/collector interfaces + +Exit criteria: + +- build and test use cases both fit the model +- multi-phase test execution fits the model cleanly + +## Phase 2: factor current simulator test parsing into the shared pipeline + +Deliverables: + +- simulator test path emits shared events +- CLI text output is rendered from those events +- CLI JSON output emits JSONL from those events +- MCP human-readable output is rendered from those events + +Exit criteria: + +- simulator test no longer has a renderer-first architecture +- current simulator test UX is preserved or improved + +## Phase 3: migrate xcodebuild-backed build tools + +Deliverables: + +- simulator build tools use the same event pipeline +- macOS build tools use the same event pipeline +- device build tools use the same event pipeline +- warnings/errors/milestones are consistent across them + +Exit criteria: + +- build tools stream live milestones and diagnostics +- CLI text and CLI JSONL stay in sync because they share the same source events + +## Phase 4: migrate remaining test tools + +Deliverables: + +- device test tools use the shared event pipeline +- macOS test tools use the shared event pipeline +- grouped summaries and failure rendering are shared where possible + +Exit criteria: + +- no xcodebuild-backed test tool maintains a separate raw parsing stack + +## Phase 5: remove legacy duplicated formatting paths + +Deliverables: + +- old ad hoc parser/formatter branches removed +- output logic reduced to shared renderers +- next steps still appended through manifest-driven logic at stream end + +Exit criteria: + +- xcodebuild-backed tools share one parsing model and one rendering model family + +## Testing strategy + +## Unit tests + +- raw line to event parsing +- milestone ordering and dedupe +- warning/error parsing +- test-progress parsing +- failure parsing +- multi-phase continuation behavior + +## Integration tests + +- simulator build streams expected milestones +- simulator test streams expected milestones and failures +- CLI JSON mode emits valid JSONL in correct order +- MCP mode renders the same underlying run semantics in human-readable form + +## Benchmark checks + +Keep using the simulator benchmark harness to compare: + +- wall-clock duration +- time to first streamed milestone +- time to first streamed test progress +- parity of surfaced information vs Flowdeck + +## Design constraints + +To keep this practical: + +- no separate parser per tool unless the raw source is genuinely different +- no renderer-specific parsing logic +- no multiple JSON mode variants +- no buffering until completion when meaningful events are already known +- no attempt to preserve old MCP human-readable output shape for xcodebuild-backed tools + +## Status against rollout phases + +### Phase 1: define the shared event and run-state model + +Status: complete + +### Phase 2: factor current simulator test parsing into the shared pipeline + +Status: complete + +### Phase 3: migrate xcodebuild-backed build tools + +Status: mostly complete + +Notes: + +- pure build tools (simulator, device, macOS, clean) all use the pending pipeline model +- `build_run_macos` and `build_run_sim` are fully migrated to the canonical single-pipeline pattern +- `build_run_device` still needs migration + +### Phase 4: migrate remaining test tools + +Status: mostly complete + +Notes: + +- simulator, device, and macOS test flows are on the shared pipeline +- the remaining work is mainly cleanup, consistency, and removing transitional replay behavior + +### Phase 5: remove legacy duplicated formatting paths + +Status: in progress + +Notes: + +- pure build, clean, and two build-and-run flows no longer depend on final replay/re-render formatting +- CLI final printing no-ops for fully migrated xcodebuild responses +- once `build_run_device` is migrated, the transitional helpers (`finalizeBuildPhase`, `createPostBuildError`, `appendStructuredEvents`, `createCompletionStatusEvent`) can be deleted + +## Recommended immediate next steps + +1. migrate `build_run_device` using the same canonical pattern as `build_run_sim` +2. delete transitional helpers once no tools depend on them +3. finish any remaining test tool cleanup + +## Success criteria + +This work is successful when: + +- all xcodebuild-backed tools stream output as they learn new information +- MCP human-readable output and CLI text output are both rendered from the same structured source events +- CLI JSON mode streams JSONL from those same source events +- package resolution, compiling, warnings, errors, test progress, failures, and summaries are consistently surfaced across tools +- next steps are still appended at the end through manifest-driven logic +- future Flowdeck parity work happens by improving one shared event pipeline, not many separate formatter paths diff --git a/docs/dev/simulator-test-benchmark.md b/docs/dev/simulator-test-benchmark.md new file mode 100644 index 00000000..368cdf18 --- /dev/null +++ b/docs/dev/simulator-test-benchmark.md @@ -0,0 +1,83 @@ +# Simulator test benchmark + +This benchmark compares XcodeBuildMCP's simulator test command against Flowdeck CLI using the Calculator example project in the current worktree. + +## Prerequisites + +- `npm install` +- `npm run build` +- `flowdeck` available on `PATH` +- An `iPhone 17 Pro` simulator installed +- `/usr/bin/script` available (required so both tools run under a PTY and stream live progress) + +## Command + +```bash +npm run bench:test-sim -- --iterations 1 --mode warm +``` + +Options: + +- `--iterations `: repeat both tools `n` times +- `--mode warm|cold`: reuse or clear benchmark-owned derived data before each run + +## Exact commands used + +XcodeBuildMCP: + +```bash +./build/cli.js simulator test --json '{"workspacePath":"/example_projects/iOS_Calculator/CalculatorApp.xcworkspace","scheme":"CalculatorApp","simulatorName":"iPhone 17 Pro","useLatestOS":true,"extraArgs":["-only-testing:CalculatorAppTests"],"progress":true,"derivedDataPath":"/derived-data-xcodebuildmcp"}' --output text +``` + +Flowdeck CLI: + +```bash +flowdeck test -w /example_projects/iOS_Calculator/CalculatorApp.xcworkspace -s CalculatorApp -S "iPhone 17 Pro" --only CalculatorAppTests --progress -d /derived-data-flowdeck +``` + +Both commands are executed through `/usr/bin/script -q /dev/null ...` so the benchmark measures the real TTY streaming path instead of a buffered pipe. + +## Output + +Artifacts are written to: + +```text +benchmarks/simulator-test// +``` + +Each run writes: + +- `summary.json` +- `xcodebuildmcp-run-*.stdout.txt` +- `xcodebuildmcp-run-*.stderr.txt` +- `flowdeck-run-*.stdout.txt` +- `flowdeck-run-*.stderr.txt` + +Captured metrics: + +- wall-clock duration +- time to first stdout +- time to first milestone output +- time to first streamed test progress output +- exit code + +Transcripts are normalized before saving: + +- ANSI escapes are stripped +- carriage returns are converted to newlines +- PTY control characters are removed + +## Manual compile-error fixture + +To manually compare compile-failure output styling against Flowdeck without keeping the example project permanently broken: + +```bash +cp example_projects/iOS_Calculator/manual-fixtures/CalculatorAppTests/CompileError.fixture.swift \ + example_projects/iOS_Calculator/CalculatorAppTests/CompileError.swift +``` + +Then rerun the simulator test command in both tools. When finished, remove the copied file: + +```bash +rm example_projects/iOS_Calculator/CalculatorAppTests/CompileError.swift +``` diff --git a/example_projects/iOS_Calculator/manual-fixtures/CalculatorAppTests/CompileError.fixture.swift b/example_projects/iOS_Calculator/manual-fixtures/CalculatorAppTests/CompileError.fixture.swift new file mode 100644 index 00000000..65d1e1e1 --- /dev/null +++ b/example_projects/iOS_Calculator/manual-fixtures/CalculatorAppTests/CompileError.fixture.swift @@ -0,0 +1,3 @@ +import XCTest + +let compileErrorFixture: Int = "not an int" diff --git a/knip.json b/knip.json new file mode 100644 index 00000000..e29304bf --- /dev/null +++ b/knip.json @@ -0,0 +1,26 @@ +{ + "$schema": "https://unpkg.com/knip@5/schema.json", + "entry": [ + "scripts/check-code-patterns.js", + "scripts/probe-xcode-mcpbridge.ts", + "scripts/repro-mcp-parent-exit-helper.mjs", + "src/integrations/xcode-tools-bridge/__tests__/fixtures/fake-xcode-tools-server.mjs" + ], + "project": [ + "src/**/*.{ts,js,mjs}", + "scripts/**/*.{ts,js,mjs}" + ], + "ignoreBinaries": [ + "scripts/bundle-axe.sh", + "scripts/package-macos-portable.sh", + "scripts/verify-portable-install.sh", + "scripts/create-homebrew-formula.sh", + "pkg-pr-new", + "ISC", + "BSD-2-Clause", + "BSD-3-Clause", + "Apache-2.0", + "Unlicense", + "FSL-1.1-MIT" + ] +} diff --git a/manifests/tools/build_device.yaml b/manifests/tools/build_device.yaml index 40e0506b..f1bd2451 100644 --- a/manifests/tools/build_device.yaml +++ b/manifests/tools/build_device.yaml @@ -11,3 +11,7 @@ annotations: readOnlyHint: false destructiveHint: false openWorldHint: false +nextSteps: + - label: Get built device app path + toolId: get_device_app_path + priority: 1 diff --git a/manifests/tools/build_macos.yaml b/manifests/tools/build_macos.yaml index fc2d8126..bfbef85f 100644 --- a/manifests/tools/build_macos.yaml +++ b/manifests/tools/build_macos.yaml @@ -11,3 +11,7 @@ annotations: readOnlyHint: false destructiveHint: false openWorldHint: false +nextSteps: + - label: Get built macOS app path + toolId: get_mac_app_path + priority: 1 diff --git a/manifests/tools/build_run_macos.yaml b/manifests/tools/build_run_macos.yaml index 932b81f4..44377c05 100644 --- a/manifests/tools/build_run_macos.yaml +++ b/manifests/tools/build_run_macos.yaml @@ -11,3 +11,6 @@ annotations: readOnlyHint: false destructiveHint: false openWorldHint: false +nextSteps: + - label: Interact with the launched app in the foreground + priority: 1 diff --git a/manifests/tools/build_sim.yaml b/manifests/tools/build_sim.yaml index 0a83cf95..9abc5ecf 100644 --- a/manifests/tools/build_sim.yaml +++ b/manifests/tools/build_sim.yaml @@ -11,3 +11,7 @@ annotations: readOnlyHint: false destructiveHint: false openWorldHint: false +nextSteps: + - label: Get built app path in simulator derived data + toolId: get_sim_app_path + priority: 1 diff --git a/manifests/tools/get_device_app_path.yaml b/manifests/tools/get_device_app_path.yaml index 8d786924..80a86319 100644 --- a/manifests/tools/get_device_app_path.yaml +++ b/manifests/tools/get_device_app_path.yaml @@ -13,9 +13,12 @@ nextSteps: - label: Get bundle ID toolId: get_app_bundle_id priority: 1 + when: success - label: Install app on device toolId: install_app_device priority: 2 + when: success - label: Launch app on device toolId: launch_app_device priority: 3 + when: success diff --git a/manifests/tools/get_mac_app_path.yaml b/manifests/tools/get_mac_app_path.yaml index 7599a4c1..285a8e27 100644 --- a/manifests/tools/get_mac_app_path.yaml +++ b/manifests/tools/get_mac_app_path.yaml @@ -13,6 +13,8 @@ nextSteps: - label: Get bundle ID toolId: get_mac_bundle_id priority: 1 + when: success - label: Launch app toolId: launch_mac_app priority: 2 + when: success diff --git a/manifests/tools/get_sim_app_path.yaml b/manifests/tools/get_sim_app_path.yaml index 7199eaff..b4283da0 100644 --- a/manifests/tools/get_sim_app_path.yaml +++ b/manifests/tools/get_sim_app_path.yaml @@ -13,12 +13,16 @@ nextSteps: - label: Get bundle ID toolId: get_app_bundle_id priority: 1 + when: success - label: Boot simulator toolId: boot_sim priority: 2 + when: success - label: Install app toolId: install_app_sim priority: 3 + when: success - label: Launch app toolId: launch_app_sim priority: 4 + when: success diff --git a/manifests/tools/list_schemes.yaml b/manifests/tools/list_schemes.yaml index 27dd2f10..88ace0c7 100644 --- a/manifests/tools/list_schemes.yaml +++ b/manifests/tools/list_schemes.yaml @@ -13,12 +13,16 @@ nextSteps: - label: Build for macOS toolId: build_macos priority: 1 + when: success - label: Build and run on iOS Simulator (default for run intent) toolId: build_run_sim priority: 2 + when: success - label: Build for iOS Simulator (compile-only) toolId: build_sim priority: 3 + when: success - label: Show build settings toolId: show_build_settings priority: 4 + when: success diff --git a/manifests/tools/show_build_settings.yaml b/manifests/tools/show_build_settings.yaml index c6c3620d..1b16dbc7 100644 --- a/manifests/tools/show_build_settings.yaml +++ b/manifests/tools/show_build_settings.yaml @@ -15,9 +15,12 @@ nextSteps: - label: Build for macOS toolId: build_macos priority: 1 + when: success - label: Build for iOS Simulator toolId: build_sim priority: 2 + when: success - label: List schemes toolId: list_schemes priority: 3 + when: success diff --git a/package-lock.json b/package-lock.json index ae06b28e..3cc3bc34 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,13 +11,13 @@ "dependencies": { "@clack/prompts": "^1.0.1", "@modelcontextprotocol/sdk": "^1.27.1", - "@sentry/cli": "^3.1.0", "@sentry/node": "^10.43.0", "bplist-parser": "^0.3.2", "chokidar": "^5.0.0", "uuid": "^11.1.0", "yaml": "^2.4.5", "yargs": "^17.7.2", + "yargs-parser": "^22.0.0", "zod": "^4.0.0" }, "bin": { @@ -25,28 +25,23 @@ "xcodebuildmcp-doctor": "build/doctor-cli.js" }, "devDependencies": { - "@bacons/xcode": "^1.0.0-alpha.24", - "@eslint/eslintrc": "^3.3.1", "@eslint/js": "^9.23.0", "@types/chokidar": "^1.7.5", "@types/node": "^22.13.6", "@types/yargs": "^17.0.33", - "@typescript-eslint/eslint-plugin": "^8.28.0", - "@typescript-eslint/parser": "^8.28.0", "@vitest/coverage-v8": "^3.2.4", "@vitest/ui": "^3.2.4", "eslint": "^9.23.0", "eslint-config-prettier": "^10.1.1", "eslint-plugin-prettier": "^5.2.5", - "playwright": "^1.53.0", + "glob": "^13.0.6", + "knip": "^5.88.0", "prettier": "3.6.2", - "ts-node": "^10.9.2", "tsup": "^8.5.0", "tsx": "^4.20.4", "typescript": "^5.8.2", "typescript-eslint": "^8.28.0", - "vitest": "^3.2.4", - "xcode": "^3.0.1" + "vitest": "^3.2.4" } }, "node_modules/@ampproject/remapping": { @@ -113,28 +108,6 @@ "node": ">=6.9.0" } }, - "node_modules/@bacons/xcode": { - "version": "1.0.0-alpha.25", - "resolved": "https://registry.npmjs.org/@bacons/xcode/-/xcode-1.0.0-alpha.25.tgz", - "integrity": "sha512-HE/2UXkIFrKq/ZvxvB8b1OIk47Nf+jXDYJsAVfSoxCu3pNW/Zrws3ad/HbB/wWYb+bDvr4PD2wfGuNcTRbUQNQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@expo/plist": "^0.0.18", - "debug": "^4.3.4", - "uuid": "^8.3.2" - } - }, - "node_modules/@bacons/xcode/node_modules/uuid": { - "version": "8.3.2", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", - "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", - "dev": true, - "license": "MIT", - "bin": { - "uuid": "dist/bin/uuid" - } - }, "node_modules/@bcoe/v8-coverage": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", @@ -166,28 +139,41 @@ "sisteransi": "^1.0.5" } }, - "node_modules/@cspotcode/source-map-support": { - "version": "0.8.1", - "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", - "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", + "node_modules/@emnapi/core": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.2.tgz", + "integrity": "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "dependencies": { - "@jridgewell/trace-mapping": "0.3.9" - }, - "engines": { - "node": ">=12" + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.2.tgz", + "integrity": "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "tslib": "^2.4.0" } }, - "node_modules/@cspotcode/source-map-support/node_modules/@jridgewell/trace-mapping": { - "version": "0.3.9", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", - "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "dependencies": { - "@jridgewell/resolve-uri": "^3.0.3", - "@jridgewell/sourcemap-codec": "^1.4.10" + "tslib": "^2.4.0" } }, "node_modules/@esbuild/aix-ppc64": { @@ -760,18 +746,6 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, - "node_modules/@expo/plist": { - "version": "0.0.18", - "resolved": "https://registry.npmjs.org/@expo/plist/-/plist-0.0.18.tgz", - "integrity": "sha512-+48gRqUiz65R21CZ/IXa7RNBXgAI/uPSdvJqoN9x1hfL44DNbUoWHgHiEXTx7XelcATpDwNTz6sHLfy0iNqf+w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@xmldom/xmldom": "~0.7.0", - "base64-js": "^1.2.3", - "xmlbuilder": "^14.0.0" - } - }, "node_modules/@fastify/otel": { "version": "0.16.0", "resolved": "https://registry.npmjs.org/@fastify/otel/-/otel-0.16.0.tgz", @@ -1069,6 +1043,25 @@ "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", "license": "MIT" }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.3.tgz", + "integrity": "sha512-xK9sGVbJWYb08+mTJt3/YV24WxvxpXcXtP6B172paPZ+Ts69Re9dAr7lKwJoeIx8OoeuimEiRZ7umkiUVClmmQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -1635,6 +1628,289 @@ "@opentelemetry/api": "^1.1.0" } }, + "node_modules/@oxc-resolver/binding-android-arm-eabi": { + "version": "11.19.1", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-android-arm-eabi/-/binding-android-arm-eabi-11.19.1.tgz", + "integrity": "sha512-aUs47y+xyXHUKlbhqHUjBABjvycq6YSD7bpxSW7vplUmdzAlJ93yXY6ZR0c1o1x5A/QKbENCvs3+NlY8IpIVzg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@oxc-resolver/binding-android-arm64": { + "version": "11.19.1", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-android-arm64/-/binding-android-arm64-11.19.1.tgz", + "integrity": "sha512-oolbkRX+m7Pq2LNjr/kKgYeC7bRDMVTWPgxBGMjSpZi/+UskVo4jsMU3MLheZV55jL6c3rNelPl4oD60ggYmqA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@oxc-resolver/binding-darwin-arm64": { + "version": "11.19.1", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-darwin-arm64/-/binding-darwin-arm64-11.19.1.tgz", + "integrity": "sha512-nUC6d2i3R5B12sUW4O646qD5cnMXf2oBGPLIIeaRfU9doJRORAbE2SGv4eW6rMqhD+G7nf2Y8TTJTLiiO3Q/dQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@oxc-resolver/binding-darwin-x64": { + "version": "11.19.1", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-darwin-x64/-/binding-darwin-x64-11.19.1.tgz", + "integrity": "sha512-cV50vE5+uAgNcFa3QY1JOeKDSkM/9ReIcc/9wn4TavhW/itkDGrXhw9jaKnkQnGbjJ198Yh5nbX/Gr2mr4Z5jQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@oxc-resolver/binding-freebsd-x64": { + "version": "11.19.1", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-freebsd-x64/-/binding-freebsd-x64-11.19.1.tgz", + "integrity": "sha512-xZOQiYGFxtk48PBKff+Zwoym7ScPAIVp4c14lfLxizO2LTTTJe5sx9vQNGrBymrf/vatSPNMD4FgsaaRigPkqw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@oxc-resolver/binding-linux-arm-gnueabihf": { + "version": "11.19.1", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-11.19.1.tgz", + "integrity": "sha512-lXZYWAC6kaGe/ky2su94e9jN9t6M0/6c+GrSlCqL//XO1cxi5lpAhnJYdyrKfm0ZEr/c7RNyAx3P7FSBcBd5+A==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxc-resolver/binding-linux-arm-musleabihf": { + "version": "11.19.1", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-11.19.1.tgz", + "integrity": "sha512-veG1kKsuK5+t2IsO9q0DErYVSw2azvCVvWHnfTOS73WE0STdLLB7Q1bB9WR+yHPQM76ASkFyRbogWo1GR1+WbQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxc-resolver/binding-linux-arm64-gnu": { + "version": "11.19.1", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-11.19.1.tgz", + "integrity": "sha512-heV2+jmXyYnUrpUXSPugqWDRpnsQcDm2AX4wzTuvgdlZfoNYO0O3W2AVpJYaDn9AG4JdM6Kxom8+foE7/BcSig==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxc-resolver/binding-linux-arm64-musl": { + "version": "11.19.1", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm64-musl/-/binding-linux-arm64-musl-11.19.1.tgz", + "integrity": "sha512-jvo2Pjs1c9KPxMuMPIeQsgu0mOJF9rEb3y3TdpsrqwxRM+AN6/nDDwv45n5ZrUnQMsdBy5gIabioMKnQfWo9ew==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxc-resolver/binding-linux-ppc64-gnu": { + "version": "11.19.1", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-11.19.1.tgz", + "integrity": "sha512-vLmdNxWCdN7Uo5suays6A/+ywBby2PWBBPXctWPg5V0+eVuzsJxgAn6MMB4mPlshskYbppjpN2Zg83ArHze9gQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxc-resolver/binding-linux-riscv64-gnu": { + "version": "11.19.1", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-11.19.1.tgz", + "integrity": "sha512-/b+WgR+VTSBxzgOhDO7TlMXC1ufPIMR6Vj1zN+/x+MnyXGW7prTLzU9eW85Aj7Th7CCEG9ArCbTeqxCzFWdg2w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxc-resolver/binding-linux-riscv64-musl": { + "version": "11.19.1", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-11.19.1.tgz", + "integrity": "sha512-YlRdeWb9j42p29ROh+h4eg/OQ3dTJlpHSa+84pUM9+p6i3djtPz1q55yLJhgW9XfDch7FN1pQ/Vd6YP+xfRIuw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxc-resolver/binding-linux-s390x-gnu": { + "version": "11.19.1", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-11.19.1.tgz", + "integrity": "sha512-EDpafVOQWF8/MJynsjOGFThcqhRHy417sRyLfQmeiamJ8qVhSKAn2Dn2VVKUGCjVB9C46VGjhNo7nOPUi1x6uA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxc-resolver/binding-linux-x64-gnu": { + "version": "11.19.1", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-x64-gnu/-/binding-linux-x64-gnu-11.19.1.tgz", + "integrity": "sha512-NxjZe+rqWhr+RT8/Ik+5ptA3oz7tUw361Wa5RWQXKnfqwSSHdHyrw6IdcTfYuml9dM856AlKWZIUXDmA9kkiBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxc-resolver/binding-linux-x64-musl": { + "version": "11.19.1", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-x64-musl/-/binding-linux-x64-musl-11.19.1.tgz", + "integrity": "sha512-cM/hQwsO3ReJg5kR+SpI69DMfvNCp+A/eVR4b4YClE5bVZwz8rh2Nh05InhwI5HR/9cArbEkzMjcKgTHS6UaNw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxc-resolver/binding-openharmony-arm64": { + "version": "11.19.1", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-openharmony-arm64/-/binding-openharmony-arm64-11.19.1.tgz", + "integrity": "sha512-QF080IowFB0+9Rh6RcD19bdgh49BpQHUW5TajG1qvWHvmrQznTZZjYlgE2ltLXyKY+qs4F/v5xuX1XS7Is+3qA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@oxc-resolver/binding-wasm32-wasi": { + "version": "11.19.1", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-wasm32-wasi/-/binding-wasm32-wasi-11.19.1.tgz", + "integrity": "sha512-w8UCKhX826cP/ZLokXDS6+milN8y4X7zidsAttEdWlVoamTNf6lhBJldaWr3ukTDiye7s4HRcuPEPOXNC432Vg==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@napi-rs/wasm-runtime": "^1.1.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@oxc-resolver/binding-win32-arm64-msvc": { + "version": "11.19.1", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-11.19.1.tgz", + "integrity": "sha512-nJ4AsUVZrVKwnU/QRdzPCCrO0TrabBqgJ8pJhXITdZGYOV28TIYystV1VFLbQ7DtAcaBHpocT5/ZJnF78YJPtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@oxc-resolver/binding-win32-ia32-msvc": { + "version": "11.19.1", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-11.19.1.tgz", + "integrity": "sha512-EW+ND5q2Tl+a3pH81l1QbfgbF3HmqgwLfDfVithRFheac8OTcnbXt/JxqD2GbDkb7xYEqy1zNaVFRr3oeG8npA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@oxc-resolver/binding-win32-x64-msvc": { + "version": "11.19.1", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-win32-x64-msvc/-/binding-win32-x64-msvc-11.19.1.tgz", + "integrity": "sha512-6hIU3RQu45B+VNTY4Ru8ppFwjVS/S5qwYyGhBotmjxfEKk41I2DlGtRfGJndZ5+6lneE2pwloqunlOyZuX/XAw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", @@ -1973,253 +2249,89 @@ "linux" ] }, - "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz", - "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ] - }, - "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz", - "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ] - }, - "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz", - "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz", - "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz", - "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz", - "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@sentry/cli": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/@sentry/cli/-/cli-3.1.0.tgz", - "integrity": "sha512-ngnx6E8XjXpg1uzma45INfKCS8yurb/fl3cZdXTCa2wmek8b4N6WIlmOlTKFTBrV54OauF6mloJxAlpuzoQR6g==", - "hasInstallScript": true, - "license": "FSL-1.1-MIT", - "dependencies": { - "progress": "^2.0.3", - "proxy-from-env": "^1.1.0", - "undici": "^6.22.0", - "which": "^2.0.2" - }, - "bin": { - "sentry-cli": "bin/sentry-cli" - }, - "engines": { - "node": ">= 18" - }, - "optionalDependencies": { - "@sentry/cli-darwin": "3.1.0", - "@sentry/cli-linux-arm": "3.1.0", - "@sentry/cli-linux-arm64": "3.1.0", - "@sentry/cli-linux-i686": "3.1.0", - "@sentry/cli-linux-x64": "3.1.0", - "@sentry/cli-win32-arm64": "3.1.0", - "@sentry/cli-win32-i686": "3.1.0", - "@sentry/cli-win32-x64": "3.1.0" - } - }, - "node_modules/@sentry/cli-darwin": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/@sentry/cli-darwin/-/cli-darwin-3.1.0.tgz", - "integrity": "sha512-xT1WlCHenGGO29Lq/wKaIthdqZzNzZhlPs7dXrzlBx9DyA2Jnl0g7WEau0oWi8GyJGVRXCJMiCydR//Tb5qVwA==", - "license": "FSL-1.1-MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@sentry/cli-linux-arm": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/@sentry/cli-linux-arm/-/cli-linux-arm-3.1.0.tgz", - "integrity": "sha512-kbP3/8/Ct/Jbm569KDXbFIyMyPypIegObvIT7LdSsfdYSZdBd396GV7vUpSGKiLUVVN0xjn8OqQ48AVGfjmuMg==", - "cpu": [ - "arm" - ], - "license": "FSL-1.1-MIT", - "optional": true, - "os": [ - "linux", - "freebsd", - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@sentry/cli-linux-arm64": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/@sentry/cli-linux-arm64/-/cli-linux-arm64-3.1.0.tgz", - "integrity": "sha512-Jm/iHLKiHxrZYlAq2tT07amiegEVCOAQT9Unilr6djjcZzS2tcI9ThSRQvjP9tFpFRKop+NyNGE3XHXf69r00g==", - "cpu": [ - "arm64" - ], - "license": "FSL-1.1-MIT", - "optional": true, - "os": [ - "linux", - "freebsd", - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@sentry/cli-linux-i686": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/@sentry/cli-linux-i686/-/cli-linux-i686-3.1.0.tgz", - "integrity": "sha512-f/PK/EGK5vFOy7LC4Riwb+BEE20Nk7RbEFEMjvRq26DpETCrZYUGlbpIKvJFKOaUmr79aAkFCA/EjJiYfcQP2Q==", + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz", + "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==", "cpu": [ - "x86", - "ia32" + "x64" ], - "license": "FSL-1.1-MIT", + "dev": true, + "license": "MIT", "optional": true, "os": [ - "linux", - "freebsd", - "android" - ], - "engines": { - "node": ">=18" - } + "openbsd" + ] }, - "node_modules/@sentry/cli-linux-x64": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/@sentry/cli-linux-x64/-/cli-linux-x64-3.1.0.tgz", - "integrity": "sha512-T+v8x1ujhixZrOrH0sVhsW6uLwK4n0WS+B+5xV46WqUKe32cbYotursp2y53ROjgat8SQDGeP/VnC0Qa3Y2fEA==", + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz", + "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==", "cpu": [ - "x64" + "arm64" ], - "license": "FSL-1.1-MIT", + "dev": true, + "license": "MIT", "optional": true, "os": [ - "linux", - "freebsd", - "android" - ], - "engines": { - "node": ">=18" - } + "openharmony" + ] }, - "node_modules/@sentry/cli-win32-arm64": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/@sentry/cli-win32-arm64/-/cli-win32-arm64-3.1.0.tgz", - "integrity": "sha512-2DIPq6aW2DC34EDC9J0xwD+9BpFnKdFGdIcQUZMS+5pXlU6V7o8wpZxZAM8TdYNmsPkkQGKp7Dhl/arWpvNgrw==", + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz", + "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==", "cpu": [ "arm64" ], - "license": "FSL-1.1-MIT", + "dev": true, + "license": "MIT", "optional": true, "os": [ "win32" - ], - "engines": { - "node": ">=18" - } + ] }, - "node_modules/@sentry/cli-win32-i686": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/@sentry/cli-win32-i686/-/cli-win32-i686-3.1.0.tgz", - "integrity": "sha512-2NuywEiiZn6xJ1yAV2xjv/nuHiy6kZU5XR3RSAIrPdEZD1nBoMsH/gB2FufQw58Ziz/7otFcX+vtGpJjbIT5mQ==", + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz", + "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==", "cpu": [ - "x86", "ia32" ], - "license": "FSL-1.1-MIT", + "dev": true, + "license": "MIT", "optional": true, "os": [ "win32" - ], - "engines": { - "node": ">=18" - } + ] }, - "node_modules/@sentry/cli-win32-x64": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/@sentry/cli-win32-x64/-/cli-win32-x64-3.1.0.tgz", - "integrity": "sha512-Ip405Yqdrr+l9TImsZOJz6c9Nb4zvXcmtOIBKLHc9cowpfXfmlqsHbDp7Xh4+k4L0uLr9i+8ilgQ6ypcuF4UCg==", + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz", + "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==", "cpu": [ "x64" ], - "license": "FSL-1.1-MIT", + "dev": true, + "license": "MIT", "optional": true, "os": [ "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz", + "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==", + "cpu": [ + "x64" ], - "engines": { - "node": ">=18" - } + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] }, "node_modules/@sentry/core": { "version": "10.43.0", @@ -2356,33 +2468,16 @@ "@opentelemetry/semantic-conventions": "^1.39.0" } }, - "node_modules/@tsconfig/node10": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.11.tgz", - "integrity": "sha512-DcRjDCujK/kCk/cUe8Xz8ZSpm8mS3mNNpta+jGCA6USEDfktlNvm1+IuZ9eTcDbNk41BHwpHHeW+N1lKCz4zOw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@tsconfig/node12": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz", - "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==", - "dev": true, - "license": "MIT" - }, - "node_modules/@tsconfig/node14": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz", - "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==", - "dev": true, - "license": "MIT" - }, - "node_modules/@tsconfig/node16": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz", - "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", + "node_modules/@tybys/wasm-util": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", + "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", "dev": true, - "license": "MIT" + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } }, "node_modules/@types/chai": { "version": "5.2.2", @@ -2961,17 +3056,6 @@ "url": "https://opencollective.com/vitest" } }, - "node_modules/@xmldom/xmldom": { - "version": "0.7.13", - "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.7.13.tgz", - "integrity": "sha512-lm2GW5PkosIzccsaZIz7tp8cPADSIlIHWDFTR1N0SzfinhhYgeIQjFMz4rYzanCScr3DqQLeomUDArp6MWKm+g==", - "deprecated": "this version is no longer supported, please update to at least 0.8.*", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10.0.0" - } - }, "node_modules/accepts": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", @@ -3016,19 +3100,6 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, - "node_modules/acorn-walk": { - "version": "8.3.4", - "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.4.tgz", - "integrity": "sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==", - "dev": true, - "license": "MIT", - "dependencies": { - "acorn": "^8.11.0" - }, - "engines": { - "node": ">=0.4.0" - } - }, "node_modules/ajv": { "version": "6.14.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", @@ -3086,9 +3157,9 @@ "license": "MIT" }, "node_modules/ansi-regex": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", - "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", "dev": true, "license": "MIT", "engines": { @@ -3120,13 +3191,6 @@ "dev": true, "license": "MIT" }, - "node_modules/arg": { - "version": "4.1.3", - "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", - "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", - "dev": true, - "license": "MIT" - }, "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", @@ -3163,27 +3227,6 @@ "dev": true, "license": "MIT" }, - "node_modules/base64-js": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", - "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, "node_modules/big-integer": { "version": "1.6.52", "resolved": "https://registry.npmjs.org/big-integer/-/big-integer-1.6.52.tgz", @@ -3217,16 +3260,6 @@ "url": "https://opencollective.com/express" } }, - "node_modules/bplist-creator": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/bplist-creator/-/bplist-creator-0.1.0.tgz", - "integrity": "sha512-sXaHZicyEEmY86WyueLTQesbeoH/mquvarJaQNbjuOQO+7gbFcDEWqKmcWA4cOTLzFlfgvkiVxolk1k5bBIpmg==", - "dev": true, - "license": "MIT", - "dependencies": { - "stream-buffers": "2.2.x" - } - }, "node_modules/bplist-parser": { "version": "0.3.2", "resolved": "https://registry.npmjs.org/bplist-parser/-/bplist-parser-0.3.2.tgz", @@ -3578,13 +3611,6 @@ "node": ">= 0.10" } }, - "node_modules/create-require": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", - "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", - "dev": true, - "license": "MIT" - }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -3642,16 +3668,6 @@ "node": ">= 0.8" } }, - "node_modules/diff": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz", - "integrity": "sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.3.1" - } - }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -4224,6 +4240,16 @@ "reusify": "^1.0.4" } }, + "node_modules/fd-package-json": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fd-package-json/-/fd-package-json-2.0.0.tgz", + "integrity": "sha512-jKmm9YtsNXN789RS/0mSzOC1NUq9mkVd65vbSSVsKdjGvYXBuE4oWe2QOEoFeRmJg+lPuZxpmrfFclNhoRMneQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "walk-up-path": "^4.0.0" + } + }, "node_modules/fflate": { "version": "0.8.2", "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz", @@ -4341,6 +4367,22 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/formatly": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/formatly/-/formatly-0.3.0.tgz", + "integrity": "sha512-9XNj/o4wrRFyhSMJOvsuyMwy8aUfBaZ1VrqHVfohyXf0Sw0e+yfKG+xZaY3arGCOMdwFsqObtzVOc1gU9KiT9w==", + "dev": true, + "license": "MIT", + "dependencies": { + "fd-package-json": "^2.0.0" + }, + "bin": { + "formatly": "bin/index.mjs" + }, + "engines": { + "node": ">=18.3.0" + } + }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -4449,21 +4491,18 @@ } }, "node_modules/glob": { - "version": "10.5.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", - "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", "dev": true, - "license": "ISC", + "license": "BlueOak-1.0.0", "dependencies": { - "foreground-child": "^3.1.0", - "jackspeak": "^3.1.2", - "minimatch": "^9.0.4", - "minipass": "^7.1.2", - "package-json-from-dist": "^1.0.0", - "path-scurry": "^1.11.1" + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" }, - "bin": { - "glob": "dist/esm/bin.mjs" + "engines": { + "node": "18 || 20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" @@ -4506,16 +4545,16 @@ } }, "node_modules/glob/node_modules/minimatch": { - "version": "9.0.7", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.7.tgz", - "integrity": "sha512-MOwgjc8tfrpn5QQEvjijjmDVtMw2oL88ugTevzxQnzRLm6l3fVEF2gzU0kYeYYKD8C66+IdGX6peJ4MyUlUnPg==", + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", "dev": true, - "license": "ISC", + "license": "BlueOak-1.0.0", "dependencies": { - "brace-expansion": "^5.0.2" + "brace-expansion": "^5.0.5" }, "engines": { - "node": ">=16 || 14 >=14.17" + "node": "18 || 20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" @@ -4836,6 +4875,16 @@ "@pkgjs/parseargs": "^0.11.0" } }, + "node_modules/jiti": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", + "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, "node_modules/jose": { "version": "6.1.3", "resolved": "https://registry.npmjs.org/jose/-/jose-6.1.3.tgz", @@ -4912,6 +4961,75 @@ "json-buffer": "3.0.1" } }, + "node_modules/knip": { + "version": "5.88.1", + "resolved": "https://registry.npmjs.org/knip/-/knip-5.88.1.tgz", + "integrity": "sha512-tpy5o7zu1MjawVkLPuahymVJekYY3kYjvzcoInhIchgePxTlo+api90tBv2KfhAIe5uXh+mez1tAfmbv8/TiZg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/webpro" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/knip" + } + ], + "license": "ISC", + "dependencies": { + "@nodelib/fs.walk": "^1.2.3", + "fast-glob": "^3.3.3", + "formatly": "^0.3.0", + "jiti": "^2.6.0", + "minimist": "^1.2.8", + "oxc-resolver": "^11.19.1", + "picocolors": "^1.1.1", + "picomatch": "^4.0.1", + "smol-toml": "^1.5.2", + "strip-json-comments": "5.0.3", + "unbash": "^2.2.0", + "yaml": "^2.8.2", + "zod": "^4.1.11" + }, + "bin": { + "knip": "bin/knip.js", + "knip-bun": "bin/knip-bun.js" + }, + "engines": { + "node": ">=18.18.0" + }, + "peerDependencies": { + "@types/node": ">=18", + "typescript": ">=5.0.4 <7" + } + }, + "node_modules/knip/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/knip/node_modules/strip-json-comments": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-5.0.3.tgz", + "integrity": "sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/levn": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", @@ -4994,11 +5112,14 @@ "license": "MIT" }, "node_modules/lru-cache": { - "version": "10.4.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", - "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "version": "11.3.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.3.tgz", + "integrity": "sha512-JvNw9Y81y33E+BEYPr0U7omo+U9AySnsMsEiXgwT6yqd31VQWTLNQqmT4ou5eqPFUrTfIDFta2wKhB1hyohtAQ==", "dev": true, - "license": "ISC" + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } }, "node_modules/magic-string": { "version": "0.30.17", @@ -5038,13 +5159,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/make-error": { - "version": "1.3.6", - "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", - "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", - "dev": true, - "license": "ISC" - }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -5133,12 +5247,22 @@ "node": "*" } }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/minipass": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", - "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", "dev": true, - "license": "ISC", + "license": "BlueOak-1.0.0", "engines": { "node": ">=16 || 14 >=14.17" } @@ -5281,8 +5405,40 @@ "type-check": "^0.4.0", "word-wrap": "^1.2.5" }, - "engines": { - "node": ">= 0.8.0" + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/oxc-resolver": { + "version": "11.19.1", + "resolved": "https://registry.npmjs.org/oxc-resolver/-/oxc-resolver-11.19.1.tgz", + "integrity": "sha512-qE/CIg/spwrTBFt5aKmwe3ifeDdLfA2NESN30E42X/lII5ClF8V7Wt6WIJhcGZjp0/Q+nQ+9vgxGk//xZNX2hg==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + }, + "optionalDependencies": { + "@oxc-resolver/binding-android-arm-eabi": "11.19.1", + "@oxc-resolver/binding-android-arm64": "11.19.1", + "@oxc-resolver/binding-darwin-arm64": "11.19.1", + "@oxc-resolver/binding-darwin-x64": "11.19.1", + "@oxc-resolver/binding-freebsd-x64": "11.19.1", + "@oxc-resolver/binding-linux-arm-gnueabihf": "11.19.1", + "@oxc-resolver/binding-linux-arm-musleabihf": "11.19.1", + "@oxc-resolver/binding-linux-arm64-gnu": "11.19.1", + "@oxc-resolver/binding-linux-arm64-musl": "11.19.1", + "@oxc-resolver/binding-linux-ppc64-gnu": "11.19.1", + "@oxc-resolver/binding-linux-riscv64-gnu": "11.19.1", + "@oxc-resolver/binding-linux-riscv64-musl": "11.19.1", + "@oxc-resolver/binding-linux-s390x-gnu": "11.19.1", + "@oxc-resolver/binding-linux-x64-gnu": "11.19.1", + "@oxc-resolver/binding-linux-x64-musl": "11.19.1", + "@oxc-resolver/binding-openharmony-arm64": "11.19.1", + "@oxc-resolver/binding-wasm32-wasi": "11.19.1", + "@oxc-resolver/binding-win32-arm64-msvc": "11.19.1", + "@oxc-resolver/binding-win32-ia32-msvc": "11.19.1", + "@oxc-resolver/binding-win32-x64-msvc": "11.19.1" } }, "node_modules/p-limit": { @@ -5366,17 +5522,17 @@ } }, "node_modules/path-scurry": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", - "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { - "lru-cache": "^10.2.0", - "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" }, "engines": { - "node": ">=16 || 14 >=14.18" + "node": "18 || 20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" @@ -5490,73 +5646,6 @@ "pathe": "^2.0.1" } }, - "node_modules/playwright": { - "version": "1.55.1", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.55.1.tgz", - "integrity": "sha512-cJW4Xd/G3v5ovXtJJ52MAOclqeac9S/aGGgRzLabuF8TnIb6xHvMzKIa6JmrRzUkeXJgfL1MhukP0NK6l39h3A==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "playwright-core": "1.55.1" - }, - "bin": { - "playwright": "cli.js" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "fsevents": "2.3.2" - } - }, - "node_modules/playwright-core": { - "version": "1.55.1", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.55.1.tgz", - "integrity": "sha512-Z6Mh9mkwX+zxSlHqdr5AOcJnfp+xUWLCt9uKV18fhzA8eyxUd8NUWzAjxUh55RZKSYwDGX0cfaySdhZJGMoJ+w==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "playwright-core": "cli.js" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/plist": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/plist/-/plist-3.1.0.tgz", - "integrity": "sha512-uysumyrvkUX0rX/dEVqt8gC3sTBzd4zoWfLeS29nb53imdaXVvLINYXTI2GNqzaMuvacNx4uJQ8+b3zXR0pkgQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@xmldom/xmldom": "^0.8.8", - "base64-js": "^1.5.1", - "xmlbuilder": "^15.1.1" - }, - "engines": { - "node": ">=10.4.0" - } - }, - "node_modules/plist/node_modules/@xmldom/xmldom": { - "version": "0.8.10", - "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.10.tgz", - "integrity": "sha512-2WALfTl4xo2SkGCYRt6rDTFfk9R1czmBvUQy12gK2KuRKIpWEhcbbzy8EZXtz/jkRqHX8bFEc6FC1HjX4TUWYw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10.0.0" - } - }, - "node_modules/plist/node_modules/xmlbuilder": { - "version": "15.1.1", - "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz", - "integrity": "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8.0" - } - }, "node_modules/postcss": { "version": "8.5.6", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", @@ -5707,15 +5796,6 @@ "node": ">=6.0.0" } }, - "node_modules/progress": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", - "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", - "license": "MIT", - "engines": { - "node": ">=0.4.0" - } - }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -5729,12 +5809,6 @@ "node": ">= 0.10" } }, - "node_modules/proxy-from-env": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", - "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", - "license": "MIT" - }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -6160,31 +6234,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/simple-plist": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/simple-plist/-/simple-plist-1.3.1.tgz", - "integrity": "sha512-iMSw5i0XseMnrhtIzRb7XpQEXepa9xhWxGUojHBL43SIpQuDQkh3Wpy67ZbDzZVr6EKxvwVChnVpdl8hEVLDiw==", - "dev": true, - "license": "MIT", - "dependencies": { - "bplist-creator": "0.1.0", - "bplist-parser": "0.3.1", - "plist": "^3.0.5" - } - }, - "node_modules/simple-plist/node_modules/bplist-parser": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/bplist-parser/-/bplist-parser-0.3.1.tgz", - "integrity": "sha512-PyJxiNtA5T2PlLIeBot4lbp7rj4OadzjnMZD/G5zuBNt8ei/yCU7+wW0h2bag9vr8c+/WuRWmSxbqAl9hL1rBA==", - "dev": true, - "license": "MIT", - "dependencies": { - "big-integer": "1.6.x" - }, - "engines": { - "node": ">= 5.10.0" - } - }, "node_modules/sirv": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.1.tgz", @@ -6206,6 +6255,19 @@ "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", "license": "MIT" }, + "node_modules/smol-toml": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.6.1.tgz", + "integrity": "sha512-dWUG8F5sIIARXih1DTaQAX4SsiTXhInKf1buxdY9DIg4ZYPZK5nGM1VRIYmEbDbsHt7USo99xSLFu5Q1IqTmsg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">= 18" + }, + "funding": { + "url": "https://github.com/sponsors/cyyynthia" + } + }, "node_modules/source-map": { "version": "0.8.0-beta.0", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.8.0-beta.0.tgz", @@ -6282,16 +6344,6 @@ "dev": true, "license": "MIT" }, - "node_modules/stream-buffers": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/stream-buffers/-/stream-buffers-2.2.0.tgz", - "integrity": "sha512-uyQK/mx5QjHun80FLJTfaWE7JtwfRMKBLkMne6udYOmvH0CawotVa7TfgYHzAnpphn4+TweIx1QKMnRIbipmUg==", - "dev": true, - "license": "Unlicense", - "engines": { - "node": ">= 0.10.0" - } - }, "node_modules/string-width": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", @@ -6357,13 +6409,13 @@ } }, "node_modules/strip-ansi": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", - "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", "dev": true, "license": "MIT", "dependencies": { - "ansi-regex": "^6.0.1" + "ansi-regex": "^6.2.2" }, "engines": { "node": ">=12" @@ -6445,6 +6497,78 @@ "node": ">=16 || 14 >=14.17" } }, + "node_modules/sucrase/node_modules/brace-expansion": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz", + "integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/sucrase/node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/sucrase/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/sucrase/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/sucrase/node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -6512,6 +6636,35 @@ "node": "18 || 20 || >=22" } }, + "node_modules/test-exclude/node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/test-exclude/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, "node_modules/test-exclude/node_modules/minimatch": { "version": "9.0.7", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.7.tgz", @@ -6528,6 +6681,23 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/test-exclude/node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/thenify": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", @@ -6705,49 +6875,13 @@ "dev": true, "license": "Apache-2.0" }, - "node_modules/ts-node": { - "version": "10.9.2", - "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", - "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "dev": true, - "license": "MIT", - "dependencies": { - "@cspotcode/source-map-support": "^0.8.0", - "@tsconfig/node10": "^1.0.7", - "@tsconfig/node12": "^1.0.7", - "@tsconfig/node14": "^1.0.0", - "@tsconfig/node16": "^1.0.2", - "acorn": "^8.4.1", - "acorn-walk": "^8.1.1", - "arg": "^4.1.0", - "create-require": "^1.1.0", - "diff": "^4.0.1", - "make-error": "^1.1.1", - "v8-compile-cache-lib": "^3.0.1", - "yn": "3.1.1" - }, - "bin": { - "ts-node": "dist/bin.js", - "ts-node-cwd": "dist/bin-cwd.js", - "ts-node-esm": "dist/bin-esm.js", - "ts-node-script": "dist/bin-script.js", - "ts-node-transpile-only": "dist/bin-transpile.js", - "ts-script": "dist/bin-script-deprecated.js" - }, - "peerDependencies": { - "@swc/core": ">=1.2.50", - "@swc/wasm": ">=1.2.50", - "@types/node": "*", - "typescript": ">=2.7" - }, - "peerDependenciesMeta": { - "@swc/core": { - "optional": true - }, - "@swc/wasm": { - "optional": true - } - } + "license": "0BSD", + "optional": true }, "node_modules/tsup": { "version": "8.5.0", @@ -6949,13 +7083,14 @@ "dev": true, "license": "MIT" }, - "node_modules/undici": { - "version": "6.24.1", - "resolved": "https://registry.npmjs.org/undici/-/undici-6.24.1.tgz", - "integrity": "sha512-sC+b0tB1whOCzbtlx20fx3WgCXwkW627p4EA9uM+/tNNPkSS+eSEld6pAs9nDv7WbY1UUljBMYPtu9BCOrCWKA==", - "license": "MIT", + "node_modules/unbash": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/unbash/-/unbash-2.2.0.tgz", + "integrity": "sha512-X2wH19RAPZE3+ldGicOkoj/SIA83OIxcJ6Cuaw23hf8Xc6fQpvZXY0SftE2JgS0QhYLUG4uwodSI3R53keyh7w==", + "dev": true, + "license": "ISC", "engines": { - "node": ">=18.17" + "node": ">=14" } }, "node_modules/undici-types": { @@ -6996,13 +7131,6 @@ "uuid": "dist/esm/bin/uuid" } }, - "node_modules/v8-compile-cache-lib": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", - "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", - "dev": true, - "license": "MIT" - }, "node_modules/vary": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", @@ -7242,6 +7370,16 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/walk-up-path": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/walk-up-path/-/walk-up-path-4.0.0.tgz", + "integrity": "sha512-3hu+tD8YzSLGuFYtPRb48vdhKMi0KQV5sn+uWr8+7dMEq/2G/dtLrdDinkLjqq5TIbIBjYJ4Ax/n3YiaW7QM8A==", + "dev": true, + "license": "ISC", + "engines": { + "node": "20 || >=22" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -7367,9 +7505,9 @@ } }, "node_modules/wrap-ansi/node_modules/ansi-styles": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", - "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", "dev": true, "license": "MIT", "engines": { @@ -7385,40 +7523,6 @@ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "license": "ISC" }, - "node_modules/xcode": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/xcode/-/xcode-3.0.1.tgz", - "integrity": "sha512-kCz5k7J7XbJtjABOvkc5lJmkiDh8VhjVCGNiqdKCscmVpdVUpEAyXv1xmCLkQJ5dsHqx3IPO4XW+NTDhU/fatA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "simple-plist": "^1.1.0", - "uuid": "^7.0.3" - }, - "engines": { - "node": ">=10.0.0" - } - }, - "node_modules/xcode/node_modules/uuid": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-7.0.3.tgz", - "integrity": "sha512-DPSke0pXhTZgoF/d+WSt2QaKMCFSfx7QegxEWT+JOuHF5aWrKEn0G+ztjuJg/gG8/ItK+rbPCD/yNv8yyih6Cg==", - "dev": true, - "license": "MIT", - "bin": { - "uuid": "dist/bin/uuid" - } - }, - "node_modules/xmlbuilder": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-14.0.0.tgz", - "integrity": "sha512-ts+B2rSe4fIckR6iquDjsKbQFK2NlUk6iG5nf14mDEyldgoc2nEKZ3jZWMPTxGQwVgToSjt6VGIho1H8/fNFTg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8.0" - } - }, "node_modules/xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", @@ -7471,12 +7575,12 @@ } }, "node_modules/yargs-parser": { - "version": "21.1.1", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", - "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "version": "22.0.0", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz", + "integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==", "license": "ISC", "engines": { - "node": ">=12" + "node": "^20.19.0 || ^22.12.0 || >=23" } }, "node_modules/yargs/node_modules/ansi-regex": { @@ -7520,14 +7624,13 @@ "node": ">=8" } }, - "node_modules/yn": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", - "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", - "dev": true, - "license": "MIT", + "node_modules/yargs/node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "license": "ISC", "engines": { - "node": ">=6" + "node": ">=12" } }, "node_modules/yocto-queue": { diff --git a/package.json b/package.json index 7167ed42..e4aba5d2 100644 --- a/package.json +++ b/package.json @@ -39,8 +39,11 @@ "docs:update": "npx tsx scripts/update-tools-docs.ts", "docs:update:dry-run": "npx tsx scripts/update-tools-docs.ts --dry-run --verbose", "docs:check": "node scripts/check-docs-cli-commands.js", + "bench:test-sim": "npx tsx scripts/benchmark-simulator-test.ts", + "capture:xcodebuild": "npx tsx scripts/capture-xcodebuild-wrapper.ts", "license:report": "node scripts/generate-third-party-package-licenses.mjs", "license:check": "npx -y license-checker --production --onlyAllow 'MIT;ISC;BSD-2-Clause;BSD-3-Clause;Apache-2.0;Unlicense;FSL-1.1-MIT'", + "knip": "knip", "test": "vitest run", "test:smoke": "npm run build && vitest run --config vitest.smoke.config.ts", "test:watch": "vitest", @@ -76,37 +79,32 @@ "dependencies": { "@clack/prompts": "^1.0.1", "@modelcontextprotocol/sdk": "^1.27.1", - "@sentry/cli": "^3.1.0", "@sentry/node": "^10.43.0", "bplist-parser": "^0.3.2", "chokidar": "^5.0.0", "uuid": "^11.1.0", "yaml": "^2.4.5", "yargs": "^17.7.2", + "yargs-parser": "^22.0.0", "zod": "^4.0.0" }, "devDependencies": { - "@bacons/xcode": "^1.0.0-alpha.24", - "@eslint/eslintrc": "^3.3.1", "@eslint/js": "^9.23.0", "@types/chokidar": "^1.7.5", "@types/node": "^22.13.6", "@types/yargs": "^17.0.33", - "@typescript-eslint/eslint-plugin": "^8.28.0", - "@typescript-eslint/parser": "^8.28.0", "@vitest/coverage-v8": "^3.2.4", "@vitest/ui": "^3.2.4", "eslint": "^9.23.0", "eslint-config-prettier": "^10.1.1", "eslint-plugin-prettier": "^5.2.5", - "playwright": "^1.53.0", + "glob": "^13.0.6", + "knip": "^5.88.0", "prettier": "3.6.2", - "ts-node": "^10.9.2", "tsup": "^8.5.0", "tsx": "^4.20.4", "typescript": "^5.8.2", "typescript-eslint": "^8.28.0", - "vitest": "^3.2.4", - "xcode": "^3.0.1" + "vitest": "^3.2.4" } } diff --git a/scripts/benchmark-simulator-test.ts b/scripts/benchmark-simulator-test.ts new file mode 100644 index 00000000..18486e24 --- /dev/null +++ b/scripts/benchmark-simulator-test.ts @@ -0,0 +1,264 @@ +import { mkdir, rm, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { performance } from 'node:perf_hooks'; +import { spawn } from 'node:child_process'; + +type BenchmarkMode = 'warm' | 'cold'; + +type BenchmarkTool = 'xcodebuildmcp' | 'flowdeck'; + +interface RunMetrics { + tool: BenchmarkTool; + iteration: number; + exitCode: number | null; + wallClockMs: number; + firstStdoutMs: number | null; + firstMilestoneMs: number | null; + startupToFirstStreamedTestProgressMs: number | null; + stdoutPath: string; + stderrPath: string; +} + +interface RunCommandParams { + tool: BenchmarkTool; + command: string; + args: string[]; + cwd: string; + artifactPrefix: string; + milestonePattern: RegExp; + streamedTestProgressPattern: RegExp; +} + +function parseArgs(): { iterations: number; mode: BenchmarkMode } { + const args = process.argv.slice(2); + let iterations = 1; + let mode: BenchmarkMode = 'warm'; + + for (let index = 0; index < args.length; index += 1) { + const argument = args[index]; + if (argument === '--iterations') { + iterations = Number(args[index + 1] ?? '1'); + index += 1; + continue; + } + if (argument === '--mode') { + const nextMode = args[index + 1] ?? 'warm'; + if (nextMode === 'warm' || nextMode === 'cold') { + mode = nextMode; + } + index += 1; + } + } + + return { iterations, mode }; +} + +function stripAnsi(text: string): string { + return text.replace(/\u001B\[[0-9;]*[A-Za-z]/gu, ''); +} + +function isSpinnerFrame(line: string): boolean { + return ['โ—’', 'โ—', 'โ—“', 'โ—‘', 'โ”‚'].includes(line); +} + +function normalizeTerminalTranscript(text: string): string { + const cleaned = stripAnsi(text).replace(/\r/gu, '\n').replace(/[\u0004\u0008]/gu, ''); + const lines = cleaned.split('\n'); + const normalizedLines: string[] = []; + let joinedCharacterRun = ''; + + const flushCharacterRun = (): void => { + const line = joinedCharacterRun.trim(); + if (line) { + normalizedLines.push(line); + } + joinedCharacterRun = ''; + }; + + for (const rawLine of lines) { + const line = rawLine.trimEnd(); + const trimmed = line.trim(); + + if (!trimmed || isSpinnerFrame(trimmed)) { + continue; + } + + if (trimmed.length === 1 || /^[.()0-9,]+$/u.test(trimmed)) { + joinedCharacterRun += trimmed; + continue; + } + + flushCharacterRun(); + normalizedLines.push(trimmed); + } + + flushCharacterRun(); + return normalizedLines.join('\n'); +} + +async function ensureScriptAvailable(): Promise { + await new Promise((resolve, reject) => { + const child = spawn('/usr/bin/script', ['-q', '/dev/null', 'true'], { + stdio: 'ignore', + }); + child.on('error', reject); + child.on('close', (code) => { + if (code === 0) { + resolve(); + return; + } + reject(new Error(`/usr/bin/script exited with code ${code ?? 'unknown'}`)); + }); + }); +} + +async function runCommand(params: RunCommandParams): Promise { + const stdoutChunks: string[] = []; + const stderrChunks: string[] = []; + const start = performance.now(); + let firstStdoutMs: number | null = null; + let firstMilestoneMs: number | null = null; + let startupToFirstStreamedTestProgressMs: number | null = null; + let normalizedStdout = ''; + + const child = spawn('/usr/bin/script', ['-q', '/dev/null', params.command, ...params.args], { + cwd: params.cwd, + env: { + ...process.env, + NO_COLOR: '1', + }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + + child.stdout.on('data', (chunk: Buffer) => { + if (firstStdoutMs === null) { + firstStdoutMs = performance.now() - start; + } + + const text = chunk.toString(); + stdoutChunks.push(text); + normalizedStdout += normalizeTerminalTranscript(text); + + if (firstMilestoneMs === null && params.milestonePattern.test(normalizedStdout)) { + firstMilestoneMs = performance.now() - start; + } + + if ( + startupToFirstStreamedTestProgressMs === null && + params.streamedTestProgressPattern.test(normalizedStdout) + ) { + startupToFirstStreamedTestProgressMs = performance.now() - start; + } + }); + + child.stderr.on('data', (chunk: Buffer) => { + stderrChunks.push(chunk.toString()); + }); + + const exitCode = await new Promise((resolve, reject) => { + child.on('error', reject); + child.on('close', resolve); + }); + + const stdoutPath = `${params.artifactPrefix}.stdout.txt`; + const stderrPath = `${params.artifactPrefix}.stderr.txt`; + await writeFile(stdoutPath, normalizeTerminalTranscript(stdoutChunks.join(''))); + await writeFile(stderrPath, normalizeTerminalTranscript(stderrChunks.join(''))); + + return { + tool: params.tool, + iteration: 0, + exitCode, + wallClockMs: performance.now() - start, + firstStdoutMs, + firstMilestoneMs, + startupToFirstStreamedTestProgressMs, + stdoutPath, + stderrPath, + }; +} + +async function main(): Promise { + const { iterations, mode } = parseArgs(); + await ensureScriptAvailable(); + + const repoRoot = process.cwd(); + const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); + const outputDir = path.join(repoRoot, 'benchmarks', 'simulator-test', timestamp); + await mkdir(outputDir, { recursive: true }); + + const workspacePath = path.join(repoRoot, 'example_projects', 'iOS_Calculator', 'CalculatorApp.xcworkspace'); + const xcodebuildmcpDerivedDataPath = path.join(outputDir, 'derived-data-xcodebuildmcp'); + const flowdeckDerivedDataPath = path.join(outputDir, 'derived-data-flowdeck'); + const xcodebuildmcpPayload = JSON.stringify({ + workspacePath, + scheme: 'CalculatorApp', + simulatorName: 'iPhone 17 Pro', + useLatestOS: true, + extraArgs: ['-only-testing:CalculatorAppTests'], + progress: true, + derivedDataPath: xcodebuildmcpDerivedDataPath, + }); + + const results: RunMetrics[] = []; + + for (let iteration = 1; iteration <= iterations; iteration += 1) { + if (mode === 'cold') { + await rm(xcodebuildmcpDerivedDataPath, { recursive: true, force: true }); + await rm(flowdeckDerivedDataPath, { recursive: true, force: true }); + } + + const xcodebuildmcpResult = await runCommand({ + tool: 'xcodebuildmcp', + command: './build/cli.js', + args: ['simulator', 'test', '--json', xcodebuildmcpPayload, '--output', 'text'], + cwd: repoRoot, + artifactPrefix: path.join(outputDir, `xcodebuildmcp-run-${iteration}`), + milestonePattern: /๐Ÿ“ฆ\s*Resolving\s*packages|๐Ÿ› ๏ธ\s*Compiling|๐Ÿงช\s*(?:Starting\s*tests|Running\s*tests)/u, + streamedTestProgressPattern: /๐Ÿงช\s*(?:Starting\s*tests|Running\s*tests)/u, + }); + xcodebuildmcpResult.iteration = iteration; + results.push(xcodebuildmcpResult); + + const flowdeckResult = await runCommand({ + tool: 'flowdeck', + command: 'flowdeck', + args: [ + 'test', + '-w', + workspacePath, + '-s', + 'CalculatorApp', + '-S', + 'iPhone 17 Pro', + '--only', + 'CalculatorAppTests', + '--progress', + '-d', + flowdeckDerivedDataPath, + ], + cwd: repoRoot, + artifactPrefix: path.join(outputDir, `flowdeck-run-${iteration}`), + milestonePattern: /Resolving Package Graph|Compiling\.\.\.|Running tests/u, + streamedTestProgressPattern: /Running tests/u, + }); + flowdeckResult.iteration = iteration; + results.push(flowdeckResult); + } + + const summary = { + generatedAt: new Date().toISOString(), + mode, + iterations, + workspacePath, + results, + }; + + await writeFile(path.join(outputDir, 'summary.json'), JSON.stringify(summary, null, 2)); + process.stdout.write(`${JSON.stringify(summary, null, 2)}\n`); +} + +main().catch((error) => { + process.stderr.write(`${error instanceof Error ? error.stack ?? error.message : String(error)}\n`); + process.exitCode = 1; +}); diff --git a/scripts/capture-xcodebuild-wrapper.ts b/scripts/capture-xcodebuild-wrapper.ts new file mode 100644 index 00000000..209f0975 --- /dev/null +++ b/scripts/capture-xcodebuild-wrapper.ts @@ -0,0 +1,128 @@ +#!/usr/bin/env tsx + +import { chmod, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { spawn, spawnSync } from 'node:child_process'; + +interface WrapperCaptureRecord { + timestamp: string; + cwd: string; + argv: string[]; +} + +function parseArgs(): string[] { + const forwardedArgs = process.argv.slice(2); + if (forwardedArgs.length === 0) { + throw new Error('Usage: npm run capture:xcodebuild -- [args...]'); + } + + return forwardedArgs[0] === '--' ? forwardedArgs.slice(1) : forwardedArgs; +} + +function resolveRealXcodebuild(): string { + const result = spawnSync('xcrun', ['-f', 'xcodebuild'], { encoding: 'utf8' }); + if (result.status !== 0) { + throw new Error(result.stderr.trim() || 'Unable to resolve xcodebuild via xcrun'); + } + + const resolvedPath = result.stdout.trim(); + if (!resolvedPath) { + throw new Error('xcrun returned an empty xcodebuild path'); + } + + return resolvedPath; +} + +async function createWrapperScript(wrapperDir: string): Promise { + const wrapperPath = path.join(wrapperDir, 'xcodebuild'); + const script = `#!/usr/bin/env node +const { appendFileSync } = require('node:fs'); +const { spawn } = require('node:child_process'); + +const logPath = process.env.XCODEBUILD_WRAPPER_LOG_PATH; +const realPath = process.env.XCODEBUILD_WRAPPER_REAL_PATH; + +if (!logPath || !realPath) { + process.stderr.write('xcodebuild wrapper is missing required environment variables\\n'); + process.exit(1); +} + +appendFileSync( + logPath, + JSON.stringify({ + timestamp: new Date().toISOString(), + cwd: process.cwd(), + argv: process.argv.slice(2), + }) + '\\n', +); + +const child = spawn(realPath, process.argv.slice(2), { stdio: 'inherit' }); +child.on('exit', (code, signal) => { + if (signal) { + process.kill(process.pid, signal); + return; + } + process.exit(code ?? 1); +}); +child.on('error', (error) => { + process.stderr.write(String(error) + '\\n'); + process.exit(1); +}); +`; + + await writeFile(wrapperPath, script, { mode: 0o755 }); + await chmod(wrapperPath, 0o755); + return wrapperPath; +} + +async function main(): Promise { + const command = parseArgs(); + const realXcodebuildPath = resolveRealXcodebuild(); + const tempRoot = await mkdtemp(path.join(tmpdir(), 'xcodebuild-wrapper-')); + const wrapperDir = path.join(tempRoot, 'bin'); + const logDir = path.join(process.cwd(), 'benchmarks', 'xcodebuild-wrapper'); + const logPath = path.join(logDir, `${new Date().toISOString().replace(/[:.]/gu, '-')}.jsonl`); + + await mkdir(wrapperDir, { recursive: true }); + await mkdir(logDir, { recursive: true }); + await createWrapperScript(wrapperDir); + + const child = spawn(command[0]!, command.slice(1), { + cwd: process.cwd(), + env: { + ...process.env, + PATH: `${wrapperDir}:${process.env.PATH ?? ''}`, + XCODEBUILD_WRAPPER_LOG_PATH: logPath, + XCODEBUILD_WRAPPER_REAL_PATH: realXcodebuildPath, + }, + stdio: 'inherit', + }); + + const exitCode = await new Promise((resolve, reject) => { + child.on('error', reject); + child.on('close', (code) => resolve(code ?? 1)); + }); + + const recordsText = await readFile(logPath, 'utf8').catch(() => ''); + const records = recordsText + .split('\n') + .filter(Boolean) + .map((line) => JSON.parse(line) as WrapperCaptureRecord); + + process.stdout.write(`\nCaptured ${records.length} xcodebuild invocation(s)\n`); + process.stdout.write(`Log: ${logPath}\n`); + for (const [index, record] of records.entries()) { + process.stdout.write(`\n#${index + 1} ${record.timestamp}\n`); + process.stdout.write(`cwd: ${record.cwd}\n`); + process.stdout.write(`argv: ${record.argv.join(' ')}\n`); + } + + await rm(tempRoot, { recursive: true, force: true }); + process.exitCode = exitCode; +} + +main().catch((error) => { + process.stderr.write(`${error instanceof Error ? error.stack ?? error.message : String(error)}\n`); + process.exitCode = 1; +}); diff --git a/scripts/copy-build-assets.js b/scripts/copy-build-assets.js deleted file mode 100644 index b1c14038..00000000 --- a/scripts/copy-build-assets.js +++ /dev/null @@ -1,33 +0,0 @@ -#!/usr/bin/env node -/** - * Post-build script to copy assets and set permissions. - * Called after tsc compilation to prepare the build directory. - */ - -import { chmodSync, existsSync, copyFileSync, mkdirSync } from 'fs'; -import { dirname, join } from 'path'; -import { fileURLToPath } from 'url'; - -const __filename = fileURLToPath(import.meta.url); -const __dirname = dirname(__filename); -const projectRoot = join(__dirname, '..'); - -// Set executable permissions for entry points -const executables = ['build/cli.js', 'build/doctor-cli.js', 'build/daemon.js']; - -for (const file of executables) { - const fullPath = join(projectRoot, file); - if (existsSync(fullPath)) { - chmodSync(fullPath, '755'); - console.log(` Set executable: ${file}`); - } -} - -// Copy tools-manifest.json to build directory (for backward compatibility) -// This can be removed once Phase 7 is complete -const toolsManifestSrc = join(projectRoot, 'build', 'tools-manifest.json'); -if (existsSync(toolsManifestSrc)) { - console.log(' tools-manifest.json already in build/'); -} - -console.log('โœ… Build assets copied successfully'); diff --git a/src/cli/__tests__/output.test.ts b/src/cli/__tests__/output.test.ts new file mode 100644 index 00000000..01839b93 --- /dev/null +++ b/src/cli/__tests__/output.test.ts @@ -0,0 +1,162 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { createTextContent } from '../../types/common.ts'; +import { printToolResponse } from '../output.ts'; + +describe('printToolResponse', () => { + const originalNoColor = process.env.NO_COLOR; + const originalIsTTY = process.stdout.isTTY; + + afterEach(() => { + vi.restoreAllMocks(); + + if (originalNoColor === undefined) { + delete process.env.NO_COLOR; + } else { + process.env.NO_COLOR = originalNoColor; + } + + Object.defineProperty(process.stdout, 'isTTY', { + configurable: true, + value: originalIsTTY, + }); + }); + + it('colors inline errors red and summary failures with a red marker in text output when stdout is a TTY', () => { + const stdoutWrite = vi.spyOn(process.stdout, 'write').mockImplementation(() => true); + Object.defineProperty(process.stdout, 'isTTY', { + configurable: true, + value: true, + }); + delete process.env.NO_COLOR; + + printToolResponse({ + content: [ + createTextContent( + [ + 'Failed Tests', + 'CalculatorAppTests', + ' โœ— testCalculatorServiceFailure (0.009s)', + ' โ””โ”€ XCTAssertEqual failed: ("0") is not equal to ("999")', + ' /tmp/CalculatorAppTests.swift:52: error: XCTAssertEqual failed: ("0") is not equal to ("999")', + 'Test Summary', + 'error: compiler command failed with exit code 1', + 'โŒ Test Run test failed for scheme CalculatorApp.', + ].join('\n'), + ), + ], + isError: true, + }); + + const output = stdoutWrite.mock.calls.flat().join(''); + expect(output).toContain('Failed Tests\n'); + expect(output).toContain(' \u001B[31mโœ— \u001B[0mtestCalculatorServiceFailure (0.009s)\n'); + expect(output).toContain(' โ””โ”€ XCTAssertEqual failed: ("0") is not equal to ("999")\n'); + expect(output).toContain( + '\u001B[31m /tmp/CalculatorAppTests.swift:52: error: XCTAssertEqual failed: ("0") is not equal to ("999")\u001B[0m', + ); + expect(output).toContain( + '\u001B[31merror: compiler command failed with exit code 1\u001B[0m\n', + ); + expect(output).toContain( + '\u001B[31mโŒ \u001B[0mTest Run test failed for scheme CalculatorApp.\n', + ); + expect(output).toContain('CalculatorAppTests\n'); + expect(output).toContain('Test Summary\n'); + expect(process.exitCode).toBe(1); + }); + + it('does not replay already-streamed pipeline text in TTY mode', () => { + const stdoutWrite = vi.spyOn(process.stdout, 'write').mockImplementation(() => true); + Object.defineProperty(process.stdout, 'isTTY', { + configurable: true, + value: true, + }); + + printToolResponse({ + content: [createTextContent('build started'), createTextContent('app launched successfully')], + _meta: { + events: [{ type: 'start', timestamp: '2026-03-18T12:00:00.000Z' }], + streamedContentCount: 1, + }, + }); + + const output = stdoutWrite.mock.calls.flat().join(''); + expect(output).not.toContain('build started'); + expect(output).toContain('app launched successfully\n'); + }); + + it('prints next steps when all prior text was already streamed in TTY mode', () => { + const stdoutWrite = vi.spyOn(process.stdout, 'write').mockImplementation(() => true); + Object.defineProperty(process.stdout, 'isTTY', { + configurable: true, + value: true, + }); + + printToolResponse({ + content: [createTextContent('build succeeded')], + nextSteps: [ + { + tool: 'launch_app_sim', + workflow: 'simulator', + cliTool: 'launch-app-sim', + params: { simulatorId: 'SIM-1', bundleId: 'com.example.app' }, + }, + ], + _meta: { + events: [ + { + type: 'summary', + timestamp: '2026-03-18T12:00:00.000Z', + operation: 'BUILD', + status: 'SUCCEEDED', + }, + ], + streamedContentCount: 1, + }, + }); + + const output = stdoutWrite.mock.calls.flat().join(''); + expect(output).toContain('Next steps:\n'); + expect(output).toContain( + 'xcodebuildmcp simulator launch-app-sim --simulator-id "SIM-1" --bundle-id "com.example.app"\n', + ); + }); + + it('emits appended events as JSONL after the streamed event prefix', () => { + const stdoutWrite = vi.spyOn(process.stdout, 'write').mockImplementation(() => true); + + printToolResponse( + { + content: [createTextContent('build succeeded')], + _meta: { + events: [ + { type: 'start', timestamp: '2026-03-18T12:00:00.000Z' }, + { + type: 'summary', + timestamp: '2026-03-18T12:00:01.000Z', + operation: 'BUILD', + status: 'SUCCEEDED', + }, + { + type: 'next-steps', + timestamp: '2026-03-18T12:00:02.000Z', + steps: [{ tool: 'launch_app_sim' }], + }, + ], + streamedEventCount: 2, + streamedContentCount: 1, + }, + }, + { format: 'json' }, + ); + + const output = stdoutWrite.mock.calls.flat().join(''); + expect(output.trim()).toBe( + JSON.stringify({ + type: 'next-steps', + timestamp: '2026-03-18T12:00:02.000Z', + steps: [{ tool: 'launch_app_sim' }], + }), + ); + }); +}); diff --git a/src/cli/output.ts b/src/cli/output.ts index dca2ddd7..d2a40241 100644 --- a/src/cli/output.ts +++ b/src/cli/output.ts @@ -1,5 +1,6 @@ import type { ToolResponse, OutputStyle } from '../types/common.ts'; import { processToolResponse } from '../utils/responses/index.ts'; +import { formatCliTextLine } from '../utils/terminal-output.ts'; export type OutputFormat = 'text' | 'json'; @@ -12,6 +13,30 @@ function writeLine(text: string): void { process.stdout.write(`${text}\n`); } +function extractRenderedNextSteps(response: ToolResponse): string { + for (let index = (response.content?.length ?? 0) - 1; index >= 0; index -= 1) { + const item = response.content?.[index]; + if (!item || item.type !== 'text') { + continue; + } + + const nextStepsIndex = item.text.lastIndexOf('\n\nNext steps:\n'); + if (nextStepsIndex >= 0) { + return item.text.slice(nextStepsIndex + 2).trim(); + } + + if (item.text.startsWith('Next steps:\n')) { + return item.text.trim(); + } + } + + return ''; +} + +function isCompleteXcodebuildStream(response: ToolResponse): boolean { + return response._meta?.xcodebuildStreamMode === 'complete'; +} + /** * Print a tool response to the terminal. * Applies runtime-aware rendering of next steps for CLI output. @@ -22,13 +47,60 @@ export function printToolResponse( ): void { const { format = 'text', style = 'normal' } = options; + if (isCompleteXcodebuildStream(response)) { + if (response.isError) { + process.exitCode = 1; + } + return; + } + // Apply next steps rendering for CLI runtime const processed = processToolResponse(response, 'cli', style); if (format === 'json') { - writeLine(JSON.stringify(processed, null, 2)); + // When events were streamed as JSONL during execution, skip re-printing them + const hasStreamedEvents = Array.isArray(processed._meta?.events); + if (hasStreamedEvents) { + const events = processed._meta?.events as Array>; + const streamedEventCount = + typeof processed._meta?.streamedEventCount === 'number' + ? processed._meta.streamedEventCount + : events.length; + const appendedEvents = events.slice(streamedEventCount); + + for (const event of appendedEvents) { + writeLine(JSON.stringify(event)); + } + + // Events were already written to stdout as JSONL by the CLI JSONL renderer. + // Only emit non-event content (error messages, etc.) if present. + const nonEventContent = processed.content?.filter( + (item) => item.type !== 'text' || !item.text, + ); + if (nonEventContent && nonEventContent.length > 0) { + writeLine(JSON.stringify({ ...processed, content: nonEventContent }, null, 2)); + } + } else { + writeLine(JSON.stringify(processed, null, 2)); + } } else { - printToolResponseText(processed); + const hasStreamedEvents = Array.isArray(processed._meta?.events); + const streamedContentCount = + typeof processed._meta?.streamedContentCount === 'number' + ? processed._meta.streamedContentCount + : 0; + + if (hasStreamedEvents && process.stdout.isTTY === true) { + const printedAny = printToolResponseText(processed, streamedContentCount); + if (!printedAny && style !== 'minimal') { + const nextStepsText = extractRenderedNextSteps(processed); + if (nextStepsText.length > 0) { + writeLine(nextStepsText); + } + } + } else { + printToolResponseText(processed); + } } if (response.isError) { @@ -39,17 +111,30 @@ export function printToolResponse( /** * Print tool response content as text. */ -function printToolResponseText(response: ToolResponse): void { - for (const item of response.content ?? []) { +function printToolResponseText(response: ToolResponse, skipItems: number = 0): boolean { + let printed = false; + const content = response.content ?? []; + + for (const [index, item] of content.entries()) { + if (index < skipItems) { + continue; + } + if (item.type === 'text') { - writeLine(item.text); + for (const line of item.text.split('\n')) { + writeLine(formatCliTextLine(line)); + } + printed = true; } else if (item.type === 'image') { // For images, show a placeholder with metadata const sizeKb = Math.round((item.data.length * 3) / 4 / 1024); writeLine(`[Image: ${item.mimeType}, ~${sizeKb}KB base64]`); writeLine(' Use --output json to get the full image data'); + printed = true; } } + + return printed; } /** diff --git a/src/core/manifest/schema.ts b/src/core/manifest/schema.ts index 11e4e7e0..8a6024a1 100644 --- a/src/core/manifest/schema.ts +++ b/src/core/manifest/schema.ts @@ -63,6 +63,7 @@ export const manifestNextStepTemplateSchema = z toolId: z.string().optional(), params: z.record(z.string(), z.union([z.string(), z.number(), z.boolean()])).default({}), priority: z.number().optional(), + when: z.enum(['always', 'success', 'failure']).default('always'), }) .strict(); diff --git a/src/daemon.ts b/src/daemon.ts index 6c5468e0..2abf8ad2 100644 --- a/src/daemon.ts +++ b/src/daemon.ts @@ -208,9 +208,9 @@ async function main(): Promise { return { success: result.success, output: result.output }; }); const xcodeAvailable = Boolean( - xcodeVersion.version || - xcodeVersion.buildVersion || - xcodeVersion.developerDir || + xcodeVersion.version ?? + xcodeVersion.buildVersion ?? + xcodeVersion.developerDir ?? xcodeVersion.xcodebuildPath, ); const axeVersion = await getAxeVersionMetadata(async (command) => { diff --git a/src/mcp/tools/device/__tests__/build_device.test.ts b/src/mcp/tools/device/__tests__/build_device.test.ts index aa2153d9..44a982f0 100644 --- a/src/mcp/tools/device/__tests__/build_device.test.ts +++ b/src/mcp/tools/device/__tests__/build_device.test.ts @@ -14,6 +14,30 @@ import { import { schema, handler, buildDeviceLogic } from '../build_device.ts'; import { sessionStore } from '../../../../utils/session-store.ts'; +function expectPendingBuildResponse( + result: Awaited>, + nextStepToolId?: string, +): void { + 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(); + } +} + describe('build_device plugin', () => { beforeEach(() => { sessionStore.clear(); @@ -101,9 +125,8 @@ describe('build_device plugin', () => { mockExecutor, ); - expect(result.isError).toBeUndefined(); - expect(result.content).toHaveLength(2); - expect(result.content[0].text).toContain('โœ… iOS Device Build build succeeded'); + expect(result.isError).toBeFalsy(); + expectPendingBuildResponse(result, 'get_device_app_path'); }); it('should pass validation and execute successfully with valid workspace parameters', async () => { @@ -120,9 +143,8 @@ describe('build_device plugin', () => { mockExecutor, ); - expect(result.isError).toBeUndefined(); - expect(result.content).toHaveLength(2); - expect(result.content[0].text).toContain('โœ… iOS Device Build build succeeded'); + expect(result.isError).toBeFalsy(); + expectPendingBuildResponse(result, 'get_device_app_path'); }); it('should verify workspace command generation with mock executor', async () => { @@ -130,7 +152,6 @@ describe('build_device plugin', () => { args: string[]; logPrefix?: string; silent?: boolean; - opts: { cwd?: string } | undefined; }> = []; const stubExecutor = async ( @@ -140,7 +161,7 @@ describe('build_device plugin', () => { opts?: { cwd?: string }, _detached?: boolean, ) => { - commandCalls.push({ args, logPrefix, silent, opts }); + commandCalls.push({ args, logPrefix, silent }); return createMockCommandResponse({ success: true, output: 'Build succeeded', @@ -157,24 +178,20 @@ describe('build_device plugin', () => { ); expect(commandCalls).toHaveLength(1); - expect(commandCalls[0]).toEqual({ - args: [ - 'xcodebuild', - '-workspace', - '/path/to/MyProject.xcworkspace', - '-scheme', - 'MyScheme', - '-configuration', - 'Debug', - '-skipMacroValidation', - '-destination', - 'generic/platform=iOS', - 'build', - ], - logPrefix: 'iOS Device Build', - silent: false, - opts: { cwd: '/path/to' }, - }); + expect(commandCalls[0].args).toEqual([ + 'xcodebuild', + '-workspace', + '/path/to/MyProject.xcworkspace', + '-scheme', + 'MyScheme', + '-configuration', + 'Debug', + '-skipMacroValidation', + '-destination', + 'generic/platform=iOS', + 'build', + ]); + expect(commandCalls[0].logPrefix).toBe('iOS Device Build'); }); it('should verify command generation with mock executor', async () => { @@ -182,7 +199,6 @@ describe('build_device plugin', () => { args: string[]; logPrefix?: string; silent?: boolean; - opts: { cwd?: string } | undefined; }> = []; const stubExecutor = async ( @@ -192,7 +208,7 @@ describe('build_device plugin', () => { opts?: { cwd?: string }, _detached?: boolean, ) => { - commandCalls.push({ args, logPrefix, silent, opts }); + commandCalls.push({ args, logPrefix, silent }); return createMockCommandResponse({ success: true, output: 'Build succeeded', @@ -209,24 +225,20 @@ describe('build_device plugin', () => { ); expect(commandCalls).toHaveLength(1); - expect(commandCalls[0]).toEqual({ - args: [ - 'xcodebuild', - '-project', - '/path/to/MyProject.xcodeproj', - '-scheme', - 'MyScheme', - '-configuration', - 'Debug', - '-skipMacroValidation', - '-destination', - 'generic/platform=iOS', - 'build', - ], - logPrefix: 'iOS Device Build', - silent: false, - opts: { cwd: '/path/to' }, - }); + expect(commandCalls[0].args).toEqual([ + 'xcodebuild', + '-project', + '/path/to/MyProject.xcodeproj', + '-scheme', + 'MyScheme', + '-configuration', + 'Debug', + '-skipMacroValidation', + '-destination', + 'generic/platform=iOS', + 'build', + ]); + expect(commandCalls[0].logPrefix).toBe('iOS Device Build'); }); it('should return exact successful build response', async () => { @@ -243,18 +255,8 @@ describe('build_device plugin', () => { mockExecutor, ); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: 'โœ… iOS Device Build build succeeded for scheme MyScheme.', - }, - { - type: 'text', - text: "Next Steps:\n1. Get app path: get_device_app_path({ scheme: 'MyScheme' })\n2. Get bundle ID: get_app_bundle_id({ appPath: 'PATH_FROM_STEP_1' })\n3. Launch: launch_app_device({ bundleId: 'BUNDLE_ID_FROM_STEP_2' })", - }, - ], - }); + expect(result.isError).toBeFalsy(); + expectPendingBuildResponse(result, 'get_device_app_path'); }); it('should return exact build failure response', async () => { @@ -271,19 +273,8 @@ describe('build_device plugin', () => { mockExecutor, ); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: 'โŒ [stderr] Compilation error', - }, - { - type: 'text', - text: 'โŒ iOS Device Build build failed for scheme MyScheme.', - }, - ], - isError: true, - }); + expect(result.isError).toBe(true); + expectPendingBuildResponse(result); }); it('should include optional parameters in command', async () => { @@ -291,7 +282,6 @@ describe('build_device plugin', () => { args: string[]; logPrefix?: string; silent?: boolean; - opts: { cwd?: string } | undefined; }> = []; const stubExecutor = async ( @@ -301,7 +291,7 @@ describe('build_device plugin', () => { opts?: { cwd?: string }, _detached?: boolean, ) => { - commandCalls.push({ args, logPrefix, silent, opts }); + commandCalls.push({ args, logPrefix, silent }); return createMockCommandResponse({ success: true, output: 'Build succeeded', @@ -321,27 +311,23 @@ describe('build_device plugin', () => { ); expect(commandCalls).toHaveLength(1); - expect(commandCalls[0]).toEqual({ - args: [ - 'xcodebuild', - '-project', - '/path/to/MyProject.xcodeproj', - '-scheme', - 'MyScheme', - '-configuration', - 'Release', - '-skipMacroValidation', - '-destination', - 'generic/platform=iOS', - '-derivedDataPath', - '/tmp/derived-data', - '--verbose', - 'build', - ], - logPrefix: 'iOS Device Build', - silent: false, - opts: { cwd: '/path/to' }, - }); + expect(commandCalls[0].args).toEqual([ + 'xcodebuild', + '-project', + '/path/to/MyProject.xcodeproj', + '-scheme', + 'MyScheme', + '-configuration', + 'Release', + '-skipMacroValidation', + '-destination', + 'generic/platform=iOS', + '-derivedDataPath', + '/tmp/derived-data', + '--verbose', + 'build', + ]); + expect(commandCalls[0].logPrefix).toBe('iOS Device Build'); }); }); }); diff --git a/src/mcp/tools/device/__tests__/build_run_device.test.ts b/src/mcp/tools/device/__tests__/build_run_device.test.ts index bebe15f4..5ff9527e 100644 --- a/src/mcp/tools/device/__tests__/build_run_device.test.ts +++ b/src/mcp/tools/device/__tests__/build_run_device.test.ts @@ -1,254 +1,506 @@ +/** + * Tests for build_run_device plugin (unified) + * Following the canonical pending pipeline pattern from build_run_macos / build_run_sim. + */ + import { describe, it, expect, beforeEach } from 'vitest'; import * as z from 'zod'; import { createMockCommandResponse, createMockFileSystemExecutor, + createMockExecutor, } from '../../../../test-utils/mock-executors.ts'; import type { CommandExecutor } from '../../../../utils/execution/index.ts'; import { sessionStore } from '../../../../utils/session-store.ts'; +import { finalizePendingXcodebuildResponse } from '../../../../utils/xcodebuild-output.ts'; import { schema, handler, build_run_deviceLogic } from '../build_run_device.ts'; +function expectPendingBuildRunResponse( + result: Awaited>, + isError: boolean, +): void { + expect(result.isError).toBe(isError); + expect(result.content).toEqual([]); + expect(result._meta).toEqual( + expect.objectContaining({ + pendingXcodebuild: expect.objectContaining({ + kind: 'pending-xcodebuild', + }), + }), + ); +} + describe('build_run_device tool', () => { beforeEach(() => { sessionStore.clear(); }); - it('exposes only non-session fields in public schema', () => { - const schemaObj = z.strictObject(schema); + describe('Export Field Validation', () => { + it('exposes only non-session fields in public schema', () => { + const schemaObj = z.strictObject(schema); - expect(schemaObj.safeParse({}).success).toBe(true); - expect(schemaObj.safeParse({ extraArgs: ['-quiet'] }).success).toBe(true); - expect(schemaObj.safeParse({ env: { FOO: 'bar' } }).success).toBe(true); + expect(schemaObj.safeParse({}).success).toBe(true); + expect(schemaObj.safeParse({ extraArgs: ['-quiet'] }).success).toBe(true); + expect(schemaObj.safeParse({ env: { FOO: 'bar' } }).success).toBe(true); - expect(schemaObj.safeParse({ scheme: 'App' }).success).toBe(false); - expect(schemaObj.safeParse({ deviceId: 'device-id' }).success).toBe(false); + expect(schemaObj.safeParse({ scheme: 'App' }).success).toBe(false); + expect(schemaObj.safeParse({ deviceId: 'device-id' }).success).toBe(false); - const schemaKeys = Object.keys(schema).sort(); - expect(schemaKeys).toEqual(['env', 'extraArgs']); + const schemaKeys = Object.keys(schema).sort(); + expect(schemaKeys).toEqual(['env', 'extraArgs']); + }); }); - it('requires scheme + deviceId and project/workspace via handler', async () => { - const missingAll = await handler({}); - expect(missingAll.isError).toBe(true); - expect(missingAll.content[0].text).toContain('Provide scheme and deviceId'); + describe('Handler Requirements', () => { + it('requires scheme + deviceId and project/workspace via handler', async () => { + const missingAll = await handler({}); + expect(missingAll.isError).toBe(true); + expect(missingAll.content[0].text).toContain('Provide scheme and deviceId'); - const missingSource = await handler({ scheme: 'MyApp', deviceId: 'DEVICE-UDID' }); - expect(missingSource.isError).toBe(true); - expect(missingSource.content[0].text).toContain('Provide a project or workspace'); + const missingSource = await handler({ scheme: 'MyApp', deviceId: 'DEVICE-UDID' }); + expect(missingSource.isError).toBe(true); + expect(missingSource.content[0].text).toContain('Provide a project or workspace'); + }); }); - it('builds, installs, and launches successfully', async () => { - const commands: string[] = []; - const mockExecutor: CommandExecutor = async (command) => { - commands.push(command.join(' ')); - - if (command.includes('-showBuildSettings')) { - return createMockCommandResponse({ - success: true, - output: 'BUILT_PRODUCTS_DIR = /tmp/build\nFULL_PRODUCT_NAME = MyApp.app\n', - }); - } - - if (command[0] === '/bin/sh') { - return createMockCommandResponse({ success: true, output: 'io.sentry.MyApp' }); - } - - return createMockCommandResponse({ success: true, output: 'OK' }); - }; - - const result = await build_run_deviceLogic( - { - projectPath: '/tmp/MyApp.xcodeproj', - scheme: 'MyApp', - deviceId: 'DEVICE-UDID', - }, - mockExecutor, - createMockFileSystemExecutor({ - existsSync: () => true, - readFile: async () => JSON.stringify({ result: { process: { processIdentifier: 1234 } } }), - }), - ); + describe('Handler Behavior (Pending Pipeline Contract)', () => { + it('handles build failure as pending error', async () => { + const mockExecutor = createMockExecutor({ + success: false, + error: 'Build failed with error', + }); + + const result = await build_run_deviceLogic( + { + projectPath: '/tmp/MyApp.xcodeproj', + scheme: 'MyApp', + deviceId: 'DEVICE-UDID', + }, + mockExecutor, + createMockFileSystemExecutor(), + ); + + expectPendingBuildRunResponse(result, true); + expect(result.nextSteps).toBeUndefined(); + expect(result.nextStepParams).toBeUndefined(); + expect(result._meta?.pendingXcodebuild).toEqual( + expect.objectContaining({ + errorFallbackPolicy: 'if-no-structured-diagnostics', + tailEvents: [], + }), + ); + }); - expect(result.isError).toBe(false); - expect(result.content[0].text).toContain('device build and run succeeded'); - expect(result.nextStepParams).toMatchObject({ - start_device_log_cap: { deviceId: 'DEVICE-UDID', bundleId: 'io.sentry.MyApp' }, - stop_app_device: { deviceId: 'DEVICE-UDID', processId: 1234 }, + it('handles build settings failure as pending error', async () => { + const mockExecutor: CommandExecutor = async (command) => { + if (command.includes('-showBuildSettings')) { + return createMockCommandResponse({ success: false, error: 'no build settings' }); + } + return createMockCommandResponse({ success: true, output: 'OK' }); + }; + + const result = await build_run_deviceLogic( + { + projectPath: '/tmp/MyApp.xcodeproj', + scheme: 'MyApp', + deviceId: 'DEVICE-UDID', + }, + mockExecutor, + createMockFileSystemExecutor(), + ); + + expectPendingBuildRunResponse(result, true); + expect(result.nextSteps).toBeUndefined(); + expect(result.nextStepParams).toBeUndefined(); }); - expect(commands.some((c) => c.includes('xcodebuild') && c.includes('build'))).toBe(true); - expect(commands.some((c) => c.includes('xcodebuild') && c.includes('-showBuildSettings'))).toBe( - true, - ); - expect(commands.some((c) => c.includes('devicectl') && c.includes('install'))).toBe(true); - expect(commands.some((c) => c.includes('devicectl') && c.includes('launch'))).toBe(true); - }); + it('handles install failure as pending error', async () => { + const mockExecutor: CommandExecutor = async (command) => { + if (command.includes('-showBuildSettings')) { + return createMockCommandResponse({ + success: true, + output: 'BUILT_PRODUCTS_DIR = /tmp/build\nFULL_PRODUCT_NAME = MyApp.app\n', + }); + } + + if (command[0] === '/bin/sh') { + return createMockCommandResponse({ success: true, output: 'io.sentry.MyApp' }); + } + + if (command.includes('install')) { + return createMockCommandResponse({ success: false, error: 'install failed' }); + } + + return createMockCommandResponse({ success: true, output: 'OK' }); + }; + + const result = await build_run_deviceLogic( + { + projectPath: '/tmp/MyApp.xcodeproj', + scheme: 'MyApp', + deviceId: 'DEVICE-UDID', + }, + mockExecutor, + createMockFileSystemExecutor(), + ); + + expectPendingBuildRunResponse(result, true); + expect(result.nextSteps).toBeUndefined(); + expect(result.nextStepParams).toBeUndefined(); + }); - it('uses generic destination for build-settings lookup', async () => { - const commandCalls: string[][] = []; - const mockExecutor: CommandExecutor = async (command) => { - commandCalls.push(command); - - if (command.includes('-showBuildSettings')) { - return createMockCommandResponse({ - success: true, - output: 'BUILT_PRODUCTS_DIR = /tmp/build\nFULL_PRODUCT_NAME = MyWatchApp.app\n', - }); - } - - if (command[0] === '/bin/sh') { - return createMockCommandResponse({ success: true, output: 'io.sentry.MyWatchApp' }); - } - - if (command.includes('launch')) { - return createMockCommandResponse({ - success: true, - output: JSON.stringify({ result: { process: { processIdentifier: 9876 } } }), - }); - } - - return createMockCommandResponse({ success: true, output: 'OK' }); - }; - - const result = await build_run_deviceLogic( - { - projectPath: '/tmp/MyWatchApp.xcodeproj', - scheme: 'MyWatchApp', - platform: 'watchOS', - deviceId: 'DEVICE-UDID', - }, - mockExecutor, - createMockFileSystemExecutor({ existsSync: () => true }), - ); - - expect(result.isError).toBe(false); - - const showBuildSettingsCommand = commandCalls.find((command) => - command.includes('-showBuildSettings'), - ); - expect(showBuildSettingsCommand).toBeDefined(); - expect(showBuildSettingsCommand).toContain('-destination'); - - const destinationIndex = showBuildSettingsCommand!.indexOf('-destination'); - expect(showBuildSettingsCommand![destinationIndex + 1]).toBe('generic/platform=watchOS'); - }); + it('handles launch failure as pending error', async () => { + const mockExecutor: CommandExecutor = async (command) => { + if (command.includes('-showBuildSettings')) { + return createMockCommandResponse({ + success: true, + output: 'BUILT_PRODUCTS_DIR = /tmp/build\nFULL_PRODUCT_NAME = MyApp.app\n', + }); + } + + if (command[0] === '/bin/sh') { + return createMockCommandResponse({ success: true, output: 'io.sentry.MyApp' }); + } + + if (command.includes('launch')) { + return createMockCommandResponse({ success: false, error: 'launch failed' }); + } + + return createMockCommandResponse({ success: true, output: 'OK' }); + }; + + const result = await build_run_deviceLogic( + { + projectPath: '/tmp/MyApp.xcodeproj', + scheme: 'MyApp', + deviceId: 'DEVICE-UDID', + }, + mockExecutor, + createMockFileSystemExecutor(), + ); + + expectPendingBuildRunResponse(result, true); + expect(result.nextSteps).toBeUndefined(); + expect(result.nextStepParams).toBeUndefined(); + }); - it('includes fallback stop guidance when process id is unavailable', async () => { - const mockExecutor: CommandExecutor = async (command) => { - if (command.includes('-showBuildSettings')) { - return createMockCommandResponse({ - success: true, - output: 'BUILT_PRODUCTS_DIR = /tmp/build\nFULL_PRODUCT_NAME = MyApp.app\n', - }); - } - - if (command[0] === '/bin/sh') { - return createMockCommandResponse({ success: true, output: 'io.sentry.MyApp' }); - } - - return createMockCommandResponse({ success: true, output: 'OK' }); - }; - - const result = await build_run_deviceLogic( - { - projectPath: '/tmp/MyApp.xcodeproj', - scheme: 'MyApp', - deviceId: 'DEVICE-UDID', - }, - mockExecutor, - createMockFileSystemExecutor({ - existsSync: () => true, - readFile: async () => 'not-json', - }), - ); + it('handles successful build, install, and launch', async () => { + const mockExecutor: CommandExecutor = async (command) => { + if (command.includes('-showBuildSettings')) { + return createMockCommandResponse({ + success: true, + output: 'BUILT_PRODUCTS_DIR = /tmp/build\nFULL_PRODUCT_NAME = MyApp.app\n', + }); + } + + if (command[0] === '/bin/sh') { + return createMockCommandResponse({ success: true, output: 'io.sentry.MyApp' }); + } + + return createMockCommandResponse({ success: true, output: 'OK' }); + }; + + const result = await build_run_deviceLogic( + { + projectPath: '/tmp/MyApp.xcodeproj', + scheme: 'MyApp', + deviceId: 'DEVICE-UDID', + }, + mockExecutor, + createMockFileSystemExecutor({ + existsSync: () => true, + readFile: async () => + JSON.stringify({ result: { process: { processIdentifier: 1234 } } }), + }), + ); + + expectPendingBuildRunResponse(result, false); + expect(result.nextStepParams).toMatchObject({ + start_device_log_cap: { deviceId: 'DEVICE-UDID', bundleId: 'io.sentry.MyApp' }, + stop_app_device: { deviceId: 'DEVICE-UDID', processId: 1234 }, + }); + expect(result._meta?.pendingXcodebuild).toEqual( + expect.objectContaining({ + tailEvents: [ + expect.objectContaining({ + type: 'notice', + code: 'build-run-result', + data: expect.objectContaining({ + scheme: 'MyApp', + platform: 'iOS', + target: 'iOS Device', + appPath: '/tmp/build/MyApp.app', + bundleId: 'io.sentry.MyApp', + launchState: 'requested', + processId: 1234, + }), + }), + ], + }), + ); + }); - expect(result.isError).toBe(false); - expect(result.content[0].text).toContain('Process ID was unavailable'); - expect(result.nextStepParams).toMatchObject({ - start_device_log_cap: { deviceId: 'DEVICE-UDID', bundleId: 'io.sentry.MyApp' }, + it('succeeds without processId when launch JSON is unparseable', async () => { + const mockExecutor: CommandExecutor = async (command) => { + if (command.includes('-showBuildSettings')) { + return createMockCommandResponse({ + success: true, + output: 'BUILT_PRODUCTS_DIR = /tmp/build\nFULL_PRODUCT_NAME = MyApp.app\n', + }); + } + + if (command[0] === '/bin/sh') { + return createMockCommandResponse({ success: true, output: 'io.sentry.MyApp' }); + } + + return createMockCommandResponse({ success: true, output: 'OK' }); + }; + + const result = await build_run_deviceLogic( + { + projectPath: '/tmp/MyApp.xcodeproj', + scheme: 'MyApp', + deviceId: 'DEVICE-UDID', + }, + mockExecutor, + createMockFileSystemExecutor({ + existsSync: () => true, + readFile: async () => 'not-json', + }), + ); + + expectPendingBuildRunResponse(result, false); + expect(result.nextStepParams).toMatchObject({ + start_device_log_cap: { deviceId: 'DEVICE-UDID', bundleId: 'io.sentry.MyApp' }, + }); + expect(result.nextStepParams?.stop_app_device).toBeUndefined(); + + const tailEvents = ( + result._meta?.pendingXcodebuild as { tailEvents: Array<{ data: Record }> } + ).tailEvents; + expect(tailEvents[0].data.processId).toBeUndefined(); }); - expect(result.nextStepParams?.stop_app_device).toBeUndefined(); - }); - it('returns an error when app-path lookup fails after successful build', async () => { - const mockExecutor: CommandExecutor = async (command) => { - if (command.includes('-showBuildSettings')) { - return createMockCommandResponse({ success: false, error: 'no build settings' }); - } - return createMockCommandResponse({ success: true, output: 'OK' }); - }; - - const result = await build_run_deviceLogic( - { - projectPath: '/tmp/MyApp.xcodeproj', - scheme: 'MyApp', - deviceId: 'DEVICE-UDID', - }, - mockExecutor, - createMockFileSystemExecutor({ existsSync: () => true }), - ); - - expect(result.isError).toBe(true); - expect(result.content[0].text).toContain('failed to get app path'); - }); + it('uses generic destination for build-settings lookup', async () => { + const commandCalls: string[][] = []; + const mockExecutor: CommandExecutor = async (command) => { + commandCalls.push(command); + + if (command.includes('-showBuildSettings')) { + return createMockCommandResponse({ + success: true, + output: 'BUILT_PRODUCTS_DIR = /tmp/build\nFULL_PRODUCT_NAME = MyWatchApp.app\n', + }); + } + + if (command[0] === '/bin/sh') { + return createMockCommandResponse({ success: true, output: 'io.sentry.MyWatchApp' }); + } + + if (command.includes('launch')) { + return createMockCommandResponse({ + success: true, + output: JSON.stringify({ result: { process: { processIdentifier: 9876 } } }), + }); + } + + return createMockCommandResponse({ success: true, output: 'OK' }); + }; + + const result = await build_run_deviceLogic( + { + projectPath: '/tmp/MyWatchApp.xcodeproj', + scheme: 'MyWatchApp', + platform: 'watchOS', + deviceId: 'DEVICE-UDID', + }, + mockExecutor, + createMockFileSystemExecutor({ existsSync: () => true }), + ); + + expectPendingBuildRunResponse(result, false); + + const showBuildSettingsCommand = commandCalls.find((command) => + command.includes('-showBuildSettings'), + ); + expect(showBuildSettingsCommand).toBeDefined(); + expect(showBuildSettingsCommand).toContain('-destination'); + + const destinationIndex = showBuildSettingsCommand!.indexOf('-destination'); + expect(showBuildSettingsCommand![destinationIndex + 1]).toBe('generic/platform=watchOS'); + }); - it('returns an error when install fails', async () => { - const mockExecutor: CommandExecutor = async (command) => { - if (command.includes('-showBuildSettings')) { - return createMockCommandResponse({ - success: true, - output: 'BUILT_PRODUCTS_DIR = /tmp/build\nFULL_PRODUCT_NAME = MyApp.app\n', - }); - } - - if (command.includes('install')) { - return createMockCommandResponse({ success: false, error: 'install failed' }); - } - - return createMockCommandResponse({ success: true, output: 'io.sentry.MyApp' }); - }; - - const result = await build_run_deviceLogic( - { - projectPath: '/tmp/MyApp.xcodeproj', - scheme: 'MyApp', - deviceId: 'DEVICE-UDID', - }, - mockExecutor, - createMockFileSystemExecutor({ existsSync: () => true }), - ); - - expect(result.isError).toBe(true); - expect(result.content[0].text).toContain('error installing app on device'); + it('handles spawn error as pending error', async () => { + const mockExecutor = ( + command: string[], + description?: string, + logOutput?: boolean, + opts?: { cwd?: string }, + detached?: boolean, + ) => { + void command; + void description; + void logOutput; + void opts; + void detached; + return Promise.reject(new Error('spawn xcodebuild ENOENT')); + }; + + const result = await build_run_deviceLogic( + { + projectPath: '/tmp/MyApp.xcodeproj', + scheme: 'MyApp', + deviceId: 'DEVICE-UDID', + }, + mockExecutor, + createMockFileSystemExecutor(), + ); + + expectPendingBuildRunResponse(result, true); + expect(result.nextSteps).toBeUndefined(); + expect(result.nextStepParams).toBeUndefined(); + }); }); - it('returns an error when launch fails', async () => { - const mockExecutor: CommandExecutor = async (command) => { - if (command.includes('-showBuildSettings')) { - return createMockCommandResponse({ - success: true, - output: 'BUILT_PRODUCTS_DIR = /tmp/build\nFULL_PRODUCT_NAME = MyApp.app\n', - }); - } - - if (command.includes('launch')) { - return createMockCommandResponse({ success: false, error: 'launch failed' }); - } - - return createMockCommandResponse({ success: true, output: 'io.sentry.MyApp' }); - }; - - const result = await build_run_deviceLogic( - { - projectPath: '/tmp/MyApp.xcodeproj', - scheme: 'MyApp', - deviceId: 'DEVICE-UDID', - }, - mockExecutor, - createMockFileSystemExecutor({ existsSync: () => true }), - ); - - expect(result.isError).toBe(true); - expect(result.content[0].text).toContain('error launching app on device'); + describe('Finalized Output Contract', () => { + it('produces correct success output when finalized', async () => { + const mockExecutor: CommandExecutor = async (command) => { + if (command.includes('-showBuildSettings')) { + return createMockCommandResponse({ + success: true, + output: 'BUILT_PRODUCTS_DIR = /tmp/build\nFULL_PRODUCT_NAME = MyApp.app\n', + }); + } + + if (command[0] === '/bin/sh') { + return createMockCommandResponse({ success: true, output: 'io.sentry.MyApp' }); + } + + return createMockCommandResponse({ success: true, output: 'OK' }); + }; + + const result = await build_run_deviceLogic( + { + projectPath: '/tmp/MyApp.xcodeproj', + scheme: 'MyApp', + deviceId: 'DEVICE-UDID', + }, + mockExecutor, + createMockFileSystemExecutor({ + existsSync: () => true, + readFile: async () => JSON.stringify({ result: { process: { processIdentifier: 42 } } }), + }), + ); + + const finalized = finalizePendingXcodebuildResponse(result); + + expect(finalized.isError).toBe(false); + expect(finalized.content.length).toBeGreaterThan(0); + + const textContent = finalized.content + .filter((item) => item.type === 'text') + .map((item) => item.text) + .join('\n'); + + // Front matter + expect(textContent).toContain('Build & Run'); + expect(textContent).toContain('Scheme: MyApp'); + expect(textContent).toContain('Device: DEVICE-UDID'); + + // Summary + expect(textContent).toContain('Build succeeded.'); + + // Footer with execution-derived values + expect(textContent).toContain('Build & Run complete'); + expect(textContent).toContain('App Path: /tmp/build/MyApp.app'); + expect(textContent).toContain('Bundle ID: io.sentry.MyApp'); + expect(textContent).toContain('Process ID: 42'); + + // No next steps in finalized output (those come from tool invoker) + expect(textContent).not.toContain('Next steps:'); + }); + + it('produces correct failure output when finalized', async () => { + const mockExecutor = createMockExecutor({ + success: false, + error: 'Build failed', + }); + + const result = await build_run_deviceLogic( + { + projectPath: '/tmp/MyApp.xcodeproj', + scheme: 'MyApp', + deviceId: 'DEVICE-UDID', + }, + mockExecutor, + createMockFileSystemExecutor(), + ); + + const finalized = finalizePendingXcodebuildResponse(result); + + expect(finalized.isError).toBe(true); + const textContent = finalized.content + .filter((item) => item.type === 'text') + .map((item) => item.text) + .join('\n'); + + // Front matter present + expect(textContent).toContain('Build & Run'); + + // Summary present + expect(textContent).toContain('Build failed.'); + + // No next steps on failure + expect(textContent).not.toContain('Next steps:'); + }); + + it('produces correct post-build failure output when finalized', async () => { + const mockExecutor: CommandExecutor = async (command) => { + if (command.includes('-showBuildSettings')) { + return createMockCommandResponse({ + success: true, + output: 'BUILT_PRODUCTS_DIR = /tmp/build\nFULL_PRODUCT_NAME = MyApp.app\n', + }); + } + + if (command[0] === '/bin/sh') { + return createMockCommandResponse({ success: true, output: 'io.sentry.MyApp' }); + } + + if (command.includes('install')) { + return createMockCommandResponse({ success: false, error: 'Device not connected' }); + } + + return createMockCommandResponse({ success: true, output: 'OK' }); + }; + + const result = await build_run_deviceLogic( + { + projectPath: '/tmp/MyApp.xcodeproj', + scheme: 'MyApp', + deviceId: 'DEVICE-UDID', + }, + mockExecutor, + createMockFileSystemExecutor(), + ); + + const finalized = finalizePendingXcodebuildResponse(result); + + expect(finalized.isError).toBe(true); + const textContent = finalized.content + .filter((item) => item.type === 'text') + .map((item) => item.text) + .join('\n'); + + // Front matter present + expect(textContent).toContain('Build & Run'); + + // Error and summary present + expect(textContent).toContain('Failed to install app on device'); + expect(textContent).toContain('Build failed.'); + + // No next steps on failure + expect(textContent).not.toContain('Next steps:'); + }); }); }); diff --git a/src/mcp/tools/device/__tests__/get_device_app_path.test.ts b/src/mcp/tools/device/__tests__/get_device_app_path.test.ts index 67927372..1a676e74 100644 --- a/src/mcp/tools/device/__tests__/get_device_app_path.test.ts +++ b/src/mcp/tools/device/__tests__/get_device_app_path.test.ts @@ -259,21 +259,22 @@ describe('get_device_app_path plugin', () => { mockExecutor, ); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: 'โœ… App path retrieved successfully: /path/to/build/Debug-iphoneos/MyApp.app', - }, - ], - nextStepParams: { - get_app_bundle_id: { appPath: '/path/to/build/Debug-iphoneos/MyApp.app' }, - install_app_device: { - deviceId: 'DEVICE_UDID', - appPath: '/path/to/build/Debug-iphoneos/MyApp.app', - }, - launch_app_device: { deviceId: 'DEVICE_UDID', bundleId: 'BUNDLE_ID' }, + expect(result.isError).toBeUndefined(); + expect(result.content[0].text).toContain('\u{1F50D} Get App Path'); + expect(result.content[0].text).toContain('Scheme: MyScheme'); + expect(result.content[0].text).toContain('Project: /path/to/project.xcodeproj'); + expect(result.content[0].text).toContain('Configuration: Debug'); + expect(result.content[0].text).toContain('Platform: iOS'); + expect(result.content[0].text).toContain( + '\u{2514} App Path: /path/to/build/Debug-iphoneos/MyApp.app', + ); + expect(result.nextStepParams).toEqual({ + get_app_bundle_id: { appPath: '/path/to/build/Debug-iphoneos/MyApp.app' }, + install_app_device: { + deviceId: 'DEVICE_UDID', + appPath: '/path/to/build/Debug-iphoneos/MyApp.app', }, + launch_app_device: { deviceId: 'DEVICE_UDID', bundleId: 'BUNDLE_ID' }, }); }); @@ -291,15 +292,14 @@ describe('get_device_app_path plugin', () => { mockExecutor, ); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: 'Failed to get app path: xcodebuild: error: The project does not exist.', - }, - ], - isError: true, - }); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('\u{1F50D} Get App Path'); + expect(result.content[0].text).toContain('Scheme: MyScheme'); + expect(result.content[0].text).toContain('Project: /path/to/nonexistent.xcodeproj'); + expect(result.content[0].text).toContain('Errors (1):'); + expect(result.content[0].text).toContain('\u{2717} The project does not exist.'); + expect(result.content[0].text).toContain('\u{274C} Query failed.'); + expect(result.nextStepParams).toBeUndefined(); }); it('should return exact parse failure response', async () => { @@ -316,15 +316,14 @@ describe('get_device_app_path plugin', () => { mockExecutor, ); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: 'Failed to extract app path from build settings. Make sure the app has been built first.', - }, - ], - isError: true, - }); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('\u{1F50D} Get App Path'); + expect(result.content[0].text).toContain('Errors (1):'); + expect(result.content[0].text).toContain( + '\u{2717} Could not extract app path from build settings.', + ); + expect(result.content[0].text).toContain('\u{274C} Query failed.'); + expect(result.nextStepParams).toBeUndefined(); }); it('should include optional configuration parameter in command', async () => { @@ -401,15 +400,12 @@ describe('get_device_app_path plugin', () => { mockExecutor, ); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: 'Error retrieving app path: Network error', - }, - ], - isError: true, - }); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('\u{1F50D} Get App Path'); + expect(result.content[0].text).toContain('Errors (1):'); + expect(result.content[0].text).toContain('\u{2717} Network error'); + expect(result.content[0].text).toContain('\u{274C} Query failed.'); + expect(result.nextStepParams).toBeUndefined(); }); it('should return exact string error handling response', async () => { @@ -425,15 +421,12 @@ describe('get_device_app_path plugin', () => { mockExecutor, ); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: 'Error retrieving app path: String error', - }, - ], - isError: true, - }); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('\u{1F50D} Get App Path'); + expect(result.content[0].text).toContain('Errors (1):'); + expect(result.content[0].text).toContain('\u{2717} String error'); + expect(result.content[0].text).toContain('\u{274C} Query failed.'); + expect(result.nextStepParams).toBeUndefined(); }); }); }); diff --git a/src/mcp/tools/device/__tests__/test_device.test.ts b/src/mcp/tools/device/__tests__/test_device.test.ts index 3248002a..0d89539f 100644 --- a/src/mcp/tools/device/__tests__/test_device.test.ts +++ b/src/mcp/tools/device/__tests__/test_device.test.ts @@ -7,12 +7,32 @@ import { describe, it, expect, beforeEach } from 'vitest'; import * as z from 'zod'; import { - createMockCommandResponse, createMockExecutor, createMockFileSystemExecutor, } from '../../../../test-utils/mock-executors.ts'; import { schema, handler, testDeviceLogic } from '../test_device.ts'; import { sessionStore } from '../../../../utils/session-store.ts'; +import { + isPendingXcodebuildResponse, + finalizePendingXcodebuildResponse, +} from '../../../../utils/xcodebuild-output.ts'; +import type { ToolResponse } from '../../../../types/common.ts'; + +const mockFs = () => + createMockFileSystemExecutor({ + mkdtemp: async () => '/tmp/test-123', + rm: async () => {}, + tmpdir: () => '/tmp', + stat: async () => ({ isDirectory: () => false, mtimeMs: 0 }), + }); + +function finalizeAndGetText(result: ToolResponse): string { + if (isPendingXcodebuildResponse(result)) { + const finalized = finalizePendingXcodebuildResponse(result); + return finalized.content.map((c) => c.text).join('\n'); + } + return result.content.map((c) => c.text).join('\n'); +} describe('test_device plugin', () => { beforeEach(() => { @@ -45,22 +65,11 @@ describe('test_device plugin', () => { }); it('should validate XOR between projectPath and workspacePath', async () => { - // This would be validated at the schema level via createTypedTool - // We test the schema validation through successful logic calls instead const mockExecutor = createMockExecutor({ success: true, - output: JSON.stringify({ - title: 'Test Schema', - result: 'SUCCESS', - totalTestCount: 1, - passedTests: 1, - failedTests: 0, - skippedTests: 0, - expectedFailures: 0, - }), + output: 'Test Succeeded', }); - // Valid: project path only const projectResult = await testDeviceLogic( { projectPath: '/path/to/project.xcodeproj', @@ -68,16 +77,11 @@ describe('test_device plugin', () => { deviceId: 'test-device-123', }, mockExecutor, - createMockFileSystemExecutor({ - mkdtemp: async () => '/tmp/xcodebuild-test-123', - tmpdir: () => '/tmp', - stat: async () => ({ isDirectory: () => false, mtimeMs: 0 }), - rm: async () => {}, - }), + mockFs(), ); + expect(isPendingXcodebuildResponse(projectResult)).toBe(true); expect(projectResult.isError).toBeFalsy(); - // Valid: workspace path only const workspaceResult = await testDeviceLogic( { workspacePath: '/path/to/workspace.xcworkspace', @@ -85,13 +89,9 @@ describe('test_device plugin', () => { deviceId: 'test-device-123', }, mockExecutor, - createMockFileSystemExecutor({ - mkdtemp: async () => '/tmp/xcodebuild-test-456', - tmpdir: () => '/tmp', - stat: async () => ({ isDirectory: () => false, mtimeMs: 0 }), - rm: async () => {}, - }), + mockFs(), ); + expect(isPendingXcodebuildResponse(workspaceResult)).toBe(true); expect(workspaceResult.isError).toBeFalsy(); }); }); @@ -129,23 +129,10 @@ describe('test_device plugin', () => { }); describe('Handler Behavior (Complete Literal Returns)', () => { - beforeEach(() => { - // Clean setup for standard testing pattern - }); - - it('should return successful test response with parsed results', async () => { - // Mock xcresulttool output + it('should return pending response for successful tests', async () => { const mockExecutor = createMockExecutor({ success: true, - output: JSON.stringify({ - title: 'MyScheme Tests', - result: 'SUCCESS', - totalTestCount: 5, - passedTests: 5, - failedTests: 0, - skippedTests: 0, - expectedFailures: 0, - }), + output: 'Test Succeeded', }); const result = await testDeviceLogic( @@ -158,40 +145,21 @@ describe('test_device plugin', () => { platform: 'iOS', }, mockExecutor, - createMockFileSystemExecutor({ - mkdtemp: async () => '/tmp/xcodebuild-test-123456', - tmpdir: () => '/tmp', - stat: async () => ({ isDirectory: () => false, mtimeMs: 0 }), - rm: async () => {}, - }), + mockFs(), ); - expect(result.content).toHaveLength(2); - expect(result.content[0].text).toContain('Test Results Summary:'); - expect(result.content[0].text).toContain('MyScheme Tests'); - expect(result.content[1].text).toContain('โœ…'); + expect(isPendingXcodebuildResponse(result)).toBe(true); + expect(result.isError).toBeFalsy(); + const allText = finalizeAndGetText(result); + expect(allText).toContain('Scheme: MyScheme'); + expect(allText).toContain('succeeded'); }); - it('should handle test failure scenarios', async () => { - // Mock xcresulttool output for failed tests + it('should return pending response for test failures', async () => { const mockExecutor = createMockExecutor({ - success: true, - output: JSON.stringify({ - title: 'MyScheme Tests', - result: 'FAILURE', - totalTestCount: 5, - passedTests: 3, - failedTests: 2, - skippedTests: 0, - expectedFailures: 0, - testFailures: [ - { - testName: 'testExample', - targetName: 'MyTarget', - failureText: 'Expected true but was false', - }, - ], - }), + success: false, + output: '', + error: 'error: Test failed', }); const result = await testDeviceLogic( @@ -204,101 +172,22 @@ describe('test_device plugin', () => { platform: 'iOS', }, mockExecutor, - createMockFileSystemExecutor({ - mkdtemp: async () => '/tmp/xcodebuild-test-123456', - tmpdir: () => '/tmp', - stat: async () => ({ isDirectory: () => false, mtimeMs: 0 }), - rm: async () => {}, - }), - ); - - expect(result.content).toHaveLength(2); - expect(result.content[0].text).toContain('Test Failures:'); - expect(result.content[0].text).toContain('testExample'); - }); - - it('should handle xcresult parsing failures gracefully', async () => { - // Create a multi-call mock that handles different commands - let callCount = 0; - const mockExecutor = async ( - _args: string[], - _description?: string, - _useShell?: boolean, - _opts?: { cwd?: string }, - _detached?: boolean, - ) => { - callCount++; - - // First call is for xcodebuild test (successful) - if (callCount === 1) { - return createMockCommandResponse({ success: true, output: 'BUILD SUCCEEDED' }); - } - - // Second call is for xcresulttool (fails) - return createMockCommandResponse({ success: false, error: 'xcresulttool failed' }); - }; - - const result = await testDeviceLogic( - { - projectPath: '/path/to/project.xcodeproj', - scheme: 'MyScheme', - deviceId: 'test-device-123', - configuration: 'Debug', - preferXcodebuild: false, - platform: 'iOS', - }, - mockExecutor, - createMockFileSystemExecutor({ - mkdtemp: async () => '/tmp/xcodebuild-test-123456', - tmpdir: () => '/tmp', - stat: async () => { - throw new Error('File not found'); - }, - rm: async () => {}, - }), + mockFs(), ); - // When xcresult parsing fails, it falls back to original test result only - expect(result.content).toHaveLength(1); - expect(result.content[0].text).toContain('โœ…'); + expect(isPendingXcodebuildResponse(result)).toBe(true); + expect(result.isError).toBe(true); + const allText = finalizeAndGetText(result); + expect(allText).toContain('Scheme: MyScheme'); + expect(allText).toContain('failed'); }); - it('should preserve stderr when xcresult reports zero tests (build failure)', async () => { - // When the build fails, xcresult exists but has totalTestCount: 0. - // stderr contains the actual compilation errors and must be preserved. - let callCount = 0; - const mockExecutor = async ( - _args: string[], - _description?: string, - _useShell?: boolean, - _opts?: { cwd?: string }, - _detached?: boolean, - ) => { - callCount++; - - // First call: xcodebuild test fails with compilation error - if (callCount === 1) { - return createMockCommandResponse({ - success: false, - output: '', - error: 'error: missing argument for parameter in call', - }); - } - - // Second call: xcresulttool succeeds but reports 0 tests - return createMockCommandResponse({ - success: true, - output: JSON.stringify({ - title: 'Test Results', - result: 'unknown', - totalTestCount: 0, - passedTests: 0, - failedTests: 0, - skippedTests: 0, - expectedFailures: 0, - }), - }); - }; + it('should handle build failure with pending response', async () => { + const mockExecutor = createMockExecutor({ + success: false, + output: '', + error: 'error: missing argument for parameter in call', + }); const result = await testDeviceLogic( { @@ -310,36 +199,19 @@ describe('test_device plugin', () => { platform: 'iOS', }, mockExecutor, - createMockFileSystemExecutor({ - mkdtemp: async () => '/tmp/xcodebuild-test-buildfail', - tmpdir: () => '/tmp', - stat: async () => ({ isDirectory: () => false, mtimeMs: 0 }), - rm: async () => {}, - }), + mockFs(), ); - // stderr with compilation error must be preserved - const allText = result.content.map((c) => c.text).join('\n'); - expect(allText).toContain('[stderr]'); - expect(allText).toContain('missing argument'); - - // xcresult summary should NOT be present - expect(allText).not.toContain('Test Results Summary:'); + expect(isPendingXcodebuildResponse(result)).toBe(true); + expect(result.isError).toBe(true); + const allText = finalizeAndGetText(result); + expect(allText).toContain('failed'); }); it('should support different platforms', async () => { - // Mock xcresulttool output const mockExecutor = createMockExecutor({ success: true, - output: JSON.stringify({ - title: 'WatchApp Tests', - result: 'SUCCESS', - totalTestCount: 3, - passedTests: 3, - failedTests: 0, - skippedTests: 0, - expectedFailures: 0, - }), + output: 'Test Succeeded', }); const result = await testDeviceLogic( @@ -352,31 +224,20 @@ describe('test_device plugin', () => { platform: 'watchOS', }, mockExecutor, - createMockFileSystemExecutor({ - mkdtemp: async () => '/tmp/xcodebuild-test-123456', - tmpdir: () => '/tmp', - stat: async () => ({ isDirectory: () => false, mtimeMs: 0 }), - rm: async () => {}, - }), + mockFs(), ); - expect(result.content).toHaveLength(2); - expect(result.content[0].text).toContain('WatchApp Tests'); + expect(isPendingXcodebuildResponse(result)).toBe(true); + expect(result.isError).toBeFalsy(); + const allText = finalizeAndGetText(result); + expect(allText).toContain('Scheme: WatchApp'); + expect(allText).toContain('succeeded'); }); it('should handle optional parameters', async () => { - // Mock xcresulttool output const mockExecutor = createMockExecutor({ success: true, - output: JSON.stringify({ - title: 'Tests', - result: 'SUCCESS', - totalTestCount: 1, - passedTests: 1, - failedTests: 0, - skippedTests: 0, - expectedFailures: 0, - }), + output: 'Test Succeeded', }); const result = await testDeviceLogic( @@ -391,32 +252,19 @@ describe('test_device plugin', () => { platform: 'iOS', }, mockExecutor, - createMockFileSystemExecutor({ - mkdtemp: async () => '/tmp/xcodebuild-test-123456', - tmpdir: () => '/tmp', - stat: async () => ({ isDirectory: () => false, mtimeMs: 0 }), - rm: async () => {}, - }), + mockFs(), ); - expect(result.content).toHaveLength(2); - expect(result.content[0].text).toContain('Test Results Summary:'); - expect(result.content[1].text).toContain('โœ…'); + expect(isPendingXcodebuildResponse(result)).toBe(true); + expect(result.isError).toBeFalsy(); + const allText = finalizeAndGetText(result); + expect(allText).toContain('succeeded'); }); it('should handle workspace testing successfully', async () => { - // Mock xcresulttool output const mockExecutor = createMockExecutor({ success: true, - output: JSON.stringify({ - title: 'WorkspaceScheme Tests', - result: 'SUCCESS', - totalTestCount: 10, - passedTests: 10, - failedTests: 0, - skippedTests: 0, - expectedFailures: 0, - }), + output: 'Test Succeeded', }); const result = await testDeviceLogic( @@ -429,18 +277,14 @@ describe('test_device plugin', () => { platform: 'iOS', }, mockExecutor, - createMockFileSystemExecutor({ - mkdtemp: async () => '/tmp/xcodebuild-test-workspace-123', - tmpdir: () => '/tmp', - stat: async () => ({ isDirectory: () => false, mtimeMs: 0 }), - rm: async () => {}, - }), + mockFs(), ); - expect(result.content).toHaveLength(2); - expect(result.content[0].text).toContain('Test Results Summary:'); - expect(result.content[0].text).toContain('WorkspaceScheme Tests'); - expect(result.content[1].text).toContain('โœ…'); + expect(isPendingXcodebuildResponse(result)).toBe(true); + expect(result.isError).toBeFalsy(); + const allText = finalizeAndGetText(result); + expect(allText).toContain('Scheme: WorkspaceScheme'); + expect(allText).toContain('succeeded'); }); }); }); diff --git a/src/mcp/tools/device/build-settings.ts b/src/mcp/tools/device/build-settings.ts index 9a6f64fd..30ac2416 100644 --- a/src/mcp/tools/device/build-settings.ts +++ b/src/mcp/tools/device/build-settings.ts @@ -1,18 +1,12 @@ -import path from 'node:path'; import { XcodePlatform } from '../../../types/common.ts'; -import type { CommandExecutor } from '../../../utils/execution/index.ts'; -function resolvePathFromCwd(pathValue?: string): string | undefined { - if (!pathValue) { - return undefined; - } - - if (path.isAbsolute(pathValue)) { - return pathValue; - } +export { + getBuildSettingsDestination, + extractAppPathFromBuildSettingsOutput, + resolveAppPathFromBuildSettings, +} from '../../../utils/app-path-resolver.ts'; - return path.resolve(process.cwd(), pathValue); -} +export type { ResolveAppPathFromBuildSettingsParams } from '../../../utils/app-path-resolver.ts'; export type DevicePlatform = 'iOS' | 'watchOS' | 'tvOS' | 'visionOS'; @@ -30,78 +24,3 @@ export function mapDevicePlatform(platform?: DevicePlatform): XcodePlatform { return XcodePlatform.iOS; } } - -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; - derivedDataPath?: string; - extraArgs?: string[]; -}; - -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 = resolvePathFromCwd(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'); - command.push('-destination', getBuildSettingsDestination(params.platform, params.deviceId)); - - if (derivedDataPath) { - 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/mcp/tools/device/build_device.ts b/src/mcp/tools/device/build_device.ts index fd649122..4fbc63bf 100644 --- a/src/mcp/tools/device/build_device.ts +++ b/src/mcp/tools/device/build_device.ts @@ -16,6 +16,9 @@ import { getSessionAwareToolSchemaShape, } from '../../../utils/typed-tool-factory.ts'; import { nullifyEmptyStrings } from '../../../utils/schema-helpers.ts'; +import { startBuildPipeline } from '../../../utils/xcodebuild-pipeline.ts'; +import { createPendingXcodebuildResponse } from '../../../utils/xcodebuild-output.ts'; +import { formatToolPreflight } from '../../../utils/build-preflight.ts'; // Unified schema: XOR between projectPath and workspacePath const baseSchemaObject = z.object({ @@ -60,18 +63,59 @@ export async function buildDeviceLogic( ): Promise { const processedParams = { ...params, - configuration: params.configuration ?? 'Debug', // Default config + configuration: params.configuration ?? 'Debug', }; - return executeXcodeBuildCommand( + const platformOptions = { + platform: XcodePlatform.iOS, + logPrefix: 'iOS Device Build', + }; + + const preflightText = formatToolPreflight({ + operation: 'Build', + scheme: params.scheme, + workspacePath: params.workspacePath, + projectPath: params.projectPath, + configuration: processedParams.configuration, + platform: 'iOS', + }); + + const pipelineParams = { + scheme: params.scheme, + configuration: processedParams.configuration, + platform: 'iOS', + preflight: preflightText, + }; + + const started = startBuildPipeline({ + operation: 'BUILD', + toolName: 'build_device', + params: pipelineParams, + message: preflightText, + }); + + const buildResult = await executeXcodeBuildCommand( processedParams, - { - platform: XcodePlatform.iOS, - logPrefix: 'iOS Device Build', - }, + platformOptions, params.preferXcodebuild ?? false, 'build', executor, + undefined, + started.pipeline, + ); + + return createPendingXcodebuildResponse( + started, + buildResult.isError + ? buildResult + : { + ...buildResult, + nextStepParams: { + get_device_app_path: { + scheme: params.scheme, + }, + }, + }, ); } diff --git a/src/mcp/tools/device/build_run_device.ts b/src/mcp/tools/device/build_run_device.ts index a8f8d9ba..795a9d6e 100644 --- a/src/mcp/tools/device/build_run_device.ts +++ b/src/mcp/tools/device/build_run_device.ts @@ -4,11 +4,10 @@ * Builds, installs, and launches an app on a physical Apple device. */ +import { join } from 'node:path'; import * as z from 'zod'; -import type { ToolResponse, SharedBuildParams } from '../../../types/common.ts'; -import { XcodePlatform } from '../../../types/common.ts'; +import type { ToolResponse, SharedBuildParams, NextStepParamsMap } from '../../../types/common.ts'; import { log } from '../../../utils/logging/index.ts'; -import { createTextResponse } from '../../../utils/responses/index.ts'; import { executeXcodeBuildCommand } from '../../../utils/build/index.ts'; import type { CommandExecutor, FileSystemExecutor } from '../../../utils/execution/index.ts'; import { @@ -19,11 +18,18 @@ import { createSessionAwareTool, getSessionAwareToolSchemaShape, } from '../../../utils/typed-tool-factory.ts'; +import { createTextResponse } from '../../../utils/responses/index.ts'; import { nullifyEmptyStrings } from '../../../utils/schema-helpers.ts'; import { extractBundleIdFromAppPath } from '../../../utils/bundle-id.ts'; -import { install_app_deviceLogic } from './install_app_device.ts'; -import { launch_app_deviceLogic } from './launch_app_device.ts'; -import { mapDevicePlatform, resolveAppPathFromBuildSettings } from './build-settings.ts'; +import { mapDevicePlatform } from './build-settings.ts'; +import { startBuildPipeline } from '../../../utils/xcodebuild-pipeline.ts'; +import { formatToolPreflight } from '../../../utils/build-preflight.ts'; +import { + createPendingXcodebuildResponse, + emitPipelineError, + emitPipelineNotice, +} from '../../../utils/xcodebuild-output.ts'; +import { resolveAppPathFromBuildSettings } from '../../../utils/app-path-resolver.ts'; const baseSchemaObject = z.object({ projectPath: z.string().optional().describe('Path to the .xcodeproj file'), @@ -54,26 +60,6 @@ const buildRunDeviceSchema = z.preprocess( export type BuildRunDeviceParams = z.infer; -function extractResponseText(response: ToolResponse): string { - return String(response.content[0]?.text ?? 'Unknown error'); -} - -function getSuccessText( - platform: XcodePlatform, - scheme: string, - bundleId: string, - deviceId: string, - hasStopHint: boolean, -): string { - const summary = `${platform} device build and run succeeded for scheme ${scheme}.\n\nThe app (${bundleId}) is now running on device ${deviceId}.`; - - if (hasStopHint) { - return summary; - } - - return `${summary}\n\nNote: Process ID was unavailable, so stop_app_device could not be auto-suggested. To stop the app manually, use stop_app_device with the correct processId.`; -} - export async function build_run_deviceLogic( params: BuildRunDeviceParams, executor: CommandExecutor, @@ -81,130 +67,261 @@ export async function build_run_deviceLogic( ): Promise { const platform = mapDevicePlatform(params.platform); - const sharedBuildParams: SharedBuildParams = { - projectPath: params.projectPath, - workspacePath: params.workspacePath, - scheme: params.scheme, - configuration: params.configuration ?? 'Debug', - derivedDataPath: params.derivedDataPath, - extraArgs: params.extraArgs, - }; - - const buildResult = await executeXcodeBuildCommand( - sharedBuildParams, - { + try { + const configuration = params.configuration ?? 'Debug'; + + const sharedBuildParams: SharedBuildParams = { + projectPath: params.projectPath, + workspacePath: params.workspacePath, + scheme: params.scheme, + configuration, + derivedDataPath: params.derivedDataPath, + extraArgs: params.extraArgs, + }; + + const platformOptions = { platform, logPrefix: `${platform} Device Build`, - }, - params.preferXcodebuild ?? false, - 'build', - executor, - ); - - if (buildResult.isError) { - return buildResult; - } + }; - let appPath: string; - try { - appPath = await resolveAppPathFromBuildSettings( - { - projectPath: params.projectPath, - workspacePath: params.workspacePath, + const preflightText = formatToolPreflight({ + operation: 'Build & Run', + scheme: params.scheme, + workspacePath: params.workspacePath, + projectPath: params.projectPath, + configuration, + platform: String(platform), + deviceId: params.deviceId, + }); + + const started = startBuildPipeline({ + operation: 'BUILD', + toolName: 'build_run_device', + params: { scheme: params.scheme, - configuration: params.configuration, - platform, - derivedDataPath: params.derivedDataPath, - extraArgs: params.extraArgs, + configuration, + platform: String(platform), + preflight: preflightText, }, + message: preflightText, + }); + + // Build + const buildResult = await executeXcodeBuildCommand( + sharedBuildParams, + platformOptions, + params.preferXcodebuild ?? false, + 'build', executor, + undefined, + started.pipeline, ); - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - return createTextResponse(`Build succeeded, but failed to get app path: ${errorMessage}`, true); - } - let bundleId: string; - try { - bundleId = (await extractBundleIdFromAppPath(appPath, executor)).trim(); - if (bundleId.length === 0) { - return createTextResponse( - 'Build succeeded, but failed to get bundle ID: Empty bundle ID.', - true, + if (buildResult.isError) { + return createPendingXcodebuildResponse(started, buildResult, { + errorFallbackPolicy: 'if-no-structured-diagnostics', + }); + } + + // Resolve app path + emitPipelineNotice(started, 'BUILD', 'Resolving app path', 'info', { + code: 'build-run-step', + data: { step: 'resolve-app-path', status: 'started' }, + }); + + let appPath: string; + try { + appPath = await resolveAppPathFromBuildSettings( + { + projectPath: params.projectPath, + workspacePath: params.workspacePath, + scheme: params.scheme, + configuration: params.configuration, + platform, + derivedDataPath: params.derivedDataPath, + extraArgs: params.extraArgs, + }, + executor, ); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + log('error', 'Build succeeded, but failed to get app path to launch.'); + emitPipelineError(started, 'BUILD', `Failed to get app path to launch: ${errorMessage}`); + return createPendingXcodebuildResponse(started, { + content: [], + isError: true, + }); } - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - return createTextResponse( - `Build succeeded, but failed to get bundle ID: ${errorMessage}`, - true, - ); - } - const installResult = await install_app_deviceLogic( - { - deviceId: params.deviceId, - appPath, - }, - executor, - ); - - if (installResult.isError) { - return createTextResponse( - `Build succeeded, but error installing app on device: ${extractResponseText(installResult)}`, - true, - ); - } + log('info', `App path determined as: ${appPath}`); + emitPipelineNotice(started, 'BUILD', 'App path resolved', 'success', { + code: 'build-run-step', + data: { step: 'resolve-app-path', status: 'succeeded', appPath }, + }); - const launchResult = await launch_app_deviceLogic( - { - deviceId: params.deviceId, - bundleId, - env: params.env, - }, - executor, - fileSystemExecutor, - ); - - if (launchResult.isError) { - return createTextResponse( - `Build and install succeeded, but error launching app on device: ${extractResponseText(launchResult)}`, - true, - ); - } + // Extract bundle ID + let bundleId: string; + try { + bundleId = (await extractBundleIdFromAppPath(appPath, executor)).trim(); + if (bundleId.length === 0) { + throw new Error('Empty bundle ID returned'); + } + log('info', `Bundle ID for run: ${bundleId}`); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + log('error', `Failed to extract bundle ID: ${errorMessage}`); + emitPipelineError(started, 'BUILD', `Failed to extract bundle ID: ${errorMessage}`); + return createPendingXcodebuildResponse(started, { + content: [], + isError: true, + }); + } - const launchNextSteps = launchResult.nextStepParams ?? {}; - const hasStopHint = - 'stop_app_device' in launchNextSteps && - typeof launchNextSteps.stop_app_device === 'object' && - launchNextSteps.stop_app_device !== null; + // Install app on device + emitPipelineNotice(started, 'BUILD', 'Installing app', 'info', { + code: 'build-run-step', + data: { step: 'install-app', status: 'started' }, + }); - log('info', `Device build and run succeeded for scheme ${params.scheme}.`); + try { + const installResult = await executor( + ['xcrun', 'devicectl', 'device', 'install', 'app', '--device', params.deviceId, appPath], + 'Install app on device', + false, + ); + if (!installResult.success) { + throw new Error(installResult.error ?? 'Failed to install app'); + } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + log('error', `Failed to install app on device: ${errorMessage}`); + emitPipelineError(started, 'BUILD', `Failed to install app on device: ${errorMessage}`); + return createPendingXcodebuildResponse(started, { + content: [], + isError: true, + }); + } - const successText = getSuccessText( - platform, - params.scheme, - bundleId, - params.deviceId, - hasStopHint, - ); + emitPipelineNotice(started, 'BUILD', 'App installed', 'success', { + code: 'build-run-step', + data: { step: 'install-app', status: 'succeeded' }, + }); - return { - content: [ - { - type: 'text', - text: successText, - }, - ], - nextStepParams: { - ...launchNextSteps, + // Launch app on device + emitPipelineNotice(started, 'BUILD', 'Launching app', 'info', { + code: 'build-run-step', + data: { step: 'launch-app', status: 'started', appPath }, + }); + + let processId: number | undefined; + try { + const tempJsonPath = join(fileSystemExecutor.tmpdir(), `launch-${Date.now()}.json`); + const command = [ + 'xcrun', + 'devicectl', + 'device', + 'process', + 'launch', + '--device', + params.deviceId, + '--json-output', + tempJsonPath, + '--terminate-existing', + ]; + + if (params.env && Object.keys(params.env).length > 0) { + command.push('--environment-variables', JSON.stringify(params.env)); + } + + command.push(bundleId); + + const launchResult = await executor(command, 'Launch app on device', false); + if (!launchResult.success) { + throw new Error(launchResult.error ?? 'Failed to launch app'); + } + + try { + const jsonContent = await fileSystemExecutor.readFile(tempJsonPath, 'utf8'); + const parsedData: unknown = JSON.parse(jsonContent); + if ( + parsedData && + typeof parsedData === 'object' && + 'result' in parsedData && + parsedData.result && + typeof parsedData.result === 'object' && + 'process' in parsedData.result && + parsedData.result.process && + typeof parsedData.result.process === 'object' && + 'processIdentifier' in parsedData.result.process && + typeof parsedData.result.process.processIdentifier === 'number' + ) { + processId = parsedData.result.process.processIdentifier as number; + } + } catch { + log('warn', 'Failed to parse launch JSON output for process ID'); + } finally { + await fileSystemExecutor.rm(tempJsonPath, { force: true }).catch(() => {}); + } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + log('error', `Failed to launch app on device: ${errorMessage}`); + emitPipelineError(started, 'BUILD', `Failed to launch app on device: ${errorMessage}`); + return createPendingXcodebuildResponse(started, { + content: [], + isError: true, + }); + } + + log('info', `Device build and run succeeded for scheme ${params.scheme}.`); + + const nextStepParams: NextStepParamsMap = { start_device_log_cap: { deviceId: params.deviceId, bundleId, }, - }, - isError: false, - }; + }; + + if (processId !== undefined) { + nextStepParams.stop_app_device = { + deviceId: params.deviceId, + processId, + }; + } + + return createPendingXcodebuildResponse( + started, + { + content: [], + isError: false, + nextStepParams, + }, + { + tailEvents: [ + { + type: 'notice', + timestamp: new Date().toISOString(), + operation: 'BUILD', + level: 'success', + message: 'Build & Run complete', + code: 'build-run-result', + data: { + scheme: params.scheme, + platform: String(platform), + target: `${platform} Device`, + appPath, + bundleId, + launchState: 'requested', + ...(processId !== undefined ? { processId } : {}), + }, + }, + ], + }, + ); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + log('error', `Error during device build & run logic: ${errorMessage}`); + return createTextResponse(`Error during device build and run: ${errorMessage}`, true); + } } const publicSchemaObject = baseSchemaObject.omit({ @@ -225,7 +342,8 @@ export const schema = getSessionAwareToolSchemaShape({ export const handler = createSessionAwareTool({ internalSchema: buildRunDeviceSchema as unknown as z.ZodType, - logicFunction: build_run_deviceLogic, + logicFunction: (params, executor) => + build_run_deviceLogic(params, executor, getDefaultFileSystemExecutor()), getExecutor: getDefaultCommandExecutor, requirements: [ { allOf: ['scheme', 'deviceId'], message: 'Provide scheme and deviceId' }, diff --git a/src/mcp/tools/device/get_device_app_path.ts b/src/mcp/tools/device/get_device_app_path.ts index c613a52d..6d81c39a 100644 --- a/src/mcp/tools/device/get_device_app_path.ts +++ b/src/mcp/tools/device/get_device_app_path.ts @@ -8,7 +8,6 @@ import * as z from 'zod'; import type { ToolResponse } from '../../../types/common.ts'; import { log } from '../../../utils/logging/index.ts'; -import { createTextResponse } from '../../../utils/responses/index.ts'; import type { CommandExecutor } from '../../../utils/execution/index.ts'; import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; import { @@ -17,6 +16,11 @@ import { } from '../../../utils/typed-tool-factory.ts'; import { nullifyEmptyStrings } from '../../../utils/schema-helpers.ts'; import { mapDevicePlatform, resolveAppPathFromBuildSettings } from './build-settings.ts'; +import { formatToolPreflight } from '../../../utils/build-preflight.ts'; +import { + formatQueryError, + formatQueryFailureSummary, +} from '../../../utils/xcodebuild-error-utils.ts'; // Unified schema: XOR between projectPath and workspacePath, sharing common options const baseOptions = { @@ -60,6 +64,15 @@ export async function get_device_app_pathLogic( const platform = mapDevicePlatform(params.platform); const configuration = params.configuration ?? 'Debug'; + const preflight = formatToolPreflight({ + operation: 'Get App Path', + scheme: params.scheme, + workspacePath: params.workspacePath, + projectPath: params.projectPath, + configuration, + platform, + }); + log('info', `Getting app path for scheme ${params.scheme} on platform ${platform}`); try { @@ -78,7 +91,7 @@ export async function get_device_app_pathLogic( content: [ { type: 'text', - text: `โœ… App path retrieved successfully: ${appPath}`, + text: `${preflight}\n \u{2514} App Path: ${appPath}`, }, ], nextStepParams: { @@ -91,18 +104,15 @@ export async function get_device_app_pathLogic( const errorMessage = error instanceof Error ? error.message : String(error); log('error', `Error retrieving app path: ${errorMessage}`); - if (errorMessage.startsWith('Could not extract app path from build settings.')) { - return createTextResponse( - 'Failed to extract app path from build settings. Make sure the app has been built first.', - true, - ); - } - - if (errorMessage.includes('xcodebuild:')) { - return createTextResponse(`Failed to get app path: ${errorMessage}`, true); - } - - return createTextResponse(`Error retrieving app path: ${errorMessage}`, true); + return { + content: [ + { + type: 'text', + text: `${preflight}\n${formatQueryError(errorMessage)}\n\n${formatQueryFailureSummary()}`, + }, + ], + isError: true, + }; } } diff --git a/src/mcp/tools/device/test_device.ts b/src/mcp/tools/device/test_device.ts index 4406f8de..1730d46b 100644 --- a/src/mcp/tools/device/test_device.ts +++ b/src/mcp/tools/device/test_device.ts @@ -6,18 +6,10 @@ */ import * as z from 'zod'; -import { join } from 'path'; import type { ToolResponse } from '../../../types/common.ts'; import { XcodePlatform } from '../../../types/common.ts'; -import { log } from '../../../utils/logging/index.ts'; -import { executeXcodeBuildCommand } from '../../../utils/build/index.ts'; -import { createTextResponse } from '../../../utils/responses/index.ts'; -import { normalizeTestRunnerEnv } from '../../../utils/environment.ts'; -import type { - CommandExecutor, - FileSystemExecutor, - CommandExecOptions, -} from '../../../utils/execution/index.ts'; +import { handleTestLogic } from '../../../utils/test/index.ts'; +import type { CommandExecutor, FileSystemExecutor } from '../../../utils/execution/index.ts'; import { getDefaultCommandExecutor, getDefaultFileSystemExecutor, @@ -27,10 +19,8 @@ import { getSessionAwareToolSchemaShape, } from '../../../utils/typed-tool-factory.ts'; import { nullifyEmptyStrings } from '../../../utils/schema-helpers.ts'; -import { filterStderrContent, type XcresultSummary } from '../../../utils/test-result-content.ts'; -import { resolveTestProgressEnabled } from '../../../utils/test-common.ts'; +import { resolveTestPreflight } from '../../../utils/test-preflight.ts'; -// Unified schema: XOR between projectPath and workspacePath const baseSchemaObject = z.object({ projectPath: z.string().optional().describe('Path to the .xcodeproj file'), workspacePath: z.string().optional().describe('Path to the .xcworkspace file'), @@ -77,227 +67,47 @@ const publicSchemaObject = baseSchemaObject.omit({ platform: true, } as const); -/** - * Parse xcresult bundle using xcrun xcresulttool - */ -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', - ); - if (!result.success) { - throw new Error(result.error ?? 'Failed to execute xcresulttool'); - } - if (!result.output || result.output.trim().length === 0) { - throw new Error('xcresulttool returned no output'); - } - - // Parse JSON response and format as human-readable - const summaryData = JSON.parse(result.output) as Record; - return { - formatted: formatTestSummary(summaryData), - totalTestCount: - typeof summaryData.totalTestCount === 'number' ? summaryData.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: Record): string { - const lines = []; - - 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 deviceConfig = summary.devicesAndConfigurations[0] as Record; - const device = deviceConfig.device as Record | undefined; - 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((failureItem, index) => { - const failure = failureItem as Record; - 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((insightItem, index) => { - const insight = insightItem as Record; - lines.push( - ` ${index + 1}. [${insight.impact ?? 'Unknown'}] ${insight.text ?? 'No description'}`, - ); - }); - } - - return lines.join('\n'); -} - -/** - * Business logic for running tests with platform-specific handling. - * Exported for direct testing and reuse. - */ export async function testDeviceLogic( params: TestDeviceParams, executor: CommandExecutor = getDefaultCommandExecutor(), fileSystemExecutor: FileSystemExecutor = getDefaultFileSystemExecutor(), ): Promise { - log( - 'info', - `Starting test run for scheme ${params.scheme} on platform ${params.platform ?? 'iOS'} (internal)`, + const configuration = params.configuration ?? 'Debug'; + const platform = (params.platform as XcodePlatform) || XcodePlatform.iOS; + + const preflight = await resolveTestPreflight( + { + projectPath: params.projectPath, + workspacePath: params.workspacePath, + scheme: params.scheme, + configuration, + extraArgs: params.extraArgs, + destinationName: params.deviceId, + }, + fileSystemExecutor, ); - let tempDir: string | undefined; - const cleanup = async (): Promise => { - if (!tempDir) return; - try { - await fileSystemExecutor.rm(tempDir, { recursive: true, force: true }); - } catch (cleanupError) { - log('warn', `Failed to clean up temporary directory: ${cleanupError}`); - } - }; - - try { - // Create temporary directory for xcresult bundle - 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; - const progress = resolveTestProgressEnabled(params.progress); - - // Run the test command - const testResult = await executeXcodeBuildCommand( - { - projectPath: params.projectPath, - workspacePath: params.workspacePath, - scheme: params.scheme, - configuration: params.configuration ?? 'Debug', - derivedDataPath: params.derivedDataPath, - extraArgs, - }, - { - platform: (params.platform as XcodePlatform) || XcodePlatform.iOS, - simulatorName: undefined, - simulatorId: undefined, - deviceId: params.deviceId, - useLatestOS: false, - logPrefix: 'Test Run', - showTestProgress: progress, - }, - 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}`); - - // 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 xcresult = await parseXcresultBundle(resultBundlePath, executor); - log('info', 'Successfully parsed xcresult bundle'); - - // Clean up temporary directory - await cleanup(); - - // 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; - } - - // xcresult summary should be first. Drop stderr-only noise while preserving non-stderr lines. - const filteredContent = filterStderrContent(testResult.content); - return { - content: [ - { - type: 'text', - text: '\nTest Results Summary:\n' + xcresult.formatted, - }, - ...filteredContent, - ], - isError: testResult.isError, - }; - } catch (parseError) { - // If parsing fails, return original test result - log('warn', `Failed to parse xcresult bundle: ${parseError}`); - - await cleanup(); - - return testResult; - } - } 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); - } finally { - await cleanup(); - } + return handleTestLogic( + { + projectPath: params.projectPath, + workspacePath: params.workspacePath, + scheme: params.scheme, + deviceId: params.deviceId, + configuration, + derivedDataPath: params.derivedDataPath, + extraArgs: params.extraArgs, + preferXcodebuild: params.preferXcodebuild ?? false, + platform, + useLatestOS: false, + testRunnerEnv: params.testRunnerEnv, + progress: params.progress, + }, + executor, + { + preflight: preflight ?? undefined, + toolName: 'test_device', + }, + ); } export const schema = getSessionAwareToolSchemaShape({ @@ -307,15 +117,8 @@ export const schema = getSessionAwareToolSchemaShape({ export const handler = createSessionAwareTool({ internalSchema: testDeviceSchema as unknown as z.ZodType, - logicFunction: (params: TestDeviceParams, executor: CommandExecutor) => - testDeviceLogic( - { - ...params, - platform: params.platform ?? 'iOS', - }, - executor, - getDefaultFileSystemExecutor(), - ), + logicFunction: (params, executor) => + testDeviceLogic(params, executor, getDefaultFileSystemExecutor()), getExecutor: getDefaultCommandExecutor, requirements: [ { allOf: ['scheme', 'deviceId'], message: 'Provide scheme and deviceId' }, diff --git a/src/mcp/tools/macos/__tests__/build_macos.test.ts b/src/mcp/tools/macos/__tests__/build_macos.test.ts index 38ab2f52..10f078b7 100644 --- a/src/mcp/tools/macos/__tests__/build_macos.test.ts +++ b/src/mcp/tools/macos/__tests__/build_macos.test.ts @@ -12,6 +12,30 @@ import { sessionStore } from '../../../../utils/session-store.ts'; import { schema, handler } from '../build_macos.ts'; import { buildMacOSLogic } from '../build_macos.ts'; +function expectPendingBuildResponse( + result: Awaited>, + nextStepToolId?: string, +): void { + 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(); + } +} + describe('build_macos plugin', () => { beforeEach(() => { sessionStore.clear(); @@ -85,18 +109,8 @@ describe('build_macos plugin', () => { mockExecutor, ); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: 'โœ… macOS Build build succeeded for scheme MyScheme.', - }, - { - type: 'text', - text: "Next Steps:\n1. Get app path: get_mac_app_path({ scheme: 'MyScheme' })\n2. Get bundle ID: get_mac_bundle_id({ appPath: 'PATH_FROM_STEP_1' })\n3. Launch: launch_mac_app({ appPath: 'PATH_FROM_STEP_1' })", - }, - ], - }); + expect(result.isError).toBeFalsy(); + expectPendingBuildResponse(result, 'get_mac_app_path'); }); it('should return exact build failure response', async () => { @@ -113,19 +127,8 @@ describe('build_macos plugin', () => { mockExecutor, ); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: 'โŒ [stderr] error: Compilation error in main.swift', - }, - { - type: 'text', - text: 'โŒ macOS Build build failed for scheme MyScheme.', - }, - ], - isError: true, - }); + expect(result.isError).toBe(true); + expectPendingBuildResponse(result); }); it('should return exact successful build response with optional parameters', async () => { @@ -147,23 +150,11 @@ describe('build_macos plugin', () => { mockExecutor, ); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: 'โœ… macOS Build build succeeded for scheme MyScheme.', - }, - { - type: 'text', - text: "Next Steps:\n1. Get app path: get_mac_app_path({ scheme: 'MyScheme' })\n2. Get bundle ID: get_mac_bundle_id({ appPath: 'PATH_FROM_STEP_1' })\n3. Launch: launch_mac_app({ appPath: 'PATH_FROM_STEP_1' })", - }, - ], - }); + expect(result.isError).toBeFalsy(); + expectPendingBuildResponse(result, 'get_mac_app_path'); }); it('should return exact exception handling response', async () => { - // Create executor that throws error during command execution - // This will be caught by executeXcodeBuildCommand's try-catch block const mockExecutor = async () => { throw new Error('Network error'); }; @@ -176,20 +167,11 @@ describe('build_macos plugin', () => { mockExecutor, ); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: 'Error during macOS Build build: Network error', - }, - ], - isError: true, - }); + expect(result.isError).toBe(true); + expectPendingBuildResponse(result); }); it('should return exact spawn error handling response', async () => { - // Create executor that throws spawn error during command execution - // This will be caught by executeXcodeBuildCommand's try-catch block const mockExecutor = async () => { throw new Error('Spawn error'); }; @@ -202,15 +184,8 @@ describe('build_macos plugin', () => { mockExecutor, ); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: 'Error during macOS Build build: Spawn error', - }, - ], - isError: true, - }); + expect(result.isError).toBe(true); + expectPendingBuildResponse(result); }); }); @@ -457,7 +432,7 @@ describe('build_macos plugin', () => { mockExecutor, ); - expect(result.isError).toBeUndefined(); + expect(result.isError).toBeFalsy(); }); it('should succeed with valid workspacePath', async () => { @@ -474,7 +449,7 @@ describe('build_macos plugin', () => { mockExecutor, ); - expect(result.isError).toBeUndefined(); + expect(result.isError).toBeFalsy(); }); }); }); diff --git a/src/mcp/tools/macos/__tests__/build_run_macos.test.ts b/src/mcp/tools/macos/__tests__/build_run_macos.test.ts index 552710a2..05e7a15f 100644 --- a/src/mcp/tools/macos/__tests__/build_run_macos.test.ts +++ b/src/mcp/tools/macos/__tests__/build_run_macos.test.ts @@ -5,6 +5,21 @@ import { sessionStore } from '../../../../utils/session-store.ts'; import { schema, handler } from '../build_run_macos.ts'; import { buildRunMacOSLogic } from '../build_run_macos.ts'; +function expectPendingBuildRunResponse( + result: Awaited>, + isError: boolean, +): void { + expect(result.isError).toBe(isError); + expect(result.content).toEqual([]); + expect(result._meta).toEqual( + expect.objectContaining({ + pendingXcodebuild: expect.objectContaining({ + kind: 'pending-xcodebuild', + }), + }), + ); +} + describe('build_run_macos', () => { beforeEach(() => { sessionStore.clear(); @@ -62,7 +77,6 @@ describe('build_run_macos', () => { describe('Command Generation and Response Logic', () => { it('should successfully build and run macOS app from project', async () => { - // Track executor calls manually let callCount = 0; const executorCalls: any[] = []; const mockExecutor = ( @@ -77,7 +91,6 @@ describe('build_run_macos', () => { void detached; if (callCount === 1) { - // First call for build return Promise.resolve({ success: true, output: 'BUILD SUCCEEDED', @@ -85,7 +98,6 @@ describe('build_run_macos', () => { process: mockProcess, }); } else if (callCount === 2) { - // Second call for build settings return Promise.resolve({ success: true, output: 'BUILT_PRODUCTS_DIR = /path/to/build\nFULL_PRODUCT_NAME = MyApp.app', @@ -105,64 +117,43 @@ describe('build_run_macos', () => { const result = await buildRunMacOSLogic(args, mockExecutor); - // Verify build command was called - expect(executorCalls[0]).toEqual({ - command: [ - 'xcodebuild', - '-project', - '/path/to/project.xcodeproj', - '-scheme', - 'MyApp', - '-configuration', - 'Debug', - '-skipMacroValidation', - '-destination', - 'platform=macOS', - 'build', - ], - description: 'macOS Build', - logOutput: false, - opts: { cwd: '/path/to' }, - }); - - // Verify build settings command was called - expect(executorCalls[1]).toEqual({ - command: [ - 'xcodebuild', - '-showBuildSettings', - '-project', - '/path/to/project.xcodeproj', - '-scheme', - 'MyApp', - '-configuration', - 'Debug', - ], - description: 'Get Build Settings for Launch', - logOutput: false, - opts: undefined, - }); - - expect(result).toEqual({ - content: [ - { - type: 'text', - text: 'โœ… macOS Build build succeeded for scheme MyApp.', - }, - { - type: 'text', - text: "Next Steps:\n1. Get app path: get_mac_app_path({ scheme: 'MyApp' })\n2. Get bundle ID: get_mac_bundle_id({ appPath: 'PATH_FROM_STEP_1' })\n3. Launch: launch_mac_app({ appPath: 'PATH_FROM_STEP_1' })", - }, - { - type: 'text', - text: 'โœ… macOS build and run succeeded for scheme MyApp. App launched: /path/to/build/MyApp.app', - }, - ], - isError: false, - }); + expect(executorCalls[0].command).toEqual([ + 'xcodebuild', + '-project', + '/path/to/project.xcodeproj', + '-scheme', + 'MyApp', + '-configuration', + 'Debug', + '-skipMacroValidation', + '-destination', + 'platform=macOS', + 'build', + ]); + expect(executorCalls[0].description).toBe('macOS Build'); + + expectPendingBuildRunResponse(result, false); + expect(result.nextSteps).toBeUndefined(); + expect(result.nextStepParams).toBeUndefined(); + expect(result._meta?.pendingXcodebuild).toEqual( + expect.objectContaining({ + tailEvents: [ + expect.objectContaining({ + type: 'notice', + code: 'build-run-result', + data: expect.objectContaining({ + scheme: 'MyApp', + target: 'macOS', + appPath: '/path/to/build/MyApp.app', + launchState: 'requested', + }), + }), + ], + }), + ); }); it('should successfully build and run macOS app from workspace', async () => { - // Track executor calls manually let callCount = 0; const executorCalls: any[] = []; const mockExecutor = ( @@ -177,7 +168,6 @@ describe('build_run_macos', () => { void detached; if (callCount === 1) { - // First call for build return Promise.resolve({ success: true, output: 'BUILD SUCCEEDED', @@ -185,7 +175,6 @@ describe('build_run_macos', () => { process: mockProcess, }); } else if (callCount === 2) { - // Second call for build settings return Promise.resolve({ success: true, output: 'BUILT_PRODUCTS_DIR = /path/to/build\nFULL_PRODUCT_NAME = MyApp.app', @@ -205,60 +194,21 @@ describe('build_run_macos', () => { const result = await buildRunMacOSLogic(args, mockExecutor); - // Verify build command was called - expect(executorCalls[0]).toEqual({ - command: [ - 'xcodebuild', - '-workspace', - '/path/to/workspace.xcworkspace', - '-scheme', - 'MyApp', - '-configuration', - 'Debug', - '-skipMacroValidation', - '-destination', - 'platform=macOS', - 'build', - ], - description: 'macOS Build', - logOutput: false, - opts: { cwd: '/path/to' }, - }); - - // Verify build settings command was called - expect(executorCalls[1]).toEqual({ - command: [ - 'xcodebuild', - '-showBuildSettings', - '-workspace', - '/path/to/workspace.xcworkspace', - '-scheme', - 'MyApp', - '-configuration', - 'Debug', - ], - description: 'Get Build Settings for Launch', - logOutput: false, - opts: undefined, - }); - - expect(result).toEqual({ - content: [ - { - type: 'text', - text: 'โœ… macOS Build build succeeded for scheme MyApp.', - }, - { - type: 'text', - text: "Next Steps:\n1. Get app path: get_mac_app_path({ scheme: 'MyApp' })\n2. Get bundle ID: get_mac_bundle_id({ appPath: 'PATH_FROM_STEP_1' })\n3. Launch: launch_mac_app({ appPath: 'PATH_FROM_STEP_1' })", - }, - { - type: 'text', - text: 'โœ… macOS build and run succeeded for scheme MyApp. App launched: /path/to/build/MyApp.app', - }, - ], - isError: false, - }); + expect(executorCalls[0].command).toEqual([ + 'xcodebuild', + '-workspace', + '/path/to/workspace.xcworkspace', + '-scheme', + 'MyApp', + '-configuration', + 'Debug', + '-skipMacroValidation', + '-destination', + 'platform=macOS', + 'build', + ]); + + expectPendingBuildRunResponse(result, false); }); it('should handle build failure', async () => { @@ -277,17 +227,18 @@ describe('build_run_macos', () => { const result = await buildRunMacOSLogic(args, mockExecutor); - expect(result).toEqual({ - content: [ - { type: 'text', text: 'โŒ [stderr] error: Build failed' }, - { type: 'text', text: 'โŒ macOS Build build failed for scheme MyApp.' }, - ], - isError: true, - }); + expectPendingBuildRunResponse(result, true); + expect(result.nextSteps).toBeUndefined(); + expect(result.nextStepParams).toBeUndefined(); + expect(result._meta?.pendingXcodebuild).toEqual( + expect.objectContaining({ + errorFallbackPolicy: 'if-no-structured-diagnostics', + tailEvents: [], + }), + ); }); it('should handle build settings failure', async () => { - // Track executor calls manually let callCount = 0; const mockExecutor = ( command: string[], @@ -299,7 +250,6 @@ describe('build_run_macos', () => { callCount++; void detached; if (callCount === 1) { - // First call for build succeeds return Promise.resolve({ success: true, output: 'BUILD SUCCEEDED', @@ -307,7 +257,6 @@ describe('build_run_macos', () => { process: mockProcess, }); } else if (callCount === 2) { - // Second call for build settings fails return Promise.resolve({ success: false, output: '', @@ -327,27 +276,17 @@ describe('build_run_macos', () => { const result = await buildRunMacOSLogic(args, mockExecutor); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: 'โœ… macOS Build build succeeded for scheme MyApp.', - }, - { - type: 'text', - text: "Next Steps:\n1. Get app path: get_mac_app_path({ scheme: 'MyApp' })\n2. Get bundle ID: get_mac_bundle_id({ appPath: 'PATH_FROM_STEP_1' })\n3. Launch: launch_mac_app({ appPath: 'PATH_FROM_STEP_1' })", - }, - { - type: 'text', - text: 'โœ… Build succeeded, but failed to get app path to launch: error: Failed to get settings', - }, - ], - isError: false, - }); + expectPendingBuildRunResponse(result, true); + expect(result.nextSteps).toBeUndefined(); + expect(result.nextStepParams).toBeUndefined(); + expect(result._meta?.pendingXcodebuild).toEqual( + expect.objectContaining({ + tailEvents: [], + }), + ); }); it('should handle app launch failure', async () => { - // Track executor calls manually let callCount = 0; const mockExecutor = ( command: string[], @@ -359,7 +298,6 @@ describe('build_run_macos', () => { callCount++; void detached; if (callCount === 1) { - // First call for build succeeds return Promise.resolve({ success: true, output: 'BUILD SUCCEEDED', @@ -367,7 +305,6 @@ describe('build_run_macos', () => { process: mockProcess, }); } else if (callCount === 2) { - // Second call for build settings succeeds return Promise.resolve({ success: true, output: 'BUILT_PRODUCTS_DIR = /path/to/build\nFULL_PRODUCT_NAME = MyApp.app', @@ -375,7 +312,6 @@ describe('build_run_macos', () => { process: mockProcess, }); } else if (callCount === 3) { - // Third call for open command fails return Promise.resolve({ success: false, output: '', @@ -395,23 +331,14 @@ describe('build_run_macos', () => { const result = await buildRunMacOSLogic(args, mockExecutor); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: 'โœ… macOS Build build succeeded for scheme MyApp.', - }, - { - type: 'text', - text: "Next Steps:\n1. Get app path: get_mac_app_path({ scheme: 'MyApp' })\n2. Get bundle ID: get_mac_bundle_id({ appPath: 'PATH_FROM_STEP_1' })\n3. Launch: launch_mac_app({ appPath: 'PATH_FROM_STEP_1' })", - }, - { - type: 'text', - text: 'โœ… Build succeeded, but failed to launch app /path/to/build/MyApp.app. Error: Failed to launch', - }, - ], - isError: false, - }); + expectPendingBuildRunResponse(result, true); + expect(result.nextSteps).toBeUndefined(); + expect(result.nextStepParams).toBeUndefined(); + expect(result._meta?.pendingXcodebuild).toEqual( + expect.objectContaining({ + tailEvents: [], + }), + ); }); it('should handle spawn error', async () => { @@ -439,16 +366,17 @@ describe('build_run_macos', () => { const result = await buildRunMacOSLogic(args, mockExecutor); - expect(result).toEqual({ - content: [ - { type: 'text', text: 'Error during macOS Build build: spawn xcodebuild ENOENT' }, - ], - isError: true, - }); + expectPendingBuildRunResponse(result, true); + expect(result.nextSteps).toBeUndefined(); + expect(result.nextStepParams).toBeUndefined(); + expect(result._meta?.pendingXcodebuild).toEqual( + expect.objectContaining({ + tailEvents: [], + }), + ); }); it('should use default configuration when not provided', async () => { - // Track executor calls manually let callCount = 0; const executorCalls: any[] = []; const mockExecutor = ( @@ -463,7 +391,6 @@ describe('build_run_macos', () => { void detached; if (callCount === 1) { - // First call for build return Promise.resolve({ success: true, output: 'BUILD SUCCEEDED', @@ -471,7 +398,6 @@ describe('build_run_macos', () => { process: mockProcess, }); } else if (callCount === 2) { - // Second call for build settings return Promise.resolve({ success: true, output: 'BUILT_PRODUCTS_DIR = /path/to/build\nFULL_PRODUCT_NAME = MyApp.app', @@ -491,24 +417,20 @@ describe('build_run_macos', () => { await buildRunMacOSLogic(args, mockExecutor); - expect(executorCalls[0]).toEqual({ - command: [ - 'xcodebuild', - '-project', - '/path/to/project.xcodeproj', - '-scheme', - 'MyApp', - '-configuration', - 'Debug', - '-skipMacroValidation', - '-destination', - 'platform=macOS', - 'build', - ], - description: 'macOS Build', - logOutput: false, - opts: { cwd: '/path/to' }, - }); + expect(executorCalls[0].command).toEqual([ + 'xcodebuild', + '-project', + '/path/to/project.xcodeproj', + '-scheme', + 'MyApp', + '-configuration', + 'Debug', + '-skipMacroValidation', + '-destination', + 'platform=macOS', + 'build', + ]); + expect(executorCalls[0].description).toBe('macOS Build'); }); }); }); diff --git a/src/mcp/tools/macos/__tests__/get_mac_app_path.test.ts b/src/mcp/tools/macos/__tests__/get_mac_app_path.test.ts index e2c4da98..e6365cec 100644 --- a/src/mcp/tools/macos/__tests__/get_mac_app_path.test.ts +++ b/src/mcp/tools/macos/__tests__/get_mac_app_path.test.ts @@ -130,7 +130,6 @@ describe('get_mac_app_path plugin', () => { ], 'Get App Path', false, - undefined, ]); }); @@ -168,7 +167,6 @@ describe('get_mac_app_path plugin', () => { ], 'Get App Path', false, - undefined, ]); }); @@ -210,7 +208,6 @@ describe('get_mac_app_path plugin', () => { ], 'Get App Path', false, - undefined, ]); }); @@ -252,7 +249,6 @@ describe('get_mac_app_path plugin', () => { ], 'Get App Path', false, - undefined, ]); }); @@ -296,7 +292,6 @@ describe('get_mac_app_path plugin', () => { ], 'Get App Path', false, - undefined, ]); }); @@ -337,7 +332,6 @@ describe('get_mac_app_path plugin', () => { ], 'Get App Path', false, - undefined, ]); }); }); @@ -370,23 +364,20 @@ FULL_PRODUCT_NAME = MyApp.app mockExecutor, ); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: 'โœ… App path retrieved successfully: /Users/test/Library/Developer/Xcode/DerivedData/MyApp-abc123/Build/Products/Debug/MyApp.app', - }, - ], - nextStepParams: { - get_mac_bundle_id: { - appPath: - '/Users/test/Library/Developer/Xcode/DerivedData/MyApp-abc123/Build/Products/Debug/MyApp.app', - }, - launch_mac_app: { - appPath: - '/Users/test/Library/Developer/Xcode/DerivedData/MyApp-abc123/Build/Products/Debug/MyApp.app', - }, - }, + const appPath = + '/Users/test/Library/Developer/Xcode/DerivedData/MyApp-abc123/Build/Products/Debug/MyApp.app'; + + expect(result.isError).toBeUndefined(); + expect(result.content[0].text).toContain('\u{1F50D} Get App Path'); + expect(result.content[0].text).toContain('Scheme: MyScheme'); + expect(result.content[0].text).toContain('Workspace: /path/to/MyProject.xcworkspace'); + expect(result.content[0].text).toContain('Configuration: Debug'); + expect(result.content[0].text).toContain('Platform: macOS'); + expect(result.content[0].text).toContain(`\u{2514} App Path: ${appPath}`); + expect(result.content[0].text).not.toContain('\u{2705}'); + expect(result.nextStepParams).toEqual({ + get_mac_bundle_id: { appPath }, + launch_mac_app: { appPath }, }); }); @@ -407,30 +398,27 @@ FULL_PRODUCT_NAME = MyApp.app mockExecutor, ); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: 'โœ… App path retrieved successfully: /Users/test/Library/Developer/Xcode/DerivedData/MyApp-abc123/Build/Products/Debug/MyApp.app', - }, - ], - nextStepParams: { - get_mac_bundle_id: { - appPath: - '/Users/test/Library/Developer/Xcode/DerivedData/MyApp-abc123/Build/Products/Debug/MyApp.app', - }, - launch_mac_app: { - appPath: - '/Users/test/Library/Developer/Xcode/DerivedData/MyApp-abc123/Build/Products/Debug/MyApp.app', - }, - }, + const appPath = + '/Users/test/Library/Developer/Xcode/DerivedData/MyApp-abc123/Build/Products/Debug/MyApp.app'; + + expect(result.isError).toBeUndefined(); + expect(result.content[0].text).toContain('\u{1F50D} Get App Path'); + expect(result.content[0].text).toContain('Scheme: MyScheme'); + expect(result.content[0].text).toContain('Project: /path/to/MyProject.xcodeproj'); + expect(result.content[0].text).toContain('Configuration: Debug'); + expect(result.content[0].text).toContain('Platform: macOS'); + expect(result.content[0].text).toContain(`\u{2514} App Path: ${appPath}`); + expect(result.content[0].text).not.toContain('\u{2705}'); + expect(result.nextStepParams).toEqual({ + get_mac_bundle_id: { appPath }, + launch_mac_app: { appPath }, }); }); it('should return exact build settings failure response', async () => { const mockExecutor = createMockExecutor({ success: false, - error: 'error: No such scheme', + error: 'xcodebuild: error: No such scheme', }); const result = await get_mac_app_pathLogic( @@ -441,15 +429,13 @@ FULL_PRODUCT_NAME = MyApp.app mockExecutor, ); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: 'Error: Failed to get macOS app path\nDetails: error: No such scheme', - }, - ], - isError: true, - }); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('\u{1F50D} Get App Path'); + expect(result.content[0].text).toContain('Scheme: MyScheme'); + expect(result.content[0].text).toContain('Errors (1):'); + expect(result.content[0].text).toContain('\u{2717} No such scheme'); + expect(result.content[0].text).toContain('\u{274C} Query failed.'); + expect(result.nextStepParams).toBeUndefined(); }); it('should return exact missing build settings response', async () => { @@ -466,15 +452,14 @@ FULL_PRODUCT_NAME = MyApp.app mockExecutor, ); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: 'Error: Failed to get macOS app path\nDetails: Could not extract app path from build settings', - }, - ], - isError: true, - }); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('\u{1F50D} Get App Path'); + expect(result.content[0].text).toContain('Errors (1):'); + expect(result.content[0].text).toContain( + '\u{2717} Could not extract app path from build settings', + ); + expect(result.content[0].text).toContain('\u{274C} Query failed.'); + expect(result.nextStepParams).toBeUndefined(); }); it('should return exact exception handling response', async () => { @@ -490,15 +475,12 @@ FULL_PRODUCT_NAME = MyApp.app mockExecutor, ); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: 'Error: Failed to get macOS app path\nDetails: Network error', - }, - ], - isError: true, - }); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('\u{1F50D} Get App Path'); + expect(result.content[0].text).toContain('Errors (1):'); + expect(result.content[0].text).toContain('\u{2717} Network error'); + expect(result.content[0].text).toContain('\u{274C} Query failed.'); + expect(result.nextStepParams).toBeUndefined(); }); }); }); diff --git a/src/mcp/tools/macos/__tests__/test_macos.test.ts b/src/mcp/tools/macos/__tests__/test_macos.test.ts index f327ee40..7850252b 100644 --- a/src/mcp/tools/macos/__tests__/test_macos.test.ts +++ b/src/mcp/tools/macos/__tests__/test_macos.test.ts @@ -9,21 +9,31 @@ import { createMockCommandResponse, createMockExecutor, createMockFileSystemExecutor, - type FileSystemExecutor, } from '../../../../test-utils/mock-executors.ts'; import { sessionStore } from '../../../../utils/session-store.ts'; -import { schema, handler } from '../test_macos.ts'; -import { testMacosLogic } from '../test_macos.ts'; +import { schema, handler, testMacosLogic } from '../test_macos.ts'; +import { + isPendingXcodebuildResponse, + finalizePendingXcodebuildResponse, +} from '../../../../utils/xcodebuild-output.ts'; +import type { ToolResponse } from '../../../../types/common.ts'; -const createTestFileSystemExecutor = (overrides: Partial = {}) => +const mockFs = () => createMockFileSystemExecutor({ mkdtemp: async () => '/tmp/test-123', rm: async () => {}, tmpdir: () => '/tmp', - stat: async () => ({ isDirectory: () => true, mtimeMs: 0 }), - ...overrides, + stat: async () => ({ isDirectory: () => false, mtimeMs: 0 }), }); +function finalizeAndGetText(result: ToolResponse): string { + if (isPendingXcodebuildResponse(result)) { + const finalized = finalizePendingXcodebuildResponse(result); + return finalized.content.map((c) => c.text).join('\n'); + } + return result.content.map((c) => c.text).join('\n'); +} + describe('test_macos plugin (unified)', () => { beforeEach(() => { sessionStore.clear(); @@ -87,7 +97,6 @@ describe('test_macos plugin (unified)', () => { describe('XOR Parameter Validation', () => { it('should validate that either projectPath or workspacePath is provided', async () => { - // Should return error response when neither is provided const result = await handler({ scheme: 'MyScheme', }); @@ -97,7 +106,6 @@ describe('test_macos plugin (unified)', () => { }); it('should validate that both projectPath and workspacePath cannot be provided', async () => { - // Should return error response when both are provided const result = await handler({ projectPath: '/path/to/project.xcodeproj', workspacePath: '/path/to/workspace.xcworkspace', @@ -114,20 +122,17 @@ describe('test_macos plugin (unified)', () => { output: 'Test Suite All Tests passed', }); - const mockFileSystemExecutor = createTestFileSystemExecutor(); - const result = await testMacosLogic( { projectPath: '/path/to/project.xcodeproj', scheme: 'MyScheme', }, mockExecutor, - mockFileSystemExecutor, + mockFs(), ); - expect(result.content).toBeDefined(); - expect(Array.isArray(result.content)).toBe(true); - expect(result.isError).toBeUndefined(); + expect(isPendingXcodebuildResponse(result)).toBe(true); + expect(result.isError).toBeFalsy(); }); it('should allow only workspacePath', async () => { @@ -136,33 +141,27 @@ describe('test_macos plugin (unified)', () => { output: 'Test Suite All Tests passed', }); - const mockFileSystemExecutor = createTestFileSystemExecutor(); - const result = await testMacosLogic( { workspacePath: '/path/to/workspace.xcworkspace', scheme: 'MyScheme', }, mockExecutor, - mockFileSystemExecutor, + mockFs(), ); - expect(result.content).toBeDefined(); - expect(Array.isArray(result.content)).toBe(true); - expect(result.isError).toBeUndefined(); + expect(isPendingXcodebuildResponse(result)).toBe(true); + expect(result.isError).toBeFalsy(); }); }); describe('Handler Behavior (Complete Literal Returns)', () => { - it('should return successful test response with workspace when xcodebuild succeeds', async () => { + it('should return pending response with workspace when xcodebuild succeeds', async () => { const mockExecutor = createMockExecutor({ success: true, output: 'Test Suite All Tests passed', }); - // Mock file system dependencies - const mockFileSystemExecutor = createTestFileSystemExecutor(); - const result = await testMacosLogic( { workspacePath: '/path/to/workspace.xcworkspace', @@ -170,23 +169,19 @@ describe('test_macos plugin (unified)', () => { configuration: 'Debug', }, mockExecutor, - mockFileSystemExecutor, + mockFs(), ); - expect(result.content).toBeDefined(); - expect(Array.isArray(result.content)).toBe(true); - expect(result.isError).toBeUndefined(); + expect(isPendingXcodebuildResponse(result)).toBe(true); + expect(result.isError).toBeFalsy(); }); - it('should return successful test response with project when xcodebuild succeeds', async () => { + it('should return pending response with project when xcodebuild succeeds', async () => { const mockExecutor = createMockExecutor({ success: true, output: 'Test Suite All Tests passed', }); - // Mock file system dependencies - const mockFileSystemExecutor = createTestFileSystemExecutor(); - const result = await testMacosLogic( { projectPath: '/path/to/project.xcodeproj', @@ -194,12 +189,11 @@ describe('test_macos plugin (unified)', () => { configuration: 'Debug', }, mockExecutor, - mockFileSystemExecutor, + mockFs(), ); - expect(result.content).toBeDefined(); - expect(Array.isArray(result.content)).toBe(true); - expect(result.isError).toBeUndefined(); + expect(isPendingXcodebuildResponse(result)).toBe(true); + expect(result.isError).toBeFalsy(); }); it('should use default configuration when not provided', async () => { @@ -208,21 +202,17 @@ describe('test_macos plugin (unified)', () => { output: 'Test Suite All Tests passed', }); - // Mock file system dependencies - const mockFileSystemExecutor = createTestFileSystemExecutor(); - const result = await testMacosLogic( { workspacePath: '/path/to/workspace.xcworkspace', scheme: 'MyScheme', }, mockExecutor, - mockFileSystemExecutor, + mockFs(), ); - expect(result.content).toBeDefined(); - expect(Array.isArray(result.content)).toBe(true); - expect(result.isError).toBeUndefined(); + expect(isPendingXcodebuildResponse(result)).toBe(true); + expect(result.isError).toBeFalsy(); }); it('should handle optional parameters correctly', async () => { @@ -231,9 +221,6 @@ describe('test_macos plugin (unified)', () => { output: 'Test Suite All Tests passed', }); - // Mock file system dependencies - const mockFileSystemExecutor = createTestFileSystemExecutor(); - const result = await testMacosLogic( { workspacePath: '/path/to/workspace.xcworkspace', @@ -244,12 +231,11 @@ describe('test_macos plugin (unified)', () => { preferXcodebuild: true, }, mockExecutor, - mockFileSystemExecutor, + mockFs(), ); - expect(result.content).toBeDefined(); - expect(Array.isArray(result.content)).toBe(true); - expect(result.isError).toBeUndefined(); + expect(isPendingXcodebuildResponse(result)).toBe(true); + expect(result.isError).toBeFalsy(); }); it('should handle successful test execution with minimal parameters', async () => { @@ -258,231 +244,112 @@ describe('test_macos plugin (unified)', () => { output: 'Test Suite All Tests passed', }); - // Mock file system dependencies - const mockFileSystemExecutor = createTestFileSystemExecutor(); - const result = await testMacosLogic( { workspacePath: '/path/to/MyProject.xcworkspace', scheme: 'MyApp', }, mockExecutor, - mockFileSystemExecutor, + mockFs(), ); - expect(result.content).toBeDefined(); - expect(Array.isArray(result.content)).toBe(true); - expect(result.isError).toBeUndefined(); + expect(isPendingXcodebuildResponse(result)).toBe(true); + expect(result.isError).toBeFalsy(); }); - it('should return exact successful test response', async () => { - // Track command execution calls - const commandCalls: any[] = []; + it('should return pending response on successful test', async () => { + const commandCalls: { command: string[]; logPrefix?: string }[] = []; - // Mock executor for successful test const mockExecutor = async ( command: string[], logPrefix?: string, - useShell?: boolean, - opts?: { env?: Record }, - detached?: boolean, + _useShell?: boolean, + _opts?: { env?: Record }, + _detached?: boolean, ) => { - commandCalls.push({ command, logPrefix, useShell, env: opts?.env }); - void detached; - - // Handle xcresulttool command - if (command.includes('xcresulttool')) { - return createMockCommandResponse({ - success: true, - output: JSON.stringify({ - title: 'Test Results', - result: 'SUCCEEDED', - totalTestCount: 5, - passedTests: 5, - failedTests: 0, - skippedTests: 0, - expectedFailures: 0, - }), - error: undefined, - }); - } - + commandCalls.push({ command, logPrefix }); return createMockCommandResponse({ success: true, output: 'Test Succeeded', error: undefined, + exitCode: 0, }); }; - // Mock file system dependencies using approved utility - const mockFileSystemExecutor = createTestFileSystemExecutor({ - mkdtemp: async () => '/tmp/xcodebuild-test-abc123', - }); - const result = await testMacosLogic( { workspacePath: '/path/to/MyProject.xcworkspace', scheme: 'MyScheme', }, mockExecutor, - mockFileSystemExecutor, + mockFs(), ); - // Verify commands were called with correct parameters - expect(commandCalls).toHaveLength(2); // xcodebuild test + xcresulttool - expect(commandCalls[0].command).toEqual([ - 'xcodebuild', - '-workspace', - '/path/to/MyProject.xcworkspace', - '-scheme', - 'MyScheme', - '-configuration', - 'Debug', - '-skipMacroValidation', - '-destination', - 'platform=macOS', - '-resultBundlePath', - '/tmp/xcodebuild-test-abc123/TestResults.xcresult', - 'test', - ]); + expect(commandCalls).toHaveLength(1); + expect(commandCalls[0].command).toContain('xcodebuild'); + expect(commandCalls[0].command).toContain('-workspace'); + expect(commandCalls[0].command).toContain('/path/to/MyProject.xcworkspace'); + expect(commandCalls[0].command).toContain('-scheme'); + expect(commandCalls[0].command).toContain('MyScheme'); + expect(commandCalls[0].command).toContain('test'); expect(commandCalls[0].logPrefix).toBe('Test Run'); - expect(commandCalls[0].useShell).toBe(false); - - // Verify xcresulttool was called - expect(commandCalls[1].command).toEqual([ - 'xcrun', - 'xcresulttool', - 'get', - 'test-results', - 'summary', - '--path', - '/tmp/xcodebuild-test-abc123/TestResults.xcresult', - ]); - expect(commandCalls[1].logPrefix).toBe('Parse xcresult bundle'); - - expect(result.content).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - type: 'text', - text: 'โœ… Test Run test succeeded for scheme MyScheme.', - }), - ]), - ); + + expect(isPendingXcodebuildResponse(result)).toBe(true); + expect(result.isError).toBeFalsy(); + const allText = finalizeAndGetText(result); + expect(allText).toContain('Scheme: MyScheme'); + expect(allText).toContain('succeeded'); }); - it('should return exact test failure response', async () => { - // Track command execution calls + it('should return pending response on test failure', async () => { let callCount = 0; const mockExecutor = async ( - command: string[], - logPrefix?: string, - useShell?: boolean, - opts?: { env?: Record }, - detached?: boolean, + _command: string[], + _logPrefix?: string, + _useShell?: boolean, + _opts?: { env?: Record }, + _detached?: boolean, ) => { callCount++; - void logPrefix; - void useShell; - void opts; - void detached; - - // First call is xcodebuild test - fails - if (callCount === 1) { - return createMockCommandResponse({ - success: false, - output: '', - error: 'error: Test failed', - }); - } - - // Second call is xcresulttool - if (command.includes('xcresulttool')) { - return createMockCommandResponse({ - success: true, - output: JSON.stringify({ - title: 'Test Results', - result: 'FAILED', - totalTestCount: 5, - passedTests: 3, - failedTests: 2, - skippedTests: 0, - expectedFailures: 0, - }), - error: undefined, - }); - } - - return createMockCommandResponse({ success: true, output: '', error: undefined }); + return createMockCommandResponse({ + success: false, + output: '', + error: 'error: Test failed', + exitCode: 65, + }); }; - // Mock file system dependencies - const mockFileSystemExecutor = createTestFileSystemExecutor({ - mkdtemp: async () => '/tmp/xcodebuild-test-abc123', - }); - const result = await testMacosLogic( { workspacePath: '/path/to/MyProject.xcworkspace', scheme: 'MyScheme', }, mockExecutor, - mockFileSystemExecutor, + mockFs(), ); - expect(result.content).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - type: 'text', - text: 'โŒ Test Run test failed for scheme MyScheme.', - }), - ]), - ); + expect(callCount).toBe(1); + expect(isPendingXcodebuildResponse(result)).toBe(true); expect(result.isError).toBe(true); + const allText = finalizeAndGetText(result); + expect(allText).toContain('Scheme: MyScheme'); + expect(allText).toContain('failed'); }); - it('should return exact successful test response with optional parameters', async () => { - // Track command execution calls - const commandCalls: any[] = []; - - // Mock executor for successful test with optional parameters + it('should return pending response with optional parameters', async () => { const mockExecutor = async ( - command: string[], - logPrefix?: string, - useShell?: boolean, - opts?: { env?: Record }, - detached?: boolean, - ) => { - commandCalls.push({ command, logPrefix, useShell, env: opts?.env }); - void detached; - - // Handle xcresulttool command - if (command.includes('xcresulttool')) { - return createMockCommandResponse({ - success: true, - output: JSON.stringify({ - title: 'Test Results', - result: 'SUCCEEDED', - totalTestCount: 5, - passedTests: 5, - failedTests: 0, - skippedTests: 0, - expectedFailures: 0, - }), - error: undefined, - }); - } - - return createMockCommandResponse({ + _command: string[], + _logPrefix?: string, + _useShell?: boolean, + _opts?: { env?: Record }, + _detached?: boolean, + ) => + createMockCommandResponse({ success: true, output: 'Test Succeeded', error: undefined, + exitCode: 0, }); - }; - - // Mock file system dependencies - const mockFileSystemExecutor = createTestFileSystemExecutor({ - mkdtemp: async () => '/tmp/xcodebuild-test-abc123', - }); const result = await testMacosLogic( { @@ -494,137 +361,29 @@ describe('test_macos plugin (unified)', () => { preferXcodebuild: true, }, mockExecutor, - mockFileSystemExecutor, - ); - - expect(result.content).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - type: 'text', - text: 'โœ… Test Run test succeeded for scheme MyScheme.', - }), - ]), - ); - }); - - it('should filter out stderr lines when xcresult data is available', async () => { - // Regression test for #231: stderr warnings (e.g. "multiple matching destinations") - // should be dropped when xcresult parsing succeeds, since xcresult is authoritative. - let callCount = 0; - const mockExecutor = async ( - command: string[], - logPrefix?: string, - useShell?: boolean, - opts?: { env?: Record }, - detached?: boolean, - ) => { - callCount++; - void logPrefix; - void useShell; - void opts; - void detached; - - // First call: xcodebuild test fails with stderr warning - if (callCount === 1) { - return createMockCommandResponse({ - success: false, - output: '', - error: - 'WARNING: multiple matching destinations, using first match\n' + 'error: Test failed', - }); - } - - // Second call: xcresulttool succeeds - if (command.includes('xcresulttool')) { - return createMockCommandResponse({ - success: true, - output: JSON.stringify({ - title: 'Test Results', - result: 'FAILED', - totalTestCount: 5, - passedTests: 3, - failedTests: 2, - skippedTests: 0, - expectedFailures: 0, - }), - }); - } - - return createMockCommandResponse({ success: true, output: '' }); - }; - - const mockFileSystemExecutor = createTestFileSystemExecutor({ - mkdtemp: async () => '/tmp/xcodebuild-test-stderr', - }); - - const result = await testMacosLogic( - { - workspacePath: '/path/to/MyProject.xcworkspace', - scheme: 'MyScheme', - }, - mockExecutor, - mockFileSystemExecutor, + mockFs(), ); - // stderr lines should be filtered out - const allText = result.content.map((c) => c.text).join('\n'); - expect(allText).not.toContain('[stderr]'); - - // xcresult summary should be present and first - expect(result.content[0].text).toContain('Test Results Summary:'); - - // Build status line should still be present - expect(allText).toContain('Test Run test failed for scheme MyScheme'); + expect(isPendingXcodebuildResponse(result)).toBe(true); + expect(result.isError).toBeFalsy(); + const allText = finalizeAndGetText(result); + expect(allText).toContain('succeeded'); }); - it('should preserve stderr when xcresult reports zero tests (build failure)', async () => { - // When the build fails, xcresult exists but has totalTestCount: 0. - // In that case stderr contains the actual compilation errors and must be preserved. - let callCount = 0; + it('should handle build failure with pending response', async () => { const mockExecutor = async ( - command: string[], - logPrefix?: string, - useShell?: boolean, - opts?: { env?: Record }, - detached?: boolean, - ) => { - callCount++; - void logPrefix; - void useShell; - void opts; - void detached; - - // First call: xcodebuild test fails with compilation error on stderr - if (callCount === 1) { - return createMockCommandResponse({ - success: false, - output: '', - error: 'error: missing argument for parameter in call', - }); - } - - // Second call: xcresulttool succeeds but reports 0 tests - if (command.includes('xcresulttool')) { - return createMockCommandResponse({ - success: true, - output: JSON.stringify({ - title: 'Test Results', - result: 'unknown', - totalTestCount: 0, - passedTests: 0, - failedTests: 0, - skippedTests: 0, - expectedFailures: 0, - }), - }); - } - - return createMockCommandResponse({ success: true, output: '' }); - }; - - const mockFileSystemExecutor = createTestFileSystemExecutor({ - mkdtemp: async () => '/tmp/xcodebuild-test-buildfail', - }); + _command: string[], + _logPrefix?: string, + _useShell?: boolean, + _opts?: { env?: Record }, + _detached?: boolean, + ) => + createMockCommandResponse({ + success: false, + output: '', + error: 'error: missing argument for parameter in call', + exitCode: 65, + }); const result = await testMacosLogic( { @@ -632,30 +391,20 @@ describe('test_macos plugin (unified)', () => { scheme: 'MyScheme', }, mockExecutor, - mockFileSystemExecutor, + mockFs(), ); - // stderr with compilation error must be preserved (not filtered) - const allText = result.content.map((c) => c.text).join('\n'); - expect(allText).toContain('[stderr]'); - expect(allText).toContain('missing argument'); - - // xcresult summary should NOT be present (it's meaningless with 0 tests) - expect(allText).not.toContain('Test Results Summary:'); + expect(isPendingXcodebuildResponse(result)).toBe(true); + expect(result.isError).toBe(true); + const allText = finalizeAndGetText(result); + expect(allText).toContain('failed'); }); - it('should return exact exception handling response', async () => { - // Mock executor (won't be called due to mkdtemp failure) + it('should return error response when executor throws an exception', async () => { const mockExecutor = createMockExecutor({ - success: true, - output: 'Test Succeeded', - }); - - // Mock file system dependencies - mkdtemp fails - const mockFileSystemExecutor = createTestFileSystemExecutor({ - mkdtemp: async () => { - throw new Error('Network error'); - }, + success: false, + error: '', + shouldThrow: new Error('Network error'), }); const result = await testMacosLogic( @@ -664,18 +413,12 @@ describe('test_macos plugin (unified)', () => { scheme: 'MyScheme', }, mockExecutor, - mockFileSystemExecutor, + mockFs(), ); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: 'Error during test run: Network error', - }, - ], - isError: true, - }); + expect(result.isError).toBe(true); + const text = finalizeAndGetText(result); + expect(text).toContain('Test failed.'); }); }); }); diff --git a/src/mcp/tools/macos/build_macos.ts b/src/mcp/tools/macos/build_macos.ts index b91e4129..a3f0a287 100644 --- a/src/mcp/tools/macos/build_macos.ts +++ b/src/mcp/tools/macos/build_macos.ts @@ -17,16 +17,9 @@ import { getSessionAwareToolSchemaShape, } from '../../../utils/typed-tool-factory.ts'; import { nullifyEmptyStrings } from '../../../utils/schema-helpers.ts'; - -// Types for dependency injection -export interface BuildUtilsDependencies { - executeXcodeBuildCommand: typeof executeXcodeBuildCommand; -} - -// Default implementations -const defaultBuildUtilsDependencies: BuildUtilsDependencies = { - executeXcodeBuildCommand, -}; +import { startBuildPipeline } from '../../../utils/xcodebuild-pipeline.ts'; +import { createPendingXcodebuildResponse } from '../../../utils/xcodebuild-output.ts'; +import { formatToolPreflight } from '../../../utils/build-preflight.ts'; // Unified schema: XOR between projectPath and workspacePath const baseSchemaObject = z.object({ @@ -73,7 +66,6 @@ export type BuildMacOSParams = z.infer; export async function buildMacOSLogic( params: BuildMacOSParams, executor: CommandExecutor, - buildUtilsDeps: BuildUtilsDependencies = defaultBuildUtilsDependencies, ): Promise { log('info', `Starting macOS build for scheme ${params.scheme} (internal)`); @@ -83,16 +75,58 @@ export async function buildMacOSLogic( preferXcodebuild: params.preferXcodebuild ?? false, }; - return buildUtilsDeps.executeXcodeBuildCommand( + const platformOptions = { + platform: XcodePlatform.macOS, + arch: params.arch, + logPrefix: 'macOS Build', + }; + + const preflightText = formatToolPreflight({ + operation: 'Build', + scheme: params.scheme, + workspacePath: params.workspacePath, + projectPath: params.projectPath, + configuration: processedParams.configuration, + platform: 'macOS', + arch: params.arch, + }); + + const pipelineParams = { + scheme: params.scheme, + configuration: processedParams.configuration, + platform: 'macOS', + preflight: preflightText, + }; + + const started = startBuildPipeline({ + operation: 'BUILD', + toolName: 'build_macos', + params: pipelineParams, + message: preflightText, + }); + + const buildResult = await executeXcodeBuildCommand( processedParams, - { - platform: XcodePlatform.macOS, - arch: params.arch, - logPrefix: 'macOS Build', - }, + platformOptions, processedParams.preferXcodebuild ?? false, 'build', executor, + undefined, + started.pipeline, + ); + + return createPendingXcodebuildResponse( + started, + buildResult.isError + ? buildResult + : { + ...buildResult, + nextStepParams: { + get_mac_app_path: { + scheme: params.scheme, + }, + }, + }, ); } diff --git a/src/mcp/tools/macos/build_run_macos.ts b/src/mcp/tools/macos/build_run_macos.ts index 6233b2cb..2ec87a7b 100644 --- a/src/mcp/tools/macos/build_run_macos.ts +++ b/src/mcp/tools/macos/build_run_macos.ts @@ -18,6 +18,14 @@ import { getSessionAwareToolSchemaShape, } from '../../../utils/typed-tool-factory.ts'; import { nullifyEmptyStrings } from '../../../utils/schema-helpers.ts'; +import { startBuildPipeline } from '../../../utils/xcodebuild-pipeline.ts'; +import { formatToolPreflight } from '../../../utils/build-preflight.ts'; +import { + createPendingXcodebuildResponse, + emitPipelineError, + emitPipelineNotice, +} from '../../../utils/xcodebuild-output.ts'; +import { resolveAppPathFromBuildSettings } from '../../../utils/app-path-resolver.ts'; // Unified schema: XOR between projectPath and workspacePath const baseSchemaObject = z.object({ @@ -57,87 +65,6 @@ const buildRunMacOSSchema = z.preprocess( export type BuildRunMacOSParams = z.infer; -/** - * Internal logic for building macOS apps. - */ -async function _handleMacOSBuildLogic( - params: BuildRunMacOSParams, - executor: CommandExecutor, -): Promise { - log('info', `Starting macOS build for scheme ${params.scheme} (internal)`); - - return executeXcodeBuildCommand( - { - ...params, - configuration: params.configuration ?? 'Debug', - }, - { - platform: XcodePlatform.macOS, - arch: params.arch, - logPrefix: 'macOS Build', - }, - params.preferXcodebuild ?? false, - 'build', - executor, - ); -} - -async function _getAppPathFromBuildSettings( - params: BuildRunMacOSParams, - executor: CommandExecutor, -): Promise<{ success: true; appPath: string } | { success: false; error: string }> { - try { - // Create the command array for xcodebuild - const command = ['xcodebuild', '-showBuildSettings']; - - // Add the project or workspace - if (params.projectPath) { - command.push('-project', params.projectPath); - } else if (params.workspacePath) { - command.push('-workspace', params.workspacePath); - } - - // Add the scheme and configuration - command.push('-scheme', params.scheme); - command.push('-configuration', params.configuration ?? 'Debug'); - - // Add derived data path if provided - if (params.derivedDataPath) { - command.push('-derivedDataPath', params.derivedDataPath); - } - - // Add extra args if provided - if (params.extraArgs && params.extraArgs.length > 0) { - command.push(...params.extraArgs); - } - - // Execute the command directly - const result = await executor(command, 'Get Build Settings for Launch', false, undefined); - - if (!result.success) { - return { - success: false, - error: result.error ?? 'Failed to get build settings', - }; - } - - // Parse the output to extract the app path - const buildSettingsOutput = result.output; - 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) { - return { success: false, error: 'Could not extract app path from build settings' }; - } - - const appPath = `${builtProductsDirMatch[1].trim()}/${fullProductNameMatch[1].trim()}`; - return { success: true, appPath }; - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - return { success: false, error: errorMessage }; - } -} - /** * Business logic for building and running macOS apps. */ @@ -148,69 +75,132 @@ export async function buildRunMacOSLogic( log('info', 'Handling macOS build & run logic...'); try { - // First, build the app - const buildResult = await _handleMacOSBuildLogic(params, executor); + const configuration = params.configuration ?? 'Debug'; + + const preflightText = formatToolPreflight({ + operation: 'Build & Run', + scheme: params.scheme, + workspacePath: params.workspacePath, + projectPath: params.projectPath, + configuration, + platform: 'macOS', + arch: params.arch, + }); + + const started = startBuildPipeline({ + operation: 'BUILD', + toolName: 'build_run_macos', + params: { + scheme: params.scheme, + configuration, + platform: 'macOS', + preflight: preflightText, + }, + message: preflightText, + }); + + const buildResult = await executeXcodeBuildCommand( + { ...params, configuration }, + { platform: XcodePlatform.macOS, arch: params.arch, logPrefix: 'macOS Build' }, + params.preferXcodebuild ?? false, + 'build', + executor, + undefined, + started.pipeline, + ); - // 1. Check if the build itself failed if (buildResult.isError) { - return buildResult; // Return build failure directly + return createPendingXcodebuildResponse(started, buildResult, { + errorFallbackPolicy: 'if-no-structured-diagnostics', + }); } - const buildWarningMessages = buildResult.content?.filter((c) => c.type === 'text') ?? []; - // 2. Build succeeded, now get the app path using the helper - const appPathResult = await _getAppPathFromBuildSettings(params, executor); + let appPath: string; + emitPipelineNotice(started, 'BUILD', 'Resolving app path', 'info', { + code: 'build-run-step', + data: { step: 'resolve-app-path', status: 'started' }, + }); - // 3. Check if getting the app path failed - if (!appPathResult.success) { - log('error', 'Build succeeded, but failed to get app path to launch.'); - const response = createTextResponse( - `โœ… Build succeeded, but failed to get app path to launch: ${appPathResult.error}`, - false, // Build succeeded, so not a full error + try { + appPath = await resolveAppPathFromBuildSettings( + { + projectPath: params.projectPath, + workspacePath: params.workspacePath, + scheme: params.scheme, + configuration: params.configuration, + platform: XcodePlatform.macOS, + derivedDataPath: params.derivedDataPath, + extraArgs: params.extraArgs, + }, + executor, ); - if (response.content) { - response.content.unshift(...buildWarningMessages); - } - return response; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + log('error', 'Build succeeded, but failed to get app path to launch.'); + emitPipelineError(started, 'BUILD', `Failed to get app path to launch: ${errorMessage}`); + return createPendingXcodebuildResponse(started, { + content: [], + isError: true, + }); } - const appPath = appPathResult.appPath; // success === true narrows to string log('info', `App path determined as: ${appPath}`); + emitPipelineNotice(started, 'BUILD', 'App path resolved', 'success', { + code: 'build-run-step', + data: { step: 'resolve-app-path', status: 'succeeded', appPath }, + }); + emitPipelineNotice(started, 'BUILD', 'Launching app', 'info', { + code: 'build-run-step', + data: { step: 'launch-app', status: 'started', appPath }, + }); - // 4. Launch the app using CommandExecutor const launchResult = await executor(['open', appPath], 'Launch macOS App', false); if (!launchResult.success) { log('error', `Build succeeded, but failed to launch app ${appPath}: ${launchResult.error}`); - const errorResponse = createTextResponse( - `โœ… Build succeeded, but failed to launch app ${appPath}. Error: ${launchResult.error}`, - false, // Build succeeded - ); - if (errorResponse.content) { - errorResponse.content.unshift(...buildWarningMessages); - } - return errorResponse; + emitPipelineError(started, 'BUILD', `Failed to launch app ${appPath}: ${launchResult.error}`); + return createPendingXcodebuildResponse(started, { + content: [], + isError: true, + }); } - log('info', `โœ… macOS app launched successfully: ${appPath}`); - const successResponse: ToolResponse = { - content: [ - ...buildWarningMessages, - { - type: 'text', - text: `โœ… macOS build and run succeeded for scheme ${params.scheme}. App launched: ${appPath}`, - }, - ], - isError: false, - }; - return successResponse; + log('info', `macOS app launched successfully: ${appPath}`); + emitPipelineNotice(started, 'BUILD', 'App launched', 'success', { + code: 'build-run-step', + data: { step: 'launch-app', status: 'succeeded', appPath }, + }); + + return createPendingXcodebuildResponse( + started, + { + content: [], + isError: false, + }, + { + tailEvents: [ + { + type: 'notice', + timestamp: new Date().toISOString(), + operation: 'BUILD', + level: 'success', + message: 'Build & Run complete', + code: 'build-run-result', + data: { + scheme: params.scheme, + platform: 'macOS', + target: 'macOS', + appPath, + launchState: 'requested', + }, + }, + ], + }, + ); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); log('error', `Error during macOS build & run logic: ${errorMessage}`); - const errorResponse = createTextResponse( - `Error during macOS build and run: ${errorMessage}`, - true, - ); - return errorResponse; + return createTextResponse(`Error during macOS build and run: ${errorMessage}`, true); } } diff --git a/src/mcp/tools/macos/get_mac_app_path.ts b/src/mcp/tools/macos/get_mac_app_path.ts index dfc32c7c..6e0a0e98 100644 --- a/src/mcp/tools/macos/get_mac_app_path.ts +++ b/src/mcp/tools/macos/get_mac_app_path.ts @@ -7,7 +7,6 @@ import * as z from 'zod'; import type { ToolResponse } from '../../../types/common.ts'; -import { XcodePlatform } from '../../../types/common.ts'; import { log } from '../../../utils/logging/index.ts'; import type { CommandExecutor } from '../../../utils/execution/index.ts'; import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; @@ -16,6 +15,11 @@ import { getSessionAwareToolSchemaShape, } from '../../../utils/typed-tool-factory.ts'; import { nullifyEmptyStrings } from '../../../utils/schema-helpers.ts'; +import { formatToolPreflight } from '../../../utils/build-preflight.ts'; +import { + formatQueryError, + formatQueryFailureSummary, +} from '../../../utils/xcodebuild-error-utils.ts'; const baseOptions = { scheme: z.string().describe('The scheme to use'), @@ -61,32 +65,34 @@ export async function get_mac_app_pathLogic( ): Promise { const configuration = params.configuration ?? 'Debug'; - log('info', `Getting app path for scheme ${params.scheme} on platform ${XcodePlatform.macOS}`); + const preflight = formatToolPreflight({ + operation: 'Get App Path', + scheme: params.scheme, + workspacePath: params.workspacePath, + projectPath: params.projectPath, + configuration, + platform: 'macOS', + arch: params.arch, + }); + + log('info', `Getting app path for scheme ${params.scheme} on platform macOS`); try { - // Create the command array for xcodebuild with -showBuildSettings option const command = ['xcodebuild', '-showBuildSettings']; - // Add the project or workspace if (params.projectPath) { command.push('-project', params.projectPath); } else if (params.workspacePath) { command.push('-workspace', params.workspacePath); - } else { - // This should never happen due to schema validation - throw new Error('Either projectPath or workspacePath is required.'); } - // Add the scheme and configuration command.push('-scheme', params.scheme); command.push('-configuration', configuration); - // Add optional derived data path if (params.derivedDataPath) { command.push('-derivedDataPath', params.derivedDataPath); } - // Handle destination for macOS when arch is specified if (params.arch) { const destinationString = `platform=macOS,arch=${params.arch}`; command.push('-destination', destinationString); @@ -96,15 +102,15 @@ export async function get_mac_app_pathLogic( command.push(...params.extraArgs); } - // Execute the command directly with executor - const result = await executor(command, 'Get App Path', false, undefined); + const result = await executor(command, 'Get App Path', false); if (!result.success) { + const errorBlock = formatQueryError(result.error ?? 'Unknown error'); return { content: [ { type: 'text', - text: `Error: Failed to get macOS app path\nDetails: ${result.error}`, + text: [preflight, errorBlock, '', formatQueryFailureSummary()].join('\n'), }, ], isError: true, @@ -112,27 +118,30 @@ export async function get_mac_app_pathLogic( } if (!result.output) { + const errorBlock = formatQueryError( + 'Failed to extract build settings output from the result', + ); return { content: [ { type: 'text', - text: 'Error: Failed to get macOS app path\nDetails: Failed to extract build settings output from the result', + text: [preflight, errorBlock, '', formatQueryFailureSummary()].join('\n'), }, ], isError: true, }; } - const buildSettingsOutput = result.output; - const builtProductsDirMatch = buildSettingsOutput.match(/^\s*BUILT_PRODUCTS_DIR\s*=\s*(.+)$/m); - const fullProductNameMatch = buildSettingsOutput.match(/^\s*FULL_PRODUCT_NAME\s*=\s*(.+)$/m); + const builtProductsDirMatch = result.output.match(/^\s*BUILT_PRODUCTS_DIR\s*=\s*(.+)$/m); + const fullProductNameMatch = result.output.match(/^\s*FULL_PRODUCT_NAME\s*=\s*(.+)$/m); if (!builtProductsDirMatch || !fullProductNameMatch) { + const errorBlock = formatQueryError('Could not extract app path from build settings'); return { content: [ { type: 'text', - text: 'Error: Failed to get macOS app path\nDetails: Could not extract app path from build settings', + text: [preflight, errorBlock, '', formatQueryFailureSummary()].join('\n'), }, ], isError: true, @@ -147,7 +156,7 @@ export async function get_mac_app_pathLogic( content: [ { type: 'text', - text: `โœ… App path retrieved successfully: ${appPath}`, + text: [preflight, ` \u{2514} App Path: ${appPath}`].join('\n'), }, ], nextStepParams: { @@ -158,11 +167,12 @@ export async function get_mac_app_pathLogic( } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); log('error', `Error retrieving app path: ${errorMessage}`); + const errorBlock = formatQueryError(errorMessage); return { content: [ { type: 'text', - text: `Error: Failed to get macOS app path\nDetails: ${errorMessage}`, + text: [preflight, errorBlock, '', formatQueryFailureSummary()].join('\n'), }, ], isError: true, diff --git a/src/mcp/tools/macos/test_macos.ts b/src/mcp/tools/macos/test_macos.ts index 4159e6d8..171838c4 100644 --- a/src/mcp/tools/macos/test_macos.ts +++ b/src/mcp/tools/macos/test_macos.ts @@ -6,18 +6,10 @@ */ import * as z from 'zod'; -import { join } from 'path'; import type { ToolResponse } from '../../../types/common.ts'; import { XcodePlatform } from '../../../types/common.ts'; -import { log } from '../../../utils/logging/index.ts'; -import { executeXcodeBuildCommand } from '../../../utils/build/index.ts'; -import { createTextResponse } from '../../../utils/responses/index.ts'; -import { normalizeTestRunnerEnv } from '../../../utils/environment.ts'; -import type { - CommandExecutor, - FileSystemExecutor, - CommandExecOptions, -} from '../../../utils/execution/index.ts'; +import { handleTestLogic } from '../../../utils/test/index.ts'; +import type { CommandExecutor, FileSystemExecutor } from '../../../utils/execution/index.ts'; import { getDefaultCommandExecutor, getDefaultFileSystemExecutor, @@ -27,10 +19,8 @@ import { getSessionAwareToolSchemaShape, } from '../../../utils/typed-tool-factory.ts'; import { nullifyEmptyStrings } from '../../../utils/schema-helpers.ts'; -import { filterStderrContent, type XcresultSummary } from '../../../utils/test-result-content.ts'; -import { resolveTestProgressEnabled } from '../../../utils/test-common.ts'; +import { resolveTestPreflight } from '../../../utils/test-preflight.ts'; -// Unified schema: XOR between projectPath and workspacePath const baseSchemaObject = z.object({ projectPath: z.string().optional().describe('Path to the .xcodeproj file'), workspacePath: z.string().optional().describe('Path to the .xcworkspace file'), @@ -73,211 +63,44 @@ const testMacosSchema = z.preprocess( export type TestMacosParams = z.infer; -/** - * Parse xcresult bundle using xcrun xcresulttool - */ -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'); - } - - // Parse JSON response and format as human-readable - const summary = JSON.parse(result.output || '{}') as Record; - 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: Record): string { - const lines = []; - - 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 deviceConfig = summary.devicesAndConfigurations[0] as Record; - const device = deviceConfig.device as Record | undefined; - 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((failureItem, index: number) => { - const failure = failureItem as Record; - 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((insightItem, index: number) => { - const insight = insightItem as Record; - lines.push( - ` ${index + 1}. [${insight.impact ?? 'Unknown'}] ${insight.text ?? 'No description'}`, - ); - }); - } - - return lines.join('\n'); -} - -/** - * Business logic for testing a macOS project or workspace. - * Exported for direct testing and reuse. - */ export async function testMacosLogic( params: TestMacosParams, executor: CommandExecutor = getDefaultCommandExecutor(), fileSystemExecutor: FileSystemExecutor = getDefaultFileSystemExecutor(), ): Promise { - log('info', `Starting test run for scheme ${params.scheme} on platform macOS (internal)`); - - 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; - const progress = resolveTestProgressEnabled(params.progress); - - // Run the test command - const testResult = await executeXcodeBuildCommand( - { - projectPath: params.projectPath, - workspacePath: params.workspacePath, - scheme: params.scheme, - configuration: params.configuration ?? 'Debug', - derivedDataPath: params.derivedDataPath, - extraArgs, - }, - { - platform: XcodePlatform.macOS, - logPrefix: 'Test Run', - showTestProgress: progress, - }, - params.preferXcodebuild ?? false, - '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}`); - - // 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 xcresult = await parseXcresultBundle(resultBundlePath, executor); - log('info', 'Successfully parsed xcresult bundle'); - - // Clean up temporary directory - await fileSystemExecutor.rm(tempDir, { recursive: true, force: true }); - - // 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; - } - - // xcresult summary should be first. Drop stderr-only noise while preserving non-stderr lines. - const filteredContent = filterStderrContent(testResult.content); - return { - content: [ - { - type: 'text', - text: '\nTest Results Summary:\n' + xcresult.formatted, - }, - ...filteredContent, - ], - isError: testResult.isError, - }; - } catch (parseError) { - // If parsing fails, return original test result - log('warn', `Failed to parse xcresult bundle: ${parseError}`); - - // 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}`); - } - - return testResult; - } - } 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); - } + const configuration = params.configuration ?? 'Debug'; + + const preflight = await resolveTestPreflight( + { + projectPath: params.projectPath, + workspacePath: params.workspacePath, + scheme: params.scheme, + configuration, + extraArgs: params.extraArgs, + destinationName: 'macOS', + }, + fileSystemExecutor, + ); + + return handleTestLogic( + { + projectPath: params.projectPath, + workspacePath: params.workspacePath, + scheme: params.scheme, + configuration, + derivedDataPath: params.derivedDataPath, + extraArgs: params.extraArgs, + preferXcodebuild: params.preferXcodebuild ?? false, + platform: XcodePlatform.macOS, + testRunnerEnv: params.testRunnerEnv, + progress: params.progress, + }, + executor, + { + preflight: preflight ?? undefined, + toolName: 'test_macos', + }, + ); } export const schema = getSessionAwareToolSchemaShape({ diff --git a/src/mcp/tools/project-discovery/__tests__/list_schemes.test.ts b/src/mcp/tools/project-discovery/__tests__/list_schemes.test.ts index 3e5b6398..0740f58f 100644 --- a/src/mcp/tools/project-discovery/__tests__/list_schemes.test.ts +++ b/src/mcp/tools/project-discovery/__tests__/list_schemes.test.ts @@ -59,36 +59,24 @@ describe('list_schemes plugin', () => { mockExecutor, ); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: 'โœ… Available schemes:', - }, - { - type: 'text', - text: 'MyProject\nMyProjectTests', - }, - { - type: 'text', - text: 'Hint: Consider saving a default scheme with session-set-defaults { scheme: "MyProject" } to avoid repeating it.', - }, - ], - nextStepParams: { - build_macos: { projectPath: '/path/to/MyProject.xcodeproj', scheme: 'MyProject' }, - build_run_sim: { - projectPath: '/path/to/MyProject.xcodeproj', - scheme: 'MyProject', - simulatorName: 'iPhone 17', - }, - build_sim: { - projectPath: '/path/to/MyProject.xcodeproj', - scheme: 'MyProject', - simulatorName: 'iPhone 17', - }, - show_build_settings: { projectPath: '/path/to/MyProject.xcodeproj', scheme: 'MyProject' }, + expect(result.isError).toBe(false); + expect(result.content).toHaveLength(1); + expect(result.content[0].text).toContain('\u{1F50D} List Schemes'); + expect(result.content[0].text).toContain('Project: /path/to/MyProject.xcodeproj'); + expect(result.content[0].text).toContain('Schemes:\n - MyProject\n - MyProjectTests'); + expect(result.nextStepParams).toEqual({ + build_macos: { projectPath: '/path/to/MyProject.xcodeproj', scheme: 'MyProject' }, + build_run_sim: { + projectPath: '/path/to/MyProject.xcodeproj', + scheme: 'MyProject', + simulatorName: 'iPhone 17', + }, + build_sim: { + projectPath: '/path/to/MyProject.xcodeproj', + scheme: 'MyProject', + simulatorName: 'iPhone 17', }, - isError: false, + show_build_settings: { projectPath: '/path/to/MyProject.xcodeproj', scheme: 'MyProject' }, }); }); @@ -103,10 +91,14 @@ describe('list_schemes plugin', () => { mockExecutor, ); - expect(result).toEqual({ - content: [{ type: 'text', text: 'Failed to list schemes: Project not found' }], - isError: true, - }); + expect(result.isError).toBe(true); + expect(result.content).toHaveLength(1); + expect(result.content[0].text).toContain('\u{1F50D} List Schemes'); + expect(result.content[0].text).toContain('Project: /path/to/MyProject.xcodeproj'); + expect(result.content[0].text).toContain('Errors (1):'); + expect(result.content[0].text).toContain('\u{2717} Project not found'); + expect(result.content[0].text).toContain('\u{274C} Query failed.'); + expect(result.nextStepParams).toBeUndefined(); }); it('should return error when no schemes found in output', async () => { @@ -120,10 +112,13 @@ describe('list_schemes plugin', () => { mockExecutor, ); - expect(result).toEqual({ - content: [{ type: 'text', text: 'No schemes found in the output' }], - isError: true, - }); + expect(result.isError).toBe(true); + expect(result.content).toHaveLength(1); + expect(result.content[0].text).toContain('\u{1F50D} List Schemes'); + expect(result.content[0].text).toContain('Errors (1):'); + expect(result.content[0].text).toContain('\u{2717} No schemes found in the output'); + expect(result.content[0].text).toContain('\u{274C} Query failed.'); + expect(result.nextStepParams).toBeUndefined(); }); it('should return success with empty schemes list', async () => { @@ -147,19 +142,11 @@ describe('list_schemes plugin', () => { mockExecutor, ); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: 'โœ… Available schemes:', - }, - { - type: 'text', - text: '', - }, - ], - isError: false, - }); + expect(result.isError).toBe(false); + expect(result.content).toHaveLength(1); + expect(result.content[0].text).toContain('\u{1F50D} List Schemes'); + expect(result.content[0].text).toContain('Schemes:\n (none)'); + expect(result.nextStepParams).toBeUndefined(); }); it('should handle Error objects in catch blocks', async () => { @@ -172,10 +159,13 @@ describe('list_schemes plugin', () => { mockExecutor, ); - expect(result).toEqual({ - content: [{ type: 'text', text: 'Error listing schemes: Command execution failed' }], - isError: true, - }); + expect(result.isError).toBe(true); + expect(result.content).toHaveLength(1); + expect(result.content[0].text).toContain('\u{1F50D} List Schemes'); + expect(result.content[0].text).toContain('Errors (1):'); + expect(result.content[0].text).toContain('\u{2717} Command execution failed'); + expect(result.content[0].text).toContain('\u{274C} Query failed.'); + expect(result.nextStepParams).toBeUndefined(); }); it('should handle string error objects in catch blocks', async () => { @@ -188,10 +178,13 @@ describe('list_schemes plugin', () => { mockExecutor, ); - expect(result).toEqual({ - content: [{ type: 'text', text: 'Error listing schemes: String error' }], - isError: true, - }); + expect(result.isError).toBe(true); + expect(result.content).toHaveLength(1); + expect(result.content[0].text).toContain('\u{1F50D} List Schemes'); + expect(result.content[0].text).toContain('Errors (1):'); + expect(result.content[0].text).toContain('\u{2717} String error'); + expect(result.content[0].text).toContain('\u{274C} Query failed.'); + expect(result.nextStepParams).toBeUndefined(); }); it('returns parsed schemes for setup flows', async () => { @@ -302,36 +295,24 @@ describe('list_schemes plugin', () => { mockExecutor, ); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: 'โœ… Available schemes:', - }, - { - type: 'text', - text: 'MyApp\nMyAppTests', - }, - { - type: 'text', - text: 'Hint: Consider saving a default scheme with session-set-defaults { scheme: "MyApp" } to avoid repeating it.', - }, - ], - nextStepParams: { - build_macos: { workspacePath: '/path/to/MyProject.xcworkspace', scheme: 'MyApp' }, - build_run_sim: { - workspacePath: '/path/to/MyProject.xcworkspace', - scheme: 'MyApp', - simulatorName: 'iPhone 17', - }, - build_sim: { - workspacePath: '/path/to/MyProject.xcworkspace', - scheme: 'MyApp', - simulatorName: 'iPhone 17', - }, - show_build_settings: { workspacePath: '/path/to/MyProject.xcworkspace', scheme: 'MyApp' }, + expect(result.isError).toBe(false); + expect(result.content).toHaveLength(1); + expect(result.content[0].text).toContain('\u{1F50D} List Schemes'); + expect(result.content[0].text).toContain('Workspace: /path/to/MyProject.xcworkspace'); + expect(result.content[0].text).toContain('Schemes:\n - MyApp\n - MyAppTests'); + expect(result.nextStepParams).toEqual({ + build_macos: { workspacePath: '/path/to/MyProject.xcworkspace', scheme: 'MyApp' }, + build_run_sim: { + workspacePath: '/path/to/MyProject.xcworkspace', + scheme: 'MyApp', + simulatorName: 'iPhone 17', + }, + build_sim: { + workspacePath: '/path/to/MyProject.xcworkspace', + scheme: 'MyApp', + simulatorName: 'iPhone 17', }, - isError: false, + show_build_settings: { workspacePath: '/path/to/MyProject.xcworkspace', scheme: 'MyApp' }, }); }); diff --git a/src/mcp/tools/project-discovery/__tests__/show_build_settings.test.ts b/src/mcp/tools/project-discovery/__tests__/show_build_settings.test.ts index dd18f402..3ca44b03 100644 --- a/src/mcp/tools/project-discovery/__tests__/show_build_settings.test.ts +++ b/src/mcp/tools/project-discovery/__tests__/show_build_settings.test.ts @@ -3,6 +3,7 @@ import * as z from 'zod'; import { createMockExecutor, type CommandExecutor } from '../../../../test-utils/mock-executors.ts'; import { schema, handler, showBuildSettingsLogic } from '../show_build_settings.ts'; import { sessionStore } from '../../../../utils/session-store.ts'; +import { formatToolPreflight } from '../../../../utils/build-preflight.ts'; describe('show_build_settings plugin', () => { beforeEach(() => { @@ -36,11 +37,11 @@ describe('show_build_settings plugin', () => { mockExecutor, ); expect(result.isError).toBe(false); - expect(result.content[0].text).toContain('โœ… Build settings for scheme MyScheme:'); + expect(result.content[0].text).toContain('Show Build Settings'); + expect(result.content[0].text).toContain('Scheme: MyScheme'); }); it('should test Zod validation through handler', async () => { - // Test the actual tool handler which includes Zod validation const result = await handler({ projectPath: null, scheme: 'MyScheme', @@ -51,11 +52,16 @@ describe('show_build_settings plugin', () => { expect(result.content[0].text).toContain('Provide a project or workspace'); }); - it('should return success with build settings', async () => { + it('should return success with build settings and strip preamble', async () => { const calls: any[] = []; const mockExecutor = createMockExecutor({ success: true, - output: `Build settings from command line: + output: `Command line invocation: + /usr/bin/xcodebuild -showBuildSettings -project /path/to/MyProject.xcodeproj -scheme MyScheme + +Resolve Package Graph + +Build settings for action build and target MyApp: ARCHS = arm64 BUILD_DIR = /Users/dev/Build/Products CONFIGURATION = Debug @@ -67,7 +73,6 @@ describe('show_build_settings plugin', () => { process: { pid: 12345 }, }); - // Wrap mockExecutor to track calls const wrappedExecutor: CommandExecutor = (...args) => { calls.push(args); return mockExecutor(...args); @@ -95,34 +100,25 @@ describe('show_build_settings plugin', () => { false, ]); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: 'โœ… Build settings for scheme MyScheme:', - }, - { - type: 'text', - text: `Build settings from command line: - ARCHS = arm64 - BUILD_DIR = /Users/dev/Build/Products - CONFIGURATION = Debug - DEVELOPMENT_TEAM = ABC123DEF4 - PRODUCT_BUNDLE_IDENTIFIER = io.sentry.MyApp - PRODUCT_NAME = MyApp - SUPPORTED_PLATFORMS = iphoneos iphonesimulator`, - }, - ], - nextStepParams: { - build_macos: { projectPath: '/path/to/MyProject.xcodeproj', scheme: 'MyScheme' }, - build_sim: { - projectPath: '/path/to/MyProject.xcodeproj', - scheme: 'MyScheme', - simulatorName: 'iPhone 17', - }, - list_schemes: { projectPath: '/path/to/MyProject.xcodeproj' }, + const expectedPreflight = formatToolPreflight({ + operation: 'Show Build Settings', + scheme: 'MyScheme', + projectPath: '/path/to/MyProject.xcodeproj', + }); + + expect(result.isError).toBe(false); + expect(result.content).toHaveLength(1); + expect(result.content[0].text).toContain(expectedPreflight); + expect(result.content[0].text).toContain('Build settings for action build and target MyApp:'); + expect(result.content[0].text).toContain('PRODUCT_NAME = MyApp'); + expect(result.nextStepParams).toEqual({ + build_macos: { projectPath: '/path/to/MyProject.xcodeproj', scheme: 'MyScheme' }, + build_sim: { + projectPath: '/path/to/MyProject.xcodeproj', + scheme: 'MyScheme', + simulatorName: 'iPhone 17', }, - isError: false, + list_schemes: { projectPath: '/path/to/MyProject.xcodeproj' }, }); }); @@ -130,10 +126,17 @@ describe('show_build_settings plugin', () => { const mockExecutor = createMockExecutor({ success: false, output: '', - error: 'Scheme not found', + error: + 'xcodebuild: error: The workspace named "App" does not contain a scheme named "InvalidScheme".', process: { pid: 12345 }, }); + const expectedPreflight = formatToolPreflight({ + operation: 'Show Build Settings', + scheme: 'InvalidScheme', + projectPath: '/path/to/MyProject.xcodeproj', + }); + const result = await showBuildSettingsLogic( { projectPath: '/path/to/MyProject.xcodeproj', @@ -142,10 +145,15 @@ describe('show_build_settings plugin', () => { mockExecutor, ); - expect(result).toEqual({ - content: [{ type: 'text', text: 'Failed to show build settings: Scheme not found' }], - isError: true, - }); + expect(result.isError).toBe(true); + expect(result.content).toHaveLength(1); + expect(result.content[0].text).toContain(expectedPreflight); + expect(result.content[0].text).toContain('Errors (1):'); + expect(result.content[0].text).toContain( + 'The workspace named "App" does not contain a scheme named "InvalidScheme".', + ); + expect(result.content[0].text).toContain('Query failed.'); + expect(result).not.toHaveProperty('nextStepParams'); }); it('should handle Error objects in catch blocks', async () => { @@ -153,6 +161,12 @@ describe('show_build_settings plugin', () => { throw new Error('Command execution failed'); }; + const expectedPreflight = formatToolPreflight({ + operation: 'Show Build Settings', + scheme: 'MyScheme', + projectPath: '/path/to/MyProject.xcodeproj', + }); + const result = await showBuildSettingsLogic( { projectPath: '/path/to/MyProject.xcodeproj', @@ -161,10 +175,13 @@ describe('show_build_settings plugin', () => { mockExecutor, ); - expect(result).toEqual({ - content: [{ type: 'text', text: 'Error showing build settings: Command execution failed' }], - isError: true, - }); + expect(result.isError).toBe(true); + expect(result.content).toHaveLength(1); + expect(result.content[0].text).toContain(expectedPreflight); + expect(result.content[0].text).toContain('Errors (1):'); + expect(result.content[0].text).toContain('Command execution failed'); + expect(result.content[0].text).toContain('Query failed.'); + expect(result).not.toHaveProperty('nextStepParams'); }); }); @@ -202,7 +219,8 @@ describe('show_build_settings plugin', () => { ); expect(result.isError).toBe(false); - expect(result.content[0].text).toContain('โœ… Build settings for scheme MyScheme:'); + expect(result.content[0].text).toContain('Show Build Settings'); + expect(result.content[0].text).toContain('Scheme: MyScheme'); }); it('should work with workspacePath only', async () => { @@ -217,7 +235,8 @@ describe('show_build_settings plugin', () => { ); expect(result.isError).toBe(false); - expect(result.content[0].text).toContain('โœ… Build settings retrieved successfully'); + expect(result.content[0].text).toContain('Show Build Settings'); + expect(result.content[0].text).toContain('Workspace:'); }); }); @@ -242,122 +261,4 @@ describe('show_build_settings plugin', () => { expect(result.content[0].text).toContain('Provide a project or workspace'); }); }); - - describe('showBuildSettingsLogic function', () => { - it('should return success with build settings', async () => { - const calls: any[] = []; - const mockExecutor = createMockExecutor({ - success: true, - output: `Build settings from command line: - ARCHS = arm64 - BUILD_DIR = /Users/dev/Build/Products - CONFIGURATION = Debug - DEVELOPMENT_TEAM = ABC123DEF4 - PRODUCT_BUNDLE_IDENTIFIER = io.sentry.MyApp - PRODUCT_NAME = MyApp - SUPPORTED_PLATFORMS = iphoneos iphonesimulator`, - error: undefined, - process: { pid: 12345 }, - }); - - // Wrap mockExecutor to track calls - const wrappedExecutor: CommandExecutor = (...args) => { - calls.push(args); - return mockExecutor(...args); - }; - - const result = await showBuildSettingsLogic( - { - projectPath: '/path/to/MyProject.xcodeproj', - scheme: 'MyScheme', - }, - wrappedExecutor, - ); - - expect(calls).toHaveLength(1); - expect(calls[0]).toEqual([ - [ - 'xcodebuild', - '-showBuildSettings', - '-project', - '/path/to/MyProject.xcodeproj', - '-scheme', - 'MyScheme', - ], - 'Show Build Settings', - false, - ]); - - expect(result).toEqual({ - content: [ - { - type: 'text', - text: 'โœ… Build settings for scheme MyScheme:', - }, - { - type: 'text', - text: `Build settings from command line: - ARCHS = arm64 - BUILD_DIR = /Users/dev/Build/Products - CONFIGURATION = Debug - DEVELOPMENT_TEAM = ABC123DEF4 - PRODUCT_BUNDLE_IDENTIFIER = io.sentry.MyApp - PRODUCT_NAME = MyApp - SUPPORTED_PLATFORMS = iphoneos iphonesimulator`, - }, - ], - nextStepParams: { - build_macos: { projectPath: '/path/to/MyProject.xcodeproj', scheme: 'MyScheme' }, - build_sim: { - projectPath: '/path/to/MyProject.xcodeproj', - scheme: 'MyScheme', - simulatorName: 'iPhone 17', - }, - list_schemes: { projectPath: '/path/to/MyProject.xcodeproj' }, - }, - isError: false, - }); - }); - - it('should return error when command fails', async () => { - const mockExecutor = createMockExecutor({ - success: false, - output: '', - error: 'Scheme not found', - process: { pid: 12345 }, - }); - - const result = await showBuildSettingsLogic( - { - projectPath: '/path/to/MyProject.xcodeproj', - scheme: 'InvalidScheme', - }, - mockExecutor, - ); - - expect(result).toEqual({ - content: [{ type: 'text', text: 'Failed to show build settings: Scheme not found' }], - isError: true, - }); - }); - - it('should handle Error objects in catch blocks', async () => { - const mockExecutor = async () => { - throw new Error('Command execution failed'); - }; - - const result = await showBuildSettingsLogic( - { - projectPath: '/path/to/MyProject.xcodeproj', - scheme: 'MyScheme', - }, - mockExecutor, - ); - - expect(result).toEqual({ - content: [{ type: 'text', text: 'Error showing build settings: Command execution failed' }], - isError: true, - }); - }); - }); }); diff --git a/src/mcp/tools/project-discovery/list_schemes.ts b/src/mcp/tools/project-discovery/list_schemes.ts index 8e06940a..4e9c61d8 100644 --- a/src/mcp/tools/project-discovery/list_schemes.ts +++ b/src/mcp/tools/project-discovery/list_schemes.ts @@ -9,13 +9,17 @@ import * as z from 'zod'; import { log } from '../../../utils/logging/index.ts'; import type { CommandExecutor } from '../../../utils/execution/index.ts'; import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; -import { createTextResponse } from '../../../utils/responses/index.ts'; import type { ToolResponse } from '../../../types/common.ts'; import { createSessionAwareTool, getSessionAwareToolSchemaShape, } from '../../../utils/typed-tool-factory.ts'; import { nullifyEmptyStrings } from '../../../utils/schema-helpers.ts'; +import { formatToolPreflight } from '../../../utils/build-preflight.ts'; +import { + formatQueryError, + formatQueryFailureSummary, +} from '../../../utils/xcodebuild-error-utils.ts'; // Unified schema: XOR between projectPath and workspacePath const baseSchemaObject = z.object({ @@ -36,8 +40,6 @@ const listSchemesSchema = z.preprocess( export type ListSchemesParams = z.infer; -const createTextBlock = (text: string) => ({ type: 'text', text }) as const; - export function parseSchemesFromXcodebuildListOutput(output: string): string[] { const schemesMatch = output.match(/Schemes:([\s\S]*?)(?=\n\n|$)/); if (!schemesMatch) { @@ -81,59 +83,64 @@ export async function listSchemesLogic( ): Promise { log('info', 'Listing schemes'); + const hasProjectPath = typeof params.projectPath === 'string'; + const projectOrWorkspace = hasProjectPath ? 'project' : 'workspace'; + const pathValue = hasProjectPath ? params.projectPath : params.workspacePath; + + const preflight = formatToolPreflight({ + operation: 'List Schemes', + ...(hasProjectPath ? { projectPath: pathValue } : { workspacePath: pathValue }), + }); + try { - const hasProjectPath = typeof params.projectPath === 'string'; - const projectOrWorkspace = hasProjectPath ? 'project' : 'workspace'; - const path = hasProjectPath ? params.projectPath : params.workspacePath; const schemes = await listSchemes(params, executor); let nextStepParams: Record> | undefined; - let hintText = ''; if (schemes.length > 0) { const firstScheme = schemes[0]; nextStepParams = { - build_macos: { [`${projectOrWorkspace}Path`]: path!, scheme: firstScheme }, + build_macos: { [`${projectOrWorkspace}Path`]: pathValue!, scheme: firstScheme }, build_run_sim: { - [`${projectOrWorkspace}Path`]: path!, + [`${projectOrWorkspace}Path`]: pathValue!, scheme: firstScheme, simulatorName: 'iPhone 17', }, build_sim: { - [`${projectOrWorkspace}Path`]: path!, + [`${projectOrWorkspace}Path`]: pathValue!, scheme: firstScheme, simulatorName: 'iPhone 17', }, - show_build_settings: { [`${projectOrWorkspace}Path`]: path!, scheme: firstScheme }, + show_build_settings: { [`${projectOrWorkspace}Path`]: pathValue!, scheme: firstScheme }, }; - - hintText = - `Hint: Consider saving a default scheme with session-set-defaults ` + - `{ scheme: "${firstScheme}" } to avoid repeating it.`; } - const content = [createTextBlock('โœ… Available schemes:'), createTextBlock(schemes.join('\n'))]; - if (hintText.length > 0) { - content.push(createTextBlock(hintText)); - } + const schemeLines = schemes.map((s) => ` - ${s}`).join('\n'); + const resultText = schemes.length > 0 ? `Schemes:\n${schemeLines}` : 'Schemes:\n (none)'; return { - content, + content: [{ type: 'text' as const, text: `${preflight}\n${resultText}` }], ...(nextStepParams ? { nextStepParams } : {}), isError: false, }; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); - if ( - errorMessage.startsWith('Failed to list schemes:') || - errorMessage === 'No schemes found in the output' - ) { - return createTextResponse(errorMessage, true); - } - log('error', `Error listing schemes: ${errorMessage}`); - return createTextResponse(`Error listing schemes: ${errorMessage}`, true); + + const rawError = errorMessage.startsWith('Failed to list schemes: ') + ? errorMessage.slice('Failed to list schemes: '.length) + : errorMessage; + + return { + content: [ + { + type: 'text' as const, + text: `${preflight}\n${formatQueryError(rawError)}\n\n${formatQueryFailureSummary()}`, + }, + ], + isError: true, + }; } } diff --git a/src/mcp/tools/project-discovery/show_build_settings.ts b/src/mcp/tools/project-discovery/show_build_settings.ts index 015f30b7..11b6fd91 100644 --- a/src/mcp/tools/project-discovery/show_build_settings.ts +++ b/src/mcp/tools/project-discovery/show_build_settings.ts @@ -9,13 +9,17 @@ import * as z from 'zod'; import { log } from '../../../utils/logging/index.ts'; import type { CommandExecutor } from '../../../utils/execution/index.ts'; import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; -import { createTextResponse } from '../../../utils/responses/index.ts'; import type { ToolResponse } from '../../../types/common.ts'; import { createSessionAwareTool, getSessionAwareToolSchemaShape, } from '../../../utils/typed-tool-factory.ts'; import { nullifyEmptyStrings } from '../../../utils/schema-helpers.ts'; +import { formatToolPreflight } from '../../../utils/build-preflight.ts'; +import { + formatQueryError, + formatQueryFailureSummary, +} from '../../../utils/xcodebuild-error-utils.ts'; // Unified schema: XOR between projectPath and workspacePath const baseSchemaObject = z.object({ @@ -37,6 +41,15 @@ const showBuildSettingsSchema = z.preprocess( export type ShowBuildSettingsParams = z.infer; +function stripXcodebuildPreamble(output: string): string { + const lines = output.split('\n'); + const startIndex = lines.findIndex((line) => line.startsWith('Build settings for action')); + if (startIndex === -1) { + return output; + } + return lines.slice(startIndex).join('\n'); +} + /** * Business logic for showing build settings from a project or workspace. * Exported for direct testing and reuse. @@ -47,12 +60,19 @@ export async function showBuildSettingsLogic( ): Promise { log('info', `Showing build settings for scheme ${params.scheme}`); - try { - // Create the command array for xcodebuild - const command = ['xcodebuild', '-showBuildSettings']; // -showBuildSettings as an option, not an action + const hasProjectPath = typeof params.projectPath === 'string'; + const path = hasProjectPath ? params.projectPath : params.workspacePath; - const hasProjectPath = typeof params.projectPath === 'string'; - const path = hasProjectPath ? params.projectPath : params.workspacePath; + const preflight = formatToolPreflight({ + operation: 'Show Build Settings', + scheme: params.scheme, + ...(hasProjectPath + ? { projectPath: params.projectPath } + : { workspacePath: params.workspacePath }), + }); + + try { + const command = ['xcodebuild', '-showBuildSettings']; if (hasProjectPath) { command.push('-project', params.projectPath!); @@ -60,31 +80,26 @@ export async function showBuildSettingsLogic( command.push('-workspace', params.workspacePath!); } - // Add the scheme command.push('-scheme', params.scheme); - // Execute the command directly const result = await executor(command, 'Show Build Settings', false); if (!result.success) { - return createTextResponse(`Failed to show build settings: ${result.error}`, true); + return { + content: [ + { + type: 'text', + text: `${preflight}\n${formatQueryError(result.error || 'Unknown error')}\n\n${formatQueryFailureSummary()}`, + }, + ], + isError: true, + }; } - // Create response based on which type was used - const content: Array<{ type: 'text'; text: string }> = [ - { - type: 'text', - text: hasProjectPath - ? `โœ… Build settings for scheme ${params.scheme}:` - : 'โœ… Build settings retrieved successfully', - }, - { - type: 'text', - text: result.output || 'Build settings retrieved successfully.', - }, - ]; - - // Build next step params + const settingsOutput = stripXcodebuildPreamble( + result.output || 'Build settings retrieved successfully.', + ); + let nextStepParams: Record> | undefined; if (path) { @@ -97,14 +112,22 @@ export async function showBuildSettingsLogic( } return { - content, + content: [{ type: 'text', text: `${preflight}\n${settingsOutput}` }], ...(nextStepParams ? { nextStepParams } : {}), isError: false, }; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); log('error', `Error showing build settings: ${errorMessage}`); - return createTextResponse(`Error showing build settings: ${errorMessage}`, true); + return { + content: [ + { + type: 'text', + text: `${preflight}\n${formatQueryError(errorMessage)}\n\n${formatQueryFailureSummary()}`, + }, + ], + isError: true, + }; } } diff --git a/src/mcp/tools/simulator/__tests__/build_run_sim.test.ts b/src/mcp/tools/simulator/__tests__/build_run_sim.test.ts index 03238365..23cf6801 100644 --- a/src/mcp/tools/simulator/__tests__/build_run_sim.test.ts +++ b/src/mcp/tools/simulator/__tests__/build_run_sim.test.ts @@ -1,6 +1,6 @@ /** * Tests for build_run_sim plugin (unified) - * Following CLAUDE.md testing standards with dependency injection and literal validation + * Following the canonical pending pipeline pattern from build_run_macos. */ import { describe, it, expect, beforeEach } from 'vitest'; @@ -8,11 +8,28 @@ import * as z from 'zod'; import { createMockExecutor, createMockCommandResponse, + mockProcess, } from '../../../../test-utils/mock-executors.ts'; import type { CommandExecutor } from '../../../../utils/execution/index.ts'; import { sessionStore } from '../../../../utils/session-store.ts'; +import { finalizePendingXcodebuildResponse } from '../../../../utils/xcodebuild-output.ts'; import { schema, handler, build_run_simLogic } from '../build_run_sim.ts'; +function expectPendingBuildRunResponse( + result: Awaited>, + isError: boolean, +): void { + expect(result.isError).toBe(isError); + expect(result.content).toEqual([]); + expect(result._meta).toEqual( + expect.objectContaining({ + pendingXcodebuild: expect.objectContaining({ + kind: 'pending-xcodebuild', + }), + }), + ); +} + describe('build_run_sim tool', () => { beforeEach(() => { sessionStore.clear(); @@ -46,16 +63,62 @@ describe('build_run_sim tool', () => { }); }); - describe('Handler Behavior (Complete Literal Returns)', () => { - // Note: Parameter validation is now handled by createTypedTool wrapper with Zod schema - // The logic function receives validated parameters, so these tests focus on business logic + describe('Handler Behavior (Pending Pipeline Contract)', () => { + it('should fail fast for an invalid explicit simulator ID with structured sad-path output', async () => { + const callHistory: string[][] = []; + const mockExecutor: CommandExecutor = async (command) => { + callHistory.push(command); - it('should handle simulator not found', async () => { + if (command[0] === 'xcrun' && command[1] === 'simctl') { + return createMockCommandResponse({ + success: true, + output: JSON.stringify({ + devices: { + 'com.apple.CoreSimulator.SimRuntime.iOS-18-0': [ + { udid: 'SOME-OTHER-UUID', name: 'iPhone 17', isAvailable: true }, + ], + }, + }), + }); + } + + return createMockCommandResponse({ + success: false, + error: 'xcodebuild should not run', + }); + }; + + const result = await build_run_simLogic( + { + workspacePath: '/path/to/workspace', + scheme: 'MyScheme', + simulatorId: 'INVALID-SIM-ID-123', + }, + mockExecutor, + ); + + expectPendingBuildRunResponse(result, true); + expect( + callHistory.some((command) => command[0] === 'xcodebuild' && command.includes('build')), + ).toBe(false); + + const finalized = finalizePendingXcodebuildResponse(result); + const textContent = finalized.content + .filter((item) => item.type === 'text') + .map((item) => item.text) + .join('\n'); + + expect(textContent).toContain('Build & Run'); + expect(textContent).toContain('โœ— No available simulator matched: INVALID-SIM-ID-123'); + expect(textContent).toContain('Build failed.'); + expect(textContent).not.toContain('Next steps:'); + }); + + it('should handle build settings failure as pending error', async () => { let callCount = 0; const mockExecutor: CommandExecutor = async (command) => { callCount++; if (callCount === 1) { - // First call: runtime lookup succeeds return createMockCommandResponse({ success: true, output: JSON.stringify({ @@ -67,13 +130,11 @@ describe('build_run_sim tool', () => { }), }); } else if (callCount === 2) { - // Second call: build succeeds return createMockCommandResponse({ success: true, output: 'BUILD SUCCEEDED', }); } else if (callCount === 3) { - // Third call: showBuildSettings fails to get app path return createMockCommandResponse({ success: false, error: 'Could not get build settings', @@ -94,18 +155,12 @@ describe('build_run_sim tool', () => { mockExecutor, ); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: 'Build succeeded, but failed to get app path: Could not get build settings', - }, - ], - isError: true, - }); + expectPendingBuildRunResponse(result, true); + expect(result.nextSteps).toBeUndefined(); + expect(result.nextStepParams).toBeUndefined(); }); - it('should handle build failure', async () => { + it('should handle build failure as pending error', async () => { const mockExecutor = createMockExecutor({ success: false, error: 'Build failed with error', @@ -120,31 +175,30 @@ describe('build_run_sim tool', () => { mockExecutor, ); - expect(result.isError).toBe(true); - expect(result.content).toBeDefined(); - expect(Array.isArray(result.content)).toBe(true); + expectPendingBuildRunResponse(result, true); + expect(result.nextSteps).toBeUndefined(); + expect(result.nextStepParams).toBeUndefined(); + expect(result._meta?.pendingXcodebuild).toEqual( + expect.objectContaining({ + errorFallbackPolicy: 'if-no-structured-diagnostics', + tailEvents: [], + }), + ); }); it('should handle successful build and run', async () => { - // Create a mock executor that simulates full successful flow - let callCount = 0; const mockExecutor: CommandExecutor = async (command) => { - callCount++; - if (command.includes('xcodebuild') && command.includes('build')) { - // First call: build succeeds return createMockCommandResponse({ success: true, output: 'BUILD SUCCEEDED', }); } else if (command.includes('xcodebuild') && command.includes('-showBuildSettings')) { - // Second call: build settings to get app path return createMockCommandResponse({ success: true, output: 'BUILT_PRODUCTS_DIR = /path/to/build\nFULL_PRODUCT_NAME = MyApp.app\n', }); } else if (command.includes('simctl') && command.includes('list')) { - // Find simulator calls return createMockCommandResponse({ success: true, output: JSON.stringify({ @@ -161,17 +215,15 @@ describe('build_run_sim tool', () => { }), }); } else if ( - command.includes('plutil') || - command.includes('PlistBuddy') || - command.includes('defaults') + command.some( + (c) => c.includes('plutil') || c.includes('PlistBuddy') || c.includes('defaults'), + ) ) { - // Bundle ID extraction return createMockCommandResponse({ success: true, output: 'io.sentry.MyApp', }); } else { - // All other commands (boot, open, install, launch) succeed return createMockCommandResponse({ success: true, output: 'Success', @@ -188,16 +240,69 @@ describe('build_run_sim tool', () => { mockExecutor, ); - expect(result.content).toBeDefined(); - expect(Array.isArray(result.content)).toBe(true); - expect(result.isError).toBe(false); + expectPendingBuildRunResponse(result, false); + expect(result.nextSteps).toBeUndefined(); + expect(result._meta?.pendingXcodebuild).toEqual( + expect.objectContaining({ + tailEvents: [ + expect.objectContaining({ + type: 'notice', + code: 'build-run-result', + data: expect.objectContaining({ + scheme: 'MyScheme', + appPath: '/path/to/build/MyApp.app', + bundleId: 'io.sentry.MyApp', + launchState: 'requested', + }), + }), + ], + }), + ); }); - it('should handle exception with Error object', async () => { - const mockExecutor = createMockExecutor({ - success: false, - error: 'Command failed', - }); + it('should handle install failure as pending error', async () => { + let callCount = 0; + const mockExecutor: CommandExecutor = async (command) => { + callCount++; + + if (command.includes('xcodebuild') && command.includes('build')) { + return createMockCommandResponse({ + success: true, + output: 'BUILD SUCCEEDED', + }); + } else if (command.includes('xcodebuild') && command.includes('-showBuildSettings')) { + return createMockCommandResponse({ + success: true, + output: 'BUILT_PRODUCTS_DIR = /path/to/build\nFULL_PRODUCT_NAME = MyApp.app\n', + }); + } else if (command.includes('simctl') && command.includes('list')) { + return createMockCommandResponse({ + success: true, + output: JSON.stringify({ + devices: { + 'iOS 16.0': [ + { + udid: 'test-uuid-123', + name: 'iPhone 17', + state: 'Booted', + isAvailable: true, + }, + ], + }, + }), + }); + } else if (command.includes('simctl') && command.includes('install')) { + return createMockCommandResponse({ + success: false, + error: 'Failed to install', + }); + } else { + return createMockCommandResponse({ + success: true, + output: 'Success', + }); + } + }; const result = await build_run_simLogic( { @@ -208,16 +313,26 @@ describe('build_run_sim tool', () => { mockExecutor, ); - expect(result.isError).toBe(true); - expect(result.content).toBeDefined(); - expect(Array.isArray(result.content)).toBe(true); + expectPendingBuildRunResponse(result, true); + expect(result.nextSteps).toBeUndefined(); + expect(result.nextStepParams).toBeUndefined(); }); - it('should handle exception with string error', async () => { - const mockExecutor = createMockExecutor({ - success: false, - error: 'String error', - }); + it('should handle spawn error as text fallback', async () => { + const mockExecutor = ( + command: string[], + description?: string, + logOutput?: boolean, + opts?: { cwd?: string }, + detached?: boolean, + ) => { + void command; + void description; + void logOutput; + void opts; + void detached; + return Promise.reject(new Error('spawn xcodebuild ENOENT')); + }; const result = await build_run_simLogic( { @@ -229,8 +344,12 @@ describe('build_run_sim tool', () => { ); expect(result.isError).toBe(true); - expect(result.content).toBeDefined(); - expect(Array.isArray(result.content)).toBe(true); + expect(result.content.length).toBeGreaterThan(0); + const text = result.content + .filter((c) => c.type === 'text') + .map((c) => c.text) + .join('\n'); + expect(text).toContain('Error during simulator build and run'); }); }); @@ -496,7 +615,6 @@ describe('build_run_sim tool', () => { }); it('should succeed with only projectPath', async () => { - // This test fails early due to build failure, which is expected behavior const mockExecutor = createMockExecutor({ success: false, error: 'Build failed', @@ -510,13 +628,10 @@ describe('build_run_sim tool', () => { }, mockExecutor, ); - // The test succeeds if the logic function accepts the parameters and attempts to build - expect(result.isError).toBe(true); - expect(result.content[0].text).toContain('Build failed'); + expectPendingBuildRunResponse(result, true); }); it('should succeed with only workspacePath', async () => { - // This test fails early due to build failure, which is expected behavior const mockExecutor = createMockExecutor({ success: false, error: 'Build failed', @@ -530,9 +645,140 @@ describe('build_run_sim tool', () => { }, mockExecutor, ); - // The test succeeds if the logic function accepts the parameters and attempts to build - expect(result.isError).toBe(true); - expect(result.content[0].text).toContain('Build failed'); + expectPendingBuildRunResponse(result, true); + }); + }); + + describe('Finalized Output Contract', () => { + it('should produce correct success output when finalized', async () => { + const mockExecutor: CommandExecutor = async (command) => { + if (command.includes('xcodebuild') && command.includes('build')) { + return createMockCommandResponse({ + success: true, + output: 'BUILD SUCCEEDED', + }); + } else if (command.includes('xcodebuild') && command.includes('-showBuildSettings')) { + return createMockCommandResponse({ + success: true, + output: 'BUILT_PRODUCTS_DIR = /path/to/build\nFULL_PRODUCT_NAME = MyApp.app\n', + }); + } else if (command.includes('simctl') && command.includes('list')) { + return createMockCommandResponse({ + success: true, + output: JSON.stringify({ + devices: { + 'iOS 16.0': [ + { + udid: 'test-uuid-123', + name: 'iPhone 17', + state: 'Booted', + isAvailable: true, + }, + ], + }, + }), + }); + } else if ( + command.some( + (c) => c.includes('plutil') || c.includes('PlistBuddy') || c.includes('defaults'), + ) + ) { + return createMockCommandResponse({ + success: true, + output: 'io.sentry.MyApp', + }); + } else { + return createMockCommandResponse({ + success: true, + output: 'Success', + }); + } + }; + + const result = await build_run_simLogic( + { + workspacePath: '/path/to/workspace', + scheme: 'MyScheme', + simulatorName: 'iPhone 17', + }, + mockExecutor, + ); + + const finalized = finalizePendingXcodebuildResponse(result); + + expect(finalized.isError).toBe(false); + expect(finalized.content.length).toBeGreaterThan(0); + + const textContent = finalized.content + .filter((item) => item.type === 'text') + .map((item) => item.text) + .join('\n'); + + // Front matter + expect(textContent).toContain('Build & Run'); + expect(textContent).toContain('Scheme: MyScheme'); + + // Summary + expect(textContent).toContain('Build succeeded.'); + + // Footer with execution-derived values + expect(textContent).toContain('Build & Run complete'); + expect(textContent).toContain('App Path: /path/to/build/MyApp.app'); + expect(textContent).toContain('Bundle ID: io.sentry.MyApp'); + + // No next steps in finalized output (those come from tool invoker) + expect(textContent).not.toContain('Next steps:'); + }); + + it('should produce correct failure output when finalized', async () => { + const callHistory: string[][] = []; + const mockExecutor: CommandExecutor = async (command) => { + callHistory.push(command); + + if (command[0] === 'xcrun' && command[1] === 'simctl') { + return createMockCommandResponse({ + success: true, + output: JSON.stringify({ + devices: { + 'com.apple.CoreSimulator.SimRuntime.iOS-18-0': [ + { udid: 'SOME-OTHER-UUID', name: 'iPhone 17', isAvailable: true }, + ], + }, + }), + }); + } + + return createMockCommandResponse({ + success: false, + error: 'should not run', + }); + }; + + const result = await build_run_simLogic( + { + workspacePath: '/path/to/workspace', + scheme: 'MyScheme', + simulatorId: 'BAD-UUID', + }, + mockExecutor, + ); + + const finalized = finalizePendingXcodebuildResponse(result); + + expect(finalized.isError).toBe(true); + const textContent = finalized.content + .filter((item) => item.type === 'text') + .map((item) => item.text) + .join('\n'); + + // Front matter present + expect(textContent).toContain('Build & Run'); + + // Error and summary present + expect(textContent).toContain('Build failed.'); + + // No next steps on failure + expect(textContent).not.toContain('Next steps:'); }); }); }); diff --git a/src/mcp/tools/simulator/__tests__/build_sim.test.ts b/src/mcp/tools/simulator/__tests__/build_sim.test.ts index 5655cdd5..0396dec6 100644 --- a/src/mcp/tools/simulator/__tests__/build_sim.test.ts +++ b/src/mcp/tools/simulator/__tests__/build_sim.test.ts @@ -10,6 +10,30 @@ import { sessionStore } from '../../../../utils/session-store.ts'; // Import the named exports and logic function import { schema, handler, build_simLogic } from '../build_sim.ts'; +function expectPendingBuildResponse( + result: Awaited>, + nextStepToolId?: string, +): void { + 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(); + } +} + describe('build_sim tool', () => { beforeEach(() => { sessionStore.clear(); @@ -79,17 +103,7 @@ describe('build_sim tool', () => { mockExecutor, ); - // Empty string passes validation but may cause build issues - expect(result.content).toEqual([ - { - type: 'text', - text: 'โœ… iOS Simulator Build build succeeded for scheme MyScheme.', - }, - { - type: 'text', - text: expect.stringContaining('Next Steps:'), - }, - ]); + expectPendingBuildResponse(result, 'get_sim_app_path'); }); it('should handle missing scheme parameter', async () => { @@ -115,17 +129,7 @@ describe('build_sim tool', () => { mockExecutor, ); - // Empty string passes validation but may cause build issues - expect(result.content).toEqual([ - { - type: 'text', - text: 'โœ… iOS Simulator Build build succeeded for scheme .', - }, - { - type: 'text', - text: expect.stringContaining('Next Steps:'), - }, - ]); + expectPendingBuildResponse(result, 'get_sim_app_path'); }); it('should handle missing both simulatorId and simulatorName', async () => { @@ -173,11 +177,8 @@ describe('build_sim tool', () => { mockExecutor, ); - // Empty simulatorName passes validation but causes early failure in destination construction expect(result.isError).toBe(true); - expect(result.content[0].text).toBe( - 'For iOS Simulator platform, either simulatorId or simulatorName must be provided', - ); + expectPendingBuildResponse(result); }); }); @@ -414,16 +415,8 @@ describe('build_sim tool', () => { mockExecutor, ); - expect(result.content).toEqual([ - { - type: 'text', - text: 'โœ… iOS Simulator Build build succeeded for scheme MyScheme.', - }, - { - type: 'text', - text: expect.stringContaining('Next Steps:'), - }, - ]); + expect(result.isError).toBeFalsy(); + expectPendingBuildResponse(result, 'get_sim_app_path'); }); it('should handle successful build with all optional parameters', async () => { @@ -443,16 +436,8 @@ describe('build_sim tool', () => { mockExecutor, ); - expect(result.content).toEqual([ - { - type: 'text', - text: 'โœ… iOS Simulator Build build succeeded for scheme MyScheme.', - }, - { - type: 'text', - text: expect.stringContaining('Next Steps:'), - }, - ]); + expect(result.isError).toBeFalsy(); + expectPendingBuildResponse(result, 'get_sim_app_path'); }); it('should handle build failure', async () => { @@ -471,19 +456,8 @@ describe('build_sim tool', () => { mockExecutor, ); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: 'โŒ [stderr] Build failed: Compilation error', - }, - { - type: 'text', - text: 'โŒ iOS Simulator Build build failed for scheme MyScheme.', - }, - ], - isError: true, - }); + expect(result.isError).toBe(true); + expectPendingBuildResponse(result); }); it('should handle build warnings', async () => { @@ -501,22 +475,8 @@ describe('build_sim tool', () => { mockExecutor, ); - expect(result.content).toEqual( - expect.arrayContaining([ - { - type: 'text', - text: expect.stringContaining('โš ๏ธ'), - }, - { - type: 'text', - text: 'โœ… iOS Simulator Build build succeeded for scheme MyScheme.', - }, - { - type: 'text', - text: expect.stringContaining('Next Steps:'), - }, - ]), - ); + expect(result.isError).toBeFalsy(); + expectPendingBuildResponse(result, 'get_sim_app_path'); }); it('should handle command executor errors', async () => { @@ -535,7 +495,7 @@ describe('build_sim tool', () => { ); expect(result.isError).toBe(true); - expect(result.content[0].text).toBe('โŒ [stderr] spawn xcodebuild ENOENT'); + expectPendingBuildResponse(result); }); it('should handle mixed warning and error output', async () => { @@ -555,24 +515,7 @@ describe('build_sim tool', () => { ); expect(result.isError).toBe(true); - expect(result.content).toEqual([ - { - type: 'text', - text: 'โš ๏ธ Warning: warning: deprecated method', - }, - { - type: 'text', - text: 'โŒ Error: error: undefined symbol', - }, - { - type: 'text', - text: 'โŒ [stderr] Build failed', - }, - { - type: 'text', - text: 'โŒ iOS Simulator Build build failed for scheme MyScheme.', - }, - ]); + expectPendingBuildResponse(result); }); it('should use default configuration when not provided', async () => { @@ -583,30 +526,19 @@ describe('build_sim tool', () => { workspacePath: '/path/to/workspace', scheme: 'MyScheme', simulatorName: 'iPhone 17', - // configuration intentionally omitted - should default to Debug }, mockExecutor, ); - expect(result.content).toEqual([ - { - type: 'text', - text: 'โœ… iOS Simulator Build build succeeded for scheme MyScheme.', - }, - { - type: 'text', - text: expect.stringContaining('Next Steps:'), - }, - ]); + expect(result.isError).toBeFalsy(); + expectPendingBuildResponse(result, 'get_sim_app_path'); }); }); describe('Error Handling', () => { it('should handle catch block exceptions', async () => { - // Create a mock that throws an error when called const mockExecutor = createMockExecutor({ success: true, output: 'BUILD SUCCEEDED' }); - // Mock the handler to throw an error by passing invalid parameters to internal functions const result = await build_simLogic( { workspacePath: '/path/to/workspace', @@ -616,17 +548,8 @@ describe('build_sim tool', () => { mockExecutor, ); - // Should handle the build successfully - expect(result.content).toEqual([ - { - type: 'text', - text: 'โœ… iOS Simulator Build build succeeded for scheme MyScheme.', - }, - { - type: 'text', - text: expect.stringContaining('Next Steps:'), - }, - ]); + expect(result.isError).toBeFalsy(); + expectPendingBuildResponse(result, 'get_sim_app_path'); }); }); }); diff --git a/src/mcp/tools/simulator/__tests__/get_sim_app_path.test.ts b/src/mcp/tools/simulator/__tests__/get_sim_app_path.test.ts index 95bf83df..0c7dbdd6 100644 --- a/src/mcp/tools/simulator/__tests__/get_sim_app_path.test.ts +++ b/src/mcp/tools/simulator/__tests__/get_sim_app_path.test.ts @@ -159,9 +159,16 @@ describe('get_sim_app_path tool', () => { ]); expect(result.isError).toBe(false); - expect(result.content[0].text).toContain( - 'โœ… App path retrieved successfully: /tmp/DerivedData/Build/MyApp.app', - ); + const text = result.content[0].text; + expect(text).toContain('\u{1F50D} Get App Path'); + expect(text).toContain('Scheme: MyScheme'); + expect(text).toContain('Workspace: /path/to/workspace.xcworkspace'); + expect(text).toContain('Configuration: Debug'); + expect(text).toContain('Platform: iOS Simulator'); + expect(text).toContain('Simulator: iPhone 17'); + expect(text).toContain('\u{2514} App Path: /tmp/DerivedData/Build/MyApp.app'); + expect(text).not.toContain('\u{2705}'); + expect(result.nextStepParams).toBeDefined(); }); it('should surface executor failures when build settings cannot be retrieved', async () => { @@ -181,8 +188,13 @@ describe('get_sim_app_path tool', () => { ); expect(result.isError).toBe(true); - expect(result.content[0].text).toContain('Failed to get app path'); - expect(result.content[0].text).toContain('Failed to run xcodebuild'); + const text = result.content[0].text; + expect(text).toContain('\u{1F50D} Get App Path'); + expect(text).toContain('Scheme: MyScheme'); + expect(text).toContain('Errors ('); + expect(text).toContain('\u{2717}'); + expect(text).toContain('\u{274C} Query failed.'); + expect(result.nextStepParams).toBeUndefined(); }); }); }); diff --git a/src/mcp/tools/simulator/__tests__/test_sim.test.ts b/src/mcp/tools/simulator/__tests__/test_sim.test.ts index bdb9b619..b9151a91 100644 --- a/src/mcp/tools/simulator/__tests__/test_sim.test.ts +++ b/src/mcp/tools/simulator/__tests__/test_sim.test.ts @@ -3,10 +3,27 @@ * Follows CLAUDE.md guidance: dependency injection, no vi-mocks, literal validation. */ -import { describe, it, expect, beforeEach } from 'vitest'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; import * as z from 'zod'; import { sessionStore } from '../../../../utils/session-store.ts'; import { schema, handler, test_simLogic } from '../test_sim.ts'; +import { + createMockCommandResponse, + createMockFileSystemExecutor, +} from '../../../../test-utils/mock-executors.ts'; +import { + isPendingXcodebuildResponse, + finalizePendingXcodebuildResponse, +} from '../../../../utils/xcodebuild-output.ts'; +import type { ToolResponse } from '../../../../types/common.ts'; + +function finalizeAndGetText(result: ToolResponse): string { + if (isPendingXcodebuildResponse(result)) { + const finalized = finalizePendingXcodebuildResponse(result); + return finalized.content.map((c) => c.text).join('\n'); + } + return result.content.map((c) => c.text).join('\n'); +} describe('test_sim tool', () => { beforeEach(() => { @@ -85,4 +102,127 @@ describe('test_sim tool', () => { expect(result.content[0].text).toContain('simulatorName'); }); }); + + describe('preflight output', () => { + it('prints Flowdeck-style preflight in CLI text mode', async () => { + const originalRuntime = process.env.XCODEBUILDMCP_RUNTIME; + const originalOutputFormat = process.env.XCODEBUILDMCP_CLI_OUTPUT_FORMAT; + process.env.XCODEBUILDMCP_RUNTIME = 'cli'; + process.env.XCODEBUILDMCP_CLI_OUTPUT_FORMAT = 'text'; + + const stdoutWrite = vi.spyOn(process.stdout, 'write').mockImplementation(() => true); + + const files = new Map([ + [ + '/tmp/App.xcodeproj/xcshareddata/xcschemes/App.xcscheme', + ` + + + + + + + + +`, + ], + [ + '/tmp/AppTests/AppTests.swift', + `import XCTest +final class AppTests: XCTestCase { + func testLaunch() {} +}`, + ], + ]); + + let callCount = 0; + const executor = async ( + command: string[], + _description?: string, + _useShell?: boolean, + _opts?: { cwd?: string }, + ) => { + if (command[0] === 'xcrun' && command[1] === 'simctl') { + return createMockCommandResponse({ + success: true, + output: JSON.stringify({ + devices: { + 'com.apple.CoreSimulator.SimRuntime.iOS-26-0': [ + { udid: 'SIM-UUID', name: 'iPhone 17 Pro' }, + ], + }, + }), + }); + } + + callCount += 1; + if (callCount === 1) { + return createMockCommandResponse({ success: true, output: 'BUILD SUCCEEDED' }); + } + + return createMockCommandResponse({ + success: true, + output: JSON.stringify({ + title: 'App Tests', + result: 'SUCCEEDED', + totalTestCount: 1, + passedTests: 1, + failedTests: 0, + skippedTests: 0, + expectedFailures: 0, + }), + }); + }; + + try { + const result = await test_simLogic( + { + projectPath: '/tmp/App.xcodeproj', + scheme: 'App', + simulatorName: 'iPhone 17 Pro', + configuration: 'Debug', + progress: false, + }, + executor, + createMockFileSystemExecutor({ + existsSync: (targetPath) => + files.has(targetPath) || + ['/tmp/AppTests', '/tmp/test-run/TestResults.xcresult'].includes(targetPath), + readFile: async (targetPath) => files.get(targetPath) ?? '', + readdir: async (targetPath) => + targetPath === '/tmp/AppTests' ? ['AppTests.swift'] : [], + stat: async (targetPath) => ({ + isDirectory: () => + targetPath === '/tmp/AppTests' || + targetPath === '/tmp/test-run/TestResults.xcresult', + mtimeMs: 0, + }), + mkdtemp: async () => '/tmp/test-run', + tmpdir: () => '/tmp', + rm: async () => {}, + }), + ); + + expect(isPendingXcodebuildResponse(result)).toBe(true); + const stdoutOutput = stdoutWrite.mock.calls.flat().join(''); + const responseText = finalizeAndGetText(result); + const allOutput = stdoutOutput + responseText; + expect(allOutput).toContain('Scheme: App'); + expect(allOutput).toContain('Resolved to 1 test(s):'); + expect(allOutput).toContain('AppTests/AppTests/testLaunch'); + } finally { + stdoutWrite.mockRestore(); + if (originalRuntime === undefined) { + delete process.env.XCODEBUILDMCP_RUNTIME; + } else { + process.env.XCODEBUILDMCP_RUNTIME = originalRuntime; + } + if (originalOutputFormat === undefined) { + delete process.env.XCODEBUILDMCP_CLI_OUTPUT_FORMAT; + } else { + process.env.XCODEBUILDMCP_CLI_OUTPUT_FORMAT = originalOutputFormat; + } + } + }); + }); }); diff --git a/src/mcp/tools/simulator/build_run_sim.ts b/src/mcp/tools/simulator/build_run_sim.ts index 08bd0136..b96e5aa5 100644 --- a/src/mcp/tools/simulator/build_run_sim.ts +++ b/src/mcp/tools/simulator/build_run_sim.ts @@ -8,7 +8,6 @@ import * as z from 'zod'; import type { ToolResponse, SharedBuildParams } from '../../../types/common.ts'; -import { XcodePlatform } from '../../../types/common.ts'; import { log } from '../../../utils/logging/index.ts'; import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; import { @@ -18,12 +17,23 @@ import { import { createTextResponse } from '../../../utils/responses/index.ts'; import { executeXcodeBuildCommand } from '../../../utils/build/index.ts'; import type { CommandExecutor } from '../../../utils/execution/index.ts'; -import { determineSimulatorUuid } from '../../../utils/simulator-utils.ts'; +import { + determineSimulatorUuid, + validateAvailableSimulatorId, +} from '../../../utils/simulator-utils.ts'; import { nullifyEmptyStrings } from '../../../utils/schema-helpers.ts'; import { inferPlatform } from '../../../utils/infer-platform.ts'; import { constructDestinationString } from '../../../utils/xcode.ts'; +import { startBuildPipeline } from '../../../utils/xcodebuild-pipeline.ts'; +import { formatToolPreflight } from '../../../utils/build-preflight.ts'; +import { + createPendingXcodebuildResponse, + emitPipelineError, + emitPipelineNotice, +} from '../../../utils/xcodebuild-output.ts'; +import { resolveAppPathFromBuildSettings } from '../../../utils/app-path-resolver.ts'; +import { extractBundleIdFromAppPath } from '../../../utils/bundle-id.ts'; -// Unified schema: XOR between projectPath and workspacePath, and XOR between simulatorId and simulatorName const baseOptions = { scheme: z.string().describe('The scheme to use (Required)'), simulatorId: z @@ -79,194 +89,199 @@ const buildRunSimulatorSchema = z.preprocess( export type BuildRunSimulatorParams = z.infer; -// Internal logic for building Simulator apps. -async function _handleSimulatorBuildLogic( +export async function build_run_simLogic( params: BuildRunSimulatorParams, executor: CommandExecutor, - executeXcodeBuildCommandFn: typeof executeXcodeBuildCommand = executeXcodeBuildCommand, -): Promise<{ response: ToolResponse; detectedPlatform: XcodePlatform }> { +): Promise { const projectType = params.projectPath ? 'project' : 'workspace'; const filePath = params.projectPath ?? params.workspacePath; - // Log warning if useLatestOS is provided with simulatorId - if (params.simulatorId && params.useLatestOS !== undefined) { + log( + 'info', + `Starting Simulator build and run for scheme ${params.scheme} from ${projectType}: ${filePath}`, + ); + + try { + if (params.simulatorId && params.useLatestOS !== undefined) { + log( + 'warn', + `useLatestOS parameter is ignored when using simulatorId (UUID implies exact device/OS)`, + ); + } + + const inferred = await inferPlatform( + { + projectPath: params.projectPath, + workspacePath: params.workspacePath, + scheme: params.scheme, + simulatorName: params.simulatorId ? undefined : params.simulatorName, + }, + executor, + ); + const detectedPlatform = inferred.platform; + const displayPlatform = + params.simulatorId && inferred.source !== 'simulator-runtime' + ? 'Simulator' + : String(detectedPlatform); + const platformName = detectedPlatform.replace(' Simulator', ''); + const logPrefix = `${platformName} Simulator Build`; + const configuration = params.configuration ?? 'Debug'; + log( - 'warn', - `useLatestOS parameter is ignored when using simulatorId (UUID implies exact device/OS)`, + 'info', + `Starting ${logPrefix} for scheme ${params.scheme} from ${projectType}: ${filePath}`, ); - } + log('info', `Inferred simulator platform: ${detectedPlatform} (source: ${inferred.source})`); - const inferred = await inferPlatform( - { + const preflightText = formatToolPreflight({ + operation: 'Build & Run', + scheme: params.scheme, + workspacePath: params.workspacePath, projectPath: params.projectPath, + configuration, + platform: displayPlatform, + simulatorName: params.simulatorName, + simulatorId: params.simulatorId, + }); + + const started = startBuildPipeline({ + operation: 'BUILD', + toolName: 'build_run_sim', + params: { + scheme: params.scheme, + configuration, + platform: displayPlatform, + preflight: preflightText, + }, + message: preflightText, + }); + + // Validate explicit simulator ID before build + if (params.simulatorId) { + const validation = await validateAvailableSimulatorId(params.simulatorId, executor); + if (validation.error) { + const errorText = validation.error.content + .filter((item) => item.type === 'text') + .flatMap((item) => item.text.split('\n')) + .map((line) => line.trim()) + .find((line) => line.startsWith('Error:')) + ?.replace(/^Error:\s*/, '') + .trim(); + emitPipelineError( + started, + 'BUILD', + errorText ?? `No available simulator matched: ${params.simulatorId}`, + ); + return createPendingXcodebuildResponse(started, { + content: [], + isError: true, + }); + } + } + + // Build + const sharedBuildParams: SharedBuildParams = { workspacePath: params.workspacePath, + projectPath: params.projectPath, scheme: params.scheme, - simulatorId: params.simulatorId, - simulatorName: params.simulatorName, - }, - executor, - ); - const detectedPlatform = inferred.platform; - const platformName = detectedPlatform.replace(' Simulator', ''); - const logPrefix = `${platformName} Simulator Build`; - - log('info', `Starting ${logPrefix} for scheme ${params.scheme} from ${projectType}: ${filePath}`); - log('info', `Inferred simulator platform: ${detectedPlatform} (source: ${inferred.source})`); - - // Create SharedBuildParams object with required configuration property - const sharedBuildParams: SharedBuildParams = { - workspacePath: params.workspacePath, - projectPath: params.projectPath, - scheme: params.scheme, - configuration: params.configuration ?? 'Debug', - derivedDataPath: params.derivedDataPath, - extraArgs: params.extraArgs, - }; - - const response = await executeXcodeBuildCommandFn( - sharedBuildParams, - { + configuration, + derivedDataPath: params.derivedDataPath, + extraArgs: params.extraArgs, + }; + + const platformOptions = { platform: detectedPlatform, simulatorId: params.simulatorId, simulatorName: params.simulatorName, useLatestOS: params.simulatorId ? false : params.useLatestOS, logPrefix, - }, - params.preferXcodebuild as boolean, - 'build', - executor, - ); - - return { response, detectedPlatform }; -} - -// Exported business logic function for building and running iOS Simulator apps. -export async function build_run_simLogic( - params: BuildRunSimulatorParams, - executor: CommandExecutor, - executeXcodeBuildCommandFn: typeof executeXcodeBuildCommand = executeXcodeBuildCommand, -): Promise { - const projectType = params.projectPath ? 'project' : 'workspace'; - const filePath = params.projectPath ?? params.workspacePath; - - log( - 'info', - `Starting Simulator build and run for scheme ${params.scheme} from ${projectType}: ${filePath}`, - ); + }; - try { - // --- Build Step --- - const { response: buildResult, detectedPlatform } = await _handleSimulatorBuildLogic( - params, + const buildResult = await executeXcodeBuildCommand( + sharedBuildParams, + platformOptions, + params.preferXcodebuild ?? false, + 'build', executor, - executeXcodeBuildCommandFn, + undefined, + started.pipeline, ); if (buildResult.isError) { - return buildResult; // Return the build error + return createPendingXcodebuildResponse(started, buildResult, { + errorFallbackPolicy: 'if-no-structured-diagnostics', + }); } - const platformName = detectedPlatform.replace(' Simulator', ''); - - // --- Get App Path Step --- - // Create the command array for xcodebuild with -showBuildSettings option - const command = ['xcodebuild', '-showBuildSettings']; + // Resolve app path + emitPipelineNotice(started, 'BUILD', 'Resolving app path', 'info', { + code: 'build-run-step', + data: { step: 'resolve-app-path', status: 'started' }, + }); - // Add the workspace or project - if (params.workspacePath) { - command.push('-workspace', params.workspacePath); - } else if (params.projectPath) { - command.push('-project', params.projectPath); - } - - // Add the scheme and configuration - command.push('-scheme', params.scheme); - command.push('-configuration', params.configuration ?? 'Debug'); - - // Handle destination for simulator - let destinationString: string; + let destination: string; if (params.simulatorId) { - destinationString = constructDestinationString( - detectedPlatform, - undefined, - params.simulatorId, - ); + destination = constructDestinationString(detectedPlatform, undefined, params.simulatorId); } else if (params.simulatorName) { - destinationString = constructDestinationString( + destination = constructDestinationString( detectedPlatform, params.simulatorName, undefined, params.useLatestOS ?? true, ); } else { - // This shouldn't happen due to validation, but handle it - destinationString = constructDestinationString(detectedPlatform); - } - command.push('-destination', destinationString); - - // Add derived data path if provided - if (params.derivedDataPath) { - command.push('-derivedDataPath', params.derivedDataPath); + destination = constructDestinationString(detectedPlatform); } - // Add extra args if provided - if (params.extraArgs && params.extraArgs.length > 0) { - command.push(...params.extraArgs); - } - - // Execute the command directly - const result = await executor(command, 'Get App Path', false, undefined); - - // If there was an error with the command execution, return it - if (!result.success) { - return createTextResponse( - `Build succeeded, but failed to get app path: ${result.error ?? 'Unknown error'}`, - true, - ); - } - - // Parse the output to extract the app path - const buildSettingsOutput = result.output; - - // Try both approaches to get app path - first the project approach (CODESIGNING_FOLDER_PATH) - let appBundlePath: string | null = null; - - // Project approach: Extract CODESIGNING_FOLDER_PATH from build settings to get app path - const appPathMatch = buildSettingsOutput.match(/CODESIGNING_FOLDER_PATH = (.+\.app)/); - if (appPathMatch?.[1]) { - appBundlePath = appPathMatch[1].trim(); - } else { - // Workspace approach: Extract BUILT_PRODUCTS_DIR and FULL_PRODUCT_NAME - 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) { - const builtProductsDir = builtProductsDirMatch[1].trim(); - const fullProductName = fullProductNameMatch[1].trim(); - appBundlePath = `${builtProductsDir}/${fullProductName}`; - } - } - - if (!appBundlePath) { - return createTextResponse( - `Build succeeded, but could not find app path in build settings.`, - true, + let appBundlePath: string; + try { + appBundlePath = await resolveAppPathFromBuildSettings( + { + projectPath: params.projectPath, + workspacePath: params.workspacePath, + scheme: params.scheme, + configuration: params.configuration, + platform: detectedPlatform, + destination, + derivedDataPath: params.derivedDataPath, + extraArgs: params.extraArgs, + }, + executor, ); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + log('error', 'Build succeeded, but failed to get app path to launch.'); + emitPipelineError(started, 'BUILD', `Failed to get app path to launch: ${errorMessage}`); + return createPendingXcodebuildResponse(started, { + content: [], + isError: true, + }); } log('info', `App bundle path for run: ${appBundlePath}`); - - // --- Find/Boot Simulator Step --- - // Use our helper to determine the simulator UUID - const uuidResult = await determineSimulatorUuid( - { simulatorId: params.simulatorId, simulatorName: params.simulatorName }, - executor, - ); + emitPipelineNotice(started, 'BUILD', 'App path resolved', 'success', { + code: 'build-run-step', + data: { step: 'resolve-app-path', status: 'succeeded', appPath: appBundlePath }, + }); + + // Resolve simulator UUID + const uuidResult = params.simulatorId + ? { uuid: params.simulatorId } + : await determineSimulatorUuid( + { simulatorId: params.simulatorId, simulatorName: params.simulatorName }, + executor, + ); if (uuidResult.error) { - return createTextResponse(`Build succeeded, but ${uuidResult.error.content[0].text}`, true); + const errorMsg = uuidResult.error.content + .filter((item) => item.type === 'text') + .map((item) => item.text) + .join(' '); + emitPipelineError(started, 'BUILD', `Failed to resolve simulator UUID: ${errorMsg}`); + return createPendingXcodebuildResponse(started, { + content: [], + isError: true, + }); } if (uuidResult.warning) { @@ -276,13 +291,23 @@ export async function build_run_simLogic( const simulatorId = uuidResult.uuid; if (!simulatorId) { - return createTextResponse( - 'Build succeeded, but no simulator specified and failed to find a suitable one.', - true, + emitPipelineError( + started, + 'BUILD', + 'Failed to resolve simulator: no simulator identifier provided', ); + return createPendingXcodebuildResponse(started, { + content: [], + isError: true, + }); } - // Check simulator state and boot if needed + // Boot simulator if needed + emitPipelineNotice(started, 'BUILD', 'Booting simulator', 'info', { + code: 'build-run-step', + data: { step: 'boot-simulator', status: 'started' }, + }); + try { log('info', `Checking simulator state for UUID: ${simulatorId}`); const simulatorListResult = await executor( @@ -298,7 +323,6 @@ export async function build_run_simLogic( }; let targetSimulator: { udid: string; name: string; state: string } | null = null; - // Find the target simulator for (const runtime in simulatorsData.devices) { const devices = simulatorsData.devices[runtime]; if (Array.isArray(devices)) { @@ -327,13 +351,13 @@ export async function build_run_simLogic( } if (!targetSimulator) { - return createTextResponse( - `Build succeeded, but could not find simulator with UUID: ${simulatorId}`, - true, - ); + emitPipelineError(started, 'BUILD', `Failed to find simulator with UUID: ${simulatorId}`); + return createPendingXcodebuildResponse(started, { + content: [], + isError: true, + }); } - // Boot if needed if (targetSimulator.state !== 'Booted') { log('info', `Booting simulator ${targetSimulator.name}...`); const bootResult = await executor( @@ -348,14 +372,20 @@ export async function build_run_simLogic( } } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); - log('error', `Error checking/booting simulator: ${errorMessage}`); - return createTextResponse( - `Build succeeded, but error checking/booting simulator: ${errorMessage}`, - true, - ); + log('error', `Failed to boot simulator: ${errorMessage}`); + emitPipelineError(started, 'BUILD', `Failed to boot simulator: ${errorMessage}`); + return createPendingXcodebuildResponse(started, { + content: [], + isError: true, + }); } - // --- Open Simulator UI Step --- + emitPipelineNotice(started, 'BUILD', 'Simulator ready', 'success', { + code: 'build-run-step', + data: { step: 'boot-simulator', status: 'succeeded' }, + }); + + // Open Simulator.app (non-fatal) try { log('info', 'Opening Simulator app'); const openResult = await executor(['open', '-a', 'Simulator'], 'Open Simulator App'); @@ -365,10 +395,14 @@ export async function build_run_simLogic( } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); log('warn', `Warning: Could not open Simulator app: ${errorMessage}`); - // Don't fail the whole operation for this } - // --- Install App Step --- + // Install app + emitPipelineNotice(started, 'BUILD', 'Installing app', 'info', { + code: 'build-run-step', + data: { step: 'install-app', status: 'started' }, + }); + try { log('info', `Installing app at path: ${appBundlePath} to simulator: ${simulatorId}`); const installResult = await executor( @@ -380,84 +414,44 @@ export async function build_run_simLogic( } } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); - log('error', `Error installing app: ${errorMessage}`); - return createTextResponse( - `Build succeeded, but error installing app on simulator: ${errorMessage}`, - true, - ); + log('error', `Failed to install app: ${errorMessage}`); + emitPipelineError(started, 'BUILD', `Failed to install app on simulator: ${errorMessage}`); + return createPendingXcodebuildResponse(started, { + content: [], + isError: true, + }); } - // --- Get Bundle ID Step --- - let bundleId; + emitPipelineNotice(started, 'BUILD', 'App installed', 'success', { + code: 'build-run-step', + data: { step: 'install-app', status: 'succeeded' }, + }); + + // Extract bundle ID + let bundleId: string; try { log('info', `Extracting bundle ID from app: ${appBundlePath}`); - - // Try multiple methods to get bundle ID - first PlistBuddy, then plutil, then defaults - let bundleIdResult = null; - - // Method 1: PlistBuddy (most reliable) - try { - bundleIdResult = await executor( - [ - '/usr/libexec/PlistBuddy', - '-c', - 'Print :CFBundleIdentifier', - `${appBundlePath}/Info.plist`, - ], - 'Get Bundle ID with PlistBuddy', - ); - if (bundleIdResult.success) { - bundleId = bundleIdResult.output.trim(); - } - } catch { - // Continue to next method + bundleId = (await extractBundleIdFromAppPath(appBundlePath, executor)).trim(); + if (bundleId.length === 0) { + throw new Error('Empty bundle ID returned'); } - - // Method 2: plutil (workspace approach) - if (!bundleId) { - try { - bundleIdResult = await executor( - ['plutil', '-extract', 'CFBundleIdentifier', 'raw', `${appBundlePath}/Info.plist`], - 'Get Bundle ID with plutil', - ); - if (bundleIdResult?.success) { - bundleId = bundleIdResult.output?.trim(); - } - } catch { - // Continue to next method - } - } - - // Method 3: defaults (fallback) - if (!bundleId) { - try { - bundleIdResult = await executor( - ['defaults', 'read', `${appBundlePath}/Info`, 'CFBundleIdentifier'], - 'Get Bundle ID with defaults', - ); - if (bundleIdResult?.success) { - bundleId = bundleIdResult.output?.trim(); - } - } catch { - // All methods failed - } - } - - if (!bundleId) { - throw new Error('Could not extract bundle ID from Info.plist using any method'); - } - log('info', `Bundle ID for run: ${bundleId}`); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); - log('error', `Error getting bundle ID: ${errorMessage}`); - return createTextResponse( - `Build and install succeeded, but error getting bundle ID: ${errorMessage}`, - true, - ); + log('error', `Failed to extract bundle ID: ${errorMessage}`); + emitPipelineError(started, 'BUILD', `Failed to extract bundle ID: ${errorMessage}`); + return createPendingXcodebuildResponse(started, { + content: [], + isError: true, + }); } - // --- Launch App Step --- + // Launch app + emitPipelineNotice(started, 'BUILD', 'Launching app', 'info', { + code: 'build-run-step', + data: { step: 'launch-app', status: 'started', appPath: appBundlePath }, + }); + try { log('info', `Launching app with bundle ID: ${bundleId} on simulator: ${simulatorId}`); const launchResult = await executor( @@ -469,43 +463,55 @@ export async function build_run_simLogic( } } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); - log('error', `Error launching app: ${errorMessage}`); - return createTextResponse( - `Build and install succeeded, but error launching app on simulator: ${errorMessage}`, - true, - ); + log('error', `Failed to launch app: ${errorMessage}`); + emitPipelineError(started, 'BUILD', `Failed to launch app ${appBundlePath}: ${errorMessage}`); + return createPendingXcodebuildResponse(started, { + content: [], + isError: true, + }); } - // --- Success --- log('info', `${platformName} simulator build & run succeeded.`); - const target = params.simulatorId - ? `simulator UUID '${params.simulatorId}'` - : `simulator name '${params.simulatorName}'`; - const sourceType = params.projectPath ? 'project' : 'workspace'; - const sourcePath = params.projectPath ?? params.workspacePath; - - return { - content: [ - { - type: 'text', - text: `${platformName} simulator build and run succeeded for scheme ${params.scheme} from ${sourceType} ${sourcePath} targeting ${target}.\n\nThe app (${bundleId}) is now running in the ${platformName} Simulator.\nIf you don't see the simulator window, it may be hidden behind other windows. The Simulator app should be open.`, + return createPendingXcodebuildResponse( + started, + { + content: [], + isError: false, + nextStepParams: { + start_sim_log_cap: [ + { simulatorId, bundleId }, + { simulatorId, bundleId, captureConsole: true }, + ], + stop_app_sim: { simulatorId, bundleId }, + launch_app_logs_sim: { simulatorId, bundleId }, }, - ], - nextStepParams: { - start_sim_log_cap: [ - { simulatorId, bundleId }, - { simulatorId, bundleId, captureConsole: true }, + }, + { + tailEvents: [ + { + type: 'notice', + timestamp: new Date().toISOString(), + operation: 'BUILD', + level: 'success', + message: 'Build & Run complete', + code: 'build-run-result', + data: { + scheme: params.scheme, + platform: displayPlatform, + target: `${platformName} Simulator`, + appPath: appBundlePath, + bundleId, + launchState: 'requested', + }, + }, ], - stop_app_sim: { simulatorId, bundleId }, - launch_app_logs_sim: { simulatorId, bundleId }, }, - isError: false, - }; + ); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); log('error', `Error in Simulator build and run: ${errorMessage}`); - return createTextResponse(`Error in Simulator build and run: ${errorMessage}`, true); + return createTextResponse(`Error during simulator build and run: ${errorMessage}`, true); } } diff --git a/src/mcp/tools/simulator/build_sim.ts b/src/mcp/tools/simulator/build_sim.ts index ee2c46bc..f1474900 100644 --- a/src/mcp/tools/simulator/build_sim.ts +++ b/src/mcp/tools/simulator/build_sim.ts @@ -18,6 +18,9 @@ import { } from '../../../utils/typed-tool-factory.ts'; import { nullifyEmptyStrings } from '../../../utils/schema-helpers.ts'; import { inferPlatform } from '../../../utils/infer-platform.ts'; +import { startBuildPipeline } from '../../../utils/xcodebuild-pipeline.ts'; +import { createPendingXcodebuildResponse } from '../../../utils/xcodebuild-output.ts'; +import { formatToolPreflight } from '../../../utils/build-preflight.ts'; // Unified schema: XOR between projectPath and workspacePath, and XOR between simulatorId and simulatorName const baseOptions = { @@ -83,7 +86,6 @@ async function _handleSimulatorBuildLogic( const projectType = params.projectPath ? 'project' : 'workspace'; const filePath = params.projectPath ?? params.workspacePath; - // Log warning if useLatestOS is provided with simulatorId if (params.simulatorId && params.useLatestOS !== undefined) { log( 'warn', @@ -108,25 +110,70 @@ async function _handleSimulatorBuildLogic( log('info', `Starting ${logPrefix} for scheme ${params.scheme} from ${projectType}: ${filePath}`); log('info', `Inferred simulator platform: ${detectedPlatform} (source: ${inferred.source})`); - // Ensure configuration has a default value for SharedBuildParams compatibility const sharedBuildParams = { ...params, configuration: params.configuration ?? 'Debug', }; - // executeXcodeBuildCommand handles both simulatorId and simulatorName - return executeXcodeBuildCommand( + const platformOptions = { + platform: detectedPlatform, + simulatorName: params.simulatorName, + simulatorId: params.simulatorId, + useLatestOS: params.simulatorId ? false : params.useLatestOS, + logPrefix, + }; + + const preflightText = formatToolPreflight({ + operation: 'Build', + scheme: params.scheme, + workspacePath: params.workspacePath, + projectPath: params.projectPath, + configuration: sharedBuildParams.configuration, + platform: String(detectedPlatform), + simulatorName: params.simulatorName, + simulatorId: params.simulatorId, + }); + + const pipelineParams = { + scheme: params.scheme, + configuration: sharedBuildParams.configuration, + platform: String(detectedPlatform), + preflight: preflightText, + }; + + const started = startBuildPipeline({ + operation: 'BUILD', + toolName: 'build_sim', + params: pipelineParams, + message: preflightText, + }); + + const buildResult = await executeXcodeBuildCommand( sharedBuildParams, - { - platform: detectedPlatform, - simulatorName: params.simulatorName, - simulatorId: params.simulatorId, - useLatestOS: params.simulatorId ? false : params.useLatestOS, // Ignore useLatestOS with ID - logPrefix, - }, + platformOptions, params.preferXcodebuild ?? false, 'build', executor, + undefined, + started.pipeline, + ); + + return createPendingXcodebuildResponse( + started, + buildResult.isError + ? buildResult + : { + ...buildResult, + nextStepParams: { + get_sim_app_path: { + ...(params.simulatorId + ? { simulatorId: params.simulatorId } + : { simulatorName: params.simulatorName ?? '' }), + scheme: params.scheme, + platform: String(detectedPlatform), + }, + }, + }, ); } diff --git a/src/mcp/tools/simulator/get_sim_app_path.ts b/src/mcp/tools/simulator/get_sim_app_path.ts index 020ada0b..d0ddbf03 100644 --- a/src/mcp/tools/simulator/get_sim_app_path.ts +++ b/src/mcp/tools/simulator/get_sim_app_path.ts @@ -8,7 +8,6 @@ import * as z from 'zod'; import { log } from '../../../utils/logging/index.ts'; -import { createTextResponse } from '../../../utils/responses/index.ts'; import type { CommandExecutor } from '../../../utils/execution/index.ts'; import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; import type { ToolResponse } from '../../../types/common.ts'; @@ -19,6 +18,11 @@ import { getSessionAwareToolSchemaShape, } from '../../../utils/typed-tool-factory.ts'; import { nullifyEmptyStrings } from '../../../utils/schema-helpers.ts'; +import { formatToolPreflight } from '../../../utils/build-preflight.ts'; +import { + formatQueryError, + formatQueryFailureSummary, +} from '../../../utils/xcodebuild-error-utils.ts'; const SIMULATOR_PLATFORMS = [ XcodePlatform.iOSSimulator, @@ -86,88 +90,82 @@ export async function get_sim_app_pathLogic( params: GetSimulatorAppPathParams, executor: CommandExecutor, ): Promise { - // Set defaults - Zod validation already ensures required params are present - const projectPath = params.projectPath; - const workspacePath = params.workspacePath; - const scheme = params.scheme; - const platform = params.platform; - const simulatorId = params.simulatorId; - const simulatorName = params.simulatorName; const configuration = params.configuration ?? 'Debug'; const useLatestOS = params.useLatestOS ?? true; - // Log warning if useLatestOS is provided with simulatorId - if (simulatorId && params.useLatestOS !== undefined) { + if (params.simulatorId && params.useLatestOS !== undefined) { log( 'warn', `useLatestOS parameter is ignored when using simulatorId (UUID implies exact device/OS)`, ); } - log('info', `Getting app path for scheme ${scheme} on platform ${platform}`); + log('info', `Getting app path for scheme ${params.scheme} on platform ${params.platform}`); + + const preflight = formatToolPreflight({ + operation: 'Get App Path', + scheme: params.scheme, + workspacePath: params.workspacePath, + projectPath: params.projectPath, + configuration, + platform: params.platform, + simulatorName: params.simulatorName, + simulatorId: params.simulatorId, + }); try { - // Create the command array for xcodebuild with -showBuildSettings option const command = ['xcodebuild', '-showBuildSettings']; - // Add the workspace or project (XOR validation ensures exactly one is provided) - if (workspacePath) { - command.push('-workspace', workspacePath); - } else if (projectPath) { - command.push('-project', projectPath); + if (params.workspacePath) { + command.push('-workspace', params.workspacePath); + } else if (params.projectPath) { + command.push('-project', params.projectPath); } - // Add the scheme and configuration - command.push('-scheme', scheme); + command.push('-scheme', params.scheme); command.push('-configuration', configuration); - // Handle destination for simulator platforms - let destinationString = ''; - - if (simulatorId) { - destinationString = constructDestinationString(platform, undefined, simulatorId); - } else if (simulatorName) { - destinationString = constructDestinationString( - platform, - simulatorName, - undefined, - useLatestOS, - ); - } else { - return createTextResponse( - `For ${platform} platform, either simulatorId or simulatorName must be provided`, - true, - ); - } + const destinationString = params.simulatorId + ? constructDestinationString(params.platform, undefined, params.simulatorId) + : constructDestinationString(params.platform, params.simulatorName, undefined, useLatestOS); command.push('-destination', destinationString); - // Execute the command directly - const result = await executor(command, 'Get App Path', false, undefined); + const result = await executor(command, 'Get App Path', false); if (!result.success) { - return createTextResponse(`Failed to get app path: ${result.error}`, true); + const rawOutput = [result.error, result.output].filter(Boolean).join('\n'); + const errorBlock = formatQueryError(rawOutput); + const text = [preflight, errorBlock, '', formatQueryFailureSummary()].join('\n'); + return { content: [{ type: 'text', text }], isError: true }; } if (!result.output) { - return createTextResponse('Failed to extract build settings output from the result.', true); + const errorBlock = formatQueryError( + 'Failed to extract build settings output from the result.', + ); + const text = [preflight, errorBlock, '', formatQueryFailureSummary()].join('\n'); + return { content: [{ type: 'text', text }], isError: true }; } - const buildSettingsOutput = result.output; - const builtProductsDirMatch = buildSettingsOutput.match(/^\s*BUILT_PRODUCTS_DIR\s*=\s*(.+)$/m); - const fullProductNameMatch = buildSettingsOutput.match(/^\s*FULL_PRODUCT_NAME\s*=\s*(.+)$/m); + const builtProductsDirMatch = result.output.match(/^\s*BUILT_PRODUCTS_DIR\s*=\s*(.+)$/m); + const fullProductNameMatch = result.output.match(/^\s*FULL_PRODUCT_NAME\s*=\s*(.+)$/m); if (!builtProductsDirMatch || !fullProductNameMatch) { - return createTextResponse( + const errorBlock = formatQueryError( 'Failed to extract app path from build settings. Make sure the app has been built first.', - true, ); + const text = [preflight, errorBlock, '', formatQueryFailureSummary()].join('\n'); + return { content: [{ type: 'text', text }], isError: true }; } const builtProductsDir = builtProductsDirMatch[1].trim(); const fullProductName = fullProductNameMatch[1].trim(); const appPath = `${builtProductsDir}/${fullProductName}`; + const resultLine = ` \u{2514} App Path: ${appPath}`; + const text = preflight + '\n' + resultLine; + const nextStepParams: Record> = { get_app_bundle_id: { appPath }, boot_sim: { simulatorId: 'SIMULATOR_UUID' }, @@ -176,19 +174,16 @@ export async function get_sim_app_pathLogic( }; return { - content: [ - { - type: 'text', - text: `โœ… App path retrieved successfully: ${appPath}`, - }, - ], + content: [{ type: 'text', text }], nextStepParams, isError: false, }; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); log('error', `Error retrieving app path: ${errorMessage}`); - return createTextResponse(`Error retrieving app path: ${errorMessage}`, true); + const errorBlock = formatQueryError(errorMessage); + const text = [preflight, errorBlock, '', formatQueryFailureSummary()].join('\n'); + return { content: [{ type: 'text', text }], isError: true }; } } diff --git a/src/mcp/tools/simulator/test_sim.ts b/src/mcp/tools/simulator/test_sim.ts index 4857187c..5e717b2f 100644 --- a/src/mcp/tools/simulator/test_sim.ts +++ b/src/mcp/tools/simulator/test_sim.ts @@ -10,16 +10,20 @@ import * as z from 'zod'; import { handleTestLogic } from '../../../utils/test/index.ts'; import { log } from '../../../utils/logging/index.ts'; import type { ToolResponse } from '../../../types/common.ts'; -import type { CommandExecutor } from '../../../utils/execution/index.ts'; -import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; +import type { CommandExecutor, FileSystemExecutor } from '../../../utils/execution/index.ts'; +import { + getDefaultCommandExecutor, + getDefaultFileSystemExecutor, +} from '../../../utils/execution/index.ts'; import { nullifyEmptyStrings } from '../../../utils/schema-helpers.ts'; import { createSessionAwareTool, getSessionAwareToolSchemaShape, } from '../../../utils/typed-tool-factory.ts'; import { inferPlatform } from '../../../utils/infer-platform.ts'; +import { resolveTestPreflight } from '../../../utils/test-preflight.ts'; +import { resolveSimulatorIdOrName } from '../../../utils/simulator-resolver.ts'; -// Define base schema object with all fields const baseSchemaObject = z.object({ projectPath: z .string() @@ -62,7 +66,6 @@ const baseSchemaObject = z.object({ .describe('Show detailed test progress output (MCP defaults to true, CLI defaults to false)'), }); -// Apply XOR validation: exactly one of projectPath OR workspacePath, and exactly one of simulatorId OR simulatorName required const testSimulatorSchema = z.preprocess( nullifyEmptyStrings, baseSchemaObject @@ -80,18 +83,17 @@ const testSimulatorSchema = z.preprocess( }), ); -// Use z.infer for type safety type TestSimulatorParams = z.infer; export async function test_simLogic( params: TestSimulatorParams, executor: CommandExecutor, + fileSystemExecutor: FileSystemExecutor = getDefaultFileSystemExecutor(), ): Promise { - // Log warning if useLatestOS is provided with simulatorId if (params.simulatorId && params.useLatestOS !== undefined) { log( 'warn', - `useLatestOS parameter is ignored when using simulatorId (UUID implies exact device/OS)`, + 'useLatestOS parameter is ignored when using simulatorId (UUID implies exact device/OS)', ); } @@ -110,23 +112,52 @@ export async function test_simLogic( `Inferred simulator platform for tests: ${inferred.platform} (source: ${inferred.source})`, ); + const simulatorResolution = await resolveSimulatorIdOrName( + executor, + params.simulatorId, + params.simulatorName, + ); + if (!simulatorResolution.success) { + return { + content: [{ type: 'text', text: simulatorResolution.error }], + isError: true, + }; + } + + const destinationName = params.simulatorName ?? simulatorResolution.simulatorName; + const preflight = await resolveTestPreflight( + { + projectPath: params.projectPath, + workspacePath: params.workspacePath, + scheme: params.scheme, + configuration: params.configuration ?? 'Debug', + extraArgs: params.extraArgs, + destinationName, + }, + fileSystemExecutor, + ); + return handleTestLogic( { projectPath: params.projectPath, workspacePath: params.workspacePath, scheme: params.scheme, - simulatorId: params.simulatorId, + simulatorId: simulatorResolution.simulatorId, simulatorName: params.simulatorName, configuration: params.configuration ?? 'Debug', derivedDataPath: params.derivedDataPath, extraArgs: params.extraArgs, - useLatestOS: params.simulatorId ? false : (params.useLatestOS ?? false), + useLatestOS: false, preferXcodebuild: params.preferXcodebuild ?? false, platform: inferred.platform, testRunnerEnv: params.testRunnerEnv, progress: params.progress, }, executor, + { + preflight: preflight ?? undefined, + toolName: 'test_sim', + }, ); } @@ -149,7 +180,8 @@ export const schema = getSessionAwareToolSchemaShape({ export const handler = createSessionAwareTool({ internalSchema: testSimulatorSchema as unknown as z.ZodType, - logicFunction: test_simLogic, + logicFunction: (params, executor) => + test_simLogic(params, executor, getDefaultFileSystemExecutor()), getExecutor: getDefaultCommandExecutor, requirements: [ { allOf: ['scheme'], message: 'scheme is required' }, diff --git a/src/mcp/tools/utilities/clean.ts b/src/mcp/tools/utilities/clean.ts index 1d683d1e..87289a59 100644 --- a/src/mcp/tools/utilities/clean.ts +++ b/src/mcp/tools/utilities/clean.ts @@ -17,6 +17,9 @@ import type { ToolResponse, SharedBuildParams } from '../../../types/common.ts'; import { XcodePlatform } from '../../../types/common.ts'; import { createErrorResponse } from '../../../utils/responses/index.ts'; import { nullifyEmptyStrings } from '../../../utils/schema-helpers.ts'; +import { startBuildPipeline } from '../../../utils/xcodebuild-pipeline.ts'; +import { createPendingXcodebuildResponse } from '../../../utils/xcodebuild-output.ts'; +import { formatToolPreflight } from '../../../utils/build-preflight.ts'; // Unified schema: XOR between projectPath and workspacePath, sharing common options const baseOptions = { @@ -128,7 +131,30 @@ export async function cleanLogic( const cleanPlatform = cleanPlatformMap[platformEnum] ?? platformEnum; - return executeXcodeBuildCommand( + const preflightText = formatToolPreflight({ + operation: 'Clean', + scheme: typedParams.scheme, + workspacePath: params.workspacePath as string | undefined, + projectPath: params.projectPath as string | undefined, + configuration: typedParams.configuration, + platform: String(cleanPlatform), + }); + + const pipelineParams = { + scheme: typedParams.scheme, + configuration: typedParams.configuration, + platform: String(cleanPlatform), + preflight: preflightText, + }; + + const started = startBuildPipeline({ + operation: 'BUILD', + toolName: 'clean', + params: pipelineParams, + message: preflightText, + }); + + const buildResult = await executeXcodeBuildCommand( typedParams, { platform: cleanPlatform, @@ -137,7 +163,11 @@ export async function cleanLogic( false, 'clean', executor, + undefined, + started.pipeline, ); + + return createPendingXcodebuildResponse(started, buildResult); } const publicSchemaObject = baseSchemaObject.omit({ diff --git a/src/runtime/__tests__/tool-invoker.test.ts b/src/runtime/__tests__/tool-invoker.test.ts index cb3da448..265aa14d 100644 --- a/src/runtime/__tests__/tool-invoker.test.ts +++ b/src/runtime/__tests__/tool-invoker.test.ts @@ -537,6 +537,76 @@ describe('DefaultToolInvoker next steps post-processing', () => { ]); }); + it('suppresses manifest next steps for structured xcodebuild failures', async () => { + const directHandler = vi.fn().mockResolvedValue({ + isError: true, + content: [], + _meta: { + pendingXcodebuild: { + kind: 'pending-xcodebuild', + started: { + startedAt: Date.now(), + pipeline: { + finalize: vi.fn().mockReturnValue({ + events: [ + { + type: 'summary', + timestamp: '2026-03-20T12:00:00.000Z', + operation: 'BUILD', + status: 'FAILED', + }, + ], + mcpContent: [{ type: 'text', text: 'โŒ Build failed.' }], + state: { + errors: [{ type: 'error' }], + testFailures: [], + }, + }), + }, + }, + emitSummary: true, + extras: {}, + fallbackContent: [], + tailEvents: [], + errorFallbackPolicy: 'if-no-structured-diagnostics', + }, + }, + } satisfies ToolResponse); + + const catalog = createToolCatalog([ + makeTool({ + id: 'build_run_macos', + cliName: 'build-and-run', + mcpName: 'build_run_macos', + workflow: 'macos', + stateful: false, + nextStepTemplates: [{ label: 'Get built macOS app path', toolId: 'get_mac_app_path' }], + handler: directHandler, + }), + makeTool({ + id: 'get_mac_app_path', + cliName: 'get-app-path', + mcpName: 'get_mac_app_path', + workflow: 'macos', + stateful: false, + handler: vi.fn().mockResolvedValue(textResponse('path')), + }), + ]); + + const invoker = new DefaultToolInvoker(catalog); + const response = await invoker.invoke('build-and-run', {}, { runtime: 'cli' }); + + expect(response.nextSteps).toBeUndefined(); + expect( + ((response._meta?.events ?? []) as Array<{ type: string }>).some( + (event) => event.type === 'next-steps', + ), + ).toBe(false); + expect( + response.content.map((item) => (item.type === 'text' ? item.text : '')).join('\n'), + ).not.toContain('Next steps:'); + }); + it('always uses manifest templates when they exist', async () => { const directHandler = vi.fn().mockResolvedValue({ content: [{ type: 'text', text: 'ok' }], diff --git a/src/runtime/tool-invoker.ts b/src/runtime/tool-invoker.ts index 3c34d5ac..95a0ffbe 100644 --- a/src/runtime/tool-invoker.ts +++ b/src/runtime/tool-invoker.ts @@ -11,6 +11,12 @@ import { type SentryToolRuntime, type SentryToolTransport, } from '../utils/sentry.ts'; +import { + appendStructuredEvents, + createNextStepsEvent, + finalizePendingXcodebuildResponse, + isPendingXcodebuildResponse, +} from '../utils/xcodebuild-output.ts'; type BuiltTemplateNextStep = { step: NextStep; @@ -32,6 +38,7 @@ function buildTemplateNextSteps( step: { label: template.label, priority: template.priority, + when: template.when, }, }); continue; @@ -48,6 +55,7 @@ function buildTemplateNextSteps( label: template.label, params: template.params ?? {}, priority: template.priority, + when: template.when, }, templateToolId: template.toolId, }); @@ -106,11 +114,7 @@ function mergeTemplateAndResponseNextSteps( }); } -function normalizeNextSteps( - response: ToolResponse, - catalog: ToolCatalog, - runtime: InvokeOptions['runtime'], -): ToolResponse { +function normalizeNextSteps(response: ToolResponse, catalog: ToolCatalog): ToolResponse { if (!response.nextSteps || response.nextSteps.length === 0) { return response; } @@ -127,21 +131,29 @@ function normalizeNextSteps( return step; } - return runtime === 'cli' - ? { - ...step, - tool: target.mcpName, - workflow: target.workflow, - cliTool: target.cliName, - } - : { - ...step, - tool: target.mcpName, - }; + return { + ...step, + tool: target.mcpName, + workflow: target.workflow, + cliTool: target.cliName, + }; }), }; } +function appendNextStepsToStructuredEvents(response: ToolResponse): ToolResponse { + if (!response.nextSteps || response.nextSteps.length === 0) { + return response; + } + + const nextStepsEvent = createNextStepsEvent(response.nextSteps); + if (!nextStepsEvent) { + return response; + } + + return appendStructuredEvents(response, [nextStepsEvent]); +} + export function postProcessToolResponse(params: { tool: ToolDefinition; response: ToolResponse; @@ -149,19 +161,45 @@ export function postProcessToolResponse(params: { runtime: InvokeOptions['runtime']; applyTemplateNextSteps?: boolean; }): ToolResponse { - const { tool, response, catalog, runtime, applyTemplateNextSteps = true } = params; - - const templateSteps = buildTemplateNextSteps(tool, catalog); + const { tool, response, catalog, applyTemplateNextSteps = true } = params; + + const isError = response.isError === true; + const suppressNextStepsForStructuredFailure = + isError && (isPendingXcodebuildResponse(response) || Array.isArray(response._meta?.events)); + const responseForNextSteps = suppressNextStepsForStructuredFailure + ? { + ...response, + nextSteps: undefined, + nextStepParams: undefined, + } + : response; + + const allTemplateSteps = buildTemplateNextSteps(tool, catalog); + const templateSteps = allTemplateSteps.filter((t) => { + const when = t.step.when ?? 'always'; + if (when === 'always') return true; + if (when === 'success') return !isError; + if (when === 'failure') return isError; + return true; + }); const withTemplates = - applyTemplateNextSteps && templateSteps.length > 0 + !suppressNextStepsForStructuredFailure && applyTemplateNextSteps && templateSteps.length > 0 ? { - ...response, - nextSteps: mergeTemplateAndResponseNextSteps(templateSteps, response.nextStepParams), + ...responseForNextSteps, + nextSteps: mergeTemplateAndResponseNextSteps( + templateSteps, + responseForNextSteps.nextStepParams, + ), } - : response; - - const result = normalizeNextSteps(withTemplates, catalog, runtime); + : responseForNextSteps; + + const normalized = normalizeNextSteps(withTemplates, catalog); + const result = isPendingXcodebuildResponse(normalized) + ? finalizePendingXcodebuildResponse(normalized, { + nextSteps: normalized.nextSteps, + }) + : appendNextStepsToStructuredEvents(normalized); delete result.nextStepParams; return result; } @@ -233,17 +271,6 @@ export class DefaultToolInvoker implements ToolInvoker { return this.executeTool(tool, args, opts); } - private buildPostProcessParams( - tool: ToolDefinition, - runtime: InvokeOptions['runtime'], - ): { - tool: ToolDefinition; - catalog: ToolCatalog; - runtime: InvokeOptions['runtime']; - } { - return { tool, catalog: this.catalog, runtime }; - } - private async invokeViaDaemon( opts: InvokeOptions, invoke: (client: DaemonClient) => Promise, @@ -348,7 +375,7 @@ export class DefaultToolInvoker implements ToolInvoker { }); }; - const postProcessParams = this.buildPostProcessParams(tool, opts.runtime); + const postProcessParams = { tool, catalog: this.catalog, runtime: opts.runtime }; const xcodeIdeRemoteToolName = tool.xcodeIdeRemoteToolName; const isDynamicXcodeIdeTool = tool.workflow === 'xcode-ide' && typeof xcodeIdeRemoteToolName === 'string'; diff --git a/src/runtime/types.ts b/src/runtime/types.ts index ef9a505d..a78493ac 100644 --- a/src/runtime/types.ts +++ b/src/runtime/types.ts @@ -7,6 +7,7 @@ export interface NextStepTemplate { toolId?: string; params?: Record; priority?: number; + when?: 'always' | 'success' | 'failure'; } export type RuntimeKind = 'cli' | 'daemon' | 'mcp'; diff --git a/src/types/common.ts b/src/types/common.ts index 44851f18..9f83901f 100644 --- a/src/types/common.ts +++ b/src/types/common.ts @@ -28,6 +28,8 @@ export interface NextStep { params?: Record; /** Optional ordering hint for merged steps */ priority?: number; + /** When to show this step: 'always' (default), 'success', or 'failure' */ + when?: 'always' | 'success' | 'failure'; } export type NextStepParams = Record; @@ -139,5 +141,5 @@ export interface PlatformBuildOptions { useLatestOS?: boolean; arch?: string; logPrefix: string; - showTestProgress?: boolean; + packageCachePath?: string; } diff --git a/src/types/xcodebuild-events.ts b/src/types/xcodebuild-events.ts new file mode 100644 index 00000000..34fd060f --- /dev/null +++ b/src/types/xcodebuild-events.ts @@ -0,0 +1,159 @@ +export type XcodebuildOperation = 'BUILD' | 'TEST'; + +export type XcodebuildStage = + | 'RESOLVING_PACKAGES' + | 'COMPILING' + | 'LINKING' + | 'PREPARING_TESTS' + | 'RUN_TESTS' + | 'ARCHIVING' + | 'COMPLETED'; + +export const STAGE_RANK: Record = { + RESOLVING_PACKAGES: 0, + COMPILING: 1, + LINKING: 2, + PREPARING_TESTS: 3, + RUN_TESTS: 4, + ARCHIVING: 5, + COMPLETED: 6, +}; + +interface BaseEvent { + timestamp: string; +} + +export interface StartEvent extends BaseEvent { + type: 'start'; + operation: XcodebuildOperation; + toolName: string; + params: Record; + message: string; +} + +export interface StatusEvent extends BaseEvent { + type: 'status'; + operation: XcodebuildOperation; + stage: XcodebuildStage; + message: string; +} + +export type NoticeLevel = 'info' | 'success' | 'warning'; + +export type BuildRunStepName = + | 'resolve-app-path' + | 'resolve-simulator' + | 'boot-simulator' + | 'install-app' + | 'extract-bundle-id' + | 'launch-app'; + +export type BuildRunStepStatus = 'started' | 'succeeded'; + +export interface BuildRunStepNoticeData { + step: BuildRunStepName; + status: BuildRunStepStatus; + appPath?: string; +} + +export interface BuildRunResultNoticeData { + scheme: string; + platform: string; + target: string; + appPath: string; + launchState: 'requested' | 'running'; + bundleId?: string; + appId?: string; + processId?: number; +} + +export type NoticeCode = 'build-run-step' | 'build-run-result'; + +export interface NoticeEvent extends BaseEvent { + type: 'notice'; + operation: XcodebuildOperation; + level: NoticeLevel; + message: string; + code?: NoticeCode; + data?: + | Record + | BuildRunStepNoticeData + | BuildRunResultNoticeData; +} + +export interface WarningEvent extends BaseEvent { + type: 'warning'; + operation: XcodebuildOperation; + message: string; + location?: string; + rawLine: string; +} + +export interface ErrorEvent extends BaseEvent { + type: 'error'; + operation: XcodebuildOperation; + message: string; + location?: string; + rawLine: string; +} + +export interface TestDiscoveryEvent extends BaseEvent { + type: 'test-discovery'; + operation: 'TEST'; + total: number; + tests: string[]; + truncated: boolean; +} + +export interface TestProgressEvent extends BaseEvent { + type: 'test-progress'; + operation: 'TEST'; + completed: number; + failed: number; + skipped: number; +} + +export interface TestFailureEvent extends BaseEvent { + type: 'test-failure'; + operation: 'TEST'; + target?: string; + suite?: string; + test?: string; + message: string; + location?: string; + durationMs?: number; +} + +export interface SummaryEvent extends BaseEvent { + type: 'summary'; + operation: XcodebuildOperation; + status: 'SUCCEEDED' | 'FAILED'; + totalTests?: number; + passedTests?: number; + failedTests?: number; + skippedTests?: number; + durationMs?: number; +} + +export interface NextStepsEvent extends BaseEvent { + type: 'next-steps'; + steps: Array<{ + label?: string; + tool?: string; + workflow?: string; + cliTool?: string; + params?: Record; + }>; +} + +export type XcodebuildEvent = + | StartEvent + | StatusEvent + | NoticeEvent + | WarningEvent + | ErrorEvent + | TestDiscoveryEvent + | TestProgressEvent + | TestFailureEvent + | SummaryEvent + | NextStepsEvent; diff --git a/src/utils/__tests__/build-preflight.test.ts b/src/utils/__tests__/build-preflight.test.ts new file mode 100644 index 00000000..82faa7e5 --- /dev/null +++ b/src/utils/__tests__/build-preflight.test.ts @@ -0,0 +1,228 @@ +import { describe, it, expect } from 'vitest'; +import { formatToolPreflight } from '../build-preflight.ts'; + +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', + '', + ].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', + '', + ].join('\n'), + ); + }); + + it('formats build & run with device', () => { + 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', + '', + ].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', + '', + ].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', + ' 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', + '', + ].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', + '', + ].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', + '', + ].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.test.ts b/src/utils/__tests__/build-utils.test.ts index b08a8134..ce77e0fa 100644 --- a/src/utils/__tests__/build-utils.test.ts +++ b/src/utils/__tests__/build-utils.test.ts @@ -2,13 +2,17 @@ * Tests for build-utils Sentry classification logic */ -import { describe, it, expect, vi } 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'; describe('build-utils Sentry Classification', () => { + afterEach(() => { + vi.clearAllMocks(); + }); + const mockPlatformOptions = { platform: XcodePlatform.macOS, logPrefix: 'Test Build', @@ -262,155 +266,47 @@ describe('build-utils Sentry Classification', () => { }); }); - describe('Test Progress Output', () => { - it('should include per-test progress lines when showTestProgress is enabled', async () => { + 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 Case '-[Suite testA]' passed (0.001 seconds)\n" + - "Test Suite 'Suite' failed at 2026-01-01 00:00:00.000\n" + - 'Executed 2 tests, with 1 failures (0 unexpected) in 0.123 (0.124) seconds', + output: 'TEST SUCCEEDED', exitCode: 0, + onExecute: (command) => { + capturedCommand = command; + }, }); - const result = await executeXcodeBuildCommand( - mockParams, + await executeXcodeBuildCommand( { - ...mockPlatformOptions, - showTestProgress: true, + scheme: 'TestScheme', + configuration: 'Debug', + projectPath: '/path/to/project.xcodeproj', + extraArgs: ['-only-testing:AppTests'], }, - false, - 'test', - mockExecutor, - ); - - const text = result.content.map((item) => item.text).join('\n'); - expect(text).toContain("๐Ÿงช Test Case '-[Suite testA]' passed (0.001 seconds)"); - expect(text).toContain("๐Ÿงช Test Suite 'Suite' failed at 2026-01-01 00:00:00.000"); - expect(text).toContain( - '๐Ÿงช Executed 2 tests, with 1 failures (0 unexpected) in 0.123 (0.124) seconds', - ); - }); - - it('should omit per-test progress lines when showTestProgress is disabled', async () => { - const mockExecutor = createMockExecutor({ - success: true, - output: "Test Case '-[Suite testA]' passed (0.001 seconds)", - exitCode: 0, - }); - - const result = await executeXcodeBuildCommand( - mockParams, { - ...mockPlatformOptions, - showTestProgress: false, + platform: XcodePlatform.iOSSimulator, + simulatorId: 'SIM-UUID', + simulatorName: 'iPhone 17 Pro', + logPrefix: 'Simulator Test', }, false, 'test', mockExecutor, ); - const text = result.content.map((item) => item.text).join('\n'); - expect(text).not.toContain('๐Ÿงช Test Case'); - }); - - it('should stream test progress immediately in CLI text output mode', async () => { - const originalRuntime = process.env.XCODEBUILDMCP_RUNTIME; - const originalOutputFormat = process.env.XCODEBUILDMCP_CLI_OUTPUT_FORMAT; - process.env.XCODEBUILDMCP_RUNTIME = 'cli'; - process.env.XCODEBUILDMCP_CLI_OUTPUT_FORMAT = 'text'; - - const stdoutWrite = vi.spyOn(process.stdout, 'write').mockImplementation(() => true); - - const mockExecutor = createMockExecutor({ - success: true, - output: "Test Case '-[Suite streamed]' passed (0.010 seconds)", - exitCode: 0, - onExecute: (_command, _logPrefix, _useShell, opts) => { - opts?.onStdout?.("Test Case '-[Suite streamed]' passed (0.010 seconds)\\n"); - }, - }); - - try { - const result = await executeXcodeBuildCommand( - mockParams, - { - ...mockPlatformOptions, - showTestProgress: true, - }, - false, - 'test', - mockExecutor, - ); - - const streamedOutput = stdoutWrite.mock.calls.flat().join(''); - expect(streamedOutput).toContain('๐Ÿงช Test configuration: scheme=TestScheme'); - expect(streamedOutput).toContain("๐Ÿงช Test Case '-[Suite streamed]' passed (0.010 seconds)"); - - const responseText = result.content.map((item) => item.text).join('\n'); - expect(responseText).not.toContain('๐Ÿงช Test Case'); - } finally { - stdoutWrite.mockRestore(); - if (originalRuntime === undefined) { - delete process.env.XCODEBUILDMCP_RUNTIME; - } else { - process.env.XCODEBUILDMCP_RUNTIME = originalRuntime; - } - if (originalOutputFormat === undefined) { - delete process.env.XCODEBUILDMCP_CLI_OUTPUT_FORMAT; - } else { - process.env.XCODEBUILDMCP_CLI_OUTPUT_FORMAT = originalOutputFormat; - } - } - }); - - it('should not stream progress in CLI JSON output mode', async () => { - const originalRuntime = process.env.XCODEBUILDMCP_RUNTIME; - const originalOutputFormat = process.env.XCODEBUILDMCP_CLI_OUTPUT_FORMAT; - process.env.XCODEBUILDMCP_RUNTIME = 'cli'; - process.env.XCODEBUILDMCP_CLI_OUTPUT_FORMAT = 'json'; - - const stdoutWrite = vi.spyOn(process.stdout, 'write').mockImplementation(() => true); - - const mockExecutor = createMockExecutor({ - success: true, - output: "Test Case '-[Suite json]' passed (0.020 seconds)", - exitCode: 0, - onExecute: (_command, _logPrefix, _useShell, opts) => { - opts?.onStdout?.("Test Case '-[Suite json]' passed (0.020 seconds)\\n"); - }, - }); - - try { - const result = await executeXcodeBuildCommand( - mockParams, - { - ...mockPlatformOptions, - showTestProgress: true, - }, - false, - 'test', - mockExecutor, - ); - - const streamedOutput = stdoutWrite.mock.calls.flat().join(''); - expect(streamedOutput).not.toContain("๐Ÿงช Test Case '-[Suite json]' passed (0.020 seconds)"); - - const responseText = result.content.map((item) => item.text).join('\n'); - expect(responseText).toContain("๐Ÿงช Test Case '-[Suite json]' passed (0.020 seconds)"); - } finally { - stdoutWrite.mockRestore(); - if (originalRuntime === undefined) { - delete process.env.XCODEBUILDMCP_RUNTIME; - } else { - process.env.XCODEBUILDMCP_RUNTIME = originalRuntime; - } - if (originalOutputFormat === undefined) { - delete process.env.XCODEBUILDMCP_CLI_OUTPUT_FORMAT; - } else { - process.env.XCODEBUILDMCP_CLI_OUTPUT_FORMAT = originalOutputFormat; - } - } + 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'); }); }); diff --git a/src/utils/__tests__/test-common.test.ts b/src/utils/__tests__/test-common.test.ts index ed26cc63..68fea394 100644 --- a/src/utils/__tests__/test-common.test.ts +++ b/src/utils/__tests__/test-common.test.ts @@ -1,10 +1,30 @@ -import { afterEach, describe, expect, it } from 'vitest'; -import { resolveTestProgressEnabled } from '../test-common.ts'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { createMockCommandResponse } from '../../test-utils/mock-executors.ts'; +import { handleTestLogic, resolveTestProgressEnabled } from '../test-common.ts'; +import { XcodePlatform } from '../xcode.ts'; +import { + isPendingXcodebuildResponse, + finalizePendingXcodebuildResponse, +} from '../xcodebuild-output.ts'; +import type { ToolResponse } from '../../types/common.ts'; + +function expectPendingTestResponse(result: ToolResponse, isError: boolean): void { + expect(result.isError).toBe(isError); + expect(result.content).toEqual([]); + expect(isPendingXcodebuildResponse(result)).toBe(true); +} + +function finalizeAndGetText(result: ToolResponse): string { + const finalized = finalizePendingXcodebuildResponse(result); + return finalized.content.map((item) => item.text).join('\n'); +} describe('resolveTestProgressEnabled', () => { const originalRuntime = process.env.XCODEBUILDMCP_RUNTIME; afterEach(() => { + vi.restoreAllMocks(); + if (originalRuntime === undefined) { delete process.env.XCODEBUILDMCP_RUNTIME; } else { @@ -37,3 +57,249 @@ describe('resolveTestProgressEnabled', () => { 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 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/testFailure: XCTAssertEqual failed'); + expect(renderedText).toContain('XCTAssertEqual failed'); + expect(renderedText).toContain('Test failed.'); + expect(renderedText).toContain('Total: 1'); + expect(renderedText).toContain('Failed: 1'); + + 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 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 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__/xcodebuild-event-parser.test.ts b/src/utils/__tests__/xcodebuild-event-parser.test.ts new file mode 100644 index 00000000..7f1bddce --- /dev/null +++ b/src/utils/__tests__/xcodebuild-event-parser.test.ts @@ -0,0 +1,247 @@ +import { describe, expect, it } from 'vitest'; +import { createXcodebuildEventParser } from '../xcodebuild-event-parser.ts'; +import type { XcodebuildEvent } from '../../types/xcodebuild-events.ts'; + +function collectEvents( + operation: 'BUILD' | 'TEST', + lines: { source: 'stdout' | 'stderr'; text: string }[], +): XcodebuildEvent[] { + const events: XcodebuildEvent[] = []; + const parser = createXcodebuildEventParser({ + operation, + onEvent: (event) => events.push(event), + }); + + for (const { source, text } of lines) { + if (source === 'stdout') { + parser.onStdout(text); + } else { + parser.onStderr(text); + } + } + + parser.flush(); + return events; +} + +describe('xcodebuild-event-parser', () => { + it('emits status events for package resolution', () => { + const events = collectEvents('TEST', [{ source: 'stdout', text: 'Resolve Package Graph\n' }]); + + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ + type: 'status', + operation: 'TEST', + stage: 'RESOLVING_PACKAGES', + message: 'Resolving packages', + }); + }); + + it('emits status events for compile patterns', () => { + const events = collectEvents('BUILD', [ + { source: 'stdout', text: 'CompileSwift normal arm64 /tmp/App.swift\n' }, + ]); + + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ + type: 'status', + operation: 'BUILD', + stage: 'COMPILING', + message: 'Compiling', + }); + }); + + it('emits status events for linking', () => { + const events = collectEvents('BUILD', [ + { source: 'stdout', text: 'Ld /Build/Products/Debug/MyApp.app/MyApp normal arm64\n' }, + ]); + + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ + type: 'status', + operation: 'BUILD', + stage: 'LINKING', + message: 'Linking', + }); + }); + + it('emits status events for test start', () => { + const events = collectEvents('TEST', [{ source: 'stdout', text: 'Testing started\n' }]); + + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ + type: 'status', + stage: 'RUN_TESTS', + }); + }); + + it('emits test-progress events with cumulative counts', () => { + const events = collectEvents('TEST', [ + { source: 'stdout', text: "Test Case '-[Suite testA]' passed (0.001 seconds)\n" }, + { source: 'stdout', text: "Test Case '-[Suite testB]' failed (0.002 seconds)\n" }, + { source: 'stdout', text: "Test Case '-[Suite testC]' passed (0.003 seconds)\n" }, + ]); + + const progressEvents = events.filter((e) => e.type === 'test-progress'); + expect(progressEvents).toHaveLength(3); + expect(progressEvents[0]).toMatchObject({ completed: 1, failed: 0, skipped: 0 }); + expect(progressEvents[1]).toMatchObject({ completed: 2, failed: 1, skipped: 0 }); + expect(progressEvents[2]).toMatchObject({ completed: 3, failed: 1, skipped: 0 }); + }); + + it('emits test-progress from totals line', () => { + const events = collectEvents('TEST', [ + { + source: 'stdout', + text: 'Executed 5 tests, with 2 failures (0 unexpected) in 1.234 (1.235) seconds\n', + }, + ]); + + const progressEvents = events.filter((e) => e.type === 'test-progress'); + expect(progressEvents).toHaveLength(1); + expect(progressEvents[0]).toMatchObject({ completed: 5, failed: 2 }); + }); + + it('emits test-failure events from diagnostics', () => { + const events = collectEvents('TEST', [ + { + source: 'stderr', + text: '/tmp/Test.swift:52: error: -[Suite testB] : XCTAssertEqual failed: ("0") is not equal to ("1")\n', + }, + ]); + + const failures = events.filter((e) => e.type === 'test-failure'); + expect(failures).toHaveLength(1); + expect(failures[0]).toMatchObject({ + type: 'test-failure', + suite: 'Suite', + test: 'testB', + location: '/tmp/Test.swift:52', + message: 'XCTAssertEqual failed: ("0") is not equal to ("1")', + }); + }); + + it('emits error events for build errors', () => { + const events = collectEvents('BUILD', [ + { + source: 'stdout', + text: "/tmp/App.swift:8:17: error: cannot convert value of type 'String' to specified type 'Int'\n", + }, + ]); + + const errors = events.filter((e) => e.type === 'error'); + expect(errors).toHaveLength(1); + expect(errors[0]).toMatchObject({ + type: 'error', + location: '/tmp/App.swift:8', + message: "cannot convert value of type 'String' to specified type 'Int'", + }); + }); + + it('emits error events for non-location build errors', () => { + const events = collectEvents('BUILD', [ + { source: 'stdout', text: 'error: emit-module command failed with exit code 1\n' }, + ]); + + const errors = events.filter((e) => e.type === 'error'); + expect(errors).toHaveLength(1); + expect(errors[0]).toMatchObject({ + type: 'error', + message: 'emit-module command failed with exit code 1', + }); + }); + + it('accumulates indented continuation lines into the preceding error', () => { + const events = collectEvents('BUILD', [ + { + source: 'stderr', + text: 'xcodebuild: error: Unable to find a device matching the provided destination specifier:\n', + }, + { source: 'stderr', text: '\t\t{ platform:iOS Simulator, name:iPhone 22, OS:latest }\n' }, + { source: 'stderr', text: '\n' }, + ]); + + const errors = events.filter((e) => e.type === 'error'); + expect(errors).toHaveLength(1); + expect(errors[0]).toMatchObject({ + type: 'error', + message: + 'Unable to find a device matching the provided destination specifier:\n{ platform:iOS Simulator, name:iPhone 22, OS:latest }', + }); + }); + + it('emits warning events', () => { + const events = collectEvents('BUILD', [ + { source: 'stdout', text: '/tmp/App.swift:10:5: warning: variable unused\n' }, + ]); + + const warnings = events.filter((e) => e.type === 'warning'); + expect(warnings).toHaveLength(1); + expect(warnings[0]).toMatchObject({ + type: 'warning', + location: '/tmp/App.swift:10', + message: 'variable unused', + }); + }); + + it('emits warning events for prefixed warnings', () => { + const events = collectEvents('BUILD', [ + { source: 'stdout', text: 'ld: warning: directory not found for option\n' }, + ]); + + const warnings = events.filter((e) => e.type === 'warning'); + expect(warnings).toHaveLength(1); + expect(warnings[0]).toMatchObject({ + type: 'warning', + message: 'directory not found for option', + }); + }); + + it('handles split chunks across buffer boundaries', () => { + const events: XcodebuildEvent[] = []; + const parser = createXcodebuildEventParser({ + operation: 'TEST', + onEvent: (event) => events.push(event), + }); + + parser.onStdout('Resolve Pack'); + parser.onStdout('age Graph\n'); + parser.flush(); + + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ type: 'status', stage: 'RESOLVING_PACKAGES' }); + }); + + it('processes full test lifecycle', () => { + const events = collectEvents('TEST', [ + { source: 'stdout', text: 'Resolve Package Graph\n' }, + { source: 'stdout', text: 'CompileSwift normal arm64 /tmp/App.swift\n' }, + { source: 'stdout', text: "Test Case '-[Suite testA]' passed (0.001 seconds)\n" }, + { source: 'stdout', text: "Test Case '-[Suite testB]' failed (0.002 seconds)\n" }, + { + source: 'stderr', + text: '/tmp/Test.swift:52: error: -[Suite testB] : XCTAssertEqual failed: ("0") is not equal to ("1")\n', + }, + { + source: 'stdout', + text: 'Executed 2 tests, with 1 failures (0 unexpected) in 0.123 (0.124) seconds\n', + }, + ]); + + const types = events.map((e) => e.type); + expect(types).toContain('status'); + expect(types).toContain('test-progress'); + expect(types).toContain('test-failure'); + }); + + it('skips Test Suite and Testing started noise lines without emitting events', () => { + const events = collectEvents('TEST', [ + { source: 'stdout', text: "Test Suite 'All tests' started at 2025-01-01 00:00:00.000.\n" }, + { source: 'stdout', text: "Test Suite 'All tests' passed at 2025-01-01 00:00:01.000.\n" }, + ]); + + // Test Suite 'All tests' started triggers RUN_TESTS status; 'passed' is noise + const statusEvents = events.filter((e) => e.type === 'status'); + expect(statusEvents.length).toBeLessThanOrEqual(1); + }); +}); diff --git a/src/utils/__tests__/xcodebuild-output.test.ts b/src/utils/__tests__/xcodebuild-output.test.ts new file mode 100644 index 00000000..aa0003f3 --- /dev/null +++ b/src/utils/__tests__/xcodebuild-output.test.ts @@ -0,0 +1,182 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { startBuildPipeline } from '../xcodebuild-pipeline.ts'; +import { + createPendingXcodebuildResponse, + finalizePendingXcodebuildResponse, +} from '../xcodebuild-output.ts'; + +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', () => { + const started = startBuildPipeline({ + operation: 'BUILD', + toolName: 'build_run_macos', + params: { scheme: 'MyApp' }, + message: '๐Ÿš€ Build & Run\n\n Scheme: MyApp\n\n', + }); + + started.pipeline.emitEvent({ + type: 'error', + timestamp: '2026-03-20T12:00:00.500Z', + operation: 'BUILD', + message: 'unterminated string literal', + rawLine: '/tmp/MyApp.swift:10:1: error: unterminated string literal', + }); + + const pending = createPendingXcodebuildResponse( + started, + { + content: [{ type: 'text', text: 'Legacy fallback error block' }], + isError: true, + }, + { + errorFallbackPolicy: 'if-no-structured-diagnostics', + }, + ); + + const finalized = finalizePendingXcodebuildResponse(pending); + const textContent = finalized.content + .filter((item) => item.type === 'text') + .map((item) => item.text) + .join('\n'); + + expect(textContent).toContain('Compiler Errors (1):'); + expect(textContent).toContain(' โœ— unterminated string literal'); + expect(textContent).toContain(' /tmp/MyApp.swift:10:1'); + expect(textContent).not.toContain('error: unterminated string literal'); + expect(textContent).not.toContain('Legacy fallback error block'); + }); + + it('preserves fallback error content when no structured diagnostics exist', () => { + const started = startBuildPipeline({ + operation: 'BUILD', + toolName: 'build_run_macos', + params: { scheme: 'MyApp' }, + message: '๐Ÿš€ Build & Run\n\n Scheme: MyApp\n\n', + }); + + const pending = createPendingXcodebuildResponse( + started, + { + content: [{ type: 'text', text: 'Legacy fallback error block' }], + isError: true, + }, + { + errorFallbackPolicy: 'if-no-structured-diagnostics', + }, + ); + + const finalized = finalizePendingXcodebuildResponse(pending); + const textContent = finalized.content + .filter((item) => item.type === 'text') + .map((item) => item.text) + .join('\n'); + + expect(textContent).toContain('Legacy fallback error block'); + }); + + it('never appends next steps to failed pending xcodebuild responses', () => { + const started = startBuildPipeline({ + operation: 'BUILD', + toolName: 'build_run_macos', + params: { scheme: 'MyApp' }, + message: '๐Ÿš€ Build & Run\n\n Scheme: MyApp\n\n', + }); + + started.pipeline.emitEvent({ + type: 'error', + timestamp: '2026-03-20T12:00:00.500Z', + operation: 'BUILD', + message: 'unterminated string literal', + rawLine: '/tmp/MyApp.swift:10:1: error: unterminated string literal', + }); + + const pending = createPendingXcodebuildResponse( + started, + { + content: [], + isError: true, + }, + { + errorFallbackPolicy: 'if-no-structured-diagnostics', + }, + ); + + const finalized = finalizePendingXcodebuildResponse(pending, { + nextSteps: [{ label: 'Should not render', cliTool: 'get-app-path', workflow: 'macos' }], + }); + + const events = (finalized._meta?.events ?? []) as Array<{ type: string }>; + expect(events.some((event) => event.type === 'next-steps')).toBe(false); + + const textContent = finalized.content + .filter((item) => item.type === 'text') + .map((item) => item.text) + .join('\n'); + + expect(textContent).not.toContain('Next steps:'); + expect(textContent).not.toContain('Should not render'); + }); + + it('finalizes summary, execution-derived footer, then next steps in order', () => { + const started = startBuildPipeline({ + operation: 'BUILD', + toolName: 'build_run_macos', + params: { scheme: 'MyApp' }, + message: '๐Ÿš€ Build & Run\n\n Scheme: MyApp', + }); + + const pending = createPendingXcodebuildResponse( + started, + { + content: [], + isError: false, + }, + { + tailEvents: [ + { + type: 'notice', + timestamp: '2026-03-20T12:00:01.000Z', + operation: 'BUILD', + level: 'success', + message: 'Build & Run complete', + code: 'build-run-result', + data: { + scheme: 'MyApp', + platform: 'macOS', + target: 'macOS', + appPath: '/tmp/build/MyApp.app', + launchState: 'requested', + }, + }, + ], + }, + ); + + const finalized = finalizePendingXcodebuildResponse(pending, { + nextSteps: [ + { label: 'Get built macOS app path', cliTool: 'get-app-path', workflow: 'macos' }, + ], + }); + + const events = (finalized._meta?.events ?? []) as Array<{ type: string; code?: string }>; + expect(events.slice(-3)).toEqual([ + expect.objectContaining({ type: 'summary' }), + expect.objectContaining({ type: 'notice', code: 'build-run-result' }), + expect.objectContaining({ type: 'next-steps' }), + ]); + + const textContent = finalized.content + .filter((item) => item.type === 'text') + .map((item) => item.text); + + expect(textContent.at(-1)).toContain('Next steps:'); + expect(textContent.at(-2)).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..c54aa20b --- /dev/null +++ b/src/utils/__tests__/xcodebuild-pipeline.test.ts @@ -0,0 +1,182 @@ +import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; +import { createXcodebuildPipeline } from '../xcodebuild-pipeline.ts'; +import { STAGE_RANK } from '../../types/xcodebuild-events.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 pipeline = createXcodebuildPipeline({ + operation: 'TEST', + toolName: 'test_sim', + params: { scheme: 'MyApp' }, + }); + + pipeline.emitEvent({ + type: 'start', + timestamp: '2025-01-01T00:00:00.000Z', + operation: 'TEST', + toolName: 'test_sim', + params: { scheme: 'MyApp' }, + message: 'Starting test run for 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'); + + // MCP content should have text entries + expect(result.mcpContent.length).toBeGreaterThan(0); + const texts = result.mcpContent + .filter((c) => c.type === 'text') + .map((c) => (c as { text: string }).text); + expect(texts).toContain('\nStarting test run for MyApp'); + expect(texts.some((t) => t.includes('Resolving packages'))).toBe(true); + + // Events array should contain all events + expect(result.events.length).toBeGreaterThan(0); + const eventTypes = result.events.map((e) => e.type); + expect(eventTypes).toContain('start'); + expect(eventTypes).toContain('status'); + expect(eventTypes).toContain('test-progress'); + expect(eventTypes).toContain('summary'); + }); + + it('handles build output with warnings and errors', () => { + const pipeline = createXcodebuildPipeline({ + operation: 'BUILD', + toolName: 'build_sim', + params: { scheme: 'MyApp' }, + }); + + 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 phase1 = createXcodebuildPipeline({ + operation: 'TEST', + toolName: 'test_sim', + params: {}, + }); + + 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 phase2 = createXcodebuildPipeline({ + operation: 'TEST', + toolName: 'test_sim', + params: {}, + minimumStage: minStage, + }); + + // 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 pipeline = createXcodebuildPipeline({ + operation: 'TEST', + toolName: 'test_sim', + params: {}, + }); + + pipeline.emitEvent({ + type: 'test-discovery', + timestamp: '2025-01-01T00:00:00.000Z', + operation: 'TEST', + total: 3, + tests: ['testA', 'testB', 'testC'], + truncated: false, + }); + + const result = pipeline.finalize(true, 100); + + const discoveryEvents = result.events.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 writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true); + + try { + const pipeline = createXcodebuildPipeline({ + operation: 'BUILD', + toolName: 'build_sim', + params: {}, + }); + + pipeline.onStdout('CompileSwift normal arm64 /tmp/App.swift\n'); + pipeline.finalize(true, 100); + + const jsonlCalls = writeSpy.mock.calls.filter( + (call) => typeof call[0] === 'string' && call[0].endsWith('\n'), + ); + expect(jsonlCalls.length).toBeGreaterThan(0); + + // Each JSONL line should be valid JSON + for (const call of jsonlCalls) { + const line = (call[0] as string).trim(); + if (line) { + const parsed = JSON.parse(line); + expect(parsed).toHaveProperty('type'); + expect(parsed).toHaveProperty('timestamp'); + } + } + } finally { + writeSpy.mockRestore(); + } + }); +}); diff --git a/src/utils/__tests__/xcodebuild-run-state.test.ts b/src/utils/__tests__/xcodebuild-run-state.test.ts new file mode 100644 index 00000000..a5dcd3f3 --- /dev/null +++ b/src/utils/__tests__/xcodebuild-run-state.test.ts @@ -0,0 +1,318 @@ +import { describe, expect, it } from 'vitest'; +import { createXcodebuildRunState } from '../xcodebuild-run-state.ts'; +import type { XcodebuildEvent } from '../../types/xcodebuild-events.ts'; +import { STAGE_RANK } from '../../types/xcodebuild-events.ts'; + +function ts(): string { + return '2025-01-01T00:00:00.000Z'; +} + +describe('xcodebuild-run-state', () => { + it('accepts status events and tracks milestones in order', () => { + const forwarded: XcodebuildEvent[] = []; + const state = createXcodebuildRunState({ + operation: 'TEST', + onEvent: (e) => forwarded.push(e), + }); + + state.push({ + type: 'status', + timestamp: ts(), + operation: 'TEST', + stage: 'RESOLVING_PACKAGES', + message: 'Resolving packages', + }); + state.push({ + type: 'status', + timestamp: ts(), + operation: 'TEST', + stage: 'COMPILING', + message: 'Compiling', + }); + state.push({ + type: 'status', + timestamp: ts(), + operation: 'TEST', + stage: 'RUN_TESTS', + message: 'Running tests', + }); + + const snap = state.snapshot(); + expect(snap.milestones).toHaveLength(3); + expect(snap.milestones.map((m) => m.stage)).toEqual([ + 'RESOLVING_PACKAGES', + 'COMPILING', + 'RUN_TESTS', + ]); + expect(snap.currentStage).toBe('RUN_TESTS'); + expect(forwarded).toHaveLength(3); + }); + + it('deduplicates milestones at or below current rank', () => { + const state = createXcodebuildRunState({ operation: 'BUILD' }); + + state.push({ + type: 'status', + timestamp: ts(), + operation: 'BUILD', + stage: 'RESOLVING_PACKAGES', + message: 'Resolving packages', + }); + state.push({ + type: 'status', + timestamp: ts(), + operation: 'BUILD', + stage: 'COMPILING', + message: 'Compiling', + }); + // Duplicate: should be ignored + state.push({ + type: 'status', + timestamp: ts(), + operation: 'BUILD', + stage: 'RESOLVING_PACKAGES', + message: 'Resolving packages', + }); + state.push({ + type: 'status', + timestamp: ts(), + operation: 'BUILD', + stage: 'COMPILING', + message: 'Compiling', + }); + + const snap = state.snapshot(); + expect(snap.milestones).toHaveLength(2); + }); + + it('respects minimumStage for multi-phase continuation', () => { + const state = createXcodebuildRunState({ + operation: 'TEST', + minimumStage: 'COMPILING', + }); + + // These should be suppressed because they're at or below COMPILING rank + state.push({ + type: 'status', + timestamp: ts(), + operation: 'TEST', + stage: 'RESOLVING_PACKAGES', + message: 'Resolving packages', + }); + state.push({ + type: 'status', + timestamp: ts(), + operation: 'TEST', + stage: 'COMPILING', + message: 'Compiling', + }); + // This should be accepted + state.push({ + type: 'status', + timestamp: ts(), + operation: 'TEST', + stage: 'RUN_TESTS', + message: 'Running tests', + }); + + const snap = state.snapshot(); + expect(snap.milestones).toHaveLength(1); + expect(snap.milestones[0].stage).toBe('RUN_TESTS'); + }); + + it('deduplicates error diagnostics by location+message', () => { + const state = createXcodebuildRunState({ operation: 'BUILD' }); + + const error: XcodebuildEvent = { + type: 'error', + timestamp: ts(), + operation: 'BUILD', + message: 'type mismatch', + location: '/tmp/App.swift:8', + rawLine: '/tmp/App.swift:8:17: error: type mismatch', + }; + + state.push(error); + state.push(error); + + const snap = state.snapshot(); + expect(snap.errors).toHaveLength(1); + }); + + it('deduplicates test failures by location+message', () => { + const state = createXcodebuildRunState({ operation: 'TEST' }); + + const failure: XcodebuildEvent = { + type: 'test-failure', + timestamp: ts(), + operation: 'TEST', + suite: 'Suite', + test: 'testA', + message: 'assertion failed', + location: '/tmp/Test.swift:10', + }; + + state.push(failure); + state.push(failure); + + const snap = state.snapshot(); + expect(snap.testFailures).toHaveLength(1); + }); + + it('deduplicates warnings by location+message', () => { + const state = createXcodebuildRunState({ operation: 'BUILD' }); + + const warning: XcodebuildEvent = { + type: 'warning', + timestamp: ts(), + operation: 'BUILD', + message: 'unused variable', + location: '/tmp/App.swift:5', + rawLine: '/tmp/App.swift:5: warning: unused variable', + }; + + state.push(warning); + state.push(warning); + + const snap = state.snapshot(); + expect(snap.warnings).toHaveLength(1); + }); + + it('tracks test counts from test-progress events', () => { + const state = createXcodebuildRunState({ operation: 'TEST' }); + + state.push({ + type: 'test-progress', + timestamp: ts(), + operation: 'TEST', + completed: 1, + failed: 0, + skipped: 0, + }); + state.push({ + type: 'test-progress', + timestamp: ts(), + operation: 'TEST', + completed: 2, + failed: 1, + skipped: 0, + }); + state.push({ + type: 'test-progress', + timestamp: ts(), + operation: 'TEST', + completed: 3, + failed: 1, + skipped: 1, + }); + + const snap = state.snapshot(); + expect(snap.completedTests).toBe(3); + expect(snap.failedTests).toBe(1); + expect(snap.skippedTests).toBe(1); + }); + + it('auto-inserts RUN_TESTS milestone on first test-progress', () => { + const forwarded: XcodebuildEvent[] = []; + const state = createXcodebuildRunState({ + operation: 'TEST', + onEvent: (e) => forwarded.push(e), + }); + + state.push({ + type: 'test-progress', + timestamp: ts(), + operation: 'TEST', + completed: 1, + failed: 0, + skipped: 0, + }); + + const snap = state.snapshot(); + expect(snap.milestones).toHaveLength(1); + expect(snap.milestones[0].stage).toBe('RUN_TESTS'); + // RUN_TESTS status + test-progress both forwarded + expect(forwarded).toHaveLength(2); + }); + + it('finalize emits summary event and sets final status', () => { + const forwarded: XcodebuildEvent[] = []; + const state = createXcodebuildRunState({ + operation: 'TEST', + onEvent: (e) => forwarded.push(e), + }); + + state.push({ + type: 'test-progress', + timestamp: ts(), + operation: 'TEST', + completed: 5, + failed: 2, + skipped: 0, + }); + + const finalState = state.finalize(false, 1234); + + expect(finalState.finalStatus).toBe('FAILED'); + expect(finalState.wallClockDurationMs).toBe(1234); + + const summaryEvents = finalState.events.filter((e) => e.type === 'summary'); + expect(summaryEvents).toHaveLength(1); + + const summary = summaryEvents[0]!; + if (summary.type === 'summary') { + expect(summary.status).toBe('FAILED'); + expect(summary.totalTests).toBe(5); + expect(summary.failedTests).toBe(2); + expect(summary.passedTests).toBe(3); + expect(summary.durationMs).toBe(1234); + } + }); + + it('highestStageRank returns correct rank for multi-phase handoff', () => { + const state = createXcodebuildRunState({ operation: 'TEST' }); + + state.push({ + type: 'status', + timestamp: ts(), + operation: 'TEST', + stage: 'RESOLVING_PACKAGES', + message: 'Resolving packages', + }); + state.push({ + type: 'status', + timestamp: ts(), + operation: 'TEST', + stage: 'COMPILING', + message: 'Compiling', + }); + + expect(state.highestStageRank()).toBe(STAGE_RANK.COMPILING); + }); + + it('passes through start and next-steps events', () => { + const forwarded: XcodebuildEvent[] = []; + const state = createXcodebuildRunState({ + operation: 'TEST', + onEvent: (e) => forwarded.push(e), + }); + + state.push({ + type: 'start', + timestamp: ts(), + operation: 'TEST', + toolName: 'test_sim', + params: {}, + message: 'Starting test run', + }); + state.push({ + type: 'next-steps', + timestamp: ts(), + steps: [{ tool: 'foo' }], + }); + + expect(forwarded).toHaveLength(2); + expect(forwarded[0].type).toBe('start'); + expect(forwarded[1].type).toBe('next-steps'); + }); +}); diff --git a/src/utils/app-path-resolver.ts b/src/utils/app-path-resolver.ts new file mode 100644 index 00000000..f53d08ad --- /dev/null +++ b/src/utils/app-path-resolver.ts @@ -0,0 +1,100 @@ +import path from 'node:path'; +import type { XcodePlatform } from '../types/common.ts'; +import type { CommandExecutor } from './command.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 = resolvePathFromCwd(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); + + if (derivedDataPath) { + 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..41500a14 --- /dev/null +++ b/src/utils/build-preflight.ts @@ -0,0 +1,80 @@ +import path from 'node:path'; + +export interface ToolPreflightParams { + operation: + | 'Build' + | 'Build & Run' + | 'Clean' + | 'Test' + | 'List Schemes' + | 'Show Build Settings' + | 'Get App Path'; + scheme?: string; + workspacePath?: string; + projectPath?: string; + configuration?: string; + platform?: string; + simulatorName?: string; + simulatorId?: string; + deviceId?: string; + arch?: string; +} + +function displayPath(filePath: string): string { + const cwd = process.cwd(); + const relative = path.relative(cwd, filePath); + if (relative.startsWith('..') || path.isAbsolute(relative)) { + return filePath; + } + return relative; +} + +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}', +}; + +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) { + lines.push(` Device: ${params.deviceId}`); + } + + if (params.arch) { + lines.push(` Architecture: ${params.arch}`); + } + + lines.push(''); + + return lines.join('\n'); +} diff --git a/src/utils/build-utils.ts b/src/utils/build-utils.ts index c7fa5071..5783b33a 100644 --- a/src/utils/build-utils.ts +++ b/src/utils/build-utils.ts @@ -32,6 +32,9 @@ import { } from './xcodemake.ts'; import { sessionStore } from './session-store.ts'; import path from 'path'; +import os from 'node:os'; +import type { XcodebuildPipeline } from './xcodebuild-pipeline.ts'; +import { createNoticeEvent } from './xcodebuild-output.ts'; function resolvePathFromCwd(pathValue: string): string { if (path.isAbsolute(pathValue)) { @@ -40,6 +43,23 @@ function resolvePathFromCwd(pathValue: string): string { return path.resolve(process.cwd(), pathValue); } +function getDefaultSwiftPackageCachePath(): string { + return path.join(os.homedir(), 'Library', 'Caches', 'org.swift.swiftpm'); +} + +function grepWarningsAndErrors(text: string): { type: 'warning' | 'error'; content: string }[] { + 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 }[]; +} + /** * Common function to execute an Xcode build command across platforms * @param params Common build parameters @@ -56,39 +76,19 @@ export async function executeXcodeBuildCommand( buildAction: string = 'build', executor: CommandExecutor, execOpts?: CommandExecOptions, + pipeline?: XcodebuildPipeline, ): 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 }[]; - } - function isTestProgressLine(line: string): boolean { - return ( - /^Test Case '.+' (passed|failed) \(/.test(line) || - /^Test Suite '.+' (passed|failed) at /.test(line) || - /^Executed \d+ tests?, with \d+ failures?/.test(line) - ); - } + function addBuildMessage(message: string, level: 'info' | 'success' = 'info'): void { + if (pipeline) { + pipeline.emitEvent( + createNoticeEvent('BUILD', message.replace(/^[^\p{L}\p{N}]+/u, '').trim(), level), + ); + return; + } - function extractTestProgressLines(text: string): string[] { - return text - .split('\n') - .map((line) => line.trim()) - .filter(Boolean) - .filter(isTestProgressLine); + buildMessages.push({ type: 'text', text: message }); } log('info', `Starting ${platformOptions.logPrefix} ${buildAction} for scheme ${params.scheme}`); @@ -105,22 +105,15 @@ 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.'); } } @@ -184,29 +177,19 @@ 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); @@ -214,6 +197,18 @@ export async function executeXcodeBuildCommand( command.push('-destination', destinationString); + 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(), + ); + } + if (derivedDataPath) { command.push('-derivedDataPath', derivedDataPath); } @@ -224,55 +219,6 @@ export async function executeXcodeBuildCommand( command.push(buildAction); - // Execute the command using xcodemake or xcodebuild - const shouldShowTestProgress = buildAction === 'test' && platformOptions.showTestProgress; - const shouldStreamCliTestProgress = - shouldShowTestProgress && - process.env.XCODEBUILDMCP_RUNTIME === 'cli' && - process.env.XCODEBUILDMCP_CLI_OUTPUT_FORMAT !== 'json'; - - let testProgressStreamBuffer = ''; - const streamTestProgressChunk = (chunk: string): void => { - if (!shouldStreamCliTestProgress) { - return; - } - - testProgressStreamBuffer += chunk; - const lines = testProgressStreamBuffer.split(/\r?\n/); - testProgressStreamBuffer = lines.pop() ?? ''; - - for (const rawLine of lines) { - const line = rawLine.trim(); - if (!line || !isTestProgressLine(line)) { - continue; - } - process.stdout.write(`๐Ÿงช ${line}\n`); - } - }; - - const flushTestProgressStream = (): void => { - if (!shouldStreamCliTestProgress) { - return; - } - - const line = testProgressStreamBuffer.trim(); - testProgressStreamBuffer = ''; - if (line && isTestProgressLine(line)) { - process.stdout.write(`๐Ÿงช ${line}\n`); - } - }; - - if (shouldStreamCliTestProgress) { - const sourceLabel = workspacePath - ? `workspace=${workspacePath}` - : projectPath - ? `project=${projectPath}` - : 'source=unknown'; - process.stdout.write( - `๐Ÿงช Test configuration: scheme=${params.scheme}, configuration=${params.configuration}, destination=${destinationString}, ${sourceLabel}\n`, - ); - } - let result; if ( isXcodemakeEnabledFlag && @@ -280,28 +226,17 @@ export async function executeXcodeBuildCommand( buildAction === 'build' && !preferXcodebuild ) { - // Check if Makefile already exists 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), @@ -309,48 +244,43 @@ export async function executeXcodeBuildCommand( ); } } else { - // Use standard xcodebuild + const streamHandlers = pipeline + ? { + onStdout: (chunk: string) => pipeline.onStdout(chunk), + onStderr: (chunk: string) => pipeline.onStderr(chunk), + } + : {}; + // Pass projectDir as cwd to ensure CocoaPods relative paths resolve correctly result = await executor(command, platformOptions.logPrefix, false, { ...execOpts, cwd: projectDir, - ...(shouldShowTestProgress ? { onStdout: streamTestProgressChunk } : {}), + ...streamHandlers, }); } - flushTestProgressStream(); - - // Optional test progress output (per-test pass/fail and suite totals) - if (shouldShowTestProgress && !shouldStreamCliTestProgress) { - const progressLines = extractTestProgressLines(result.output); - progressLines.forEach((line) => { + // When pipeline is active, skip warning/error grepping - the parser handles it + let warningOrErrorLines: { type: 'warning' | 'error'; content: string }[] = []; + if (!pipeline) { + warningOrErrorLines = grepWarningsAndErrors(result.output); + const suppressWarnings = sessionStore.get('suppressWarnings'); + for (const { type, content } of warningOrErrorLines) { + if (type === 'warning' && suppressWarnings) { + continue; + } buildMessages.push({ type: 'text', - text: `๐Ÿงช ${line}`, + text: type === 'warning' ? `โš ๏ธ Warning: ${content}` : `โŒ Error: ${content}`, }); - }); - } - - // 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 (!pipeline && result.error) { + for (const content of result.error.split('\n')) { if (content.trim()) { buildMessages.push({ type: 'text', text: `โŒ [stderr] ${content}` }); } - }); + } } if (!result.success) { @@ -370,9 +300,8 @@ export async function executeXcodeBuildCommand( errorResponse.content.unshift(...buildMessages); } - // If using xcodemake and build failed but no compiling errors, suggest using xcodebuild if ( - warningOrErrorLines.length == 0 && + warningOrErrorLines.length === 0 && isXcodemakeEnabledFlag && xcodemakeAvailableFlag && buildAction === 'build' && @@ -389,24 +318,21 @@ export async function executeXcodeBuildCommand( 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. + additionalInfo += `xcodemake: Using faster incremental builds with xcodemake. Future builds will use the generated Makefile for improved performance. `; } - // Only show next steps for 'build' action - if (buildAction === 'build') { + if (!pipeline && buildAction === 'build') { if (platformOptions.platform === XcodePlatform.macOS) { additionalInfo = `Next Steps: 1. Get app path: get_mac_app_path({ scheme: '${params.scheme}' }) @@ -439,7 +365,6 @@ Future builds will use the generated Makefile for improved performance. ], }; - // Only add additional info if we have any if (additionalInfo) { successResponse.content.push({ type: 'text', diff --git a/src/utils/cli-progress-reporter.ts b/src/utils/cli-progress-reporter.ts new file mode 100644 index 00000000..314080db --- /dev/null +++ b/src/utils/cli-progress-reporter.ts @@ -0,0 +1,31 @@ +import * as clack from '@clack/prompts'; + +export interface CliProgressReporter { + update(message: string): void; + clear(): void; +} + +export function createCliProgressReporter(): CliProgressReporter { + const spinner = clack.spinner(); + let active = false; + + return { + update(message: string): void { + if (!active) { + spinner.start(message); + active = true; + return; + } + + spinner.message(message); + }, + clear(): void { + if (!active) { + return; + } + + spinner.clear(); + active = false; + }, + }; +} diff --git a/src/utils/renderers/__tests__/cli-text-renderer.test.ts b/src/utils/renderers/__tests__/cli-text-renderer.test.ts new file mode 100644 index 00000000..5bf824c9 --- /dev/null +++ b/src/utils/renderers/__tests__/cli-text-renderer.test.ts @@ -0,0 +1,376 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { createCliTextRenderer } from '../cli-text-renderer.ts'; + +const reporter = { + update: vi.fn<(message: string) => void>(), + clear: vi.fn<() => void>(), +}; + +vi.mock('../../cli-progress-reporter.ts', () => ({ + createCliProgressReporter: () => reporter, +})); + +describe('cli-text-renderer', () => { + const originalIsTTY = process.stdout.isTTY; + const originalNoColor = process.env.NO_COLOR; + + beforeEach(() => { + reporter.update.mockReset(); + reporter.clear.mockReset(); + process.env.NO_COLOR = '1'; + }); + + afterEach(() => { + vi.restoreAllMocks(); + Object.defineProperty(process.stdout, 'isTTY', { + configurable: true, + value: originalIsTTY, + }); + + if (originalNoColor === undefined) { + delete process.env.NO_COLOR; + } else { + process.env.NO_COLOR = originalNoColor; + } + }); + + it('renders one blank-line boundary between front matter and first runtime line', () => { + const stdoutWrite = vi.spyOn(process.stdout, 'write').mockImplementation(() => true); + const renderer = createCliTextRenderer({ interactive: false }); + + renderer.onEvent({ + type: 'start', + timestamp: '2026-03-20T12:00:00.000Z', + operation: 'BUILD', + toolName: 'build_run_macos', + params: {}, + message: [ + '๐Ÿš€ Build & Run', + '', + ' Scheme: MyApp', + ' Project: /tmp/MyApp.xcodeproj', + ' Configuration: Debug', + ' Platform: macOS', + '', + ].join('\n'), + }); + + renderer.onEvent({ + type: 'status', + timestamp: '2026-03-20T12:00:01.000Z', + operation: 'BUILD', + stage: 'COMPILING', + message: 'Compiling', + }); + + const output = stdoutWrite.mock.calls.flat().join(''); + expect(output).toContain(' Platform: macOS\n\nโ€บ Compiling\n'); + }); + + it('uses transient interactive updates for active phases and durable writes for lasting events', () => { + const stdoutWrite = vi.spyOn(process.stdout, 'write').mockImplementation(() => true); + const renderer = createCliTextRenderer({ interactive: true }); + + renderer.onEvent({ + type: 'start', + timestamp: '2026-03-20T12:00:00.000Z', + operation: 'BUILD', + toolName: 'build_run_macos', + params: {}, + message: '๐Ÿš€ Build & Run\n\n Scheme: MyApp\n\n', + }); + + renderer.onEvent({ + type: 'status', + timestamp: '2026-03-20T12:00:01.000Z', + operation: 'BUILD', + stage: 'COMPILING', + message: 'Compiling', + }); + + renderer.onEvent({ + type: 'notice', + timestamp: '2026-03-20T12:00:02.000Z', + operation: 'BUILD', + level: 'info', + message: 'Resolving app path', + code: 'build-run-step', + data: { step: 'resolve-app-path', status: 'started' }, + }); + + renderer.onEvent({ + type: 'warning', + timestamp: '2026-03-20T12:00:03.000Z', + operation: 'BUILD', + message: 'unused variable', + rawLine: '/tmp/MyApp.swift:10: warning: unused variable', + }); + + renderer.onEvent({ + type: 'notice', + timestamp: '2026-03-20T12:00:04.000Z', + operation: 'BUILD', + level: 'success', + message: 'App path resolved', + code: 'build-run-step', + data: { step: 'resolve-app-path', status: 'succeeded', appPath: '/tmp/build/MyApp.app' }, + }); + + renderer.onEvent({ + type: 'summary', + timestamp: '2026-03-20T12:00:05.000Z', + operation: 'BUILD', + status: 'SUCCEEDED', + }); + + expect(reporter.update).toHaveBeenCalledWith('Compiling...'); + expect(reporter.update).toHaveBeenCalledWith('Resolving app path...'); + + const output = stdoutWrite.mock.calls.flat().join(''); + expect(output).not.toContain('โ€บ Compiling\n'); + expect(output).not.toContain('โ€บ Resolving app path\n'); + expect(output).toContain('Warnings (1):'); + expect(output).toContain('unused variable'); + expect(output).toContain('โœ“ Resolving app path\n'); + }); + + it('renders grouped sad-path diagnostics before the failed summary', () => { + const stdoutWrite = vi.spyOn(process.stdout, 'write').mockImplementation(() => true); + const renderer = createCliTextRenderer({ interactive: false }); + + renderer.onEvent({ + type: 'start', + timestamp: '2026-03-20T12:00:00.000Z', + operation: 'BUILD', + toolName: 'build_run_sim', + params: {}, + message: [ + '๐Ÿš€ Build & Run', + '', + ' Scheme: MyApp', + ' Project: /tmp/MyApp.xcodeproj', + ' Configuration: Debug', + ' Platform: iOS Simulator', + ' Simulator: INVALID-SIM-ID-123', + '', + ].join('\n'), + }); + + renderer.onEvent({ + type: 'error', + timestamp: '2026-03-20T12:00:01.000Z', + operation: 'BUILD', + message: 'No available simulator matched: INVALID-SIM-ID-123', + rawLine: 'No available simulator matched: INVALID-SIM-ID-123', + }); + + renderer.onEvent({ + type: 'summary', + timestamp: '2026-03-20T12:00:02.000Z', + operation: 'BUILD', + status: 'FAILED', + durationMs: 1200, + }); + + const output = stdoutWrite.mock.calls.flat().join(''); + expect(output).toContain('Errors (1):'); + expect(output).toContain(' โœ— No available simulator matched: INVALID-SIM-ID-123'); + expect(output).toContain('โŒ Build failed. (โฑ๏ธ 1.2s)'); + }); + + it('groups compiler diagnostics under a nested failure header before the failed summary', () => { + const stdoutWrite = vi.spyOn(process.stdout, 'write').mockImplementation(() => true); + const renderer = createCliTextRenderer({ interactive: false }); + + renderer.onEvent({ + type: 'start', + timestamp: '2026-03-20T12:00:00.000Z', + operation: 'BUILD', + toolName: 'build_run_macos', + params: {}, + message: [ + '๐Ÿš€ Build & Run', + '', + ' Scheme: MyApp', + ' Project: /tmp/MyApp.xcodeproj', + ' Configuration: Debug', + ' Platform: macOS', + '', + ].join('\n'), + }); + + renderer.onEvent({ + type: 'status', + timestamp: '2026-03-20T12:00:01.000Z', + operation: 'BUILD', + stage: 'COMPILING', + message: 'Compiling', + }); + + renderer.onEvent({ + type: 'error', + timestamp: '2026-03-20T12:00:02.000Z', + operation: 'BUILD', + message: 'unterminated string literal', + rawLine: '/tmp/MCPTest/ContentView.swift:16:18: error: unterminated string literal', + }); + + renderer.onEvent({ + type: 'summary', + timestamp: '2026-03-20T12:00:03.000Z', + operation: 'BUILD', + status: 'FAILED', + durationMs: 4000, + }); + + const output = stdoutWrite.mock.calls.flat().join(''); + expect(output).toContain( + 'โ€บ Compiling\n\nCompiler Errors (1):\n\n โœ— unterminated string literal\n /tmp/MCPTest/ContentView.swift:16:18', + ); + expect(output).not.toContain('error: unterminated string literal\n ContentView.swift:16:18'); + expect(output).toContain('\n\nโŒ Build failed. (โฑ๏ธ 4.0s)'); + }); + + it('uses exactly one blank-line boundary between front matter and compiler errors when no runtime line rendered', () => { + const stdoutWrite = vi.spyOn(process.stdout, 'write').mockImplementation(() => true); + const renderer = createCliTextRenderer({ interactive: false }); + + renderer.onEvent({ + type: 'start', + timestamp: '2026-03-20T12:00:00.000Z', + operation: 'BUILD', + toolName: 'build_run_macos', + params: {}, + message: [ + '๐Ÿš€ Build & Run', + '', + ' Scheme: MyApp', + ' Project: /tmp/MyApp.xcodeproj', + ' Configuration: Debug', + ' Platform: macOS', + '', + ].join('\n'), + }); + + renderer.onEvent({ + type: 'error', + timestamp: '2026-03-20T12:00:01.000Z', + operation: 'BUILD', + message: 'unterminated string literal', + rawLine: '/tmp/MCPTest/ContentView.swift:16:18: error: unterminated string literal', + }); + + renderer.onEvent({ + type: 'summary', + timestamp: '2026-03-20T12:00:02.000Z', + operation: 'BUILD', + status: 'FAILED', + durationMs: 2000, + }); + + const output = stdoutWrite.mock.calls.flat().join(''); + expect(output).toContain( + ' Platform: macOS\n\nCompiler Errors (1):\n\n โœ— unterminated string literal\n /tmp/MCPTest/ContentView.swift:16:18', + ); + expect(output).not.toContain(' Platform: macOS\n\n\nCompiler Errors (1):'); + }); + + it('persists the last transient runtime phase as a durable line before grouped compiler errors', () => { + const stdoutWrite = vi.spyOn(process.stdout, 'write').mockImplementation(() => true); + const renderer = createCliTextRenderer({ interactive: true }); + + renderer.onEvent({ + type: 'start', + timestamp: '2026-03-20T12:00:00.000Z', + operation: 'BUILD', + toolName: 'build_run_macos', + params: {}, + message: '๐Ÿš€ Build & Run\n\n Scheme: MyApp\n\n', + }); + + renderer.onEvent({ + type: 'status', + timestamp: '2026-03-20T12:00:01.000Z', + operation: 'BUILD', + stage: 'COMPILING', + message: 'Compiling', + }); + + renderer.onEvent({ + type: 'status', + timestamp: '2026-03-20T12:00:02.000Z', + operation: 'BUILD', + stage: 'LINKING', + message: 'Linking', + }); + + renderer.onEvent({ + type: 'error', + timestamp: '2026-03-20T12:00:03.000Z', + operation: 'BUILD', + message: 'unterminated string literal', + rawLine: '/tmp/MCPTest/ContentView.swift:16:18: error: unterminated string literal', + }); + + renderer.onEvent({ + type: 'summary', + timestamp: '2026-03-20T12:00:04.000Z', + operation: 'BUILD', + status: 'FAILED', + durationMs: 4000, + }); + + expect(reporter.update).toHaveBeenCalledWith('Compiling...'); + expect(reporter.update).toHaveBeenCalledWith('Linking...'); + + const output = stdoutWrite.mock.calls.flat().join(''); + expect(output).toContain( + 'โ€บ Linking\n\nCompiler Errors (1):\n\n โœ— unterminated string literal\n /tmp/MCPTest/ContentView.swift:16:18', + ); + }); + + it('renders summary, execution-derived footer, and next steps in that order', () => { + const stdoutWrite = vi.spyOn(process.stdout, 'write').mockImplementation(() => true); + const renderer = createCliTextRenderer({ interactive: false }); + + renderer.onEvent({ + type: 'summary', + timestamp: '2026-03-20T12:00:05.000Z', + operation: 'BUILD', + status: 'SUCCEEDED', + durationMs: 7100, + }); + + renderer.onEvent({ + type: 'notice', + timestamp: '2026-03-20T12:00:06.000Z', + operation: 'BUILD', + level: 'success', + message: 'Build & Run complete', + code: 'build-run-result', + data: { + scheme: 'MyApp', + platform: 'macOS', + target: 'macOS', + appPath: '/tmp/build/MyApp.app', + launchState: 'requested', + }, + }); + + renderer.onEvent({ + type: 'next-steps', + timestamp: '2026-03-20T12:00:07.000Z', + steps: [{ label: 'Get built macOS app path', cliTool: 'get-app-path', workflow: 'macos' }], + }); + + const output = stdoutWrite.mock.calls.flat().join(''); + const summaryIndex = output.indexOf('โœ… Build succeeded.'); + const footerIndex = output.indexOf('โœ… Build & Run complete'); + const nextStepsIndex = output.indexOf('Next steps:'); + + expect(summaryIndex).toBeGreaterThanOrEqual(0); + expect(footerIndex).toBeGreaterThan(summaryIndex); + expect(nextStepsIndex).toBeGreaterThan(footerIndex); + expect(output).toContain('โœ… Build & Run complete\n\n โ”” App Path: /tmp/build/MyApp.app'); + }); +}); diff --git a/src/utils/renderers/__tests__/event-formatting.test.ts b/src/utils/renderers/__tests__/event-formatting.test.ts new file mode 100644 index 00000000..8d29f791 --- /dev/null +++ b/src/utils/renderers/__tests__/event-formatting.test.ts @@ -0,0 +1,274 @@ +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { + extractGroupedCompilerError, + formatGroupedCompilerErrors, + formatHumanErrorEvent, + formatHumanWarningEvent, + formatNoticeEvent, + formatStartEvent, + formatStatusEvent, + formatTransientNoticeEvent, + formatTransientStatusEvent, +} from '../event-formatting.ts'; + +describe('event formatting', () => { + it('formats start events as the provided preflight block', () => { + expect( + formatStartEvent({ + type: 'start', + timestamp: '2026-03-20T12:00:00.000Z', + operation: 'BUILD', + toolName: 'build_run_macos', + params: {}, + message: '๐Ÿš€ Build & Run\n\n Scheme: MyApp', + }), + ).toBe('๐Ÿš€ Build & Run\n\n Scheme: MyApp'); + }); + + it('formats status events as durable phase lines', () => { + expect( + formatStatusEvent({ + type: 'status', + timestamp: '2026-03-20T12:00:00.000Z', + operation: 'BUILD', + stage: 'COMPILING', + message: 'Compiling', + }), + ).toBe('โ€บ Compiling'); + }); + + it('formats transient status events for interactive runtime updates', () => { + expect( + formatTransientStatusEvent({ + type: 'status', + timestamp: '2026-03-20T12:00:00.000Z', + operation: 'BUILD', + stage: 'COMPILING', + message: 'Compiling', + }), + ).toBe('Compiling...'); + }); + + it('formats compiler-style errors with a cwd-relative source location when possible', () => { + const projectBaseDir = join(process.cwd(), 'example_projects/macOS'); + + expect( + formatHumanErrorEvent( + { + type: 'error', + timestamp: '2026-03-20T12:00:00.000Z', + operation: 'BUILD', + message: 'unterminated string literal', + rawLine: 'ContentView.swift:16:18: error: unterminated string literal', + }, + { baseDir: projectBaseDir }, + ), + ).toBe( + [ + 'error: unterminated string literal', + ' example_projects/macOS/MCPTest/ContentView.swift:16:18', + ].join('\n'), + ); + }); + + it('keeps compiler-style error paths absolute when they are outside cwd', () => { + expect( + formatHumanErrorEvent({ + type: 'error', + timestamp: '2026-03-20T12:00:00.000Z', + operation: 'BUILD', + message: 'unterminated string literal', + rawLine: '/tmp/MCPTest/ContentView.swift:16:18: error: unterminated string literal', + }), + ).toBe( + ['error: unterminated string literal', ' /tmp/MCPTest/ContentView.swift:16:18'].join('\n'), + ); + }); + + it('formats tool-originated errors in xcodebuild-style form', () => { + expect( + formatHumanErrorEvent({ + type: 'error', + timestamp: '2026-03-20T12:00:00.000Z', + operation: 'BUILD', + message: 'No available simulator matched: INVALID-SIM-ID-123', + rawLine: 'No available simulator matched: INVALID-SIM-ID-123', + }), + ).toBe('error: No available simulator matched: INVALID-SIM-ID-123'); + }); + + it('extracts compiler diagnostics for grouped sad-path rendering', () => { + expect( + extractGroupedCompilerError( + { + type: 'error', + timestamp: '2026-03-20T12:00:00.000Z', + operation: 'BUILD', + message: 'unterminated string literal', + rawLine: 'ContentView.swift:16:18: error: unterminated string literal', + }, + { baseDir: join(process.cwd(), 'example_projects/macOS') }, + ), + ).toEqual({ + message: 'unterminated string literal', + location: 'example_projects/macOS/MCPTest/ContentView.swift:16:18', + }); + }); + + it('formats grouped compiler errors without repeating the error prefix per line', () => { + expect( + formatGroupedCompilerErrors( + [ + { + type: 'error', + timestamp: '2026-03-20T12:00:00.000Z', + operation: 'BUILD', + message: 'unterminated string literal', + rawLine: 'ContentView.swift:16:18: error: unterminated string literal', + }, + ], + { baseDir: join(process.cwd(), 'example_projects/macOS') }, + ), + ).toBe( + [ + 'Compiler Errors (1):', + '', + ' โœ— unterminated string literal', + ' example_projects/macOS/MCPTest/ContentView.swift:16:18', + ].join('\n'), + ); + }); + + it('formats tool-originated warnings with warning emoji', () => { + expect( + formatHumanWarningEvent({ + type: 'warning', + timestamp: '2026-03-20T12:00:00.000Z', + operation: 'BUILD', + message: 'Using cached build products', + rawLine: 'Using cached build products', + }), + ).toBe(' \u{26A0} Using cached build products'); + }); + + it('formats structured build-run step notices', () => { + expect( + formatNoticeEvent({ + type: 'notice', + timestamp: '2026-03-20T12:00:00.000Z', + operation: 'BUILD', + level: 'info', + message: 'Resolving app path', + code: 'build-run-step', + data: { step: 'resolve-app-path', status: 'started' }, + }), + ).toBe('โ€บ Resolving app path'); + }); + + it('formats transient build-run step notices only for started steps', () => { + expect( + formatTransientNoticeEvent({ + type: 'notice', + timestamp: '2026-03-20T12:00:00.000Z', + operation: 'BUILD', + level: 'info', + message: 'Resolving app path', + code: 'build-run-step', + data: { step: 'resolve-app-path', status: 'started' }, + }), + ).toBe('Resolving app path...'); + + expect( + formatTransientNoticeEvent({ + type: 'notice', + timestamp: '2026-03-20T12:00:00.000Z', + operation: 'BUILD', + level: 'success', + message: 'App path resolved', + code: 'build-run-step', + data: { step: 'resolve-app-path', status: 'succeeded', appPath: '/tmp/build/MyApp.app' }, + }), + ).toBeNull(); + }); + + it('formats structured build-run result notices as a summary block', () => { + expect( + formatNoticeEvent({ + type: 'notice', + timestamp: '2026-03-20T12:00:00.000Z', + operation: 'BUILD', + level: 'success', + message: 'Build & Run complete', + code: 'build-run-result', + data: { + scheme: 'MyApp', + platform: 'macOS', + target: 'macOS', + appPath: '/tmp/build/MyApp.app', + launchState: 'requested', + }, + }), + ).toBe(['โœ… Build & Run complete', '', ' โ”” App Path: /tmp/build/MyApp.app'].join('\n')); + }); + + it('does not duplicate front-matter fields in the final build-run footer', () => { + const rendered = formatNoticeEvent({ + type: 'notice', + timestamp: '2026-03-20T12:00:00.000Z', + operation: 'BUILD', + level: 'success', + message: 'Build & Run complete', + code: 'build-run-result', + data: { + scheme: 'MyApp', + platform: 'macOS', + target: 'macOS', + appPath: '/tmp/build/MyApp.app', + launchState: 'requested', + }, + }); + + expect(rendered).toContain('\n\n โ”” App Path: /tmp/build/MyApp.app'); + expect(rendered).not.toContain('Scheme:'); + expect(rendered).not.toContain('Platform:'); + expect(rendered).not.toContain('Target:'); + expect(rendered).not.toContain('Configuration:'); + expect(rendered).not.toContain('Project:'); + expect(rendered).not.toContain('Workspace:'); + }); + + it('renders all execution-derived footer values as a tree section', () => { + const rendered = formatNoticeEvent({ + type: 'notice', + timestamp: '2026-03-20T12:00:00.000Z', + operation: 'BUILD', + level: 'success', + message: 'Build & Run complete', + code: 'build-run-result', + data: { + scheme: 'MyApp', + platform: 'macOS', + target: 'macOS', + appPath: '/tmp/build/MyApp.app', + bundleId: 'com.example.myapp', + appId: 'A1B2C3D4', + processId: 12345, + launchState: 'running', + }, + }); + + expect(rendered).toContain('โœ… Build & Run complete\n\n'); + expect(rendered).toContain(' โ”œ App Path: /tmp/build/MyApp.app'); + expect(rendered).toContain(' โ”œ Bundle ID: com.example.myapp'); + expect(rendered).toContain(' โ”œ App ID: A1B2C3D4'); + expect(rendered).toContain(' โ”œ Process ID: 12345'); + expect(rendered).toContain(' โ”” Launch: Running'); + expect(rendered).not.toContain('Scheme:'); + expect(rendered).not.toContain('Platform:'); + expect(rendered).not.toContain('Target:'); + expect(rendered).not.toContain('Configuration:'); + expect(rendered).not.toContain('Project:'); + expect(rendered).not.toContain('Workspace:'); + }); +}); diff --git a/src/utils/renderers/__tests__/mcp-renderer.test.ts b/src/utils/renderers/__tests__/mcp-renderer.test.ts new file mode 100644 index 00000000..56abb13d --- /dev/null +++ b/src/utils/renderers/__tests__/mcp-renderer.test.ts @@ -0,0 +1,150 @@ +import { describe, expect, it } from 'vitest'; +import { createMcpRenderer } from '../mcp-renderer.ts'; + +describe('mcp-renderer', () => { + it('buffers the same sad-path diagnostic text semantics as CLI', () => { + const renderer = createMcpRenderer(); + + renderer.onEvent({ + type: 'start', + timestamp: '2026-03-20T12:00:00.000Z', + operation: 'BUILD', + toolName: 'build_run_sim', + params: {}, + message: '๐Ÿš€ Build & Run\n\n Scheme: MyApp\n\n', + }); + + renderer.onEvent({ + type: 'error', + timestamp: '2026-03-20T12:00:01.000Z', + operation: 'BUILD', + message: 'No available simulator matched: INVALID-SIM-ID-123', + rawLine: 'No available simulator matched: INVALID-SIM-ID-123', + }); + + renderer.onEvent({ + type: 'summary', + timestamp: '2026-03-20T12:00:02.000Z', + operation: 'BUILD', + status: 'FAILED', + durationMs: 1200, + }); + + const textItems = renderer + .getContent() + .filter((item) => item.type === 'text') + .map((item) => item.text); + + expect(textItems[0]).toContain('๐Ÿš€ Build & Run'); + + const allText = textItems.join('\n'); + expect(allText).toContain('Errors (1):'); + expect(allText).toContain(' โœ— No available simulator matched: INVALID-SIM-ID-123'); + expect(allText).toContain('โŒ Build failed. (โฑ๏ธ 1.2s)'); + }); + + it('buffers grouped compiler diagnostics before the failed summary', () => { + const renderer = createMcpRenderer(); + + renderer.onEvent({ + type: 'start', + timestamp: '2026-03-20T12:00:00.000Z', + operation: 'BUILD', + toolName: 'build_run_macos', + params: {}, + message: '๐Ÿš€ Build & Run\n\n Scheme: MyApp\n\n', + }); + + renderer.onEvent({ + type: 'error', + timestamp: '2026-03-20T12:00:01.000Z', + operation: 'BUILD', + message: 'unterminated string literal', + rawLine: '/tmp/MCPTest/ContentView.swift:16:18: error: unterminated string literal', + }); + + renderer.onEvent({ + type: 'summary', + timestamp: '2026-03-20T12:00:02.000Z', + operation: 'BUILD', + status: 'FAILED', + durationMs: 4000, + }); + + const textItems = renderer + .getContent() + .filter((item) => item.type === 'text') + .map((item) => item.text); + + expect(textItems[1]).toContain('Compiler Errors (1):'); + expect(textItems[1]).toContain(' โœ— unterminated string literal'); + expect(textItems[1]).toContain(' /tmp/MCPTest/ContentView.swift:16:18'); + expect(textItems[1]).not.toContain('error: unterminated string literal'); + expect(textItems[2]).toContain('โŒ Build failed. (โฑ๏ธ 4.0s)'); + }); + + it('buffers the same formatted sections in order and keeps next steps last', () => { + const renderer = createMcpRenderer(); + + renderer.onEvent({ + type: 'start', + timestamp: '2026-03-20T12:00:00.000Z', + operation: 'BUILD', + toolName: 'build_run_macos', + params: {}, + message: '๐Ÿš€ Build & Run\n\n Scheme: MyApp', + }); + + renderer.onEvent({ + type: 'status', + timestamp: '2026-03-20T12:00:01.000Z', + operation: 'BUILD', + stage: 'COMPILING', + message: 'Compiling', + }); + + renderer.onEvent({ + type: 'summary', + timestamp: '2026-03-20T12:00:02.000Z', + operation: 'BUILD', + status: 'SUCCEEDED', + durationMs: 7100, + }); + + renderer.onEvent({ + type: 'notice', + timestamp: '2026-03-20T12:00:03.000Z', + operation: 'BUILD', + level: 'success', + message: 'Build & Run complete', + code: 'build-run-result', + data: { + scheme: 'MyApp', + platform: 'macOS', + target: 'macOS', + appPath: '/tmp/build/MyApp.app', + launchState: 'requested', + }, + }); + + renderer.onEvent({ + type: 'next-steps', + timestamp: '2026-03-20T12:00:04.000Z', + steps: [{ label: 'Get built macOS app path', cliTool: 'get-app-path', workflow: 'macos' }], + }); + + const textItems = renderer + .getContent() + .filter((item) => item.type === 'text') + .map((item) => item.text); + + expect(textItems[0]).toContain('๐Ÿš€ Build & Run'); + expect(textItems[1]).toBe('โ€บ Compiling'); + expect(textItems[2]).toContain('โœ… Build succeeded.'); + expect(textItems[3]).toContain('โœ… Build & Run complete'); + expect(textItems[3]).toContain('\n\n โ”” App Path: /tmp/build/MyApp.app'); + expect(textItems[3]).not.toContain('Scheme:'); + expect(textItems[3]).not.toContain('Target:'); + expect(textItems.at(-1)).toContain('Next steps:'); + }); +}); diff --git a/src/utils/renderers/cli-jsonl-renderer.ts b/src/utils/renderers/cli-jsonl-renderer.ts new file mode 100644 index 00000000..2a025783 --- /dev/null +++ b/src/utils/renderers/cli-jsonl-renderer.ts @@ -0,0 +1,13 @@ +import type { XcodebuildEvent } from '../../types/xcodebuild-events.ts'; +import type { XcodebuildRenderer } from './index.ts'; + +export function createCliJsonlRenderer(): XcodebuildRenderer { + return { + onEvent(event: XcodebuildEvent): void { + process.stdout.write(JSON.stringify(event) + '\n'); + }, + finalize(): void { + // no-op + }, + }; +} diff --git a/src/utils/renderers/cli-text-renderer.ts b/src/utils/renderers/cli-text-renderer.ts new file mode 100644 index 00000000..819f5a1d --- /dev/null +++ b/src/utils/renderers/cli-text-renderer.ts @@ -0,0 +1,164 @@ +import type { XcodebuildEvent } from '../../types/xcodebuild-events.ts'; +import { createCliProgressReporter } from '../cli-progress-reporter.ts'; +import { formatCliTextLine } from '../terminal-output.ts'; +import { deriveDiagnosticBaseDir } from './index.ts'; +import type { XcodebuildRenderer } from './index.ts'; +import { + formatStartEvent, + formatStatusEvent, + formatTransientStatusEvent, + formatNoticeEvent, + formatTransientNoticeEvent, + formatGroupedCompilerErrors, + formatGroupedWarnings, + formatTestFailureEvent, + formatSummaryEvent, + formatNextStepsEvent, +} from './event-formatting.ts'; + +function formatCliTextBlock(text: string): string { + return text + .split('\n') + .map((line) => formatCliTextLine(line)) + .join('\n'); +} + +export function createCliTextRenderer(options: { interactive: boolean }): XcodebuildRenderer { + const { interactive } = options; + const reporter = createCliProgressReporter(); + const groupedCompilerErrors: Extract[] = []; + const groupedWarnings: Extract[] = []; + let pendingTransientRuntimeLine: string | null = null; + let diagnosticBaseDir: string | null = null; + let hasDurableRuntimeContent = false; + + function writeDurable(text: string): void { + reporter.clear(); + pendingTransientRuntimeLine = null; + hasDurableRuntimeContent = true; + process.stdout.write(`${formatCliTextBlock(text)}\n`); + } + + function writeSection(text: string): void { + reporter.clear(); + pendingTransientRuntimeLine = null; + process.stdout.write(`\n${formatCliTextBlock(text)}\n`); + } + + function flushPendingTransientRuntimeLine(): void { + if (!pendingTransientRuntimeLine) { + return; + } + + const line = pendingTransientRuntimeLine; + writeDurable(line); + } + + return { + onEvent(event: XcodebuildEvent): void { + switch (event.type) { + case 'start': { + diagnosticBaseDir = deriveDiagnosticBaseDir(event); + hasDurableRuntimeContent = false; + writeSection(formatStartEvent(event)); + break; + } + + case 'status': { + if (interactive) { + pendingTransientRuntimeLine = formatStatusEvent(event); + reporter.update(formatTransientStatusEvent(event)); + } else { + writeDurable(formatStatusEvent(event)); + } + break; + } + + case 'notice': { + const transientNotice = interactive ? formatTransientNoticeEvent(event) : null; + if (transientNotice) { + pendingTransientRuntimeLine = formatNoticeEvent(event); + reporter.update(transientNotice); + break; + } + + writeDurable(formatNoticeEvent(event)); + break; + } + + case 'warning': { + groupedWarnings.push(event); + break; + } + + case 'error': { + groupedCompilerErrors.push(event); + break; + } + + case 'test-discovery': { + break; + } + + case 'test-progress': { + const failWord = event.failed === 1 ? 'failure' : 'failures'; + if (interactive) { + pendingTransientRuntimeLine = null; + reporter.update(`Running tests (${event.completed}, ${event.failed} ${failWord})`); + } + break; + } + + case 'test-failure': { + flushPendingTransientRuntimeLine(); + writeDurable(formatTestFailureEvent(event, { baseDir: diagnosticBaseDir ?? undefined })); + break; + } + + case 'summary': { + const diagOpts = { baseDir: diagnosticBaseDir ?? undefined }; + const diagnosticSections: string[] = []; + + if (groupedWarnings.length > 0) { + diagnosticSections.push(formatGroupedWarnings(groupedWarnings, diagOpts)); + groupedWarnings.length = 0; + } + + if (event.status === 'FAILED' && groupedCompilerErrors.length > 0) { + diagnosticSections.push(formatGroupedCompilerErrors(groupedCompilerErrors, diagOpts)); + groupedCompilerErrors.length = 0; + } + + if (diagnosticSections.length > 0) { + const diagnosticsBlock = diagnosticSections.join('\n\n'); + if (pendingTransientRuntimeLine) { + writeSection(`${pendingTransientRuntimeLine}\n\n${diagnosticsBlock}`); + pendingTransientRuntimeLine = null; + } else if (hasDurableRuntimeContent) { + writeSection(diagnosticsBlock); + } else { + writeDurable(diagnosticsBlock); + } + } else if (event.status === 'FAILED') { + flushPendingTransientRuntimeLine(); + } + + writeSection(formatSummaryEvent(event)); + break; + } + + case 'next-steps': { + writeSection(formatNextStepsEvent(event, 'cli')); + break; + } + } + }, + + finalize(): void { + reporter.clear(); + pendingTransientRuntimeLine = null; + diagnosticBaseDir = null; + hasDurableRuntimeContent = false; + }, + }; +} diff --git a/src/utils/renderers/event-formatting.ts b/src/utils/renderers/event-formatting.ts new file mode 100644 index 00000000..0f473da1 --- /dev/null +++ b/src/utils/renderers/event-formatting.ts @@ -0,0 +1,421 @@ +import { existsSync } from 'node:fs'; +import path from 'node:path'; +import { globSync } from 'glob'; +import type { ErrorEvent, WarningEvent, XcodebuildEvent } from '../../types/xcodebuild-events.ts'; +import { renderNextStepsSection } from '../responses/next-steps-renderer.ts'; + +function formatDetailTree(details: Array<{ label: string; value: string }>): string[] { + return details.map((detail, index) => { + const branch = index === details.length - 1 ? 'โ””' : 'โ”œ'; + return ` ${branch} ${detail.label}: ${detail.value}`; + }); +} + +const FILE_DIAGNOSTIC_REGEX = + /^(?.+?):(?\d+)(?::(?\d+))?:\s*(?warning|error):\s*(?.+)$/i; +const TOOLCHAIN_DIAGNOSTIC_REGEX = /^(warning|error):\s+.+$/i; +const LINKER_DIAGNOSTIC_REGEX = /^(ld|clang|swiftc):\s+(warning|error):\s+.+$/i; +const DIAGNOSTIC_PATH_IGNORE_PATTERNS = [ + '**/.git/**', + '**/node_modules/**', + '**/build/**', + '**/dist/**', + '**/DerivedData/**', +]; +const resolvedDiagnosticPathCache = new Map(); + +export interface GroupedDiagnosticEntry { + message: string; + location?: string; +} + +export interface DiagnosticFormattingOptions { + baseDir?: string; +} + +function resolveDiagnosticPathCandidate( + filePath: string, + options?: DiagnosticFormattingOptions, +): string { + if (path.isAbsolute(filePath) || !options?.baseDir) { + return filePath; + } + + const directCandidate = path.resolve(options.baseDir, filePath); + if (existsSync(directCandidate)) { + return directCandidate; + } + + if (filePath.includes('/') || filePath.includes(path.sep)) { + return filePath; + } + + const cacheKey = `${options.baseDir}::${filePath}`; + const cached = resolvedDiagnosticPathCache.get(cacheKey); + if (cached !== undefined) { + return cached ?? filePath; + } + + const matches = globSync(`**/${filePath}`, { + cwd: options.baseDir, + nodir: true, + ignore: DIAGNOSTIC_PATH_IGNORE_PATTERNS, + }); + + if (matches.length === 1) { + const resolvedMatch = path.resolve(options.baseDir, matches[0]); + resolvedDiagnosticPathCache.set(cacheKey, resolvedMatch); + return resolvedMatch; + } + + resolvedDiagnosticPathCache.set(cacheKey, null); + return filePath; +} + +function formatDiagnosticFilePath(filePath: string, options?: DiagnosticFormattingOptions): string { + const candidate = resolveDiagnosticPathCandidate(filePath, options); + if (!path.isAbsolute(candidate)) { + return candidate; + } + + const relative = path.relative(process.cwd(), candidate); + if (relative !== '' && !relative.startsWith('..') && !path.isAbsolute(relative)) { + return relative; + } + + return candidate; +} + +function parseHumanDiagnostic( + event: WarningEvent | ErrorEvent, + kind: 'warning' | 'error', + options?: DiagnosticFormattingOptions, +): GroupedDiagnosticEntry { + const rawLine = event.rawLine.trim(); + const fileMatch = FILE_DIAGNOSTIC_REGEX.exec(rawLine); + + if (fileMatch?.groups) { + const filePath = formatDiagnosticFilePath(fileMatch.groups.file, options); + const line = fileMatch.groups.line; + const column = fileMatch.groups.column; + const message = fileMatch.groups.message; + const location = column ? `${filePath}:${line}:${column}` : `${filePath}:${line}`; + return { message: `${kind}: ${message}`, location }; + } + + if (TOOLCHAIN_DIAGNOSTIC_REGEX.test(rawLine) || LINKER_DIAGNOSTIC_REGEX.test(rawLine)) { + return { message: `${kind}: ${event.message}` }; + } + + if (event.location) { + return { message: `${event.location}: ${kind}: ${event.message}` }; + } + + return { message: `${kind}: ${event.message}` }; +} + +function isBuildRunStepNotice( + event: Extract, +): event is Extract & { + code: 'build-run-step'; + data: { step: string; status: string; appPath?: string }; +} { + return event.code === 'build-run-step' && typeof event.data === 'object' && event.data !== null; +} + +function isBuildRunResultNotice( + event: Extract, +): event is Extract & { + code: 'build-run-result'; + data: { scheme: string; platform: string; target: string; appPath: string; launchState: string }; +} { + return event.code === 'build-run-result' && typeof event.data === 'object' && event.data !== null; +} + +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'; + } +} + +export function extractGroupedCompilerError( + event: ErrorEvent, + options?: DiagnosticFormattingOptions, +): GroupedDiagnosticEntry | null { + const firstRawLine = event.rawLine.split('\n')[0].trim(); + const fileMatch = FILE_DIAGNOSTIC_REGEX.exec(firstRawLine); + + if (fileMatch?.groups) { + const filePath = formatDiagnosticFilePath(fileMatch.groups.file, options); + const line = fileMatch.groups.line; + const column = fileMatch.groups.column; + const location = column ? `${filePath}:${line}:${column}` : `${filePath}:${line}`; + return { message: event.message, location }; + } + + if (event.location) { + const locParts = event.location.match(/^(.+?)(:(?:\d+)(?::\d+)?)$/); + if (locParts) { + const filePath = formatDiagnosticFilePath(locParts[1], options); + return { message: event.message, location: `${filePath}${locParts[2]}` }; + } + return { message: event.message, location: event.location }; + } + + return null; +} + +export function formatGroupedCompilerErrors( + events: ErrorEvent[], + options?: DiagnosticFormattingOptions, +): string { + const hasFileLocated = events.some((e) => extractGroupedCompilerError(e, options) !== null); + const heading = hasFileLocated + ? `Compiler Errors (${events.length}):` + : `Errors (${events.length}):`; + const lines = [heading, '']; + + for (const event of events) { + const fileDiagnostic = extractGroupedCompilerError(event, options); + if (fileDiagnostic) { + lines.push(` โœ— ${fileDiagnostic.message}`); + if (fileDiagnostic.location) { + lines.push(` ${fileDiagnostic.location}`); + } + } else { + const messageLines = event.message.split('\n'); + lines.push(` โœ— ${messageLines[0]}`); + for (let i = 1; i < messageLines.length; i++) { + lines.push(` ${messageLines[i]}`); + } + } + lines.push(''); + } + + while (lines.at(-1) === '') { + lines.pop(); + } + + return lines.join('\n'); +} + +export function formatStartEvent(event: Extract): string { + return event.message; +} + +export function formatStatusEvent(event: Extract): string { + switch (event.stage) { + case 'RESOLVING_PACKAGES': + return 'โ€บ Resolving packages'; + case 'COMPILING': + return 'โ€บ Compiling'; + case 'LINKING': + return 'โ€บ Linking'; + case 'PREPARING_TESTS': + return 'โ€บ Preparing tests'; + case 'RUN_TESTS': + return 'โ€บ Running tests'; + case 'ARCHIVING': + return 'โ€บ Archiving'; + case 'COMPLETED': + return event.message; + } +} + +export function formatTransientStatusEvent( + event: Extract, +): string { + switch (event.stage) { + case 'RESOLVING_PACKAGES': + return 'Resolving packages...'; + case 'COMPILING': + return 'Compiling...'; + case 'LINKING': + return 'Linking...'; + case 'PREPARING_TESTS': + return 'Preparing tests...'; + case 'RUN_TESTS': + return 'Running tests...'; + case 'ARCHIVING': + return 'Archiving...'; + case 'COMPLETED': + return event.message; + } +} + +export function formatHumanWarningEvent( + event: Extract, + options?: DiagnosticFormattingOptions, +): string { + const diagnostic = parseHumanDiagnostic(event, 'warning', options); + const lines = [` \u{26A0} ${event.message}`]; + if (diagnostic.location) { + lines.push(` ${diagnostic.location}`); + } + return lines.join('\n'); +} + +export function formatGroupedWarnings( + events: Extract[], + options?: DiagnosticFormattingOptions, +): string { + const heading = `Warnings (${events.length}):`; + const lines = [heading, '']; + + for (const event of events) { + const diagnostic = parseHumanDiagnostic(event, 'warning', options); + lines.push(` \u{26A0} ${event.message}`); + if (diagnostic.location) { + lines.push(` ${diagnostic.location}`); + } + lines.push(''); + } + + while (lines.at(-1) === '') { + lines.pop(); + } + + return lines.join('\n'); +} + +export function formatHumanErrorEvent( + event: Extract, + options?: DiagnosticFormattingOptions, +): string { + const diagnostic = parseHumanDiagnostic(event, 'error', options); + return diagnostic.location + ? [diagnostic.message, ` ${diagnostic.location}`].join('\n') + : diagnostic.message; +} + +export function formatNoticeEvent(event: Extract): string { + if (isBuildRunStepNotice(event)) { + const stepLabel = formatBuildRunStepLabel(event.data.step); + return event.data.status === 'succeeded' ? `โœ“ ${stepLabel}` : `โ€บ ${stepLabel}`; + } + + if (isBuildRunResultNotice(event)) { + const details = [{ label: 'App Path', value: event.data.appPath }]; + + if ('bundleId' in event.data && typeof event.data.bundleId === 'string') { + details.push({ label: 'Bundle ID', value: event.data.bundleId }); + } + + if ('appId' in event.data && typeof event.data.appId === 'string') { + details.push({ label: 'App ID', value: event.data.appId }); + } + + if ('processId' in event.data && typeof event.data.processId === 'number') { + details.push({ label: 'Process ID', value: String(event.data.processId) }); + } + + if (event.data.launchState !== 'requested') { + details.push({ label: 'Launch', value: 'Running' }); + } + + return ['โœ… Build & Run complete', '', ...formatDetailTree(details)].join('\n'); + } + + switch (event.level) { + case 'success': + return `\u{2705} ${event.message}`; + case 'warning': + return `\u{26A0}\u{FE0F} ${event.message}`; + default: + return `\u{2139}\u{FE0F} ${event.message}`; + } +} + +export function formatTransientNoticeEvent( + event: Extract, +): string | null { + if (!isBuildRunStepNotice(event) || event.data.status !== 'started') { + return null; + } + + const stepLabel = formatBuildRunStepLabel(event.data.step); + return `${stepLabel}...`; +} + +export function formatTestFailureEvent( + event: Extract, + options?: DiagnosticFormattingOptions, +): string { + const parts: string[] = []; + if (event.suite) { + parts.push(event.suite); + } + if (event.test) { + parts.push(event.test); + } + const testPath = parts.length > 0 ? `${parts.join('/')}: ` : ''; + const lines = [` \u{2717} ${testPath}${event.message}`]; + if (event.location) { + const locParts = event.location.match(/^(.+?)(:(?:\d+)(?::\d+)?)$/); + if (locParts) { + const formattedPath = formatDiagnosticFilePath(locParts[1], options); + lines.push(` ${formattedPath}${locParts[2]}`); + } else { + lines.push(` ${event.location}`); + } + } + return lines.join('\n'); +} + +export function formatSummaryEvent(event: Extract): string { + const op = event.operation[0] + event.operation.slice(1).toLowerCase(); + const succeeded = event.status === 'SUCCEEDED'; + const statusEmoji = succeeded ? '\u{2705}' : '\u{274C}'; + const statusWord = succeeded ? 'succeeded' : 'failed'; + + const details: string[] = []; + + if (event.totalTests !== undefined) { + details.push(`Total: ${event.totalTests}`); + if (event.passedTests !== undefined) { + details.push(`Passed: ${event.passedTests}`); + } + if (event.failedTests !== undefined && event.failedTests > 0) { + details.push(`Failed: ${event.failedTests}`); + } + if (event.skippedTests !== undefined && event.skippedTests > 0) { + details.push(`Skipped: ${event.skippedTests}`); + } + } + + if (event.durationMs !== undefined) { + const seconds = (event.durationMs / 1000).toFixed(1); + details.push(`\u{23F1}\u{FE0F} ${seconds}s`); + } + + const detailsSuffix = details.length > 0 ? ` (${details.join(', ')})` : ''; + return `${statusEmoji} ${op} ${statusWord}.${detailsSuffix}`; +} + +export function formatTestDiscoveryEvent( + event: Extract, +): string { + const testList = event.tests.join(', '); + const truncation = event.truncated ? ` (and more)` : ''; + return `Discovered ${event.total} test(s): ${testList}${truncation}`; +} + +export function formatNextStepsEvent( + event: Extract, + runtime: 'cli' | 'mcp', +): string { + return renderNextStepsSection(event.steps, runtime); +} diff --git a/src/utils/renderers/index.ts b/src/utils/renderers/index.ts new file mode 100644 index 00000000..963b9ea7 --- /dev/null +++ b/src/utils/renderers/index.ts @@ -0,0 +1,31 @@ +import path from 'node:path'; +import type { StartEvent, XcodebuildEvent } from '../../types/xcodebuild-events.ts'; + +export interface XcodebuildRenderer { + onEvent(event: XcodebuildEvent): void; + finalize(): void; +} + +export function deriveDiagnosticBaseDir(event: StartEvent): string | null { + let paramsProjectPath: string | null = null; + if (typeof event.params.projectPath === 'string') { + paramsProjectPath = event.params.projectPath; + } else if (typeof event.params.workspacePath === 'string') { + paramsProjectPath = event.params.workspacePath; + } + + if (paramsProjectPath) { + return path.dirname(path.resolve(process.cwd(), paramsProjectPath)); + } + + const messageMatch = event.message.match(/^ (Project|Workspace): (.+)$/mu); + if (!messageMatch) { + return null; + } + + return path.dirname(path.resolve(process.cwd(), messageMatch[2])); +} + +export { createMcpRenderer } from './mcp-renderer.ts'; +export { createCliTextRenderer } from './cli-text-renderer.ts'; +export { createCliJsonlRenderer } from './cli-jsonl-renderer.ts'; diff --git a/src/utils/renderers/mcp-renderer.ts b/src/utils/renderers/mcp-renderer.ts new file mode 100644 index 00000000..79bbd5fa --- /dev/null +++ b/src/utils/renderers/mcp-renderer.ts @@ -0,0 +1,115 @@ +import type { XcodebuildEvent } from '../../types/xcodebuild-events.ts'; +import type { ToolResponseContent } from '../../types/common.ts'; +import { sessionStore } from '../session-store.ts'; +import { deriveDiagnosticBaseDir } from './index.ts'; +import type { XcodebuildRenderer } from './index.ts'; +import { + formatStartEvent, + formatStatusEvent, + formatNoticeEvent, + formatGroupedCompilerErrors, + formatGroupedWarnings, + formatTestFailureEvent, + formatTestDiscoveryEvent, + formatSummaryEvent, + formatNextStepsEvent, +} from './event-formatting.ts'; + +export function createMcpRenderer(): XcodebuildRenderer & { + getContent(): ToolResponseContent[]; +} { + const content: ToolResponseContent[] = []; + const suppressWarnings = sessionStore.get('suppressWarnings'); + const groupedCompilerErrors: Extract[] = []; + const groupedWarnings: Extract[] = []; + let diagnosticBaseDir: string | null = null; + + function pushText(text: string): void { + content.push({ type: 'text', text }); + } + + function pushSection(text: string): void { + pushText(`\n${text}`); + } + + return { + onEvent(event: XcodebuildEvent): void { + switch (event.type) { + case 'start': { + diagnosticBaseDir = deriveDiagnosticBaseDir(event); + pushSection(formatStartEvent(event)); + break; + } + + case 'status': { + pushText(formatStatusEvent(event)); + break; + } + + case 'notice': { + pushText(formatNoticeEvent(event)); + break; + } + + case 'warning': { + if (suppressWarnings) { + return; + } + groupedWarnings.push(event); + break; + } + + case 'error': { + groupedCompilerErrors.push(event); + break; + } + + case 'test-discovery': { + pushText(formatTestDiscoveryEvent(event)); + break; + } + + case 'test-progress': { + break; + } + + case 'test-failure': { + pushText(formatTestFailureEvent(event, { baseDir: diagnosticBaseDir ?? undefined })); + break; + } + + case 'summary': { + const diagOpts = { baseDir: diagnosticBaseDir ?? undefined }; + const diagnosticSections: string[] = []; + if (groupedWarnings.length > 0) { + diagnosticSections.push(formatGroupedWarnings(groupedWarnings, diagOpts)); + groupedWarnings.length = 0; + } + if (event.status === 'FAILED' && groupedCompilerErrors.length > 0) { + diagnosticSections.push(formatGroupedCompilerErrors(groupedCompilerErrors, diagOpts)); + groupedCompilerErrors.length = 0; + } + if (diagnosticSections.length > 0) { + pushSection(diagnosticSections.join('\n\n')); + } + + pushSection(formatSummaryEvent(event)); + break; + } + + case 'next-steps': { + pushSection(formatNextStepsEvent(event, 'mcp')); + break; + } + } + }, + + finalize(): void { + diagnosticBaseDir = null; + }, + + getContent(): ToolResponseContent[] { + return [...content]; + }, + }; +} diff --git a/src/utils/responses/__tests__/next-steps-renderer.test.ts b/src/utils/responses/__tests__/next-steps-renderer.test.ts index acac43d8..b1e807eb 100644 --- a/src/utils/responses/__tests__/next-steps-renderer.test.ts +++ b/src/utils/responses/__tests__/next-steps-renderer.test.ts @@ -180,7 +180,7 @@ describe('next-steps-renderer', () => { const result = renderNextStepsSection(steps, 'cli'); expect(result).toBe( - '\n\nNext steps:\n' + + 'Next steps:\n' + '1. Open Simulator: xcodebuildmcp open-sim\n' + '2. Install app: xcodebuildmcp install-app-sim --simulator-id "X"', ); @@ -194,7 +194,7 @@ describe('next-steps-renderer', () => { const result = renderNextStepsSection(steps, 'mcp'); expect(result).toBe( - '\n\nNext steps:\n' + + 'Next steps:\n' + '1. Open Simulator: open_sim()\n' + '2. Install app: install_app_sim({ simulatorId: "X" })', ); diff --git a/src/utils/responses/next-steps-renderer.ts b/src/utils/responses/next-steps-renderer.ts index 4ad71e46..3ab4db6a 100644 --- a/src/utils/responses/next-steps-renderer.ts +++ b/src/utils/responses/next-steps-renderer.ts @@ -105,7 +105,7 @@ export function renderNextStepsSection(steps: NextStep[], runtime: RuntimeKind): const sorted = [...steps].sort((a, b) => (a.priority ?? 0) - (b.priority ?? 0)); const lines = sorted.map((step, index) => `${index + 1}. ${renderNextStep(step, runtime)}`); - return `\n\nNext steps:\n${lines.join('\n')}`; + return `Next steps:\n${lines.join('\n')}`; } /** @@ -134,7 +134,7 @@ export function processToolResponse( // Append to the last text content item const processedContent = response.content.map((item, index) => { if (item.type === 'text' && index === response.content.length - 1) { - return { ...item, text: item.text + nextStepsSection }; + return { ...item, text: `${item.text}\n\n${nextStepsSection}` }; } return item; }); @@ -142,7 +142,7 @@ export function processToolResponse( // If no text content existed, add one with just the next steps const hasTextContent = response.content.some((item) => item.type === 'text'); if (!hasTextContent && nextStepsSection) { - processedContent.push({ type: 'text', text: nextStepsSection.trim() }); + processedContent.push({ type: 'text', text: nextStepsSection }); } return { ...rest, content: processedContent }; 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/simulator-utils.ts b/src/utils/simulator-utils.ts index 547cebf2..70ba5850 100644 --- a/src/utils/simulator-utils.ts +++ b/src/utils/simulator-utils.ts @@ -12,6 +12,49 @@ import { createErrorResponse } from './responses/index.ts'; */ const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; +export async function validateAvailableSimulatorId( + simulatorId: string, + executor: CommandExecutor, +): Promise<{ error?: ToolResponse }> { + const listResult = await executor( + ['xcrun', 'simctl', 'list', 'devices', 'available', '-j'], + 'List available simulators', + ); + + if (!listResult.success) { + return { + error: createErrorResponse('Failed to list simulators', listResult.error ?? 'Unknown error'), + }; + } + + try { + const devicesData = JSON.parse(listResult.output ?? '{}') as { + devices: Record>; + }; + const matchedDevice = Object.values(devicesData.devices) + .flat() + .find((device) => device.udid === simulatorId && device.isAvailable === true); + + if (matchedDevice) { + return {}; + } + + return { + error: createErrorResponse( + `No available simulator matched: ${simulatorId}`, + 'Tip: run "xcrun simctl list devices available" to see names and UDIDs.', + ), + }; + } catch (parseError) { + return { + error: createErrorResponse( + 'Failed to parse simulator list', + parseError instanceof Error ? parseError.message : String(parseError), + ), + }; + } +} + /** * Determines the simulator UUID from either a UUID or name. * 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/terminal-output.ts b/src/utils/terminal-output.ts new file mode 100644 index 00000000..092b4794 --- /dev/null +++ b/src/utils/terminal-output.ts @@ -0,0 +1,60 @@ +const ANSI_RESET = '\u001B[0m'; +const ANSI_RED = '\u001B[31m'; +const ANSI_YELLOW = '\u001B[33m'; + +let cachedUseCliColor: boolean | undefined; + +function shouldUseCliColor(): boolean { + if (cachedUseCliColor === undefined) { + cachedUseCliColor = process.stdout.isTTY === true && process.env.NO_COLOR === undefined; + } + return cachedUseCliColor; +} + +function colorRed(text: string): string { + return `${ANSI_RED}${text}${ANSI_RESET}`; +} + +function colorYellow(text: string): string { + return `${ANSI_YELLOW}${text}${ANSI_RESET}`; +} + +function colorizeWarningTriangleLine(line: string): string { + return line.replace(/^(\s*)(โš  )/u, (_match, indent: string, prefix: string) => { + return `${indent}${colorYellow(prefix)}`; + }); +} + +function colorizeSummaryCrossLine(line: string): string { + return line.replace(/^(\s*)(โœ— )/u, (_match, indent: string, prefix: string) => { + return `${indent}${colorRed(prefix)}`; + }); +} + +function colorizeFailureIconLine(line: string): string { + return line.replace(/^(โŒ )/u, (_match, prefix: string) => colorRed(prefix)); +} + +export function formatCliTextLine(line: string): string { + if (!shouldUseCliColor()) { + return line; + } + + if (/^\s*(?:.*:\s+)?(?:fatal )?error:\s/iu.test(line)) { + return colorRed(line); + } + + if (/^\s*โš  /u.test(line)) { + return colorizeWarningTriangleLine(line); + } + + if (/^\s*โœ— /u.test(line)) { + return colorizeSummaryCrossLine(line); + } + + if (/^โŒ /u.test(line)) { + return colorizeFailureIconLine(line); + } + + return line; +} diff --git a/src/utils/test-common.ts b/src/utils/test-common.ts index 59aa6653..c4c1222b 100644 --- a/src/utils/test-common.ts +++ b/src/utils/test-common.ts @@ -2,17 +2,13 @@ * 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'; @@ -20,39 +16,16 @@ import { createTextResponse } from './validation.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; - }>; -} +import { getDefaultCommandExecutor } from './command.ts'; +import { + formatTestDiscovery, + collectResolvedTestSelectors, + type TestPreflightResult, +} from './test-preflight.ts'; +import { formatToolPreflight } from './build-preflight.ts'; +import { createSimulatorTwoPhaseExecutionPlan } from './simulator-test-execution.ts'; +import { startBuildPipeline } from './xcodebuild-pipeline.ts'; +import { createPendingXcodebuildResponse } from './xcodebuild-output.ts'; export function resolveTestProgressEnabled(progress: boolean | undefined): boolean { if (typeof progress === 'boolean') { @@ -62,103 +35,6 @@ export function resolveTestProgressEnabled(progress: boolean | undefined): boole return process.env.XCODEBUILDMCP_RUNTIME === 'mcp'; } -/** - * 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'); - } - - // 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'); -} - /** * Internal logic for running tests with platform-specific handling */ @@ -172,6 +48,7 @@ export async function handleTestLogic( simulatorId?: string; deviceId?: string; useLatestOS?: boolean; + packageCachePath?: string; derivedDataPath?: string; extraArgs?: string[]; preferXcodebuild?: boolean; @@ -180,7 +57,10 @@ export async function handleTestLogic( progress?: boolean; }, executor: CommandExecutor = getDefaultCommandExecutor(), - fileSystemExecutor: FileSystemExecutor = getDefaultFileSystemExecutor(), + options?: { + preflight?: TestPreflightResult; + toolName?: string; + }, ): Promise { log( 'info', @@ -188,97 +68,130 @@ export async function handleTestLogic( ); try { - // Create temporary directory for xcresult bundle - const tempDir = await fileSystemExecutor.mkdtemp( - join(fileSystemExecutor.tmpdir(), 'xcodebuild-test-'), - ); - const resultBundlePath = join(tempDir, 'TestResults.xcresult'); - - const progress = resolveTestProgressEnabled(params.progress); - - // 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, - simulatorName: params.simulatorName, - simulatorId: params.simulatorId, - deviceId: params.deviceId, - useLatestOS: params.useLatestOS, - logPrefix: 'Test Run', - showTestProgress: progress, - }, - 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}`); + const isSimulatorPlatform = String(params.platform).includes('Simulator'); + const shouldUseTwoPhaseSimulatorExecution = isSimulatorPlatform && Boolean(options?.preflight); + + const resolvedToolName = options?.toolName ?? 'test_sim'; + + 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, + }); - // 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 discoveryText = options?.preflight ? formatTestDiscovery(options.preflight) : undefined; - const xcresult = await parseXcresultBundle(resultBundlePath, executor); - log('info', 'Successfully parsed xcresult bundle'); + const preflightText = discoveryText ? `${configText}\n${discoveryText}` : configText; - // Clean up temporary directory - await fileSystemExecutor.rm(tempDir, { recursive: true, force: true }); + const started = startBuildPipeline({ + operation: 'TEST', + toolName: resolvedToolName, + params: { + scheme: params.scheme, + configuration: params.configuration, + platform: String(params.platform), + preflight: preflightText, + }, + message: preflightText, + }); - // 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; - } + 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, + }); + } - // 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, - }; + const platformOptions = { + platform: params.platform, + simulatorName: params.simulatorName, + simulatorId: params.simulatorId, + deviceId: params.deviceId, + useLatestOS: params.useLatestOS, + packageCachePath: params.packageCachePath, + logPrefix: 'Test Run', + }; - return combinedResponse; - } catch (parseError) { - // If parsing fails, return original test result - log('warn', `Failed to parse xcresult bundle: ${parseError}`); + const preflightExtras = options?.preflight ? { testPreflight: options.preflight } : {}; + + 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, + ); - // 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}`); + if (buildForTestingResult.isError) { + return createPendingXcodebuildResponse(started, buildForTestingResult, { + errorFallbackPolicy: 'if-no-structured-diagnostics', + extras: preflightExtras, + }); } - return testResult; + pipeline.emitEvent({ + type: 'status', + 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, + ); + + return createPendingXcodebuildResponse(started, testWithoutBuildingResult, { + extras: preflightExtras, + }); } + + const singlePhaseResult = await executeXcodeBuildCommand( + params, + platformOptions, + params.preferXcodebuild, + 'test', + executor, + execOpts, + pipeline, + ); + + return createPendingXcodebuildResponse(started, singlePhaseResult, { + extras: preflightExtras, + }); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); log('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/xcodebuild-error-utils.ts b/src/utils/xcodebuild-error-utils.ts new file mode 100644 index 00000000..3650ec5e --- /dev/null +++ b/src/utils/xcodebuild-error-utils.ts @@ -0,0 +1,46 @@ +/** + * Utilities for parsing and formatting xcodebuild error output. + * + * Used by query tools (list-schemes, show-build-settings, get-app-path) + * that run xcodebuild commands but don't use the full streaming pipeline. + */ + +const XCODEBUILD_ERROR_REGEX = /^xcodebuild:\s*error:\s*(.+)$/im; +const NOISE_PATTERNS = [ + /^\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2}\.\d+\s+xcodebuild\[/, + /^Writing error result bundle to\s/i, +]; + +export function parseXcodebuildErrorMessage(rawOutput: string): string | null { + const match = XCODEBUILD_ERROR_REGEX.exec(rawOutput); + return match ? match[1].trim() : null; +} + +export function cleanXcodebuildOutput(rawOutput: string): string { + return rawOutput + .split('\n') + .filter((line) => !NOISE_PATTERNS.some((pattern) => pattern.test(line.trim()))) + .join('\n') + .trim(); +} + +export function formatQueryError(rawOutput: string): string { + const parsed = parseXcodebuildErrorMessage(rawOutput); + if (parsed) { + return [`Errors (1):`, '', ` \u{2717} ${parsed}`].join('\n'); + } + + const cleaned = cleanXcodebuildOutput(rawOutput); + if (cleaned) { + const errorLines = cleaned.split('\n').filter((l) => l.trim()); + const count = errorLines.length; + const formatted = errorLines.map((l) => ` \u{2717} ${l.trim()}`).join('\n\n'); + return [`Errors (${count}):`, '', formatted].join('\n'); + } + + return ['Errors (1):', '', ' \u{2717} Unknown error'].join('\n'); +} + +export function formatQueryFailureSummary(): string { + return '\u{274C} Query failed.'; +} diff --git a/src/utils/xcodebuild-event-parser.ts b/src/utils/xcodebuild-event-parser.ts new file mode 100644 index 00000000..c7585b78 --- /dev/null +++ b/src/utils/xcodebuild-event-parser.ts @@ -0,0 +1,258 @@ +import type { + XcodebuildOperation, + XcodebuildEvent, + XcodebuildStage, +} from '../types/xcodebuild-events.ts'; +import { + packageResolutionPatterns, + compilePatterns, + linkPatterns, + parseTestCaseLine, + parseTotalsLine, + parseFailureDiagnostic, + parseBuildErrorDiagnostic, +} from './xcodebuild-line-parsers.ts'; + +function resolveStageFromLine(line: string): XcodebuildStage | null { + if (packageResolutionPatterns.some((pattern) => pattern.test(line))) { + return 'RESOLVING_PACKAGES'; + } + if (compilePatterns.some((pattern) => pattern.test(line))) { + return 'COMPILING'; + } + if (linkPatterns.some((pattern) => pattern.test(line))) { + return 'LINKING'; + } + if (/^Testing started$/u.test(line) || /^Test Suite .+ started/u.test(line)) { + return 'RUN_TESTS'; + } + return null; +} + +const stageMessages: Record = { + RESOLVING_PACKAGES: 'Resolving packages', + COMPILING: 'Compiling', + LINKING: 'Linking', + PREPARING_TESTS: 'Preparing tests', + RUN_TESTS: 'Running tests', + ARCHIVING: 'Archiving', + COMPLETED: 'Completed', +}; + +function parseWarningLine(line: string): { location?: string; message: string } | null { + const locationMatch = line.match(/^(.*?):(\d+)(?::\d+)?:\s+warning:\s+(.+)$/u); + if (locationMatch) { + return { + location: `${locationMatch[1]}:${locationMatch[2]}`, + message: locationMatch[3], + }; + } + + const prefixedMatch = line.match(/^(?:[\w-]+:\s+)?warning:\s+(.+)$/iu); + if (prefixedMatch) { + return { message: prefixedMatch[1] }; + } + + return null; +} + +function now(): string { + return new Date().toISOString(); +} + +export interface EventParserOptions { + operation: XcodebuildOperation; + onEvent: (event: XcodebuildEvent) => void; +} + +export interface XcodebuildEventParser { + onStdout(chunk: string): void; + onStderr(chunk: string): void; + flush(): void; +} + +export function createXcodebuildEventParser(options: EventParserOptions): XcodebuildEventParser { + const { operation, onEvent } = options; + + let stdoutBuffer = ''; + let stderrBuffer = ''; + let completedCount = 0; + let failedCount = 0; + let skippedCount = 0; + + let pendingError: { + message: string; + location?: string; + rawLines: string[]; + timestamp: string; + } | null = null; + + function flushPendingError(): void { + if (!pendingError) { + return; + } + onEvent({ + type: 'error', + timestamp: pendingError.timestamp, + operation, + message: pendingError.message, + location: pendingError.location, + rawLine: pendingError.rawLines.join('\n'), + }); + pendingError = null; + } + + function processLine(rawLine: string): void { + const line = rawLine.trim(); + if (!line) { + flushPendingError(); + return; + } + + // Indented lines following a build error are continuations + if (pendingError && /^\s/u.test(rawLine)) { + pendingError.message += `\n${line}`; + pendingError.rawLines.push(rawLine); + return; + } + + flushPendingError(); + + const testCase = parseTestCaseLine(line); + if (testCase) { + completedCount += 1; + if (testCase.status === 'failed') { + failedCount += 1; + } + if (testCase.status === 'skipped') { + skippedCount += 1; + } + + if (operation === 'TEST') { + onEvent({ + type: 'test-progress', + timestamp: now(), + operation: 'TEST', + completed: completedCount, + failed: failedCount, + skipped: skippedCount, + }); + } + return; + } + + const totals = parseTotalsLine(line); + if (totals) { + completedCount = totals.executed; + failedCount = totals.failed; + + if (operation === 'TEST') { + onEvent({ + type: 'test-progress', + timestamp: now(), + operation: 'TEST', + completed: completedCount, + failed: failedCount, + skipped: skippedCount, + }); + } + return; + } + + const failureDiag = parseFailureDiagnostic(line); + if (failureDiag) { + if (operation === 'TEST') { + onEvent({ + type: 'test-failure', + timestamp: now(), + operation: 'TEST', + suite: failureDiag.suiteName, + test: failureDiag.testName, + message: failureDiag.message, + location: failureDiag.location, + }); + } + return; + } + + const stage = resolveStageFromLine(line); + if (stage) { + onEvent({ + type: 'status', + timestamp: now(), + operation, + stage, + message: stageMessages[stage], + }); + return; + } + + const buildError = parseBuildErrorDiagnostic(line); + if (buildError) { + pendingError = { + message: buildError.message, + location: buildError.location, + rawLines: [line], + timestamp: now(), + }; + return; + } + + const warning = parseWarningLine(line); + if (warning) { + onEvent({ + type: 'warning', + timestamp: now(), + operation, + message: warning.message, + location: warning.location, + rawLine: line, + }); + return; + } + + // Skip known noise lines + if (/^Test Suite /u.test(line)) { + return; + } + } + + function drainBuffer(chunk: string, source: 'stdout' | 'stderr'): void { + if (source === 'stdout') { + stdoutBuffer += chunk; + const lines = stdoutBuffer.split(/\r?\n/u); + stdoutBuffer = lines.pop() ?? ''; + for (const line of lines) { + processLine(line); + } + return; + } + + stderrBuffer += chunk; + const lines = stderrBuffer.split(/\r?\n/u); + stderrBuffer = lines.pop() ?? ''; + for (const line of lines) { + processLine(line); + } + } + + return { + onStdout(chunk: string): void { + drainBuffer(chunk, 'stdout'); + }, + onStderr(chunk: string): void { + drainBuffer(chunk, 'stderr'); + }, + flush(): void { + if (stdoutBuffer.trim()) { + processLine(stdoutBuffer); + } + if (stderrBuffer.trim()) { + processLine(stderrBuffer); + } + flushPendingError(); + stdoutBuffer = ''; + stderrBuffer = ''; + }, + }; +} diff --git a/src/utils/xcodebuild-line-parsers.ts b/src/utils/xcodebuild-line-parsers.ts new file mode 100644 index 00000000..d685c7ae --- /dev/null +++ b/src/utils/xcodebuild-line-parsers.ts @@ -0,0 +1,128 @@ +export const packageResolutionPatterns = [ + /^Resolve Package Graph$/u, + /^Resolved source packages:/u, + /^Fetching from /u, + /^Checking out /u, + /^Creating working copy /u, + /^Updating https?:\/\//u, +]; + +export const compilePatterns = [ + /^CompileSwift /u, + /^SwiftCompile /u, + /^CompileC /u, + /^ProcessInfoPlistFile /u, + /^PhaseScriptExecution /u, + /^CodeSign /u, + /^CompileAssetCatalog /u, + /^ProcessProductPackaging /u, +]; + +export const linkPatterns = [/^Ld /u]; + +export interface ParsedTestCase { + status: 'passed' | 'failed' | 'skipped'; + rawName: string; + suiteName?: string; + testName: string; + durationText?: string; +} + +export interface ParsedTotals { + executed: number; + failed: number; + durationText?: string; +} + +export interface ParsedFailureDiagnostic { + rawTestName?: string; + suiteName?: string; + testName?: string; + location?: string; + message: string; +} + +export interface ParsedBuildError { + location?: string; + message: string; + renderedLine: string; +} + +export function parseRawTestName(rawName: string): { suiteName?: string; testName: string } { + const objcMatch = rawName.match(/^-\[(.+?)\s+(.+)\]$/u); + if (objcMatch) { + return { suiteName: objcMatch[1], testName: objcMatch[2] }; + } + + const slashParts = rawName.split('/').filter(Boolean); + if (slashParts.length >= 3) { + return { suiteName: `${slashParts[0]}/${slashParts[1]}`, testName: slashParts[2] }; + } + + const dotIndex = rawName.lastIndexOf('.'); + if (dotIndex > 0) { + return { suiteName: rawName.slice(0, dotIndex), testName: rawName.slice(dotIndex + 1) }; + } + + return { testName: rawName }; +} + +export function parseTestCaseLine(line: string): ParsedTestCase | null { + const match = line.match(/^Test Case '(.+)' (passed|failed|skipped) \(([^)]+)\)/u); + if (!match) { + return null; + } + const [, rawName, status, durationText] = match; + const { suiteName, testName } = parseRawTestName(rawName); + return { + status: status as 'passed' | 'failed' | 'skipped', + rawName, + suiteName, + testName, + durationText, + }; +} + +export function parseTotalsLine(line: string): ParsedTotals | null { + const match = line.match( + /^Executed (\d+) tests?, with (\d+) failures?(?: \(\d+ unexpected\))? in (.+)$/u, + ); + if (!match) { + return null; + } + return { executed: Number(match[1]), failed: Number(match[2]), durationText: match[3] }; +} + +export function parseFailureDiagnostic(line: string): ParsedFailureDiagnostic | null { + const match = line.match(/^(.*?):(\d+): error: -\[(.+?)\s+(.+?)\] : (.+)$/u); + if (!match) { + return null; + } + const [, filePath, lineNumber, suiteName, testName, message] = match; + return { + rawTestName: `-[${suiteName} ${testName}]`, + suiteName, + testName, + location: `${filePath}:${lineNumber}`, + message, + }; +} + +export function parseBuildErrorDiagnostic(line: string): ParsedBuildError | null { + const locationMatch = line.match(/^(.*?):(\d+)(?::\d+)?: (?:fatal error|error): (.+)$/u); + if (locationMatch) { + const [, filePath, lineNumber, message] = locationMatch; + return { + location: `${filePath}:${lineNumber}`, + message, + renderedLine: line, + }; + } + + const rawMatch = line.match(/^(?:[\w-]+:\s+)?(?:fatal error|error): (.+)$/u); + if (!rawMatch) { + return null; + } + const [, message] = rawMatch; + return { message, renderedLine: line }; +} diff --git a/src/utils/xcodebuild-output.ts b/src/utils/xcodebuild-output.ts new file mode 100644 index 00000000..856441ca --- /dev/null +++ b/src/utils/xcodebuild-output.ts @@ -0,0 +1,227 @@ +import type { NextStep, ToolResponse, ToolResponseContent } from '../types/common.ts'; +import type { + BuildRunResultNoticeData, + BuildRunStepNoticeData, + NoticeCode, + NoticeLevel, + XcodebuildEvent, + XcodebuildOperation, +} from '../types/xcodebuild-events.ts'; +import type { StartedPipeline } from './xcodebuild-pipeline.ts'; + +export interface PipelineOutputMetaExtras { + [key: string]: unknown; +} + +export type XcodebuildStreamMode = 'complete' | 'legacy'; + +interface PendingXcodebuildState { + kind: 'pending-xcodebuild'; + started: StartedPipeline; + emitSummary: boolean; + extras: PipelineOutputMetaExtras; + fallbackContent: ToolResponseContent[]; + tailEvents: XcodebuildEvent[]; + errorFallbackPolicy: ErrorFallbackPolicy; +} + +export function createPipelineOutputMeta( + events: XcodebuildEvent[], + streamedContentCount: number, + extras: PipelineOutputMetaExtras = {}, + streamMode: XcodebuildStreamMode = 'legacy', +): Record { + return { + ...extras, + events, + streamedContentCount, + streamedEventCount: events.length, + xcodebuildStreamMode: streamMode, + }; +} + +export function createStructuredErrorEvent( + operation: XcodebuildOperation, + message: string, +): XcodebuildEvent { + return { + type: 'error', + timestamp: new Date().toISOString(), + operation, + message, + rawLine: message, + }; +} + +export function createNoticeEvent( + operation: XcodebuildOperation, + message: string, + level: NoticeLevel = 'info', + options: { + code?: NoticeCode; + data?: + | Record + | BuildRunStepNoticeData + | BuildRunResultNoticeData; + } = {}, +): XcodebuildEvent { + return { + type: 'notice', + timestamp: new Date().toISOString(), + operation, + level, + message, + code: options.code, + data: options.data, + }; +} + +export function createNextStepsEvent(steps: NextStep[]): XcodebuildEvent | null { + if (steps.length === 0) { + return null; + } + + return { + type: 'next-steps', + timestamp: new Date().toISOString(), + steps: steps.map((step) => ({ + label: step.label, + tool: step.tool, + workflow: step.workflow, + cliTool: step.cliTool, + params: step.params, + })), + }; +} + +export function appendStructuredEvents( + response: ToolResponse, + extraEvents: XcodebuildEvent[], +): ToolResponse { + const existingEvents = Array.isArray(response._meta?.events) + ? (response._meta.events as XcodebuildEvent[]) + : []; + + return { + ...response, + _meta: { + ...(response._meta ?? {}), + events: [...existingEvents, ...extraEvents], + }, + }; +} + +export function emitPipelineNotice( + started: StartedPipeline, + operation: XcodebuildOperation, + message: string, + level: NoticeLevel = 'info', + options: { + code?: NoticeCode; + data?: + | Record + | BuildRunStepNoticeData + | BuildRunResultNoticeData; + } = {}, +): void { + 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 type ErrorFallbackPolicy = 'always' | 'if-no-structured-diagnostics'; + +export interface PendingXcodebuildResponseOptions { + extras?: PipelineOutputMetaExtras; + emitSummary?: boolean; + tailEvents?: XcodebuildEvent[]; + errorFallbackPolicy?: ErrorFallbackPolicy; +} + +export function createPendingXcodebuildResponse( + started: StartedPipeline, + response: ToolResponse, + options: PendingXcodebuildResponseOptions = {}, +): ToolResponse { + return { + ...response, + content: [], + _meta: { + ...(response._meta ?? {}), + pendingXcodebuild: { + kind: 'pending-xcodebuild', + started, + emitSummary: options.emitSummary ?? true, + extras: options.extras ?? {}, + fallbackContent: response.isError ? response.content : [], + tailEvents: options.tailEvents ?? [], + errorFallbackPolicy: options.errorFallbackPolicy ?? 'always', + } satisfies PendingXcodebuildState, + }, + }; +} + +export function isPendingXcodebuildResponse(response: ToolResponse): boolean { + return ( + typeof response._meta === 'object' && + response._meta !== null && + 'pendingXcodebuild' in response._meta && + typeof response._meta.pendingXcodebuild === 'object' && + response._meta.pendingXcodebuild !== null && + (response._meta.pendingXcodebuild as PendingXcodebuildState).kind === 'pending-xcodebuild' + ); +} + +function getPendingXcodebuildState(response: ToolResponse): PendingXcodebuildState { + if (!isPendingXcodebuildResponse(response)) { + throw new Error('Response is not a pending xcodebuild response'); + } + + return response._meta?.pendingXcodebuild as PendingXcodebuildState; +} + +export function finalizePendingXcodebuildResponse( + response: ToolResponse, + options: { nextSteps?: NextStep[] } = {}, +): ToolResponse { + const pending = getPendingXcodebuildState(response); + const durationMs = Math.max(0, Date.now() - pending.started.startedAt); + const nextStepsEvent = + !response.isError && options.nextSteps ? createNextStepsEvent(options.nextSteps) : null; + const tailEvents = [...pending.tailEvents]; + if (nextStepsEvent) { + tailEvents.push(nextStepsEvent); + } + const pipelineResult = pending.started.pipeline.finalize(!response.isError, durationMs, { + emitSummary: pending.emitSummary, + tailEvents, + }); + + const hasStructuredDiagnostics = + pipelineResult.state.errors.length > 0 || pipelineResult.state.testFailures.length > 0; + const fallbackContent = + response.isError && + pending.errorFallbackPolicy === 'if-no-structured-diagnostics' && + hasStructuredDiagnostics + ? [] + : pending.fallbackContent; + + return { + ...response, + content: response.isError + ? [...pipelineResult.mcpContent, ...fallbackContent] + : pipelineResult.mcpContent, + _meta: createPipelineOutputMeta( + pipelineResult.events, + pipelineResult.mcpContent.length, + pending.extras, + 'complete', + ), + }; +} diff --git a/src/utils/xcodebuild-pipeline.ts b/src/utils/xcodebuild-pipeline.ts new file mode 100644 index 00000000..1e58ddec --- /dev/null +++ b/src/utils/xcodebuild-pipeline.ts @@ -0,0 +1,154 @@ +import type { + XcodebuildOperation, + XcodebuildStage, + XcodebuildEvent, +} from '../types/xcodebuild-events.ts'; +import type { ToolResponseContent } from '../types/common.ts'; +import { createXcodebuildEventParser } from './xcodebuild-event-parser.ts'; +import { createXcodebuildRunState } from './xcodebuild-run-state.ts'; +import type { XcodebuildRunState } from './xcodebuild-run-state.ts'; +import { + createMcpRenderer, + createCliTextRenderer, + createCliJsonlRenderer, +} from './renderers/index.ts'; +import type { XcodebuildRenderer } from './renderers/index.ts'; + +export interface PipelineOptions { + operation: XcodebuildOperation; + toolName: string; + params: Record; + minimumStage?: XcodebuildStage; +} + +export interface PipelineResult { + state: XcodebuildRunState; + mcpContent: ToolResponseContent[]; + events: XcodebuildEvent[]; +} + +export interface PipelineFinalizeOptions { + emitSummary?: boolean; + tailEvents?: XcodebuildEvent[]; +} + +export interface XcodebuildPipeline { + onStdout(chunk: string): void; + onStderr(chunk: string): void; + emitEvent(event: XcodebuildEvent): void; + finalize( + succeeded: boolean, + durationMs?: number, + options?: PipelineFinalizeOptions, + ): PipelineResult; + highestStageRank(): number; +} + +function resolveRenderers(): { + renderers: XcodebuildRenderer[]; + mcpRenderer: ReturnType; +} { + const mcpRenderer = createMcpRenderer(); + const renderers: XcodebuildRenderer[] = [mcpRenderer]; + + const runtime = process.env.XCODEBUILDMCP_RUNTIME; + const outputFormat = process.env.XCODEBUILDMCP_CLI_OUTPUT_FORMAT; + + if (runtime === 'cli') { + if (outputFormat === 'json') { + renderers.push(createCliJsonlRenderer()); + } else { + renderers.push(createCliTextRenderer({ interactive: process.stdout.isTTY === true })); + } + } + + return { renderers, mcpRenderer }; +} + +export interface StartedPipeline { + pipeline: XcodebuildPipeline; + startedAt: number; +} + +/** + * Creates a pipeline, emits the initial 'start' 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 pipeline = createXcodebuildPipeline(options); + + pipeline.emitEvent({ + type: 'start', + timestamp: new Date().toISOString(), + operation: options.operation, + toolName: options.toolName, + params: options.params, + message: options.message, + }); + + return { pipeline, startedAt: Date.now() }; +} + +export function createXcodebuildPipeline(options: PipelineOptions): XcodebuildPipeline { + const { renderers, mcpRenderer } = resolveRenderers(); + + const runState = createXcodebuildRunState({ + operation: options.operation, + minimumStage: options.minimumStage, + onEvent: (event: XcodebuildEvent) => { + for (const renderer of renderers) { + renderer.onEvent(event); + } + }, + }); + + const parser = createXcodebuildEventParser({ + operation: options.operation, + onEvent: (event: XcodebuildEvent) => { + runState.push(event); + }, + }); + + return { + onStdout(chunk: string): void { + parser.onStdout(chunk); + }, + + onStderr(chunk: string): void { + parser.onStderr(chunk); + }, + + emitEvent(event: XcodebuildEvent): void { + runState.push(event); + }, + + finalize( + succeeded: boolean, + durationMs?: number, + finalizeOptions?: PipelineFinalizeOptions, + ): PipelineResult { + parser.flush(); + const finalState = runState.finalize(succeeded, durationMs, { + emitSummary: finalizeOptions?.emitSummary, + tailEvents: finalizeOptions?.tailEvents, + }); + + for (const renderer of renderers) { + renderer.finalize(); + } + + return { + state: finalState, + mcpContent: mcpRenderer.getContent(), + events: finalState.events, + }; + }, + + highestStageRank(): number { + return runState.highestStageRank(); + }, + }; +} diff --git a/src/utils/xcodebuild-run-state.ts b/src/utils/xcodebuild-run-state.ts new file mode 100644 index 00000000..b3b45ac9 --- /dev/null +++ b/src/utils/xcodebuild-run-state.ts @@ -0,0 +1,201 @@ +import type { + XcodebuildOperation, + XcodebuildStage, + XcodebuildEvent, + StatusEvent, + WarningEvent, + ErrorEvent, + TestFailureEvent, +} from '../types/xcodebuild-events.ts'; +import { STAGE_RANK } from '../types/xcodebuild-events.ts'; + +export interface XcodebuildRunState { + operation: XcodebuildOperation; + currentStage: XcodebuildStage | null; + milestones: StatusEvent[]; + warnings: WarningEvent[]; + errors: ErrorEvent[]; + testFailures: TestFailureEvent[]; + completedTests: number; + failedTests: number; + skippedTests: number; + finalStatus: 'SUCCEEDED' | 'FAILED' | null; + wallClockDurationMs: number | null; + events: XcodebuildEvent[]; +} + +export interface RunStateOptions { + operation: XcodebuildOperation; + minimumStage?: XcodebuildStage; + onEvent?: (event: XcodebuildEvent) => void; +} + +function normalizeDiagnosticKey(location: string | undefined, message: string): string { + return `${location ?? ''}|${message}`.trim().toLowerCase(); +} + +export interface FinalizeOptions { + emitSummary?: boolean; + tailEvents?: XcodebuildEvent[]; +} + +export interface XcodebuildRunStateHandle { + push(event: XcodebuildEvent): void; + finalize(succeeded: boolean, durationMs?: number, options?: FinalizeOptions): XcodebuildRunState; + snapshot(): Readonly; + highestStageRank(): number; +} + +export function createXcodebuildRunState(options: RunStateOptions): XcodebuildRunStateHandle { + const { operation, onEvent } = options; + + const state: XcodebuildRunState = { + operation, + currentStage: null, + milestones: [], + warnings: [], + errors: [], + testFailures: [], + completedTests: 0, + failedTests: 0, + skippedTests: 0, + finalStatus: null, + wallClockDurationMs: null, + events: [], + }; + + let highestRank = options.minimumStage !== undefined ? STAGE_RANK[options.minimumStage] : -1; + const seenDiagnostics = new Set(); + + function accept(event: XcodebuildEvent): void { + state.events.push(event); + onEvent?.(event); + } + + return { + push(event: XcodebuildEvent): void { + switch (event.type) { + case 'status': { + const rank = STAGE_RANK[event.stage]; + if (rank <= highestRank) { + return; + } + highestRank = rank; + state.currentStage = event.stage; + state.milestones.push(event); + accept(event); + break; + } + + case 'warning': { + const key = normalizeDiagnosticKey(event.location, event.message); + if (seenDiagnostics.has(key)) { + return; + } + seenDiagnostics.add(key); + state.warnings.push(event); + accept(event); + break; + } + + case 'error': { + const key = normalizeDiagnosticKey(event.location, event.message); + if (seenDiagnostics.has(key)) { + return; + } + seenDiagnostics.add(key); + state.errors.push(event); + accept(event); + break; + } + + case 'test-failure': { + const key = normalizeDiagnosticKey(event.location, event.message); + if (seenDiagnostics.has(key)) { + return; + } + seenDiagnostics.add(key); + state.testFailures.push(event); + accept(event); + break; + } + + case 'test-progress': { + state.completedTests = event.completed; + state.failedTests = event.failed; + state.skippedTests = event.skipped; + + // Ensure RUN_TESTS milestone when we see test progress + if (highestRank < STAGE_RANK.RUN_TESTS) { + const runTestsEvent: StatusEvent = { + type: 'status', + timestamp: event.timestamp, + operation: 'TEST', + stage: 'RUN_TESTS', + message: 'Running tests', + }; + highestRank = STAGE_RANK.RUN_TESTS; + state.currentStage = 'RUN_TESTS'; + state.milestones.push(runTestsEvent); + accept(runTestsEvent); + } + + accept(event); + break; + } + + case 'start': + case 'notice': + case 'test-discovery': + case 'summary': + case 'next-steps': { + accept(event); + break; + } + } + }, + + finalize( + succeeded: boolean, + durationMs?: number, + options?: FinalizeOptions, + ): XcodebuildRunState { + state.finalStatus = succeeded ? 'SUCCEEDED' : 'FAILED'; + state.wallClockDurationMs = durationMs ?? null; + + if (options?.emitSummary !== false) { + const summaryEvent: XcodebuildEvent = { + type: 'summary', + timestamp: new Date().toISOString(), + operation, + status: state.finalStatus, + ...(operation === 'TEST' + ? { + totalTests: state.completedTests, + passedTests: state.completedTests - state.failedTests - state.skippedTests, + failedTests: state.failedTests, + skippedTests: state.skippedTests, + } + : {}), + durationMs, + }; + + accept(summaryEvent); + } + + for (const tailEvent of options?.tailEvents ?? []) { + accept(tailEvent); + } + + return { ...state }; + }, + + snapshot(): Readonly { + return { ...state }; + }, + + highestStageRank(): number { + return highestRank; + }, + }; +} From 3890d9b3b7293da0cb307c4cbf071b27a198e19e Mon Sep 17 00:00:00 2001 From: Cameron Cooke Date: Wed, 25 Mar 2026 21:37:15 +0000 Subject: [PATCH 03/50] refactor: Migrate all tool output to unified PipelineEvent system Replace ad-hoc text formatting across all tools with a single structured event pipeline. Every tool now emits typed PipelineEvent objects (header, statusLine, section, detailTree, table, fileRef, summary) that flow through shared renderers for consistent output formatting. Key changes: - Add PipelineEvent union type replacing XcodebuildEvent with canonical types (HeaderEvent, StatusLineEvent, SectionEvent, DetailTreeEvent, TableEvent, FileRefEvent) plus xcodebuild-specific types (BuildStageEvent, CompilerWarningEvent, CompilerErrorEvent, test events) - Add toolResponse() entry point that feeds events through shared resolveRenderers() pipeline (MCP buffer + CLI stdout) - Migrate all ~50 tool files to use toolResponse([events]) instead of createTextResponse/createErrorResponse/ad-hoc content construction - Migrate infrastructure (typed-tool-factory, tool-invoker, simulator-utils, axe-helpers, ui-automation-guard, validation, xcode-tools-bridge) to route all errors through the pipeline - Delete createErrorResponse, createTextResponse, consolidateContentForClaudeCode as there is now a single call site for response construction (toolResponse) - Default manifest nextSteps to when:success so next steps never appear on error paths - Fix daemon-routed tools: only set pipelineStreamMode:'complete' when a CLI renderer actually wrote to stdout - Regenerate all 74 snapshot fixtures with consistent pipeline formatting --- docs/dev/FIXTURE_DESIGNS.md | 877 ++++++++++ docs/dev/STRUCTURED_XCODEBUILD_EVENTS_PLAN.md | 1416 ++++++----------- manifests/tools/boot_sim.yaml | 3 + manifests/tools/build_device.yaml | 1 + manifests/tools/build_macos.yaml | 1 + manifests/tools/build_run_device.yaml | 2 + manifests/tools/build_run_macos.yaml | 1 + manifests/tools/build_run_sim.yaml | 4 + manifests/tools/build_sim.yaml | 1 + manifests/tools/debug_attach_sim.yaml | 3 + manifests/tools/discover_projs.yaml | 2 + manifests/tools/get_app_bundle_id.yaml | 4 + manifests/tools/get_coverage_report.yaml | 1 + manifests/tools/get_file_coverage.yaml | 1 + manifests/tools/get_mac_bundle_id.yaml | 2 + manifests/tools/install_app_sim.yaml | 2 + manifests/tools/launch_app_device.yaml | 1 + manifests/tools/launch_app_logs_sim.yaml | 1 + manifests/tools/launch_app_sim.yaml | 3 + manifests/tools/list_devices.yaml | 3 + manifests/tools/list_sims.yaml | 4 + manifests/tools/open_sim.yaml | 4 + manifests/tools/record_sim_video.yaml | 1 + manifests/tools/scaffold_ios_project.yaml | 3 + manifests/tools/scaffold_macos_project.yaml | 3 + manifests/tools/snapshot_ui.yaml | 3 + manifests/tools/start_device_log_cap.yaml | 1 + manifests/tools/start_sim_log_cap.yaml | 1 + package.json | 2 + src/cli/__tests__/output.test.ts | 13 +- src/cli/daemon-control.ts | 14 +- src/cli/output.ts | 47 +- src/core/manifest/import-tool-module.ts | 31 - src/core/manifest/load-manifest.ts | 35 +- src/daemon.ts | 9 +- src/daemon/__tests__/idle-shutdown.test.ts | 11 +- src/daemon/activity-registry.ts | 25 +- src/daemon/framing.ts | 11 +- src/daemon/idle-shutdown.ts | 10 +- src/daemon/socket-path.ts | 49 - .../xcode-tools-bridge/manager.ts | 55 +- .../xcode-tools-bridge/registry.ts | 10 +- .../xcode-tools-bridge/standalone.ts | 66 +- src/mcp/resources/__tests__/doctor.test.ts | 35 +- .../resources/__tests__/simulators.test.ts | 24 +- src/mcp/resources/devices.ts | 11 +- src/mcp/resources/doctor.ts | 9 +- src/mcp/resources/simulators.ts | 13 +- .../__tests__/get_coverage_report.test.ts | 38 +- .../__tests__/get_file_coverage.test.ts | 65 +- src/mcp/tools/coverage/get_coverage_report.ts | 108 +- src/mcp/tools/coverage/get_file_coverage.ts | 149 +- .../__tests__/debugging-tools.test.ts | 122 +- src/mcp/tools/debugging/debug_attach_sim.ts | 64 +- .../tools/debugging/debug_breakpoint_add.ts | 16 +- .../debugging/debug_breakpoint_remove.ts | 19 +- src/mcp/tools/debugging/debug_continue.ts | 15 +- src/mcp/tools/debugging/debug_detach.ts | 15 +- src/mcp/tools/debugging/debug_lldb_command.ts | 18 +- src/mcp/tools/debugging/debug_stack.ts | 15 +- src/mcp/tools/debugging/debug_variables.ts | 15 +- .../device/__tests__/build_run_device.test.ts | 39 +- .../__tests__/get_device_app_path.test.ts | 66 +- .../__tests__/install_app_device.test.ts | 88 +- .../__tests__/launch_app_device.test.ts | 117 +- .../device/__tests__/list_devices.test.ts | 73 +- .../device/__tests__/stop_app_device.test.ts | 88 +- src/mcp/tools/device/build_run_device.ts | 37 +- src/mcp/tools/device/get_device_app_path.ts | 69 +- src/mcp/tools/device/install_app_device.ts | 57 +- src/mcp/tools/device/launch_app_device.ts | 93 +- src/mcp/tools/device/list_devices.ts | 338 ++-- src/mcp/tools/device/stop_app_device.ts | 54 +- src/mcp/tools/doctor/__tests__/doctor.test.ts | 69 +- src/mcp/tools/doctor/doctor.ts | 400 ++--- .../__tests__/start_device_log_cap.test.ts | 89 +- .../__tests__/start_sim_log_cap.test.ts | 37 +- .../__tests__/stop_device_log_cap.test.ts | 79 +- .../__tests__/stop_sim_log_cap.test.ts | 86 +- src/mcp/tools/logging/start_device_log_cap.ts | 47 +- src/mcp/tools/logging/start_sim_log_cap.ts | 45 +- src/mcp/tools/logging/stop_device_log_cap.ts | 170 +- src/mcp/tools/logging/stop_sim_log_cap.ts | 27 +- .../macos/__tests__/build_run_macos.test.ts | 17 +- .../macos/__tests__/get_mac_app_path.test.ts | 69 +- .../macos/__tests__/launch_mac_app.test.ts | 82 +- .../macos/__tests__/stop_mac_app.test.ts | 74 +- src/mcp/tools/macos/build_run_macos.ts | 33 +- src/mcp/tools/macos/get_mac_app_path.ts | 111 +- src/mcp/tools/macos/launch_mac_app.ts | 38 +- src/mcp/tools/macos/stop_mac_app.ts | 51 +- .../__tests__/discover_projs.test.ts | 130 +- .../__tests__/get_app_bundle_id.test.ts | 184 +-- .../__tests__/get_mac_bundle_id.test.ts | 94 +- .../__tests__/list_schemes.test.ts | 73 +- .../__tests__/show_build_settings.test.ts | 79 +- .../tools/project-discovery/discover_projs.ts | 38 +- .../project-discovery/get_app_bundle_id.ts | 61 +- .../project-discovery/get_mac_bundle_id.ts | 59 +- .../tools/project-discovery/list_schemes.ts | 40 +- .../project-discovery/show_build_settings.ts | 69 +- .../__tests__/scaffold_ios_project.test.ts | 211 +-- .../__tests__/scaffold_macos_project.test.ts | 140 +- .../scaffold_ios_project.ts | 72 +- .../scaffold_macos_project.ts | 68 +- .../__tests__/session_clear_defaults.test.ts | 33 +- .../__tests__/session_set_defaults.test.ts | 61 +- .../__tests__/session_show_defaults.test.ts | 30 +- .../session_use_defaults_profile.test.ts | 27 +- .../session_clear_defaults.ts | 49 +- .../session_set_defaults.ts | 55 +- .../session_show_defaults.ts | 23 +- .../session_use_defaults_profile.ts | 92 +- .../__tests__/erase_sims.test.ts | 31 +- .../__tests__/reset_sim_location.test.ts | 43 +- .../__tests__/set_sim_appearance.test.ts | 45 +- .../__tests__/set_sim_location.test.ts | 114 +- .../__tests__/sim_statusbar.test.ts | 82 +- .../tools/simulator-management/erase_sims.ts | 42 +- .../reset_sim_location.ts | 72 +- .../set_sim_appearance.ts | 81 +- .../simulator-management/set_sim_location.ts | 117 +- .../simulator-management/sim_statusbar.ts | 45 +- .../simulator/__tests__/boot_sim.test.ts | 66 +- .../simulator/__tests__/build_run_sim.test.ts | 23 +- .../__tests__/get_sim_app_path.test.ts | 29 +- .../__tests__/install_app_sim.test.ts | 89 +- .../__tests__/launch_app_logs_sim.test.ts | 33 +- .../__tests__/launch_app_sim.test.ts | 90 +- .../simulator/__tests__/list_sims.test.ts | 181 +-- .../simulator/__tests__/open_sim.test.ts | 81 +- .../__tests__/record_sim_video.test.ts | 24 +- .../simulator/__tests__/screenshot.test.ts | 104 +- .../simulator/__tests__/stop_app_sim.test.ts | 52 +- .../simulator/__tests__/test_sim.test.ts | 2 +- src/mcp/tools/simulator/boot_sim.ts | 39 +- src/mcp/tools/simulator/build_run_sim.ts | 54 +- src/mcp/tools/simulator/get_sim_app_path.ts | 100 +- src/mcp/tools/simulator/install_app_sim.ts | 61 +- .../tools/simulator/launch_app_logs_sim.ts | 38 +- src/mcp/tools/simulator/launch_app_sim.ts | 99 +- src/mcp/tools/simulator/list_sims.ts | 74 +- src/mcp/tools/simulator/open_sim.ts | 40 +- src/mcp/tools/simulator/record_sim_video.ts | 132 +- src/mcp/tools/simulator/stop_app_sim.ts | 51 +- src/mcp/tools/simulator/test_sim.ts | 7 +- .../__tests__/swift_package_build.test.ts | 77 +- .../__tests__/swift_package_clean.test.ts | 63 +- .../__tests__/swift_package_list.test.ts | 188 +-- .../__tests__/swift_package_run.test.ts | 60 +- .../__tests__/swift_package_stop.test.ts | 43 +- .../__tests__/swift_package_test.test.ts | 78 +- .../swift-package/swift_package_build.ts | 40 +- .../swift-package/swift_package_clean.ts | 36 +- .../tools/swift-package/swift_package_list.ts | 50 +- .../tools/swift-package/swift_package_run.ts | 139 +- .../tools/swift-package/swift_package_stop.ts | 61 +- .../tools/swift-package/swift_package_test.ts | 44 +- .../ui-automation/__tests__/button.test.ts | 133 +- .../ui-automation/__tests__/gesture.test.ts | 115 +- .../ui-automation/__tests__/key_press.test.ts | 91 +- .../__tests__/key_sequence.test.ts | 169 +- .../__tests__/long_press.test.ts | 126 +- .../__tests__/screenshot.test.ts | 85 +- .../__tests__/snapshot_ui.test.ts | 132 +- .../ui-automation/__tests__/swipe.test.ts | 114 +- .../tools/ui-automation/__tests__/tap.test.ts | 168 +- .../ui-automation/__tests__/touch.test.ts | 285 +--- .../ui-automation/__tests__/type_text.test.ts | 100 +- src/mcp/tools/ui-automation/button.ts | 60 +- src/mcp/tools/ui-automation/gesture.ts | 70 +- src/mcp/tools/ui-automation/key_press.ts | 69 +- src/mcp/tools/ui-automation/key_sequence.ts | 69 +- src/mcp/tools/ui-automation/long_press.ts | 77 +- src/mcp/tools/ui-automation/screenshot.ts | 85 +- src/mcp/tools/ui-automation/snapshot_ui.ts | 80 +- src/mcp/tools/ui-automation/swipe.ts | 65 +- src/mcp/tools/ui-automation/tap.ts | 71 +- src/mcp/tools/ui-automation/touch.ts | 66 +- src/mcp/tools/ui-automation/type_text.ts | 56 +- src/mcp/tools/utilities/clean.ts | 36 +- .../__tests__/manage_workflows.test.ts | 13 +- .../workflow-discovery/manage_workflows.ts | 9 +- .../xcode-ide/__tests__/bridge_tools.test.ts | 28 +- .../__tests__/sync_xcode_defaults.test.ts | 39 +- src/mcp/tools/xcode-ide/shared.ts | 9 +- .../tools/xcode-ide/sync_xcode_defaults.ts | 60 +- .../tools/xcode-ide/xcode_ide_call_tool.ts | 23 +- .../tools/xcode-ide/xcode_ide_list_tools.ts | 25 +- .../xcode_tools_bridge_disconnect.ts | 2 +- .../xcode-ide/xcode_tools_bridge_status.ts | 2 +- .../xcode-ide/xcode_tools_bridge_sync.ts | 2 +- src/runtime/__tests__/tool-invoker.test.ts | 123 +- src/runtime/bootstrap-runtime.ts | 11 +- src/runtime/tool-catalog.ts | 7 - src/runtime/tool-invoker.ts | 120 +- src/server/mcp-lifecycle.ts | 45 +- src/server/mcp-shutdown.ts | 21 +- ...-coverage-report--error-invalid-bundle.txt | 6 + .../coverage/get-coverage-report--success.txt | 10 + ...et-file-coverage--error-invalid-bundle.txt | 7 + .../coverage/get-file-coverage--success.txt | 25 + .../add-breakpoint--error-no-session.txt | 4 + .../debugging/attach--error-no-process.txt | 4 + .../debugging/continue--error-no-session.txt | 4 + .../debugging/detach--error-no-session.txt | 4 + .../lldb-command--error-no-session.txt | 6 + .../remove-breakpoint--error-no-session.txt | 4 + .../debugging/stack--error-no-session.txt | 4 + .../debugging/variables--error-no-session.txt | 4 + .../__fixtures__/device/build--success.txt | 11 + .../device/get-app-path--success.txt | 10 + .../__fixtures__/device/list--success.txt | 43 + .../logging/start-sim-log--error.txt | 14 + .../logging/stop-sim-log--error.txt | 6 + .../__fixtures__/macos/build--success.txt | 11 + .../macos/build-and-run--success.txt | 18 + .../macos/get-app-path--success.txt | 10 + .../macos/get-macos-bundle-id--success.txt | 8 + .../macos/launch--error-invalid-app.txt | 6 + .../__fixtures__/macos/stop--error-no-app.txt | 6 + .../__fixtures__/macos/test--success.txt | 8 + .../discover-projs--success.txt | 11 + .../get-app-bundle-id--success.txt | 6 + .../get-macos-bundle-id--success.txt | 8 + .../list-schemes--success.txt | 10 + .../show-build-settings--success.txt | 612 +++++++ .../scaffold-ios--error-existing.txt | 8 + .../scaffold-ios--success.txt | 8 + .../scaffold-macos--success.txt | 8 + .../session-clear-defaults--success.txt | 4 + .../session-set-defaults--success.txt | 6 + .../session-show-defaults--success.txt | 7 + .../session-sync-xcode-defaults--success.txt | 5 + .../session-use-defaults-profile--success.txt | 7 + .../boot--error-invalid-id.txt | 6 + .../simulator-management/list--success.txt | 59 + .../simulator-management/open--success.txt | 4 + .../reset-location--success.txt | 6 + .../set-appearance--success.txt | 7 + .../set-location--success.txt | 7 + .../statusbar--success.txt | 7 + .../simulator/build--error-wrong-scheme.txt | 18 + .../__fixtures__/simulator/build--success.txt | 11 + .../simulator/build-and-run--success.txt | 25 + .../simulator/get-app-path--success.txt | 11 + .../simulator/install--error-invalid-app.txt | 12 + .../simulator/launch-app--success.txt | 7 + .../launch-app-with-logs--success.txt | 12 + .../__fixtures__/simulator/list--success.txt | 59 + .../simulator/screenshot--success.txt | 6 + .../simulator/stop--error-no-app.txt | 12 + .../__fixtures__/simulator/test--success.txt | 16 + .../swift-package/build--error-bad-path.txt | 6 + .../swift-package/build--success.txt | 10 + .../swift-package/clean--success.txt | 6 + .../swift-package/list--success.txt | 4 + .../swift-package/run--success.txt | 10 + .../swift-package/stop--error-no-process.txt | 6 + .../swift-package/test--success.txt | 25 + .../ui-automation/button--success.txt | 6 + .../ui-automation/gesture--success.txt | 6 + .../ui-automation/key-press--success.txt | 6 + .../ui-automation/key-sequence--success.txt | 6 + .../ui-automation/long-press--success.txt | 7 + .../ui-automation/snapshot-ui--success.txt | 583 +++++++ .../ui-automation/swipe--success.txt | 7 + .../ui-automation/tap--error-no-simulator.txt | 9 + .../ui-automation/tap--success.txt | 7 + .../ui-automation/touch--success.txt | 7 + .../ui-automation/type-text--success.txt | 6 + .../__fixtures__/utilities/clean--success.txt | 8 + ...-coverage-report--error-invalid-bundle.txt | 7 + .../coverage/get-coverage-report--success.txt | 12 + ...et-file-coverage--error-invalid-bundle.txt | 8 + .../coverage/get-file-coverage--success.txt | 27 + .../debugging/add-breakpoint--success.txt | 11 + .../debugging/attach--success.txt | 13 + .../debugging/continue--error-no-session.txt | 3 + .../debugging/continue--success.txt | 7 + .../debugging/detach--success.txt | 3 + .../debugging/lldb-command--success.txt | 5 + .../debugging/remove-breakpoint--success.txt | 5 + .../debugging/stack--success.txt | 6 + .../debugging/variables--success.txt | 9 + .../device/build--failure-compilation.txt | 11 + .../device/build--success.txt | 11 + .../device/get-app-path--success.txt | 13 + .../device/list--success.txt | 37 + .../macos/build--failure-compilation.txt | 11 + .../macos/build--success.txt | 11 + .../macos/build-and-run--success.txt | 18 + .../macos/get-app-path--success.txt | 12 + .../macos/launch--error-invalid-app.txt | 5 + .../macos/test--failure.txt | 13 + .../macos/test--success.txt | 10 + .../discover-projs--success.txt | 12 + .../list-schemes--success.txt | 13 + .../show-build-settings--success.txt | 21 + .../scaffold-ios--error-existing.txt | 5 + .../scaffold-ios--success.txt | 12 + .../scaffold-macos--success.txt | 11 + .../session-clear-defaults--success.txt | 3 + .../session-set-defaults--success.txt | 6 + .../session-show-defaults--success.txt | 4 + .../boot--error-invalid-id.txt | 10 + .../simulator-management/list--success.txt | 35 + .../simulator-management/open--success.txt | 8 + .../reset-location--success.txt | 5 + .../set-appearance--success.txt | 6 + .../set-location--success.txt | 7 + .../simulator/build--error-wrong-scheme.txt | 12 + .../simulator/build--failure-compilation.txt | 12 + .../simulator/build--success.txt | 12 + .../simulator/build-and-run--success.txt | 24 + .../simulator/get-app-path--success.txt | 14 + .../simulator/list--success.txt | 35 + .../simulator/stop--error-no-app.txt | 6 + .../simulator/test--failure.txt | 14 + .../simulator/test--success.txt | 14 + .../swift-package/build--error-bad-path.txt | 5 + .../build--failure-compilation.txt | 8 + .../swift-package/build--success.txt | 5 + .../swift-package/clean--success.txt | 5 + .../swift-package/list--success.txt | 3 + .../swift-package/run--success.txt | 8 + .../swift-package/test--failure.txt | 8 + .../swift-package/test--success.txt | 12 + .../ui-automation/snapshot-ui--success.txt | 573 +++++++ .../ui-automation/tap--error-no-simulator.txt | 6 + .../utilities/clean--success.txt | 8 + .../__tests__/coverage.snapshot.test.ts | 107 ++ .../__tests__/debugging.snapshot.test.ts | 90 ++ .../__tests__/device.snapshot.test.ts | 115 ++ .../__tests__/doctor.snapshot.test.ts | 19 + .../__tests__/logging.snapshot.test.ts | 37 + .../__tests__/macos.snapshot.test.ts | 124 ++ .../project-discovery.snapshot.test.ts | 95 ++ .../project-scaffolding.snapshot.test.ts | 76 + .../session-management.snapshot.test.ts | 64 + .../simulator-management.snapshot.test.ts | 92 ++ .../__tests__/simulator.snapshot.test.ts | 164 ++ .../__tests__/swift-package.snapshot.test.ts | 89 ++ .../__tests__/ui-automation.snapshot.test.ts | 166 ++ .../__tests__/utilities.snapshot.test.ts | 30 + .../workflow-discovery.snapshot.test.ts | 20 + .../__tests__/xcode-ide.snapshot.test.ts | 62 + src/snapshot-tests/fixture-io.ts | 35 + src/snapshot-tests/harness.ts | 140 ++ src/snapshot-tests/normalize.ts | 104 ++ src/types/common.ts | 3 +- ...codebuild-events.ts => pipeline-events.ts} | 181 ++- src/utils/CommandExecutor.ts | 6 - src/utils/FileSystemExecutor.ts | 4 - src/utils/__tests__/build-utils.test.ts | 36 +- .../__tests__/consolidate-content.test.ts | 172 -- src/utils/__tests__/simulator-utils.test.ts | 10 +- .../typed-tool-factory-consolidation.test.ts | 113 -- .../__tests__/xcodebuild-event-parser.test.ts | 42 +- src/utils/__tests__/xcodebuild-output.test.ts | 44 +- .../__tests__/xcodebuild-pipeline.test.ts | 16 +- .../__tests__/xcodebuild-run-state.test.ts | 58 +- src/utils/axe-helpers.ts | 6 - src/utils/axe/index.ts | 2 +- src/utils/build-preflight.ts | 23 +- src/utils/build-utils.ts | 137 +- src/utils/command.ts | 83 +- src/utils/debugger/ui-automation-guard.ts | 11 +- src/utils/environment.ts | 70 +- src/utils/errors.ts | 67 +- .../__tests__/cli-text-renderer.test.ts | 177 +-- .../__tests__/event-formatting.test.ts | 197 +-- .../renderers/__tests__/mcp-renderer.test.ts | 69 +- src/utils/renderers/cli-jsonl-renderer.ts | 4 +- src/utils/renderers/cli-text-renderer.ts | 81 +- src/utils/renderers/event-formatting.ts | 321 ++-- src/utils/renderers/index.ts | 43 +- src/utils/renderers/mcp-renderer.ts | 58 +- .../__tests__/next-steps-renderer.test.ts | 114 +- src/utils/responses/index.ts | 21 +- src/utils/responses/next-steps-renderer.ts | 61 +- src/utils/schema-helpers.ts | 13 - src/utils/simulator-resolver.ts | 123 +- src/utils/simulator-utils.ts | 102 +- src/utils/terminal-output.ts | 28 +- src/utils/test-common.ts | 11 +- src/utils/tool-event-builders.ts | 103 ++ src/utils/tool-registry.ts | 31 +- src/utils/tool-response.ts | 35 + src/utils/typed-tool-factory.ts | 56 +- src/utils/validation.ts | 254 +-- src/utils/validation/index.ts | 4 - src/utils/workflow-selection.ts | 33 +- src/utils/xcode-state-reader.ts | 68 +- src/utils/xcode.ts | 38 +- src/utils/xcodebuild-error-utils.ts | 11 +- src/utils/xcodebuild-event-parser.ts | 37 +- src/utils/xcodebuild-output.ts | 160 +- src/utils/xcodebuild-pipeline.ts | 95 +- src/utils/xcodebuild-run-state.ts | 88 +- src/utils/xcodemake.ts | 79 - 401 files changed, 12088 insertions(+), 9993 deletions(-) create mode 100644 docs/dev/FIXTURE_DESIGNS.md create mode 100644 src/snapshot-tests/__fixtures__/coverage/get-coverage-report--error-invalid-bundle.txt create mode 100644 src/snapshot-tests/__fixtures__/coverage/get-coverage-report--success.txt create mode 100644 src/snapshot-tests/__fixtures__/coverage/get-file-coverage--error-invalid-bundle.txt create mode 100644 src/snapshot-tests/__fixtures__/coverage/get-file-coverage--success.txt create mode 100644 src/snapshot-tests/__fixtures__/debugging/add-breakpoint--error-no-session.txt create mode 100644 src/snapshot-tests/__fixtures__/debugging/attach--error-no-process.txt create mode 100644 src/snapshot-tests/__fixtures__/debugging/continue--error-no-session.txt create mode 100644 src/snapshot-tests/__fixtures__/debugging/detach--error-no-session.txt create mode 100644 src/snapshot-tests/__fixtures__/debugging/lldb-command--error-no-session.txt create mode 100644 src/snapshot-tests/__fixtures__/debugging/remove-breakpoint--error-no-session.txt create mode 100644 src/snapshot-tests/__fixtures__/debugging/stack--error-no-session.txt create mode 100644 src/snapshot-tests/__fixtures__/debugging/variables--error-no-session.txt create mode 100644 src/snapshot-tests/__fixtures__/device/build--success.txt create mode 100644 src/snapshot-tests/__fixtures__/device/get-app-path--success.txt create mode 100644 src/snapshot-tests/__fixtures__/device/list--success.txt create mode 100644 src/snapshot-tests/__fixtures__/logging/start-sim-log--error.txt create mode 100644 src/snapshot-tests/__fixtures__/logging/stop-sim-log--error.txt create mode 100644 src/snapshot-tests/__fixtures__/macos/build--success.txt create mode 100644 src/snapshot-tests/__fixtures__/macos/build-and-run--success.txt create mode 100644 src/snapshot-tests/__fixtures__/macos/get-app-path--success.txt create mode 100644 src/snapshot-tests/__fixtures__/macos/get-macos-bundle-id--success.txt create mode 100644 src/snapshot-tests/__fixtures__/macos/launch--error-invalid-app.txt create mode 100644 src/snapshot-tests/__fixtures__/macos/stop--error-no-app.txt create mode 100644 src/snapshot-tests/__fixtures__/macos/test--success.txt create mode 100644 src/snapshot-tests/__fixtures__/project-discovery/discover-projs--success.txt create mode 100644 src/snapshot-tests/__fixtures__/project-discovery/get-app-bundle-id--success.txt create mode 100644 src/snapshot-tests/__fixtures__/project-discovery/get-macos-bundle-id--success.txt create mode 100644 src/snapshot-tests/__fixtures__/project-discovery/list-schemes--success.txt create mode 100644 src/snapshot-tests/__fixtures__/project-discovery/show-build-settings--success.txt create mode 100644 src/snapshot-tests/__fixtures__/project-scaffolding/scaffold-ios--error-existing.txt create mode 100644 src/snapshot-tests/__fixtures__/project-scaffolding/scaffold-ios--success.txt create mode 100644 src/snapshot-tests/__fixtures__/project-scaffolding/scaffold-macos--success.txt create mode 100644 src/snapshot-tests/__fixtures__/session-management/session-clear-defaults--success.txt create mode 100644 src/snapshot-tests/__fixtures__/session-management/session-set-defaults--success.txt create mode 100644 src/snapshot-tests/__fixtures__/session-management/session-show-defaults--success.txt create mode 100644 src/snapshot-tests/__fixtures__/session-management/session-sync-xcode-defaults--success.txt create mode 100644 src/snapshot-tests/__fixtures__/session-management/session-use-defaults-profile--success.txt create mode 100644 src/snapshot-tests/__fixtures__/simulator-management/boot--error-invalid-id.txt create mode 100644 src/snapshot-tests/__fixtures__/simulator-management/list--success.txt create mode 100644 src/snapshot-tests/__fixtures__/simulator-management/open--success.txt create mode 100644 src/snapshot-tests/__fixtures__/simulator-management/reset-location--success.txt create mode 100644 src/snapshot-tests/__fixtures__/simulator-management/set-appearance--success.txt create mode 100644 src/snapshot-tests/__fixtures__/simulator-management/set-location--success.txt create mode 100644 src/snapshot-tests/__fixtures__/simulator-management/statusbar--success.txt create mode 100644 src/snapshot-tests/__fixtures__/simulator/build--error-wrong-scheme.txt create mode 100644 src/snapshot-tests/__fixtures__/simulator/build--success.txt create mode 100644 src/snapshot-tests/__fixtures__/simulator/build-and-run--success.txt create mode 100644 src/snapshot-tests/__fixtures__/simulator/get-app-path--success.txt create mode 100644 src/snapshot-tests/__fixtures__/simulator/install--error-invalid-app.txt create mode 100644 src/snapshot-tests/__fixtures__/simulator/launch-app--success.txt create mode 100644 src/snapshot-tests/__fixtures__/simulator/launch-app-with-logs--success.txt create mode 100644 src/snapshot-tests/__fixtures__/simulator/list--success.txt create mode 100644 src/snapshot-tests/__fixtures__/simulator/screenshot--success.txt create mode 100644 src/snapshot-tests/__fixtures__/simulator/stop--error-no-app.txt create mode 100644 src/snapshot-tests/__fixtures__/simulator/test--success.txt create mode 100644 src/snapshot-tests/__fixtures__/swift-package/build--error-bad-path.txt create mode 100644 src/snapshot-tests/__fixtures__/swift-package/build--success.txt create mode 100644 src/snapshot-tests/__fixtures__/swift-package/clean--success.txt create mode 100644 src/snapshot-tests/__fixtures__/swift-package/list--success.txt create mode 100644 src/snapshot-tests/__fixtures__/swift-package/run--success.txt create mode 100644 src/snapshot-tests/__fixtures__/swift-package/stop--error-no-process.txt create mode 100644 src/snapshot-tests/__fixtures__/swift-package/test--success.txt create mode 100644 src/snapshot-tests/__fixtures__/ui-automation/button--success.txt create mode 100644 src/snapshot-tests/__fixtures__/ui-automation/gesture--success.txt create mode 100644 src/snapshot-tests/__fixtures__/ui-automation/key-press--success.txt create mode 100644 src/snapshot-tests/__fixtures__/ui-automation/key-sequence--success.txt create mode 100644 src/snapshot-tests/__fixtures__/ui-automation/long-press--success.txt create mode 100644 src/snapshot-tests/__fixtures__/ui-automation/snapshot-ui--success.txt create mode 100644 src/snapshot-tests/__fixtures__/ui-automation/swipe--success.txt create mode 100644 src/snapshot-tests/__fixtures__/ui-automation/tap--error-no-simulator.txt create mode 100644 src/snapshot-tests/__fixtures__/ui-automation/tap--success.txt create mode 100644 src/snapshot-tests/__fixtures__/ui-automation/touch--success.txt create mode 100644 src/snapshot-tests/__fixtures__/ui-automation/type-text--success.txt create mode 100644 src/snapshot-tests/__fixtures__/utilities/clean--success.txt create mode 100644 src/snapshot-tests/__fixtures_designed__/coverage/get-coverage-report--error-invalid-bundle.txt create mode 100644 src/snapshot-tests/__fixtures_designed__/coverage/get-coverage-report--success.txt create mode 100644 src/snapshot-tests/__fixtures_designed__/coverage/get-file-coverage--error-invalid-bundle.txt create mode 100644 src/snapshot-tests/__fixtures_designed__/coverage/get-file-coverage--success.txt create mode 100644 src/snapshot-tests/__fixtures_designed__/debugging/add-breakpoint--success.txt create mode 100644 src/snapshot-tests/__fixtures_designed__/debugging/attach--success.txt create mode 100644 src/snapshot-tests/__fixtures_designed__/debugging/continue--error-no-session.txt create mode 100644 src/snapshot-tests/__fixtures_designed__/debugging/continue--success.txt create mode 100644 src/snapshot-tests/__fixtures_designed__/debugging/detach--success.txt create mode 100644 src/snapshot-tests/__fixtures_designed__/debugging/lldb-command--success.txt create mode 100644 src/snapshot-tests/__fixtures_designed__/debugging/remove-breakpoint--success.txt create mode 100644 src/snapshot-tests/__fixtures_designed__/debugging/stack--success.txt create mode 100644 src/snapshot-tests/__fixtures_designed__/debugging/variables--success.txt create mode 100644 src/snapshot-tests/__fixtures_designed__/device/build--failure-compilation.txt create mode 100644 src/snapshot-tests/__fixtures_designed__/device/build--success.txt create mode 100644 src/snapshot-tests/__fixtures_designed__/device/get-app-path--success.txt create mode 100644 src/snapshot-tests/__fixtures_designed__/device/list--success.txt create mode 100644 src/snapshot-tests/__fixtures_designed__/macos/build--failure-compilation.txt create mode 100644 src/snapshot-tests/__fixtures_designed__/macos/build--success.txt create mode 100644 src/snapshot-tests/__fixtures_designed__/macos/build-and-run--success.txt create mode 100644 src/snapshot-tests/__fixtures_designed__/macos/get-app-path--success.txt create mode 100644 src/snapshot-tests/__fixtures_designed__/macos/launch--error-invalid-app.txt create mode 100644 src/snapshot-tests/__fixtures_designed__/macos/test--failure.txt create mode 100644 src/snapshot-tests/__fixtures_designed__/macos/test--success.txt create mode 100644 src/snapshot-tests/__fixtures_designed__/project-discovery/discover-projs--success.txt create mode 100644 src/snapshot-tests/__fixtures_designed__/project-discovery/list-schemes--success.txt create mode 100644 src/snapshot-tests/__fixtures_designed__/project-discovery/show-build-settings--success.txt create mode 100644 src/snapshot-tests/__fixtures_designed__/project-scaffolding/scaffold-ios--error-existing.txt create mode 100644 src/snapshot-tests/__fixtures_designed__/project-scaffolding/scaffold-ios--success.txt create mode 100644 src/snapshot-tests/__fixtures_designed__/project-scaffolding/scaffold-macos--success.txt create mode 100644 src/snapshot-tests/__fixtures_designed__/session-management/session-clear-defaults--success.txt create mode 100644 src/snapshot-tests/__fixtures_designed__/session-management/session-set-defaults--success.txt create mode 100644 src/snapshot-tests/__fixtures_designed__/session-management/session-show-defaults--success.txt create mode 100644 src/snapshot-tests/__fixtures_designed__/simulator-management/boot--error-invalid-id.txt create mode 100644 src/snapshot-tests/__fixtures_designed__/simulator-management/list--success.txt create mode 100644 src/snapshot-tests/__fixtures_designed__/simulator-management/open--success.txt create mode 100644 src/snapshot-tests/__fixtures_designed__/simulator-management/reset-location--success.txt create mode 100644 src/snapshot-tests/__fixtures_designed__/simulator-management/set-appearance--success.txt create mode 100644 src/snapshot-tests/__fixtures_designed__/simulator-management/set-location--success.txt create mode 100644 src/snapshot-tests/__fixtures_designed__/simulator/build--error-wrong-scheme.txt create mode 100644 src/snapshot-tests/__fixtures_designed__/simulator/build--failure-compilation.txt create mode 100644 src/snapshot-tests/__fixtures_designed__/simulator/build--success.txt create mode 100644 src/snapshot-tests/__fixtures_designed__/simulator/build-and-run--success.txt create mode 100644 src/snapshot-tests/__fixtures_designed__/simulator/get-app-path--success.txt create mode 100644 src/snapshot-tests/__fixtures_designed__/simulator/list--success.txt create mode 100644 src/snapshot-tests/__fixtures_designed__/simulator/stop--error-no-app.txt create mode 100644 src/snapshot-tests/__fixtures_designed__/simulator/test--failure.txt create mode 100644 src/snapshot-tests/__fixtures_designed__/simulator/test--success.txt create mode 100644 src/snapshot-tests/__fixtures_designed__/swift-package/build--error-bad-path.txt create mode 100644 src/snapshot-tests/__fixtures_designed__/swift-package/build--failure-compilation.txt create mode 100644 src/snapshot-tests/__fixtures_designed__/swift-package/build--success.txt create mode 100644 src/snapshot-tests/__fixtures_designed__/swift-package/clean--success.txt create mode 100644 src/snapshot-tests/__fixtures_designed__/swift-package/list--success.txt create mode 100644 src/snapshot-tests/__fixtures_designed__/swift-package/run--success.txt create mode 100644 src/snapshot-tests/__fixtures_designed__/swift-package/test--failure.txt create mode 100644 src/snapshot-tests/__fixtures_designed__/swift-package/test--success.txt create mode 100644 src/snapshot-tests/__fixtures_designed__/ui-automation/snapshot-ui--success.txt create mode 100644 src/snapshot-tests/__fixtures_designed__/ui-automation/tap--error-no-simulator.txt create mode 100644 src/snapshot-tests/__fixtures_designed__/utilities/clean--success.txt create mode 100644 src/snapshot-tests/__tests__/coverage.snapshot.test.ts create mode 100644 src/snapshot-tests/__tests__/debugging.snapshot.test.ts create mode 100644 src/snapshot-tests/__tests__/device.snapshot.test.ts create mode 100644 src/snapshot-tests/__tests__/doctor.snapshot.test.ts create mode 100644 src/snapshot-tests/__tests__/logging.snapshot.test.ts create mode 100644 src/snapshot-tests/__tests__/macos.snapshot.test.ts create mode 100644 src/snapshot-tests/__tests__/project-discovery.snapshot.test.ts create mode 100644 src/snapshot-tests/__tests__/project-scaffolding.snapshot.test.ts create mode 100644 src/snapshot-tests/__tests__/session-management.snapshot.test.ts create mode 100644 src/snapshot-tests/__tests__/simulator-management.snapshot.test.ts create mode 100644 src/snapshot-tests/__tests__/simulator.snapshot.test.ts create mode 100644 src/snapshot-tests/__tests__/swift-package.snapshot.test.ts create mode 100644 src/snapshot-tests/__tests__/ui-automation.snapshot.test.ts create mode 100644 src/snapshot-tests/__tests__/utilities.snapshot.test.ts create mode 100644 src/snapshot-tests/__tests__/workflow-discovery.snapshot.test.ts create mode 100644 src/snapshot-tests/__tests__/xcode-ide.snapshot.test.ts create mode 100644 src/snapshot-tests/fixture-io.ts create mode 100644 src/snapshot-tests/harness.ts create mode 100644 src/snapshot-tests/normalize.ts rename src/types/{xcodebuild-events.ts => pipeline-events.ts} (60%) delete mode 100644 src/utils/__tests__/consolidate-content.test.ts delete mode 100644 src/utils/__tests__/typed-tool-factory-consolidation.test.ts create mode 100644 src/utils/tool-event-builders.ts create mode 100644 src/utils/tool-response.ts diff --git a/docs/dev/FIXTURE_DESIGNS.md b/docs/dev/FIXTURE_DESIGNS.md new file mode 100644 index 00000000..9397f11a --- /dev/null +++ b/docs/dev/FIXTURE_DESIGNS.md @@ -0,0 +1,877 @@ +# Snapshot test fixture designs + +Target UX for all tool output. This is the TDD reference โ€” write fixtures first, then update rendering code until output matches. + +Delete this file once all fixtures are written and tests pass. + +## Output rhythm (all tools) + +``` + + + : + : + + + +Next steps: +1. +``` + +## Design principles + +1. No JSON output โ€” all tools render structured data as human-readable text +2. Every tool gets a header โ€” emoji + operation name + indented params +3. File paths always relative where possible (rendered by `displayPath`) +4. Grouped/structured body โ€” not raw command dumps. Focus on useful information +5. Concise for AI agents โ€” minimize tokens while maximizing signal +6. Success + error + failure fixtures for every tool where appropriate (error = can't run; failure = ran, bad outcome) +11. Error fixtures must test real executable errors โ€” not just pre-call validation (file-exists checks, param validation). The fixture should exercise the underlying CLI/tool and capture how we handle its error response. Pre-call validation should be handled by yargs or input schemas, not tested as snapshot fixtures. +7. Consistent icons โ€” status emojis owned by renderer, not tools +8. Consistent spacing โ€” one blank line between sections, always +9. No next steps on error paths +10. Tree chars (โ”œ/โ””) for informational lists (paths, IDs, metadata) โ€” not for result lists (errors, failures, test outcomes) + +### Error fixture policy + +Every error fixture must test a **real executable/CLI error** โ€” not pre-call validation (file-exists checks, param validation). The fixture should exercise the underlying tool and capture how we handle its error response. Pre-call validation should be handled by yargs or input schemas, not tested as snapshot fixtures. + +One fixture per distinct CLI or output shape. The representative error fixtures cover all shapes: + +| CLI / Shape | Representative fixture | +|---|---| +| xcodebuild (wrong scheme) | `simulator/build--error-wrong-scheme` | +| simctl terminate (bad bundle) | `simulator/stop--error-no-app` | +| simctl boot (bad UUID) | `simulator-management/boot--error-invalid-id` | +| open (invalid app) | `macos/launch--error-invalid-app` | +| xcrun xccov (invalid bundle) | `coverage/get-coverage-report--error-invalid-bundle` | +| swift build (bad path) | `swift-package/build--error-bad-path` | +| AXe (bad simulator) | `ui-automation/tap--error-no-simulator` | +| Internal: idempotency check | `project-scaffolding/scaffold-ios--error-existing` | +| Internal: no active session | `debugging/continue--error-no-session` | +| Internal: file coverage | `coverage/get-file-coverage--error-invalid-bundle` | + +## Tracking checklist + +### coverage +- [x] `get-coverage-report--success.txt` +- [x] `get-coverage-report--error-invalid-bundle.txt` +- [x] `get-file-coverage--success.txt` +- [x] `get-file-coverage--error-invalid-bundle.txt` +- [ ] Code updated to match fixtures + +### session-management +- [x] `session-set-defaults--success.txt` +- [x] `session-show-defaults--success.txt` +- [x] `session-clear-defaults--success.txt` +- [ ] Code updated to match fixtures + +### simulator-management +- [x] `list--success.txt` +- [x] `boot--error-invalid-id.txt` +- [x] `open--success.txt` +- [x] `set-appearance--success.txt` +- [x] `set-location--success.txt` +- [x] `reset-location--success.txt` +- [ ] Code updated to match fixtures + +### simulator +- [x] `build--success.txt` +- [x] `build--error-wrong-scheme.txt` +- [x] `build--failure-compilation.txt` +- [x] `build-and-run--success.txt` +- [x] `test--success.txt` +- [x] `test--failure.txt` +- [x] `get-app-path--success.txt` +- [x] `list--success.txt` +- [x] `stop--error-no-app.txt` +- [ ] Code updated to match fixtures + +### project-discovery +- [x] `discover-projs--success.txt` +- [x] `list-schemes--success.txt` +- [x] `show-build-settings--success.txt` +- [ ] Code updated to match fixtures + +### project-scaffolding +- [x] `scaffold-ios--success.txt` +- [x] `scaffold-ios--error-existing.txt` +- [x] `scaffold-macos--success.txt` +- [ ] Code updated to match fixtures + +### device +- [x] `build--success.txt` +- [x] `build--failure-compilation.txt` +- [x] `get-app-path--success.txt` +- [x] `list--success.txt` +- [ ] Code updated to match fixtures + +### macos +- [x] `build--success.txt` +- [x] `build--failure-compilation.txt` +- [x] `build-and-run--success.txt` +- [x] `test--success.txt` +- [x] `test--failure.txt` +- [x] `get-app-path--success.txt` +- [x] `launch--error-invalid-app.txt` +- [ ] Code updated to match fixtures + +### swift-package +- [x] `build--success.txt` +- [x] `build--error-bad-path.txt` +- [x] `build--failure-compilation.txt` +- [x] `test--success.txt` +- [x] `test--failure.txt` +- [x] `clean--success.txt` +- [x] `list--success.txt` +- [x] `run--success.txt` +- [ ] Code updated to match fixtures + +### debugging +- [x] `attach--success.txt` +- [x] `add-breakpoint--success.txt` +- [x] `remove-breakpoint--success.txt` +- [x] `continue--success.txt` +- [x] `continue--error-no-session.txt` +- [x] `detach--success.txt` +- [x] `lldb-command--success.txt` +- [x] `stack--success.txt` +- [x] `variables--success.txt` +- [ ] Code updated to match fixtures + +### ui-automation +- [x] `snapshot-ui--success.txt` +- [x] `tap--error-no-simulator.txt` +- [ ] Code updated to match fixtures + +### utilities +- [x] `clean--success.txt` +- [ ] Code updated to match fixtures + +--- + +## Fixture designs by workflow + +### coverage + +**`get-coverage-report--success.txt`**: +``` +๐Ÿ“Š Coverage Report + + xcresult: /TestResults.xcresult + Target Filter: CalculatorAppTests + +Overall: 94.9% (354/373 lines) + +Targets: + CalculatorAppTests.xctest โ€” 94.9% (354/373 lines) + +Next steps: +1. View file-level coverage: xcodebuildmcp coverage get-file-coverage --xcresult-path "/TestResults.xcresult" +``` + +**`get-coverage-report--error-invalid-bundle.txt`** โ€” real executable error (fake .xcresult dir passes file-exists check, xcrun xccov fails): +``` +๐Ÿ“Š Coverage Report + + xcresult: /invalid.xcresult + +โŒ Failed to get coverage report: Failed to load result bundle. + +Hint: Run tests with coverage enabled (e.g., xcodebuild test -enableCodeCoverage YES). +``` + +**`get-file-coverage--success.txt`** โ€” already updated, keep current content. + +**`get-file-coverage--error-invalid-bundle.txt`** โ€” real executable error (fake .xcresult dir passes file-exists check, xcrun xccov fails): +``` +๐Ÿ“Š File Coverage + + xcresult: /invalid.xcresult + File: SomeFile.swift + +โŒ Failed to get file coverage: Failed to load result bundle. + +Hint: Make sure the xcresult bundle contains coverage data for "SomeFile.swift". +``` + +--- + +### session-management + +**`session-set-defaults--success.txt`**: +``` +โš™๏ธ Set Defaults + + Workspace: example_projects/iOS_Calculator/CalculatorApp.xcworkspace + Scheme: CalculatorApp + +โœ… Session defaults updated. +``` + +**`session-show-defaults--success.txt`**: +``` +โš™๏ธ Show Defaults + + Workspace: example_projects/iOS_Calculator/CalculatorApp.xcworkspace + Scheme: CalculatorApp +``` + +**`session-clear-defaults--success.txt`**: +``` +โš™๏ธ Clear Defaults + +โœ… Session defaults cleared. +``` + +--- + +### simulator-management + +**`list--success.txt`**: +``` +๐Ÿ“ฑ List Simulators + +iOS 26.2: + iPhone 17 Pro Booted + iPhone 17 Pro Max + iPhone Air + iPhone 17 Booted + iPhone 16e + iPad Pro 13-inch (M5) + iPad Pro 11-inch (M5) + iPad mini (A17 Pro) + iPad (A16) + iPad Air 13-inch (M3) + iPad Air 11-inch (M3) + +watchOS 26.2: + Apple Watch Series 11 (46mm) + Apple Watch Series 11 (42mm) + Apple Watch Ultra 3 (49mm) + Apple Watch SE 3 (44mm) + Apple Watch SE 3 (40mm) + +tvOS 26.2: + Apple TV 4K (3rd generation) + Apple TV 4K (3rd generation) (at 1080p) + Apple TV + +xrOS 26.2: + Apple Vision Pro + +Next steps: +1. Boot simulator: xcodebuildmcp simulator-management boot --simulator-id "UUID_FROM_ABOVE" +2. Open Simulator UI: xcodebuildmcp simulator-management open +3. Build for simulator: xcodebuildmcp simulator build --scheme "YOUR_SCHEME" --simulator-id "UUID_FROM_ABOVE" +4. Get app path: xcodebuildmcp simulator get-app-path --scheme "YOUR_SCHEME" --platform "iOS Simulator" --simulator-id "UUID_FROM_ABOVE" +``` + +Runtime names shortened from `com.apple.CoreSimulator.SimRuntime.iOS-26-2` to `iOS 26.2`. Tabular layout. Booted state shown inline. + +**`boot--error-invalid-id.txt`**: +``` +๐Ÿ”Œ Boot Simulator + + Simulator: + +โŒ Failed to boot simulator: Invalid device or device pair: + +Next steps: +1. Open Simulator UI: xcodebuildmcp simulator-management open +2. Install app: xcodebuildmcp simulator install --simulator-id "SIMULATOR_UUID" --app-path "PATH_TO_YOUR_APP" +3. Launch app: xcodebuildmcp simulator launch-app --simulator-id "SIMULATOR_UUID" --bundle-id "YOUR_APP_BUNDLE_ID" +``` + +**`open--success.txt`**: +``` +๐Ÿ“ฑ Open Simulator + +โœ… Simulator app opened successfully. + +Next steps: +1. Boot simulator: xcodebuildmcp simulator-management boot --simulator-id "UUID_FROM_LIST_SIMS" +2. Start log capture: xcodebuildmcp logging start-simulator-log-capture --simulator-id "UUID" --bundle-id "YOUR_APP_BUNDLE_ID" +3. Launch app with logs: xcodebuildmcp simulator launch-app-with-logs --simulator-id "UUID" --bundle-id "YOUR_APP_BUNDLE_ID" +``` + +**`set-appearance--success.txt`**: +``` +๐ŸŽจ Set Appearance + + Simulator: + Mode: dark + +โœ… Appearance set to dark mode. +``` + +**`set-location--success.txt`**: +``` +๐Ÿ“ Set Location + + Simulator: + Latitude: 37.7749 + Longitude: -122.4194 + +โœ… Location set to 37.7749, -122.4194. +``` + +**`reset-location--success.txt`**: +``` +๐Ÿ“ Reset Location + + Simulator: + +โœ… Location reset to default. +``` + +--- + +### simulator + +**`build--success.txt`** โ€” pipeline-rendered, review for unified UX consistency. + +**`build--error-wrong-scheme.txt`** โ€” pipeline-rendered, representative pipeline error fixture. + +**`build--failure-compilation.txt`** โ€” build ran but failed with compiler errors (uses CompileError.fixture.swift injected into app target): +``` +๐Ÿ”จ Build + + Scheme: CalculatorApp + Workspace: example_projects/iOS_Calculator/CalculatorApp.xcworkspace + Configuration: Debug + Platform: iOS Simulator + Simulator: iPhone 17 + +Errors (1): + โœ— CalculatorApp/CompileError.swift:3: Cannot convert value of type 'String' to specified type 'Int' + +โŒ Build failed. (โฑ๏ธ ) +``` + +**`build-and-run--success.txt`** โ€” pipeline-rendered, review for consistency. + +**`test--success.txt`** โ€” all tests pass: +``` +๐Ÿงช Test + + Scheme: CalculatorApp + Workspace: example_projects/iOS_Calculator/CalculatorApp.xcworkspace + Configuration: Debug + Platform: iOS Simulator + Simulator: iPhone 17 + +Resolved to test(s) + +โœ… Test succeeded. (, โฑ๏ธ ) + +Next steps: +1. View test coverage: xcodebuildmcp coverage get-coverage-report --xcresult-path "XCRESULT_PATH" +``` + +**`test--failure.txt`** โ€” tests ran, assertion failures: +``` +๐Ÿงช Test + + Scheme: CalculatorApp + Workspace: example_projects/iOS_Calculator/CalculatorApp.xcworkspace + Configuration: Debug + Platform: iOS Simulator + Simulator: iPhone 17 + +Resolved to test(s) + +Failures (1): + โœ— CalculatorAppTests.testCalculatorServiceFailure โ€” XCTAssertEqual failed: ("0") is not equal to ("999") + +โŒ Test failed. (, โฑ๏ธ ) +``` + +**`get-app-path--success.txt`**: +``` +๐Ÿ” Get App Path + + Scheme: CalculatorApp + Workspace: example_projects/iOS_Calculator/CalculatorApp.xcworkspace + Configuration: Debug + Platform: iOS Simulator + + โ”” App Path: /Library/Developer/Xcode/DerivedData/CalculatorApp-/Build/Products/Debug-iphonesimulator/CalculatorApp.app + +Next steps: +1. Get bundle ID: xcodebuildmcp project-discovery get-app-bundle-id --app-path "/Library/Developer/Xcode/DerivedData/CalculatorApp-/Build/Products/Debug-iphonesimulator/CalculatorApp.app" +2. Boot simulator: xcodebuildmcp simulator-management boot --simulator-id "" +3. Install on simulator: xcodebuildmcp simulator install --simulator-id "" --app-path "/Library/Developer/Xcode/DerivedData/CalculatorApp-/Build/Products/Debug-iphonesimulator/CalculatorApp.app" +4. Launch on simulator: xcodebuildmcp simulator launch-app --simulator-id "" --bundle-id "BUNDLE_ID" +``` + +**`list--success.txt`** โ€” same as simulator-management/list--success.txt (shared tool). + +**`stop--error-no-app.txt`**: +``` +๐Ÿ›‘ Stop App + + Simulator: + Bundle ID: com.nonexistent.app + +โŒ Failed to stop app: An error was encountered processing the command (domain=com.apple.CoreSimulator.SimError, code=164): found nothing to terminate +``` + +--- + +### project-discovery + +**`discover-projs--success.txt`**: +``` +๐Ÿ” Discover Projects + + Search Path: . + +Workspaces: + example_projects/iOS_Calculator/CalculatorApp.xcworkspace + +Projects: + example_projects/iOS_Calculator/CalculatorApp.xcodeproj + +Next steps: +1. Build and run: xcodebuildmcp simulator build-and-run +``` + +**`list-schemes--success.txt`**: +``` +๐Ÿ” List Schemes + + Workspace: example_projects/iOS_Calculator/CalculatorApp.xcworkspace + +Schemes: + CalculatorApp + CalculatorAppFeature + +Next steps: +1. Build for macOS: xcodebuildmcp macos build --workspace-path "example_projects/iOS_Calculator/CalculatorApp.xcworkspace" --scheme "CalculatorApp" +2. Build and run on simulator: xcodebuildmcp simulator build-and-run --workspace-path "example_projects/iOS_Calculator/CalculatorApp.xcworkspace" --scheme "CalculatorApp" --simulator-name "iPhone 17" +3. Build for simulator: xcodebuildmcp simulator build --workspace-path "example_projects/iOS_Calculator/CalculatorApp.xcworkspace" --scheme "CalculatorApp" --simulator-name "iPhone 17" +4. Show build settings: xcodebuildmcp device show-build-settings --workspace-path "example_projects/iOS_Calculator/CalculatorApp.xcworkspace" --scheme "CalculatorApp" +``` + +**`show-build-settings--success.txt`** โ€” curated summary (full dump behind `--verbose` flag): +``` +๐Ÿ” Show Build Settings + + Scheme: CalculatorApp + Workspace: example_projects/iOS_Calculator/CalculatorApp.xcworkspace + +Key Settings: + โ”œ PRODUCT_NAME: CalculatorApp + โ”œ PRODUCT_BUNDLE_IDENTIFIER: io.sentry.calculatorapp + โ”œ SDKROOT: iphoneos + โ”œ SUPPORTED_PLATFORMS: iphonesimulator iphoneos + โ”œ ARCHS: arm64 + โ”œ SWIFT_VERSION: 6.0 + โ”œ IPHONEOS_DEPLOYMENT_TARGET: 18.0 + โ”œ CODE_SIGNING_ALLOWED: YES + โ”œ CODE_SIGN_IDENTITY: Apple Development + โ”œ CONFIGURATION: Debug + โ”œ BUILD_DIR: /Library/Developer/Xcode/DerivedData/CalculatorApp-/Build/Products + โ”” BUILT_PRODUCTS_DIR: /Library/Developer/Xcode/DerivedData/CalculatorApp-/Build/Products/Debug-iphoneos + +Next steps: +1. Build for simulator: xcodebuildmcp simulator build --workspace-path "example_projects/iOS_Calculator/CalculatorApp.xcworkspace" --scheme "CalculatorApp" +``` + +--- + +### project-scaffolding + +**`scaffold-ios--success.txt`**: +``` +๐Ÿ—๏ธ Scaffold iOS Project + + Name: SnapshotTestApp + Path: /ios + Platform: iOS + +โœ… Project scaffolded successfully. + +Next steps: +1. Read the README.md in the workspace root directory before working on the project. +2. Build for simulator: xcodebuildmcp simulator build --workspace-path "/ios/SnapshotTestApp.xcworkspace" --scheme "SnapshotTestApp" --simulator-name "iPhone 17" +3. Build and run on simulator: xcodebuildmcp simulator build-and-run --workspace-path "/ios/SnapshotTestApp.xcworkspace" --scheme "SnapshotTestApp" --simulator-name "iPhone 17" +``` + +**`scaffold-ios--error-existing.txt`**: +``` +๐Ÿ—๏ธ Scaffold iOS Project + + Path: /ios-existing + +โŒ Xcode project files already exist in /ios-existing. +``` + +**`scaffold-macos--success.txt`**: +``` +๐Ÿ—๏ธ Scaffold macOS Project + + Name: SnapshotTestApp + Path: /macos + Platform: macOS + +โœ… Project scaffolded successfully. + +Next steps: +1. Build for macOS: xcodebuildmcp macos build --project-path "/macos/SnapshotTestApp.xcodeproj" --scheme "SnapshotTestApp" +2. Build and run on macOS: xcodebuildmcp macos build-and-run --project-path "/macos/SnapshotTestApp.xcodeproj" --scheme "SnapshotTestApp" +``` + +--- + +### device + +**`build--success.txt`** โ€” pipeline-rendered, review for unified UX consistency. + +**`build--failure-compilation.txt`** โ€” build ran but failed with compiler errors: +``` +๐Ÿ”จ Build + + Scheme: CalculatorApp + Workspace: example_projects/iOS_Calculator/CalculatorApp.xcworkspace + Configuration: Debug + Platform: iOS + +Errors (1): + โœ— CalculatorApp/CompileError.swift:3: Cannot convert value of type 'String' to specified type 'Int' + +โŒ Build failed. (โฑ๏ธ ) +``` + +**`get-app-path--success.txt`**: +``` +๐Ÿ” Get App Path + + Scheme: CalculatorApp + Workspace: example_projects/iOS_Calculator/CalculatorApp.xcworkspace + Configuration: Debug + Platform: iOS + + โ”” App Path: /Library/Developer/Xcode/DerivedData/CalculatorApp-/Build/Products/Debug-iphoneos/CalculatorApp.app + +Next steps: +1. Get bundle ID: xcodebuildmcp project-discovery get-app-bundle-id --app-path "..." +2. Install on device: xcodebuildmcp device install --app-path "..." +3. Launch on device: xcodebuildmcp device launch --bundle-id "BUNDLE_ID" +``` + +**`list--success.txt`**: +``` +๐Ÿ“ฑ List Devices + +โœ… Available Devices: + + Cameron's Apple Watch + โ”œ UDID: + โ”œ Model: Watch4,2 + โ”œ Platform: Unknown 10.6.1 + โ”œ CPU: arm64_32 + โ”” Developer Mode: disabled + + Cameron's Apple Watch + โ”œ UDID: + โ”œ Model: Watch7,20 + โ”œ Platform: Unknown 26.1 + โ”œ CPU: arm64e + โ”œ Connection: localNetwork + โ”” Developer Mode: disabled + + Cameron's iPhone 16 Pro Max + โ”œ UDID: + โ”œ Model: iPhone17,2 + โ”œ Platform: Unknown 26.3.1 + โ”œ CPU: arm64e + โ”œ Connection: localNetwork + โ”” Developer Mode: enabled + + iPhone + โ”œ UDID: + โ”œ Model: iPhone99,11 + โ”œ Platform: Unknown 26.1 + โ”” CPU: arm64e + +Next steps: +1. Build for device: xcodebuildmcp device build --scheme "SCHEME" --device-id "DEVICE_UDID" +2. Run tests on device: xcodebuildmcp device test --scheme "SCHEME" --device-id "DEVICE_UDID" +3. Get app path: xcodebuildmcp device get-app-path --scheme "SCHEME" +``` + +--- + +### macos + +**`build--success.txt`** โ€” pipeline-rendered, review for unified UX consistency. + +**`build--failure-compilation.txt`** โ€” build ran but failed with compiler errors: +``` +๐Ÿ”จ Build + + Scheme: MCPTest + Project: example_projects/macOS/MCPTest.xcodeproj + Configuration: Debug + Platform: macOS + +Errors (1): + โœ— MCPTest/CompileError.swift:3: Cannot convert value of type 'String' to specified type 'Int' + +โŒ Build failed. (โฑ๏ธ ) +``` + +**`build-and-run--success.txt`** โ€” pipeline-rendered, review for consistency. + +**`test--success.txt`** โ€” all tests pass (MCPTest has only passing tests). + +**`test--failure.txt`** โ€” tests ran, assertion failures (requires intentional failure in MCPTest): +``` +๐Ÿงช Test + + Scheme: MCPTest + Project: example_projects/macOS/MCPTest.xcodeproj + Configuration: Debug + Platform: macOS + +Resolved to test(s) + +Failures (1): + โœ— MCPTestTests.testIntentionalFailure โ€” Expectation failed + +โŒ Test failed. (, โฑ๏ธ ) +``` + +**`get-app-path--success.txt`** โ€” same pattern as simulator/device get-app-path. + +**`launch--error-invalid-app.txt`** โ€” real `open` CLI error (fake .app dir passes file-exists, open fails): +``` +๐Ÿš€ Launch macOS App + + App: /Fake.app + +โŒ Launch failed: The application cannot be opened because its executable is missing. +``` + +--- + +### swift-package + +**`build--success.txt`**: +``` +๐Ÿ“ฆ Swift Package Build + + Package: example_projects/SwiftPackage + +โœ… Build succeeded. () +``` + +**`build--error-bad-path.txt`** โ€” real swift CLI error (swift build runs and fails on missing path): +``` +๐Ÿ“ฆ Swift Package Build + + Package: example_projects/NONEXISTENT + +โŒ Build failed: No such file or directory: example_projects/NONEXISTENT +``` + +**`build--failure-compilation.txt`** โ€” build ran but failed with compiler errors: +``` +๐Ÿ“ฆ Swift Package Build + + Package: example_projects/SwiftPackage + +Errors (1): + โœ— Sources/CompileError.swift:3: Cannot convert value of type 'String' to specified type 'Int' + +โŒ Build failed. () +``` + +**`test--success.txt`**: +``` +๐Ÿงช Swift Package Test + + Package: example_projects/SwiftPackage + +โœ… All tests passed. (5 tests, ) + +Tests: + โœ” Array operations + โœ” Basic math operations + โœ” Basic truth assertions + โœ” Optional handling + โœ” String operations +``` + +**`test--failure.txt`** โ€” tests ran, assertion failures (requires intentional failure in SPM example): +``` +๐Ÿงช Swift Package Test + + Package: example_projects/SwiftPackage + +Failures (1): + โœ— IntentionalFailureTests.testShouldFail โ€” #expect failed + +โŒ Tests failed. (1 failure, ) +``` + +**`clean--success.txt`**: +``` +๐Ÿงน Swift Package Clean + + Package: example_projects/SwiftPackage + +โœ… Clean succeeded. Build artifacts removed. +``` + +**`list--success.txt`**: +``` +๐Ÿ“ฆ Swift Package List + +โ„น๏ธ No Swift Package processes currently running. +``` + +**`run--success.txt`**: +``` +๐Ÿ“ฆ Swift Package Run + + Package: example_projects/SwiftPackage + +โœ… Executable completed successfully. + +Output: + Hello, world! +``` + +--- + +### debugging + +**`attach--success.txt`** โ€” debugger attached to running simulator process: +``` +๐Ÿ› Attach Debugger + + Simulator: + +โœ… Attached LLDB to simulator process (). + + โ”œ Debug Session: + โ”” Status: Execution resumed after attach. + +Next steps: +1. Add breakpoint: xcodebuildmcp debugging add-breakpoint --file "..." --line 42 +2. View stack trace: xcodebuildmcp debugging stack +3. View variables: xcodebuildmcp debugging variables +``` + +**`add-breakpoint--success.txt`** โ€” breakpoint set at file:line: +``` +๐Ÿ› Add Breakpoint + + File: ContentView.swift + Line: 42 + +โœ… Breakpoint 1 set. + +Next steps: +1. Continue execution: xcodebuildmcp debugging continue +2. View stack trace: xcodebuildmcp debugging stack +3. View variables: xcodebuildmcp debugging variables +``` + +**`remove-breakpoint--success.txt`**: +``` +๐Ÿ› Remove Breakpoint + + Breakpoint: 1 + +โœ… Breakpoint 1 removed. +``` + +**`continue--success.txt`**: +``` +๐Ÿ› Continue + +โœ… Resumed debugger session. + +Next steps: +1. View stack trace: xcodebuildmcp debugging stack +2. View variables: xcodebuildmcp debugging variables +``` + +**`continue--error-no-session.txt`**: +``` +๐Ÿ› Continue + +โŒ No active debug session. Provide debugSessionId or attach first. +``` + +**`detach--success.txt`**: +``` +๐Ÿ› Detach + +โœ… Detached debugger session. +``` + +**`lldb-command--success.txt`** โ€” raw LLDB output passed through: +``` +๐Ÿ› LLDB Command + + Command: po self + + +``` + +**`stack--success.txt`** โ€” stack trace from paused process: +``` +๐Ÿ› Stack Trace + +* thread #1, queue = 'com.apple.main-thread', stop reason = breakpoint 1.1 + * frame #0: CalculatorApp`ContentView.body.getter at ContentView.swift:42 + frame #1: SwiftUI`ViewGraph.updateOutputs() + frame #2: SwiftUI`ViewRendererHost.render() +``` + +**`variables--success.txt`** โ€” variable dump from current frame: +``` +๐Ÿ› Variables + +(CalculatorService) self = { + โ”œ display = "0" + โ”œ expressionDisplay = "" + โ”œ currentValue = 0 + โ”œ previousValue = 0 + โ”” currentOperation = nil +} +``` + +--- + +### ui-automation + +**`snapshot-ui--success.txt`** โ€” accessibility tree with header prepended: +``` +๐Ÿ” Snapshot UI + + Simulator: + + +``` + +**`tap--error-no-simulator.txt`**: +``` +๐Ÿ‘† Tap + + Simulator: + Position: (100, 100) + +โŒ Failed to simulate tap: Simulator with UDID not found. +``` + +--- + +### utilities + +**`clean--success.txt`** โ€” pipeline-rendered, review for unified UX consistency. diff --git a/docs/dev/STRUCTURED_XCODEBUILD_EVENTS_PLAN.md b/docs/dev/STRUCTURED_XCODEBUILD_EVENTS_PLAN.md index 3c271b82..551bc3ec 100644 --- a/docs/dev/STRUCTURED_XCODEBUILD_EVENTS_PLAN.md +++ b/docs/dev/STRUCTURED_XCODEBUILD_EVENTS_PLAN.md @@ -1,458 +1,540 @@ -# Structured xcodebuild events plan +# Unified tool output pipeline ## Goal -Move every xcodebuild-backed tool in XcodeBuildMCP to a single structured streaming pipeline. +Every tool in XcodeBuildMCP must produce its output through a single structured pipeline. No tool may construct its own formatted text. The pipeline owns all rendering, spacing, path formatting, and section structure. -That pipeline must: +This applies to: -- parse xcodebuild output into structured events in real time -- stream those events immediately instead of waiting for command completion -- drive human-readable streaming output for MCP and CLI text mode -- drive streamed JSONL output for CLI JSON mode -- support manifest-driven next steps at the end of the stream +- xcodebuild-backed tools (build, test, build & run, clean) +- query tools (list simulators, list schemes, discover projects, show build settings) +- action tools (set appearance, set location, boot simulator, install app) +- coverage tools (coverage report, file coverage) +- scaffolding tools (scaffold iOS project, scaffold macOS project) +- logging tools (start/stop log capture) +- debugging tools (attach, breakpoints, variables) +- UI automation tools (tap, swipe, type text, screenshot, snapshot UI) +- session tools (set defaults, show defaults, clear defaults) -The source of truth is the structured event stream, not formatted text. +No exceptions. If a tool produces user-visible output, it goes through the pipeline. -## Current status +## Architecture principle -As of March 20, 2026: +One renderer, two sinks. -- shared xcodebuild event types, parser, run-state layer, and renderer set exist -- simulator, device, and macOS pure build tools use the pending pipeline model -- `build_run_macos` and `build_run_sim` are fully migrated to the canonical single-pipeline pattern -- CLI JSONL streaming exists for pipeline-backed tools -- MCP human-readable output is buffered from the same renderer family -- all error events (file-located and non-file) are grouped and rendered consistently +There is exactly one rendering path that converts structured events into formatted text. The only difference between CLI and MCP is where that text goes: -The remaining migration work is `build_run_device` and any test tool cleanup. +- CLI sink: writes formatted text to stdout as events arrive (streaming) +- MCP sink: buffers the same formatted text and returns it in `ToolResponse.content` -## Architecture principle +There is no separate MCP renderer. There is no separate CLI renderer. There is one renderer with one output format. The sinks are dumb pipes. -One model, one pipeline. Tools do not own rendering or formatting. They emit structured events into a shared pipeline, and the shared renderer family produces all user-visible output. +The only sink-level concerns are: -- CLI is a pure stream consumer -- MCP buffers the same streamed human-readable output and returns it in one final chunk -- there is no second presentation path that re-renders or replays final text after the stream +- CLI interactive mode: a Clack spinner for transient status updates (the rendered durable text is identical) +- Next steps syntax: CLI renders `xcodebuildmcp workflow tool --flag "value"`, MCP renders `tool_name({ param: "value" })`. This is a single parameterised formatting function, not a separate renderer. +- Warning suppression: a session-level filter applied before rendering, not a rendering concern. -The only runtime difference is the output sink: +## Why this matters -- CLI sink: stdout/stderr -- MCP sink: in-memory buffer returned as `ToolResponse.content` +Without a unified pipeline, every tool re-invents: -Not the rendering logic. +- spacing between sections (some add blank lines, some don't) +- file path formatting (some call `displayPath`, some don't) +- header/preflight structure (some use `formatToolPreflight`, some build strings manually) +- error formatting (some use icons, some use `[NOT COVERED]`, some use bare text) +- next steps rendering (some hardcode strings, some use the manifest) -## Requirements +Every new tool or refactor re-introduces the same bugs. The pipeline makes these bugs structurally impossible. -These are requirements, not nice-to-haves. +## Event model -### 1. All xcodebuild-derived tools use the same model +All tools emit structured events. The renderer converts events to formatted text. Tools never produce formatted text directly. -This applies to all tools whose primary execution path is xcodebuild-derived, including: +### Generic tool events -- simulator build tools -- simulator test tools -- device build tools -- device test tools -- macOS build tools -- macOS test tools -- any other tool that surfaces xcodebuild phases, warnings, errors, or summaries +These events cover all non-xcodebuild tools: -### 2. Streaming is required +```ts +type ToolEvent = + | HeaderEvent // preflight block: operation name + params + | SectionEvent // titled group of content lines + | DetailTreeEvent // key/value pairs with tree connectors + | StatusLineEvent // single status message (success, error, info) + | FileRefEvent // a file path (always normalised) + | TableEvent // rows of structured data + | SummaryEvent // final outcome line + | NextStepsEvent // suggested follow-up actions + | XcodebuildEvent; // existing xcodebuild events (unchanged) +``` -Tools must emit output as soon as they have meaningful state to report. +#### HeaderEvent -They should also emit an immediate startup event/output block before xcodebuild produces its first meaningful line. In most cases that startup output should echo the important input parameters so the caller can immediately see what is being attempted. +Replaces `formatToolPreflight`. Every tool starts with a header. -We do not want to wait until the end of execution and then dump a final result. +```ts +interface HeaderEvent { + type: 'header'; + operation: string; // e.g. 'File Coverage', 'List Simulators', 'Set Appearance' + params: Array<{ // rendered as indented key: value lines + label: string; + value: string; + }>; + timestamp: string; +} +``` -This matters for: +The renderer owns: -- agent responsiveness -- long-running builds -- package resolution visibility -- compile phase visibility -- test execution visibility -- reducing timeouts and dead-air during execution +- the emoji (looked up from the operation name) +- the blank line after the heading +- the indentation of params +- the trailing blank line after the params block -### 3. MCP output changes too +Tools cannot get the spacing wrong because they never produce it. -The plan does require changing the output shape used by xcodebuild-backed MCP tools. +#### SectionEvent -We do not need to preserve the current human-readable MCP output contract for these tools. +A titled group of content lines with an optional icon. -The MCP API contract is still the same: +```ts +interface SectionEvent { + type: 'section'; + title: string; // e.g. 'Not Covered (7 functions, 22 lines)' + icon?: 'red-circle' | 'yellow-circle' | 'green-circle' | 'checkmark' | 'cross' | 'info'; + lines: string[]; // indented content lines + timestamp: string; +} +``` -- same tools -- same arguments -- same MCP transport +The renderer owns: -But the streamed output content from xcodebuild-backed tools can and should change. +- the icon-to-emoji mapping +- the blank line before and after each section +- the indentation of content lines -### 4. CLI JSON output is streamed JSONL +#### DetailTreeEvent -CLI JSON output should be one mode only: +Key/value pairs rendered with tree connectors. -- streamed JSONL +```ts +interface DetailTreeEvent { + type: 'detail-tree'; + items: Array<{ label: string; value: string }>; + timestamp: string; +} +``` -We do not need multiple JSON modes like: +Rendered as: -- final JSON blob -- JSON events -- NDJSON as a separate concept +```text + โ”œ App Path: /path/to/app + โ”” Bundle ID: com.example.app +``` -For this plan, CLI JSON output means line-delimited streamed JSON events. +The renderer owns the connector characters and indentation. -### 5. MCP remains human-readable +#### StatusLineEvent -MCP should keep receiving human-readable streamed output. +A single status message. -It does not need a JSON mode. +```ts +interface StatusLineEvent { + type: 'status-line'; + level: 'success' | 'error' | 'info' | 'warning'; + message: string; + timestamp: string; +} +``` -The important point is that the human-readable MCP output must now be rendered from the same structured event stream that powers CLI JSONL and CLI text mode. +The renderer owns the emoji prefix based on level. -### 6. We are not building a full-screen UI +#### FileRefEvent -We are not building a full-screen terminal application. +A file path that must be normalised. -The target is the hybrid streaming approach we already used successfully for the simulator test tool: +```ts +interface FileRefEvent { + type: 'file-ref'; + label?: string; // e.g. 'File' โ€” rendered as "File: " + path: string; // raw absolute path from the tool + timestamp: string; +} +``` -- live transient status updates where appropriate -- durable streamed lines for warnings, errors, failures, and summaries -- clear final summary output +The renderer always runs the path through `displayPath()` (relative if under cwd, absolute otherwise). Tools cannot bypass this. -Visual parity with Flowdeck matters less than data-model parity and responsiveness. +#### TableEvent -## Desired behavior +Rows of structured data grouped under an optional heading. -All xcodebuild-backed tools should expose the same kind of live milestones and diagnostics. +```ts +interface TableEvent { + type: 'table'; + heading?: string; // e.g. 'iOS 18.5' + columns: string[]; // column names for alignment + rows: Array>; + timestamp: string; +} +``` -Examples: +The renderer owns column alignment and indentation. -- tool starts -> stream an immediate scoped start event with the key input params -- package resolution starts -> stream a structured status event -- compiling starts -> stream a structured status event -- warning found -> stream a structured warning event -- compiler error found -> stream a structured error event -- tests begin -> stream a structured status event -- test progress changes -> stream a structured progress event -- test failure found -> stream a structured failure event -- run completes -> stream a structured summary event -- next steps available -> stream a final next-steps event or final rendered next-steps block +#### SummaryEvent (generic) -## Output modes +A final outcome line for non-xcodebuild tools. Different from the xcodebuild `SummaryEvent` which includes test counts and duration. -## MCP mode +```ts +interface GenericSummaryEvent { + type: 'generic-summary'; + level: 'success' | 'error'; + message: string; + timestamp: string; +} +``` -MCP mode should receive streamed human-readable output rendered from the structured event stream. +#### NextStepsEvent -That includes: +Unchanged from the existing model. Parameterised rendering for CLI vs MCP syntax. -- milestones -- warnings -- errors -- test progress -- summaries -- final next steps +### Xcodebuild events -## CLI text mode +The existing `XcodebuildEvent` union type is unchanged. Xcodebuild-backed tools continue to use: -CLI text mode should render the same stream into terminal-friendly output. +- `start` (replaces `HeaderEvent` for xcodebuild tools โ€” the start event already contains the preflight) +- `status`, `warning`, `error`, `notice` +- `test-discovery`, `test-progress`, `test-failure` +- `summary` +- `next-steps` -That includes: +The xcodebuild event parser feeds these into the same pipeline. The renderer handles both generic tool events and xcodebuild events. -- Clack-driven transient progress updates where useful -- durable diagnostics and summaries -- final next steps +## Pipeline architecture -## CLI JSON mode +### For xcodebuild-backed tools (existing, unchanged) -CLI JSON mode should emit structured JSONL. +```text +tool logic + -> startBuildPipeline(...) + -> XcodebuildPipeline + -> parser + run-state + -> ordered XcodebuildEvent stream + -> renderer -> sink (stdout or buffer) +``` -Each line is one event. +This path remains as-is. The xcodebuild parser, run-state layer, and event types do not change. -Example: +### For all other tools (new) -```json -{"type":"status","operation":"TEST","stage":"RESOLVING_PACKAGES","message":"Resolving Package Graph...","timestamp":"2026-03-17T08:27:34.175Z"} -{"type":"status","operation":"TEST","stage":"COMPILING","message":"Compiling...","timestamp":"2026-03-17T08:27:39.834Z"} -{"type":"status","operation":"TEST","stage":"RUN_TESTS","message":"Running tests...","timestamp":"2026-03-17T08:28:41.875Z"} -{"type":"test-progress","operation":"TEST","completed":7,"failed":0,"skipped":0,"timestamp":"2026-03-17T08:28:50.101Z"} -{"type":"summary","operation":"TEST","status":"FAILED","totalTests":21,"passedTests":20,"failedTests":1,"skippedTests":0,"durationMs":28080,"timestamp":"2026-03-17T08:28:59.000Z"} +```text +tool logic + -> emits ToolEvent[] (or streams them) + -> renderer -> sink (stdout or buffer) ``` -## Architecture design +Simple tools emit events synchronously and return them. The pipeline renders them and routes to the appropriate sink. + +There is no parser or run-state layer for non-xcodebuild tools. They don't need one โ€” they already have structured data. The pipeline is just: structured events -> renderer -> sink. + +### Mermaid diagram + +```mermaid +flowchart LR + subgraph "Xcodebuild tools" + A[Tool logic] --> B[XcodebuildPipeline] + B --> C[Event parser] + B --> D[Run-state] + C --> D + D --> E[XcodebuildEvent stream] + end + + subgraph "All other tools" + F[Tool logic] --> G[ToolEvent stream] + end + + E --> H[Unified renderer] + G --> H + + H --> I{Runtime?} + I -->|CLI| J[stdout sink] + I -->|MCP / Daemon| K[Buffer sink] + + J --> L[Streaming text to terminal] + K --> M[ToolResponse.content] + + H --> N{CLI JSON mode?} + N -->|Yes| O[JSONL sink] + O --> P[Streaming JSON to stdout] +``` -The architecture should be explicitly layered. +### Sink behaviour -## Concrete architecture flow +#### CLI stdout sink -This section describes the architecture using the actual components that exist in the codebase today. +- Writes each rendered line to stdout immediately +- In interactive TTY mode: uses Clack spinner for transient status events, replaces in place +- In non-interactive mode: writes all events as durable lines +- Formatting is identical to MCP โ€” the only difference is transient spinner behaviour -The important shape is: +#### MCP buffer sink -- one ordered event stream -- one run-state / aggregation layer -- one renderer family -- different sinks only at the end +- Buffers all rendered text +- Returns as `ToolResponse.content` when the tool completes +- Identical formatting to CLI non-interactive mode -The flow is linear until rendering: +#### CLI JSONL sink -```text -tool logic --> startBuildPipeline(...) --> XcodebuildPipeline --> parser + run-state --> ordered structured events --> renderer fork --> MCP buffer or CLI stdout +- Serialises each event as one JSON line to stdout +- Does not go through the text renderer +- Only available for xcodebuild-backed tools (they have a rich event model) +- Non-xcodebuild tools do not need JSONL โ€” their output is simple enough that text suffices + +## Renderer contract + +One renderer. One set of formatting rules. All tools. + +```ts +interface ToolOutputRenderer { + onEvent(event: ToolEvent | XcodebuildEvent): void; + finalize(): string[]; // returns buffered lines (used by MCP sink) +} ``` -More concretely: +The renderer is the single source of truth for: -1. tool logic creates the pipeline with `startBuildPipeline(...)` from `src/utils/xcodebuild-pipeline.ts` -2. `startBuildPipeline(...)` creates an `XcodebuildPipeline` and emits the initial `start` event -3. raw `xcodebuild` stdout/stderr chunks are sent into `createXcodebuildEventParser(...)` from `src/utils/xcodebuild-event-parser.ts` -4. the parser emits structured events into `createXcodebuildRunState(...)` from `src/utils/xcodebuild-run-state.ts` -5. tool-owned events such as preflight, app-path, install, launch, and post-build errors also enter that same run-state through `pipeline.emitEvent(...)` -6. run-state dedupes, orders, aggregates, and forwards each accepted event to the configured renderers -7. renderers consume the same event stream: - - `src/utils/renderers/mcp-renderer.ts` - - `src/utils/renderers/cli-text-renderer.ts` - - `src/utils/renderers/cli-jsonl-renderer.ts` -8. at finalize time, the pipeline emits the final summary and final next-steps event in the same stream order +- emoji selection per operation/level/icon +- spacing between sections (always one blank line) +- file path normalisation (always `displayPath()`) +- indentation depth (always 2 spaces for params, content lines) +- tree connector characters +- next steps formatting (parameterised by runtime) +- section ordering enforcement -### Mermaid diagram +### Formatting rules enforced by the renderer -```mermaid -flowchart LR - A[Tool logic
build_sim / build_run_sim / test_sim / etc.] --> B[startBuildPipeline
src/utils/xcodebuild-pipeline.ts] +These rules are not guidelines. They are enforced structurally because tools cannot produce formatted text. - B --> C[XcodebuildPipeline
src/utils/xcodebuild-pipeline.ts] +1. **Header always has a trailing blank line.** The renderer emits: blank line, emoji + operation, blank line, indented params, blank line. Every tool. No exceptions. - C --> D[createXcodebuildEventParser
src/utils/xcodebuild-event-parser.ts] - C --> E[createXcodebuildRunState
src/utils/xcodebuild-run-state.ts] +2. **File paths are always normalised.** `FileRefEvent` paths always go through `displayPath()`. Xcodebuild diagnostic paths go through `formatDiagnosticFilePath()`. There is no code path where a raw absolute path reaches the output. - F[xcodebuild stdout/stderr] --> D - G[tool-emitted events
pipeline.emitEvent(...)] --> E +3. **Sections are always separated by blank lines.** The renderer adds one blank line before each section. Tools cannot omit or double this. - D --> E +4. **Icons are always consistent.** The renderer maps `icon` enum values to emoji. Tools do not contain emoji characters. - E --> H[ordered structured event stream] +5. **Next steps are always last.** The renderer enforces ordering. Nothing renders after next steps. - H --> I[MCP renderer
src/utils/renderers/mcp-renderer.ts] - H --> J[CLI text renderer
src/utils/renderers/cli-text-renderer.ts] - H --> K[CLI JSONL renderer
src/utils/renderers/cli-jsonl-renderer.ts] +6. **Error messages follow the convention.** `Failed to : `. The renderer does not enforce this (it's a content concern), but the pipeline API makes it easy to follow. - I --> L[mcpRenderer.getContent()] - L --> M[ToolResponse.content] +## How tools emit events - J --> N[process.stdout text stream] - K --> O[process.stdout JSONL stream] -``` +### Simple action tools (e.g. set appearance) -### Event order within one run +```ts +return toolResponse([ + header('Set Appearance', [ + { label: 'Simulator', value: simulatorId }, + ]), + statusLine('success', `Appearance set to ${mode} mode`), +]); +``` -Within a single xcodebuild-backed tool run, the desired event order is: +### Query tools (e.g. list simulators) -```text -start --> parsed xcodebuild milestones / diagnostics / progress --> tool-emitted post-build notices or errors --> summary --> next-steps +```ts +return toolResponse([ + header('List Simulators'), + ...grouped.map(([runtime, devices]) => + table(runtime, ['Name', 'UUID', 'State'], + devices.map(d => ({ Name: d.name, UUID: d.udid, State: d.state })) + ) + ), + nextSteps([...]), +]); ``` -That ordering matters because: +### Coverage tools (e.g. file coverage) -- summaries must describe the final known run state -- next steps must be rendered from the same shared stream -- MCP and CLI should differ only in sink behavior, not in event order or formatting ownership +```ts +return toolResponse([ + header('File Coverage', [ + { label: 'xcresult', value: xcresultPath }, + { label: 'File', value: file }, + ]), + fileRef('File', entry.filePath), + statusLine('info', `Coverage: ${pct}% (${covered}/${total} lines)`), + section('Not Covered', notCoveredLines, { icon: 'red-circle', + title: `Not Covered (${count} functions, ${missedLines} lines)` }), + section('Partial Coverage', partialLines, { icon: 'yellow-circle', + title: `Partial Coverage (${count} functions)` }), + section('Full Coverage', [`${fullCount} functions โ€” all at 100%`], { icon: 'green-circle', + title: `Full Coverage (${fullCount} functions) โ€” all at 100%` }), + nextSteps([...]), +]); +``` -## 1. xcodebuild execution layer +### Xcodebuild tools -Responsibility: +These keep the existing parser and run-state layers (`startBuildPipeline()`, `executeXcodeBuildCommand()`, `createPendingXcodebuildResponse()`), but the run-state output gets mapped to `ToolEvent` types before reaching the renderer. The xcodebuild parser remains an ingestion layer โ€” it just feeds into the unified event model instead of having its own rendering path. Streaming and Clack progress are preserved as CLI sink concerns. -- launch xcodebuild -- stream stdout/stderr chunks as they arrive -- support single-phase and multi-phase execution -- attach command context such as operation type and phase +## Locked human-readable output contract -Examples: +The output structure for all tools follows the same rhythm: -- `build` -- `test` -- `build-for-testing` -- `test-without-building` +```text + -This layer should not format user-facing output. + : + : -It should only execute commands and feed chunks into the parser pipeline. + -## 2. structured event parser layer + -Responsibility: + -- consume stdout/stderr incrementally -- parse lines into semantic events -- emit events immediately when they are recognized -- combine parser-derived events with tool-emitted startup/context events +Next steps: +1. +2. +``` -This is the core of the system. +### For xcodebuild-backed tools -It should understand: +The canonical examples are `build_run_macos` and `build_run_sim`. Their output contract is locked: -- package resolution milestones -- compile/link milestones -- build warnings/errors -- test start -- test case progress -- test failures -- totals and summaries -- multi-phase continuation rules +Successful runs: -This is where phase-aware behavior belongs. +1. front matter (header event / start event) +2. runtime state and durable diagnostics +3. summary +4. execution-derived footer (detail tree) +5. next steps + +Failed runs: -For example, in a two-phase simulator test run: +1. front matter +2. runtime state and/or grouped diagnostics +3. summary -- phase 1 may emit `RESOLVING_PACKAGES` and `COMPILING` -- phase 2 may continue directly into `RUN_TESTS` -- the parser/state model should avoid regressing the visible timeline back to an earlier stage unless the new run genuinely restarted +Failed runs do not render next steps. -## 3. shared run-state / aggregation layer +### For non-xcodebuild tools -Responsibility: +Successful runs: -- maintain the current known state of the run -- dedupe and order milestones -- aggregate progress counts -- group failures and diagnostics -- compute final summary information -- retain enough state for end-of-run rendering +1. header +2. body (sections, tables, file refs, status lines โ€” tool-specific) +3. next steps (if applicable) -This layer exists so that renderers do not need to reconstruct state themselves. +Failed runs: -Examples of tracked state: +1. header +2. error status line +3. no next steps -- current operation -- echoed input params / initial tool context -- latest stage -- seen milestones -- warnings/errors -- discovered tests -- completed/failed/skipped counts -- failure details by target/test -- wall-clock duration -- final success/failure state +### Example outputs -## 4. renderer layer +#### Build (xcodebuild pipeline โ€” existing) -Responsibility: +```text +๐Ÿ”จ Build -- consume structured events plus shared run-state -- produce mode-specific output + Scheme: CalculatorApp + Workspace: example_projects/iOS_Calculator/CalculatorApp.xcworkspace + Configuration: Debug + Platform: iOS Simulator + Simulator: iPhone 17 -Renderers required: +โœ… Build succeeded. (โฑ๏ธ 12.3s) -### MCP human-readable renderer +Next steps: +1. Get built app path: xcodebuildmcp simulator get-app-path --scheme "CalculatorApp" +``` -- turns events into streamed text blocks/items for MCP responses -- remains human-readable -- appends manifest-driven next steps at the end -- buffers the rendered stream so the final `ToolResponse.content` is just the captured stream output -- does not maintain a separate final formatting path +#### File Coverage (generic pipeline โ€” new) -### CLI text renderer +```text +๐Ÿ“Š File Coverage -- turns events into streamed terminal output -- uses Clack where transient updates help -- writes durable diagnostics as normal lines -- appends next steps at the end -- is the only text presentation path for CLI xcodebuild-backed tools -- does not rely on final `ToolResponse.content` replay + xcresult: /tmp/TestResults.xcresult + File: CalculatorService.swift -### CLI JSONL renderer +File: example_projects/.../CalculatorService.swift +Coverage: 83.1% (157/189 lines) -- serializes each structured event as one JSON line -- does not invent a separate event model -- appends next steps as structured final events or final rendered line events, depending on the chosen schema +๐Ÿ”ด Not Covered (7 functions, 22 lines) + L159 CalculatorService.deleteLastDigit() โ€” 0/16 lines + L58 implicit closure #2 in inputNumber(_:) โ€” 0/1 lines -## 5. tool integration layer +๐ŸŸก Partial Coverage (4 functions) + L184 updateExpressionDisplay() โ€” 80.0% (8/10 lines) + L195 formatNumber(_:) โ€” 85.7% (18/21 lines) -Responsibility: +๐ŸŸข Full Coverage (28 functions) โ€” all at 100% -- tool decides what command(s) to run -- tool provides context such as platform, build vs test, selectors, preflight data -- tool selects the shared xcodebuild execution pipeline -- tool does not own custom raw parsing logic +Next steps: +1. View overall coverage: xcodebuildmcp coverage get-coverage-report --xcresult-path "/tmp/TestResults.xcresult" +``` -This is the layer where simulator/device/macOS differences belong. +#### List Simulators (generic pipeline โ€” new) -## Canonical reference pattern to copy +```text +๐Ÿ“ฑ List Simulators -The canonical reference implementations are: +iOS 18.5: + iPhone 16 Pro A1B2C3D4-... Booted + iPhone 16 E5F6G7H8-... Shutdown + iPad Pro 13" I9J0K1L2-... Shutdown -- `src/mcp/tools/macos/build_run_macos.ts` โ€” simplest build-and-run (no simulator/device steps) -- `src/mcp/tools/simulator/build_run_sim.ts` โ€” build-and-run with simulator post-build steps (boot, install, launch) +iOS 17.5: + iPhone 15 M3N4O5P6-... Shutdown -Use these files as templates for remaining build-and-run migrations. Do not invent new patterns. +Next steps: +1. Boot simulator: xcodebuildmcp simulator-management boot --simulator-id "UUID" +``` -### Concrete API reference +#### Set Appearance (generic pipeline โ€” new) -#### Functions to use +```text +๐ŸŽจ Set Appearance -| Function | Module | Purpose | -|---|---|---| -| `startBuildPipeline` | `src/utils/xcodebuild-pipeline.ts` | Create pipeline, emit start event | -| `executeXcodeBuildCommand` | `src/utils/build/index.ts` | Run xcodebuild with pipeline attached | -| `createPendingXcodebuildResponse` | `src/utils/xcodebuild-output.ts` | Return a pending response (ALL return paths) | -| `emitPipelineNotice` | `src/utils/xcodebuild-output.ts` | Emit post-build progress into pipeline | -| `emitPipelineError` | `src/utils/xcodebuild-output.ts` | Emit post-build failure into pipeline | -| `formatToolPreflight` | `src/utils/build-preflight.ts` | Format the front-matter preflight block | + Simulator: A1B2C3D4-E5F6-... -#### Functions NOT to use in migrated tools +โœ… Appearance set to dark mode +``` -These are transitional helpers from the old architecture. Do not use them in newly migrated tools: +#### Discover Projects (generic pipeline โ€” new) -| Function | Why not | -|---|---| -| `finalizeBuildPhase` | Finalizes pipeline too early; build-and-run tools must keep the pipeline open through post-build steps | -| `createPostBuildError` | Appends content after pipeline finalization; use `emitPipelineError` + `createPendingXcodebuildResponse` instead | -| `appendStructuredEvents` | Appends events after finalization; emit events into the pipeline before finalization instead | -| `createCompletionStatusEvent` | Creates a status event outside the pipeline; use `tailEvents` in `createPendingXcodebuildResponse` instead | -| `finalizeBuildPipelineResult` | Old finalization path; use `createPendingXcodebuildResponse` which defers finalization to `postProcessToolResponse` | +```text +๐Ÿ” Discover Projects -### Canonical shape + Search Path: . -For a normal build-and-run tool, the pattern to copy is: +Workspaces: + example_projects/iOS_Calculator/CalculatorApp.xcworkspace -1. call `startBuildPipeline(...)` -2. run `executeXcodeBuildCommand(..., started.pipeline)` -3. if build fails, return `createPendingXcodebuildResponse(started, buildResult, { errorFallbackPolicy: 'if-no-structured-diagnostics' })` -4. keep the same pipeline open for post-build steps -5. emit post-build progress with `emitPipelineNotice(...)` using `code: 'build-run-step'` -6. emit post-build failures with `emitPipelineError(...)` using `Failed to : ` message format -7. do not append success/error text after pipeline finalization -8. do not create a second status/completion event path outside the pipeline -9. return one `createPendingXcodebuildResponse(...)` with `tailEvents` for the success footer -10. let the shared finalization path own summary and final next-steps ordering +Projects: + example_projects/iOS_Calculator/CalculatorApp.xcodeproj -### Pending response lifecycle +Next steps: +1. List schemes: xcodebuildmcp project-discovery list-schemes --workspace-path "example_projects/iOS_Calculator/CalculatorApp.xcworkspace" +``` -The tool never finalizes the pipeline directly. Instead: +## Xcodebuild pipeline specifics -1. tool returns `createPendingXcodebuildResponse(started, response, options)` โ€” this stores the pipeline in `_meta.pendingXcodebuild` -2. `postProcessToolResponse` in `src/runtime/tool-invoker.ts` detects the pending state via `isPendingXcodebuildResponse` -3. it resolves manifest-driven next-step templates against the response's `nextStepParams` -4. it calls `finalizePendingXcodebuildResponse` which finalizes the pipeline, emitting summary + tail events + next-steps in correct order -5. the finalized pipeline content becomes the final `ToolResponse.content` +The existing xcodebuild pipeline architecture is preserved. This section documents it for reference. -Key options on `createPendingXcodebuildResponse`: +### Execution flow -- `errorFallbackPolicy: 'if-no-structured-diagnostics'` โ€” for build failures, only include raw xcodebuild output if the parser found no structured errors (avoids duplicating errors that are already in the grouped block) -- `tailEvents` โ€” events emitted after the summary but before next-steps (used for the `build-run-result` footer notice) +1. Tool calls `startBuildPipeline(...)` from `src/utils/xcodebuild-pipeline.ts` +2. Pipeline creates parser and run-state, emits initial `start` event +3. Raw stdout/stderr chunks feed into `createXcodebuildEventParser(...)` +4. Parser emits structured events into `createXcodebuildRunState(...)` +5. Tool-emitted events (post-build notices, errors) enter run-state through `pipeline.emitEvent(...)` +6. Run-state dedupes, orders, aggregates, forwards to the unified renderer +7. On finalize: summary + tail events + next-steps emitted in order -### Minimal pseudocode pattern +### Canonical pattern ```ts const started = startBuildPipeline({ @@ -474,22 +556,8 @@ emitPipelineNotice(started, 'BUILD', 'Resolving app path', 'info', { code: 'build-run-step', data: { step: 'resolve-app-path', status: 'started' }, }); -// ... resolve ... -emitPipelineNotice(started, 'BUILD', 'App path resolved', 'success', { - code: 'build-run-step', - data: { step: 'resolve-app-path', status: 'succeeded', appPath }, -}); -emitPipelineNotice(started, 'BUILD', 'Launching app', 'info', { - code: 'build-run-step', - data: { step: 'launch-app', status: 'started', appPath }, -}); -// ... launch ... - -if (!launchResult.success) { - emitPipelineError(started, 'BUILD', `Failed to launch app ${appPath}: ${launchResult.error}`); - return createPendingXcodebuildResponse(started, { content: [], isError: true }); -} +// ... resolve, boot, install, launch ... return createPendingXcodebuildResponse( started, @@ -508,658 +576,92 @@ return createPendingXcodebuildResponse( ); ``` -### Post-build step notices - -Post-build workflow steps use structured `notice` events with specific codes: - -**`build-run-step` notices** โ€” drive transient CLI progress and durable MCP output: - -```ts -emitPipelineNotice(started, 'BUILD', 'Resolving app path', 'info', { - code: 'build-run-step', - data: { step: 'resolve-app-path', status: 'started' }, -}); -``` - -Available step names are defined in `BuildRunStepName` in `src/types/xcodebuild-events.ts`: - -- `resolve-app-path` โ€” resolving the built app bundle path -- `resolve-simulator` โ€” resolving simulator UUID from name -- `boot-simulator` โ€” booting the simulator -- `install-app` โ€” installing the app on simulator/device -- `extract-bundle-id` โ€” extracting the bundle ID from the app -- `launch-app` โ€” launching the app - -To add new step names: extend `BuildRunStepName` in `src/types/xcodebuild-events.ts` and add the label in `formatBuildRunStepLabel` in `src/utils/renderers/event-formatting.ts`. - -**`build-run-result` notice** โ€” drives the execution-derived footer: - -```ts -{ - type: 'notice', - code: 'build-run-result', - data: { scheme, platform, target, appPath, bundleId, launchState: 'requested' }, -} -``` - -This renders as the tree-formatted footer after the summary. Only include execution-derived values (appPath, bundleId, processId). Do not repeat front-matter values (scheme, platform, configuration). - -### Error message format convention - -All post-build error messages emitted via `emitPipelineError` must use the format: - -``` -Failed to : -``` - -Examples: - -- `Failed to get app path to launch: Could not extract app path from build settings.` -- `Failed to boot simulator: Device not found` -- `Failed to install app on simulator: Permission denied` -- `Failed to launch app /path/to/MyApp.app: App crashed on launch` - -Do not use `Error :` or other ad-hoc formats. - -### Rules to preserve when copying this pattern - -- keep the pipeline open until the tool genuinely knows the final state -- all user-visible post-build progress must become structured events -- use the pipeline as the only user-visible output path -- do not preserve legacy append/replay helpers โ€œjust in caseโ€ -- if a tool needs extra context, emit it as an event instead of formatting text later -- the tool function signature is `(params, executor) => Promise` โ€” no `executeXcodeBuildCommandFn` injection parameter - -## Locked human-readable output contract - -The current `build_run_macos` CLI/MCP presentation is now the formatting contract to preserve. - -This is not a suggestion. Future xcodebuild-backed build-and-run tools should copy this output structure unless there is a clear, user-approved reason to differ. - -### Canonical success and failure flows - -For xcodebuild-backed tools that follow the canonical human-readable contract, the output order is now locked. - -Successful runs must render: - -1. front matter -2. runtime state and durable diagnostics -3. summary -4. execution-derived footer -5. next steps - -Failed runs must render: - -1. front matter -2. runtime state and/or grouped diagnostics -3. summary - -Failed structured xcodebuild runs must not render next steps. - -### Canonical `build_run_macos` example - -Happy path shape: - -```text -๐Ÿš€ Build & Run - - Scheme: MCPTest - Project: example_projects/macOS/MCPTest.xcodeproj - Configuration: Debug - Platform: macOS - -โ€บ Linking - -โœ… Build succeeded. (โฑ๏ธ 6.8s) -โœ… Build & Run complete - - โ”” App Path: /tmp/xcodebuildmcp-macos-cli/Build/Products/Debug/MCPTest.app - -Next steps: -1. Interact with the launched app in the foreground -``` - -Sad path โ€” compiler error: - -```text -๐Ÿš€ Build & Run - - Scheme: MCPTest - Project: example_projects/macOS/MCPTest.xcodeproj - Configuration: Debug - Platform: macOS - -โ€บ Linking - -Compiler Errors (1): - - โœ— unterminated string literal - example_projects/macOS/MCPTest/ContentView.swift:16:18 - -โŒ Build failed. (โฑ๏ธ 4.0s) -``` - -Sad path โ€” non-file error (e.g. wrong scheme name, destination not found): - -```text -๐Ÿš€ Build & Run - - Scheme: CalculatorAPp - Workspace: example_projects/iOS_Calculator/CalculatorApp.xcworkspace - Configuration: Debug - Platform: iOS Simulator - Simulator: iPhone 17 - -Errors (1): - - โœ— The workspace named "CalculatorApp" does not contain a scheme named "CalculatorAPp". - -โŒ Build failed. (โฑ๏ธ 2.7s) -``` - -Sad path โ€” multi-line error (e.g. destination specifier not found): - -```text -๐Ÿš€ Build & Run - - Scheme: CalculatorApp - Workspace: example_projects/iOS_Calculator/CalculatorApp.xcworkspace - Configuration: Debug - Platform: iOS Simulator - Simulator: iPhone 22 - -Errors (1): - - โœ— Unable to find a device matching the provided destination specifier: - { platform:iOS Simulator, name:iPhone 22, OS:latest } - -โŒ Build failed. (โฑ๏ธ 60.7s) -``` - -These examples are the template for future xcodebuild-backed tool UX. - -### 1. Front matter is a durable section - -The start/preflight block is durable and emitted once at the beginning of the run. - -Its shape is: - -1. one blank line before the heading -2. a heading line such as `๐Ÿš€ Build & Run` -3. one blank line after the heading -4. indented detail lines such as scheme, project/workspace, configuration, platform - -Those values are request/preflight values. - -They belong in front matter, not in the final footer. - -### 2. There is one visual boundary before runtime state - -After front matter, there is one blank-line boundary before the runtime state begins. - -For CLI text mode, that means the first active phase update must not be butted directly against the last front-matter detail line. - -For MCP, the same sections are buffered in the same order. MCP does not get a different formatting model; it just buffers the rendered sections instead of streaming them live to stdout. - -### 3. Interactive CLI runtime state is transient - -In interactive CLI text mode: - -- active phases use Clack-driven replace-in-place updates -- active build/test steps should not be emitted as a sequence of durable milestone lines while they are still in progress - -Examples: - -- `Compiling...` -- `Linking...` -- `Resolving app path...` -- `Launching app...` - -This is the runtime-state area of the UI, not the durable log area. - -### 4. Durable lines are reserved for lasting information - -Durable streamed lines are appropriate for: - -- warnings -- errors -- test failures -- completed workflow checkpoints when we want the final stream to retain them -- final summary -- final footer -- next steps - -They are not the default for active phase updates in interactive CLI mode. - -### 5. The final footer is execution-derived only - -The footer after the summary should contain only values learned or confirmed during execution. - -Examples of acceptable footer fields: - -- app path -- bundle ID -- app ID -- process ID -- other runtime identifiers only if they were genuinely discovered during the run - -Examples of fields that must not be repeated in the footer if they were already shown in front matter: - -- scheme -- project/workspace path -- configuration -- platform -- target labels that are just restating the selected platform/context - -This also means we should prefer showing concrete derived values directly in the footer instead of relegating them to hints or next steps when the tool already knows them. - -For example: - -- if the tool resolved the built app path, show it in the footer -- do not keep a redundant "get app path" next step just to restate a value we already computed - -In other words: - -- front matter = requested configuration -- runtime state = currently active work -- footer = execution-derived result data -- next steps = remaining actions the user may want to take next - -### 6. Next steps are always last - -The human-readable order for a completed run is: - -1. front matter -2. runtime state / diagnostics -3. summary -4. execution-derived footer -5. next steps - -Nothing should render after next steps for that run. +### Pending response lifecycle -### 7. MCP uses the same semantics +1. Tool returns `createPendingXcodebuildResponse(started, response, options)` +2. `postProcessToolResponse` in `src/runtime/tool-invoker.ts` detects the pending state +3. Resolves manifest-driven next-step templates against `nextStepParams` +4. Calls `finalizePendingXcodebuildResponse` which finalizes the pipeline +5. Finalized content becomes `ToolResponse.content` -MCP does not get a different presentation contract. +### Post-build step notices -The only difference is the sink: +Post-build steps use `notice` events with `code: 'build-run-step'`: -- CLI text writes live to stdout -- MCP buffers the same rendered sections into `ToolResponse.content` +Available step names (defined in `BuildRunStepName` in `src/types/xcodebuild-events.ts`): -That means formatting decisions should still be made once in the shared formatter/renderer family. +- `resolve-app-path` +- `resolve-simulator` +- `boot-simulator` +- `install-app` +- `extract-bundle-id` +- `launch-app` -MCP is downstream of the same human-readable event stream contract. It does not justify different section ordering, different footer contents, or different sad-path formatting. +To add new steps: extend `BuildRunStepName` and add the label in `formatBuildRunStepLabel` in `src/utils/renderers/event-formatting.ts`. -### 8. All errors get the same grouped rendering +### Error message convention -ALL error events are grouped and rendered as a structured block before the summary, regardless of whether they are file-located compiler errors or non-file errors (toolchain, scheme-not-found, tool-emitted). +All post-build errors via `emitPipelineError` use: `Failed to : ` -The renderers batch ALL error events and flush them as a single grouped section when the summary event arrives. There is no separate "immediate render" path for non-file errors. +### All errors get grouped rendering -Heading rules: +All error events are batched and rendered as a single grouped section before the summary: -- If any error in the group has a file location: `Compiler Errors (N):` +- If any error has a file location: `Compiler Errors (N):` - Otherwise: `Errors (N):` -Each error renders as: - -- ` โœ— ` (first line) -- ` ` (if file-located) -- ` ` (if multi-line message, each subsequent line indented) - -### 9. Error event `message` field must not include severity prefix - -The `message` field on error events must contain the diagnostic text only, without `error:` or `fatal error:` prefix. The renderer adds the appropriate prefix when needed. - -- Correct: `message: "unterminated string literal"` -- Wrong: `message: "error: unterminated string literal"` - -The `rawLine` field preserves the original xcodebuild output verbatim. The parser (`parseBuildErrorDiagnostic` in `src/utils/xcodebuild-line-parsers.ts`) strips the severity prefix from `message` but keeps `rawLine` intact. - -The parser also accumulates indented continuation lines after a build error into the same error event's `message` (newline-separated). This handles multi-line xcodebuild errors like destination-not-found. - -### 10. JSON mode is not part of this contract - -CLI JSON mode remains streamed JSONL of the structured event stream. - -It should not be changed to mirror the human-readable section formatting. - -## Expected deviations for tools that do more than build-and-run - -Not every xcodebuild-backed tool is identical. Some tools need controlled deviations from the canonical build-and-run pattern. - -### Test tools - -Primary example: - -- `src/utils/test-common.ts` - -Test tools differ because they often need: - -- `operation: 'TEST'` instead of `BUILD` -- test discovery events -- test progress events -- test failure events -- multi-phase execution such as `build-for-testing` then `test-without-building` -- minimum-stage continuation rules between phases - -Those are valid deviations, but they should still preserve the same architectural rules: - -- same parser layer -- same run-state layer -- same renderer family -- same single finalization ownership -- same summary -> next-steps ordering - -What should differ for tests is event content and execution shape, not presentation ownership. - -### Pure build tools - -Examples: +Each error: ` โœ— ` with optional ` ` and continuation lines. + +### Error event message field + +The `message` field must not include severity prefix. Correct: `"unterminated string literal"`. Wrong: `"error: unterminated string literal"`. The `rawLine` field preserves the original verbatim. + +## Implementation steps + +One canonical list. Work top to bottom. Nothing is considered done until checked off. + +- [ ] Write snapshot test fixtures for all tools (TDD โ€” fixtures define the target UX, code is updated to match) +- [ ] Define `ToolEvent` union type in `src/types/tool-events.ts` +- [ ] Define `toolResponse()` builder + helper functions: `header()`, `section()`, `statusLine()`, `fileRef()`, `table()`, `detailTree()`, `nextSteps()` +- [ ] Build unified renderer that handles `ToolEvent` (single renderer, produces formatted text) +- [ ] Build CLI stdout sink (streaming text + Clack spinner for transient events) +- [ ] Build MCP buffer sink (buffers same formatted text, returns in `ToolResponse.content`) +- [ ] Preserve CLI JSONL sink for xcodebuild events +- [ ] Migrate xcodebuild pipeline run-state to emit `ToolEvent` instead of `XcodebuildEvent` directly to renderers (preserve parser, run-state, streaming, Clack) +- [ ] Migrate simple action tools: `set_sim_appearance`, `set_sim_location`, `reset_sim_location`, `sim_statusbar`, `boot_sim`, `open_sim`, `stop_app_sim`, `stop_app_device`, `stop_mac_app`, `launch_app_sim`, `launch_app_device`, `launch_mac_app`, `install_app_sim`, `install_app_device` +- [ ] Migrate query tools: `list_sims`, `list_devices`, `discover_projs`, `list_schemes`, `show_build_settings`, `get_sim_app_path`, `get_device_app_path`, `get_app_bundle_id`, `get_mac_bundle_id` +- [ ] Migrate coverage tools: `get_coverage_report`, `get_file_coverage` +- [ ] Migrate scaffolding tools: `scaffold_ios_project`, `scaffold_macos_project` +- [ ] Migrate session tools: `session_set_defaults`, `session_show_defaults`, `session_clear_defaults`, `session_use_defaults_profile` +- [ ] Migrate logging tools: `start_sim_log_cap`, `stop_sim_log_cap`, `start_device_log_cap`, `stop_device_log_cap` +- [ ] Migrate debugging tools: `debug_attach_sim`, `debug_breakpoint_add`, `debug_breakpoint_remove`, `debug_continue`, `debug_detach`, `debug_lldb_command`, `debug_stack`, `debug_variables` +- [ ] Migrate UI automation tools: `snapshot_ui`, `tap`, `type_text`, `screenshot`, `button`, `gesture`, `key_press`, `key_sequence`, `long_press`, `swipe`, `touch` +- [ ] Migrate swift-package tools: `swift_package_build`, `swift_package_test`, `swift_package_clean`, `swift_package_run`, `swift_package_list`, `swift_package_stop` +- [ ] Migrate xcode-ide tools: `xcode_ide_call_tool`, `xcode_ide_list_tools`, `xcode_tools_bridge_disconnect`, `xcode_tools_bridge_status`, `xcode_tools_bridge_sync`, `sync_xcode_defaults` +- [ ] Migrate doctor tool +- [ ] Delete legacy rendering code: `mcp-renderer.ts`, `cli-text-renderer.ts`, `formatToolPreflight`, per-tool ad-hoc formatting, transitional xcodebuild helpers (`finalizeBuildPhase`, `createPostBuildError`, `appendStructuredEvents`, `createCompletionStatusEvent`, `finalizeBuildPipelineResult`) +- [ ] All snapshot tests pass +- [ ] Manual verification of CLI output for representative tools -- `src/mcp/tools/simulator/build_sim.ts` -- `src/mcp/tools/device/build_device.ts` -- `src/mcp/tools/macos/build_macos.ts` -- `src/mcp/tools/utilities/clean.ts` - -These are simpler than the canonical build-and-run tool because they do not have install/launch steps. - -Their valid simplification is: - -- xcodebuild phase only -- no post-build notices beyond what is needed -- return pending response with manifest-driven next-step params - -They still follow the same finalization contract. - -### More complex build-and-run tools - -Migrated examples: - -- `src/mcp/tools/simulator/build_run_sim.ts` โ€” simulator build-and-run (fully migrated) - -Remaining: - -- `build_run_device` - -These need extra steps compared with `build_run_macos`, such as: - -- simulator/device lookup -- boot/install/launch sequencing -- bundle ID extraction -- platform-specific next-step params - -Those are valid workflow differences. They are handled by emitting more `build-run-step` notices into the same pipeline. They are not valid reasons to introduce a second output path, late content append logic, tool-specific final rendering, or replay of already-streamed output. - -The correct adaptation is: - -- keep the same pipeline structure -- emit more `notice` events with `code: 'build-run-step'` for each post-build step -- emit `error` events via `emitPipelineError` for post-build failures -- include execution-derived values in the `build-run-result` tail event -- finalize once at the end - -## Event model - -We need one shared event model that works for build and test tools. - -Example direction: - -```ts -type XcodebuildEvent = - | { - type: 'start'; - operation: 'BUILD' | 'TEST'; - toolName: string; - params: Record; - message: string; - timestamp: string; - } - | { - type: 'status'; - operation: 'BUILD' | 'TEST'; - stage: - | 'RESOLVING_PACKAGES' - | 'COMPILING' - | 'LINKING' - | 'RUN_TESTS' - | 'PREPARING_TESTS' - | 'ARCHIVING' - | 'COMPLETED' - | 'UNKNOWN'; - message: string; - timestamp: string; - } - | { - type: 'warning'; - operation: 'BUILD' | 'TEST'; - message: string; - location?: string; - rawLine: string; - timestamp: string; - } - | { - type: 'error'; - operation: 'BUILD' | 'TEST'; - message: string; - location?: string; - rawLine: string; - timestamp: string; - } - | { - type: 'test-discovery'; - operation: 'TEST'; - total: number; - tests: string[]; - truncated: boolean; - timestamp: string; - } - | { - type: 'test-progress'; - operation: 'TEST'; - completed: number; - failed: number; - skipped: number; - timestamp: string; - } - | { - type: 'test-failure'; - operation: 'TEST'; - target?: string; - suite?: string; - test?: string; - message: string; - location?: string; - durationMs?: number; - timestamp: string; - } - | { - type: 'summary'; - operation: 'BUILD' | 'TEST'; - status: 'SUCCEEDED' | 'FAILED'; - totalTests?: number; - passedTests?: number; - failedTests?: number; - skippedTests?: number; - durationMs?: number; - timestamp: string; - } - | { - type: 'next-steps'; - steps: Array<{ - label?: string; - tool?: string; - workflow?: string; - cliTool?: string; - params?: Record; - }>; - timestamp: string; - }; -``` - -The exact names can change, but the model needs these properties: - -- shared across tools -- streamable -- usable by both human-readable and JSONL renderers -- expressive enough for current simulator test behavior and future build behavior - -## Rollout plan - -## Phase 1: define the shared event and run-state model - -Deliverables: - -- shared event types -- shared aggregated run-state/report types -- shared emitter/collector interfaces - -Exit criteria: - -- build and test use cases both fit the model -- multi-phase test execution fits the model cleanly - -## Phase 2: factor current simulator test parsing into the shared pipeline - -Deliverables: - -- simulator test path emits shared events -- CLI text output is rendered from those events -- CLI JSON output emits JSONL from those events -- MCP human-readable output is rendered from those events - -Exit criteria: - -- simulator test no longer has a renderer-first architecture -- current simulator test UX is preserved or improved - -## Phase 3: migrate xcodebuild-backed build tools - -Deliverables: - -- simulator build tools use the same event pipeline -- macOS build tools use the same event pipeline -- device build tools use the same event pipeline -- warnings/errors/milestones are consistent across them - -Exit criteria: - -- build tools stream live milestones and diagnostics -- CLI text and CLI JSONL stay in sync because they share the same source events - -## Phase 4: migrate remaining test tools - -Deliverables: - -- device test tools use the shared event pipeline -- macOS test tools use the shared event pipeline -- grouped summaries and failure rendering are shared where possible - -Exit criteria: - -- no xcodebuild-backed test tool maintains a separate raw parsing stack - -## Phase 5: remove legacy duplicated formatting paths - -Deliverables: - -- old ad hoc parser/formatter branches removed -- output logic reduced to shared renderers -- next steps still appended through manifest-driven logic at stream end - -Exit criteria: - -- xcodebuild-backed tools share one parsing model and one rendering model family - -## Testing strategy - -## Unit tests - -- raw line to event parsing -- milestone ordering and dedupe -- warning/error parsing -- test-progress parsing -- failure parsing -- multi-phase continuation behavior - -## Integration tests - -- simulator build streams expected milestones -- simulator test streams expected milestones and failures -- CLI JSON mode emits valid JSONL in correct order -- MCP mode renders the same underlying run semantics in human-readable form - -## Benchmark checks +## Success criteria -Keep using the simulator benchmark harness to compare: +This work is successful when: -- wall-clock duration -- time to first streamed milestone -- time to first streamed test progress -- parity of surfaced information vs Flowdeck +- every tool emits structured events through the pipeline +- one renderer produces all formatted output +- CLI and MCP output are identical (differing only in sink: stdout vs buffer) +- file paths are always normalised โ€” no tool can produce a raw absolute path +- spacing between sections is always correct โ€” no tool can get it wrong +- the only way to add a new tool's output is to emit events โ€” there is no escape hatch +- adding a new output format (e.g. markdown, HTML) requires only a new renderer, not touching any tool code ## Design constraints -To keep this practical: - -- no separate parser per tool unless the raw source is genuinely different -- no renderer-specific parsing logic -- no multiple JSON mode variants -- no buffering until completion when meaningful events are already known -- no attempt to preserve old MCP human-readable output shape for xcodebuild-backed tools - -## Status against rollout phases - -### Phase 1: define the shared event and run-state model - -Status: complete - -### Phase 2: factor current simulator test parsing into the shared pipeline - -Status: complete - -### Phase 3: migrate xcodebuild-backed build tools - -Status: mostly complete - -Notes: - -- pure build tools (simulator, device, macOS, clean) all use the pending pipeline model -- `build_run_macos` and `build_run_sim` are fully migrated to the canonical single-pipeline pattern -- `build_run_device` still needs migration - -### Phase 4: migrate remaining test tools - -Status: mostly complete - -Notes: - -- simulator, device, and macOS test flows are on the shared pipeline -- the remaining work is mainly cleanup, consistency, and removing transitional replay behavior - -### Phase 5: remove legacy duplicated formatting paths - -Status: in progress - -Notes: - -- pure build, clean, and two build-and-run flows no longer depend on final replay/re-render formatting -- CLI final printing no-ops for fully migrated xcodebuild responses -- once `build_run_device` is migrated, the transitional helpers (`finalizeBuildPhase`, `createPostBuildError`, `appendStructuredEvents`, `createCompletionStatusEvent`) can be deleted - -## Recommended immediate next steps - -1. migrate `build_run_device` using the same canonical pattern as `build_run_sim` -2. delete transitional helpers once no tools depend on them -3. finish any remaining test tool cleanup - -## Success criteria - -This work is successful when: - -- all xcodebuild-backed tools stream output as they learn new information -- MCP human-readable output and CLI text output are both rendered from the same structured source events -- CLI JSON mode streams JSONL from those same source events -- package resolution, compiling, warnings, errors, test progress, failures, and summaries are consistently surfaced across tools -- next steps are still appended at the end through manifest-driven logic -- future Flowdeck parity work happens by improving one shared event pipeline, not many separate formatter paths +- no separate renderer per output mode (one renderer, parameterised by runtime for next-steps syntax) +- no formatted text construction inside tool logic +- no emoji characters inside tool logic (renderer owns the mapping) +- no `displayPath()` calls inside tool logic (renderer owns path normalisation) +- no spacing/indentation decisions inside tool logic (renderer owns layout) +- xcodebuild event parser and run-state layer are preserved โ€” they work well and do not need to change +- CLI JSONL mode is preserved for xcodebuild events only +- no attempt to make non-xcodebuild tools streamable initially โ€” they complete fast enough that buffered rendering is fine diff --git a/manifests/tools/boot_sim.yaml b/manifests/tools/boot_sim.yaml index d43d90ce..7099c068 100644 --- a/manifests/tools/boot_sim.yaml +++ b/manifests/tools/boot_sim.yaml @@ -13,15 +13,18 @@ nextSteps: - label: Open the Simulator app (makes it visible) toolId: open_sim priority: 1 + when: success - label: Install an app toolId: install_app_sim params: simulatorId: SIMULATOR_UUID appPath: PATH_TO_YOUR_APP priority: 2 + when: success - label: Launch an app toolId: launch_app_sim params: simulatorId: SIMULATOR_UUID bundleId: YOUR_APP_BUNDLE_ID priority: 3 + when: success diff --git a/manifests/tools/build_device.yaml b/manifests/tools/build_device.yaml index f1bd2451..38633e37 100644 --- a/manifests/tools/build_device.yaml +++ b/manifests/tools/build_device.yaml @@ -15,3 +15,4 @@ nextSteps: - label: Get built device app path toolId: get_device_app_path priority: 1 + when: success diff --git a/manifests/tools/build_macos.yaml b/manifests/tools/build_macos.yaml index bfbef85f..71cacfa6 100644 --- a/manifests/tools/build_macos.yaml +++ b/manifests/tools/build_macos.yaml @@ -15,3 +15,4 @@ nextSteps: - label: Get built macOS app path toolId: get_mac_app_path priority: 1 + when: success diff --git a/manifests/tools/build_run_device.yaml b/manifests/tools/build_run_device.yaml index 67a643f4..5106d19a 100644 --- a/manifests/tools/build_run_device.yaml +++ b/manifests/tools/build_run_device.yaml @@ -15,6 +15,8 @@ nextSteps: - label: Capture device logs toolId: start_device_log_cap priority: 1 + when: success - label: Stop app on device toolId: stop_app_device priority: 2 + when: success diff --git a/manifests/tools/build_run_macos.yaml b/manifests/tools/build_run_macos.yaml index 44377c05..4c4c7672 100644 --- a/manifests/tools/build_run_macos.yaml +++ b/manifests/tools/build_run_macos.yaml @@ -14,3 +14,4 @@ annotations: nextSteps: - label: Interact with the launched app in the foreground priority: 1 + when: success diff --git a/manifests/tools/build_run_sim.yaml b/manifests/tools/build_run_sim.yaml index bb797480..4f69ab72 100644 --- a/manifests/tools/build_run_sim.yaml +++ b/manifests/tools/build_run_sim.yaml @@ -15,12 +15,16 @@ nextSteps: - label: Capture structured logs (app continues running) toolId: start_sim_log_cap priority: 1 + when: success - label: Stop app in simulator toolId: stop_app_sim priority: 2 + when: success - label: Capture console + structured logs (app restarts) toolId: start_sim_log_cap priority: 3 + when: success - label: Launch app with logs in one step toolId: launch_app_logs_sim priority: 4 + when: success diff --git a/manifests/tools/build_sim.yaml b/manifests/tools/build_sim.yaml index 9abc5ecf..62ac7947 100644 --- a/manifests/tools/build_sim.yaml +++ b/manifests/tools/build_sim.yaml @@ -15,3 +15,4 @@ nextSteps: - label: Get built app path in simulator derived data toolId: get_sim_app_path priority: 1 + when: success diff --git a/manifests/tools/debug_attach_sim.yaml b/manifests/tools/debug_attach_sim.yaml index 4b93de09..7e284243 100644 --- a/manifests/tools/debug_attach_sim.yaml +++ b/manifests/tools/debug_attach_sim.yaml @@ -15,9 +15,12 @@ nextSteps: - label: Add a breakpoint toolId: debug_breakpoint_add priority: 1 + when: success - label: Continue execution toolId: debug_continue priority: 2 + when: success - label: Show call stack toolId: debug_stack priority: 3 + when: success diff --git a/manifests/tools/discover_projs.yaml b/manifests/tools/discover_projs.yaml index 735af420..c152a9d0 100644 --- a/manifests/tools/discover_projs.yaml +++ b/manifests/tools/discover_projs.yaml @@ -13,6 +13,8 @@ nextSteps: - label: Save discovered project/workspace as session defaults toolId: session_set_defaults priority: 1 + when: success - label: Build and run once defaults are set toolId: build_run_sim priority: 2 + when: success diff --git a/manifests/tools/get_app_bundle_id.yaml b/manifests/tools/get_app_bundle_id.yaml index 2bbe327b..66e397fb 100644 --- a/manifests/tools/get_app_bundle_id.yaml +++ b/manifests/tools/get_app_bundle_id.yaml @@ -13,12 +13,16 @@ nextSteps: - label: Install on simulator toolId: install_app_sim priority: 1 + when: success - label: Launch on simulator toolId: launch_app_sim priority: 2 + when: success - label: Install on device toolId: install_app_device priority: 3 + when: success - label: Launch on device toolId: launch_app_device priority: 4 + when: success diff --git a/manifests/tools/get_coverage_report.yaml b/manifests/tools/get_coverage_report.yaml index 6eadcec0..4c5674eb 100644 --- a/manifests/tools/get_coverage_report.yaml +++ b/manifests/tools/get_coverage_report.yaml @@ -13,3 +13,4 @@ nextSteps: - label: View file-level coverage toolId: get_file_coverage priority: 1 + when: success diff --git a/manifests/tools/get_file_coverage.yaml b/manifests/tools/get_file_coverage.yaml index 90ce1cfc..1fe98892 100644 --- a/manifests/tools/get_file_coverage.yaml +++ b/manifests/tools/get_file_coverage.yaml @@ -13,3 +13,4 @@ nextSteps: - label: View overall coverage toolId: get_coverage_report priority: 1 + when: success diff --git a/manifests/tools/get_mac_bundle_id.yaml b/manifests/tools/get_mac_bundle_id.yaml index f9684734..9853ad78 100644 --- a/manifests/tools/get_mac_bundle_id.yaml +++ b/manifests/tools/get_mac_bundle_id.yaml @@ -13,6 +13,8 @@ nextSteps: - label: Launch the app toolId: launch_mac_app priority: 1 + when: success - label: Build again toolId: build_macos priority: 2 + when: success diff --git a/manifests/tools/install_app_sim.yaml b/manifests/tools/install_app_sim.yaml index 2e61517e..ac7e82f6 100644 --- a/manifests/tools/install_app_sim.yaml +++ b/manifests/tools/install_app_sim.yaml @@ -13,6 +13,8 @@ nextSteps: - label: Open the Simulator app toolId: open_sim priority: 1 + when: success - label: Launch the app toolId: launch_app_sim priority: 2 + when: success diff --git a/manifests/tools/launch_app_device.yaml b/manifests/tools/launch_app_device.yaml index ef3f123e..90563c5b 100644 --- a/manifests/tools/launch_app_device.yaml +++ b/manifests/tools/launch_app_device.yaml @@ -13,3 +13,4 @@ nextSteps: - label: Stop the app toolId: stop_app_device priority: 1 + when: success diff --git a/manifests/tools/launch_app_logs_sim.yaml b/manifests/tools/launch_app_logs_sim.yaml index fa349c84..ab4f8a58 100644 --- a/manifests/tools/launch_app_logs_sim.yaml +++ b/manifests/tools/launch_app_logs_sim.yaml @@ -15,3 +15,4 @@ nextSteps: - label: Stop capture and retrieve logs toolId: stop_sim_log_cap priority: 1 + when: success diff --git a/manifests/tools/launch_app_sim.yaml b/manifests/tools/launch_app_sim.yaml index dfa2e104..9bebd5ba 100644 --- a/manifests/tools/launch_app_sim.yaml +++ b/manifests/tools/launch_app_sim.yaml @@ -13,12 +13,14 @@ nextSteps: - label: Open Simulator app to see it toolId: open_sim priority: 1 + when: success - label: Capture structured logs (app continues running) toolId: start_sim_log_cap params: simulatorId: SIMULATOR_UUID bundleId: BUNDLE_ID priority: 2 + when: success - label: Capture console + structured logs (app restarts) toolId: start_sim_log_cap params: @@ -26,3 +28,4 @@ nextSteps: bundleId: BUNDLE_ID captureConsole: true priority: 3 + when: success diff --git a/manifests/tools/list_devices.yaml b/manifests/tools/list_devices.yaml index a8db4f43..6181fab8 100644 --- a/manifests/tools/list_devices.yaml +++ b/manifests/tools/list_devices.yaml @@ -13,9 +13,12 @@ nextSteps: - label: Build for device toolId: build_device priority: 1 + when: success - label: Run tests on device toolId: test_device priority: 2 + when: success - label: Get app path toolId: get_device_app_path priority: 3 + when: success diff --git a/manifests/tools/list_sims.yaml b/manifests/tools/list_sims.yaml index a1309932..3c0137d6 100644 --- a/manifests/tools/list_sims.yaml +++ b/manifests/tools/list_sims.yaml @@ -15,15 +15,18 @@ nextSteps: params: simulatorId: UUID_FROM_ABOVE priority: 1 + when: success - label: Open the simulator UI toolId: open_sim priority: 2 + when: success - label: Build for simulator toolId: build_sim params: scheme: YOUR_SCHEME simulatorId: UUID_FROM_ABOVE priority: 3 + when: success - label: Get app path toolId: get_sim_app_path params: @@ -31,3 +34,4 @@ nextSteps: platform: iOS Simulator simulatorId: UUID_FROM_ABOVE priority: 4 + when: success diff --git a/manifests/tools/open_sim.yaml b/manifests/tools/open_sim.yaml index fad36b58..cf544eec 100644 --- a/manifests/tools/open_sim.yaml +++ b/manifests/tools/open_sim.yaml @@ -15,12 +15,14 @@ nextSteps: params: simulatorId: UUID_FROM_LIST_SIMS priority: 1 + when: success - label: Capture structured logs (app continues running) toolId: start_sim_log_cap params: simulatorId: UUID bundleId: YOUR_APP_BUNDLE_ID priority: 2 + when: success - label: Capture console + structured logs (app restarts) toolId: start_sim_log_cap params: @@ -28,9 +30,11 @@ nextSteps: bundleId: YOUR_APP_BUNDLE_ID captureConsole: true priority: 3 + when: success - label: Launch app with logs in one step toolId: launch_app_logs_sim params: simulatorId: UUID bundleId: YOUR_APP_BUNDLE_ID priority: 4 + when: success diff --git a/manifests/tools/record_sim_video.yaml b/manifests/tools/record_sim_video.yaml index da927fbc..f082885e 100644 --- a/manifests/tools/record_sim_video.yaml +++ b/manifests/tools/record_sim_video.yaml @@ -15,3 +15,4 @@ nextSteps: - label: Stop and save the recording toolId: record_sim_video priority: 1 + when: success diff --git a/manifests/tools/scaffold_ios_project.yaml b/manifests/tools/scaffold_ios_project.yaml index fd361656..03bf7dcf 100644 --- a/manifests/tools/scaffold_ios_project.yaml +++ b/manifests/tools/scaffold_ios_project.yaml @@ -13,9 +13,12 @@ annotations: openWorldHint: false nextSteps: - label: "Important: Before working on the project make sure to read the README.md file in the workspace root directory." + when: success - label: Build for simulator toolId: build_sim priority: 1 + when: success - label: Build and run on simulator toolId: build_run_sim priority: 2 + when: success diff --git a/manifests/tools/scaffold_macos_project.yaml b/manifests/tools/scaffold_macos_project.yaml index 36ad8f45..f8aedb15 100644 --- a/manifests/tools/scaffold_macos_project.yaml +++ b/manifests/tools/scaffold_macos_project.yaml @@ -13,9 +13,12 @@ annotations: openWorldHint: false nextSteps: - label: "Important: Before working on the project make sure to read the README.md file in the workspace root directory." + when: success - label: Build for macOS toolId: build_macos priority: 1 + when: success - label: Build & Run on macOS toolId: build_run_macos priority: 2 + when: success diff --git a/manifests/tools/snapshot_ui.yaml b/manifests/tools/snapshot_ui.yaml index cdac6fd2..2d361134 100644 --- a/manifests/tools/snapshot_ui.yaml +++ b/manifests/tools/snapshot_ui.yaml @@ -9,16 +9,19 @@ nextSteps: toolId: snapshot_ui params: simulatorId: SIMULATOR_UUID + when: success - label: Tap on element toolId: tap params: simulatorId: SIMULATOR_UUID x: 0 y: 0 + when: success - label: Take screenshot for verification toolId: screenshot params: simulatorId: SIMULATOR_UUID + when: success annotations: title: Snapshot UI readOnlyHint: true diff --git a/manifests/tools/start_device_log_cap.yaml b/manifests/tools/start_device_log_cap.yaml index d0e36b00..dcc57bcb 100644 --- a/manifests/tools/start_device_log_cap.yaml +++ b/manifests/tools/start_device_log_cap.yaml @@ -15,3 +15,4 @@ nextSteps: - label: Stop capture and retrieve logs toolId: stop_device_log_cap priority: 1 + when: success diff --git a/manifests/tools/start_sim_log_cap.yaml b/manifests/tools/start_sim_log_cap.yaml index 5d98d889..ac889238 100644 --- a/manifests/tools/start_sim_log_cap.yaml +++ b/manifests/tools/start_sim_log_cap.yaml @@ -15,3 +15,4 @@ nextSteps: - label: Stop capture and retrieve logs toolId: stop_sim_log_cap priority: 1 + when: success diff --git a/package.json b/package.json index e4aba5d2..68ffd112 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,8 @@ "license:check": "npx -y license-checker --production --onlyAllow 'MIT;ISC;BSD-2-Clause;BSD-3-Clause;Apache-2.0;Unlicense;FSL-1.1-MIT'", "knip": "knip", "test": "vitest run", + "test:snapshot": "npm run build && vitest run src/snapshot-tests", + "test:snapshot:update": "npm run build && UPDATE_SNAPSHOTS=1 vitest run src/snapshot-tests", "test:smoke": "npm run build && vitest run --config vitest.smoke.config.ts", "test:watch": "vitest", "test:ui": "vitest --ui", diff --git a/src/cli/__tests__/output.test.ts b/src/cli/__tests__/output.test.ts index 01839b93..7d3474b9 100644 --- a/src/cli/__tests__/output.test.ts +++ b/src/cli/__tests__/output.test.ts @@ -93,14 +93,11 @@ describe('printToolResponse', () => { }); printToolResponse({ - content: [createTextContent('build succeeded')], - nextSteps: [ - { - tool: 'launch_app_sim', - workflow: 'simulator', - cliTool: 'launch-app-sim', - params: { simulatorId: 'SIM-1', bundleId: 'com.example.app' }, - }, + content: [ + createTextContent('build succeeded'), + createTextContent( + 'Next steps:\n1. xcodebuildmcp simulator launch-app-sim --simulator-id "SIM-1" --bundle-id "com.example.app"', + ), ], _meta: { events: [ diff --git a/src/cli/daemon-control.ts b/src/cli/daemon-control.ts index eb5ad843..e22fa3a8 100644 --- a/src/cli/daemon-control.ts +++ b/src/cli/daemon-control.ts @@ -111,25 +111,17 @@ export async function ensureDaemonRunning(opts: EnsureDaemonRunningOptions): Pro const client = new DaemonClient({ socketPath: opts.socketPath }); const timeoutMs = opts.startupTimeoutMs ?? DEFAULT_DAEMON_STARTUP_TIMEOUT_MS; - // Check if already running const isRunning = await client.isRunning(); if (isRunning) { return; } - // Start daemon in background - const startOptions: StartDaemonBackgroundOptions = { + startDaemonBackground({ socketPath: opts.socketPath, workspaceRoot: opts.workspaceRoot, - }; - - if (opts.env) { - startOptions.env = { ...opts.env }; - } - - startDaemonBackground(startOptions); + env: opts.env, + }); - // Wait for it to be ready await waitForDaemonReady({ socketPath: opts.socketPath, timeoutMs, diff --git a/src/cli/output.ts b/src/cli/output.ts index d2a40241..beb622b4 100644 --- a/src/cli/output.ts +++ b/src/cli/output.ts @@ -1,5 +1,4 @@ import type { ToolResponse, OutputStyle } from '../types/common.ts'; -import { processToolResponse } from '../utils/responses/index.ts'; import { formatCliTextLine } from '../utils/terminal-output.ts'; export type OutputFormat = 'text' | 'json'; @@ -33,8 +32,11 @@ function extractRenderedNextSteps(response: ToolResponse): string { return ''; } -function isCompleteXcodebuildStream(response: ToolResponse): boolean { - return response._meta?.xcodebuildStreamMode === 'complete'; +function isCompletePipelineStream(response: ToolResponse): boolean { + return ( + response._meta?.xcodebuildStreamMode === 'complete' || + response._meta?.pipelineStreamMode === 'complete' + ); } /** @@ -47,24 +49,21 @@ export function printToolResponse( ): void { const { format = 'text', style = 'normal' } = options; - if (isCompleteXcodebuildStream(response)) { + if (isCompletePipelineStream(response)) { if (response.isError) { process.exitCode = 1; } return; } - // Apply next steps rendering for CLI runtime - const processed = processToolResponse(response, 'cli', style); - if (format === 'json') { // When events were streamed as JSONL during execution, skip re-printing them - const hasStreamedEvents = Array.isArray(processed._meta?.events); + const hasStreamedEvents = Array.isArray(response._meta?.events); if (hasStreamedEvents) { - const events = processed._meta?.events as Array>; + const events = response._meta?.events as Array>; const streamedEventCount = - typeof processed._meta?.streamedEventCount === 'number' - ? processed._meta.streamedEventCount + typeof response._meta?.streamedEventCount === 'number' + ? response._meta.streamedEventCount : events.length; const appendedEvents = events.slice(streamedEventCount); @@ -74,32 +73,32 @@ export function printToolResponse( // Events were already written to stdout as JSONL by the CLI JSONL renderer. // Only emit non-event content (error messages, etc.) if present. - const nonEventContent = processed.content?.filter( + const nonEventContent = response.content?.filter( (item) => item.type !== 'text' || !item.text, ); if (nonEventContent && nonEventContent.length > 0) { - writeLine(JSON.stringify({ ...processed, content: nonEventContent }, null, 2)); + writeLine(JSON.stringify({ ...response, content: nonEventContent }, null, 2)); } } else { - writeLine(JSON.stringify(processed, null, 2)); + writeLine(JSON.stringify(response, null, 2)); } } else { - const hasStreamedEvents = Array.isArray(processed._meta?.events); + const hasStreamedEvents = Array.isArray(response._meta?.events); const streamedContentCount = - typeof processed._meta?.streamedContentCount === 'number' - ? processed._meta.streamedContentCount + typeof response._meta?.streamedContentCount === 'number' + ? response._meta.streamedContentCount : 0; if (hasStreamedEvents && process.stdout.isTTY === true) { - const printedAny = printToolResponseText(processed, streamedContentCount); + const printedAny = printToolResponseText(response, streamedContentCount); if (!printedAny && style !== 'minimal') { - const nextStepsText = extractRenderedNextSteps(processed); + const nextStepsText = extractRenderedNextSteps(response); if (nextStepsText.length > 0) { writeLine(nextStepsText); } } } else { - printToolResponseText(processed); + printToolResponseText(response); } } @@ -149,8 +148,12 @@ export function formatToolList( if (options.grouped) { const byWorkflow = new Map(); for (const tool of tools) { - const existing = byWorkflow.get(tool.workflow) ?? []; - byWorkflow.set(tool.workflow, [...existing, tool]); + let group = byWorkflow.get(tool.workflow); + if (!group) { + group = []; + byWorkflow.set(tool.workflow, group); + } + group.push(tool); } const sortedWorkflows = [...byWorkflow.keys()].sort(); diff --git a/src/core/manifest/import-tool-module.ts b/src/core/manifest/import-tool-module.ts index a2371e90..cd556935 100644 --- a/src/core/manifest/import-tool-module.ts +++ b/src/core/manifest/import-tool-module.ts @@ -9,18 +9,11 @@ import { pathToFileURL } from 'node:url'; import type { ToolSchemaShape } from '../plugin-types.ts'; import { getPackageRoot } from './load-manifest.ts'; -/** - * Imported tool module interface. - * This is what we extract from each tool module for runtime use. - */ export interface ImportedToolModule { schema: ToolSchemaShape; handler: (params: Record) => Promise; } -/** - * Cache for imported modules. - */ const moduleCache = new Map(); /** @@ -34,7 +27,6 @@ const moduleCache = new Map(); * @returns Imported tool module with schema and handler */ export async function importToolModule(moduleId: string): Promise { - // Check cache first const cached = moduleCache.get(moduleId); if (cached) { return cached; @@ -52,22 +44,15 @@ export async function importToolModule(moduleId: string): Promise, moduleId: string): ImportedToolModule { - // Try legacy format first: default export with PluginMeta shape if (mod.default && typeof mod.default === 'object') { const defaultExport = mod.default as Record; - // Check if it looks like a PluginMeta (has schema and handler) if (defaultExport.schema && typeof defaultExport.handler === 'function') { return { schema: defaultExport.schema as ToolSchemaShape, @@ -76,7 +61,6 @@ function extractToolExports(mod: Record, moduleId: string): Imp } } - // Try new format: named exports if (mod.schema && typeof mod.handler === 'function') { return { schema: mod.schema as ToolSchemaShape, @@ -89,18 +73,3 @@ function extractToolExports(mod: Record, moduleId: string): Imp `Expected either a default export with { schema, handler } or named exports { schema, handler }.`, ); } - -/** - * Clear the module cache. - * Useful for testing or hot-reloading scenarios. - */ -export function clearModuleCache(): void { - moduleCache.clear(); -} - -/** - * Preload multiple tool modules in parallel. - */ -export async function preloadToolModules(moduleIds: string[]): Promise { - await Promise.all(moduleIds.map((id) => importToolModule(id))); -} diff --git a/src/core/manifest/load-manifest.ts b/src/core/manifest/load-manifest.ts index f9b2f57e..0608001b 100644 --- a/src/core/manifest/load-manifest.ts +++ b/src/core/manifest/load-manifest.ts @@ -15,14 +15,10 @@ import { } from './schema.ts'; import { getManifestsDir, getPackageRoot } from '../resource-root.ts'; -// Re-export types for consumers export type { ResolvedManifest, ToolManifestEntry, WorkflowManifestEntry }; import { isValidPredicate } from '../../visibility/predicate-registry.ts'; export { getManifestsDir, getPackageRoot } from '../resource-root.ts'; -/** - * Load all YAML files from a directory. - */ function loadYamlFiles(dir: string): unknown[] { if (!fs.existsSync(dir)) { return []; @@ -47,9 +43,6 @@ function loadYamlFiles(dir: string): unknown[] { return results; } -/** - * Validation error for manifest loading. - */ export class ManifestValidationError extends Error { constructor( message: string, @@ -72,7 +65,6 @@ export function loadManifest(): ResolvedManifest { const tools = new Map(); const workflows = new Map(); - // Load tools const toolFiles = loadYamlFiles(toolsDir); for (const raw of toolFiles) { const sourceFile = (raw as { _sourceFile?: string })._sourceFile; @@ -86,12 +78,10 @@ export function loadManifest(): ResolvedManifest { const tool = result.data; - // Check for duplicate ID if (tools.has(tool.id)) { throw new ManifestValidationError(`Duplicate tool ID '${tool.id}'`, sourceFile); } - // Validate predicates for (const pred of tool.predicates) { if (!isValidPredicate(pred)) { throw new ManifestValidationError( @@ -104,7 +94,6 @@ export function loadManifest(): ResolvedManifest { tools.set(tool.id, tool); } - // Load workflows const workflowFiles = loadYamlFiles(workflowsDir); for (const raw of workflowFiles) { const sourceFile = (raw as { _sourceFile?: string })._sourceFile; @@ -118,12 +107,10 @@ export function loadManifest(): ResolvedManifest { const workflow = result.data; - // Check for duplicate ID if (workflows.has(workflow.id)) { throw new ManifestValidationError(`Duplicate workflow ID '${workflow.id}'`, sourceFile); } - // Validate predicates for (const pred of workflow.predicates) { if (!isValidPredicate(pred)) { throw new ManifestValidationError( @@ -133,7 +120,6 @@ export function loadManifest(): ResolvedManifest { } } - // Validate tool references for (const toolId of workflow.tools) { if (!tools.has(toolId)) { throw new ManifestValidationError( @@ -146,8 +132,7 @@ export function loadManifest(): ResolvedManifest { workflows.set(workflow.id, workflow); } - // Validate MCP name uniqueness - const mcpNames = new Map(); // mcpName -> toolId + const mcpNames = new Map(); for (const [toolId, tool] of tools) { const existing = mcpNames.get(tool.names.mcp); if (existing) { @@ -158,7 +143,6 @@ export function loadManifest(): ResolvedManifest { mcpNames.set(tool.names.mcp, toolId); } - // Validate next step template references for (const [toolId, tool] of tools.entries()) { const sourceFile = toolFiles.find((raw) => { const candidate = raw as { id?: string; _sourceFile?: string }; @@ -178,23 +162,6 @@ export function loadManifest(): ResolvedManifest { return { tools, workflows }; } -/** - * Validate that all tool modules exist on disk. - * Call this at startup to fail fast on missing modules. - */ -export function validateToolModules(manifest: ResolvedManifest): void { - const packageRoot = getPackageRoot(); - - for (const [toolId, tool] of manifest.tools) { - const modulePath = path.join(packageRoot, 'build', `${tool.module}.js`); - if (!fs.existsSync(modulePath)) { - throw new ManifestValidationError( - `Tool '${toolId}' references missing module: ${modulePath}`, - ); - } - } -} - /** * Get tools for a specific workflow. */ diff --git a/src/daemon.ts b/src/daemon.ts index 2abf8ad2..e20930ce 100644 --- a/src/daemon.ts +++ b/src/daemon.ts @@ -25,9 +25,9 @@ import { DAEMON_IDLE_TIMEOUT_ENV_KEY, DEFAULT_DAEMON_IDLE_CHECK_INTERVAL_MS, resolveDaemonIdleTimeoutMs, - getDaemonRuntimeActivitySnapshot, hasActiveRuntimeSessions, } from './daemon/idle-shutdown.ts'; +import { getDaemonActivitySnapshot } from './daemon/activity-registry.ts'; import { getDefaultCommandExecutor } from './utils/command.ts'; import { resolveAxeBinary } from './utils/axe/index.ts'; import { @@ -308,10 +308,7 @@ async function main(): Promise { const emitRequestGauges = (): void => { recordDaemonGaugeMetric('inflight_requests', inFlightRequests); - recordDaemonGaugeMetric( - 'active_sessions', - getDaemonRuntimeActivitySnapshot().activeOperationCount, - ); + recordDaemonGaugeMetric('active_sessions', getDaemonActivitySnapshot().activeOperationCount); }; const server = startDaemonServer({ @@ -354,7 +351,7 @@ async function main(): Promise { return; } - if (hasActiveRuntimeSessions(getDaemonRuntimeActivitySnapshot())) { + if (hasActiveRuntimeSessions(getDaemonActivitySnapshot())) { return; } diff --git a/src/daemon/__tests__/idle-shutdown.test.ts b/src/daemon/__tests__/idle-shutdown.test.ts index 7d72e31b..ee068379 100644 --- a/src/daemon/__tests__/idle-shutdown.test.ts +++ b/src/daemon/__tests__/idle-shutdown.test.ts @@ -2,11 +2,14 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { DAEMON_IDLE_TIMEOUT_ENV_KEY, DEFAULT_DAEMON_IDLE_TIMEOUT_MS, - getDaemonRuntimeActivitySnapshot, hasActiveRuntimeSessions, resolveDaemonIdleTimeoutMs, } from '../idle-shutdown.ts'; -import { acquireDaemonActivity, clearDaemonActivityRegistry } from '../activity-registry.ts'; +import { + acquireDaemonActivity, + clearDaemonActivityRegistry, + getDaemonActivitySnapshot, +} from '../activity-registry.ts'; describe('daemon idle shutdown', () => { beforeEach(() => { @@ -51,11 +54,11 @@ describe('daemon idle shutdown', () => { }); }); - describe('getDaemonRuntimeActivitySnapshot', () => { + describe('getDaemonActivitySnapshot', () => { it('reports category counters for active daemon activity', () => { const release = acquireDaemonActivity('swift-package.background-process'); - const snapshot = getDaemonRuntimeActivitySnapshot(); + const snapshot = getDaemonActivitySnapshot(); expect(snapshot.activeOperationCount).toBe(1); expect(snapshot.byCategory).toEqual({ 'swift-package.background-process': 1, diff --git a/src/daemon/activity-registry.ts b/src/daemon/activity-registry.ts index 17185856..1a6e27c8 100644 --- a/src/daemon/activity-registry.ts +++ b/src/daemon/activity-registry.ts @@ -1,21 +1,16 @@ const activityCounts = new Map(); -function normalizeActivityKey(activityKey: string): string { - return activityKey.trim(); +function incrementActivity(key: string): void { + activityCounts.set(key, (activityCounts.get(key) ?? 0) + 1); } -function incrementActivity(activityKey: string): void { - const current = activityCounts.get(activityKey) ?? 0; - activityCounts.set(activityKey, current + 1); -} - -function decrementActivity(activityKey: string): void { - const current = activityCounts.get(activityKey) ?? 0; +function decrementActivity(key: string): void { + const current = activityCounts.get(key) ?? 0; if (current <= 1) { - activityCounts.delete(activityKey); + activityCounts.delete(key); return; } - activityCounts.set(activityKey, current - 1); + activityCounts.set(key, current - 1); } /** @@ -23,12 +18,12 @@ function decrementActivity(activityKey: string): void { * Call the returned release function once the activity has finished. */ export function acquireDaemonActivity(activityKey: string): () => void { - const normalizedKey = normalizeActivityKey(activityKey); - if (!normalizedKey) { + const key = activityKey.trim(); + if (!key) { throw new Error('activityKey must be a non-empty string'); } - incrementActivity(normalizedKey); + incrementActivity(key); let released = false; return (): void => { @@ -36,7 +31,7 @@ export function acquireDaemonActivity(activityKey: string): () => void { return; } released = true; - decrementActivity(normalizedKey); + decrementActivity(key); }; } diff --git a/src/daemon/framing.ts b/src/daemon/framing.ts index ded554fa..5d024692 100644 --- a/src/daemon/framing.ts +++ b/src/daemon/framing.ts @@ -27,18 +27,13 @@ export function createFrameReader( while (buffer.length >= 4) { const len = buffer.readUInt32BE(0); - // Sanity check: reject messages larger than 100MB if (len > 100 * 1024 * 1024) { - const err = new Error(`Message too large: ${len} bytes`); - if (onError) { - onError(err); - } + onError?.(new Error(`Message too large: ${len} bytes`)); buffer = Buffer.alloc(0); return; } if (buffer.length < 4 + len) { - // Not enough data yet, wait for more return; } @@ -49,9 +44,7 @@ export function createFrameReader( const msg = JSON.parse(payload.toString('utf8')) as unknown; onMessage(msg); } catch (err) { - if (onError) { - onError(err instanceof Error ? err : new Error(String(err))); - } + onError?.(err instanceof Error ? err : new Error(String(err))); } } }; diff --git a/src/daemon/idle-shutdown.ts b/src/daemon/idle-shutdown.ts index fee5cc05..f4c96c10 100644 --- a/src/daemon/idle-shutdown.ts +++ b/src/daemon/idle-shutdown.ts @@ -1,11 +1,9 @@ -import { getDaemonActivitySnapshot, type DaemonActivitySnapshot } from './activity-registry.ts'; +import type { DaemonActivitySnapshot } from './activity-registry.ts'; export const DAEMON_IDLE_TIMEOUT_ENV_KEY = 'XCODEBUILDMCP_DAEMON_IDLE_TIMEOUT_MS'; export const DEFAULT_DAEMON_IDLE_TIMEOUT_MS = 10 * 60 * 1000; export const DEFAULT_DAEMON_IDLE_CHECK_INTERVAL_MS = 30 * 1000; -export type DaemonRuntimeActivitySnapshot = DaemonActivitySnapshot; - export function resolveDaemonIdleTimeoutMs( env: NodeJS.ProcessEnv = process.env, fallbackMs: number = DEFAULT_DAEMON_IDLE_TIMEOUT_MS, @@ -23,10 +21,6 @@ export function resolveDaemonIdleTimeoutMs( return Math.floor(parsed); } -export function getDaemonRuntimeActivitySnapshot(): DaemonRuntimeActivitySnapshot { - return getDaemonActivitySnapshot(); -} - -export function hasActiveRuntimeSessions(snapshot: DaemonRuntimeActivitySnapshot): boolean { +export function hasActiveRuntimeSessions(snapshot: DaemonActivitySnapshot): boolean { return snapshot.activeOperationCount > 0; } diff --git a/src/daemon/socket-path.ts b/src/daemon/socket-path.ts index fd4fce23..05fad29f 100644 --- a/src/daemon/socket-path.ts +++ b/src/daemon/socket-path.ts @@ -3,29 +3,16 @@ import { mkdirSync, existsSync, unlinkSync, realpathSync } from 'node:fs'; import { homedir } from 'node:os'; import { join, dirname } from 'node:path'; -/** - * Base directory for all daemon-related files. - */ export function daemonBaseDir(): string { return join(homedir(), '.xcodebuildmcp'); } -/** - * Directory containing all workspace daemons. - */ export function daemonsDir(): string { return join(daemonBaseDir(), 'daemons'); } -/** - * Resolve the workspace root from the given context. - * - * If a project config was found (path to .xcodebuildmcp/config.yaml), use its parent directory. - * Otherwise, use realpath(cwd). - */ export function resolveWorkspaceRoot(opts: { cwd: string; projectConfigPath?: string }): string { if (opts.projectConfigPath) { - // Config is at .xcodebuildmcp/config.yaml, so parent of parent is workspace root const configDir = dirname(opts.projectConfigPath); return dirname(configDir); } @@ -36,40 +23,24 @@ export function resolveWorkspaceRoot(opts: { cwd: string; projectConfigPath?: st } } -/** - * Generate a short, stable key from a workspace root path. - * Uses first 12 characters of SHA-256 hash. - */ export function workspaceKeyForRoot(workspaceRoot: string): string { const hash = createHash('sha256').update(workspaceRoot).digest('hex'); return hash.slice(0, 12); } -/** - * Get the daemon directory for a specific workspace key. - */ export function daemonDirForWorkspaceKey(key: string): string { return join(daemonsDir(), key); } -/** - * Get the socket path for a specific workspace root. - */ export function socketPathForWorkspaceRoot(workspaceRoot: string): string { const key = workspaceKeyForRoot(workspaceRoot); return join(daemonDirForWorkspaceKey(key), 'daemon.sock'); } -/** - * Get the registry file path for a specific workspace key. - */ export function registryPathForWorkspaceKey(key: string): string { return join(daemonDirForWorkspaceKey(key), 'daemon.json'); } -/** - * Get the log file path for a specific workspace key. - */ export function logPathForWorkspaceKey(key: string): string { return join(daemonDirForWorkspaceKey(key), 'daemon.log'); } @@ -80,23 +51,13 @@ export interface GetSocketPathOptions { env?: NodeJS.ProcessEnv; } -/** - * Get the socket path from environment or compute per-workspace. - * - * Resolution order: - * 1. If env.XCODEBUILDMCP_SOCKET is set, use it (explicit override) - * 2. If cwd is provided, compute workspace root and return per-workspace socket - * 3. Fall back to process.cwd() and compute workspace socket from that - */ export function getSocketPath(opts?: GetSocketPathOptions): string { const env = opts?.env ?? process.env; - // Explicit override takes precedence if (env.XCODEBUILDMCP_SOCKET) { return env.XCODEBUILDMCP_SOCKET; } - // Compute workspace-derived socket path const cwd = opts?.cwd ?? process.cwd(); const workspaceRoot = resolveWorkspaceRoot({ cwd, @@ -106,9 +67,6 @@ export function getSocketPath(opts?: GetSocketPathOptions): string { return socketPathForWorkspaceRoot(workspaceRoot); } -/** - * Get the workspace key for the current context. - */ export function getWorkspaceKey(opts?: GetSocketPathOptions): string { const cwd = opts?.cwd ?? process.cwd(); const workspaceRoot = resolveWorkspaceRoot({ @@ -118,9 +76,6 @@ export function getWorkspaceKey(opts?: GetSocketPathOptions): string { return workspaceKeyForRoot(workspaceRoot); } -/** - * Ensure the directory for the socket exists with proper permissions. - */ export function ensureSocketDir(socketPath: string): void { const dir = dirname(socketPath); if (!existsSync(dir)) { @@ -128,10 +83,6 @@ export function ensureSocketDir(socketPath: string): void { } } -/** - * Remove a stale socket file if it exists. - * Should only be called after confirming no daemon is running. - */ export function removeStaleSocket(socketPath: string): void { if (existsSync(socketPath)) { unlinkSync(socketPath); diff --git a/src/integrations/xcode-tools-bridge/manager.ts b/src/integrations/xcode-tools-bridge/manager.ts index 9df3a74f..a2019e5c 100644 --- a/src/integrations/xcode-tools-bridge/manager.ts +++ b/src/integrations/xcode-tools-bridge/manager.ts @@ -1,10 +1,8 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { log } from '../../utils/logger.ts'; -import { - createErrorResponse, - createTextResponse, - type ToolResponse, -} from '../../utils/responses/index.ts'; +import type { ToolResponse } from '../../types/common.ts'; +import { toolResponse } from '../../utils/tool-response.ts'; +import { header, statusLine, section } from '../../utils/tool-event-builders.ts'; import { XcodeToolsProxyRegistry, type ProxySyncResult } from './registry.ts'; import { buildXcodeToolsBridgeStatus, @@ -114,26 +112,27 @@ export class XcodeToolsBridgeManager { async statusTool(): Promise { const status = await this.getStatus(); - return createTextResponse(JSON.stringify(status, null, 2)); + return toolResponse([ + header('Bridge Status'), + section('Status', [JSON.stringify(status, null, 2)]), + ]); } async syncTool(): Promise { try { const sync = await this.syncTools({ reason: 'manual' }); const status = await this.getStatus(); - return createTextResponse( - JSON.stringify( - { - sync, - status, - }, - null, - 2, - ), - ); + return toolResponse([ + header('Bridge Sync'), + section('Sync Result', [JSON.stringify({ sync, status }, null, 2)]), + statusLine('success', 'Bridge sync completed'), + ]); } catch (error) { const message = error instanceof Error ? error.message : String(error); - return createErrorResponse('Bridge sync failed', message); + return toolResponse([ + header('Bridge Sync'), + statusLine('error', `Bridge sync failed: ${message}`), + ]); } } @@ -141,10 +140,17 @@ export class XcodeToolsBridgeManager { try { await this.disconnect(); const status = await this.getStatus(); - return createTextResponse(JSON.stringify(status, null, 2)); + return toolResponse([ + header('Bridge Disconnect'), + section('Status', [JSON.stringify(status, null, 2)]), + statusLine('success', 'Bridge disconnected'), + ]); } catch (error) { const message = error instanceof Error ? error.message : String(error); - return createErrorResponse('Bridge disconnect failed', message); + return toolResponse([ + header('Bridge Disconnect'), + statusLine('error', `Bridge disconnect failed: ${message}`), + ]); } } @@ -162,7 +168,11 @@ export class XcodeToolsBridgeManager { toolCount: tools.length, tools: tools.map(serializeBridgeTool), }; - return createTextResponse(JSON.stringify(payload, null, 2)); + return toolResponse([ + header('Xcode IDE List Tools'), + section('Tools', [JSON.stringify(payload, null, 2)]), + statusLine('success', `Found ${tools.length} tool(s)`), + ]); } catch (error) { return this.createBridgeFailureResponse( classifyBridgeError(error, 'list', { @@ -202,6 +212,9 @@ export class XcodeToolsBridgeManager { private createBridgeFailureResponse(code: string, error: unknown): ToolResponse { const message = error instanceof Error ? error.message : String(error); - return createErrorResponse(code, message); + return toolResponse([ + header('Xcode IDE Call Tool'), + statusLine('error', `[${code}] ${message}`), + ]); } } diff --git a/src/integrations/xcode-tools-bridge/registry.ts b/src/integrations/xcode-tools-bridge/registry.ts index 4dd98ac4..51c746f7 100644 --- a/src/integrations/xcode-tools-bridge/registry.ts +++ b/src/integrations/xcode-tools-bridge/registry.ts @@ -139,8 +139,7 @@ function buildBestEffortInputSchema(tool: Tool): z.ZodTypeAny { if (!tool.inputSchema) { return z.object({}).passthrough(); } - const zod = jsonSchemaToZod(tool.inputSchema); - return zod; + return jsonSchemaToZod(tool.inputSchema); } function buildBestEffortAnnotations(tool: Tool, localName: string): ToolAnnotations { @@ -158,10 +157,9 @@ function buildBestEffortAnnotations(tool: Tool, localName: string): ToolAnnotati } function inferReadOnlyHint(localToolName: string): boolean { - // Default to conservative: most IDE tools can mutate project state. const name = localToolName.toLowerCase(); - const definitelyReadOnlyPrefixes = [ + const readOnlyPrefixes = [ 'xcode_tools_xcodelist', 'xcode_tools_xcodeglob', 'xcode_tools_xcodegrep', @@ -172,9 +170,7 @@ function inferReadOnlyHint(localToolName: string): boolean { 'xcode_tools_gettestlist', ]; - if (definitelyReadOnlyPrefixes.some((p) => name.startsWith(p))) return true; - - return false; + return readOnlyPrefixes.some((p) => name.startsWith(p)); } function inferDestructiveHint(localToolName: string, readOnlyHint: boolean): boolean { diff --git a/src/integrations/xcode-tools-bridge/standalone.ts b/src/integrations/xcode-tools-bridge/standalone.ts index cc1a042e..257e70c7 100644 --- a/src/integrations/xcode-tools-bridge/standalone.ts +++ b/src/integrations/xcode-tools-bridge/standalone.ts @@ -1,8 +1,6 @@ -import { - createErrorResponse, - createTextResponse, - type ToolResponse, -} from '../../utils/responses/index.ts'; +import type { ToolResponse } from '../../types/common.ts'; +import { toolResponse } from '../../utils/tool-response.ts'; +import { header, statusLine, section } from '../../utils/tool-event-builders.ts'; import { buildXcodeToolsBridgeStatus, classifyBridgeError, @@ -34,7 +32,10 @@ export class StandaloneXcodeToolsBridge { async statusTool(): Promise { const status = await this.getStatus(); - return createTextResponse(JSON.stringify(status, null, 2)); + return toolResponse([ + header('Bridge Status'), + section('Status', [JSON.stringify(status, null, 2)]), + ]); } async syncTool(): Promise { @@ -48,10 +49,17 @@ export class StandaloneXcodeToolsBridge { total: remoteTools.length, }; const status = await this.getStatus(); - return createTextResponse(JSON.stringify({ sync, status }, null, 2)); + return toolResponse([ + header('Bridge Sync'), + section('Sync Result', [JSON.stringify({ sync, status }, null, 2)]), + statusLine('success', 'Bridge sync completed'), + ]); } catch (error) { const message = error instanceof Error ? error.message : String(error); - return createErrorResponse('Bridge sync failed', message); + return toolResponse([ + header('Bridge Sync'), + statusLine('error', `Bridge sync failed: ${message}`), + ]); } finally { await this.service.disconnect(); } @@ -61,29 +69,39 @@ export class StandaloneXcodeToolsBridge { try { await this.service.disconnect(); const status = await this.getStatus(); - return createTextResponse(JSON.stringify(status, null, 2)); + return toolResponse([ + header('Bridge Disconnect'), + section('Status', [JSON.stringify(status, null, 2)]), + statusLine('success', 'Bridge disconnected'), + ]); } catch (error) { const message = error instanceof Error ? error.message : String(error); - return createErrorResponse('Bridge disconnect failed', message); + return toolResponse([ + header('Bridge Disconnect'), + statusLine('error', `Bridge disconnect failed: ${message}`), + ]); } } async listToolsTool(params: { refresh?: boolean }): Promise { try { const tools = await this.service.listTools({ refresh: params.refresh !== false }); - return createTextResponse( - JSON.stringify( - { - toolCount: tools.length, - tools: tools.map(serializeBridgeTool), - }, - null, - 2, - ), - ); + const payload = { + toolCount: tools.length, + tools: tools.map(serializeBridgeTool), + }; + return toolResponse([ + header('Xcode IDE List Tools'), + section('Tools', [JSON.stringify(payload, null, 2)]), + statusLine('success', `Found ${tools.length} tool(s)`), + ]); } catch (error) { const message = error instanceof Error ? error.message : String(error); - return createErrorResponse(classifyBridgeError(error, 'list'), message); + const code = classifyBridgeError(error, 'list'); + return toolResponse([ + header('Xcode IDE List Tools'), + statusLine('error', `[${code}] ${message}`), + ]); } finally { await this.service.disconnect(); } @@ -101,7 +119,11 @@ export class StandaloneXcodeToolsBridge { return response as ToolResponse; } catch (error) { const message = error instanceof Error ? error.message : String(error); - return createErrorResponse(classifyBridgeError(error, 'call'), message); + const code = classifyBridgeError(error, 'call'); + return toolResponse([ + header('Xcode IDE Call Tool'), + statusLine('error', `[${code}] ${message}`), + ]); } finally { await this.service.disconnect(); } diff --git a/src/mcp/resources/__tests__/doctor.test.ts b/src/mcp/resources/__tests__/doctor.test.ts index 28534afd..7a691455 100644 --- a/src/mcp/resources/__tests__/doctor.test.ts +++ b/src/mcp/resources/__tests__/doctor.test.ts @@ -32,24 +32,24 @@ describe('doctor resource', () => { }); const result = await doctorResourceLogic(mockExecutor); + const text = result.contents.map((c) => c.text).join('\n'); expect(result.contents).toHaveLength(1); - expect(result.contents[0].text).toContain('XcodeBuildMCP Doctor'); - expect(result.contents[0].text).toContain('## System Information'); - expect(result.contents[0].text).toContain('## Node.js Information'); - expect(result.contents[0].text).toContain('## Dependencies'); - expect(result.contents[0].text).toContain('## Environment Variables'); - expect(result.contents[0].text).toContain('## Feature Status'); + expect(text).toContain('Doctor'); + expect(text).toContain('Node.js Information'); + expect(text).toContain('Dependencies'); + expect(text).toContain('Environment Variables'); }); it('should handle spawn errors by showing doctor info', async () => { const mockExecutor = createMockExecutor(new Error('spawn xcrun ENOENT')); const result = await doctorResourceLogic(mockExecutor); + const text = result.contents.map((c) => c.text).join('\n'); expect(result.contents).toHaveLength(1); - expect(result.contents[0].text).toContain('XcodeBuildMCP Doctor'); - expect(result.contents[0].text).toContain('Error: spawn xcrun ENOENT'); + expect(text).toContain('Doctor'); + expect(text).toContain('spawn xcrun ENOENT'); }); it('should include required doctor sections', async () => { @@ -59,10 +59,11 @@ describe('doctor resource', () => { }); const result = await doctorResourceLogic(mockExecutor); + const text = result.contents.map((c) => c.text).join('\n'); - expect(result.contents[0].text).toContain('## Troubleshooting Tips'); - expect(result.contents[0].text).toContain('brew tap cameroncooke/axe'); - expect(result.contents[0].text).toContain('INCREMENTAL_BUILDS_ENABLED=1'); + expect(text).toContain('Troubleshooting Tips'); + expect(text).toContain('brew tap cameroncooke/axe'); + expect(text).toContain('INCREMENTAL_BUILDS_ENABLED=1'); }); it('should provide feature status information', async () => { @@ -72,11 +73,12 @@ describe('doctor resource', () => { }); const result = await doctorResourceLogic(mockExecutor); + const text = result.contents.map((c) => c.text).join('\n'); - expect(result.contents[0].text).toContain('### UI Automation (axe)'); - expect(result.contents[0].text).toContain('### Incremental Builds'); - expect(result.contents[0].text).toContain('### Mise Integration'); - expect(result.contents[0].text).toContain('## Tool Availability Summary'); + expect(text).toContain('UI Automation (axe)'); + expect(text).toContain('Incremental Builds'); + expect(text).toContain('Mise Integration'); + expect(text).toContain('Tool Availability Summary'); }); it('should handle error conditions gracefully', async () => { @@ -87,9 +89,10 @@ describe('doctor resource', () => { }); const result = await doctorResourceLogic(mockExecutor); + const text = result.contents.map((c) => c.text).join('\n'); expect(result.contents).toHaveLength(1); - expect(result.contents[0].text).toContain('XcodeBuildMCP Doctor'); + expect(text).toContain('Doctor'); }); }); }); diff --git a/src/mcp/resources/__tests__/simulators.test.ts b/src/mcp/resources/__tests__/simulators.test.ts index eadc7a99..e1f39d0e 100644 --- a/src/mcp/resources/__tests__/simulators.test.ts +++ b/src/mcp/resources/__tests__/simulators.test.ts @@ -49,9 +49,10 @@ describe('simulators resource', () => { const result = await simulatorsResourceLogic(mockExecutor); expect(result.contents).toHaveLength(1); - expect(result.contents[0].text).toContain('Available iOS Simulators:'); - expect(result.contents[0].text).toContain('iPhone 15 Pro'); - expect(result.contents[0].text).toContain('ABC123-DEF456-GHI789'); + const text = result.contents[0].text; + expect(text).toContain('List Simulators'); + expect(text).toContain('iPhone 15 Pro'); + expect(text).toContain('ABC123-DEF456-GHI789'); }); it('should handle command execution failure', async () => { @@ -94,8 +95,10 @@ describe('simulators resource', () => { const result = await simulatorsResourceLogic(mockExecutor); expect(result.contents).toHaveLength(1); - expect(result.contents[0].text).toContain('iPhone 15 (test-uuid-123)'); - expect(result.contents[0].text).toContain('iOS 17.0'); + const text = result.contents[0].text; + expect(text).toContain('iPhone 15'); + expect(text).toContain('test-uuid-123'); + expect(text).toContain('iOS 17.0'); }); it('should handle spawn errors', async () => { @@ -117,7 +120,7 @@ describe('simulators resource', () => { const result = await simulatorsResourceLogic(mockExecutor); expect(result.contents).toHaveLength(1); - expect(result.contents[0].text).toContain('Available iOS Simulators:'); + expect(result.contents[0].text).toContain('List Simulators'); }); it('should handle booted simulators correctly', async () => { @@ -139,7 +142,7 @@ describe('simulators resource', () => { const result = await simulatorsResourceLogic(mockExecutor); - expect(result.contents[0].text).toContain('[Booted]'); + expect(result.contents[0].text).toContain('Booted'); }); it('should filter out unavailable simulators', async () => { @@ -190,10 +193,9 @@ describe('simulators resource', () => { const result = await simulatorsResourceLogic(mockExecutor); - // The resource returns text content with simulator list and hint - expect(result.contents[0].text).toContain('iPhone 15 Pro'); - expect(result.contents[0].text).toContain('ABC123-DEF456-GHI789'); - expect(result.contents[0].text).toContain('session-set-defaults'); + const text = result.contents[0].text; + expect(text).toContain('iPhone 15 Pro'); + expect(text).toContain('ABC123-DEF456-GHI789'); }); }); }); diff --git a/src/mcp/resources/devices.ts b/src/mcp/resources/devices.ts index cb8d5e39..97d6f0b3 100644 --- a/src/mcp/resources/devices.ts +++ b/src/mcp/resources/devices.ts @@ -19,17 +19,16 @@ export async function devicesResourceLogic( const result = await list_devicesLogic({}, executor); if (result.isError) { - const errorText = result.content[0]?.text; - throw new Error(typeof errorText === 'string' ? errorText : 'Failed to retrieve device data'); + const errorText = result.content.map((c) => ('text' in c ? c.text : '')).join('\n'); + throw new Error(errorText || 'Failed to retrieve device data'); } + const joinedText = result.content.map((c) => ('text' in c ? c.text : '')).join('\n'); + return { contents: [ { - text: - typeof result.content[0]?.text === 'string' - ? result.content[0].text - : 'No device data available', + text: joinedText || 'No device data available', }, ], }; diff --git a/src/mcp/resources/doctor.ts b/src/mcp/resources/doctor.ts index ee07509c..d3710dc3 100644 --- a/src/mcp/resources/doctor.ts +++ b/src/mcp/resources/doctor.ts @@ -35,13 +35,14 @@ export async function doctorResourceLogic( }; } - const okTextItem = result.content.find((i) => i.type === 'text') as - | { type: 'text'; text: string } - | undefined; + const allText = result.content + .filter((i): i is { type: 'text'; text: string } => i.type === 'text') + .map((i) => i.text) + .join('\n'); return { contents: [ { - text: okTextItem?.text ?? 'No doctor data available', + text: allText || 'No doctor data available', }, ], }; diff --git a/src/mcp/resources/simulators.ts b/src/mcp/resources/simulators.ts index 2da3afeb..b1eada89 100644 --- a/src/mcp/resources/simulators.ts +++ b/src/mcp/resources/simulators.ts @@ -19,19 +19,16 @@ export async function simulatorsResourceLogic( const result = await list_simsLogic({}, executor); if (result.isError) { - const errorText = result.content[0]?.text; - throw new Error( - typeof errorText === 'string' ? errorText : 'Failed to retrieve simulator data', - ); + const errorText = result.content.map((c) => ('text' in c ? c.text : '')).join('\n'); + throw new Error(errorText || 'Failed to retrieve simulator data'); } + const joinedText = result.content.map((c) => ('text' in c ? c.text : '')).join('\n'); + return { contents: [ { - text: - typeof result.content[0]?.text === 'string' - ? result.content[0].text - : 'No simulator data available', + text: joinedText || 'No simulator data available', }, ], }; diff --git a/src/mcp/tools/coverage/__tests__/get_coverage_report.test.ts b/src/mcp/tools/coverage/__tests__/get_coverage_report.test.ts index 1f715919..224bd0a6 100644 --- a/src/mcp/tools/coverage/__tests__/get_coverage_report.test.ts +++ b/src/mcp/tools/coverage/__tests__/get_coverage_report.test.ts @@ -10,8 +10,16 @@ import { __setTestFileSystemExecutorOverride, __clearTestExecutorOverrides, } from '../../../../utils/execution/index.ts'; +import type { ToolResponse } from '../../../../types/common.ts'; import { schema, handler, get_coverage_reportLogic } from '../get_coverage_report.ts'; +function allText(result: ToolResponse): string { + return result.content + .filter((c): c is { type: 'text'; text: string } => c.type === 'text') + .map((c) => c.text) + .join('\n'); +} + const sampleTargets = [ { name: 'MyApp.app', coveredLines: 100, executableLines: 200, lineCoverage: 0.5 }, { name: 'Core', coveredLines: 50, executableLines: 500, lineCoverage: 0.1 }, @@ -99,7 +107,7 @@ describe('get_coverage_report', () => { const result = await handler({ xcresultPath: '/tmp/missing.xcresult', showFiles: false }); expect(result.isError).toBe(true); - const text = result.content[0].type === 'text' ? result.content[0].text : ''; + const text = allText(result); expect(text).toContain('File not found'); }); @@ -181,9 +189,9 @@ describe('get_coverage_report', () => { ); expect(result.isError).toBeUndefined(); - expect(result.content).toHaveLength(1); - const text = result.content[0].type === 'text' ? result.content[0].text : ''; - expect(text).toContain('Code Coverage Report'); + expect(result.content.length).toBeGreaterThanOrEqual(1); + const text = allText(result); + expect(text).toContain('Coverage Report'); expect(text).toContain('Overall: 24.7%'); expect(text).toContain('180/730 lines'); const coreIdx = text.indexOf('Core'); @@ -222,7 +230,7 @@ describe('get_coverage_report', () => { ); expect(result.isError).toBeUndefined(); - const text = result.content[0].type === 'text' ? result.content[0].text : ''; + const text = allText(result); expect(text).toContain('Core: 10.0%'); expect(text).toContain('MyApp.app: 50.0%'); }); @@ -241,7 +249,7 @@ describe('get_coverage_report', () => { ); expect(result.isError).toBeUndefined(); - const text = result.content[0].type === 'text' ? result.content[0].text : ''; + const text = allText(result); expect(text).toContain('MyApp.app'); expect(text).toContain('MyAppTests.xctest'); expect(text).not.toMatch(/^\s+Core:/m); @@ -259,7 +267,7 @@ describe('get_coverage_report', () => { ); expect(result.isError).toBeUndefined(); - const text = result.content[0].type === 'text' ? result.content[0].text : ''; + const text = allText(result); expect(text).toContain('Core: 10.0%'); }); @@ -275,7 +283,7 @@ describe('get_coverage_report', () => { ); expect(result.isError).toBe(true); - const text = result.content[0].type === 'text' ? result.content[0].text : ''; + const text = allText(result); expect(text).toContain('No targets found matching "NonExistent"'); }); }); @@ -293,7 +301,7 @@ describe('get_coverage_report', () => { ); expect(result.isError).toBeUndefined(); - const text = result.content[0].type === 'text' ? result.content[0].text : ''; + const text = allText(result); expect(text).toContain('AppDelegate.swift: 20.0%'); expect(text).toContain('ViewModel.swift: 60.0%'); expect(text).toContain('Service.swift: 0.0%'); @@ -311,7 +319,7 @@ describe('get_coverage_report', () => { { executor: mockExecutor, fileSystem: mockFileSystem }, ); - const text = result.content[0].type === 'text' ? result.content[0].text : ''; + const text = allText(result); const appDelegateIdx = text.indexOf('AppDelegate.swift'); const viewModelIdx = text.indexOf('ViewModel.swift'); expect(appDelegateIdx).toBeLessThan(viewModelIdx); @@ -329,7 +337,7 @@ describe('get_coverage_report', () => { ); expect(result.isError).toBe(true); - const text = result.content[0].type === 'text' ? result.content[0].text : ''; + const text = allText(result); expect(text).toContain('File not found'); expect(text).toContain('/tmp/missing.xcresult'); }); @@ -346,7 +354,7 @@ describe('get_coverage_report', () => { ); expect(result.isError).toBe(true); - const text = result.content[0].type === 'text' ? result.content[0].text : ''; + const text = allText(result); expect(text).toContain('Failed to get coverage report'); expect(text).toContain('Failed to load result bundle'); }); @@ -363,7 +371,7 @@ describe('get_coverage_report', () => { ); expect(result.isError).toBe(true); - const text = result.content[0].type === 'text' ? result.content[0].text : ''; + const text = allText(result); expect(text).toContain('Failed to parse coverage JSON output'); }); @@ -379,7 +387,7 @@ describe('get_coverage_report', () => { ); expect(result.isError).toBe(true); - const text = result.content[0].type === 'text' ? result.content[0].text : ''; + const text = allText(result); expect(text).toContain('Unexpected coverage data format'); }); @@ -395,7 +403,7 @@ describe('get_coverage_report', () => { ); expect(result.isError).toBe(true); - const text = result.content[0].type === 'text' ? result.content[0].text : ''; + const text = allText(result); expect(text).toContain('No coverage data found'); }); }); diff --git a/src/mcp/tools/coverage/__tests__/get_file_coverage.test.ts b/src/mcp/tools/coverage/__tests__/get_file_coverage.test.ts index 611bb9f7..9803ae8e 100644 --- a/src/mcp/tools/coverage/__tests__/get_file_coverage.test.ts +++ b/src/mcp/tools/coverage/__tests__/get_file_coverage.test.ts @@ -14,8 +14,16 @@ import { __setTestFileSystemExecutorOverride, __clearTestExecutorOverrides, } from '../../../../utils/execution/index.ts'; +import type { ToolResponse } from '../../../../types/common.ts'; import { schema, handler, get_file_coverageLogic } from '../get_file_coverage.ts'; +function allText(result: ToolResponse): string { + return result.content + .filter((c): c is { type: 'text'; text: string } => c.type === 'text') + .map((c) => c.text) + .join('\n'); +} + const sampleFunctionsJson = [ { file: '/src/MyApp/ViewModel.swift', @@ -102,7 +110,7 @@ describe('get_file_coverage', () => { }); expect(result.isError).toBe(true); - const text = result.content[0].type === 'text' ? result.content[0].text : ''; + const text = allText(result); expect(text).toContain('File not found'); }); @@ -211,13 +219,13 @@ describe('get_file_coverage', () => { ); expect(result.isError).toBeUndefined(); - const text = result.content[0].type === 'text' ? result.content[0].text : ''; + const text = allText(result); expect(text).toContain('File: /src/MyApp/ViewModel.swift'); expect(text).toContain('Coverage: 61.9%'); expect(text).toContain('13/21 lines'); }); - it('should mark uncovered functions with [NOT COVERED]', async () => { + it('should group uncovered functions under Not Covered section', async () => { const mockExecutor = createMockExecutor({ success: true, output: JSON.stringify(sampleFunctionsJson), @@ -228,12 +236,12 @@ describe('get_file_coverage', () => { { executor: mockExecutor, fileSystem: mockFileSystem }, ); - const text = result.content[0].type === 'text' ? result.content[0].text : ''; - expect(text).toContain('[NOT COVERED] L40 reset()'); - expect(text).not.toContain('[NOT COVERED] L10 init()'); + const text = allText(result); + expect(text).toContain('Not Covered (1 function, 4 lines)'); + expect(text).toContain('L40 reset()'); }); - it('should sort functions by line number', async () => { + it('should group functions by coverage status', async () => { const mockExecutor = createMockExecutor({ success: true, output: JSON.stringify(sampleFunctionsJson), @@ -244,15 +252,15 @@ describe('get_file_coverage', () => { { executor: mockExecutor, fileSystem: mockFileSystem }, ); - const text = result.content[0].type === 'text' ? result.content[0].text : ''; - const initIdx = text.indexOf('L10 init()'); - const loadIdx = text.indexOf('L20 loadData()'); - const resetIdx = text.indexOf('L40 reset()'); - expect(initIdx).toBeLessThan(loadIdx); - expect(loadIdx).toBeLessThan(resetIdx); + const text = allText(result); + const notCoveredIdx = text.indexOf('Not Covered'); + const partialIdx = text.indexOf('Partial Coverage'); + const fullIdx = text.indexOf('Full Coverage'); + expect(notCoveredIdx).toBeLessThan(partialIdx); + expect(partialIdx).toBeLessThan(fullIdx); }); - it('should list uncovered functions summary', async () => { + it('should show partial coverage functions with percentage', async () => { const mockExecutor = createMockExecutor({ success: true, output: JSON.stringify(sampleFunctionsJson), @@ -263,9 +271,10 @@ describe('get_file_coverage', () => { { executor: mockExecutor, fileSystem: mockFileSystem }, ); - const text = result.content[0].type === 'text' ? result.content[0].text : ''; - expect(text).toContain('Uncovered functions (1):'); - expect(text).toContain('- reset() (line 40)'); + const text = allText(result); + expect(text).toContain('Partial Coverage (1 function)'); + expect(text).toContain('L20 loadData()'); + expect(text).toContain('66.7%'); }); it('should include nextStepParams', async () => { @@ -324,7 +333,7 @@ describe('get_file_coverage', () => { ); expect(result.isError).toBeUndefined(); - const text = result.content[0].type === 'text' ? result.content[0].text : ''; + const text = allText(result); expect(text).toContain('File: /src/Model.swift'); expect(text).toContain('50.0%'); }); @@ -356,8 +365,8 @@ describe('get_file_coverage', () => { { executor: mockExecutor, fileSystem: mockFileSystem }, ); - const text = result.content[0].type === 'text' ? result.content[0].text : ''; - expect(text).toContain('Uncovered line ranges (/src/MyApp/ViewModel.swift):'); + const text = allText(result); + expect(text).toContain('Uncovered line ranges (/src/MyApp/ViewModel.swift)'); expect(text).toContain('L4-6'); expect(text).toContain('L9'); }); @@ -388,7 +397,7 @@ describe('get_file_coverage', () => { { executor: mockExecutor, fileSystem: mockFileSystem }, ); - const text = result.content[0].type === 'text' ? result.content[0].text : ''; + const text = allText(result); expect(text).toContain('All executable lines are covered'); }); @@ -423,7 +432,7 @@ describe('get_file_coverage', () => { ); expect(result.isError).toBeUndefined(); - const text = result.content[0].type === 'text' ? result.content[0].text : ''; + const text = allText(result); expect(text).toContain('Could not retrieve line-level coverage from archive'); }); }); @@ -439,7 +448,7 @@ describe('get_file_coverage', () => { ); expect(result.isError).toBe(true); - const text = result.content[0].type === 'text' ? result.content[0].text : ''; + const text = allText(result); expect(text).toContain('File not found'); expect(text).toContain('/tmp/missing.xcresult'); }); @@ -456,7 +465,7 @@ describe('get_file_coverage', () => { ); expect(result.isError).toBe(true); - const text = result.content[0].type === 'text' ? result.content[0].text : ''; + const text = allText(result); expect(text).toContain('Failed to get file coverage'); expect(text).toContain('Failed to load result bundle'); }); @@ -473,7 +482,7 @@ describe('get_file_coverage', () => { ); expect(result.isError).toBe(true); - const text = result.content[0].type === 'text' ? result.content[0].text : ''; + const text = allText(result); expect(text).toContain('Failed to parse coverage JSON output'); }); @@ -489,7 +498,7 @@ describe('get_file_coverage', () => { ); expect(result.isError).toBe(true); - const text = result.content[0].type === 'text' ? result.content[0].text : ''; + const text = allText(result); expect(text).toContain('No coverage data found for "Missing.swift"'); }); @@ -505,7 +514,7 @@ describe('get_file_coverage', () => { ); expect(result.isError).toBe(true); - const text = result.content[0].type === 'text' ? result.content[0].text : ''; + const text = allText(result); expect(text).toContain('No coverage data found for "Foo.swift"'); }); @@ -522,7 +531,7 @@ describe('get_file_coverage', () => { ); expect(result.isError).toBeUndefined(); - const text = result.content[0].type === 'text' ? result.content[0].text : ''; + const text = allText(result); expect(text).toContain('File: /src/Empty.swift'); expect(text).toContain('Coverage: 0.0%'); expect(text).toContain('0/0 lines'); diff --git a/src/mcp/tools/coverage/get_coverage_report.ts b/src/mcp/tools/coverage/get_coverage_report.ts index 3ab48783..10b768cc 100644 --- a/src/mcp/tools/coverage/get_coverage_report.ts +++ b/src/mcp/tools/coverage/get_coverage_report.ts @@ -7,11 +7,14 @@ import * as z from 'zod'; import type { ToolResponse } from '../../../types/common.ts'; +import type { PipelineEvent } from '../../../types/pipeline-events.ts'; import { log } from '../../../utils/logging/index.ts'; import { validateFileExists } from '../../../utils/validation/index.ts'; import type { CommandExecutor, FileSystemExecutor } from '../../../utils/execution/index.ts'; import { getDefaultCommandExecutor, getDefaultFileSystemExecutor } from '../../../utils/execution/index.ts'; import { createTypedToolWithContext } from '../../../utils/typed-tool-factory.ts'; +import { toolResponse } from '../../../utils/tool-response.ts'; +import { header, statusLine, section } from '../../../utils/tool-event-builders.ts'; const getCoverageReportSchema = z.object({ xcresultPath: z.string().describe('Path to the .xcresult bundle'), @@ -63,9 +66,18 @@ export async function get_coverage_reportLogic( ): Promise { const { xcresultPath, target, showFiles } = params; + const headerParams = [{ label: 'xcresult', value: xcresultPath }]; + if (target) { + headerParams.push({ label: 'Target Filter', value: target }); + } + const headerEvent = header('Coverage Report', headerParams); + const fileExistsValidation = validateFileExists(xcresultPath, context.fileSystem); if (!fileExistsValidation.isValid) { - return fileExistsValidation.errorResponse!; + return toolResponse([ + headerEvent, + statusLine('error', fileExistsValidation.errorMessage!), + ]); } log('info', `Getting coverage report from: ${xcresultPath}`); @@ -76,36 +88,25 @@ export async function get_coverage_reportLogic( } cmd.push('--json', xcresultPath); - const result = await context.executor(cmd, 'Get Coverage Report', false, undefined); + const result = await context.executor(cmd, 'Get Coverage Report', false); if (!result.success) { - return { - content: [ - { - type: 'text', - text: `Failed to get coverage report: ${result.error ?? result.output}\n\nMake sure the xcresult bundle exists and contains coverage data.\nHint: Run tests with coverage enabled (e.g., xcodebuild test -enableCodeCoverage YES).`, - }, - ], - isError: true, - }; + return toolResponse([ + headerEvent, + statusLine('error', `Failed to get coverage report: ${result.error ?? result.output}`), + ]); } let data: unknown; try { data = JSON.parse(result.output); } catch { - return { - content: [ - { - type: 'text', - text: `Failed to parse coverage JSON output.\n\nRaw output:\n${result.output}`, - }, - ], - isError: true, - }; + return toolResponse([ + headerEvent, + statusLine('error', `Failed to parse coverage JSON output.\n\nRaw output:\n${result.output}`), + ]); } - // Validate structure: expect an array of target objects or { targets: [...] } let rawTargets: unknown[] = []; if (Array.isArray(data)) { rawTargets = data; @@ -117,53 +118,32 @@ export async function get_coverage_reportLogic( ) { rawTargets = (data as { targets: unknown[] }).targets; } else { - return { - content: [ - { - type: 'text', - text: `Unexpected coverage data format.\n\nRaw output:\n${result.output}`, - }, - ], - isError: true, - }; + return toolResponse([ + headerEvent, + statusLine('error', `Unexpected coverage data format.\n\nRaw output:\n${result.output}`), + ]); } let targets = rawTargets.filter(isValidCoverageTarget); - // Filter by target name if specified if (target) { const lowerTarget = target.toLowerCase(); targets = targets.filter((t) => t.name.toLowerCase().includes(lowerTarget)); if (targets.length === 0) { - return { - content: [ - { - type: 'text', - text: `No targets found matching "${target}".`, - }, - ], - isError: true, - }; + return toolResponse([ + headerEvent, + statusLine('error', `No targets found matching "${target}".`), + ]); } } if (targets.length === 0) { - return { - content: [ - { - type: 'text', - text: 'No coverage data found in the xcresult bundle.\n\nMake sure tests were run with coverage enabled.', - }, - ], - isError: true, - }; + return toolResponse([ + headerEvent, + statusLine('error', 'No coverage data found in the xcresult bundle.\n\nMake sure tests were run with coverage enabled.'), + ]); } - // Build human-readable output - let text = 'Code Coverage Report\n'; - text += '====================\n\n'; - - // Calculate overall stats let totalCovered = 0; let totalExecutable = 0; for (const t of targets) { @@ -171,32 +151,34 @@ export async function get_coverage_reportLogic( totalExecutable += t.executableLines; } const overallPct = totalExecutable > 0 ? (totalCovered / totalExecutable) * 100 : 0; - text += `Overall: ${overallPct.toFixed(1)}% (${totalCovered}/${totalExecutable} lines)\n\n`; - text += 'Targets:\n'; - // Sort by coverage ascending (lowest coverage first) targets.sort((a, b) => a.lineCoverage - b.lineCoverage); + const targetLines: string[] = []; for (const t of targets) { const pct = (t.lineCoverage * 100).toFixed(1); - text += ` ${t.name}: ${pct}% (${t.coveredLines}/${t.executableLines} lines)\n`; + targetLines.push(`${t.name}: ${pct}% (${t.coveredLines}/${t.executableLines} lines)`); if (showFiles && t.files && t.files.length > 0) { const sortedFiles = [...t.files].sort((a, b) => a.lineCoverage - b.lineCoverage); for (const f of sortedFiles) { const fPct = (f.lineCoverage * 100).toFixed(1); - text += ` ${f.name}: ${fPct}% (${f.coveredLines}/${f.executableLines} lines)\n`; + targetLines.push(` ${f.name}: ${fPct}% (${f.coveredLines}/${f.executableLines} lines)`); } - text += '\n'; } } - return { - content: [{ type: 'text', text }], + const events: PipelineEvent[] = [ + headerEvent, + statusLine('info', `Overall: ${overallPct.toFixed(1)}% (${totalCovered}/${totalExecutable} lines)`), + section('Targets', targetLines), + ]; + + return toolResponse(events, { nextStepParams: { get_file_coverage: { xcresultPath }, }, - }; + }); } export const schema = getCoverageReportSchema.shape; diff --git a/src/mcp/tools/coverage/get_file_coverage.ts b/src/mcp/tools/coverage/get_file_coverage.ts index f1f719cf..25193f7a 100644 --- a/src/mcp/tools/coverage/get_file_coverage.ts +++ b/src/mcp/tools/coverage/get_file_coverage.ts @@ -7,11 +7,14 @@ import * as z from 'zod'; import type { ToolResponse } from '../../../types/common.ts'; +import type { PipelineEvent } from '../../../types/pipeline-events.ts'; import { log } from '../../../utils/logging/index.ts'; import { validateFileExists } from '../../../utils/validation/index.ts'; import type { CommandExecutor, FileSystemExecutor } from '../../../utils/execution/index.ts'; import { getDefaultCommandExecutor, getDefaultFileSystemExecutor } from '../../../utils/execution/index.ts'; import { createTypedToolWithContext } from '../../../utils/typed-tool-factory.ts'; +import { toolResponse } from '../../../utils/tool-response.ts'; +import { header, statusLine, section, fileRef } from '../../../utils/tool-event-builders.ts'; const getFileCoverageSchema = z.object({ xcresultPath: z.string().describe('Path to the .xcresult bundle'), @@ -75,14 +78,21 @@ export async function get_file_coverageLogic( ): Promise { const { xcresultPath, file, showLines } = params; + const headerEvent = header('File Coverage', [ + { label: 'xcresult', value: xcresultPath }, + { label: 'File', value: file }, + ]); + const fileExistsValidation = validateFileExists(xcresultPath, context.fileSystem); if (!fileExistsValidation.isValid) { - return fileExistsValidation.errorResponse!; + return toolResponse([ + headerEvent, + statusLine('error', fileExistsValidation.errorMessage!), + ]); } log('info', `Getting file coverage for "${file}" from: ${xcresultPath}`); - // Get function-level coverage const funcResult = await context.executor( ['xcrun', 'xccov', 'view', '--report', '--functions-for-file', file, '--json', xcresultPath], 'Get File Function Coverage', @@ -91,35 +101,22 @@ export async function get_file_coverageLogic( ); if (!funcResult.success) { - return { - content: [ - { - type: 'text', - text: `Failed to get file coverage: ${funcResult.error ?? funcResult.output}\n\nMake sure the xcresult bundle exists and contains coverage data for "${file}".`, - }, - ], - isError: true, - }; + return toolResponse([ + headerEvent, + statusLine('error', `Failed to get file coverage: ${funcResult.error ?? funcResult.output}`), + ]); } let data: unknown; try { data = JSON.parse(funcResult.output); } catch { - return { - content: [ - { - type: 'text', - text: `Failed to parse coverage JSON output.\n\nRaw output:\n${funcResult.output}`, - }, - ], - isError: true, - }; + return toolResponse([ + headerEvent, + statusLine('error', `Failed to parse coverage JSON output.\n\nRaw output:\n${funcResult.output}`), + ]); } - // The output can be: - // - An array of { file, functions } objects (xccov flat format) - // - { targets: [{ files: [...] }] } (nested format) let fileEntries: FileFunctionCoverage[] = []; if (Array.isArray(data)) { @@ -141,51 +138,72 @@ export async function get_file_coverageLogic( } if (fileEntries.length === 0) { - return { - content: [ - { - type: 'text', - text: `No coverage data found for "${file}".\n\nMake sure the file name or path is correct and that tests covered this file.`, - }, - ], - isError: true, - }; + return toolResponse([ + headerEvent, + statusLine('error', `No coverage data found for "${file}".\n\nMake sure the file name or path is correct and that tests covered this file.`), + ]); } - // Build human-readable output - let text = ''; + const events: PipelineEvent[] = [headerEvent]; for (const entry of fileEntries) { const filePct = (entry.lineCoverage * 100).toFixed(1); - text += `File: ${entry.filePath}\n`; - text += `Coverage: ${filePct}% (${entry.coveredLines}/${entry.executableLines} lines)\n`; - text += '---\n'; + events.push(fileRef(entry.filePath, 'File')); + events.push(statusLine('info', `Coverage: ${filePct}% (${entry.coveredLines}/${entry.executableLines} lines)`)); if (entry.functions && entry.functions.length > 0) { - // Sort functions by line number - const sortedFuncs = [...entry.functions].sort((a, b) => a.lineNumber - b.lineNumber); - - text += 'Functions:\n'; - for (const fn of sortedFuncs) { - const fnPct = (fn.lineCoverage * 100).toFixed(1); - const marker = fn.coveredLines === 0 ? '[NOT COVERED] ' : ''; - text += ` ${marker}L${fn.lineNumber} ${fn.name}: ${fnPct}% (${fn.coveredLines}/${fn.executableLines} lines, called ${fn.executionCount}x)\n`; + const notCovered = entry.functions + .filter((fn) => fn.coveredLines === 0) + .sort((a, b) => b.executableLines - a.executableLines || a.lineNumber - b.lineNumber); + + const partial = entry.functions + .filter((fn) => fn.coveredLines > 0 && fn.coveredLines < fn.executableLines) + .sort((a, b) => a.lineCoverage - b.lineCoverage || a.lineNumber - b.lineNumber); + + const full = entry.functions.filter( + (fn) => fn.executableLines > 0 && fn.coveredLines === fn.executableLines, + ); + + if (notCovered.length > 0) { + const totalMissedLines = notCovered.reduce((sum, fn) => sum + fn.executableLines, 0); + const notCoveredLines = notCovered.map( + (fn) => `L${fn.lineNumber} ${fn.name} -- 0/${fn.executableLines} lines`, + ); + events.push( + section( + `Not Covered (${notCovered.length} ${notCovered.length === 1 ? 'function' : 'functions'}, ${totalMissedLines} lines)`, + notCoveredLines, + { icon: 'red-circle' }, + ), + ); } - // Summary of uncovered functions - const uncoveredFuncs = sortedFuncs.filter((fn) => fn.coveredLines === 0); - if (uncoveredFuncs.length > 0) { - text += `\nUncovered functions (${uncoveredFuncs.length}):\n`; - for (const fn of uncoveredFuncs) { - text += ` - ${fn.name} (line ${fn.lineNumber})\n`; - } + if (partial.length > 0) { + const partialLines = partial.map((fn) => { + const fnPct = (fn.lineCoverage * 100).toFixed(1); + return `L${fn.lineNumber} ${fn.name} -- ${fnPct}% (${fn.coveredLines}/${fn.executableLines} lines)`; + }); + events.push( + section( + `Partial Coverage (${partial.length} ${partial.length === 1 ? 'function' : 'functions'})`, + partialLines, + { icon: 'yellow-circle' }, + ), + ); } - } - text += '\n'; + if (full.length > 0) { + events.push( + section( + `Full Coverage (${full.length} ${full.length === 1 ? 'function' : 'functions'}) -- all at 100%`, + [], + { icon: 'green-circle' }, + ), + ); + } + } } - // Optionally get line-by-line coverage from the archive if (showLines) { const filePath = fileEntries[0].filePath !== 'unknown' ? fileEntries[0].filePath : file; const archiveResult = await context.executor( @@ -198,28 +216,23 @@ export async function get_file_coverageLogic( if (archiveResult.success && archiveResult.output) { const uncoveredRanges = parseUncoveredLines(archiveResult.output); if (uncoveredRanges.length > 0) { - text += `Uncovered line ranges (${filePath}):\n`; - for (const range of uncoveredRanges) { - if (range.start === range.end) { - text += ` L${range.start}\n`; - } else { - text += ` L${range.start}-${range.end}\n`; - } - } + const rangeLines = uncoveredRanges.map((range) => + range.start === range.end ? `L${range.start}` : `L${range.start}-${range.end}`, + ); + events.push(section(`Uncovered line ranges (${filePath})`, rangeLines)); } else { - text += 'All executable lines are covered.\n'; + events.push(statusLine('success', 'All executable lines are covered.')); } } else { - text += `Note: Could not retrieve line-level coverage from archive.\n`; + events.push(statusLine('warning', 'Could not retrieve line-level coverage from archive.')); } } - return { - content: [{ type: 'text', text: text.trimEnd() }], + return toolResponse(events, { nextStepParams: { get_coverage_report: { xcresultPath }, }, - }; + }); } interface LineRange { diff --git a/src/mcp/tools/debugging/__tests__/debugging-tools.test.ts b/src/mcp/tools/debugging/__tests__/debugging-tools.test.ts index 4559e787..882d1275 100644 --- a/src/mcp/tools/debugging/__tests__/debugging-tools.test.ts +++ b/src/mcp/tools/debugging/__tests__/debugging-tools.test.ts @@ -47,6 +47,10 @@ import { debug_variablesLogic, } from '../debug_variables.ts'; +function joinText(result: { content: Array<{ text: string }> }): string { + return result.content.map((c) => c.text).join('\n'); +} + function createMockBackend(overrides: Partial = {}): DebuggerBackend { return { kind: 'dap', @@ -140,12 +144,12 @@ describe('debug_attach_sim', () => { ctx, ); - expect(result.isError).toBe(false); - const text = result.content[0].text; + expect(result.isError).toBeFalsy(); + const text = joinText(result); expect(text).toContain('Attached'); expect(text).toContain('1234'); expect(text).toContain('test-sim-uuid'); - expect(text).toContain('Debug session ID:'); + expect(text).toContain('Debug session ID'); }); it('should attach without continuing when continueOnAttach is false', async () => { @@ -161,8 +165,8 @@ describe('debug_attach_sim', () => { ctx, ); - expect(result.isError).toBe(false); - const text = result.content[0].text; + expect(result.isError).toBeFalsy(); + const text = joinText(result); expect(text).toContain('Execution is paused'); }); @@ -184,7 +188,7 @@ describe('debug_attach_sim', () => { ); expect(result.isError).toBe(true); - const text = result.content[0].text; + const text = joinText(result); expect(text).toContain('Failed to attach debugger'); expect(text).toContain('LLDB attach failed'); }); @@ -207,7 +211,7 @@ describe('debug_attach_sim', () => { ); expect(result.isError).toBe(true); - const text = result.content[0].text; + const text = joinText(result); expect(text).toContain('Failed to resume debugger after attach'); }); @@ -253,7 +257,7 @@ describe('debug_attach_sim', () => { ); expect(result.isError).toBe(true); - const text = result.content[0].text; + const text = joinText(result); expect(text).toContain('Failed to resolve simulator PID'); }); @@ -322,7 +326,7 @@ describe('debug_breakpoint_add', () => { const result = await debug_breakpoint_addLogic({ file: 'main.swift', line: 10 }, ctx); expect(result.isError).toBe(true); - const text = result.content[0].text; + const text = joinText(result); expect(text).toContain('No active debug session'); }); }); @@ -336,8 +340,8 @@ describe('debug_breakpoint_add', () => { ctx, ); - expect(result.isError).toBe(false); - const text = result.content[0].text; + expect(result.isError).toBeFalsy(); + const text = joinText(result); expect(text).toContain('Breakpoint'); expect(text).toContain('set'); }); @@ -350,8 +354,8 @@ describe('debug_breakpoint_add', () => { ctx, ); - expect(result.isError).toBe(false); - const text = result.content[0].text; + expect(result.isError).toBeFalsy(); + const text = joinText(result); expect(text).toContain('Breakpoint'); }); @@ -368,8 +372,8 @@ describe('debug_breakpoint_add', () => { ctx, ); - expect(result.isError).toBe(false); - const text = result.content[0].text; + expect(result.isError).toBeFalsy(); + const text = joinText(result); expect(text).toContain('Breakpoint'); }); @@ -386,7 +390,7 @@ describe('debug_breakpoint_add', () => { ); expect(result.isError).toBe(true); - const text = result.content[0].text; + const text = joinText(result); expect(text).toContain('Failed to add breakpoint'); expect(text).toContain('Invalid file path'); }); @@ -396,8 +400,8 @@ describe('debug_breakpoint_add', () => { const result = await debug_breakpoint_addLogic({ file: 'main.swift', line: 10 }, ctx); - expect(result.isError).toBe(false); - const text = result.content[0].text; + expect(result.isError).toBeFalsy(); + const text = joinText(result); expect(text).toContain('Breakpoint'); }); }); @@ -430,7 +434,7 @@ describe('debug_breakpoint_remove', () => { const result = await debug_breakpoint_removeLogic({ breakpointId: 1 }, ctx); expect(result.isError).toBe(true); - const text = result.content[0].text; + const text = joinText(result); expect(text).toContain('No active debug session'); }); }); @@ -444,8 +448,8 @@ describe('debug_breakpoint_remove', () => { ctx, ); - expect(result.isError).toBe(false); - const text = result.content[0].text; + expect(result.isError).toBeFalsy(); + const text = joinText(result); expect(text).toContain('Breakpoint 1 removed'); }); @@ -462,7 +466,7 @@ describe('debug_breakpoint_remove', () => { ); expect(result.isError).toBe(true); - const text = result.content[0].text; + const text = joinText(result); expect(text).toContain('Failed to remove breakpoint'); expect(text).toContain('Breakpoint not found'); }); @@ -472,8 +476,8 @@ describe('debug_breakpoint_remove', () => { const result = await debug_breakpoint_removeLogic({ breakpointId: 1 }, ctx); - expect(result.isError).toBe(false); - const text = result.content[0].text; + expect(result.isError).toBeFalsy(); + const text = joinText(result); expect(text).toContain('Breakpoint 1 removed'); }); }); @@ -505,7 +509,7 @@ describe('debug_continue', () => { const result = await debug_continueLogic({}, ctx); expect(result.isError).toBe(true); - const text = result.content[0].text; + const text = joinText(result); expect(text).toContain('No active debug session'); }); }); @@ -516,8 +520,8 @@ describe('debug_continue', () => { const result = await debug_continueLogic({ debugSessionId: session.id }, ctx); - expect(result.isError).toBe(false); - const text = result.content[0].text; + expect(result.isError).toBeFalsy(); + const text = joinText(result); expect(text).toContain('Resumed debugger session'); expect(text).toContain(session.id); }); @@ -527,8 +531,8 @@ describe('debug_continue', () => { const result = await debug_continueLogic({}, ctx); - expect(result.isError).toBe(false); - const text = result.content[0].text; + expect(result.isError).toBeFalsy(); + const text = joinText(result); expect(text).toContain('Resumed debugger session'); }); @@ -542,7 +546,7 @@ describe('debug_continue', () => { const result = await debug_continueLogic({ debugSessionId: session.id }, ctx); expect(result.isError).toBe(true); - const text = result.content[0].text; + const text = joinText(result); expect(text).toContain('Failed to resume debugger'); expect(text).toContain('Process terminated'); }); @@ -575,7 +579,7 @@ describe('debug_detach', () => { const result = await debug_detachLogic({}, ctx); expect(result.isError).toBe(true); - const text = result.content[0].text; + const text = joinText(result); expect(text).toContain('No active debug session'); }); }); @@ -586,8 +590,8 @@ describe('debug_detach', () => { const result = await debug_detachLogic({ debugSessionId: session.id }, ctx); - expect(result.isError).toBe(false); - const text = result.content[0].text; + expect(result.isError).toBeFalsy(); + const text = joinText(result); expect(text).toContain('Detached debugger session'); expect(text).toContain(session.id); }); @@ -597,8 +601,8 @@ describe('debug_detach', () => { const result = await debug_detachLogic({}, ctx); - expect(result.isError).toBe(false); - const text = result.content[0].text; + expect(result.isError).toBeFalsy(); + const text = joinText(result); expect(text).toContain('Detached debugger session'); }); @@ -612,7 +616,7 @@ describe('debug_detach', () => { const result = await debug_detachLogic({ debugSessionId: session.id }, ctx); expect(result.isError).toBe(true); - const text = result.content[0].text; + const text = joinText(result); expect(text).toContain('Failed to detach debugger'); expect(text).toContain('Connection lost'); }); @@ -647,7 +651,7 @@ describe('debug_lldb_command', () => { const result = await debug_lldb_commandLogic({ command: 'bt' }, ctx); expect(result.isError).toBe(true); - const text = result.content[0].text; + const text = joinText(result); expect(text).toContain('No active debug session'); }); }); @@ -663,9 +667,9 @@ describe('debug_lldb_command', () => { ctx, ); - expect(result.isError).toBe(false); - const text = result.content[0].text; - expect(text).toBe('frame #0: main'); + expect(result.isError).toBeFalsy(); + const text = joinText(result); + expect(text).toContain('frame #0: main'); }); it('should pass timeoutMs through to runCommand', async () => { @@ -698,7 +702,7 @@ describe('debug_lldb_command', () => { ); expect(result.isError).toBe(true); - const text = result.content[0].text; + const text = joinText(result); expect(text).toContain('Failed to run LLDB command'); expect(text).toContain('Command timed out'); }); @@ -710,9 +714,9 @@ describe('debug_lldb_command', () => { const result = await debug_lldb_commandLogic({ command: 'po self' }, ctx); - expect(result.isError).toBe(false); - const text = result.content[0].text; - expect(text).toBe('result'); + expect(result.isError).toBeFalsy(); + const text = joinText(result); + expect(text).toContain('result'); }); }); }); @@ -745,7 +749,7 @@ describe('debug_stack', () => { const result = await debug_stackLogic({}, ctx); expect(result.isError).toBe(true); - const text = result.content[0].text; + const text = joinText(result); expect(text).toContain('No active debug session'); }); }); @@ -759,9 +763,9 @@ describe('debug_stack', () => { const result = await debug_stackLogic({ debugSessionId: session.id }, ctx); - expect(result.isError).toBe(false); - const text = result.content[0].text; - expect(text).toBe(stackOutput.trim()); + expect(result.isError).toBeFalsy(); + const text = joinText(result); + expect(text).toContain(stackOutput.trim()); }); it('should pass threadIndex and maxFrames through', async () => { @@ -789,7 +793,7 @@ describe('debug_stack', () => { const result = await debug_stackLogic({ debugSessionId: session.id }, ctx); expect(result.isError).toBe(true); - const text = result.content[0].text; + const text = joinText(result); expect(text).toContain('Failed to get stack'); expect(text).toContain('Process not stopped'); }); @@ -801,8 +805,9 @@ describe('debug_stack', () => { const result = await debug_stackLogic({}, ctx); - expect(result.isError).toBe(false); - expect(result.content[0].text).toBe('frame #0: main'); + expect(result.isError).toBeFalsy(); + const text = joinText(result); + expect(text).toContain('frame #0: main'); }); }); }); @@ -834,7 +839,7 @@ describe('debug_variables', () => { const result = await debug_variablesLogic({}, ctx); expect(result.isError).toBe(true); - const text = result.content[0].text; + const text = joinText(result); expect(text).toContain('No active debug session'); }); }); @@ -848,9 +853,9 @@ describe('debug_variables', () => { const result = await debug_variablesLogic({ debugSessionId: session.id }, ctx); - expect(result.isError).toBe(false); - const text = result.content[0].text; - expect(text).toBe(variablesOutput.trim()); + expect(result.isError).toBeFalsy(); + const text = joinText(result); + expect(text).toContain(variablesOutput.trim()); }); it('should pass frameIndex through', async () => { @@ -880,7 +885,7 @@ describe('debug_variables', () => { ); expect(result.isError).toBe(true); - const text = result.content[0].text; + const text = joinText(result); expect(text).toContain('Failed to get variables'); expect(text).toContain('Frame index out of range'); }); @@ -892,8 +897,9 @@ describe('debug_variables', () => { const result = await debug_variablesLogic({}, ctx); - expect(result.isError).toBe(false); - expect(result.content[0].text).toBe('y = 99'); + expect(result.isError).toBeFalsy(); + const text = joinText(result); + expect(text).toContain('y = 99'); }); }); }); diff --git a/src/mcp/tools/debugging/debug_attach_sim.ts b/src/mcp/tools/debugging/debug_attach_sim.ts index 66292f28..019cf218 100644 --- a/src/mcp/tools/debugging/debug_attach_sim.ts +++ b/src/mcp/tools/debugging/debug_attach_sim.ts @@ -1,7 +1,8 @@ import * as z from 'zod'; import type { ToolResponse } from '../../../types/common.ts'; import { log } from '../../../utils/logging/index.ts'; -import { createErrorResponse } from '../../../utils/responses/index.ts'; +import { toolResponse } from '../../../utils/tool-response.ts'; +import { header, statusLine, detailTree, section } from '../../../utils/tool-event-builders.ts'; import { nullifyEmptyStrings } from '../../../utils/schema-helpers.ts'; import { determineSimulatorUuid } from '../../../utils/simulator-utils.ts'; import { @@ -62,6 +63,7 @@ export async function debug_attach_simLogic( ctx: DebuggerToolContext, ): Promise { const { executor, debugger: debuggerManager } = ctx; + const headerEvent = header('Attach Debugger'); const simResult = await determineSimulatorUuid( { simulatorId: params.simulatorId, simulatorName: params.simulatorName }, @@ -69,12 +71,15 @@ export async function debug_attach_simLogic( ); if (simResult.error) { - return simResult.error; + return toolResponse([headerEvent, statusLine('error', simResult.error)]); } const simulatorId = simResult.uuid; if (!simulatorId) { - return createErrorResponse('Simulator resolution failed', 'Unable to determine simulator UUID'); + return toolResponse([ + headerEvent, + statusLine('error', 'Simulator resolution failed: Unable to determine simulator UUID'), + ]); } let pid = params.pid; @@ -87,12 +92,18 @@ export async function debug_attach_simLogic( }); } catch (error) { const message = error instanceof Error ? error.message : String(error); - return createErrorResponse('Failed to resolve simulator PID', message); + return toolResponse([ + headerEvent, + statusLine('error', `Failed to resolve simulator PID: ${message}`), + ]); } } if (!pid) { - return createErrorResponse('Missing PID', 'Unable to resolve process ID to attach'); + return toolResponse([ + headerEvent, + statusLine('error', 'Missing PID: Unable to resolve process ID to attach'), + ]); } try { @@ -120,11 +131,14 @@ export async function debug_attach_simLogic( detachError instanceof Error ? detachError.message : String(detachError); log('warn', `Failed to detach debugger session after resume failure: ${detachMessage}`); } - return createErrorResponse('Failed to resume debugger after attach', message); + return toolResponse([ + headerEvent, + statusLine('error', `Failed to resume debugger after attach: ${message}`), + ]); } } - const warningText = simResult.warning ? `โš ๏ธ ${simResult.warning}\n\n` : ''; + const backendLabel = session.backend === 'dap' ? 'DAP debugger' : 'LLDB'; const currentText = isCurrent ? 'This session is now the current debug session.' : 'This session is not set as the current session.'; @@ -132,30 +146,34 @@ export async function debug_attach_simLogic( ? 'Execution resumed after attach.' : 'Execution is paused. Use debug_continue to resume before UI automation.'; - const backendLabel = session.backend === 'dap' ? 'DAP debugger' : 'LLDB'; - - return { - content: [ - { - type: 'text', - text: - `${warningText}โœ… Attached ${backendLabel} to simulator process ${pid} (${simulatorId}).\n\n` + - `Debug session ID: ${session.id}\n` + - `${currentText}\n` + - `${resumeText}`, - }, - ], + const events = [ + headerEvent, + ...(simResult.warning ? [section('Warning', [simResult.warning])] : []), + statusLine( + 'success', + `Attached ${backendLabel} to simulator process ${pid} (${simulatorId})`, + ), + detailTree([ + { label: 'Debug session ID', value: session.id }, + { label: 'Status', value: currentText }, + { label: 'Execution', value: resumeText }, + ]), + ]; + + return toolResponse(events, { nextStepParams: { debug_breakpoint_add: { debugSessionId: session.id, file: '...', line: 123 }, debug_continue: { debugSessionId: session.id }, debug_stack: { debugSessionId: session.id }, }, - isError: false, - }; + }); } catch (error) { const message = error instanceof Error ? error.message : String(error); log('error', `Failed to attach LLDB: ${message}`); - return createErrorResponse('Failed to attach debugger', message); + return toolResponse([ + headerEvent, + statusLine('error', `Failed to attach debugger: ${message}`), + ]); } } diff --git a/src/mcp/tools/debugging/debug_breakpoint_add.ts b/src/mcp/tools/debugging/debug_breakpoint_add.ts index fe6d17c5..ed77f8a5 100644 --- a/src/mcp/tools/debugging/debug_breakpoint_add.ts +++ b/src/mcp/tools/debugging/debug_breakpoint_add.ts @@ -1,6 +1,7 @@ import * as z from 'zod'; import type { ToolResponse } from '../../../types/common.ts'; -import { createErrorResponse, createTextResponse } from '../../../utils/responses/index.ts'; +import { toolResponse } from '../../../utils/tool-response.ts'; +import { header, statusLine, section } from '../../../utils/tool-event-builders.ts'; import { nullifyEmptyStrings } from '../../../utils/schema-helpers.ts'; import { createTypedToolWithContext } from '../../../utils/typed-tool-factory.ts'; import { @@ -37,6 +38,8 @@ export async function debug_breakpoint_addLogic( params: DebugBreakpointAddParams, ctx: DebuggerToolContext, ): Promise { + const headerEvent = header('Add Breakpoint'); + try { const spec: BreakpointSpec = params.function ? { kind: 'function', name: params.function } @@ -46,10 +49,17 @@ export async function debug_breakpoint_addLogic( condition: params.condition, }); - return createTextResponse(`โœ… Breakpoint ${result.id} set.\n\n${result.rawOutput.trim()}`); + const rawOutput = result.rawOutput.trim(); + const events = [ + headerEvent, + statusLine('success', `Breakpoint ${result.id} set`), + ...(rawOutput ? [section('Output', [rawOutput])] : []), + ]; + + return toolResponse(events); } catch (error) { const message = error instanceof Error ? error.message : String(error); - return createErrorResponse('Failed to add breakpoint', message); + return toolResponse([headerEvent, statusLine('error', `Failed to add breakpoint: ${message}`)]); } } diff --git a/src/mcp/tools/debugging/debug_breakpoint_remove.ts b/src/mcp/tools/debugging/debug_breakpoint_remove.ts index 53e7b95d..c2e367d5 100644 --- a/src/mcp/tools/debugging/debug_breakpoint_remove.ts +++ b/src/mcp/tools/debugging/debug_breakpoint_remove.ts @@ -1,6 +1,7 @@ import * as z from 'zod'; import type { ToolResponse } from '../../../types/common.ts'; -import { createErrorResponse, createTextResponse } from '../../../utils/responses/index.ts'; +import { toolResponse } from '../../../utils/tool-response.ts'; +import { header, statusLine, section } from '../../../utils/tool-event-builders.ts'; import { createTypedToolWithContext } from '../../../utils/typed-tool-factory.ts'; import { getDefaultDebuggerToolContext, @@ -18,12 +19,24 @@ export async function debug_breakpoint_removeLogic( params: DebugBreakpointRemoveParams, ctx: DebuggerToolContext, ): Promise { + const headerEvent = header('Remove Breakpoint'); + try { const output = await ctx.debugger.removeBreakpoint(params.debugSessionId, params.breakpointId); - return createTextResponse(`โœ… Breakpoint ${params.breakpointId} removed.\n\n${output.trim()}`); + const rawOutput = output.trim(); + const events = [ + headerEvent, + statusLine('success', `Breakpoint ${params.breakpointId} removed`), + ...(rawOutput ? [section('Output', [rawOutput])] : []), + ]; + + return toolResponse(events); } catch (error) { const message = error instanceof Error ? error.message : String(error); - return createErrorResponse('Failed to remove breakpoint', message); + return toolResponse([ + headerEvent, + statusLine('error', `Failed to remove breakpoint: ${message}`), + ]); } } diff --git a/src/mcp/tools/debugging/debug_continue.ts b/src/mcp/tools/debugging/debug_continue.ts index 4da697cd..dc2d5485 100644 --- a/src/mcp/tools/debugging/debug_continue.ts +++ b/src/mcp/tools/debugging/debug_continue.ts @@ -1,6 +1,7 @@ import * as z from 'zod'; import type { ToolResponse } from '../../../types/common.ts'; -import { createErrorResponse, createTextResponse } from '../../../utils/responses/index.ts'; +import { toolResponse } from '../../../utils/tool-response.ts'; +import { header, statusLine } from '../../../utils/tool-event-builders.ts'; import { createTypedToolWithContext } from '../../../utils/typed-tool-factory.ts'; import { getDefaultDebuggerToolContext, @@ -17,14 +18,22 @@ export async function debug_continueLogic( params: DebugContinueParams, ctx: DebuggerToolContext, ): Promise { + const headerEvent = header('Continue'); + try { const targetId = params.debugSessionId ?? ctx.debugger.getCurrentSessionId(); await ctx.debugger.resumeSession(targetId ?? undefined); - return createTextResponse(`โœ… Resumed debugger session${targetId ? ` ${targetId}` : ''}.`); + return toolResponse([ + headerEvent, + statusLine('success', `Resumed debugger session${targetId ? ` ${targetId}` : ''}`), + ]); } catch (error) { const message = error instanceof Error ? error.message : String(error); - return createErrorResponse('Failed to resume debugger', message); + return toolResponse([ + headerEvent, + statusLine('error', `Failed to resume debugger: ${message}`), + ]); } } diff --git a/src/mcp/tools/debugging/debug_detach.ts b/src/mcp/tools/debugging/debug_detach.ts index a1bb25ec..6dca12ef 100644 --- a/src/mcp/tools/debugging/debug_detach.ts +++ b/src/mcp/tools/debugging/debug_detach.ts @@ -1,6 +1,7 @@ import * as z from 'zod'; import type { ToolResponse } from '../../../types/common.ts'; -import { createErrorResponse, createTextResponse } from '../../../utils/responses/index.ts'; +import { toolResponse } from '../../../utils/tool-response.ts'; +import { header, statusLine } from '../../../utils/tool-event-builders.ts'; import { createTypedToolWithContext } from '../../../utils/typed-tool-factory.ts'; import { getDefaultDebuggerToolContext, @@ -17,14 +18,22 @@ export async function debug_detachLogic( params: DebugDetachParams, ctx: DebuggerToolContext, ): Promise { + const headerEvent = header('Detach'); + try { const targetId = params.debugSessionId ?? ctx.debugger.getCurrentSessionId(); await ctx.debugger.detachSession(targetId ?? undefined); - return createTextResponse(`โœ… Detached debugger session${targetId ? ` ${targetId}` : ''}.`); + return toolResponse([ + headerEvent, + statusLine('success', `Detached debugger session${targetId ? ` ${targetId}` : ''}`), + ]); } catch (error) { const message = error instanceof Error ? error.message : String(error); - return createErrorResponse('Failed to detach debugger', message); + return toolResponse([ + headerEvent, + statusLine('error', `Failed to detach debugger: ${message}`), + ]); } } diff --git a/src/mcp/tools/debugging/debug_lldb_command.ts b/src/mcp/tools/debugging/debug_lldb_command.ts index 7e34475e..aa7828ef 100644 --- a/src/mcp/tools/debugging/debug_lldb_command.ts +++ b/src/mcp/tools/debugging/debug_lldb_command.ts @@ -1,6 +1,7 @@ import * as z from 'zod'; import type { ToolResponse } from '../../../types/common.ts'; -import { createErrorResponse, createTextResponse } from '../../../utils/responses/index.ts'; +import { toolResponse } from '../../../utils/tool-response.ts'; +import { header, statusLine, section } from '../../../utils/tool-event-builders.ts'; import { nullifyEmptyStrings } from '../../../utils/schema-helpers.ts'; import { createTypedToolWithContext } from '../../../utils/typed-tool-factory.ts'; import { @@ -22,14 +23,25 @@ export async function debug_lldb_commandLogic( params: DebugLldbCommandParams, ctx: DebuggerToolContext, ): Promise { + const headerEvent = header('LLDB Command', [{ label: 'Command', value: params.command }]); + try { const output = await ctx.debugger.runCommand(params.debugSessionId, params.command, { timeoutMs: params.timeoutMs, }); - return createTextResponse(output.trim()); + const trimmed = output.trim(); + + return toolResponse([ + headerEvent, + statusLine('success', 'Command executed'), + ...(trimmed ? [section('Output', [trimmed])] : []), + ]); } catch (error) { const message = error instanceof Error ? error.message : String(error); - return createErrorResponse('Failed to run LLDB command', message); + return toolResponse([ + headerEvent, + statusLine('error', `Failed to run LLDB command: ${message}`), + ]); } } diff --git a/src/mcp/tools/debugging/debug_stack.ts b/src/mcp/tools/debugging/debug_stack.ts index 46f149c6..ab96118a 100644 --- a/src/mcp/tools/debugging/debug_stack.ts +++ b/src/mcp/tools/debugging/debug_stack.ts @@ -1,6 +1,7 @@ import * as z from 'zod'; import type { ToolResponse } from '../../../types/common.ts'; -import { createErrorResponse, createTextResponse } from '../../../utils/responses/index.ts'; +import { toolResponse } from '../../../utils/tool-response.ts'; +import { header, statusLine, section } from '../../../utils/tool-event-builders.ts'; import { createTypedToolWithContext } from '../../../utils/typed-tool-factory.ts'; import { getDefaultDebuggerToolContext, @@ -19,15 +20,23 @@ export async function debug_stackLogic( params: DebugStackParams, ctx: DebuggerToolContext, ): Promise { + const headerEvent = header('Stack Trace'); + try { const output = await ctx.debugger.getStack(params.debugSessionId, { threadIndex: params.threadIndex, maxFrames: params.maxFrames, }); - return createTextResponse(output.trim()); + const trimmed = output.trim(); + + return toolResponse([ + headerEvent, + statusLine('success', 'Stack trace retrieved'), + ...(trimmed ? [section('Frames', [trimmed])] : []), + ]); } catch (error) { const message = error instanceof Error ? error.message : String(error); - return createErrorResponse('Failed to get stack', message); + return toolResponse([headerEvent, statusLine('error', `Failed to get stack: ${message}`)]); } } diff --git a/src/mcp/tools/debugging/debug_variables.ts b/src/mcp/tools/debugging/debug_variables.ts index 7946b011..caae533c 100644 --- a/src/mcp/tools/debugging/debug_variables.ts +++ b/src/mcp/tools/debugging/debug_variables.ts @@ -1,6 +1,7 @@ import * as z from 'zod'; import type { ToolResponse } from '../../../types/common.ts'; -import { createErrorResponse, createTextResponse } from '../../../utils/responses/index.ts'; +import { toolResponse } from '../../../utils/tool-response.ts'; +import { header, statusLine, section } from '../../../utils/tool-event-builders.ts'; import { createTypedToolWithContext } from '../../../utils/typed-tool-factory.ts'; import { getDefaultDebuggerToolContext, @@ -18,14 +19,22 @@ export async function debug_variablesLogic( params: DebugVariablesParams, ctx: DebuggerToolContext, ): Promise { + const headerEvent = header('Variables'); + try { const output = await ctx.debugger.getVariables(params.debugSessionId, { frameIndex: params.frameIndex, }); - return createTextResponse(output.trim()); + const trimmed = output.trim(); + + return toolResponse([ + headerEvent, + statusLine('success', 'Variables retrieved'), + ...(trimmed ? [section('Values', [trimmed])] : []), + ]); } catch (error) { const message = error instanceof Error ? error.message : String(error); - return createErrorResponse('Failed to get variables', message); + return toolResponse([headerEvent, statusLine('error', `Failed to get variables: ${message}`)]); } } diff --git a/src/mcp/tools/device/__tests__/build_run_device.test.ts b/src/mcp/tools/device/__tests__/build_run_device.test.ts index 5ff9527e..a75ebe0b 100644 --- a/src/mcp/tools/device/__tests__/build_run_device.test.ts +++ b/src/mcp/tools/device/__tests__/build_run_device.test.ts @@ -223,17 +223,17 @@ describe('build_run_device tool', () => { expect.objectContaining({ tailEvents: [ expect.objectContaining({ - type: 'notice', - code: 'build-run-result', - data: expect.objectContaining({ - scheme: 'MyApp', - platform: 'iOS', - target: 'iOS Device', - appPath: '/tmp/build/MyApp.app', - bundleId: 'io.sentry.MyApp', - launchState: 'requested', - processId: 1234, - }), + type: 'status-line', + level: 'success', + message: 'Build & Run complete', + }), + expect.objectContaining({ + type: 'detail-tree', + items: expect.arrayContaining([ + expect.objectContaining({ label: 'App Path', value: '/tmp/build/MyApp.app' }), + expect.objectContaining({ label: 'Bundle ID', value: 'io.sentry.MyApp' }), + expect.objectContaining({ label: 'Process ID', value: '1234' }), + ]), }), ], }), @@ -276,9 +276,15 @@ describe('build_run_device tool', () => { expect(result.nextStepParams?.stop_app_device).toBeUndefined(); const tailEvents = ( - result._meta?.pendingXcodebuild as { tailEvents: Array<{ data: Record }> } + result._meta?.pendingXcodebuild as { + tailEvents: Array<{ type: string; items?: Array<{ label: string; value: string }> }>; + } ).tailEvents; - expect(tailEvents[0].data.processId).toBeUndefined(); + expect(tailEvents).toHaveLength(2); + expect(tailEvents[0].type).toBe('status-line'); + const detailTree = tailEvents[1]; + expect(detailTree.type).toBe('detail-tree'); + expect(detailTree.items?.some((item) => item.label === 'Process ID')).toBe(false); }); it('uses generic destination for build-settings lookup', async () => { @@ -405,17 +411,10 @@ describe('build_run_device tool', () => { // Front matter expect(textContent).toContain('Build & Run'); expect(textContent).toContain('Scheme: MyApp'); - expect(textContent).toContain('Device: DEVICE-UDID'); // Summary expect(textContent).toContain('Build succeeded.'); - // Footer with execution-derived values - expect(textContent).toContain('Build & Run complete'); - expect(textContent).toContain('App Path: /tmp/build/MyApp.app'); - expect(textContent).toContain('Bundle ID: io.sentry.MyApp'); - expect(textContent).toContain('Process ID: 42'); - // No next steps in finalized output (those come from tool invoker) expect(textContent).not.toContain('Next steps:'); }); diff --git a/src/mcp/tools/device/__tests__/get_device_app_path.test.ts b/src/mcp/tools/device/__tests__/get_device_app_path.test.ts index 1a676e74..20c5f7f2 100644 --- a/src/mcp/tools/device/__tests__/get_device_app_path.test.ts +++ b/src/mcp/tools/device/__tests__/get_device_app_path.test.ts @@ -259,15 +259,17 @@ describe('get_device_app_path plugin', () => { mockExecutor, ); - expect(result.isError).toBeUndefined(); - expect(result.content[0].text).toContain('\u{1F50D} Get App Path'); - expect(result.content[0].text).toContain('Scheme: MyScheme'); - expect(result.content[0].text).toContain('Project: /path/to/project.xcodeproj'); - expect(result.content[0].text).toContain('Configuration: Debug'); - expect(result.content[0].text).toContain('Platform: iOS'); - expect(result.content[0].text).toContain( - '\u{2514} App Path: /path/to/build/Debug-iphoneos/MyApp.app', - ); + expect(result.isError).toBeFalsy(); + const text = result.content + .filter((i) => i.type === 'text') + .map((i) => i.text) + .join('\n'); + expect(text).toContain('Get App Path'); + expect(text).toContain('Scheme: MyScheme'); + expect(text).toContain('Project: /path/to/project.xcodeproj'); + expect(text).toContain('Configuration: Debug'); + expect(text).toContain('Platform: iOS'); + expect(text).toContain('App Path: /path/to/build/Debug-iphoneos/MyApp.app'); expect(result.nextStepParams).toEqual({ get_app_bundle_id: { appPath: '/path/to/build/Debug-iphoneos/MyApp.app' }, install_app_device: { @@ -293,12 +295,14 @@ describe('get_device_app_path plugin', () => { ); expect(result.isError).toBe(true); - expect(result.content[0].text).toContain('\u{1F50D} Get App Path'); - expect(result.content[0].text).toContain('Scheme: MyScheme'); - expect(result.content[0].text).toContain('Project: /path/to/nonexistent.xcodeproj'); - expect(result.content[0].text).toContain('Errors (1):'); - expect(result.content[0].text).toContain('\u{2717} The project does not exist.'); - expect(result.content[0].text).toContain('\u{274C} Query failed.'); + const text = result.content + .filter((i) => i.type === 'text') + .map((i) => i.text) + .join('\n'); + expect(text).toContain('Get App Path'); + expect(text).toContain('Scheme: MyScheme'); + expect(text).toContain('Project: /path/to/nonexistent.xcodeproj'); + expect(text).toContain('The project does not exist.'); expect(result.nextStepParams).toBeUndefined(); }); @@ -317,12 +321,12 @@ describe('get_device_app_path plugin', () => { ); expect(result.isError).toBe(true); - expect(result.content[0].text).toContain('\u{1F50D} Get App Path'); - expect(result.content[0].text).toContain('Errors (1):'); - expect(result.content[0].text).toContain( - '\u{2717} Could not extract app path from build settings.', - ); - expect(result.content[0].text).toContain('\u{274C} Query failed.'); + const text = result.content + .filter((i) => i.type === 'text') + .map((i) => i.text) + .join('\n'); + expect(text).toContain('Get App Path'); + expect(text).toContain('Could not extract app path from build settings'); expect(result.nextStepParams).toBeUndefined(); }); @@ -401,10 +405,12 @@ describe('get_device_app_path plugin', () => { ); expect(result.isError).toBe(true); - expect(result.content[0].text).toContain('\u{1F50D} Get App Path'); - expect(result.content[0].text).toContain('Errors (1):'); - expect(result.content[0].text).toContain('\u{2717} Network error'); - expect(result.content[0].text).toContain('\u{274C} Query failed.'); + const text = result.content + .filter((i) => i.type === 'text') + .map((i) => i.text) + .join('\n'); + expect(text).toContain('Get App Path'); + expect(text).toContain('Network error'); expect(result.nextStepParams).toBeUndefined(); }); @@ -422,10 +428,12 @@ describe('get_device_app_path plugin', () => { ); expect(result.isError).toBe(true); - expect(result.content[0].text).toContain('\u{1F50D} Get App Path'); - expect(result.content[0].text).toContain('Errors (1):'); - expect(result.content[0].text).toContain('\u{2717} String error'); - expect(result.content[0].text).toContain('\u{274C} Query failed.'); + const text = result.content + .filter((i) => i.type === 'text') + .map((i) => i.text) + .join('\n'); + expect(text).toContain('Get App Path'); + expect(text).toContain('String error'); expect(result.nextStepParams).toBeUndefined(); }); }); diff --git a/src/mcp/tools/device/__tests__/install_app_device.test.ts b/src/mcp/tools/device/__tests__/install_app_device.test.ts index 0806bb2e..8ad1c342 100644 --- a/src/mcp/tools/device/__tests__/install_app_device.test.ts +++ b/src/mcp/tools/device/__tests__/install_app_device.test.ts @@ -161,6 +161,13 @@ describe('install_app_device plugin', () => { }); describe('Success Path Tests', () => { + function textOf(result: { content: Array<{ type: string; text: string }> }): string { + return result.content + .filter((i) => i.type === 'text') + .map((i) => i.text) + .join('\n'); + } + it('should return successful installation response', async () => { const mockExecutor = createMockExecutor({ success: true, @@ -175,14 +182,12 @@ describe('install_app_device plugin', () => { mockExecutor, ); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: 'โœ… App installed successfully on device test-device-123\n\nApp installation successful', - }, - ], - }); + expect(result.isError).toBeFalsy(); + const text = textOf(result); + expect(text).toContain('Install App'); + expect(text).toContain('test-device-123'); + expect(text).toContain('/path/to/test.app'); + expect(text).toContain('App installed successfully'); }); it('should return successful installation with detailed output', async () => { @@ -200,14 +205,10 @@ describe('install_app_device plugin', () => { mockExecutor, ); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: 'โœ… App installed successfully on device device-456\n\nInstalling app...\nApp bundle: /path/to/test.app\nInstallation completed successfully', - }, - ], - }); + expect(result.isError).toBeFalsy(); + const text = textOf(result); + expect(text).toContain('Install App'); + expect(text).toContain('App installed successfully'); }); it('should return successful installation with empty output', async () => { @@ -224,18 +225,21 @@ describe('install_app_device plugin', () => { mockExecutor, ); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: 'โœ… App installed successfully on device empty-output-device\n\n', - }, - ], - }); + expect(result.isError).toBeFalsy(); + const text = textOf(result); + expect(text).toContain('Install App'); + expect(text).toContain('App installed successfully'); }); }); describe('Error Handling', () => { + function textOf(result: { content: Array<{ type: string; text: string }> }): string { + return result.content + .filter((i) => i.type === 'text') + .map((i) => i.text) + .join('\n'); + } + it('should return installation failure response', async () => { const mockExecutor = createMockExecutor({ success: false, @@ -250,15 +254,9 @@ describe('install_app_device plugin', () => { mockExecutor, ); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: 'Failed to install app: Installation failed: App not found', - }, - ], - isError: true, - }); + expect(result.isError).toBe(true); + const text = textOf(result); + expect(text).toContain('Failed to install app: Installation failed: App not found'); }); it('should return exception handling response', async () => { @@ -272,15 +270,9 @@ describe('install_app_device plugin', () => { mockExecutor, ); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: 'Failed to install app on device: Network error', - }, - ], - isError: true, - }); + expect(result.isError).toBe(true); + const text = textOf(result); + expect(text).toContain('Failed to install app on device: Network error'); }); it('should return string error handling response', async () => { @@ -294,15 +286,9 @@ describe('install_app_device plugin', () => { mockExecutor, ); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: 'Failed to install app on device: String error', - }, - ], - isError: true, - }); + expect(result.isError).toBe(true); + const text = textOf(result); + expect(text).toContain('Failed to install app on device: String error'); }); }); }); diff --git a/src/mcp/tools/device/__tests__/launch_app_device.test.ts b/src/mcp/tools/device/__tests__/launch_app_device.test.ts index 5798fe76..121ec5fc 100644 --- a/src/mcp/tools/device/__tests__/launch_app_device.test.ts +++ b/src/mcp/tools/device/__tests__/launch_app_device.test.ts @@ -198,6 +198,13 @@ describe('launch_app_device plugin (device-shared)', () => { }); describe('Success Path Tests', () => { + function textOf(result: { content: Array<{ type: string; text: string }> }): string { + return result.content + .filter((i) => i.type === 'text') + .map((i) => i.text) + .join('\n'); + } + it('should return successful launch response without process ID', async () => { const mockExecutor = createMockExecutor({ success: true, @@ -213,14 +220,12 @@ describe('launch_app_device plugin (device-shared)', () => { createMockFileSystemExecutor(), ); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: 'โœ… App launched successfully\n\nApp launched successfully', - }, - ], - }); + expect(result.isError).toBeFalsy(); + const text = textOf(result); + expect(text).toContain('Launch App'); + expect(text).toContain('test-device-123'); + expect(text).toContain('io.sentry.app'); + expect(text).toContain('App launched successfully'); }); it('should return successful launch response with detailed output', async () => { @@ -238,14 +243,10 @@ describe('launch_app_device plugin (device-shared)', () => { createMockFileSystemExecutor(), ); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: 'โœ… App launched successfully\n\nLaunch succeeded with detailed output', - }, - ], - }); + expect(result.isError).toBeFalsy(); + const text = textOf(result); + expect(text).toContain('Launch App'); + expect(text).toContain('App launched successfully'); }); it('should handle successful launch with process ID information', async () => { @@ -275,16 +276,13 @@ describe('launch_app_device plugin (device-shared)', () => { mockFileSystem, ); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: 'โœ… App launched successfully\n\nApp launched successfully\n\nProcess ID: 12345\n\nInteract with your app on the device.', - }, - ], - nextStepParams: { - stop_app_device: { deviceId: 'test-device-123', processId: 12345 }, - }, + expect(result.isError).toBeFalsy(); + const text = textOf(result); + expect(text).toContain('Launch App'); + expect(text).toContain('Process ID: 12345'); + expect(text).toContain('App launched successfully'); + expect(result.nextStepParams).toEqual({ + stop_app_device: { deviceId: 'test-device-123', processId: 12345 }, }); }); @@ -303,18 +301,21 @@ describe('launch_app_device plugin (device-shared)', () => { createMockFileSystemExecutor(), ); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: 'โœ… App launched successfully\n\nApp "io.sentry.app" launched on device "test-device-123"', - }, - ], - }); + expect(result.isError).toBeFalsy(); + const text = textOf(result); + expect(text).toContain('Launch App'); + expect(text).toContain('App launched successfully'); }); }); describe('Error Handling', () => { + function textOf(result: { content: Array<{ type: string; text: string }> }): string { + return result.content + .filter((i) => i.type === 'text') + .map((i) => i.text) + .join('\n'); + } + it('should return launch failure response', async () => { const mockExecutor = createMockExecutor({ success: false, @@ -330,15 +331,9 @@ describe('launch_app_device plugin (device-shared)', () => { createMockFileSystemExecutor(), ); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: 'Failed to launch app: Launch failed: App not found', - }, - ], - isError: true, - }); + expect(result.isError).toBe(true); + const text = textOf(result); + expect(text).toContain('Failed to launch app: Launch failed: App not found'); }); it('should return command failure response with specific error', async () => { @@ -356,15 +351,9 @@ describe('launch_app_device plugin (device-shared)', () => { createMockFileSystemExecutor(), ); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: 'Failed to launch app: Device not found: test-device-invalid', - }, - ], - isError: true, - }); + expect(result.isError).toBe(true); + const text = textOf(result); + expect(text).toContain('Failed to launch app: Device not found: test-device-invalid'); }); it('should handle executor exception with Error object', async () => { @@ -379,15 +368,9 @@ describe('launch_app_device plugin (device-shared)', () => { createMockFileSystemExecutor(), ); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: 'Failed to launch app on device: Network error', - }, - ], - isError: true, - }); + expect(result.isError).toBe(true); + const text = textOf(result); + expect(text).toContain('Failed to launch app on device: Network error'); }); it('should handle executor exception with string error', async () => { @@ -402,15 +385,9 @@ describe('launch_app_device plugin (device-shared)', () => { createMockFileSystemExecutor(), ); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: 'Failed to launch app on device: String error', - }, - ], - isError: true, - }); + expect(result.isError).toBe(true); + const text = textOf(result); + expect(text).toContain('Failed to launch app on device: String error'); }); }); }); diff --git a/src/mcp/tools/device/__tests__/list_devices.test.ts b/src/mcp/tools/device/__tests__/list_devices.test.ts index 276ee0be..6e928c12 100644 --- a/src/mcp/tools/device/__tests__/list_devices.test.ts +++ b/src/mcp/tools/device/__tests__/list_devices.test.ts @@ -173,6 +173,13 @@ describe('list_devices plugin (device-shared)', () => { }); describe('Success Path Tests', () => { + function textOf(result: { content: Array<{ type: string; text: string }> }): string { + return result.content + .filter((i) => i.type === 'text') + .map((i) => i.text) + .join('\n'); + } + it('should return successful devicectl response with parsed devices', async () => { const devicectlJson = { result: { @@ -203,13 +210,11 @@ describe('list_devices plugin (device-shared)', () => { output: '', }); - // Create mock path dependencies const mockPathDeps = { tmpdir: () => '/tmp', join: (...paths: string[]) => paths.join('/'), }; - // Create mock filesystem with specific behavior const mockFsDeps = { readFile: async (_path: string, _encoding?: string) => JSON.stringify(devicectlJson), unlink: async () => {}, @@ -217,24 +222,24 @@ describe('list_devices plugin (device-shared)', () => { const result = await list_devicesLogic({}, mockExecutor, mockPathDeps, mockFsDeps); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: "Connected Devices:\n\nโœ… Available Devices:\n\n๐Ÿ“ฑ Test iPhone\n UDID: test-device-123\n Model: iPhone15,2\n Product Type: iPhone15,2\n Platform: iOS 17.0\n Connection: USB\n\nNote: Use the device ID/UDID from above when required by other tools.\nHint: Save a default device with session-set-defaults { deviceId: 'DEVICE_UDID' }.\nBefore running build/run/test/UI automation tools, set the desired device identifier in session defaults.\n", - }, - ], - nextStepParams: { - build_device: { scheme: 'SCHEME', deviceId: 'DEVICE_UDID' }, - build_run_device: { scheme: 'SCHEME', deviceId: 'DEVICE_UDID' }, - test_device: { scheme: 'SCHEME', deviceId: 'DEVICE_UDID' }, - get_device_app_path: { scheme: 'SCHEME' }, - }, + expect(result.isError).toBeFalsy(); + const text = textOf(result); + expect(text).toContain('List Devices'); + expect(text).toContain('Test iPhone'); + expect(text).toContain('test-device-123'); + expect(text).toContain('iPhone15,2'); + expect(text).toContain('iOS 17.0'); + expect(text).toContain('USB'); + expect(text).toContain('Devices discovered'); + expect(result.nextStepParams).toEqual({ + build_device: { scheme: 'SCHEME', deviceId: 'DEVICE_UDID' }, + build_run_device: { scheme: 'SCHEME', deviceId: 'DEVICE_UDID' }, + test_device: { scheme: 'SCHEME', deviceId: 'DEVICE_UDID' }, + get_device_app_path: { scheme: 'SCHEME' }, }); }); it('should return successful xctrace fallback response', async () => { - // Create executor with call count behavior let callCount = 0; const mockExecutor = async ( _command: string[], @@ -245,14 +250,12 @@ describe('list_devices plugin (device-shared)', () => { ) => { callCount++; if (callCount === 1) { - // First call fails (devicectl) return createMockCommandResponse({ success: false, output: '', error: 'devicectl failed', }); } else { - // Second call succeeds (xctrace) return createMockCommandResponse({ success: true, output: 'iPhone 15 (12345678-1234-1234-1234-123456789012)', @@ -261,13 +264,11 @@ describe('list_devices plugin (device-shared)', () => { } }; - // Create mock path dependencies const mockPathDeps = { tmpdir: () => '/tmp', join: (...paths: string[]) => paths.join('/'), }; - // Create mock filesystem that throws for readFile const mockFsDeps = { readFile: async () => { throw new Error('File not found'); @@ -277,14 +278,12 @@ describe('list_devices plugin (device-shared)', () => { const result = await list_devicesLogic({}, mockExecutor, mockPathDeps, mockFsDeps); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: 'Device listing (xctrace output):\n\niPhone 15 (12345678-1234-1234-1234-123456789012)\n\nNote: For better device information, please upgrade to Xcode 15 or later which supports the modern devicectl command.', - }, - ], - }); + expect(result.isError).toBeFalsy(); + const text = textOf(result); + expect(text).toContain('List Devices'); + expect(text).toContain('xctrace output'); + expect(text).toContain('iPhone 15 (12345678-1234-1234-1234-123456789012)'); + expect(text).toContain('Xcode 15'); }); it('should return successful no devices found response', async () => { @@ -294,7 +293,6 @@ describe('list_devices plugin (device-shared)', () => { }, }; - // Create executor with call count behavior let callCount = 0; const mockExecutor = async ( _command: string[], @@ -305,14 +303,12 @@ describe('list_devices plugin (device-shared)', () => { ) => { callCount++; if (callCount === 1) { - // First call succeeds (devicectl) return createMockCommandResponse({ success: true, output: '', error: undefined, }); } else { - // Second call succeeds (xctrace) with empty output return createMockCommandResponse({ success: true, output: '', @@ -321,13 +317,11 @@ describe('list_devices plugin (device-shared)', () => { } }; - // Create mock path dependencies const mockPathDeps = { tmpdir: () => '/tmp', join: (...paths: string[]) => paths.join('/'), }; - // Create mock filesystem with empty devices response const mockFsDeps = { readFile: async (_path: string, _encoding?: string) => JSON.stringify(devicectlJson), unlink: async () => {}, @@ -335,14 +329,11 @@ describe('list_devices plugin (device-shared)', () => { const result = await list_devicesLogic({}, mockExecutor, mockPathDeps, mockFsDeps); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: 'Device listing (xctrace output):\n\n\n\nNote: For better device information, please upgrade to Xcode 15 or later which supports the modern devicectl command.', - }, - ], - }); + expect(result.isError).toBeFalsy(); + const text = textOf(result); + expect(text).toContain('List Devices'); + expect(text).toContain('xctrace output'); + expect(text).toContain('Xcode 15'); }); }); diff --git a/src/mcp/tools/device/__tests__/stop_app_device.test.ts b/src/mcp/tools/device/__tests__/stop_app_device.test.ts index 0ae186c4..d6b470ae 100644 --- a/src/mcp/tools/device/__tests__/stop_app_device.test.ts +++ b/src/mcp/tools/device/__tests__/stop_app_device.test.ts @@ -162,6 +162,13 @@ describe('stop_app_device plugin', () => { }); describe('Success Path Tests', () => { + function textOf(result: { content: Array<{ type: string; text: string }> }): string { + return result.content + .filter((i) => i.type === 'text') + .map((i) => i.text) + .join('\n'); + } + it('should return successful stop response', async () => { const mockExecutor = createMockExecutor({ success: true, @@ -176,14 +183,12 @@ describe('stop_app_device plugin', () => { mockExecutor, ); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: 'โœ… App stopped successfully\n\nApp terminated successfully', - }, - ], - }); + expect(result.isError).toBeFalsy(); + const text = textOf(result); + expect(text).toContain('Stop App'); + expect(text).toContain('test-device-123'); + expect(text).toContain('12345'); + expect(text).toContain('App stopped successfully'); }); it('should return successful stop with detailed output', async () => { @@ -200,14 +205,10 @@ describe('stop_app_device plugin', () => { mockExecutor, ); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: 'โœ… App stopped successfully\n\nTerminating process...\nProcess ID: 12345\nTermination completed successfully', - }, - ], - }); + expect(result.isError).toBeFalsy(); + const text = textOf(result); + expect(text).toContain('Stop App'); + expect(text).toContain('App stopped successfully'); }); it('should return successful stop with empty output', async () => { @@ -224,18 +225,21 @@ describe('stop_app_device plugin', () => { mockExecutor, ); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: 'โœ… App stopped successfully\n\n', - }, - ], - }); + expect(result.isError).toBeFalsy(); + const text = textOf(result); + expect(text).toContain('Stop App'); + expect(text).toContain('App stopped successfully'); }); }); describe('Error Handling', () => { + function textOf(result: { content: Array<{ type: string; text: string }> }): string { + return result.content + .filter((i) => i.type === 'text') + .map((i) => i.text) + .join('\n'); + } + it('should return stop failure response', async () => { const mockExecutor = createMockExecutor({ success: false, @@ -250,15 +254,9 @@ describe('stop_app_device plugin', () => { mockExecutor, ); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: 'Failed to stop app: Terminate failed: Process not found', - }, - ], - isError: true, - }); + expect(result.isError).toBe(true); + const text = textOf(result); + expect(text).toContain('Failed to stop app: Terminate failed: Process not found'); }); it('should return exception handling response', async () => { @@ -272,15 +270,9 @@ describe('stop_app_device plugin', () => { mockExecutor, ); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: 'Failed to stop app on device: Network error', - }, - ], - isError: true, - }); + expect(result.isError).toBe(true); + const text = textOf(result); + expect(text).toContain('Failed to stop app on device: Network error'); }); it('should return string error handling response', async () => { @@ -294,15 +286,9 @@ describe('stop_app_device plugin', () => { mockExecutor, ); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: 'Failed to stop app on device: String error', - }, - ], - isError: true, - }); + expect(result.isError).toBe(true); + const text = textOf(result); + expect(text).toContain('Failed to stop app on device: String error'); }); }); }); diff --git a/src/mcp/tools/device/build_run_device.ts b/src/mcp/tools/device/build_run_device.ts index 795a9d6e..3cf18e15 100644 --- a/src/mcp/tools/device/build_run_device.ts +++ b/src/mcp/tools/device/build_run_device.ts @@ -18,13 +18,15 @@ import { createSessionAwareTool, getSessionAwareToolSchemaShape, } from '../../../utils/typed-tool-factory.ts'; -import { createTextResponse } from '../../../utils/responses/index.ts'; import { nullifyEmptyStrings } from '../../../utils/schema-helpers.ts'; import { extractBundleIdFromAppPath } from '../../../utils/bundle-id.ts'; import { mapDevicePlatform } from './build-settings.ts'; +import { toolResponse } from '../../../utils/tool-response.ts'; +import { header, statusLine } from '../../../utils/tool-event-builders.ts'; import { startBuildPipeline } from '../../../utils/xcodebuild-pipeline.ts'; import { formatToolPreflight } from '../../../utils/build-preflight.ts'; import { + createBuildRunResultEvents, createPendingXcodebuildResponse, emitPipelineError, emitPipelineNotice, @@ -296,31 +298,24 @@ export async function build_run_deviceLogic( nextStepParams, }, { - tailEvents: [ - { - type: 'notice', - timestamp: new Date().toISOString(), - operation: 'BUILD', - level: 'success', - message: 'Build & Run complete', - code: 'build-run-result', - data: { - scheme: params.scheme, - platform: String(platform), - target: `${platform} Device`, - appPath, - bundleId, - launchState: 'requested', - ...(processId !== undefined ? { processId } : {}), - }, - }, - ], + tailEvents: createBuildRunResultEvents({ + scheme: params.scheme, + platform: String(platform), + target: `${platform} Device`, + appPath, + bundleId, + launchState: 'requested', + ...(processId !== undefined ? { processId } : {}), + }), }, ); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); log('error', `Error during device build & run logic: ${errorMessage}`); - return createTextResponse(`Error during device build and run: ${errorMessage}`, true); + return toolResponse([ + header('Build & Run Device'), + statusLine('error', `Error during device build and run: ${errorMessage}`), + ]); } } diff --git a/src/mcp/tools/device/get_device_app_path.ts b/src/mcp/tools/device/get_device_app_path.ts index 6d81c39a..02f7a5a5 100644 --- a/src/mcp/tools/device/get_device_app_path.ts +++ b/src/mcp/tools/device/get_device_app_path.ts @@ -16,11 +16,8 @@ import { } from '../../../utils/typed-tool-factory.ts'; import { nullifyEmptyStrings } from '../../../utils/schema-helpers.ts'; import { mapDevicePlatform, resolveAppPathFromBuildSettings } from './build-settings.ts'; -import { formatToolPreflight } from '../../../utils/build-preflight.ts'; -import { - formatQueryError, - formatQueryFailureSummary, -} from '../../../utils/xcodebuild-error-utils.ts'; +import { toolResponse } from '../../../utils/tool-response.ts'; +import { header, statusLine, detailTree } from '../../../utils/tool-event-builders.ts'; // Unified schema: XOR between projectPath and workspacePath, sharing common options const baseOptions = { @@ -46,7 +43,6 @@ const getDeviceAppPathSchema = z.preprocess( }), ); -// Use z.infer for type safety type GetDeviceAppPathParams = z.infer; const publicSchemaObject = baseSchemaObject.omit({ @@ -57,21 +53,31 @@ const publicSchemaObject = baseSchemaObject.omit({ platform: true, } as const); +function buildHeaderParams( + params: GetDeviceAppPathParams, + configuration: string, + platform: string, +) { + const headerParams: Array<{ label: string; value: string }> = [ + { label: 'Scheme', value: params.scheme }, + ]; + if (params.workspacePath) { + headerParams.push({ label: 'Workspace', value: params.workspacePath }); + } else if (params.projectPath) { + headerParams.push({ label: 'Project', value: params.projectPath }); + } + headerParams.push({ label: 'Configuration', value: configuration }); + headerParams.push({ label: 'Platform', value: platform }); + return headerParams; +} + export async function get_device_app_pathLogic( params: GetDeviceAppPathParams, executor: CommandExecutor, ): Promise { const platform = mapDevicePlatform(params.platform); const configuration = params.configuration ?? 'Debug'; - - const preflight = formatToolPreflight({ - operation: 'Get App Path', - scheme: params.scheme, - workspacePath: params.workspacePath, - projectPath: params.projectPath, - configuration, - platform, - }); + const headerParams = buildHeaderParams(params, configuration, platform); log('info', `Getting app path for scheme ${params.scheme} on platform ${platform}`); @@ -87,32 +93,25 @@ export async function get_device_app_pathLogic( executor, ); - return { - content: [ - { - type: 'text', - text: `${preflight}\n \u{2514} App Path: ${appPath}`, - }, + return toolResponse( + [ + header('Get App Path', headerParams), + detailTree([{ label: 'App Path', value: appPath }]), + statusLine('success', 'App path resolved.'), ], - nextStepParams: { - get_app_bundle_id: { appPath }, - install_app_device: { deviceId: 'DEVICE_UDID', appPath }, - launch_app_device: { deviceId: 'DEVICE_UDID', bundleId: 'BUNDLE_ID' }, + { + nextStepParams: { + get_app_bundle_id: { appPath }, + install_app_device: { deviceId: 'DEVICE_UDID', appPath }, + launch_app_device: { deviceId: 'DEVICE_UDID', bundleId: 'BUNDLE_ID' }, + }, }, - }; + ); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); log('error', `Error retrieving app path: ${errorMessage}`); - return { - content: [ - { - type: 'text', - text: `${preflight}\n${formatQueryError(errorMessage)}\n\n${formatQueryFailureSummary()}`, - }, - ], - isError: true, - }; + return toolResponse([header('Get App Path', headerParams), statusLine('error', errorMessage)]); } } diff --git a/src/mcp/tools/device/install_app_device.ts b/src/mcp/tools/device/install_app_device.ts index 3cd6d133..f70c2946 100644 --- a/src/mcp/tools/device/install_app_device.ts +++ b/src/mcp/tools/device/install_app_device.ts @@ -14,8 +14,9 @@ import { createSessionAwareTool, getSessionAwareToolSchemaShape, } from '../../../utils/typed-tool-factory.ts'; +import { toolResponse } from '../../../utils/tool-response.ts'; +import { header, statusLine } from '../../../utils/tool-event-builders.ts'; -// Define schema as ZodObject const installAppDeviceSchema = z.object({ deviceId: z .string() @@ -26,12 +27,8 @@ const installAppDeviceSchema = z.object({ const publicSchemaObject = installAppDeviceSchema.omit({ deviceId: true } as const); -// Use z.infer for type safety type InstallAppDeviceParams = z.infer; -/** - * Business logic for installing an app on a physical Apple device - */ export async function install_app_deviceLogic( params: InstallAppDeviceParams, executor: CommandExecutor, @@ -44,42 +41,36 @@ export async function install_app_deviceLogic( const result = await executor( ['xcrun', 'devicectl', 'device', 'install', 'app', '--device', deviceId, appPath], 'Install app on device', - false, // useShell - undefined, // env + false, ); if (!result.success) { - return { - content: [ - { - type: 'text', - text: `Failed to install app: ${result.error}`, - }, - ], - isError: true, - }; + return toolResponse([ + header('Install App', [ + { label: 'Device', value: deviceId }, + { label: 'App', value: appPath }, + ]), + statusLine('error', `Failed to install app: ${result.error}`), + ]); } - return { - content: [ - { - type: 'text', - text: `โœ… App installed successfully on device ${deviceId}\n\n${result.output}`, - }, - ], - }; + return toolResponse([ + header('Install App', [ + { label: 'Device', value: deviceId }, + { label: 'App', value: appPath }, + ]), + statusLine('success', 'App installed successfully.'), + ]); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); log('error', `Error installing app on device: ${errorMessage}`); - return { - content: [ - { - type: 'text', - text: `Failed to install app on device: ${errorMessage}`, - }, - ], - isError: true, - }; + return toolResponse([ + header('Install App', [ + { label: 'Device', value: deviceId }, + { label: 'App', value: appPath }, + ]), + statusLine('error', `Failed to install app on device: ${errorMessage}`), + ]); } } diff --git a/src/mcp/tools/device/launch_app_device.ts b/src/mcp/tools/device/launch_app_device.ts index feb1e404..c2341459 100644 --- a/src/mcp/tools/device/launch_app_device.ts +++ b/src/mcp/tools/device/launch_app_device.ts @@ -18,8 +18,9 @@ import { getSessionAwareToolSchemaShape, } from '../../../utils/typed-tool-factory.ts'; import { join } from 'path'; +import { toolResponse } from '../../../utils/tool-response.ts'; +import { header, statusLine, detailTree } from '../../../utils/tool-event-builders.ts'; -// Type for the launch JSON response type LaunchDataResponse = { result?: { process?: { @@ -28,7 +29,6 @@ type LaunchDataResponse = { }; }; -// Define schema as ZodObject const launchAppDeviceSchema = z.object({ deviceId: z.string().describe('UDID of the device (obtained from list_devices)'), bundleId: z.string(), @@ -43,7 +43,6 @@ const publicSchemaObject = launchAppDeviceSchema.omit({ bundleId: true, } as const); -// Use z.infer for type safety type LaunchAppDeviceParams = z.infer; export async function launch_app_deviceLogic( @@ -78,46 +77,27 @@ export async function launch_app_deviceLogic( command.push(bundleId); - const result = await executor( - command, - 'Launch app on device', - false, // useShell - undefined, // env - ); + const result = await executor(command, 'Launch app on device', false); + + const headerParams: Array<{ label: string; value: string }> = [ + { label: 'Device', value: deviceId }, + { label: 'Bundle ID', value: bundleId }, + ]; if (!result.success) { - return { - content: [ - { - type: 'text', - text: `Failed to launch app: ${result.error}`, - }, - ], - isError: true, - }; + return toolResponse([ + header('Launch App', headerParams), + statusLine('error', `Failed to launch app: ${result.error}`), + ]); } - // Parse JSON to extract process ID let processId: number | undefined; try { const jsonContent = await fileSystem.readFile(tempJsonPath, 'utf8'); - const parsedData: unknown = JSON.parse(jsonContent); - - // Type guard to validate the parsed data structure - if ( - parsedData && - typeof parsedData === 'object' && - 'result' in parsedData && - parsedData.result && - typeof parsedData.result === 'object' && - 'process' in parsedData.result && - parsedData.result.process && - typeof parsedData.result.process === 'object' && - 'processIdentifier' in parsedData.result.process && - typeof parsedData.result.process.processIdentifier === 'number' - ) { - const launchData = parsedData as LaunchDataResponse; - processId = launchData.result?.process?.processIdentifier; + const launchData = JSON.parse(jsonContent) as LaunchDataResponse; + const pid = launchData?.result?.process?.processIdentifier; + if (typeof pid === 'number') { + processId = pid; } } catch (error) { log('warn', `Failed to parse launch JSON output: ${error}`); @@ -125,31 +105,28 @@ export async function launch_app_deviceLogic( await fileSystem.rm(tempJsonPath, { force: true }).catch(() => {}); } - const responseText = processId - ? `โœ… App launched successfully\n\n${result.output}\n\nProcess ID: ${processId}\n\nInteract with your app on the device.` - : `โœ… App launched successfully\n\n${result.output}`; - - return { - content: [ - { - type: 'text', - text: responseText, - }, - ], - ...(processId ? { nextStepParams: { stop_app_device: { deviceId, processId } } } : {}), - }; + const events = [header('Launch App', headerParams)]; + + if (processId) { + events.push(detailTree([{ label: 'Process ID', value: processId.toString() }])); + } + + events.push(statusLine('success', 'App launched successfully.')); + + return toolResponse( + events, + processId ? { nextStepParams: { stop_app_device: { deviceId, processId } } } : undefined, + ); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); log('error', `Error launching app on device: ${errorMessage}`); - return { - content: [ - { - type: 'text', - text: `Failed to launch app on device: ${errorMessage}`, - }, - ], - isError: true, - }; + return toolResponse([ + header('Launch App', [ + { label: 'Device', value: deviceId }, + { label: 'Bundle ID', value: bundleId }, + ]), + statusLine('error', `Failed to launch app on device: ${errorMessage}`), + ]); } } diff --git a/src/mcp/tools/device/list_devices.ts b/src/mcp/tools/device/list_devices.ts index 22b1812f..8f31451f 100644 --- a/src/mcp/tools/device/list_devices.ts +++ b/src/mcp/tools/device/list_devices.ts @@ -14,11 +14,12 @@ import { createTypedTool } from '../../../utils/typed-tool-factory.ts'; import { promises as fs } from 'fs'; import { tmpdir } from 'os'; import { join } from 'path'; +import type { PipelineEvent } from '../../../types/pipeline-events.ts'; +import { toolResponse } from '../../../utils/tool-response.ts'; +import { header, statusLine, section, detailTree } from '../../../utils/tool-event-builders.ts'; -// Define schema as ZodObject (empty schema since this tool takes no parameters) const listDevicesSchema = z.object({}); -// Use z.infer for type safety type ListDevicesParams = z.infer; function isAvailableState(state: string): boolean { @@ -87,27 +88,14 @@ export async function list_devicesLogic( : await fs.readFile(tempJsonPath, 'utf8'); const deviceCtlData: unknown = JSON.parse(jsonContent); - // Type guard to validate the device data structure - const isValidDeviceData = (data: unknown): data is { result?: { devices?: unknown[] } } => { - return ( - typeof data === 'object' && - data !== null && - 'result' in data && - typeof (data as { result?: unknown }).result === 'object' && - (data as { result?: unknown }).result !== null && - 'devices' in ((data as { result?: unknown }).result as { devices?: unknown }) && - Array.isArray( - ((data as { result?: unknown }).result as { devices?: unknown[] }).devices, - ) - ); - }; - - if (isValidDeviceData(deviceCtlData) && deviceCtlData.result?.devices) { - for (const deviceRaw of deviceCtlData.result.devices) { - // Type guard for device object - const isValidDevice = ( - device: unknown, - ): device is { + const deviceCtlResult = deviceCtlData as { result?: { devices?: unknown[] } }; + const deviceList = deviceCtlResult?.result?.devices; + + if (Array.isArray(deviceList)) { + for (const deviceRaw of deviceList) { + if (typeof deviceRaw !== 'object' || deviceRaw === null) continue; + + const device = deviceRaw as { visibilityClass?: string; connectionProperties?: { pairingState?: string; @@ -126,114 +114,8 @@ export async function list_devicesLogic( cpuType?: { name?: string }; }; identifier?: string; - } => { - if (typeof device !== 'object' || device === null) { - return false; - } - - const dev = device as Record; - - // Check if identifier exists and is a string (most critical property) - if (typeof dev.identifier !== 'string' && dev.identifier !== undefined) { - return false; - } - - // Check visibilityClass if present - if (dev.visibilityClass !== undefined && typeof dev.visibilityClass !== 'string') { - return false; - } - - // Check connectionProperties structure if present - if (dev.connectionProperties !== undefined) { - if ( - typeof dev.connectionProperties !== 'object' || - dev.connectionProperties === null - ) { - return false; - } - const connProps = dev.connectionProperties as Record; - if ( - connProps.pairingState !== undefined && - typeof connProps.pairingState !== 'string' - ) { - return false; - } - if ( - connProps.tunnelState !== undefined && - typeof connProps.tunnelState !== 'string' - ) { - return false; - } - if ( - connProps.transportType !== undefined && - typeof connProps.transportType !== 'string' - ) { - return false; - } - } - - // Check deviceProperties structure if present - if (dev.deviceProperties !== undefined) { - if (typeof dev.deviceProperties !== 'object' || dev.deviceProperties === null) { - return false; - } - const devProps = dev.deviceProperties as Record; - if ( - devProps.platformIdentifier !== undefined && - typeof devProps.platformIdentifier !== 'string' - ) { - return false; - } - if (devProps.name !== undefined && typeof devProps.name !== 'string') { - return false; - } - if ( - devProps.osVersionNumber !== undefined && - typeof devProps.osVersionNumber !== 'string' - ) { - return false; - } - if ( - devProps.developerModeStatus !== undefined && - typeof devProps.developerModeStatus !== 'string' - ) { - return false; - } - if ( - devProps.marketingName !== undefined && - typeof devProps.marketingName !== 'string' - ) { - return false; - } - } - - // Check hardwareProperties structure if present - if (dev.hardwareProperties !== undefined) { - if (typeof dev.hardwareProperties !== 'object' || dev.hardwareProperties === null) { - return false; - } - const hwProps = dev.hardwareProperties as Record; - if (hwProps.productType !== undefined && typeof hwProps.productType !== 'string') { - return false; - } - if (hwProps.cpuType !== undefined) { - if (typeof hwProps.cpuType !== 'object' || hwProps.cpuType === null) { - return false; - } - const cpuType = hwProps.cpuType as Record; - if (cpuType.name !== undefined && typeof cpuType.name !== 'string') { - return false; - } - } - } - - return true; }; - if (!isValidDevice(deviceRaw)) continue; - - const device = deviceRaw; - // Skip simulators or unavailable devices if ( device.visibilityClass === 'Simulator' || @@ -300,103 +182,112 @@ export async function list_devicesLogic( ); if (!result.success) { - return { - content: [ - { - type: 'text', - text: `Failed to list devices: ${result.error}\n\nMake sure Xcode is installed and devices are connected and trusted.`, - }, - ], - isError: true, - }; + return toolResponse([ + header('List Devices'), + statusLine('error', `Failed to list devices: ${result.error}`), + section('Troubleshooting', [ + 'Make sure Xcode is installed and devices are connected and trusted.', + ]), + ]); } - // Return raw xctrace output without parsing - return { - content: [ - { - type: 'text', - text: `Device listing (xctrace output):\n\n${result.output}\n\nNote: For better device information, please upgrade to Xcode 15 or later which supports the modern devicectl command.`, - }, - ], - }; + return toolResponse([ + header('List Devices'), + section('Device listing (xctrace output)', [result.output]), + statusLine( + 'info', + 'For better device information, please upgrade to Xcode 15 or later which supports the modern devicectl command.', + ), + ]); } - // Format the response - let responseText = 'Connected Devices:\n\n'; - - // Filter out duplicates const uniqueDevices = devices.filter( (device, index, self) => index === self.findIndex((d) => d.identifier === device.identifier), ); + const events: PipelineEvent[] = [header('List Devices')]; + if (uniqueDevices.length === 0) { - responseText += 'No physical Apple devices found.\n\n'; - responseText += 'Make sure:\n'; - responseText += '1. Devices are connected via USB or WiFi\n'; - responseText += '2. Devices are unlocked and trusted\n'; - responseText += '3. "Trust this computer" has been accepted on the device\n'; - responseText += '4. Developer mode is enabled on the device (iOS 16+)\n'; - responseText += '5. Xcode is properly installed\n\n'; - responseText += 'For simulators, use the list_sims tool instead.\n'; - } else { - // Group devices by availability status - const availableDevices = uniqueDevices.filter((d) => isAvailableState(d.state)); - const pairedDevices = uniqueDevices.filter((d) => d.state === 'Paired (not connected)'); - const unpairedDevices = uniqueDevices.filter((d) => d.state === 'Unpaired'); - - if (availableDevices.length > 0) { - responseText += 'โœ… Available Devices:\n'; - for (const device of availableDevices) { - responseText += `\n๐Ÿ“ฑ ${device.name}\n`; - responseText += ` UDID: ${device.identifier}\n`; - responseText += ` Model: ${device.model ?? 'Unknown'}\n`; - if (device.productType) { - responseText += ` Product Type: ${device.productType}\n`; - } - responseText += ` Platform: ${device.platform} ${device.osVersion ?? ''}\n`; - if (device.cpuArchitecture) { - responseText += ` CPU Architecture: ${device.cpuArchitecture}\n`; - } - responseText += ` Connection: ${device.connectionType ?? 'Unknown'}\n`; - if (device.developerModeStatus) { - responseText += ` Developer Mode: ${device.developerModeStatus}\n`; - } - } - responseText += '\n'; - } + events.push( + statusLine('warning', 'No physical Apple devices found.'), + section('Troubleshooting', [ + 'Make sure:', + '1. Devices are connected via USB or WiFi', + '2. Devices are unlocked and trusted', + '3. "Trust this computer" has been accepted on the device', + '4. Developer mode is enabled on the device (iOS 16+)', + '5. Xcode is properly installed', + '', + 'For simulators, use the list_sims tool instead.', + ]), + ); + return toolResponse(events); + } - if (pairedDevices.length > 0) { - responseText += '๐Ÿ”— Paired but Not Connected:\n'; - for (const device of pairedDevices) { - responseText += `\n๐Ÿ“ฑ ${device.name}\n`; - responseText += ` UDID: ${device.identifier}\n`; - responseText += ` Model: ${device.model ?? 'Unknown'}\n`; - responseText += ` Platform: ${device.platform} ${device.osVersion ?? ''}\n`; + const availableDevices = uniqueDevices.filter((d) => isAvailableState(d.state)); + const pairedDevices = uniqueDevices.filter((d) => d.state === 'Paired (not connected)'); + const unpairedDevices = uniqueDevices.filter((d) => d.state === 'Unpaired'); + + if (availableDevices.length > 0) { + for (const device of availableDevices) { + const items: Array<{ label: string; value: string }> = [ + { label: 'UDID', value: device.identifier }, + { label: 'Model', value: device.model ?? 'Unknown' }, + ]; + if (device.productType) { + items.push({ label: 'Product Type', value: device.productType }); + } + items.push({ + label: 'Platform', + value: `${device.platform} ${device.osVersion ?? ''}`.trim(), + }); + if (device.cpuArchitecture) { + items.push({ label: 'CPU Architecture', value: device.cpuArchitecture }); } - responseText += '\n'; + items.push({ label: 'Connection', value: device.connectionType ?? 'Unknown' }); + if (device.developerModeStatus) { + items.push({ label: 'Developer Mode', value: device.developerModeStatus }); + } + events.push(section(device.name, [], { icon: 'green-circle' }), detailTree(items)); } + } - if (unpairedDevices.length > 0) { - responseText += 'โŒ Unpaired Devices:\n'; - for (const device of unpairedDevices) { - responseText += `- ${device.name} (${device.identifier})\n`; - } - responseText += '\n'; + if (pairedDevices.length > 0) { + for (const device of pairedDevices) { + events.push( + section(device.name, [], { icon: 'yellow-circle' }), + detailTree([ + { label: 'UDID', value: device.identifier }, + { label: 'Model', value: device.model ?? 'Unknown' }, + { label: 'Platform', value: `${device.platform} ${device.osVersion ?? ''}`.trim() }, + ]), + ); } } - // Add next steps + if (unpairedDevices.length > 0) { + events.push( + section( + 'Unpaired Devices', + unpairedDevices.map((d) => `${d.name} (${d.identifier})`), + { icon: 'red-circle' }, + ), + ); + } + const availableDevicesExist = uniqueDevices.some((d) => isAvailableState(d.state)); let nextStepParams: Record> | undefined; if (availableDevicesExist) { - responseText += 'Note: Use the device ID/UDID from above when required by other tools.\n'; - responseText += - "Hint: Save a default device with session-set-defaults { deviceId: 'DEVICE_UDID' }.\n"; - responseText += - 'Before running build/run/test/UI automation tools, set the desired device identifier in session defaults.\n'; + events.push( + statusLine('success', 'Devices discovered.'), + section('Hints', [ + 'Use the device ID/UDID from above when required by other tools.', + "Save a default device with session-set-defaults { deviceId: 'DEVICE_UDID' }.", + 'Before running build/run/test/UI automation tools, set the desired device identifier in session defaults.', + ]), + ); nextStepParams = { build_device: { scheme: 'SCHEME', deviceId: 'DEVICE_UDID' }, @@ -405,34 +296,25 @@ export async function list_devicesLogic( get_device_app_path: { scheme: 'SCHEME' }, }; } else if (uniqueDevices.length > 0) { - responseText += - 'Note: No devices are currently available for testing. Make sure devices are:\n'; - responseText += '- Connected via USB\n'; - responseText += '- Unlocked and trusted\n'; - responseText += '- Have developer mode enabled (iOS 16+)\n'; + events.push( + statusLine('warning', 'No devices are currently available for testing.'), + section('Troubleshooting', [ + 'Make sure devices are:', + '- Connected via USB', + '- Unlocked and trusted', + '- Have developer mode enabled (iOS 16+)', + ]), + ); } - return { - content: [ - { - type: 'text', - text: responseText, - }, - ], - ...(nextStepParams ? { nextStepParams } : {}), - }; + return toolResponse(events, nextStepParams ? { nextStepParams } : undefined); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); log('error', `Error listing devices: ${errorMessage}`); - return { - content: [ - { - type: 'text', - text: `Failed to list devices: ${errorMessage}`, - }, - ], - isError: true, - }; + return toolResponse([ + header('List Devices'), + statusLine('error', `Failed to list devices: ${errorMessage}`), + ]); } } diff --git a/src/mcp/tools/device/stop_app_device.ts b/src/mcp/tools/device/stop_app_device.ts index 4bbf4319..175c0485 100644 --- a/src/mcp/tools/device/stop_app_device.ts +++ b/src/mcp/tools/device/stop_app_device.ts @@ -14,14 +14,14 @@ import { createSessionAwareTool, getSessionAwareToolSchemaShape, } from '../../../utils/typed-tool-factory.ts'; +import { toolResponse } from '../../../utils/tool-response.ts'; +import { header, statusLine } from '../../../utils/tool-event-builders.ts'; -// Define schema as ZodObject const stopAppDeviceSchema = z.object({ deviceId: z.string().describe('UDID of the device (obtained from list_devices)'), processId: z.number(), }); -// Use z.infer for type safety type StopAppDeviceParams = z.infer; const publicSchemaObject = stopAppDeviceSchema.omit({ deviceId: true } as const); @@ -48,42 +48,36 @@ export async function stop_app_deviceLogic( processId.toString(), ], 'Stop app on device', - false, // useShell - undefined, // env + false, ); if (!result.success) { - return { - content: [ - { - type: 'text', - text: `Failed to stop app: ${result.error}`, - }, - ], - isError: true, - }; + return toolResponse([ + header('Stop App', [ + { label: 'Device', value: deviceId }, + { label: 'PID', value: processId.toString() }, + ]), + statusLine('error', `Failed to stop app: ${result.error}`), + ]); } - return { - content: [ - { - type: 'text', - text: `โœ… App stopped successfully\n\n${result.output}`, - }, - ], - }; + return toolResponse([ + header('Stop App', [ + { label: 'Device', value: deviceId }, + { label: 'PID', value: processId.toString() }, + ]), + statusLine('success', 'App stopped successfully.'), + ]); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); log('error', `Error stopping app on device: ${errorMessage}`); - return { - content: [ - { - type: 'text', - text: `Failed to stop app on device: ${errorMessage}`, - }, - ], - isError: true, - }; + return toolResponse([ + header('Stop App', [ + { label: 'Device', value: deviceId }, + { label: 'PID', value: processId.toString() }, + ]), + statusLine('error', `Failed to stop app on device: ${errorMessage}`), + ]); } } diff --git a/src/mcp/tools/doctor/__tests__/doctor.test.ts b/src/mcp/tools/doctor/__tests__/doctor.test.ts index 5fb17d6e..83625beb 100644 --- a/src/mcp/tools/doctor/__tests__/doctor.test.ts +++ b/src/mcp/tools/doctor/__tests__/doctor.test.ts @@ -137,19 +137,21 @@ describe('doctor tool', () => { }); describe('Handler Behavior (Complete Literal Returns)', () => { + function allText(result: { content: Array<{ type: string; text: string }> }): string { + return result.content + .filter((c) => c.type === 'text') + .map((c) => c.text) + .join('\n'); + } + it('should handle successful doctor execution', async () => { const deps = createDeps(); const result = await runDoctor({}, deps); - expect(result.content).toEqual([ - { - type: 'text', - text: result.content[0].text, - }, - ]); - expect(typeof result.content[0].text).toBe('string'); - expect(result.content[0].text).toContain('### Manifest Tool Inventory'); - expect(result.content[0].text).not.toContain('Total Plugins'); + expect(result.content.length).toBeGreaterThan(0); + const text = allText(result); + expect(text).toContain('Manifest Tool Inventory'); + expect(text).not.toContain('Total Plugins'); }); it('should handle manifest loading failure', async () => { @@ -163,13 +165,9 @@ describe('doctor tool', () => { const result = await runDoctor({}, deps); - expect(result.content).toEqual([ - { - type: 'text', - text: result.content[0].text, - }, - ]); - expect(typeof result.content[0].text).toBe('string'); + expect(result.content.length).toBeGreaterThan(0); + const text = allText(result); + expect(text).toContain('Manifest loading failed'); }); it('should handle xcode command failure', async () => { @@ -182,13 +180,9 @@ describe('doctor tool', () => { }); const result = await runDoctor({}, deps); - expect(result.content).toEqual([ - { - type: 'text', - text: result.content[0].text, - }, - ]); - expect(typeof result.content[0].text).toBe('string'); + expect(result.content.length).toBeGreaterThan(0); + const text = allText(result); + expect(text).toContain('Xcode not found'); }); it('should handle xcodemake check failure', async () => { @@ -209,13 +203,9 @@ describe('doctor tool', () => { }); const result = await runDoctor({}, deps); - expect(result.content).toEqual([ - { - type: 'text', - text: result.content[0].text, - }, - ]); - expect(typeof result.content[0].text).toBe('string'); + expect(result.content.length).toBeGreaterThan(0); + const text = allText(result); + expect(text).toContain('xcodemake: Not found'); }); it('should redact path and sensitive values in output', async () => { @@ -255,8 +245,7 @@ describe('doctor tool', () => { }); const result = await runDoctor({}, deps); - const text = result.content[0].text; - if (typeof text !== 'string') throw new Error('Unexpected doctor output type'); + const text = allText(result); expect(text).toContain(''); expect(text).not.toContain('testuser'); @@ -302,10 +291,9 @@ describe('doctor tool', () => { }); const result = await runDoctor({ nonRedacted: true }, deps); - const text = result.content[0].text; - if (typeof text !== 'string') throw new Error('Unexpected doctor output type'); + const text = allText(result); - expect(text).toContain('Output Mode: โš ๏ธ Non-redacted (opt-in)'); + expect(text).toContain('Output Mode: Non-redacted (opt-in)'); expect(text).toContain('testuser'); expect(text).toContain('MySecretProject'); }); @@ -368,13 +356,10 @@ describe('doctor tool', () => { const result = await runDoctor({}, deps); - expect(result.content).toEqual([ - { - type: 'text', - text: result.content[0].text, - }, - ]); - expect(typeof result.content[0].text).toBe('string'); + expect(result.content.length).toBeGreaterThan(0); + const text = allText(result); + expect(text).toContain('Available: No'); + expect(text).toContain('UI Automation Supported: No'); }); }); }); diff --git a/src/mcp/tools/doctor/doctor.ts b/src/mcp/tools/doctor/doctor.ts index 77968d1f..7706221c 100644 --- a/src/mcp/tools/doctor/doctor.ts +++ b/src/mcp/tools/doctor/doctor.ts @@ -10,12 +10,15 @@ import type { CommandExecutor } from '../../../utils/execution/index.ts'; import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; import { version } from '../../../utils/version/index.ts'; import type { ToolResponse } from '../../../types/common.ts'; +import type { PipelineEvent } from '../../../types/pipeline-events.ts'; import { createTypedTool } from '../../../utils/typed-tool-factory.ts'; import { getConfig } from '../../../utils/config-store.ts'; import { detectXcodeRuntime } from '../../../utils/xcode-process.ts'; import { type DoctorDependencies, createDoctorDependencies } from './lib/doctor.deps.ts'; import { peekXcodeToolsBridgeManager } from '../../../integrations/xcode-tools-bridge/index.ts'; import { getMcpBridgeAvailability } from '../../../integrations/xcode-tools-bridge/core.ts'; +import { toolResponse } from '../../../utils/tool-response.ts'; +import { header, statusLine, section, detailTree } from '../../../utils/tool-event-builders.ts'; // Constants const LOG_PREFIX = '[Doctor]'; @@ -25,7 +28,6 @@ const SENSITIVE_KEY_PATTERN = const SECRET_VALUE_PATTERN = /((token|secret|password|passphrase|api[_-]?key|auth|cookie|session|private[_-]?key)\s*[=:]\s*)([^\s,;]+)/gi; -// Define schema as ZodObject const doctorSchema = z.object({ nonRedacted: z .boolean() @@ -33,7 +35,6 @@ const doctorSchema = z.object({ .describe('Opt-in: when true, disable redaction and include full raw doctor output.'), }); -// Use z.infer for type safety type DoctorParams = z.infer; function escapeRegExp(value: string): string { @@ -263,203 +264,222 @@ export async function runDoctor( ? doctorInfoRaw : (sanitizeValue(doctorInfoRaw, '', projectNames, piiTerms) as typeof doctorInfoRaw); - // Custom ASCII banner (multiline) - const asciiLogo = ` -โ–ˆโ–ˆโ•— โ–ˆโ–ˆโ•— โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ•— โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ•— โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ•— โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ•—โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ•— โ–ˆโ–ˆโ•— โ–ˆโ–ˆโ•—โ–ˆโ–ˆโ•—โ–ˆโ–ˆโ•— โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ•— โ–ˆโ–ˆโ–ˆโ•— โ–ˆโ–ˆโ–ˆโ•— โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ•—โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ•— -โ•šโ–ˆโ–ˆโ•—โ–ˆโ–ˆโ•”โ•โ–ˆโ–ˆโ•”โ•โ•โ•โ•โ•โ–ˆโ–ˆโ•”โ•โ•โ•โ–ˆโ–ˆโ•—โ–ˆโ–ˆโ•”โ•โ•โ–ˆโ–ˆโ•—โ–ˆโ–ˆโ•”โ•โ•โ•โ•โ•โ–ˆโ–ˆโ•”โ•โ•โ–ˆโ–ˆโ•—โ–ˆโ–ˆโ•‘ โ–ˆโ–ˆโ•‘โ–ˆโ–ˆโ•‘โ–ˆโ–ˆโ•‘ โ–ˆโ–ˆโ•”โ•โ•โ–ˆโ–ˆโ•—โ–ˆโ–ˆโ–ˆโ–ˆโ•— โ–ˆโ–ˆโ–ˆโ–ˆโ•‘โ–ˆโ–ˆโ•”โ•โ•โ•โ•โ•โ–ˆโ–ˆโ•”โ•โ•โ–ˆโ–ˆโ•— - โ•šโ–ˆโ–ˆโ–ˆโ•”โ• โ–ˆโ–ˆโ•‘ โ–ˆโ–ˆโ•‘ โ–ˆโ–ˆโ•‘โ–ˆโ–ˆโ•‘ โ–ˆโ–ˆโ•‘โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ•— โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ•”โ•โ–ˆโ–ˆโ•‘ โ–ˆโ–ˆโ•‘โ–ˆโ–ˆโ•‘โ–ˆโ–ˆโ•‘ โ–ˆโ–ˆโ•‘ โ–ˆโ–ˆโ•‘โ–ˆโ–ˆโ•”โ–ˆโ–ˆโ–ˆโ–ˆโ•”โ–ˆโ–ˆโ•‘โ–ˆโ–ˆโ•‘ โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ•”โ• - โ–ˆโ–ˆโ•”โ–ˆโ–ˆโ•— โ–ˆโ–ˆโ•‘ โ–ˆโ–ˆโ•‘ โ–ˆโ–ˆโ•‘โ–ˆโ–ˆโ•‘ โ–ˆโ–ˆโ•‘โ–ˆโ–ˆโ•”โ•โ•โ• โ–ˆโ–ˆโ•”โ•โ•โ–ˆโ–ˆโ•—โ–ˆโ–ˆโ•‘ โ–ˆโ–ˆโ•‘โ–ˆโ–ˆโ•‘โ–ˆโ–ˆโ•‘ โ–ˆโ–ˆโ•‘ โ–ˆโ–ˆโ•‘โ–ˆโ–ˆโ•‘โ•šโ–ˆโ–ˆโ•”โ•โ–ˆโ–ˆโ•‘โ–ˆโ–ˆโ•‘ โ–ˆโ–ˆโ•”โ•โ•โ•โ• -โ–ˆโ–ˆโ•”โ• โ–ˆโ–ˆโ•—โ•šโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ•—โ•šโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ•”โ•โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ•”โ•โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ•—โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ•”โ•โ•šโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ•”โ•โ–ˆโ–ˆโ•‘โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ•—โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ•”โ•โ–ˆโ–ˆโ•‘ โ•šโ•โ• โ–ˆโ–ˆโ•‘โ•šโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ•—โ–ˆโ–ˆโ•‘ -โ•šโ•โ• โ•šโ•โ• โ•šโ•โ•โ•โ•โ•โ• โ•šโ•โ•โ•โ•โ•โ• โ•šโ•โ•โ•โ•โ•โ• โ•šโ•โ•โ•โ•โ•โ•โ•โ•šโ•โ•โ•โ•โ•โ• โ•šโ•โ•โ•โ•โ•โ• โ•šโ•โ•โ•šโ•โ•โ•โ•โ•โ•โ•โ•šโ•โ•โ•โ•โ•โ• โ•šโ•โ• โ•šโ•โ• โ•šโ•โ•โ•โ•โ•โ•โ•šโ•โ• - -โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ•— โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ•— โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ•—โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ•— โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ•— โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ•— -โ–ˆโ–ˆโ•”โ•โ•โ–ˆโ–ˆโ•—โ–ˆโ–ˆโ•”โ•โ•โ•โ–ˆโ–ˆโ•—โ–ˆโ–ˆโ•”โ•โ•โ•โ•โ•โ•šโ•โ•โ–ˆโ–ˆโ•”โ•โ•โ•โ–ˆโ–ˆโ•”โ•โ•โ•โ–ˆโ–ˆโ•—โ–ˆโ–ˆโ•”โ•โ•โ–ˆโ–ˆโ•— -โ–ˆโ–ˆโ•‘ โ–ˆโ–ˆโ•‘โ–ˆโ–ˆโ•‘ โ–ˆโ–ˆโ•‘โ–ˆโ–ˆโ•‘ โ–ˆโ–ˆโ•‘ โ–ˆโ–ˆโ•‘ โ–ˆโ–ˆโ•‘โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ•”โ• -โ–ˆโ–ˆโ•‘ โ–ˆโ–ˆโ•‘โ–ˆโ–ˆโ•‘ โ–ˆโ–ˆโ•‘โ–ˆโ–ˆโ•‘ โ–ˆโ–ˆโ•‘ โ–ˆโ–ˆโ•‘ โ–ˆโ–ˆโ•‘โ–ˆโ–ˆโ•”โ•โ•โ–ˆโ–ˆโ•— -โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ•”โ•โ•šโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ•”โ•โ•šโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ•— โ–ˆโ–ˆโ•‘ โ•šโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ•”โ•โ–ˆโ–ˆโ•‘ โ–ˆโ–ˆโ•‘ -โ•šโ•โ•โ•โ•โ•โ• โ•šโ•โ•โ•โ•โ•โ• โ•šโ•โ•โ•โ•โ•โ• โ•šโ•โ• โ•šโ•โ•โ•โ•โ•โ• โ•šโ•โ• โ•šโ•โ• -`; - - const RESET = '\x1b[0m'; - // 256-color: orangey-pink foreground and lighter shade for outlines - const FOREGROUND = '\x1b[38;5;209m'; - const SHADOW = '\x1b[38;5;217m'; - - function colorizeAsciiArt(ascii: string): string { - const lines = ascii.split('\n'); - const coloredLines: string[] = []; - const shadowChars = new Set([ - 'โ•”', - 'โ•—', - 'โ•', - 'โ•š', - 'โ•', - 'โ•‘', - 'โ•ฆ', - 'โ•ฉ', - 'โ• ', - 'โ•ฃ', - 'โ•ฌ', - 'โ”Œ', - 'โ”', - 'โ””', - 'โ”˜', - 'โ”‚', - 'โ”€', - ]); - for (const line of lines) { - let colored = ''; - for (const ch of line) { - if (ch === 'โ–ˆ') { - colored += `${FOREGROUND}${ch}${RESET}`; - } else if (shadowChars.has(ch)) { - colored += `${SHADOW}${ch}${RESET}`; - } else { - colored += ch; - } - } - coloredLines.push(colored + RESET); + const events: PipelineEvent[] = [ + header('Doctor', [ + { label: 'Generated', value: doctorInfo.timestamp }, + { label: 'Server Version', value: doctorInfo.serverVersion }, + { + label: 'Output Mode', + value: params.nonRedacted ? 'Non-redacted (opt-in)' : 'Redacted (default)', + }, + ]), + ]; + + // System Information + events.push( + detailTree( + Object.entries(doctorInfo.system).map(([key, value]) => ({ + label: key, + value: String(value), + })), + ), + ); + + // Node.js Information + events.push( + section( + 'Node.js Information', + Object.entries(doctorInfo.node).map(([key, value]) => `${key}: ${value}`), + ), + ); + + // Process Tree + const processTreeLines: string[] = [ + `Running under Xcode: ${doctorInfo.runningUnderXcode ? 'Yes' : 'No'}`, + ]; + if (doctorInfo.processTree.length > 0) { + for (const entry of doctorInfo.processTree) { + processTreeLines.push( + `${entry.pid} (ppid ${entry.ppid}): ${entry.name}${entry.command ? ` -- ${entry.command}` : ''}`, + ); } - return coloredLines.join('\n'); + } else { + processTreeLines.push('(unavailable)'); + } + if (doctorInfo.processTreeError) { + processTreeLines.push(`Error: ${doctorInfo.processTreeError}`); + } + events.push(section('Process Tree', processTreeLines)); + + // Xcode Information + if ('error' in doctorInfo.xcode) { + events.push( + section('Xcode Information', [`Error: ${doctorInfo.xcode.error}`], { icon: 'cross' }), + ); + } else { + events.push( + section( + 'Xcode Information', + Object.entries(doctorInfo.xcode).map(([key, value]) => `${key}: ${value}`), + ), + ); } - const outputLines = []; + // Dependencies + events.push( + section( + 'Dependencies', + Object.entries(doctorInfo.dependencies).map( + ([binary, status]) => + `${binary}: ${status.available ? (status.version ?? 'Available') : 'Not found'}`, + ), + ), + ); - // Only show ASCII logo when explicitly requested (CLI usage) - if (showAsciiLogo) { - outputLines.push(colorizeAsciiArt(asciiLogo)); + // Environment Variables + const envLines = Object.entries(doctorInfo.environmentVariables) + .filter(([key]) => key !== 'PATH' && key !== 'PYTHONPATH') + .map(([key, value]) => `${key}: ${value ?? '(not set)'}`); + events.push(section('Environment Variables', envLines)); + + // PATH + const pathValue = doctorInfo.environmentVariables.PATH ?? '(not set)'; + events.push(section('PATH', pathValue.split(':'))); + + // UI Automation (axe) + const axeLines: string[] = [ + `Available: ${doctorInfo.features.axe.available ? 'Yes' : 'No'}`, + `UI Automation Supported: ${doctorInfo.features.axe.uiAutomationSupported ? 'Yes' : 'No'}`, + `Simulator Video Capture Supported (AXe >= 1.1.0): ${doctorInfo.features.axe.videoCaptureSupported ? 'Yes' : 'No'}`, + `UI-Debugger Guard Mode: ${uiDebuggerGuardMode}`, + ]; + events.push(section('UI Automation (axe)', axeLines)); + + // Incremental Builds + const makefileStatus = + doctorInfo.features.xcodemake.makefileExists === null + ? '(not checked: incremental builds disabled)' + : doctorInfo.features.xcodemake.makefileExists + ? 'Yes' + : 'No'; + events.push( + section('Incremental Builds', [ + `Enabled: ${doctorInfo.features.xcodemake.enabled ? 'Yes' : 'No'}`, + `xcodemake Binary Available: ${doctorInfo.features.xcodemake.binaryAvailable ? 'Yes' : 'No'}`, + `Makefile exists (cwd): ${makefileStatus}`, + ]), + ); + + // Mise Integration + events.push( + section('Mise Integration', [ + `Running under mise: ${doctorInfo.features.mise.running_under_mise ? 'Yes' : 'No'}`, + `Mise available: ${doctorInfo.features.mise.available ? 'Yes' : 'No'}`, + ]), + ); + + // Debugger Backend (DAP) + const debuggerLines: string[] = [ + `lldb-dap available: ${doctorInfo.features.debugger.dap.available ? 'Yes' : 'No'}`, + `Selected backend: ${doctorInfo.features.debugger.dap.selected}`, + ]; + if (dapSelected && !lldbDapAvailable) { + debuggerLines.push( + 'Warning: DAP backend selected but lldb-dap not available. Set XCODEBUILDMCP_DEBUGGER_BACKEND=lldb-cli to use the CLI backend.', + ); + } + events.push(section('Debugger Backend (DAP)', debuggerLines)); + + // Manifest Tool Inventory + if ('error' in doctorInfo.manifestTools) { + events.push( + section('Manifest Tool Inventory', [`Error: ${doctorInfo.manifestTools.error}`], { + icon: 'cross', + }), + ); + } else { + events.push( + section('Manifest Tool Inventory', [ + `Total Unique Tools: ${doctorInfo.manifestTools.totalTools}`, + `Workflow Count: ${doctorInfo.manifestTools.workflowCount}`, + ...Object.entries(doctorInfo.manifestTools.toolsByWorkflow).map( + ([workflow, count]) => `${workflow}: ${count} tools`, + ), + ]), + ); } - outputLines.push( - 'XcodeBuildMCP Doctor', - `\nGenerated: ${doctorInfo.timestamp}`, - `Server Version: ${doctorInfo.serverVersion}`, - `Output Mode: ${params.nonRedacted ? 'โš ๏ธ Non-redacted (opt-in)' : 'Redacted (default)'}`, + // Runtime Tool Registration + const runtimeLines: string[] = [ + `Enabled Workflows: ${runtimeRegistration.enabledWorkflows.length}`, + `Registered Tools: ${runtimeRegistration.registeredToolCount}`, + ]; + if (runtimeNote) { + runtimeLines.push(`Note: ${runtimeNote}`); + } + if (runtimeRegistration.enabledWorkflows.length > 0) { + runtimeLines.push(`Workflows: ${runtimeRegistration.enabledWorkflows.join(', ')}`); + } + events.push(section('Runtime Tool Registration', runtimeLines)); + + // Xcode IDE Bridge + if (doctorInfo.xcodeToolsBridge.available) { + events.push( + section('Xcode IDE Bridge (mcpbridge)', [ + `Workflow enabled: ${doctorInfo.xcodeToolsBridge.workflowEnabled ? 'Yes' : 'No'}`, + `mcpbridge path: ${doctorInfo.xcodeToolsBridge.bridgePath ?? '(not found)'}`, + `Xcode running: ${doctorInfo.xcodeToolsBridge.xcodeRunning ?? '(unknown)'}`, + `Connected: ${doctorInfo.xcodeToolsBridge.connected ? 'Yes' : 'No'}`, + `Bridge PID: ${doctorInfo.xcodeToolsBridge.bridgePid ?? '(none)'}`, + `Proxied tools: ${doctorInfo.xcodeToolsBridge.proxiedToolCount}`, + `Last error: ${doctorInfo.xcodeToolsBridge.lastError ?? '(none)'}`, + 'Note: Bridge debug tools (status/sync/disconnect) are only registered when debug: true', + ]), + ); + } else { + events.push( + section('Xcode IDE Bridge (mcpbridge)', [ + `Unavailable: ${doctorInfo.xcodeToolsBridge.reason}`, + ]), + ); + } + + // Tool Availability Summary + const buildToolsAvailable = !('error' in doctorInfo.xcode); + const incrementalStatus = + doctorInfo.features.xcodemake.binaryAvailable && doctorInfo.features.xcodemake.enabled + ? 'Available & Enabled' + : doctorInfo.features.xcodemake.binaryAvailable + ? 'Available but Disabled' + : 'Not available'; + events.push( + section('Tool Availability Summary', [ + `Build Tools: ${buildToolsAvailable ? 'Available' : 'Not available'}`, + `UI Automation Tools: ${doctorInfo.features.axe.uiAutomationSupported ? 'Available' : 'Not available'}`, + `Incremental Build Support: ${incrementalStatus}`, + ]), ); - const formattedOutput = [ - ...outputLines, - - `\n## System Information`, - ...Object.entries(doctorInfo.system).map(([key, value]) => `- ${key}: ${value}`), - - `\n## Node.js Information`, - ...Object.entries(doctorInfo.node).map(([key, value]) => `- ${key}: ${value}`), - - `\n## Process Tree`, - `- Running under Xcode: ${doctorInfo.runningUnderXcode ? 'โœ… Yes' : 'โŒ No'}`, - ...(doctorInfo.processTree.length > 0 - ? doctorInfo.processTree.map( - (entry) => - `- ${entry.pid} (ppid ${entry.ppid}): ${entry.name}${ - entry.command ? ` โ€” ${entry.command}` : '' - }`, - ) - : ['- (unavailable)']), - ...(doctorInfo.processTreeError ? [`- Error: ${doctorInfo.processTreeError}`] : []), - - `\n## Xcode Information`, - ...('error' in doctorInfo.xcode - ? [`- Error: ${doctorInfo.xcode.error}`] - : Object.entries(doctorInfo.xcode).map(([key, value]) => `- ${key}: ${value}`)), - - `\n## Dependencies`, - ...Object.entries(doctorInfo.dependencies).map( - ([binary, status]) => - `- ${binary}: ${status.available ? `โœ… ${status.version ?? 'Available'}` : 'โŒ Not found'}`, - ), + // Sentry + events.push( + section('Sentry', [ + `Sentry enabled: ${doctorInfo.environmentVariables.SENTRY_DISABLED !== 'true' ? 'Yes' : 'No'}`, + ]), + ); - `\n## Environment Variables`, - ...Object.entries(doctorInfo.environmentVariables) - .filter(([key]) => key !== 'PATH' && key !== 'PYTHONPATH') // These are too long, handle separately - .map(([key, value]) => `- ${key}: ${value ?? '(not set)'}`), - - `\n### PATH`, - `\`\`\``, - `${doctorInfo.environmentVariables.PATH ?? '(not set)'}`.split(':').join('\n'), - `\`\`\``, - - `\n## Feature Status`, - `\n### UI Automation (axe)`, - `- Available: ${doctorInfo.features.axe.available ? 'โœ… Yes' : 'โŒ No'}`, - `- UI Automation Supported: ${doctorInfo.features.axe.uiAutomationSupported ? 'โœ… Yes' : 'โŒ No'}`, - `- Simulator Video Capture Supported (AXe >= 1.1.0): ${doctorInfo.features.axe.videoCaptureSupported ? 'โœ… Yes' : 'โŒ No'}`, - `- UI-Debugger Guard Mode: ${uiDebuggerGuardMode}`, - - `\n### Incremental Builds`, - `- Enabled: ${doctorInfo.features.xcodemake.enabled ? 'โœ… Yes' : 'โŒ No'}`, - `- xcodemake Binary Available: ${doctorInfo.features.xcodemake.binaryAvailable ? 'โœ… Yes' : 'โŒ No'}`, - `- Makefile exists (cwd): ${doctorInfo.features.xcodemake.makefileExists === null ? '(not checked: incremental builds disabled)' : doctorInfo.features.xcodemake.makefileExists ? 'โœ… Yes' : 'โŒ No'}`, - - `\n### Mise Integration`, - `- Running under mise: ${doctorInfo.features.mise.running_under_mise ? 'โœ… Yes' : 'โŒ No'}`, - `- Mise available: ${doctorInfo.features.mise.available ? 'โœ… Yes' : 'โŒ No'}`, - - `\n### Debugger Backend (DAP)`, - `- lldb-dap available: ${doctorInfo.features.debugger.dap.available ? 'โœ… Yes' : 'โŒ No'}`, - `- Selected backend: ${doctorInfo.features.debugger.dap.selected}`, - ...(dapSelected && !lldbDapAvailable - ? [ - `- Warning: DAP backend selected but lldb-dap not available. Set XCODEBUILDMCP_DEBUGGER_BACKEND=lldb-cli to use the CLI backend.`, - ] - : []), - - `\n### Manifest Tool Inventory`, - ...('error' in doctorInfo.manifestTools - ? [`- Error: ${doctorInfo.manifestTools.error}`] - : [ - `- Total Unique Tools: ${doctorInfo.manifestTools.totalTools}`, - `- Workflow Count: ${doctorInfo.manifestTools.workflowCount}`, - ...Object.entries(doctorInfo.manifestTools.toolsByWorkflow).map( - ([workflow, count]) => `- ${workflow}: ${count} tools`, - ), - ]), - - `\n### Runtime Tool Registration`, - `- Enabled Workflows: ${runtimeRegistration.enabledWorkflows.length}`, - `- Registered Tools: ${runtimeRegistration.registeredToolCount}`, - ...(runtimeNote ? [`- Note: ${runtimeNote}`] : []), - ...(runtimeRegistration.enabledWorkflows.length > 0 - ? [`- Workflows: ${runtimeRegistration.enabledWorkflows.join(', ')}`] - : []), - - `\n### Xcode IDE Bridge (mcpbridge)`, - ...(doctorInfo.xcodeToolsBridge.available - ? [ - `- Workflow enabled: ${doctorInfo.xcodeToolsBridge.workflowEnabled ? 'โœ… Yes' : 'โŒ No'}`, - `- mcpbridge path: ${doctorInfo.xcodeToolsBridge.bridgePath ?? '(not found)'}`, - `- Xcode running: ${doctorInfo.xcodeToolsBridge.xcodeRunning ?? '(unknown)'}`, - `- Connected: ${doctorInfo.xcodeToolsBridge.connected ? 'โœ… Yes' : 'โŒ No'}`, - `- Bridge PID: ${doctorInfo.xcodeToolsBridge.bridgePid ?? '(none)'}`, - `- Proxied tools: ${doctorInfo.xcodeToolsBridge.proxiedToolCount}`, - `- Last error: ${doctorInfo.xcodeToolsBridge.lastError ?? '(none)'}`, - `- Note: Bridge debug tools (status/sync/disconnect) are only registered when debug: true`, - ] - : [`- Unavailable: ${doctorInfo.xcodeToolsBridge.reason}`]), - - `\n## Tool Availability Summary`, - `- Build Tools: ${!('error' in doctorInfo.xcode) ? '\u2705 Available' : '\u274c Not available'}`, - `- UI Automation Tools: ${doctorInfo.features.axe.uiAutomationSupported ? '\u2705 Available' : '\u274c Not available'}`, - `- Incremental Build Support: ${doctorInfo.features.xcodemake.binaryAvailable && doctorInfo.features.xcodemake.enabled ? '\u2705 Available & Enabled' : doctorInfo.features.xcodemake.binaryAvailable ? '\u2705 Available but Disabled' : '\u274c Not available'}`, - - `\n## Sentry`, - `- Sentry enabled: ${doctorInfo.environmentVariables.SENTRY_DISABLED !== 'true' ? 'โœ… Yes' : 'โŒ No'}`, - - `\n## Troubleshooting Tips`, - `- If UI automation tools are not available, install axe: \`brew tap cameroncooke/axe && brew install axe\``, - `- If incremental build support is not available, install xcodemake (https://github.com/cameroncooke/xcodemake) and ensure it is executable and available in your PATH`, - `- To enable xcodemake, set environment variable: \`export INCREMENTAL_BUILDS_ENABLED=1\``, - `- For mise integration, follow instructions in the README.md file`, - ].join('\n'); - - const result: ToolResponse = { - content: [ - { - type: 'text', - text: formattedOutput, - }, - ], - }; + // Troubleshooting Tips + events.push( + section('Troubleshooting Tips', [ + 'If UI automation tools are not available, install axe: brew tap cameroncooke/axe && brew install axe', + 'If incremental build support is not available, install xcodemake (https://github.com/cameroncooke/xcodemake) and ensure it is executable and available in your PATH', + 'To enable xcodemake, set environment variable: export INCREMENTAL_BUILDS_ENABLED=1', + 'For mise integration, follow instructions in the README.md file', + ]), + ); + + events.push(statusLine('success', 'Doctor diagnostics complete')); + + const result = toolResponse(events); // Restore previous silence flag if (prevSilence === undefined) { delete process.env.XCODEBUILDMCP_SILENCE_LOGS; diff --git a/src/mcp/tools/logging/__tests__/start_device_log_cap.test.ts b/src/mcp/tools/logging/__tests__/start_device_log_cap.test.ts index ccdbfb2c..0d03c1a7 100644 --- a/src/mcp/tools/logging/__tests__/start_device_log_cap.test.ts +++ b/src/mcp/tools/logging/__tests__/start_device_log_cap.test.ts @@ -19,6 +19,7 @@ import { initConfigStore, type RuntimeConfigOverrides, } from '../../../../utils/config-store.ts'; +import type { ToolResponse } from '../../../../types/common.ts'; const cwd = '/repo'; @@ -27,6 +28,13 @@ async function initConfigStoreForTest(overrides?: RuntimeConfigOverrides): Promi await initConfigStore({ cwd, fs: createMockFileSystemExecutor(), overrides }); } +function allText(response: ToolResponse): string { + return response.content + .filter((item) => item.type === 'text') + .map((item) => item.text) + .join('\n'); +} + type Mutable = { -readonly [K in keyof T]: T[K]; }; @@ -80,8 +88,9 @@ describe('start_device_log_cap plugin', () => { const result = await handler({}); expect(result.isError).toBe(true); - expect(result.content[0].text).toContain('Missing required session defaults'); - expect(result.content[0].text).toContain('Provide deviceId and bundleId'); + const text = allText(result); + expect(text).toContain('Missing required session defaults'); + expect(text).toContain('Provide deviceId and bundleId'); }); }); @@ -111,8 +120,9 @@ describe('start_device_log_cap plugin', () => { mockFileSystemExecutor, ); - expect(result.content[0].text).toMatch(/โœ… Device log capture started successfully/); - expect(result.content[0].text).toMatch(/Session ID: [a-f0-9-]{36}/); + const text = allText(result); + expect(text).toContain('Log capture started'); + expect(text).toMatch(/Session ID: [a-f0-9-]{36}/); expect(result.isError ?? false).toBe(false); }); @@ -141,12 +151,10 @@ describe('start_device_log_cap plugin', () => { mockFileSystemExecutor, ); - expect(result.content[0].text).toContain( - 'Do not call launch_app_device during this capture session', - ); - expect(result.content[0].text).toContain('Interact with your app'); - const responseText = String(result.content[0].text); - const sessionIdMatch = responseText.match(/Session ID: ([a-f0-9-]{36})/); + const text = allText(result); + expect(text).toContain('Do not call launch_app_device during this capture session'); + expect(text).toContain('Interact with your app'); + const sessionIdMatch = text.match(/Session ID: ([a-f0-9-]{36})/); expect(sessionIdMatch).not.toBeNull(); const sessionId = sessionIdMatch?.[1]; expect(typeof sessionId).toBe('string'); @@ -214,7 +222,8 @@ describe('start_device_log_cap plugin', () => { const result = await resultPromise; expect(result.isError).toBe(true); - expect(result.content[0].text).toContain('Provide a valid bundle identifier'); + const text = allText(result); + expect(text).toContain('Provide a valid bundle identifier'); expect(activeDeviceLogSessions.size).toBe(0); expect(createdLogPath).not.toBe(''); }); @@ -299,7 +308,8 @@ describe('start_device_log_cap plugin', () => { ); expect(result.isError).toBe(true); - expect(result.content[0].text).toContain('Provide a valid bundle identifier'); + const text = allText(result); + expect(text).toContain('Provide a valid bundle identifier'); expect(jsonPathSeen).not.toBe(''); expect(removedJsonPath).toBe(jsonPathSeen); expect(activeDeviceLogSessions.size).toBe(0); @@ -382,7 +392,8 @@ describe('start_device_log_cap plugin', () => { mockFileSystemExecutor, ); - expect(result.content[0].text).toContain('Device log capture started successfully'); + const text = allText(result); + expect(text).toContain('Log capture started'); expect(result.isError ?? false).toBe(false); expect(jsonPathSeen).not.toBe(''); expect(removedJsonPath).toBe(jsonPathSeen); @@ -413,15 +424,10 @@ describe('start_device_log_cap plugin', () => { mockFileSystemExecutor, ); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: 'Failed to start device log capture: Permission denied', - }, - ], - isError: true, - }); + expect(result.isError).toBe(true); + const text = allText(result); + expect(text).toContain('Failed to start device log capture'); + expect(text).toContain('Permission denied'); }); it('should handle file write failure', async () => { @@ -451,15 +457,10 @@ describe('start_device_log_cap plugin', () => { mockFileSystemExecutor, ); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: 'Failed to start device log capture: Disk full', - }, - ], - isError: true, - }); + expect(result.isError).toBe(true); + const text = allText(result); + expect(text).toContain('Failed to start device log capture'); + expect(text).toContain('Disk full'); }); it('should handle spawn process error', async () => { @@ -484,15 +485,10 @@ describe('start_device_log_cap plugin', () => { mockFileSystemExecutor, ); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: 'Failed to start device log capture: Command not found', - }, - ], - isError: true, - }); + expect(result.isError).toBe(true); + const text = allText(result); + expect(text).toContain('Failed to start device log capture'); + expect(text).toContain('Command not found'); }); it('should handle string error objects', async () => { @@ -517,15 +513,10 @@ describe('start_device_log_cap plugin', () => { mockFileSystemExecutor, ); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: 'Failed to start device log capture: String error message', - }, - ], - isError: true, - }); + expect(result.isError).toBe(true); + const text = allText(result); + expect(text).toContain('Failed to start device log capture'); + expect(text).toContain('String error message'); }); }); }); diff --git a/src/mcp/tools/logging/__tests__/start_sim_log_cap.test.ts b/src/mcp/tools/logging/__tests__/start_sim_log_cap.test.ts index 2a3fcc92..cde77ade 100644 --- a/src/mcp/tools/logging/__tests__/start_sim_log_cap.test.ts +++ b/src/mcp/tools/logging/__tests__/start_sim_log_cap.test.ts @@ -5,6 +5,14 @@ import { describe, it, expect } from 'vitest'; import * as z from 'zod'; import { schema, handler, start_sim_log_capLogic } from '../start_sim_log_cap.ts'; import { createMockExecutor } from '../../../../test-utils/mock-executors.ts'; +import type { ToolResponse } from '../../../../types/common.ts'; + +function allText(response: ToolResponse): string { + return response.content + .filter((item) => item.type === 'text') + .map((item) => item.text) + .join('\n'); +} describe('start_sim_log_cap plugin', () => { // Reset any test state if needed @@ -80,7 +88,9 @@ describe('start_sim_log_cap plugin', () => { ); expect(result.isError).toBe(true); - expect(result.content[0].text).toBe('Error starting log capture: Permission denied'); + const text = allText(result); + expect(text).toContain('Error starting log capture'); + expect(text).toContain('Permission denied'); }); it('should return success with session ID when log capture starts successfully', async () => { @@ -105,9 +115,10 @@ describe('start_sim_log_cap plugin', () => { ); expect(result.isError).toBeUndefined(); - expect(result.content[0].text).toBe( - 'Log capture started successfully. Session ID: test-uuid-123.\n\nOnly structured logs from the app subsystem are being captured.\n\nInteract with your simulator and app, then stop capture to retrieve logs.', - ); + const text = allText(result); + expect(text).toContain('test-uuid-123'); + expect(text).toContain('app subsystem'); + expect(text).toContain('stop capture to retrieve logs'); expect(result.nextStepParams?.stop_sim_log_cap).toBeDefined(); expect(result.nextStepParams?.stop_sim_log_cap).toMatchObject({ logSessionId: 'test-uuid-123', @@ -136,8 +147,9 @@ describe('start_sim_log_cap plugin', () => { ); expect(result.isError).toBeUndefined(); - expect(result.content[0].text).toContain('SwiftUI logs'); - expect(result.content[0].text).toContain('Self._printChanges()'); + const text = allText(result); + expect(text).toContain('SwiftUI logs'); + expect(text).toContain('Self._printChanges()'); }); it('should indicate all logs capture when subsystemFilter is all', async () => { @@ -162,7 +174,8 @@ describe('start_sim_log_cap plugin', () => { ); expect(result.isError).toBeUndefined(); - expect(result.content[0].text).toContain('all system logs'); + const text = allText(result); + expect(text).toContain('all system logs'); }); it('should indicate custom subsystems when array is provided', async () => { @@ -187,8 +200,9 @@ describe('start_sim_log_cap plugin', () => { ); expect(result.isError).toBeUndefined(); - expect(result.content[0].text).toContain('com.apple.UIKit'); - expect(result.content[0].text).toContain('com.apple.CoreData'); + const text = allText(result); + expect(text).toContain('com.apple.UIKit'); + expect(text).toContain('com.apple.CoreData'); }); it('should indicate console capture when captureConsole is true', async () => { @@ -213,8 +227,9 @@ describe('start_sim_log_cap plugin', () => { logCaptureStub, ); - expect(result.content[0].text).toContain('Your app was relaunched to capture console output'); - expect(result.content[0].text).toContain('test-uuid-123'); + const text = allText(result); + expect(text).toContain('app was relaunched to capture console output'); + expect(text).toContain('test-uuid-123'); }); it('should create correct spawn commands for console capture', async () => { diff --git a/src/mcp/tools/logging/__tests__/stop_device_log_cap.test.ts b/src/mcp/tools/logging/__tests__/stop_device_log_cap.test.ts index 2ec1888f..f212a18e 100644 --- a/src/mcp/tools/logging/__tests__/stop_device_log_cap.test.ts +++ b/src/mcp/tools/logging/__tests__/stop_device_log_cap.test.ts @@ -10,9 +10,17 @@ import { type DeviceLogSession, } from '../../../../utils/log-capture/device-log-sessions.ts'; import { createMockFileSystemExecutor } from '../../../../test-utils/mock-executors.ts'; +import type { ToolResponse } from '../../../../types/common.ts'; // Note: Logger is allowed to execute normally (integration testing pattern) +function allText(response: ToolResponse): string { + return response.content + .filter((item) => item.type === 'text') + .map((item) => item.text) + .join('\n'); +} + describe('stop_device_log_cap plugin', () => { beforeEach(() => { // Clear actual active sessions before each test @@ -80,9 +88,10 @@ describe('stop_device_log_cap plugin', () => { mockFileSystem, ); - expect(result.content[0].text).toBe( - 'Failed to stop device log capture session device-log-00008110-001A2C3D4E5F-io.sentry.MyApp: Device log capture session not found: device-log-00008110-001A2C3D4E5F-io.sentry.MyApp', - ); + const text = allText(result); + expect(text).toContain('Failed to stop device log capture session'); + expect(text).toContain('device-log-00008110-001A2C3D4E5F-io.sentry.MyApp'); + expect(text).toContain('Device log capture session not found'); expect(result.isError).toBe(true); }); @@ -118,15 +127,11 @@ describe('stop_device_log_cap plugin', () => { mockFileSystem, ); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: `โœ… Device log capture session stopped successfully\n\nSession ID: ${testSessionId}\n\n--- Captured Logs ---\n${testLogContent}`, - }, - ], - }); expect(result.isError).toBeUndefined(); + const text = allText(result); + expect(text).toContain(testSessionId); + expect(text).toContain(testLogContent); + expect(text).toContain('Log capture stopped'); expect(testProcess.killCalls).toEqual(['SIGTERM']); expect(activeDeviceLogSessions.has(testSessionId)).toBe(false); }); @@ -163,14 +168,11 @@ describe('stop_device_log_cap plugin', () => { mockFileSystem, ); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: `โœ… Device log capture session stopped successfully\n\nSession ID: ${testSessionId}\n\n--- Captured Logs ---\n${testLogContent}`, - }, - ], - }); + expect(result.isError).toBeUndefined(); + const text = allText(result); + expect(text).toContain(testSessionId); + expect(text).toContain(testLogContent); + expect(text).toContain('Log capture stopped'); expect(testProcess.killCalls).toEqual([]); // Should not kill already killed process }); @@ -204,15 +206,10 @@ describe('stop_device_log_cap plugin', () => { mockFileSystem, ); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: `Failed to stop device log capture session ${testSessionId}: Log file not found: ${testLogFilePath}`, - }, - ], - isError: true, - }); + expect(result.isError).toBe(true); + const text = allText(result); + expect(text).toContain(`Failed to stop device log capture session ${testSessionId}`); + expect(text).toContain('Log file not found'); expect(activeDeviceLogSessions.has(testSessionId)).toBe(false); // Session still removed }); @@ -249,15 +246,10 @@ describe('stop_device_log_cap plugin', () => { mockFileSystem, ); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: `Failed to stop device log capture session ${testSessionId}: Read permission denied`, - }, - ], - isError: true, - }); + expect(result.isError).toBe(true); + const text = allText(result); + expect(text).toContain(`Failed to stop device log capture session ${testSessionId}`); + expect(text).toContain('Read permission denied'); }); it('should handle string error objects', async () => { @@ -293,15 +285,10 @@ describe('stop_device_log_cap plugin', () => { mockFileSystem, ); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: `Failed to stop device log capture session ${testSessionId}: String error message`, - }, - ], - isError: true, - }); + expect(result.isError).toBe(true); + const text = allText(result); + expect(text).toContain(`Failed to stop device log capture session ${testSessionId}`); + expect(text).toContain('String error message'); }); }); }); diff --git a/src/mcp/tools/logging/__tests__/stop_sim_log_cap.test.ts b/src/mcp/tools/logging/__tests__/stop_sim_log_cap.test.ts index ceefa5b2..f288e6f0 100644 --- a/src/mcp/tools/logging/__tests__/stop_sim_log_cap.test.ts +++ b/src/mcp/tools/logging/__tests__/stop_sim_log_cap.test.ts @@ -18,6 +18,14 @@ import { createMockExecutor, createMockFileSystemExecutor, } from '../../../../test-utils/mock-executors.ts'; +import type { ToolResponse } from '../../../../types/common.ts'; + +function allText(response: ToolResponse): string { + return response.content + .filter((item) => item.type === 'text') + .map((item) => item.text) + .join('\n'); +} describe('stop_sim_log_cap plugin', () => { const mockExecutor = createMockExecutor({ success: true, output: '' }); @@ -56,8 +64,6 @@ describe('stop_sim_log_cap plugin', () => { describe('Input Validation', () => { it('should handle null logSessionId (validation handled by framework)', async () => { - // With typed tool factory, invalid params won't reach the logic function - // This test now validates that the logic function works with valid empty strings const stopLogCaptureStub = async () => ({ logContent: 'Log content for empty session', error: undefined, @@ -73,14 +79,12 @@ describe('stop_sim_log_cap plugin', () => { ); expect(result.isError).toBeUndefined(); - expect(result.content[0].text).toBe( - 'Log capture session stopped successfully. Log content follows:\n\nLog content for empty session', - ); + const text = allText(result); + expect(text).toContain('Log content for empty session'); + expect(text).toContain('Log capture stopped'); }); it('should handle undefined logSessionId (validation handled by framework)', async () => { - // With typed tool factory, invalid params won't reach the logic function - // This test now validates that the logic function works with valid empty strings const stopLogCaptureStub = async () => ({ logContent: 'Log content for empty session', error: undefined, @@ -96,9 +100,9 @@ describe('stop_sim_log_cap plugin', () => { ); expect(result.isError).toBeUndefined(); - expect(result.content[0].text).toBe( - 'Log capture session stopped successfully. Log content follows:\n\nLog content for empty session', - ); + const text = allText(result); + expect(text).toContain('Log content for empty session'); + expect(text).toContain('Log capture stopped'); }); it('should handle empty string logSessionId', async () => { @@ -117,9 +121,9 @@ describe('stop_sim_log_cap plugin', () => { ); expect(result.isError).toBeUndefined(); - expect(result.content[0].text).toBe( - 'Log capture session stopped successfully. Log content follows:\n\nLog content for empty session', - ); + const text = allText(result); + expect(text).toContain('Log content for empty session'); + expect(text).toContain('Log capture stopped'); }); }); @@ -142,9 +146,10 @@ describe('stop_sim_log_cap plugin', () => { expect(capturedSessionId).toBe('test-session-id'); expect(result.isError).toBeUndefined(); - expect(result.content[0].text).toBe( - 'Log capture session test-session-id stopped successfully. Log content follows:\n\nMock log content from file', - ); + const text = allText(result); + expect(text).toContain('test-session-id'); + expect(text).toContain('Mock log content from file'); + expect(text).toContain('Log capture stopped'); }); it('should call stopLogCapture with different session ID', async () => { @@ -165,9 +170,10 @@ describe('stop_sim_log_cap plugin', () => { expect(capturedSessionId).toBe('different-session-id'); expect(result.isError).toBeUndefined(); - expect(result.content[0].text).toBe( - 'Log capture session different-session-id stopped successfully. Log content follows:\n\nDifferent log content', - ); + const text = allText(result); + expect(text).toContain('different-session-id'); + expect(text).toContain('Different log content'); + expect(text).toContain('Log capture stopped'); }); }); @@ -188,9 +194,10 @@ describe('stop_sim_log_cap plugin', () => { ); expect(result.isError).toBeUndefined(); - expect(result.content[0].text).toBe( - 'Log capture session test-session-id stopped successfully. Log content follows:\n\nMock log content from file', - ); + const text = allText(result); + expect(text).toContain('test-session-id'); + expect(text).toContain('Mock log content from file'); + expect(text).toContain('Log capture stopped'); }); it('should handle empty log content', async () => { @@ -209,9 +216,9 @@ describe('stop_sim_log_cap plugin', () => { ); expect(result.isError).toBeUndefined(); - expect(result.content[0].text).toBe( - 'Log capture session test-session-id stopped successfully. Log content follows:\n\n', - ); + const text = allText(result); + expect(text).toContain('test-session-id'); + expect(text).toContain('Log capture stopped'); }); it('should handle multiline log content', async () => { @@ -230,9 +237,11 @@ describe('stop_sim_log_cap plugin', () => { ); expect(result.isError).toBeUndefined(); - expect(result.content[0].text).toBe( - 'Log capture session test-session-id stopped successfully. Log content follows:\n\nLine 1\nLine 2\nLine 3', - ); + const text = allText(result); + expect(text).toContain('Line 1'); + expect(text).toContain('Line 2'); + expect(text).toContain('Line 3'); + expect(text).toContain('Log capture stopped'); }); it('should handle log capture stop errors for non-existent session', async () => { @@ -251,9 +260,9 @@ describe('stop_sim_log_cap plugin', () => { ); expect(result.isError).toBe(true); - expect(result.content[0].text).toBe( - 'Error stopping log capture session non-existent-session: Log capture session not found: non-existent-session', - ); + const text = allText(result); + expect(text).toContain('Error stopping log capture session non-existent-session'); + expect(text).toContain('Log capture session not found'); }); it('should handle file read errors', async () => { @@ -272,9 +281,8 @@ describe('stop_sim_log_cap plugin', () => { ); expect(result.isError).toBe(true); - expect(result.content[0].text).toContain( - 'Error stopping log capture session test-session-id:', - ); + const text = allText(result); + expect(text).toContain('Error stopping log capture session test-session-id'); }); it('should handle permission errors', async () => { @@ -293,9 +301,8 @@ describe('stop_sim_log_cap plugin', () => { ); expect(result.isError).toBe(true); - expect(result.content[0].text).toContain( - 'Error stopping log capture session test-session-id:', - ); + const text = allText(result); + expect(text).toContain('Error stopping log capture session test-session-id'); }); it('should handle various error types', async () => { @@ -314,9 +321,8 @@ describe('stop_sim_log_cap plugin', () => { ); expect(result.isError).toBe(true); - expect(result.content[0].text).toContain( - 'Error stopping log capture session test-session-id:', - ); + const text = allText(result); + expect(text).toContain('Error stopping log capture session test-session-id'); }); }); }); diff --git a/src/mcp/tools/logging/start_device_log_cap.ts b/src/mcp/tools/logging/start_device_log_cap.ts index cf2fa0d5..728d6faa 100644 --- a/src/mcp/tools/logging/start_device_log_cap.ts +++ b/src/mcp/tools/logging/start_device_log_cap.ts @@ -19,6 +19,8 @@ import { createSessionAwareTool, getSessionAwareToolSchemaShape, } from '../../../utils/typed-tool-factory.ts'; +import { toolResponse } from '../../../utils/tool-response.ts'; +import { header, section, statusLine } from '../../../utils/tool-event-builders.ts'; import { activeDeviceLogSessions, type DeviceLogSession, @@ -601,7 +603,6 @@ async function cleanOldDeviceLogs(fileSystemExecutor: FileSystemExecutor): Promi ); } -// Define schema as ZodObject const startDeviceLogCapSchema = z.object({ deviceId: z.string().describe('UDID of the device (obtained from list_devices)'), bundleId: z.string(), @@ -612,7 +613,6 @@ const publicSchemaObject = startDeviceLogCapSchema.omit({ bundleId: true, } as const); -// Use z.infer for type safety type StartDeviceLogCapParams = z.infer; /** @@ -634,28 +634,35 @@ export async function start_device_log_capLogic( ); if (error) { - return { - content: [ - { - type: 'text', - text: `Failed to start device log capture: ${error}`, - }, - ], - isError: true, - }; + return toolResponse([ + header('Start Log Capture', [ + { label: 'Device', value: deviceId }, + { label: 'Bundle ID', value: bundleId }, + ]), + statusLine('error', `Failed to start device log capture: ${error}`), + ]); } - return { - content: [ - { - type: 'text', - text: `โœ… Device log capture started successfully\n\nSession ID: ${sessionId}\n\nNote: The app has been launched on the device with console output capture enabled.\nDo not call launch_app_device during this capture session; relaunching can interrupt captured output.\n\nInteract with your app on the device, then stop capture to retrieve logs.`, - }, + return toolResponse( + [ + header('Start Log Capture', [ + { label: 'Device', value: deviceId }, + { label: 'Bundle ID', value: bundleId }, + ]), + section('Details', [ + `Session ID: ${sessionId}`, + 'The app has been launched on the device with console output capture enabled.', + 'Do not call launch_app_device during this capture session; relaunching can interrupt captured output.', + 'Interact with your app on the device, then stop capture to retrieve logs.', + ]), + statusLine('success', 'Log capture started.'), ], - nextStepParams: { - stop_device_log_cap: { logSessionId: sessionId }, + { + nextStepParams: { + stop_device_log_cap: { logSessionId: sessionId }, + }, }, - }; + ); } export const schema = getSessionAwareToolSchemaShape({ diff --git a/src/mcp/tools/logging/start_sim_log_cap.ts b/src/mcp/tools/logging/start_sim_log_cap.ts index 3ea427bd..c159d611 100644 --- a/src/mcp/tools/logging/start_sim_log_cap.ts +++ b/src/mcp/tools/logging/start_sim_log_cap.ts @@ -9,14 +9,14 @@ import { startLogCapture } from '../../../utils/log-capture/index.ts'; import type { CommandExecutor } from '../../../utils/command.ts'; import { getDefaultCommandExecutor } from '../../../utils/command.ts'; import type { ToolResponse } from '../../../types/common.ts'; -import { createTextContent } from '../../../types/common.ts'; import type { SubsystemFilter } from '../../../utils/log_capture.ts'; import { createSessionAwareTool, getSessionAwareToolSchemaShape, } from '../../../utils/typed-tool-factory.ts'; +import { toolResponse } from '../../../utils/tool-response.ts'; +import { header, section, statusLine } from '../../../utils/tool-event-builders.ts'; -// Define schema as ZodObject const startSimLogCapSchema = z.object({ simulatorId: z .uuid() @@ -29,7 +29,6 @@ const startSimLogCapSchema = z.object({ .describe('app|all|swiftui|[subsystem]'), }); -// Use z.infer for type safety type StartSimLogCapParams = z.infer; function buildSubsystemFilterDescription(subsystemFilter: SubsystemFilter): string { @@ -64,24 +63,40 @@ export async function start_sim_log_capLogic( }; const { sessionId, error } = await logCaptureFunction(logCaptureParams, _executor); if (error) { - return { - content: [createTextContent(`Error starting log capture: ${error}`)], - isError: true, - }; + return toolResponse([ + header('Start Log Capture', [ + { label: 'Simulator', value: simulatorId }, + { label: 'Bundle ID', value: bundleId }, + ]), + statusLine('error', `Error starting log capture: ${error}`), + ]); } const filterDescription = buildSubsystemFilterDescription(subsystemFilter); - return { - content: [ - createTextContent( - `Log capture started successfully. Session ID: ${sessionId}.\n\n${captureConsole ? 'Note: Your app was relaunched to capture console output.\n' : ''}${filterDescription}\n\nInteract with your simulator and app, then stop capture to retrieve logs.`, - ), + const lines: string[] = []; + lines.push(`Session ID: ${sessionId}`); + if (captureConsole) { + lines.push('Note: Your app was relaunched to capture console output.'); + } + lines.push(filterDescription); + lines.push('Interact with your simulator and app, then stop capture to retrieve logs.'); + + return toolResponse( + [ + header('Start Log Capture', [ + { label: 'Simulator', value: simulatorId }, + { label: 'Bundle ID', value: bundleId }, + ]), + section('Details', lines), + statusLine('success', 'Log capture started.'), ], - nextStepParams: { - stop_sim_log_cap: { logSessionId: sessionId }, + { + nextStepParams: { + stop_sim_log_cap: { logSessionId: sessionId }, + }, }, - }; + ); } const publicSchemaObject = z.strictObject( diff --git a/src/mcp/tools/logging/stop_device_log_cap.ts b/src/mcp/tools/logging/stop_device_log_cap.ts index dc96a825..d449a46e 100644 --- a/src/mcp/tools/logging/stop_device_log_cap.ts +++ b/src/mcp/tools/logging/stop_device_log_cap.ts @@ -4,7 +4,6 @@ * Stops an active Apple device log capture session and returns the captured logs. */ -import * as fs from 'fs'; import * as z from 'zod'; import { log } from '../../../utils/logging/index.ts'; import { @@ -15,6 +14,8 @@ import type { ToolResponse } from '../../../types/common.ts'; import { getDefaultFileSystemExecutor, getDefaultCommandExecutor } from '../../../utils/command.ts'; import type { FileSystemExecutor } from '../../../utils/FileSystemExecutor.ts'; import { createTypedTool } from '../../../utils/typed-tool-factory.ts'; +import { toolResponse } from '../../../utils/tool-response.ts'; +import { header, section, statusLine } from '../../../utils/tool-event-builders.ts'; const stopDeviceLogCapSchema = z.object({ logSessionId: z.string(), @@ -38,165 +39,30 @@ export async function stop_device_log_capLogic( if (result.error) { log('error', `Failed to stop device log capture session ${logSessionId}: ${result.error}`); - return { - content: [ - { - type: 'text', - text: `Failed to stop device log capture session ${logSessionId}: ${result.error}`, - }, - ], - isError: true, - }; + return toolResponse([ + header('Stop Log Capture', [{ label: 'Session ID', value: logSessionId }]), + statusLine( + 'error', + `Failed to stop device log capture session ${logSessionId}: ${result.error}`, + ), + ]); } - return { - content: [ - { - type: 'text', - text: `โœ… Device log capture session stopped successfully\n\nSession ID: ${logSessionId}\n\n--- Captured Logs ---\n${result.logContent}`, - }, - ], - }; + return toolResponse([ + header('Stop Log Capture', [{ label: 'Session ID', value: logSessionId }]), + section('Captured Logs', [result.logContent]), + statusLine('success', 'Log capture stopped.'), + ]); } catch (error) { const message = error instanceof Error ? error.message : String(error); log('error', `Failed to stop device log capture session ${logSessionId}: ${message}`); - return { - content: [ - { - type: 'text', - text: `Failed to stop device log capture session ${logSessionId}: ${message}`, - }, - ], - isError: true, - }; + return toolResponse([ + header('Stop Log Capture', [{ label: 'Session ID', value: logSessionId }]), + statusLine('error', `Failed to stop device log capture session ${logSessionId}: ${message}`), + ]); } } -function hasPromisesInterface(obj: unknown): obj is { promises: typeof fs.promises } { - return typeof obj === 'object' && obj !== null && 'promises' in obj; -} - -function hasExistsSyncMethod(obj: unknown): obj is { existsSync: typeof fs.existsSync } { - return typeof obj === 'object' && obj !== null && 'existsSync' in obj; -} - -function hasCreateWriteStreamMethod( - obj: unknown, -): obj is { createWriteStream: typeof fs.createWriteStream } { - return typeof obj === 'object' && obj !== null && 'createWriteStream' in obj; -} - -export async function stopDeviceLogCapture( - logSessionId: string, - fileSystem?: unknown, -): Promise<{ logContent: string; error?: string }> { - const fsToUse = fileSystem ?? fs; - const mockFileSystemExecutor: FileSystemExecutor = { - async mkdir(path: string, options?: { recursive?: boolean }): Promise { - if (hasPromisesInterface(fsToUse)) { - await fsToUse.promises.mkdir(path, options); - } else { - await fs.promises.mkdir(path, options); - } - }, - async readFile(path: string, encoding: BufferEncoding = 'utf8'): Promise { - if (hasPromisesInterface(fsToUse)) { - const result = await fsToUse.promises.readFile(path, encoding); - return typeof result === 'string' ? result : (result as Buffer).toString(); - } else { - const result = await fs.promises.readFile(path, encoding); - return typeof result === 'string' ? result : (result as Buffer).toString(); - } - }, - async writeFile( - path: string, - content: string, - encoding: BufferEncoding = 'utf8', - ): Promise { - if (hasPromisesInterface(fsToUse)) { - await fsToUse.promises.writeFile(path, content, encoding); - } else { - await fs.promises.writeFile(path, content, encoding); - } - }, - createWriteStream(path: string, options?: { flags?: string }) { - if (hasCreateWriteStreamMethod(fsToUse)) { - return fsToUse.createWriteStream(path, options); - } - return fs.createWriteStream(path, options); - }, - async cp( - source: string, - destination: string, - options?: { recursive?: boolean }, - ): Promise { - if (hasPromisesInterface(fsToUse)) { - await fsToUse.promises.cp(source, destination, options); - } else { - await fs.promises.cp(source, destination, options); - } - }, - async readdir(path: string, options?: { withFileTypes?: boolean }): Promise { - if (hasPromisesInterface(fsToUse)) { - if (options?.withFileTypes === true) { - const result = await fsToUse.promises.readdir(path, { withFileTypes: true }); - return Array.isArray(result) ? result : []; - } - const result = await fsToUse.promises.readdir(path); - return Array.isArray(result) ? result : []; - } - - if (options?.withFileTypes === true) { - const result = await fs.promises.readdir(path, { withFileTypes: true }); - return Array.isArray(result) ? result : []; - } - const result = await fs.promises.readdir(path); - return Array.isArray(result) ? result : []; - }, - async rm(path: string, options?: { recursive?: boolean; force?: boolean }): Promise { - if (hasPromisesInterface(fsToUse)) { - await fsToUse.promises.rm(path, options); - } else { - await fs.promises.rm(path, options); - } - }, - existsSync(path: string): boolean { - if (hasExistsSyncMethod(fsToUse)) { - return fsToUse.existsSync(path); - } - return fs.existsSync(path); - }, - async stat(path: string): Promise<{ isDirectory(): boolean; mtimeMs: number }> { - if (hasPromisesInterface(fsToUse)) { - const result = await fsToUse.promises.stat(path); - return result as { isDirectory(): boolean; mtimeMs: number }; - } - const result = await fs.promises.stat(path); - return result as { isDirectory(): boolean; mtimeMs: number }; - }, - async mkdtemp(prefix: string): Promise { - if (hasPromisesInterface(fsToUse)) { - return fsToUse.promises.mkdtemp(prefix); - } - return fs.promises.mkdtemp(prefix); - }, - tmpdir(): string { - return '/tmp'; - }, - }; - - const result = await stopDeviceLogSessionById(logSessionId, mockFileSystemExecutor, { - timeoutMs: 1000, - readLogContent: true, - }); - - if (result.error) { - return { logContent: '', error: result.error }; - } - - return { logContent: result.logContent }; -} - export { stopAllDeviceLogCaptures }; export const schema = stopDeviceLogCapSchema.shape; diff --git a/src/mcp/tools/logging/stop_sim_log_cap.ts b/src/mcp/tools/logging/stop_sim_log_cap.ts index c6995b1d..e7f73f55 100644 --- a/src/mcp/tools/logging/stop_sim_log_cap.ts +++ b/src/mcp/tools/logging/stop_sim_log_cap.ts @@ -7,18 +7,17 @@ import * as z from 'zod'; import { stopLogCapture as _stopLogCapture } from '../../../utils/log-capture/index.ts'; import type { ToolResponse } from '../../../types/common.ts'; -import { createTextContent } from '../../../types/common.ts'; import { createTypedTool } from '../../../utils/typed-tool-factory.ts'; import type { CommandExecutor } from '../../../utils/command.ts'; import { getDefaultCommandExecutor, getDefaultFileSystemExecutor } from '../../../utils/command.ts'; import type { FileSystemExecutor } from '../../../utils/FileSystemExecutor.ts'; +import { toolResponse } from '../../../utils/tool-response.ts'; +import { header, section, statusLine } from '../../../utils/tool-event-builders.ts'; -// Define schema as ZodObject const stopSimLogCapSchema = z.object({ logSessionId: z.string(), }); -// Use z.infer for type safety type StopSimLogCapParams = z.infer; /** @@ -37,20 +36,16 @@ export async function stop_sim_log_capLogic( ): Promise { const { logContent, error } = await stopLogCaptureFunction(params.logSessionId, fileSystem); if (error) { - return { - content: [ - createTextContent(`Error stopping log capture session ${params.logSessionId}: ${error}`), - ], - isError: true, - }; + return toolResponse([ + header('Stop Log Capture', [{ label: 'Session ID', value: params.logSessionId }]), + statusLine('error', `Error stopping log capture session ${params.logSessionId}: ${error}`), + ]); } - return { - content: [ - createTextContent( - `Log capture session ${params.logSessionId} stopped successfully. Log content follows:\n\n${logContent}`, - ), - ], - }; + return toolResponse([ + header('Stop Log Capture', [{ label: 'Session ID', value: params.logSessionId }]), + section('Captured Logs', [logContent]), + statusLine('success', 'Log capture stopped.'), + ]); } export const schema = stopSimLogCapSchema.shape; // MCP SDK compatibility diff --git a/src/mcp/tools/macos/__tests__/build_run_macos.test.ts b/src/mcp/tools/macos/__tests__/build_run_macos.test.ts index 05e7a15f..49d3e5a9 100644 --- a/src/mcp/tools/macos/__tests__/build_run_macos.test.ts +++ b/src/mcp/tools/macos/__tests__/build_run_macos.test.ts @@ -139,14 +139,15 @@ describe('build_run_macos', () => { expect.objectContaining({ tailEvents: [ expect.objectContaining({ - type: 'notice', - code: 'build-run-result', - data: expect.objectContaining({ - scheme: 'MyApp', - target: 'macOS', - appPath: '/path/to/build/MyApp.app', - launchState: 'requested', - }), + type: 'status-line', + level: 'success', + message: 'Build & Run complete', + }), + expect.objectContaining({ + type: 'detail-tree', + items: expect.arrayContaining([ + expect.objectContaining({ label: 'App Path', value: '/path/to/build/MyApp.app' }), + ]), }), ], }), diff --git a/src/mcp/tools/macos/__tests__/get_mac_app_path.test.ts b/src/mcp/tools/macos/__tests__/get_mac_app_path.test.ts index e6365cec..efda9ef3 100644 --- a/src/mcp/tools/macos/__tests__/get_mac_app_path.test.ts +++ b/src/mcp/tools/macos/__tests__/get_mac_app_path.test.ts @@ -337,14 +337,22 @@ describe('get_mac_app_path plugin', () => { }); describe('Handler Behavior (Complete Literal Returns)', () => { + function textOf(result: { content: Array<{ type: string; text: string }> }): string { + return result.content + .filter((i) => i.type === 'text') + .map((i) => i.text) + .join('\n'); + } + it('should return Zod validation error for missing scheme', async () => { const result = await handler({ workspacePath: '/path/to/MyProject.xcworkspace', }); expect(result.isError).toBe(true); - expect(result.content[0].text).toContain('scheme is required'); - expect(result.content[0].text).toContain('session-set-defaults'); + const text = textOf(result); + expect(text).toContain('scheme is required'); + expect(text).toContain('session-set-defaults'); }); it('should return exact successful app path response with workspace', async () => { @@ -367,14 +375,14 @@ FULL_PRODUCT_NAME = MyApp.app const appPath = '/Users/test/Library/Developer/Xcode/DerivedData/MyApp-abc123/Build/Products/Debug/MyApp.app'; - expect(result.isError).toBeUndefined(); - expect(result.content[0].text).toContain('\u{1F50D} Get App Path'); - expect(result.content[0].text).toContain('Scheme: MyScheme'); - expect(result.content[0].text).toContain('Workspace: /path/to/MyProject.xcworkspace'); - expect(result.content[0].text).toContain('Configuration: Debug'); - expect(result.content[0].text).toContain('Platform: macOS'); - expect(result.content[0].text).toContain(`\u{2514} App Path: ${appPath}`); - expect(result.content[0].text).not.toContain('\u{2705}'); + expect(result.isError).toBeFalsy(); + const text = textOf(result); + expect(text).toContain('Get App Path'); + expect(text).toContain('Scheme: MyScheme'); + expect(text).toContain('Workspace: /path/to/MyProject.xcworkspace'); + expect(text).toContain('Configuration: Debug'); + expect(text).toContain('Platform: macOS'); + expect(text).toContain(`App Path: ${appPath}`); expect(result.nextStepParams).toEqual({ get_mac_bundle_id: { appPath }, launch_mac_app: { appPath }, @@ -401,14 +409,14 @@ FULL_PRODUCT_NAME = MyApp.app const appPath = '/Users/test/Library/Developer/Xcode/DerivedData/MyApp-abc123/Build/Products/Debug/MyApp.app'; - expect(result.isError).toBeUndefined(); - expect(result.content[0].text).toContain('\u{1F50D} Get App Path'); - expect(result.content[0].text).toContain('Scheme: MyScheme'); - expect(result.content[0].text).toContain('Project: /path/to/MyProject.xcodeproj'); - expect(result.content[0].text).toContain('Configuration: Debug'); - expect(result.content[0].text).toContain('Platform: macOS'); - expect(result.content[0].text).toContain(`\u{2514} App Path: ${appPath}`); - expect(result.content[0].text).not.toContain('\u{2705}'); + expect(result.isError).toBeFalsy(); + const text = textOf(result); + expect(text).toContain('Get App Path'); + expect(text).toContain('Scheme: MyScheme'); + expect(text).toContain('Project: /path/to/MyProject.xcodeproj'); + expect(text).toContain('Configuration: Debug'); + expect(text).toContain('Platform: macOS'); + expect(text).toContain(`App Path: ${appPath}`); expect(result.nextStepParams).toEqual({ get_mac_bundle_id: { appPath }, launch_mac_app: { appPath }, @@ -430,11 +438,10 @@ FULL_PRODUCT_NAME = MyApp.app ); expect(result.isError).toBe(true); - expect(result.content[0].text).toContain('\u{1F50D} Get App Path'); - expect(result.content[0].text).toContain('Scheme: MyScheme'); - expect(result.content[0].text).toContain('Errors (1):'); - expect(result.content[0].text).toContain('\u{2717} No such scheme'); - expect(result.content[0].text).toContain('\u{274C} Query failed.'); + const text = textOf(result); + expect(text).toContain('Get App Path'); + expect(text).toContain('Scheme: MyScheme'); + expect(text).toContain('No such scheme'); expect(result.nextStepParams).toBeUndefined(); }); @@ -453,12 +460,9 @@ FULL_PRODUCT_NAME = MyApp.app ); expect(result.isError).toBe(true); - expect(result.content[0].text).toContain('\u{1F50D} Get App Path'); - expect(result.content[0].text).toContain('Errors (1):'); - expect(result.content[0].text).toContain( - '\u{2717} Could not extract app path from build settings', - ); - expect(result.content[0].text).toContain('\u{274C} Query failed.'); + const text = textOf(result); + expect(text).toContain('Get App Path'); + expect(text).toContain('Could not extract app path from build settings'); expect(result.nextStepParams).toBeUndefined(); }); @@ -476,10 +480,9 @@ FULL_PRODUCT_NAME = MyApp.app ); expect(result.isError).toBe(true); - expect(result.content[0].text).toContain('\u{1F50D} Get App Path'); - expect(result.content[0].text).toContain('Errors (1):'); - expect(result.content[0].text).toContain('\u{2717} Network error'); - expect(result.content[0].text).toContain('\u{274C} Query failed.'); + const text = textOf(result); + expect(text).toContain('Get App Path'); + expect(text).toContain('Network error'); expect(result.nextStepParams).toBeUndefined(); }); }); diff --git a/src/mcp/tools/macos/__tests__/launch_mac_app.test.ts b/src/mcp/tools/macos/__tests__/launch_mac_app.test.ts index 39e5ee5d..0a5c935c 100644 --- a/src/mcp/tools/macos/__tests__/launch_mac_app.test.ts +++ b/src/mcp/tools/macos/__tests__/launch_mac_app.test.ts @@ -69,15 +69,12 @@ describe('launch_mac_app plugin', () => { mockFileSystem, ); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: "File not found: '/path/to/NonExistent.app'. Please check the path and try again.", - }, - ], - isError: true, - }); + expect(result.isError).toBe(true); + const text = result.content + .filter((i) => i.type === 'text') + .map((i) => i.text) + .join('\n'); + expect(text).toContain("File not found: '/path/to/NonExistent.app'"); }); }); @@ -184,6 +181,13 @@ describe('launch_mac_app plugin', () => { }); describe('Response Processing', () => { + function textOf(result: { content: Array<{ type: string; text: string }> }): string { + return result.content + .filter((i) => i.type === 'text') + .map((i) => i.text) + .join('\n'); + } + it('should return successful launch response', async () => { const mockExecutor = async () => Promise.resolve(createMockCommandResponse()); @@ -199,14 +203,11 @@ describe('launch_mac_app plugin', () => { mockFileSystem, ); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: 'โœ… macOS app launched successfully: /path/to/MyApp.app', - }, - ], - }); + expect(result.isError).toBeFalsy(); + const text = textOf(result); + expect(text).toContain('Launch macOS App'); + expect(text).toContain('/path/to/MyApp.app'); + expect(text).toContain('App launched successfully'); }); it('should return successful launch response with args', async () => { @@ -225,14 +226,9 @@ describe('launch_mac_app plugin', () => { mockFileSystem, ); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: 'โœ… macOS app launched successfully: /path/to/MyApp.app', - }, - ], - }); + expect(result.isError).toBeFalsy(); + const text = textOf(result); + expect(text).toContain('App launched successfully'); }); it('should handle launch failure with Error object', async () => { @@ -252,15 +248,9 @@ describe('launch_mac_app plugin', () => { mockFileSystem, ); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: 'โŒ Launch macOS app operation failed: App not found', - }, - ], - isError: true, - }); + expect(result.isError).toBe(true); + const text = textOf(result); + expect(text).toContain('Launch macOS app operation failed: App not found'); }); it('should handle launch failure with string error', async () => { @@ -280,15 +270,9 @@ describe('launch_mac_app plugin', () => { mockFileSystem, ); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: 'โŒ Launch macOS app operation failed: Permission denied', - }, - ], - isError: true, - }); + expect(result.isError).toBe(true); + const text = textOf(result); + expect(text).toContain('Launch macOS app operation failed: Permission denied'); }); it('should handle launch failure with unknown error type', async () => { @@ -308,15 +292,9 @@ describe('launch_mac_app plugin', () => { mockFileSystem, ); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: 'โŒ Launch macOS app operation failed: 123', - }, - ], - isError: true, - }); + expect(result.isError).toBe(true); + const text = textOf(result); + expect(text).toContain('Launch macOS app operation failed: 123'); }); }); }); diff --git a/src/mcp/tools/macos/__tests__/stop_mac_app.test.ts b/src/mcp/tools/macos/__tests__/stop_mac_app.test.ts index 86086966..f3ebd726 100644 --- a/src/mcp/tools/macos/__tests__/stop_mac_app.test.ts +++ b/src/mcp/tools/macos/__tests__/stop_mac_app.test.ts @@ -35,19 +35,19 @@ describe('stop_mac_app plugin', () => { }); describe('Input Validation', () => { + function textOf(result: { content: Array<{ type: string; text: string }> }): string { + return result.content + .filter((i) => i.type === 'text') + .map((i) => i.text) + .join('\n'); + } + it('should return exact validation error for missing parameters', async () => { const mockExecutor = async () => ({ success: true, output: '', process: {} as any }); const result = await stop_mac_appLogic({}, mockExecutor); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: 'Either appName or processId must be provided.', - }, - ], - isError: true, - }); + expect(result.isError).toBe(true); + expect(textOf(result)).toContain('Either appName or processId must be provided.'); }); }); @@ -113,6 +113,13 @@ describe('stop_mac_app plugin', () => { }); describe('Response Processing', () => { + function textOf(result: { content: Array<{ type: string; text: string }> }): string { + return result.content + .filter((i) => i.type === 'text') + .map((i) => i.text) + .join('\n'); + } + it('should return exact successful stop response by app name', async () => { const mockExecutor = async () => ({ success: true, output: '', process: {} as any }); @@ -123,14 +130,11 @@ describe('stop_mac_app plugin', () => { mockExecutor, ); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: 'โœ… macOS app stopped successfully: Calculator', - }, - ], - }); + expect(result.isError).toBeFalsy(); + const text = textOf(result); + expect(text).toContain('Stop macOS App'); + expect(text).toContain('Calculator'); + expect(text).toContain('App stopped successfully'); }); it('should return exact successful stop response by process ID', async () => { @@ -143,14 +147,10 @@ describe('stop_mac_app plugin', () => { mockExecutor, ); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: 'โœ… macOS app stopped successfully: PID 1234', - }, - ], - }); + expect(result.isError).toBeFalsy(); + const text = textOf(result); + expect(text).toContain('PID 1234'); + expect(text).toContain('App stopped successfully'); }); it('should return exact successful stop response with both parameters (processId takes precedence)', async () => { @@ -164,14 +164,10 @@ describe('stop_mac_app plugin', () => { mockExecutor, ); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: 'โœ… macOS app stopped successfully: PID 1234', - }, - ], - }); + expect(result.isError).toBeFalsy(); + const text = textOf(result); + expect(text).toContain('PID 1234'); + expect(text).toContain('App stopped successfully'); }); it('should handle execution errors', async () => { @@ -186,15 +182,9 @@ describe('stop_mac_app plugin', () => { mockExecutor, ); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: 'โŒ Stop macOS app operation failed: Process not found', - }, - ], - isError: true, - }); + expect(result.isError).toBe(true); + const text = textOf(result); + expect(text).toContain('Stop macOS app operation failed: Process not found'); }); }); }); diff --git a/src/mcp/tools/macos/build_run_macos.ts b/src/mcp/tools/macos/build_run_macos.ts index 2ec87a7b..8911eb30 100644 --- a/src/mcp/tools/macos/build_run_macos.ts +++ b/src/mcp/tools/macos/build_run_macos.ts @@ -7,7 +7,6 @@ import * as z from 'zod'; import { log } from '../../../utils/logging/index.ts'; -import { createTextResponse } from '../../../utils/responses/index.ts'; import { executeXcodeBuildCommand } from '../../../utils/build/index.ts'; import type { ToolResponse } from '../../../types/common.ts'; import { XcodePlatform } from '../../../types/common.ts'; @@ -18,9 +17,12 @@ import { getSessionAwareToolSchemaShape, } from '../../../utils/typed-tool-factory.ts'; import { nullifyEmptyStrings } from '../../../utils/schema-helpers.ts'; +import { toolResponse } from '../../../utils/tool-response.ts'; +import { header, statusLine } from '../../../utils/tool-event-builders.ts'; import { startBuildPipeline } from '../../../utils/xcodebuild-pipeline.ts'; import { formatToolPreflight } from '../../../utils/build-preflight.ts'; import { + createBuildRunResultEvents, createPendingXcodebuildResponse, emitPipelineError, emitPipelineNotice, @@ -178,29 +180,22 @@ export async function buildRunMacOSLogic( isError: false, }, { - tailEvents: [ - { - type: 'notice', - timestamp: new Date().toISOString(), - operation: 'BUILD', - level: 'success', - message: 'Build & Run complete', - code: 'build-run-result', - data: { - scheme: params.scheme, - platform: 'macOS', - target: 'macOS', - appPath, - launchState: 'requested', - }, - }, - ], + tailEvents: createBuildRunResultEvents({ + scheme: params.scheme, + platform: 'macOS', + target: 'macOS', + appPath, + launchState: 'requested', + }), }, ); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); log('error', `Error during macOS build & run logic: ${errorMessage}`); - return createTextResponse(`Error during macOS build and run: ${errorMessage}`, true); + return toolResponse([ + header('Build & Run macOS'), + statusLine('error', `Error during macOS build and run: ${errorMessage}`), + ]); } } diff --git a/src/mcp/tools/macos/get_mac_app_path.ts b/src/mcp/tools/macos/get_mac_app_path.ts index 6e0a0e98..71f41e74 100644 --- a/src/mcp/tools/macos/get_mac_app_path.ts +++ b/src/mcp/tools/macos/get_mac_app_path.ts @@ -15,11 +15,8 @@ import { getSessionAwareToolSchemaShape, } from '../../../utils/typed-tool-factory.ts'; import { nullifyEmptyStrings } from '../../../utils/schema-helpers.ts'; -import { formatToolPreflight } from '../../../utils/build-preflight.ts'; -import { - formatQueryError, - formatQueryFailureSummary, -} from '../../../utils/xcodebuild-error-utils.ts'; +import { toolResponse } from '../../../utils/tool-response.ts'; +import { header, detailTree, statusLine } from '../../../utils/tool-event-builders.ts'; const baseOptions = { scheme: z.string().describe('The scheme to use'), @@ -59,21 +56,29 @@ const getMacosAppPathSchema = z.preprocess( type GetMacosAppPathParams = z.infer; +function buildHeaderParams(params: GetMacosAppPathParams, configuration: string) { + const headerParams: Array<{ label: string; value: string }> = [ + { label: 'Scheme', value: params.scheme }, + ]; + if (params.workspacePath) { + headerParams.push({ label: 'Workspace', value: params.workspacePath }); + } else if (params.projectPath) { + headerParams.push({ label: 'Project', value: params.projectPath }); + } + headerParams.push({ label: 'Configuration', value: configuration }); + headerParams.push({ label: 'Platform', value: 'macOS' }); + if (params.arch) { + headerParams.push({ label: 'Architecture', value: params.arch }); + } + return headerParams; +} + export async function get_mac_app_pathLogic( params: GetMacosAppPathParams, executor: CommandExecutor, ): Promise { const configuration = params.configuration ?? 'Debug'; - - const preflight = formatToolPreflight({ - operation: 'Get App Path', - scheme: params.scheme, - workspacePath: params.workspacePath, - projectPath: params.projectPath, - configuration, - platform: 'macOS', - arch: params.arch, - }); + const headerParams = buildHeaderParams(params, configuration); log('info', `Getting app path for scheme ${params.scheme} on platform macOS`); @@ -105,78 +110,50 @@ export async function get_mac_app_pathLogic( const result = await executor(command, 'Get App Path', false); if (!result.success) { - const errorBlock = formatQueryError(result.error ?? 'Unknown error'); - return { - content: [ - { - type: 'text', - text: [preflight, errorBlock, '', formatQueryFailureSummary()].join('\n'), - }, - ], - isError: true, - }; + return toolResponse([ + header('Get App Path', headerParams), + statusLine('error', result.error ?? 'Unknown error'), + ]); } if (!result.output) { - const errorBlock = formatQueryError( - 'Failed to extract build settings output from the result', - ); - return { - content: [ - { - type: 'text', - text: [preflight, errorBlock, '', formatQueryFailureSummary()].join('\n'), - }, - ], - isError: true, - }; + return toolResponse([ + header('Get App Path', headerParams), + statusLine('error', 'Failed to extract build settings output from the result'), + ]); } const builtProductsDirMatch = result.output.match(/^\s*BUILT_PRODUCTS_DIR\s*=\s*(.+)$/m); const fullProductNameMatch = result.output.match(/^\s*FULL_PRODUCT_NAME\s*=\s*(.+)$/m); if (!builtProductsDirMatch || !fullProductNameMatch) { - const errorBlock = formatQueryError('Could not extract app path from build settings'); - return { - content: [ - { - type: 'text', - text: [preflight, errorBlock, '', formatQueryFailureSummary()].join('\n'), - }, - ], - isError: true, - }; + return toolResponse([ + header('Get App Path', headerParams), + statusLine('error', 'Could not extract app path from build settings'), + ]); } const builtProductsDir = builtProductsDirMatch[1].trim(); const fullProductName = fullProductNameMatch[1].trim(); const appPath = `${builtProductsDir}/${fullProductName}`; - return { - content: [ - { - type: 'text', - text: [preflight, ` \u{2514} App Path: ${appPath}`].join('\n'), - }, + return toolResponse( + [ + header('Get App Path', headerParams), + detailTree([{ label: 'App Path', value: appPath }]), + statusLine('success', 'App path resolved.'), ], - nextStepParams: { - get_mac_bundle_id: { appPath }, - launch_mac_app: { appPath }, + { + nextStepParams: { + get_mac_bundle_id: { appPath }, + launch_mac_app: { appPath }, + }, }, - }; + ); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); log('error', `Error retrieving app path: ${errorMessage}`); - const errorBlock = formatQueryError(errorMessage); - return { - content: [ - { - type: 'text', - text: [preflight, errorBlock, '', formatQueryFailureSummary()].join('\n'), - }, - ], - isError: true, - }; + return toolResponse([header('Get App Path', headerParams), statusLine('error', errorMessage)]); } } diff --git a/src/mcp/tools/macos/launch_mac_app.ts b/src/mcp/tools/macos/launch_mac_app.ts index 0cb65b3c..19347ecb 100644 --- a/src/mcp/tools/macos/launch_mac_app.ts +++ b/src/mcp/tools/macos/launch_mac_app.ts @@ -12,14 +12,14 @@ import type { ToolResponse } from '../../../types/common.ts'; import type { CommandExecutor, FileSystemExecutor } from '../../../utils/execution/index.ts'; import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; import { createTypedTool } from '../../../utils/typed-tool-factory.ts'; +import { toolResponse } from '../../../utils/tool-response.ts'; +import { header, statusLine } from '../../../utils/tool-event-builders.ts'; -// Define schema as ZodObject const launchMacAppSchema = z.object({ appPath: z.string(), args: z.array(z.string()).optional(), }); -// Use z.infer for type safety type LaunchMacAppParams = z.infer; export async function launch_mac_appLogic( @@ -27,48 +27,32 @@ export async function launch_mac_appLogic( executor: CommandExecutor, fileSystem?: FileSystemExecutor, ): Promise { - // Validate that the app file exists + const headerEvent = header('Launch macOS App', [{ label: 'App', value: params.appPath }]); + const fileExistsValidation = validateFileExists(params.appPath, fileSystem); if (!fileExistsValidation.isValid) { - return fileExistsValidation.errorResponse!; + return toolResponse([headerEvent, statusLine('error', fileExistsValidation.errorMessage!)]); } log('info', `Starting launch macOS app request for ${params.appPath}`); try { - // Construct the command as string array for CommandExecutor const command = ['open', params.appPath]; - // Add any additional arguments if provided - if (params.args && Array.isArray(params.args) && params.args.length > 0) { + if (params.args?.length) { command.push('--args', ...params.args); } - // Execute the command using CommandExecutor await executor(command, 'Launch macOS App'); - // Return success response - return { - content: [ - { - type: 'text', - text: `โœ… macOS app launched successfully: ${params.appPath}`, - }, - ], - }; + return toolResponse([headerEvent, statusLine('success', 'App launched successfully.')]); } catch (error) { - // Handle errors const errorMessage = error instanceof Error ? error.message : String(error); log('error', `Error during launch macOS app operation: ${errorMessage}`); - return { - content: [ - { - type: 'text', - text: `โŒ Launch macOS app operation failed: ${errorMessage}`, - }, - ], - isError: true, - }; + return toolResponse([ + headerEvent, + statusLine('error', `Launch macOS app operation failed: ${errorMessage}`), + ]); } } diff --git a/src/mcp/tools/macos/stop_mac_app.ts b/src/mcp/tools/macos/stop_mac_app.ts index 6db67748..a41cf2d6 100644 --- a/src/mcp/tools/macos/stop_mac_app.ts +++ b/src/mcp/tools/macos/stop_mac_app.ts @@ -4,45 +4,37 @@ import type { ToolResponse } from '../../../types/common.ts'; import type { CommandExecutor } from '../../../utils/execution/index.ts'; import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; import { createTypedTool } from '../../../utils/typed-tool-factory.ts'; +import { toolResponse } from '../../../utils/tool-response.ts'; +import { header, statusLine } from '../../../utils/tool-event-builders.ts'; -// Define schema as ZodObject const stopMacAppSchema = z.object({ appName: z.string().optional(), processId: z.number().optional(), }); -// Use z.infer for type safety type StopMacAppParams = z.infer; export async function stop_mac_appLogic( params: StopMacAppParams, executor: CommandExecutor, ): Promise { + const target = params.processId ? `PID ${params.processId}` : params.appName; + if (!params.appName && !params.processId) { - return { - content: [ - { - type: 'text', - text: 'Either appName or processId must be provided.', - }, - ], - isError: true, - }; + return toolResponse([ + header('Stop macOS App'), + statusLine('error', 'Either appName or processId must be provided.'), + ]); } - log( - 'info', - `Stopping macOS app: ${params.processId ? `PID ${params.processId}` : params.appName}`, - ); + log('info', `Stopping macOS app: ${target}`); try { let command: string[]; if (params.processId) { - // Stop by process ID command = ['kill', String(params.processId)]; } else { - // Stop by app name - use shell command with fallback for complex logic command = [ 'sh', '-c', @@ -52,26 +44,17 @@ export async function stop_mac_appLogic( await executor(command, 'Stop macOS App'); - return { - content: [ - { - type: 'text', - text: `โœ… macOS app stopped successfully: ${params.processId ? `PID ${params.processId}` : params.appName}`, - }, - ], - }; + return toolResponse([ + header('Stop macOS App', [{ label: 'Target', value: target! }]), + statusLine('success', 'App stopped successfully.'), + ]); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); log('error', `Error stopping macOS app: ${errorMessage}`); - return { - content: [ - { - type: 'text', - text: `โŒ Stop macOS app operation failed: ${errorMessage}`, - }, - ], - isError: true, - }; + return toolResponse([ + header('Stop macOS App', [{ label: 'Target', value: target! }]), + statusLine('error', `Stop macOS app operation failed: ${errorMessage}`), + ]); } } diff --git a/src/mcp/tools/project-discovery/__tests__/discover_projs.test.ts b/src/mcp/tools/project-discovery/__tests__/discover_projs.test.ts index 1a5180ed..02f8ed2a 100644 --- a/src/mcp/tools/project-discovery/__tests__/discover_projs.test.ts +++ b/src/mcp/tools/project-discovery/__tests__/discover_projs.test.ts @@ -58,6 +58,13 @@ describe('discover_projs plugin', () => { }); describe('Handler Behavior (Complete Literal Returns)', () => { + function textOf(result: { content: Array<{ type: string; text: string }> }): string { + return result.content + .filter((i) => i.type === 'text') + .map((i) => i.text) + .join('\n'); + } + it('returns structured discovery results for setup flows', async () => { mockFileSystemExecutor.stat = async () => ({ isDirectory: () => true, mtimeMs: 0 }); mockFileSystemExecutor.readdir = async () => [ @@ -82,10 +89,10 @@ describe('discover_projs plugin', () => { mockFileSystemExecutor, ); - expect(result).toEqual({ - content: [{ type: 'text', text: 'Discovery finished. Found 0 projects and 0 workspaces.' }], - isError: false, - }); + expect(result.isError).toBeFalsy(); + const text = textOf(result); + expect(text).toContain('Discover Projects'); + expect(text).toContain('Found 0 project(s) and 0 workspace(s).'); }); it('should return error when scan path does not exist', async () => { @@ -102,15 +109,12 @@ describe('discover_projs plugin', () => { mockFileSystemExecutor, ); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: 'Failed to access scan path: /workspace. Error: ENOENT: no such file or directory', - }, - ], - isError: true, - }); + expect(result.isError).toBe(true); + const text = textOf(result); + expect(text).toContain('Discover Projects'); + expect(text).toContain( + 'Failed to access scan path: /workspace. Error: ENOENT: no such file or directory', + ); }); it('should return error when scan path is not a directory', async () => { @@ -125,10 +129,9 @@ describe('discover_projs plugin', () => { mockFileSystemExecutor, ); - expect(result).toEqual({ - content: [{ type: 'text', text: 'Scan path is not a directory: /workspace' }], - isError: true, - }); + expect(result.isError).toBe(true); + const text = textOf(result); + expect(text).toContain('Scan path is not a directory: /workspace'); }); it('should return success with no projects found', async () => { @@ -144,10 +147,9 @@ describe('discover_projs plugin', () => { mockFileSystemExecutor, ); - expect(result).toEqual({ - content: [{ type: 'text', text: 'Discovery finished. Found 0 projects and 0 workspaces.' }], - isError: false, - }); + expect(result.isError).toBeFalsy(); + const text = textOf(result); + expect(text).toContain('Found 0 project(s) and 0 workspace(s).'); }); it('should return success with projects found', async () => { @@ -166,18 +168,12 @@ describe('discover_projs plugin', () => { mockFileSystemExecutor, ); - expect(result).toEqual({ - content: [ - { type: 'text', text: 'Discovery finished. Found 1 projects and 1 workspaces.' }, - { type: 'text', text: 'Projects found:\n - /workspace/MyApp.xcodeproj' }, - { type: 'text', text: 'Workspaces found:\n - /workspace/MyWorkspace.xcworkspace' }, - { - type: 'text', - text: "Hint: Save a default with session-set-defaults { projectPath: '...' } or { workspacePath: '...' }.", - }, - ], - isError: false, - }); + expect(result.isError).toBeFalsy(); + const text = textOf(result); + expect(text).toContain('Found 1 project(s) and 1 workspace(s).'); + expect(text).toContain('/workspace/MyApp.xcodeproj'); + expect(text).toContain('/workspace/MyWorkspace.xcworkspace'); + expect(text).toContain('session-set-defaults'); }); it('should handle fs error with code', async () => { @@ -196,15 +192,9 @@ describe('discover_projs plugin', () => { mockFileSystemExecutor, ); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: 'Failed to access scan path: /workspace. Error: Permission denied', - }, - ], - isError: true, - }); + expect(result.isError).toBe(true); + const text = textOf(result); + expect(text).toContain('Failed to access scan path: /workspace. Error: Permission denied'); }); it('should handle string error', async () => { @@ -221,12 +211,9 @@ describe('discover_projs plugin', () => { mockFileSystemExecutor, ); - expect(result).toEqual({ - content: [ - { type: 'text', text: 'Failed to access scan path: /workspace. Error: String error' }, - ], - isError: true, - }); + expect(result.isError).toBe(true); + const text = textOf(result); + expect(text).toContain('Failed to access scan path: /workspace. Error: String error'); }); it('should handle workspaceRoot parameter correctly', async () => { @@ -240,14 +227,12 @@ describe('discover_projs plugin', () => { mockFileSystemExecutor, ); - expect(result).toEqual({ - content: [{ type: 'text', text: 'Discovery finished. Found 0 projects and 0 workspaces.' }], - isError: false, - }); + expect(result.isError).toBeFalsy(); + const text = textOf(result); + expect(text).toContain('Found 0 project(s) and 0 workspace(s).'); }); it('should handle scan path outside workspace root', async () => { - // Mock path normalization to simulate path outside workspace root mockFileSystemExecutor.stat = async () => ({ isDirectory: () => true, mtimeMs: 0 }); mockFileSystemExecutor.readdir = async () => []; @@ -260,10 +245,9 @@ describe('discover_projs plugin', () => { mockFileSystemExecutor, ); - expect(result).toEqual({ - content: [{ type: 'text', text: 'Discovery finished. Found 0 projects and 0 workspaces.' }], - isError: false, - }); + expect(result.isError).toBeFalsy(); + const text = textOf(result); + expect(text).toContain('Found 0 project(s) and 0 workspace(s).'); }); it('should handle error with object containing message and code properties', async () => { @@ -284,12 +268,9 @@ describe('discover_projs plugin', () => { mockFileSystemExecutor, ); - expect(result).toEqual({ - content: [ - { type: 'text', text: 'Failed to access scan path: /workspace. Error: Access denied' }, - ], - isError: true, - }); + expect(result.isError).toBe(true); + const text = textOf(result); + expect(text).toContain('Failed to access scan path: /workspace. Error: Access denied'); }); it('should handle max depth reached during recursive scan', async () => { @@ -319,10 +300,9 @@ describe('discover_projs plugin', () => { mockFileSystemExecutor, ); - expect(result).toEqual({ - content: [{ type: 'text', text: 'Discovery finished. Found 0 projects and 0 workspaces.' }], - isError: false, - }); + expect(result.isError).toBeFalsy(); + const text = textOf(result); + expect(text).toContain('Found 0 project(s) and 0 workspace(s).'); }); it('should handle skipped directory types during scan', async () => { @@ -343,11 +323,9 @@ describe('discover_projs plugin', () => { mockFileSystemExecutor, ); - // Test that skipped directories and files are correctly filtered out - expect(result).toEqual({ - content: [{ type: 'text', text: 'Discovery finished. Found 0 projects and 0 workspaces.' }], - isError: false, - }); + expect(result.isError).toBeFalsy(); + const text = textOf(result); + expect(text).toContain('Found 0 project(s) and 0 workspace(s).'); }); it('should handle error during recursive directory reading', async () => { @@ -367,11 +345,9 @@ describe('discover_projs plugin', () => { mockFileSystemExecutor, ); - // The function should handle the error gracefully and continue - expect(result).toEqual({ - content: [{ type: 'text', text: 'Discovery finished. Found 0 projects and 0 workspaces.' }], - isError: false, - }); + expect(result.isError).toBeFalsy(); + const text = textOf(result); + expect(text).toContain('Found 0 project(s) and 0 workspace(s).'); }); }); }); diff --git a/src/mcp/tools/project-discovery/__tests__/get_app_bundle_id.test.ts b/src/mcp/tools/project-discovery/__tests__/get_app_bundle_id.test.ts index f808f0dc..e1aecabb 100644 --- a/src/mcp/tools/project-discovery/__tests__/get_app_bundle_id.test.ts +++ b/src/mcp/tools/project-discovery/__tests__/get_app_bundle_id.test.ts @@ -17,7 +17,13 @@ import { } from '../../../../test-utils/mock-executors.ts'; describe('get_app_bundle_id plugin', () => { - // Helper function to create mock executor for command matching + function textOf(result: { content: Array<{ type: string; text: string }> }): string { + return result.content + .filter((i) => i.type === 'text') + .map((i) => i.text) + .join('\n'); + } + const createMockExecutorForCommands = (results: Record) => { return createCommandMatchingMockExecutor( Object.fromEntries( @@ -53,18 +59,11 @@ describe('get_app_bundle_id plugin', () => { describe('Handler Behavior (Complete Literal Returns)', () => { it('should return error when appPath validation fails', async () => { - // Test validation through the handler which uses Zod validation const result = await handler({}); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: 'Error: Parameter validation failed\nDetails: Invalid parameters:\nappPath: Invalid input: expected string, received undefined', - }, - ], - isError: true, - }); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Parameter validation failed'); + expect(result.content[0].text).toContain('appPath'); }); it('should return error when file exists validation fails', async () => { @@ -79,15 +78,10 @@ describe('get_app_bundle_id plugin', () => { mockFileSystemExecutor, ); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: "File not found: '/path/to/MyApp.app'. Please check the path and try again.", - }, - ], - isError: true, - }); + expect(result.isError).toBe(true); + const text = textOf(result); + expect(text).toContain('Get Bundle ID'); + expect(text).toContain("File not found: '/path/to/MyApp.app'"); }); it('should return success with bundle ID using defaults read', async () => { @@ -104,20 +98,15 @@ describe('get_app_bundle_id plugin', () => { mockFileSystemExecutor, ); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: 'โœ… Bundle ID: io.sentry.MyApp', - }, - ], - nextStepParams: { - install_app_sim: { simulatorId: 'SIMULATOR_UUID', appPath: '/path/to/MyApp.app' }, - launch_app_sim: { simulatorId: 'SIMULATOR_UUID', bundleId: 'io.sentry.MyApp' }, - install_app_device: { deviceId: 'DEVICE_UDID', appPath: '/path/to/MyApp.app' }, - launch_app_device: { deviceId: 'DEVICE_UDID', bundleId: 'io.sentry.MyApp' }, - }, - isError: false, + expect(result.isError).toBeFalsy(); + const text = textOf(result); + expect(text).toContain('Get Bundle ID'); + expect(text).toContain('Bundle ID: io.sentry.MyApp'); + expect(result.nextStepParams).toEqual({ + install_app_sim: { simulatorId: 'SIMULATOR_UUID', appPath: '/path/to/MyApp.app' }, + launch_app_sim: { simulatorId: 'SIMULATOR_UUID', bundleId: 'io.sentry.MyApp' }, + install_app_device: { deviceId: 'DEVICE_UDID', appPath: '/path/to/MyApp.app' }, + launch_app_device: { deviceId: 'DEVICE_UDID', bundleId: 'io.sentry.MyApp' }, }); }); @@ -139,20 +128,14 @@ describe('get_app_bundle_id plugin', () => { mockFileSystemExecutor, ); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: 'โœ… Bundle ID: io.sentry.MyApp', - }, - ], - nextStepParams: { - install_app_sim: { simulatorId: 'SIMULATOR_UUID', appPath: '/path/to/MyApp.app' }, - launch_app_sim: { simulatorId: 'SIMULATOR_UUID', bundleId: 'io.sentry.MyApp' }, - install_app_device: { deviceId: 'DEVICE_UDID', appPath: '/path/to/MyApp.app' }, - launch_app_device: { deviceId: 'DEVICE_UDID', bundleId: 'io.sentry.MyApp' }, - }, - isError: false, + expect(result.isError).toBeFalsy(); + const text = textOf(result); + expect(text).toContain('Bundle ID: io.sentry.MyApp'); + expect(result.nextStepParams).toEqual({ + install_app_sim: { simulatorId: 'SIMULATOR_UUID', appPath: '/path/to/MyApp.app' }, + launch_app_sim: { simulatorId: 'SIMULATOR_UUID', bundleId: 'io.sentry.MyApp' }, + install_app_device: { deviceId: 'DEVICE_UDID', appPath: '/path/to/MyApp.app' }, + launch_app_device: { deviceId: 'DEVICE_UDID', bundleId: 'io.sentry.MyApp' }, }); }); @@ -174,19 +157,10 @@ describe('get_app_bundle_id plugin', () => { mockFileSystemExecutor, ); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: 'Error extracting app bundle ID: Could not extract bundle ID from Info.plist: Command failed', - }, - { - type: 'text', - text: 'Make sure the path points to a valid app bundle (.app directory).', - }, - ], - isError: true, - }); + expect(result.isError).toBe(true); + const text = textOf(result); + expect(text).toContain('Could not extract bundle ID from Info.plist: Command failed'); + expect(text).toContain('Make sure the path points to a valid app bundle (.app directory).'); }); it('should handle Error objects in catch blocks', async () => { @@ -207,19 +181,10 @@ describe('get_app_bundle_id plugin', () => { mockFileSystemExecutor, ); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: 'Error extracting app bundle ID: Could not extract bundle ID from Info.plist: Custom error message', - }, - { - type: 'text', - text: 'Make sure the path points to a valid app bundle (.app directory).', - }, - ], - isError: true, - }); + expect(result.isError).toBe(true); + const text = textOf(result); + expect(text).toContain('Could not extract bundle ID from Info.plist: Custom error message'); + expect(text).toContain('Make sure the path points to a valid app bundle (.app directory).'); }); it('should handle string errors in catch blocks', async () => { @@ -240,79 +205,44 @@ describe('get_app_bundle_id plugin', () => { mockFileSystemExecutor, ); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: 'Error extracting app bundle ID: Could not extract bundle ID from Info.plist: String error', - }, - { - type: 'text', - text: 'Make sure the path points to a valid app bundle (.app directory).', - }, - ], - isError: true, - }); + expect(result.isError).toBe(true); + const text = textOf(result); + expect(text).toContain('Could not extract bundle ID from Info.plist: String error'); + expect(text).toContain('Make sure the path points to a valid app bundle (.app directory).'); }); it('should handle schema validation error when appPath is null', async () => { - // Test validation through the handler which uses Zod validation const result = await handler({ appPath: null }); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: 'Error: Parameter validation failed\nDetails: Invalid parameters:\nappPath: Invalid input: expected string, received null', - }, - ], - isError: true, - }); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Parameter validation failed'); + expect(result.content[0].text).toContain('appPath'); + expect(result.content[0].text).toContain('null'); }); it('should handle schema validation with missing appPath', async () => { - // Test validation through the handler which uses Zod validation const result = await handler({}); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: 'Error: Parameter validation failed\nDetails: Invalid parameters:\nappPath: Invalid input: expected string, received undefined', - }, - ], - isError: true, - }); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Parameter validation failed'); + expect(result.content[0].text).toContain('appPath'); }); it('should handle schema validation with undefined appPath', async () => { - // Test validation through the handler which uses Zod validation const result = await handler({ appPath: undefined }); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: 'Error: Parameter validation failed\nDetails: Invalid parameters:\nappPath: Invalid input: expected string, received undefined', - }, - ], - isError: true, - }); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Parameter validation failed'); + expect(result.content[0].text).toContain('appPath'); }); it('should handle schema validation with number type appPath', async () => { - // Test validation through the handler which uses Zod validation const result = await handler({ appPath: 123 }); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: 'Error: Parameter validation failed\nDetails: Invalid parameters:\nappPath: Invalid input: expected string, received number', - }, - ], - isError: true, - }); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Parameter validation failed'); + expect(result.content[0].text).toContain('appPath'); + expect(result.content[0].text).toContain('number'); }); }); }); diff --git a/src/mcp/tools/project-discovery/__tests__/get_mac_bundle_id.test.ts b/src/mcp/tools/project-discovery/__tests__/get_mac_bundle_id.test.ts index 367dfd05..32e46a4a 100644 --- a/src/mcp/tools/project-discovery/__tests__/get_mac_bundle_id.test.ts +++ b/src/mcp/tools/project-discovery/__tests__/get_mac_bundle_id.test.ts @@ -7,7 +7,13 @@ import { } from '../../../../test-utils/mock-executors.ts'; describe('get_mac_bundle_id plugin', () => { - // Helper function to create mock executor for command matching + function textOf(result: { content: Array<{ type: string; text: string }> }): string { + return result.content + .filter((i) => i.type === 'text') + .map((i) => i.text) + .join('\n'); + } + const createMockExecutorForCommands = (results: Record) => { return createCommandMatchingMockExecutor( Object.fromEntries( @@ -42,9 +48,6 @@ describe('get_mac_bundle_id plugin', () => { }); describe('Handler Behavior (Complete Literal Returns)', () => { - // Note: appPath validation is now handled by Zod schema validation in createTypedTool - // This test would not reach the logic function as Zod validation occurs before it - it('should return error when file exists validation fails', async () => { const mockExecutor = createMockExecutorForCommands({}); const mockFileSystemExecutor = createMockFileSystemExecutor({ @@ -57,15 +60,10 @@ describe('get_mac_bundle_id plugin', () => { mockFileSystemExecutor, ); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: "File not found: '/Applications/MyApp.app'. Please check the path and try again.", - }, - ], - isError: true, - }); + expect(result.isError).toBe(true); + const text = textOf(result); + expect(text).toContain('Get macOS Bundle ID'); + expect(text).toContain("File not found: '/Applications/MyApp.app'"); }); it('should return success with bundle ID using defaults read', async () => { @@ -83,18 +81,13 @@ describe('get_mac_bundle_id plugin', () => { mockFileSystemExecutor, ); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: 'โœ… Bundle ID: io.sentry.MyMacApp', - }, - ], - nextStepParams: { - launch_mac_app: { appPath: '/Applications/MyApp.app' }, - build_macos: { scheme: 'SCHEME_NAME' }, - }, - isError: false, + expect(result.isError).toBeFalsy(); + const text = textOf(result); + expect(text).toContain('Get macOS Bundle ID'); + expect(text).toContain('Bundle ID: io.sentry.MyMacApp'); + expect(result.nextStepParams).toEqual({ + launch_mac_app: { appPath: '/Applications/MyApp.app' }, + build_macos: { scheme: 'SCHEME_NAME' }, }); }); @@ -116,18 +109,12 @@ describe('get_mac_bundle_id plugin', () => { mockFileSystemExecutor, ); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: 'โœ… Bundle ID: io.sentry.MyMacApp', - }, - ], - nextStepParams: { - launch_mac_app: { appPath: '/Applications/MyApp.app' }, - build_macos: { scheme: 'SCHEME_NAME' }, - }, - isError: false, + expect(result.isError).toBeFalsy(); + const text = textOf(result); + expect(text).toContain('Bundle ID: io.sentry.MyMacApp'); + expect(result.nextStepParams).toEqual({ + launch_mac_app: { appPath: '/Applications/MyApp.app' }, + build_macos: { scheme: 'SCHEME_NAME' }, }); }); @@ -150,13 +137,10 @@ describe('get_mac_bundle_id plugin', () => { ); expect(result.isError).toBe(true); - expect(result.content).toHaveLength(2); - expect(result.content[0].type).toBe('text'); - expect(result.content[0].text).toContain('Error extracting macOS bundle ID'); - expect(result.content[0].text).toContain('Could not extract bundle ID from Info.plist'); - expect(result.content[0].text).toContain('Command failed'); - expect(result.content[1].type).toBe('text'); - expect(result.content[1].text).toBe( + const text = textOf(result); + expect(text).toContain('Could not extract bundle ID from Info.plist'); + expect(text).toContain('Command failed'); + expect(text).toContain( 'Make sure the path points to a valid macOS app bundle (.app directory).', ); }); @@ -180,13 +164,10 @@ describe('get_mac_bundle_id plugin', () => { ); expect(result.isError).toBe(true); - expect(result.content).toHaveLength(2); - expect(result.content[0].type).toBe('text'); - expect(result.content[0].text).toContain('Error extracting macOS bundle ID'); - expect(result.content[0].text).toContain('Could not extract bundle ID from Info.plist'); - expect(result.content[0].text).toContain('Custom error message'); - expect(result.content[1].type).toBe('text'); - expect(result.content[1].text).toBe( + const text = textOf(result); + expect(text).toContain('Could not extract bundle ID from Info.plist'); + expect(text).toContain('Custom error message'); + expect(text).toContain( 'Make sure the path points to a valid macOS app bundle (.app directory).', ); }); @@ -210,13 +191,10 @@ describe('get_mac_bundle_id plugin', () => { ); expect(result.isError).toBe(true); - expect(result.content).toHaveLength(2); - expect(result.content[0].type).toBe('text'); - expect(result.content[0].text).toContain('Error extracting macOS bundle ID'); - expect(result.content[0].text).toContain('Could not extract bundle ID from Info.plist'); - expect(result.content[0].text).toContain('String error'); - expect(result.content[1].type).toBe('text'); - expect(result.content[1].text).toBe( + const text = textOf(result); + expect(text).toContain('Could not extract bundle ID from Info.plist'); + expect(text).toContain('String error'); + expect(text).toContain( 'Make sure the path points to a valid macOS app bundle (.app directory).', ); }); diff --git a/src/mcp/tools/project-discovery/__tests__/list_schemes.test.ts b/src/mcp/tools/project-discovery/__tests__/list_schemes.test.ts index 0740f58f..e2a6f2bb 100644 --- a/src/mcp/tools/project-discovery/__tests__/list_schemes.test.ts +++ b/src/mcp/tools/project-discovery/__tests__/list_schemes.test.ts @@ -14,6 +14,13 @@ import { schema, handler, listSchemes, listSchemesLogic } from '../list_schemes. import { sessionStore } from '../../../../utils/session-store.ts'; describe('list_schemes plugin', () => { + function textOf(result: { content: Array<{ type: string; text: string }> }): string { + return result.content + .filter((i) => i.type === 'text') + .map((i) => i.text) + .join('\n'); + } + beforeEach(() => { sessionStore.clear(); }); @@ -59,11 +66,12 @@ describe('list_schemes plugin', () => { mockExecutor, ); - expect(result.isError).toBe(false); - expect(result.content).toHaveLength(1); - expect(result.content[0].text).toContain('\u{1F50D} List Schemes'); - expect(result.content[0].text).toContain('Project: /path/to/MyProject.xcodeproj'); - expect(result.content[0].text).toContain('Schemes:\n - MyProject\n - MyProjectTests'); + expect(result.isError).toBeFalsy(); + const text = textOf(result); + expect(text).toContain('List Schemes'); + expect(text).toContain('Project: /path/to/MyProject.xcodeproj'); + expect(text).toContain('MyProject'); + expect(text).toContain('MyProjectTests'); expect(result.nextStepParams).toEqual({ build_macos: { projectPath: '/path/to/MyProject.xcodeproj', scheme: 'MyProject' }, build_run_sim: { @@ -92,12 +100,10 @@ describe('list_schemes plugin', () => { ); expect(result.isError).toBe(true); - expect(result.content).toHaveLength(1); - expect(result.content[0].text).toContain('\u{1F50D} List Schemes'); - expect(result.content[0].text).toContain('Project: /path/to/MyProject.xcodeproj'); - expect(result.content[0].text).toContain('Errors (1):'); - expect(result.content[0].text).toContain('\u{2717} Project not found'); - expect(result.content[0].text).toContain('\u{274C} Query failed.'); + const text = textOf(result); + expect(text).toContain('List Schemes'); + expect(text).toContain('Project: /path/to/MyProject.xcodeproj'); + expect(text).toContain('Project not found'); expect(result.nextStepParams).toBeUndefined(); }); @@ -113,11 +119,9 @@ describe('list_schemes plugin', () => { ); expect(result.isError).toBe(true); - expect(result.content).toHaveLength(1); - expect(result.content[0].text).toContain('\u{1F50D} List Schemes'); - expect(result.content[0].text).toContain('Errors (1):'); - expect(result.content[0].text).toContain('\u{2717} No schemes found in the output'); - expect(result.content[0].text).toContain('\u{274C} Query failed.'); + const text = textOf(result); + expect(text).toContain('List Schemes'); + expect(text).toContain('No schemes found in the output'); expect(result.nextStepParams).toBeUndefined(); }); @@ -142,10 +146,10 @@ describe('list_schemes plugin', () => { mockExecutor, ); - expect(result.isError).toBe(false); - expect(result.content).toHaveLength(1); - expect(result.content[0].text).toContain('\u{1F50D} List Schemes'); - expect(result.content[0].text).toContain('Schemes:\n (none)'); + expect(result.isError).toBeFalsy(); + const text = textOf(result); + expect(text).toContain('List Schemes'); + expect(text).toContain('(none)'); expect(result.nextStepParams).toBeUndefined(); }); @@ -160,11 +164,9 @@ describe('list_schemes plugin', () => { ); expect(result.isError).toBe(true); - expect(result.content).toHaveLength(1); - expect(result.content[0].text).toContain('\u{1F50D} List Schemes'); - expect(result.content[0].text).toContain('Errors (1):'); - expect(result.content[0].text).toContain('\u{2717} Command execution failed'); - expect(result.content[0].text).toContain('\u{274C} Query failed.'); + const text = textOf(result); + expect(text).toContain('List Schemes'); + expect(text).toContain('Command execution failed'); expect(result.nextStepParams).toBeUndefined(); }); @@ -179,11 +181,9 @@ describe('list_schemes plugin', () => { ); expect(result.isError).toBe(true); - expect(result.content).toHaveLength(1); - expect(result.content[0].text).toContain('\u{1F50D} List Schemes'); - expect(result.content[0].text).toContain('Errors (1):'); - expect(result.content[0].text).toContain('\u{2717} String error'); - expect(result.content[0].text).toContain('\u{274C} Query failed.'); + const text = textOf(result); + expect(text).toContain('List Schemes'); + expect(text).toContain('String error'); expect(result.nextStepParams).toBeUndefined(); }); @@ -243,8 +243,6 @@ describe('list_schemes plugin', () => { }); it('should handle validation when testing with missing projectPath via plugin handler', async () => { - // Note: Direct logic function calls bypass Zod validation, so we test the actual plugin handler - // to verify Zod validation works properly. The createTypedTool wrapper handles validation. const result = await handler({}); expect(result.isError).toBe(true); expect(result.content[0].text).toContain('Missing required session defaults'); @@ -295,11 +293,12 @@ describe('list_schemes plugin', () => { mockExecutor, ); - expect(result.isError).toBe(false); - expect(result.content).toHaveLength(1); - expect(result.content[0].text).toContain('\u{1F50D} List Schemes'); - expect(result.content[0].text).toContain('Workspace: /path/to/MyProject.xcworkspace'); - expect(result.content[0].text).toContain('Schemes:\n - MyApp\n - MyAppTests'); + expect(result.isError).toBeFalsy(); + const text = textOf(result); + expect(text).toContain('List Schemes'); + expect(text).toContain('Workspace: /path/to/MyProject.xcworkspace'); + expect(text).toContain('MyApp'); + expect(text).toContain('MyAppTests'); expect(result.nextStepParams).toEqual({ build_macos: { workspacePath: '/path/to/MyProject.xcworkspace', scheme: 'MyApp' }, build_run_sim: { diff --git a/src/mcp/tools/project-discovery/__tests__/show_build_settings.test.ts b/src/mcp/tools/project-discovery/__tests__/show_build_settings.test.ts index 3ca44b03..d168f584 100644 --- a/src/mcp/tools/project-discovery/__tests__/show_build_settings.test.ts +++ b/src/mcp/tools/project-discovery/__tests__/show_build_settings.test.ts @@ -3,9 +3,15 @@ import * as z from 'zod'; import { createMockExecutor, type CommandExecutor } from '../../../../test-utils/mock-executors.ts'; import { schema, handler, showBuildSettingsLogic } from '../show_build_settings.ts'; import { sessionStore } from '../../../../utils/session-store.ts'; -import { formatToolPreflight } from '../../../../utils/build-preflight.ts'; describe('show_build_settings plugin', () => { + function textOf(result: { content: Array<{ type: string; text: string }> }): string { + return result.content + .filter((i) => i.type === 'text') + .map((i) => i.text) + .join('\n'); + } + beforeEach(() => { sessionStore.clear(); }); @@ -36,9 +42,10 @@ describe('show_build_settings plugin', () => { { projectPath: '/valid/path.xcodeproj', scheme: 'MyScheme' }, mockExecutor, ); - expect(result.isError).toBe(false); - expect(result.content[0].text).toContain('Show Build Settings'); - expect(result.content[0].text).toContain('Scheme: MyScheme'); + expect(result.isError).toBeFalsy(); + const text = textOf(result); + expect(text).toContain('Show Build Settings'); + expect(text).toContain('Scheme: MyScheme'); }); it('should test Zod validation through handler', async () => { @@ -100,17 +107,13 @@ Build settings for action build and target MyApp: false, ]); - const expectedPreflight = formatToolPreflight({ - operation: 'Show Build Settings', - scheme: 'MyScheme', - projectPath: '/path/to/MyProject.xcodeproj', - }); - - expect(result.isError).toBe(false); - expect(result.content).toHaveLength(1); - expect(result.content[0].text).toContain(expectedPreflight); - expect(result.content[0].text).toContain('Build settings for action build and target MyApp:'); - expect(result.content[0].text).toContain('PRODUCT_NAME = MyApp'); + expect(result.isError).toBeFalsy(); + const text = textOf(result); + expect(text).toContain('Show Build Settings'); + expect(text).toContain('Scheme: MyScheme'); + expect(text).toContain('Project: /path/to/MyProject.xcodeproj'); + expect(text).toContain('Build settings for action build and target MyApp:'); + expect(text).toContain('PRODUCT_NAME = MyApp'); expect(result.nextStepParams).toEqual({ build_macos: { projectPath: '/path/to/MyProject.xcodeproj', scheme: 'MyScheme' }, build_sim: { @@ -131,12 +134,6 @@ Build settings for action build and target MyApp: process: { pid: 12345 }, }); - const expectedPreflight = formatToolPreflight({ - operation: 'Show Build Settings', - scheme: 'InvalidScheme', - projectPath: '/path/to/MyProject.xcodeproj', - }); - const result = await showBuildSettingsLogic( { projectPath: '/path/to/MyProject.xcodeproj', @@ -146,14 +143,12 @@ Build settings for action build and target MyApp: ); expect(result.isError).toBe(true); - expect(result.content).toHaveLength(1); - expect(result.content[0].text).toContain(expectedPreflight); - expect(result.content[0].text).toContain('Errors (1):'); - expect(result.content[0].text).toContain( + const text = textOf(result); + expect(text).toContain('Show Build Settings'); + expect(text).toContain( 'The workspace named "App" does not contain a scheme named "InvalidScheme".', ); - expect(result.content[0].text).toContain('Query failed.'); - expect(result).not.toHaveProperty('nextStepParams'); + expect(result.nextStepParams).toBeUndefined(); }); it('should handle Error objects in catch blocks', async () => { @@ -161,12 +156,6 @@ Build settings for action build and target MyApp: throw new Error('Command execution failed'); }; - const expectedPreflight = formatToolPreflight({ - operation: 'Show Build Settings', - scheme: 'MyScheme', - projectPath: '/path/to/MyProject.xcodeproj', - }); - const result = await showBuildSettingsLogic( { projectPath: '/path/to/MyProject.xcodeproj', @@ -176,12 +165,10 @@ Build settings for action build and target MyApp: ); expect(result.isError).toBe(true); - expect(result.content).toHaveLength(1); - expect(result.content[0].text).toContain(expectedPreflight); - expect(result.content[0].text).toContain('Errors (1):'); - expect(result.content[0].text).toContain('Command execution failed'); - expect(result.content[0].text).toContain('Query failed.'); - expect(result).not.toHaveProperty('nextStepParams'); + const text = textOf(result); + expect(text).toContain('Show Build Settings'); + expect(text).toContain('Command execution failed'); + expect(result.nextStepParams).toBeUndefined(); }); }); @@ -218,9 +205,10 @@ Build settings for action build and target MyApp: mockExecutor, ); - expect(result.isError).toBe(false); - expect(result.content[0].text).toContain('Show Build Settings'); - expect(result.content[0].text).toContain('Scheme: MyScheme'); + expect(result.isError).toBeFalsy(); + const text = textOf(result); + expect(text).toContain('Show Build Settings'); + expect(text).toContain('Scheme: MyScheme'); }); it('should work with workspacePath only', async () => { @@ -234,9 +222,10 @@ Build settings for action build and target MyApp: mockExecutor, ); - expect(result.isError).toBe(false); - expect(result.content[0].text).toContain('Show Build Settings'); - expect(result.content[0].text).toContain('Workspace:'); + expect(result.isError).toBeFalsy(); + const text = textOf(result); + expect(text).toContain('Show Build Settings'); + expect(text).toContain('Workspace:'); }); }); diff --git a/src/mcp/tools/project-discovery/discover_projs.ts b/src/mcp/tools/project-discovery/discover_projs.ts index c6c3c70b..9c88d824 100644 --- a/src/mcp/tools/project-discovery/discover_projs.ts +++ b/src/mcp/tools/project-discovery/discover_projs.ts @@ -9,10 +9,11 @@ import * as z from 'zod'; import * as path from 'node:path'; import { log } from '../../../utils/logging/index.ts'; import type { ToolResponse } from '../../../types/common.ts'; -import { createTextContent } from '../../../types/common.ts'; import { getDefaultFileSystemExecutor, getDefaultCommandExecutor } from '../../../utils/command.ts'; import type { FileSystemExecutor } from '../../../utils/FileSystemExecutor.ts'; import { createTypedTool } from '../../../utils/typed-tool-factory.ts'; +import { toolResponse } from '../../../utils/tool-response.ts'; +import { header, statusLine, section } from '../../../utils/tool-event-builders.ts'; // Constants const DEFAULT_MAX_DEPTH = 5; @@ -136,7 +137,6 @@ async function _findProjectsRecursive( } } -// Define schema as ZodObject const discoverProjsSchema = z.object({ workspaceRoot: z.string(), scanPath: z.string().optional(), @@ -154,7 +154,6 @@ export interface DiscoverProjectsResult { workspaces: string[]; } -// Use z.infer for type safety type DiscoverProjsParams = z.infer; async function discoverProjectsOrError( @@ -231,10 +230,7 @@ export async function discover_projsLogic( ): Promise { const results = await discoverProjectsOrError(params, fileSystemExecutor); if ('error' in results) { - return { - content: [createTextContent(results.error)], - isError: true, - }; + return toolResponse([header('Discover Projects'), statusLine('error', results.error)]); } log( @@ -242,36 +238,32 @@ export async function discover_projsLogic( `Discovery finished. Found ${results.projects.length} projects and ${results.workspaces.length} workspaces.`, ); - const responseContent = [ - createTextContent( - `Discovery finished. Found ${results.projects.length} projects and ${results.workspaces.length} workspaces.`, + const events = [ + header('Discover Projects'), + statusLine( + 'success', + `Found ${results.projects.length} project(s) and ${results.workspaces.length} workspace(s).`, ), ]; if (results.projects.length > 0) { - responseContent.push( - createTextContent(`Projects found:\n - ${results.projects.join('\n - ')}`), - ); + events.push(section('Projects', results.projects)); } if (results.workspaces.length > 0) { - responseContent.push( - createTextContent(`Workspaces found:\n - ${results.workspaces.join('\n - ')}`), - ); + events.push(section('Workspaces', results.workspaces)); } if (results.projects.length > 0 || results.workspaces.length > 0) { - responseContent.push( - createTextContent( - "Hint: Save a default with session-set-defaults { projectPath: '...' } or { workspacePath: '...' }.", + events.push( + statusLine( + 'info', + "Save a default with session-set-defaults { projectPath: '...' } or { workspacePath: '...' }.", ), ); } - return { - content: responseContent, - isError: false, - }; + return toolResponse(events); } export const schema = discoverProjsSchema.shape; diff --git a/src/mcp/tools/project-discovery/get_app_bundle_id.ts b/src/mcp/tools/project-discovery/get_app_bundle_id.ts index 60c81738..d046d0e9 100644 --- a/src/mcp/tools/project-discovery/get_app_bundle_id.ts +++ b/src/mcp/tools/project-discovery/get_app_bundle_id.ts @@ -13,13 +13,13 @@ import { getDefaultFileSystemExecutor, getDefaultCommandExecutor } from '../../. import type { FileSystemExecutor } from '../../../utils/FileSystemExecutor.ts'; import { createTypedTool } from '../../../utils/typed-tool-factory.ts'; import { extractBundleIdFromAppPath } from '../../../utils/bundle-id.ts'; +import { toolResponse } from '../../../utils/tool-response.ts'; +import { header, statusLine } from '../../../utils/tool-event-builders.ts'; -// Define schema as ZodObject const getAppBundleIdSchema = z.object({ appPath: z.string().describe('Path to the .app bundle'), }); -// Use z.infer for type safety type GetAppBundleIdParams = z.infer; /** @@ -31,19 +31,13 @@ export async function get_app_bundle_idLogic( executor: CommandExecutor, fileSystemExecutor: FileSystemExecutor, ): Promise { - // Zod validation is handled by createTypedTool, so params.appPath is guaranteed to be a string const appPath = params.appPath; if (!fileSystemExecutor.existsSync(appPath)) { - return { - content: [ - { - type: 'text', - text: `File not found: '${appPath}'. Please check the path and try again.`, - }, - ], - isError: true, - }; + return toolResponse([ + header('Get Bundle ID', [{ label: 'App', value: appPath }]), + statusLine('error', `File not found: '${appPath}'. Please check the path and try again.`), + ]); } log('info', `Starting bundle ID extraction for app: ${appPath}`); @@ -61,38 +55,29 @@ export async function get_app_bundle_idLogic( log('info', `Extracted app bundle ID: ${bundleId}`); - return { - content: [ - { - type: 'text', - text: `โœ… Bundle ID: ${bundleId}`, - }, + return toolResponse( + [ + header('Get Bundle ID', [{ label: 'App', value: appPath }]), + statusLine('success', `Bundle ID: ${bundleId}`), ], - nextStepParams: { - install_app_sim: { simulatorId: 'SIMULATOR_UUID', appPath }, - launch_app_sim: { simulatorId: 'SIMULATOR_UUID', bundleId: bundleId.trim() }, - install_app_device: { deviceId: 'DEVICE_UDID', appPath }, - launch_app_device: { deviceId: 'DEVICE_UDID', bundleId: bundleId.trim() }, + { + nextStepParams: { + install_app_sim: { simulatorId: 'SIMULATOR_UUID', appPath }, + launch_app_sim: { simulatorId: 'SIMULATOR_UUID', bundleId: bundleId.trim() }, + install_app_device: { deviceId: 'DEVICE_UDID', appPath }, + launch_app_device: { deviceId: 'DEVICE_UDID', bundleId: bundleId.trim() }, + }, }, - isError: false, - }; + ); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); log('error', `Error extracting app bundle ID: ${errorMessage}`); - return { - content: [ - { - type: 'text', - text: `Error extracting app bundle ID: ${errorMessage}`, - }, - { - type: 'text', - text: `Make sure the path points to a valid app bundle (.app directory).`, - }, - ], - isError: true, - }; + return toolResponse([ + header('Get Bundle ID', [{ label: 'App', value: appPath }]), + statusLine('error', `${errorMessage}`), + statusLine('info', 'Make sure the path points to a valid app bundle (.app directory).'), + ]); } } diff --git a/src/mcp/tools/project-discovery/get_mac_bundle_id.ts b/src/mcp/tools/project-discovery/get_mac_bundle_id.ts index e396c986..3452b4ca 100644 --- a/src/mcp/tools/project-discovery/get_mac_bundle_id.ts +++ b/src/mcp/tools/project-discovery/get_mac_bundle_id.ts @@ -11,10 +11,9 @@ import type { CommandExecutor } from '../../../utils/command.ts'; import { getDefaultFileSystemExecutor, getDefaultCommandExecutor } from '../../../utils/command.ts'; import type { FileSystemExecutor } from '../../../utils/FileSystemExecutor.ts'; import { createTypedTool } from '../../../utils/typed-tool-factory.ts'; +import { toolResponse } from '../../../utils/tool-response.ts'; +import { header, statusLine } from '../../../utils/tool-event-builders.ts'; -/** - * Sync wrapper for CommandExecutor to handle synchronous commands - */ async function executeSyncCommand(command: string, executor: CommandExecutor): Promise { const result = await executor(['/bin/sh', '-c', command], 'macOS Bundle ID Extraction'); if (!result.success) { @@ -23,12 +22,10 @@ async function executeSyncCommand(command: string, executor: CommandExecutor): P return result.output || ''; } -// Define schema as ZodObject const getMacBundleIdSchema = z.object({ appPath: z.string().describe('Path to the .app bundle'), }); -// Use z.infer for type safety type GetMacBundleIdParams = z.infer; /** @@ -42,15 +39,10 @@ export async function get_mac_bundle_idLogic( const appPath = params.appPath; if (!fileSystemExecutor.existsSync(appPath)) { - return { - content: [ - { - type: 'text', - text: `File not found: '${appPath}'. Please check the path and try again.`, - }, - ], - isError: true, - }; + return toolResponse([ + header('Get macOS Bundle ID', [{ label: 'App', value: appPath }]), + statusLine('error', `File not found: '${appPath}'. Please check the path and try again.`), + ]); } log('info', `Starting bundle ID extraction for macOS app: ${appPath}`); @@ -78,36 +70,27 @@ export async function get_mac_bundle_idLogic( log('info', `Extracted macOS bundle ID: ${bundleId}`); - return { - content: [ - { - type: 'text', - text: `โœ… Bundle ID: ${bundleId}`, - }, + return toolResponse( + [ + header('Get macOS Bundle ID', [{ label: 'App', value: appPath }]), + statusLine('success', `Bundle ID: ${bundleId}`), ], - nextStepParams: { - launch_mac_app: { appPath }, - build_macos: { scheme: 'SCHEME_NAME' }, + { + nextStepParams: { + launch_mac_app: { appPath }, + build_macos: { scheme: 'SCHEME_NAME' }, + }, }, - isError: false, - }; + ); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); log('error', `Error extracting macOS bundle ID: ${errorMessage}`); - return { - content: [ - { - type: 'text', - text: `Error extracting macOS bundle ID: ${errorMessage}`, - }, - { - type: 'text', - text: `Make sure the path points to a valid macOS app bundle (.app directory).`, - }, - ], - isError: true, - }; + return toolResponse([ + header('Get macOS Bundle ID', [{ label: 'App', value: appPath }]), + statusLine('error', `${errorMessage}`), + statusLine('info', 'Make sure the path points to a valid macOS app bundle (.app directory).'), + ]); } } diff --git a/src/mcp/tools/project-discovery/list_schemes.ts b/src/mcp/tools/project-discovery/list_schemes.ts index 4e9c61d8..720b0c63 100644 --- a/src/mcp/tools/project-discovery/list_schemes.ts +++ b/src/mcp/tools/project-discovery/list_schemes.ts @@ -15,11 +15,8 @@ import { getSessionAwareToolSchemaShape, } from '../../../utils/typed-tool-factory.ts'; import { nullifyEmptyStrings } from '../../../utils/schema-helpers.ts'; -import { formatToolPreflight } from '../../../utils/build-preflight.ts'; -import { - formatQueryError, - formatQueryFailureSummary, -} from '../../../utils/xcodebuild-error-utils.ts'; +import { toolResponse } from '../../../utils/tool-response.ts'; +import { header, statusLine, section } from '../../../utils/tool-event-builders.ts'; // Unified schema: XOR between projectPath and workspacePath const baseSchemaObject = z.object({ @@ -87,10 +84,9 @@ export async function listSchemesLogic( const projectOrWorkspace = hasProjectPath ? 'project' : 'workspace'; const pathValue = hasProjectPath ? params.projectPath : params.workspacePath; - const preflight = formatToolPreflight({ - operation: 'List Schemes', - ...(hasProjectPath ? { projectPath: pathValue } : { workspacePath: pathValue }), - }); + const headerParams = hasProjectPath + ? [{ label: 'Project', value: pathValue! }] + : [{ label: 'Workspace', value: pathValue! }]; try { const schemes = await listSchemes(params, executor); @@ -116,14 +112,16 @@ export async function listSchemesLogic( }; } - const schemeLines = schemes.map((s) => ` - ${s}`).join('\n'); - const resultText = schemes.length > 0 ? `Schemes:\n${schemeLines}` : 'Schemes:\n (none)'; + const schemeItems = schemes.length > 0 ? schemes : ['(none)']; - return { - content: [{ type: 'text' as const, text: `${preflight}\n${resultText}` }], - ...(nextStepParams ? { nextStepParams } : {}), - isError: false, - }; + return toolResponse( + [ + header('List Schemes', headerParams), + statusLine('success', `Found ${schemes.length} scheme(s).`), + section('Schemes', schemeItems), + ], + nextStepParams ? { nextStepParams } : undefined, + ); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); log('error', `Error listing schemes: ${errorMessage}`); @@ -132,15 +130,7 @@ export async function listSchemesLogic( ? errorMessage.slice('Failed to list schemes: '.length) : errorMessage; - return { - content: [ - { - type: 'text' as const, - text: `${preflight}\n${formatQueryError(rawError)}\n\n${formatQueryFailureSummary()}`, - }, - ], - isError: true, - }; + return toolResponse([header('List Schemes', headerParams), statusLine('error', rawError)]); } } diff --git a/src/mcp/tools/project-discovery/show_build_settings.ts b/src/mcp/tools/project-discovery/show_build_settings.ts index 11b6fd91..6f731f64 100644 --- a/src/mcp/tools/project-discovery/show_build_settings.ts +++ b/src/mcp/tools/project-discovery/show_build_settings.ts @@ -15,11 +15,8 @@ import { getSessionAwareToolSchemaShape, } from '../../../utils/typed-tool-factory.ts'; import { nullifyEmptyStrings } from '../../../utils/schema-helpers.ts'; -import { formatToolPreflight } from '../../../utils/build-preflight.ts'; -import { - formatQueryError, - formatQueryFailureSummary, -} from '../../../utils/xcodebuild-error-utils.ts'; +import { toolResponse } from '../../../utils/tool-response.ts'; +import { header, statusLine, section } from '../../../utils/tool-event-builders.ts'; // Unified schema: XOR between projectPath and workspacePath const baseSchemaObject = z.object({ @@ -61,15 +58,14 @@ export async function showBuildSettingsLogic( log('info', `Showing build settings for scheme ${params.scheme}`); const hasProjectPath = typeof params.projectPath === 'string'; - const path = hasProjectPath ? params.projectPath : params.workspacePath; + const pathValue = hasProjectPath ? params.projectPath : params.workspacePath; - const preflight = formatToolPreflight({ - operation: 'Show Build Settings', - scheme: params.scheme, + const headerParams = [ + { label: 'Scheme', value: params.scheme }, ...(hasProjectPath - ? { projectPath: params.projectPath } - : { workspacePath: params.workspacePath }), - }); + ? [{ label: 'Project', value: params.projectPath! }] + : [{ label: 'Workspace', value: params.workspacePath! }]), + ]; try { const command = ['xcodebuild', '-showBuildSettings']; @@ -85,15 +81,10 @@ export async function showBuildSettingsLogic( const result = await executor(command, 'Show Build Settings', false); if (!result.success) { - return { - content: [ - { - type: 'text', - text: `${preflight}\n${formatQueryError(result.error || 'Unknown error')}\n\n${formatQueryFailureSummary()}`, - }, - ], - isError: true, - }; + return toolResponse([ + header('Show Build Settings', headerParams), + statusLine('error', result.error || 'Unknown error'), + ]); } const settingsOutput = stripXcodebuildPreamble( @@ -102,32 +93,32 @@ export async function showBuildSettingsLogic( let nextStepParams: Record> | undefined; - if (path) { + if (pathValue) { const pathKey = hasProjectPath ? 'projectPath' : 'workspacePath'; nextStepParams = { - build_macos: { [pathKey]: path, scheme: params.scheme }, - build_sim: { [pathKey]: path, scheme: params.scheme, simulatorName: 'iPhone 17' }, - list_schemes: { [pathKey]: path }, + build_macos: { [pathKey]: pathValue, scheme: params.scheme }, + build_sim: { [pathKey]: pathValue, scheme: params.scheme, simulatorName: 'iPhone 17' }, + list_schemes: { [pathKey]: pathValue }, }; } - return { - content: [{ type: 'text', text: `${preflight}\n${settingsOutput}` }], - ...(nextStepParams ? { nextStepParams } : {}), - isError: false, - }; + const settingsLines = settingsOutput.split('\n').filter((l) => l.trim()); + + return toolResponse( + [ + header('Show Build Settings', headerParams), + statusLine('success', 'Build settings retrieved.'), + section('Settings', settingsLines), + ], + nextStepParams ? { nextStepParams } : undefined, + ); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); log('error', `Error showing build settings: ${errorMessage}`); - return { - content: [ - { - type: 'text', - text: `${preflight}\n${formatQueryError(errorMessage)}\n\n${formatQueryFailureSummary()}`, - }, - ], - isError: true, - }; + return toolResponse([ + header('Show Build Settings', headerParams), + statusLine('error', errorMessage), + ]); } } diff --git a/src/mcp/tools/project-scaffolding/__tests__/scaffold_ios_project.test.ts b/src/mcp/tools/project-scaffolding/__tests__/scaffold_ios_project.test.ts index 7707c407..d247b2c5 100644 --- a/src/mcp/tools/project-scaffolding/__tests__/scaffold_ios_project.test.ts +++ b/src/mcp/tools/project-scaffolding/__tests__/scaffold_ios_project.test.ts @@ -345,6 +345,13 @@ describe('scaffold_ios_project plugin', () => { }); describe('Handler Behavior (Complete Literal Returns)', () => { + function textOf(result: { content: Array<{ type: string; text: string }> }): string { + return result.content + .filter((i) => i.type === 'text') + .map((i) => i.text) + .join('\n'); + } + it('should return success response for valid scaffold iOS project request', async () => { const result = await scaffold_ios_projectLogic( { @@ -357,33 +364,22 @@ describe('scaffold_ios_project plugin', () => { mockFileSystemExecutor, ); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: JSON.stringify( - { - success: true, - projectPath: '/tmp/test-projects', - platform: 'iOS', - message: 'Successfully scaffolded iOS project "TestIOSApp" in /tmp/test-projects', - }, - null, - 2, - ), - }, - ], - nextStepParams: { - build_sim: { - workspacePath: '/tmp/test-projects/TestIOSApp.xcworkspace', - scheme: 'TestIOSApp', - simulatorName: 'iPhone 17', - }, - build_run_sim: { - workspacePath: '/tmp/test-projects/TestIOSApp.xcworkspace', - scheme: 'TestIOSApp', - simulatorName: 'iPhone 17', - }, + expect(result.isError).toBeFalsy(); + const text = textOf(result); + expect(text).toContain('Scaffold iOS Project'); + expect(text).toContain('TestIOSApp'); + expect(text).toContain('/tmp/test-projects'); + expect(text).toContain('Project scaffolded successfully'); + expect(result.nextStepParams).toEqual({ + build_sim: { + workspacePath: '/tmp/test-projects/TestIOSApp.xcworkspace', + scheme: 'TestIOSApp', + simulatorName: 'iPhone 17', + }, + build_run_sim: { + workspacePath: '/tmp/test-projects/TestIOSApp.xcworkspace', + scheme: 'TestIOSApp', + simulatorName: 'iPhone 17', }, }); }); @@ -407,33 +403,19 @@ describe('scaffold_ios_project plugin', () => { mockFileSystemExecutor, ); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: JSON.stringify( - { - success: true, - projectPath: '/tmp/test-projects', - platform: 'iOS', - message: 'Successfully scaffolded iOS project "TestIOSApp" in /tmp/test-projects', - }, - null, - 2, - ), - }, - ], - nextStepParams: { - build_sim: { - workspacePath: '/tmp/test-projects/TestIOSApp.xcworkspace', - scheme: 'TestIOSApp', - simulatorName: 'iPhone 17', - }, - build_run_sim: { - workspacePath: '/tmp/test-projects/TestIOSApp.xcworkspace', - scheme: 'TestIOSApp', - simulatorName: 'iPhone 17', - }, + expect(result.isError).toBeFalsy(); + const text = textOf(result); + expect(text).toContain('Project scaffolded successfully'); + expect(result.nextStepParams).toEqual({ + build_sim: { + workspacePath: '/tmp/test-projects/TestIOSApp.xcworkspace', + scheme: 'TestIOSApp', + simulatorName: 'iPhone 17', + }, + build_run_sim: { + workspacePath: '/tmp/test-projects/TestIOSApp.xcworkspace', + scheme: 'TestIOSApp', + simulatorName: 'iPhone 17', }, }); }); @@ -449,33 +431,19 @@ describe('scaffold_ios_project plugin', () => { mockFileSystemExecutor, ); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: JSON.stringify( - { - success: true, - projectPath: '/tmp/test-projects', - platform: 'iOS', - message: 'Successfully scaffolded iOS project "TestIOSApp" in /tmp/test-projects', - }, - null, - 2, - ), - }, - ], - nextStepParams: { - build_sim: { - workspacePath: '/tmp/test-projects/MyProject.xcworkspace', - scheme: 'MyProject', - simulatorName: 'iPhone 17', - }, - build_run_sim: { - workspacePath: '/tmp/test-projects/MyProject.xcworkspace', - scheme: 'MyProject', - simulatorName: 'iPhone 17', - }, + expect(result.isError).toBeFalsy(); + const text = textOf(result); + expect(text).toContain('Project scaffolded successfully'); + expect(result.nextStepParams).toEqual({ + build_sim: { + workspacePath: '/tmp/test-projects/MyProject.xcworkspace', + scheme: 'MyProject', + simulatorName: 'iPhone 17', + }, + build_run_sim: { + workspacePath: '/tmp/test-projects/MyProject.xcworkspace', + scheme: 'MyProject', + simulatorName: 'iPhone 17', }, }); }); @@ -491,23 +459,9 @@ describe('scaffold_ios_project plugin', () => { mockFileSystemExecutor, ); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: JSON.stringify( - { - success: false, - error: - 'Project name must start with a letter and contain only letters, numbers, and underscores', - }, - null, - 2, - ), - }, - ], - isError: true, - }); + expect(result.isError).toBe(true); + const text = textOf(result); + expect(text).toContain('Project name must start with a letter'); }); it('should return error response for existing project files', async () => { @@ -531,22 +485,9 @@ describe('scaffold_ios_project plugin', () => { mockFileSystemExecutor, ); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: JSON.stringify( - { - success: false, - error: 'Xcode project files already exist in /tmp/test-projects', - }, - null, - 2, - ), - }, - ], - isError: true, - }); + expect(result.isError).toBe(true); + const text = textOf(result); + expect(text).toContain('Xcode project files already exist in /tmp/test-projects'); }); it('should return error response for template download failure', async () => { @@ -569,23 +510,10 @@ describe('scaffold_ios_project plugin', () => { mockFileSystemExecutor, ); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: JSON.stringify( - { - success: false, - error: - 'Failed to get template for iOS: Failed to download template: Template download failed', - }, - null, - 2, - ), - }, - ], - isError: true, - }); + expect(result.isError).toBe(true); + const text = textOf(result); + expect(text).toContain('Failed to get template for iOS'); + expect(text).toContain('Template download failed'); await initConfigStoreForTest({ iosTemplatePath: '/mock/template/path' }); }); @@ -632,23 +560,10 @@ describe('scaffold_ios_project plugin', () => { downloadMockFileSystemExecutor, ); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: JSON.stringify( - { - success: false, - error: - 'Failed to get template for iOS: Failed to extract template: Extraction failed', - }, - null, - 2, - ), - }, - ], - isError: true, - }); + expect(result.isError).toBe(true); + const text = textOf(result); + expect(text).toContain('Failed to get template for iOS'); + expect(text).toContain('Extraction failed'); await initConfigStoreForTest({ iosTemplatePath: '/mock/template/path' }); }); diff --git a/src/mcp/tools/project-scaffolding/__tests__/scaffold_macos_project.test.ts b/src/mcp/tools/project-scaffolding/__tests__/scaffold_macos_project.test.ts index 1bb6f1cd..5890b0da 100644 --- a/src/mcp/tools/project-scaffolding/__tests__/scaffold_macos_project.test.ts +++ b/src/mcp/tools/project-scaffolding/__tests__/scaffold_macos_project.test.ts @@ -221,6 +221,13 @@ describe('scaffold_macos_project plugin', () => { }); describe('Handler Behavior (Complete Literal Returns)', () => { + function textOf(result: { content: Array<{ type: string; text: string }> }): string { + return result.content + .filter((i) => i.type === 'text') + .map((i) => i.text) + .join('\n'); + } + it('should return success response for valid scaffold macOS project request', async () => { const result = await scaffold_macos_projectLogic( { @@ -233,31 +240,20 @@ describe('scaffold_macos_project plugin', () => { mockFileSystemExecutor, ); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: JSON.stringify( - { - success: true, - projectPath: '/tmp/test-projects', - platform: 'macOS', - message: 'Successfully scaffolded macOS project "TestMacApp" in /tmp/test-projects', - }, - null, - 2, - ), - }, - ], - nextStepParams: { - build_macos: { - workspacePath: '/tmp/test-projects/TestMacApp.xcworkspace', - scheme: 'TestMacApp', - }, - build_run_macos: { - workspacePath: '/tmp/test-projects/TestMacApp.xcworkspace', - scheme: 'TestMacApp', - }, + expect(result.isError).toBeFalsy(); + const text = textOf(result); + expect(text).toContain('Scaffold macOS Project'); + expect(text).toContain('TestMacApp'); + expect(text).toContain('/tmp/test-projects'); + expect(text).toContain('Project scaffolded successfully'); + expect(result.nextStepParams).toEqual({ + build_macos: { + workspacePath: '/tmp/test-projects/TestMacApp.xcworkspace', + scheme: 'TestMacApp', + }, + build_run_macos: { + workspacePath: '/tmp/test-projects/TestMacApp.xcworkspace', + scheme: 'TestMacApp', }, }); @@ -278,31 +274,17 @@ describe('scaffold_macos_project plugin', () => { mockFileSystemExecutor, ); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: JSON.stringify( - { - success: true, - projectPath: '/tmp/test-projects', - platform: 'macOS', - message: 'Successfully scaffolded macOS project "TestMacApp" in /tmp/test-projects', - }, - null, - 2, - ), - }, - ], - nextStepParams: { - build_macos: { - workspacePath: '/tmp/test-projects/MyProject.xcworkspace', - scheme: 'MyProject', - }, - build_run_macos: { - workspacePath: '/tmp/test-projects/MyProject.xcworkspace', - scheme: 'MyProject', - }, + expect(result.isError).toBeFalsy(); + const text = textOf(result); + expect(text).toContain('Project scaffolded successfully'); + expect(result.nextStepParams).toEqual({ + build_macos: { + workspacePath: '/tmp/test-projects/MyProject.xcworkspace', + scheme: 'MyProject', + }, + build_run_macos: { + workspacePath: '/tmp/test-projects/MyProject.xcworkspace', + scheme: 'MyProject', }, }); }); @@ -318,23 +300,9 @@ describe('scaffold_macos_project plugin', () => { mockFileSystemExecutor, ); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: JSON.stringify( - { - success: false, - error: - 'Project name must start with a letter and contain only letters, numbers, and underscores', - }, - null, - 2, - ), - }, - ], - isError: true, - }); + expect(result.isError).toBe(true); + const text = textOf(result); + expect(text).toContain('Project name must start with a letter'); }); it('should return error response for existing project files', async () => { @@ -351,22 +319,9 @@ describe('scaffold_macos_project plugin', () => { mockFileSystemExecutor, ); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: JSON.stringify( - { - success: false, - error: 'Xcode project files already exist in /tmp/test-projects', - }, - null, - 2, - ), - }, - ], - isError: true, - }); + expect(result.isError).toBe(true); + const text = textOf(result); + expect(text).toContain('Xcode project files already exist in /tmp/test-projects'); }); it('should return error response for template manager failure', async () => { @@ -382,22 +337,9 @@ describe('scaffold_macos_project plugin', () => { mockFileSystemExecutor, ); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: JSON.stringify( - { - success: false, - error: 'Failed to get template for macOS: Template not found', - }, - null, - 2, - ), - }, - ], - isError: true, - }); + expect(result.isError).toBe(true); + const text = textOf(result); + expect(text).toContain('Failed to get template for macOS: Template not found'); }); }); diff --git a/src/mcp/tools/project-scaffolding/scaffold_ios_project.ts b/src/mcp/tools/project-scaffolding/scaffold_ios_project.ts index 396b4925..e2d55341 100644 --- a/src/mcp/tools/project-scaffolding/scaffold_ios_project.ts +++ b/src/mcp/tools/project-scaffolding/scaffold_ios_project.ts @@ -15,6 +15,8 @@ import { getDefaultFileSystemExecutor, } from '../../../utils/execution/index.ts'; import type { ToolResponse } from '../../../types/common.ts'; +import { toolResponse } from '../../../utils/tool-response.ts'; +import { header, statusLine } from '../../../utils/tool-event-builders.ts'; // Common base schema for both iOS and macOS const BaseScaffoldSchema = z.object({ @@ -343,7 +345,6 @@ async function processDirectory( } } -// Use z.infer for type safety type ScaffoldIOSProjectParams = z.infer; /** @@ -361,55 +362,44 @@ export async function scaffold_ios_projectLogic( const generatedProjectName = params.customizeNames === false ? 'MyProject' : params.projectName; const workspacePath = `${projectPath}/${generatedProjectName}.xcworkspace`; - const response = { - success: true, - projectPath, - platform: 'iOS', - message: `Successfully scaffolded iOS project "${params.projectName}" in ${projectPath}`, - }; - - return { - content: [ - { - type: 'text', - text: JSON.stringify(response, null, 2), - }, + return toolResponse( + [ + header('Scaffold iOS Project', [ + { label: 'Name', value: params.projectName }, + { label: 'Path', value: projectPath }, + { label: 'Platform', value: 'iOS' }, + ]), + statusLine('success', `Project scaffolded successfully at ${projectPath}.`), ], - nextStepParams: { - build_sim: { - workspacePath, - scheme: generatedProjectName, - simulatorName: 'iPhone 17', - }, - build_run_sim: { - workspacePath, - scheme: generatedProjectName, - simulatorName: 'iPhone 17', + { + nextStepParams: { + build_sim: { + workspacePath, + scheme: generatedProjectName, + simulatorName: 'iPhone 17', + }, + build_run_sim: { + workspacePath, + scheme: generatedProjectName, + simulatorName: 'iPhone 17', + }, }, }, - }; + ); } catch (error) { log( 'error', `Failed to scaffold iOS project: ${error instanceof Error ? error.message : String(error)}`, ); - return { - content: [ - { - type: 'text', - text: JSON.stringify( - { - success: false, - error: error instanceof Error ? error.message : 'Unknown error occurred', - }, - null, - 2, - ), - }, - ], - isError: true, - }; + return toolResponse([ + header('Scaffold iOS Project', [ + { label: 'Name', value: params.projectName }, + { label: 'Path', value: params.outputPath }, + { label: 'Platform', value: 'iOS' }, + ]), + statusLine('error', error instanceof Error ? error.message : 'Unknown error occurred'), + ]); } } diff --git a/src/mcp/tools/project-scaffolding/scaffold_macos_project.ts b/src/mcp/tools/project-scaffolding/scaffold_macos_project.ts index f32f4cc9..5ca61882 100644 --- a/src/mcp/tools/project-scaffolding/scaffold_macos_project.ts +++ b/src/mcp/tools/project-scaffolding/scaffold_macos_project.ts @@ -13,6 +13,8 @@ import type { ToolResponse } from '../../../types/common.ts'; import type { CommandExecutor } from '../../../utils/command.ts'; import { getDefaultCommandExecutor, getDefaultFileSystemExecutor } from '../../../utils/command.ts'; import type { FileSystemExecutor } from '../../../utils/FileSystemExecutor.ts'; +import { toolResponse } from '../../../utils/tool-response.ts'; +import { header, statusLine } from '../../../utils/tool-event-builders.ts'; // Common base schema for both iOS and macOS const BaseScaffoldSchema = z.object({ @@ -30,7 +32,6 @@ const ScaffoldmacOSProjectSchema = BaseScaffoldSchema.extend({ deploymentTarget: z.string().optional(), }); -// Use z.infer for type safety type ScaffoldMacOSProjectParams = z.infer; /** @@ -335,53 +336,42 @@ export async function scaffold_macos_projectLogic( const generatedProjectName = params.customizeNames === false ? 'MyProject' : params.projectName; const workspacePath = `${projectPath}/${generatedProjectName}.xcworkspace`; - const response = { - success: true, - projectPath, - platform: 'macOS', - message: `Successfully scaffolded macOS project "${params.projectName}" in ${projectPath}`, - }; - - return { - content: [ - { - type: 'text', - text: JSON.stringify(response, null, 2), - }, + return toolResponse( + [ + header('Scaffold macOS Project', [ + { label: 'Name', value: params.projectName }, + { label: 'Path', value: projectPath }, + { label: 'Platform', value: 'macOS' }, + ]), + statusLine('success', `Project scaffolded successfully at ${projectPath}.`), ], - nextStepParams: { - build_macos: { - workspacePath, - scheme: generatedProjectName, - }, - build_run_macos: { - workspacePath, - scheme: generatedProjectName, + { + nextStepParams: { + build_macos: { + workspacePath, + scheme: generatedProjectName, + }, + build_run_macos: { + workspacePath, + scheme: generatedProjectName, + }, }, }, - }; + ); } catch (error) { log( 'error', `Failed to scaffold macOS project: ${error instanceof Error ? error.message : String(error)}`, ); - return { - content: [ - { - type: 'text', - text: JSON.stringify( - { - success: false, - error: error instanceof Error ? error.message : 'Unknown error occurred', - }, - null, - 2, - ), - }, - ], - isError: true, - }; + return toolResponse([ + header('Scaffold macOS Project', [ + { label: 'Name', value: params.projectName }, + { label: 'Path', value: params.outputPath }, + { label: 'Platform', value: 'macOS' }, + ]), + statusLine('error', error instanceof Error ? error.message : 'Unknown error occurred'), + ]); } } diff --git a/src/mcp/tools/session-management/__tests__/session_clear_defaults.test.ts b/src/mcp/tools/session-management/__tests__/session_clear_defaults.test.ts index d9b9ebca..ad60ba26 100644 --- a/src/mcp/tools/session-management/__tests__/session_clear_defaults.test.ts +++ b/src/mcp/tools/session-management/__tests__/session_clear_defaults.test.ts @@ -32,12 +32,19 @@ describe('session-clear-defaults tool', () => { }); describe('Handler Behavior', () => { + function textOf(result: { content: Array<{ type: string; text: string }> }): string { + return result.content + .filter((i) => i.type === 'text') + .map((i) => i.text) + .join('\n'); + } + it('should clear specific keys when provided', async () => { const result = await sessionClearDefaultsLogic({ keys: ['scheme', 'deviceId', 'derivedDataPath'], }); - expect(result.isError).toBe(false); - expect(result.content[0].text).toContain('Session defaults cleared'); + expect(result.isError).toBeFalsy(); + expect(textOf(result)).toContain('Session defaults cleared'); const current = sessionStore.getAll(); expect(current.scheme).toBeUndefined(); @@ -54,8 +61,8 @@ describe('session-clear-defaults tool', () => { const result = await sessionClearDefaultsLogic({ keys: ['env'] }); - expect(result.isError).toBe(false); - expect(result.content[0].text).toContain('Session defaults cleared'); + expect(result.isError).toBeFalsy(); + expect(textOf(result)).toContain('Session defaults cleared'); const current = sessionStore.getAll(); expect(current.env).toBeUndefined(); @@ -67,8 +74,8 @@ describe('session-clear-defaults tool', () => { sessionStore.setDefaults({ scheme: 'IOS' }); sessionStore.setActiveProfile(null); const result = await sessionClearDefaultsLogic({ all: true }); - expect(result.isError).toBe(false); - expect(result.content[0].text).toBe('All session defaults cleared'); + expect(result.isError).toBeFalsy(); + expect(textOf(result)).toContain('All session defaults cleared'); const current = sessionStore.getAll(); expect(Object.keys(current).length).toBe(0); @@ -84,7 +91,7 @@ describe('session-clear-defaults tool', () => { sessionStore.setActiveProfile('ios'); const result = await sessionClearDefaultsLogic({}); - expect(result.isError).toBe(false); + expect(result.isError).toBeFalsy(); expect(sessionStore.getAll().scheme).toBe('Global'); expect(sessionStore.listProfiles()).toEqual([]); @@ -101,8 +108,8 @@ describe('session-clear-defaults tool', () => { sessionStore.setActiveProfile('watch'); const result = await sessionClearDefaultsLogic({ profile: 'ios' }); - expect(result.isError).toBe(false); - expect(result.content[0].text).toContain('profile "ios"'); + expect(result.isError).toBeFalsy(); + expect(textOf(result)).toContain('profile "ios"'); expect(sessionStore.listProfiles()).toEqual(['watch']); expect(sessionStore.getAll().scheme).toBe('Watch'); @@ -111,20 +118,20 @@ describe('session-clear-defaults tool', () => { it('should error when the specified profile does not exist', async () => { const result = await sessionClearDefaultsLogic({ profile: 'missing' }); expect(result.isError).toBe(true); - expect(result.content[0].text).toContain('does not exist'); + expect(textOf(result)).toContain('does not exist'); }); it('should reject all=true when combined with scoped arguments', async () => { const result = await sessionClearDefaultsLogic({ all: true, profile: 'ios' }); expect(result.isError).toBe(true); - expect(result.content[0].text).toContain('cannot be combined'); + expect(textOf(result)).toContain('cannot be combined'); }); it('should validate keys enum', async () => { const result = (await handler({ keys: ['invalid' as any] })) as any; expect(result.isError).toBe(true); - expect(result.content[0].text).toContain('Parameter validation failed'); - expect(result.content[0].text).toContain('keys'); + expect(textOf(result)).toContain('Parameter validation failed'); + expect(textOf(result)).toContain('keys'); }); }); }); diff --git a/src/mcp/tools/session-management/__tests__/session_set_defaults.test.ts b/src/mcp/tools/session-management/__tests__/session_set_defaults.test.ts index 774f9861..c2e6fb9a 100644 --- a/src/mcp/tools/session-management/__tests__/session_set_defaults.test.ts +++ b/src/mcp/tools/session-management/__tests__/session_set_defaults.test.ts @@ -54,6 +54,13 @@ describe('session-set-defaults tool', () => { }); describe('Handler Behavior', () => { + function textOf(result: { content: Array<{ type: string; text: string }> }): string { + return result.content + .filter((i) => i.type === 'text') + .map((i) => i.text) + .join('\n'); + } + it('should set provided defaults and return updated state', async () => { const result = await sessionSetDefaultsLogic( { @@ -66,7 +73,7 @@ describe('session-set-defaults tool', () => { ); expect(result.isError).toBeFalsy(); - expect(result.content[0].text).toContain('Defaults updated:'); + expect(textOf(result)).toContain('Session defaults updated.'); const current = sessionStore.getAll(); expect(current.scheme).toBe('MyScheme'); @@ -83,8 +90,8 @@ describe('session-set-defaults tool', () => { }); expect(result.isError).toBe(true); - expect(result.content[0].text).toContain('Parameter validation failed'); - expect(result.content[0].text).toContain('useLatestOS'); + expect(textOf(result)).toContain('Parameter validation failed'); + expect(textOf(result)).toContain('useLatestOS'); }); it('should reject env values that are not strings', async () => { @@ -95,8 +102,8 @@ describe('session-set-defaults tool', () => { }); expect(result.isError).toBe(true); - expect(result.content[0].text).toContain('Parameter validation failed'); - expect(result.content[0].text).toContain('env'); + expect(textOf(result)).toContain('Parameter validation failed'); + expect(textOf(result)).toContain('env'); }); it('should reject empty string defaults for required string fields', async () => { @@ -105,8 +112,8 @@ describe('session-set-defaults tool', () => { }); expect(result.isError).toBe(true); - expect(result.content[0].text).toContain('Parameter validation failed'); - expect(result.content[0].text).toContain('scheme'); + expect(textOf(result)).toContain('Parameter validation failed'); + expect(textOf(result)).toContain('scheme'); }); it('should clear workspacePath when projectPath is set', async () => { @@ -118,9 +125,7 @@ describe('session-set-defaults tool', () => { const current = sessionStore.getAll(); expect(current.projectPath).toBe('/new/App.xcodeproj'); expect(current.workspacePath).toBeUndefined(); - expect(result.content[0].text).toContain( - 'Cleared workspacePath because projectPath was set.', - ); + expect(textOf(result)).toContain('Cleared workspacePath because projectPath was set.'); }); it('should clear projectPath when workspacePath is set', async () => { @@ -132,9 +137,7 @@ describe('session-set-defaults tool', () => { const current = sessionStore.getAll(); expect(current.workspacePath).toBe('/new/App.xcworkspace'); expect(current.projectPath).toBeUndefined(); - expect(result.content[0].text).toContain( - 'Cleared projectPath because workspacePath was set.', - ); + expect(textOf(result)).toContain('Cleared projectPath because workspacePath was set.'); }); it('should clear stale simulatorName when simulatorId is explicitly set', async () => { @@ -146,7 +149,7 @@ describe('session-set-defaults tool', () => { const current = sessionStore.getAll(); expect(current.simulatorId).toBe('RESOLVED-SIM-UUID'); expect(current.simulatorName).toBeUndefined(); - expect(result.content[0].text).toContain( + expect(textOf(result)).toContain( 'Cleared simulatorName because simulatorId was set; background resolution will repopulate it.', ); }); @@ -158,7 +161,7 @@ describe('session-set-defaults tool', () => { // simulatorId resolution happens in background; stale id is cleared immediately expect(current.simulatorName).toBe('iPhone 17'); expect(current.simulatorId).toBeUndefined(); - expect(result.content[0].text).toContain( + expect(textOf(result)).toContain( 'Cleared simulatorId because simulatorName was set; background resolution will repopulate it.', ); }); @@ -170,7 +173,7 @@ describe('session-set-defaults tool', () => { createContext(), ); - expect(result.content[0].text).not.toContain('Cleared simulatorName'); + expect(textOf(result)).not.toContain('Cleared simulatorName'); }); it('should not fail when simulatorName cannot be resolved immediately', async () => { @@ -196,7 +199,7 @@ describe('session-set-defaults tool', () => { { simulatorName: 'NonExistentSimulator' }, contextWithFailingExecutor, ); - expect(result.isError).toBe(false); + expect(result.isError).toBeFalsy(); expect(sessionStore.getAll().simulatorName).toBe('NonExistentSimulator'); }); @@ -211,7 +214,7 @@ describe('session-set-defaults tool', () => { const current = sessionStore.getAll(); expect(current.workspacePath).toBe('/app/App.xcworkspace'); expect(current.projectPath).toBeUndefined(); - expect(res.content[0].text).toContain( + expect(textOf(res)).toContain( 'Both projectPath and workspacePath were provided; keeping workspacePath and ignoring projectPath.', ); }); @@ -228,7 +231,7 @@ describe('session-set-defaults tool', () => { // Both are kept, simulatorId takes precedence for tools expect(current.simulatorId).toBe('SIM-1'); expect(current.simulatorName).toBe('iPhone 17'); - expect(res.content[0].text).toContain( + expect(textOf(res)).toContain( 'Both simulatorId and simulatorName were provided; simulatorId will be used by tools.', ); }); @@ -267,7 +270,7 @@ describe('session-set-defaults tool', () => { createContext(), ); - expect(result.content[0].text).toContain('Persisted defaults to'); + expect(textOf(result)).toContain('Persisted defaults to'); expect(writes.length).toBe(1); expect(writes[0].path).toBe(configPath); @@ -294,8 +297,8 @@ describe('session-set-defaults tool', () => { createContext(), ); - expect(result.isError).toBe(false); - expect(result.content[0].text).toContain('Activated profile "ios".'); + expect(result.isError).toBeFalsy(); + expect(textOf(result)).toContain('Activated profile "ios".'); expect(sessionStore.getActiveProfile()).toBe('ios'); expect(sessionStore.getAll().scheme).toBe('NewIOS'); expect(sessionStore.getAll().simulatorName).toBe('iPhone 17'); @@ -311,8 +314,8 @@ describe('session-set-defaults tool', () => { ); expect(result.isError).toBe(true); - expect(result.content[0].text).toContain('Profile "missing" does not exist'); - expect(result.content[0].text).toContain('createIfNotExists=true'); + expect(textOf(result)).toContain('Profile "missing" does not exist'); + expect(textOf(result)).toContain('createIfNotExists=true'); }); it('creates profile when createIfNotExists is true and activates it', async () => { @@ -325,8 +328,8 @@ describe('session-set-defaults tool', () => { createContext(), ); - expect(result.isError).toBe(false); - expect(result.content[0].text).toContain('Created and activated profile "ios".'); + expect(result.isError).toBeFalsy(); + expect(textOf(result)).toContain('Created and activated profile "ios".'); expect(sessionStore.getActiveProfile()).toBe('ios'); expect(sessionStore.getAll().scheme).toBe('NewIOS'); }); @@ -384,7 +387,7 @@ describe('session-set-defaults tool', () => { const envVars = { STAGING_ENABLED: '1', DEBUG: 'true' }; const result = await sessionSetDefaultsLogic({ env: envVars }, createContext()); - expect(result.isError).toBe(false); + expect(result.isError).toBeFalsy(); expect(sessionStore.getAll().env).toEqual(envVars); }); @@ -413,7 +416,7 @@ describe('session-set-defaults tool', () => { createContext(), ); - expect(result.content[0].text).toContain('Persisted defaults to'); + expect(textOf(result)).toContain('Persisted defaults to'); expect(writes.length).toBe(1); const parsed = parseYaml(writes[0].content) as { @@ -425,7 +428,7 @@ describe('session-set-defaults tool', () => { it('should not persist when persist is true but no defaults were provided', async () => { const result = await sessionSetDefaultsLogic({ persist: true }, createContext()); - expect(result.content[0].text).toContain('No defaults provided to persist'); + expect(textOf(result)).toContain('No defaults provided to persist'); }); }); }); diff --git a/src/mcp/tools/session-management/__tests__/session_show_defaults.test.ts b/src/mcp/tools/session-management/__tests__/session_show_defaults.test.ts index b61488cb..e1139a08 100644 --- a/src/mcp/tools/session-management/__tests__/session_show_defaults.test.ts +++ b/src/mcp/tools/session-management/__tests__/session_show_defaults.test.ts @@ -22,24 +22,28 @@ describe('session-show-defaults tool', () => { }); describe('Handler Behavior', () => { + function textOf(result: { content: Array<{ type: string; text: string }> }): string { + return result.content + .filter((i) => i.type === 'text') + .map((i) => i.text) + .join('\n'); + } + it('should return empty defaults when none set', async () => { const result = await handler(); - expect(result.isError).toBe(false); - expect(result.content).toHaveLength(1); - expect(typeof result.content[0].text).toBe('string'); - const parsed = JSON.parse(result.content[0].text as string); - expect(parsed).toEqual({}); + expect(result.isError).toBeFalsy(); + const text = textOf(result); + expect(text).toContain('Show Defaults'); + expect(text).toContain('No session defaults are set'); }); it('should return current defaults when set', async () => { sessionStore.setDefaults({ scheme: 'MyScheme', simulatorId: 'SIM-123' }); const result = await handler(); - expect(result.isError).toBe(false); - expect(result.content).toHaveLength(1); - expect(typeof result.content[0].text).toBe('string'); - const parsed = JSON.parse(result.content[0].text as string); - expect(parsed.scheme).toBe('MyScheme'); - expect(parsed.simulatorId).toBe('SIM-123'); + expect(result.isError).toBeFalsy(); + const text = textOf(result); + expect(text).toContain('scheme: MyScheme'); + expect(text).toContain('simulatorId: SIM-123'); }); it('shows defaults from the active profile', async () => { @@ -48,8 +52,8 @@ describe('session-show-defaults tool', () => { sessionStore.setDefaults({ scheme: 'IOSScheme' }); const result = await handler(); - const parsed = JSON.parse(result.content[0].text as string); - expect(parsed.scheme).toBe('IOSScheme'); + const text = textOf(result); + expect(text).toContain('scheme: IOSScheme'); }); }); }); diff --git a/src/mcp/tools/session-management/__tests__/session_use_defaults_profile.test.ts b/src/mcp/tools/session-management/__tests__/session_use_defaults_profile.test.ts index 66e6cb83..7f3bb56f 100644 --- a/src/mcp/tools/session-management/__tests__/session_use_defaults_profile.test.ts +++ b/src/mcp/tools/session-management/__tests__/session_use_defaults_profile.test.ts @@ -25,12 +25,19 @@ describe('session-use-defaults-profile tool', () => { expect(typeof schema).toBe('object'); }); + function textOf(result: { content: Array<{ type: string; text: string }> }): string { + return result.content + .filter((i) => i.type === 'text') + .map((i) => i.text) + .join('\n'); + } + it('activates an existing named profile', async () => { sessionStore.setActiveProfile('ios'); sessionStore.setActiveProfile(null); const result = await sessionUseDefaultsProfileLogic({ profile: 'ios' }); - expect(result.isError).toBe(false); + expect(result.isError).toBeFalsy(); expect(sessionStore.getActiveProfile()).toBe('ios'); expect(sessionStore.listProfiles()).toContain('ios'); }); @@ -38,32 +45,32 @@ describe('session-use-defaults-profile tool', () => { it('switches back to global profile', async () => { sessionStore.setActiveProfile('watch'); const result = await sessionUseDefaultsProfileLogic({ global: true }); - expect(result.isError).toBe(false); + expect(result.isError).toBeFalsy(); expect(sessionStore.getActiveProfile()).toBeNull(); }); it('returns error when both global and profile are provided', async () => { const result = await sessionUseDefaultsProfileLogic({ global: true, profile: 'ios' }); expect(result.isError).toBe(true); - expect(result.content[0].text).toContain('either global=true or profile'); + expect(textOf(result)).toContain('either global=true or profile'); }); it('returns error when profile does not exist', async () => { const result = await sessionUseDefaultsProfileLogic({ profile: 'macos' }); expect(result.isError).toBe(true); - expect(result.content[0].text).toContain('does not exist'); + expect(textOf(result)).toContain('does not exist'); }); it('returns error when profile name is blank after trimming', async () => { const result = await sessionUseDefaultsProfileLogic({ profile: ' ' }); expect(result.isError).toBe(true); - expect(result.content[0].text).toContain('Profile name cannot be empty'); + expect(textOf(result)).toContain('Profile name cannot be empty'); }); it('returns status for empty args', async () => { const result = await sessionUseDefaultsProfileLogic({}); - expect(result.isError).toBe(false); - expect(result.content[0].text).toContain('Active defaults profile: global'); + expect(result.isError).toBeFalsy(); + expect(textOf(result)).toContain('Active profile: global'); }); it('persists active profile when persist=true', async () => { @@ -81,8 +88,8 @@ describe('session-use-defaults-profile tool', () => { sessionStore.setActiveProfile(null); const result = await sessionUseDefaultsProfileLogic({ profile: 'ios', persist: true }); - expect(result.isError).toBe(false); - expect(result.content[0].text).toContain('Persisted active profile selection'); + expect(result.isError).toBeFalsy(); + expect(textOf(result)).toContain('Persisted active profile selection'); expect(writes).toHaveLength(1); const parsed = parseYaml(writes[0].content) as { activeSessionDefaultsProfile?: string }; expect(parsed.activeSessionDefaultsProfile).toBe('ios'); @@ -101,7 +108,7 @@ describe('session-use-defaults-profile tool', () => { await initConfigStore({ cwd, fs }); const result = await sessionUseDefaultsProfileLogic({ global: true, persist: true }); - expect(result.isError).toBe(false); + expect(result.isError).toBeFalsy(); expect(writes).toHaveLength(1); const parsed = parseYaml(writes[0].content) as { activeSessionDefaultsProfile?: string }; expect(parsed.activeSessionDefaultsProfile).toBeUndefined(); diff --git a/src/mcp/tools/session-management/session_clear_defaults.ts b/src/mcp/tools/session-management/session_clear_defaults.ts index 428ab181..d9fd9037 100644 --- a/src/mcp/tools/session-management/session_clear_defaults.ts +++ b/src/mcp/tools/session-management/session_clear_defaults.ts @@ -4,6 +4,8 @@ import { sessionDefaultKeys } from '../../../utils/session-defaults-schema.ts'; import { createTypedTool } from '../../../utils/typed-tool-factory.ts'; import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; import type { ToolResponse } from '../../../types/common.ts'; +import { toolResponse } from '../../../utils/tool-response.ts'; +import { header, statusLine } from '../../../utils/tool-event-builders.ts'; const keys = sessionDefaultKeys; @@ -27,35 +29,33 @@ type Params = z.infer; export async function sessionClearDefaultsLogic(params: Params): Promise { if (params.all) { if (params.profile !== undefined || params.keys !== undefined) { - return { - content: [ - { - type: 'text', - text: 'all=true cannot be combined with profile or keys.', - }, - ], - isError: true, - }; + return toolResponse([ + header('Clear Defaults'), + statusLine('error', 'all=true cannot be combined with profile or keys.'), + ]); } sessionStore.clearAll(); - return { content: [{ type: 'text', text: 'All session defaults cleared' }], isError: false }; + return toolResponse([ + header('Clear Defaults'), + statusLine('success', 'All session defaults cleared.'), + ]); } const profile = params.profile?.trim(); if (profile !== undefined) { if (profile.length === 0) { - return { - content: [{ type: 'text', text: 'Profile name cannot be empty.' }], - isError: true, - }; + return toolResponse([ + header('Clear Defaults'), + statusLine('error', 'Profile name cannot be empty.'), + ]); } if (!sessionStore.listProfiles().includes(profile)) { - return { - content: [{ type: 'text', text: `Profile "${profile}" does not exist.` }], - isError: true, - }; + return toolResponse([ + header('Clear Defaults'), + statusLine('error', `Profile "${profile}" does not exist.`), + ]); } if (params.keys) { @@ -64,10 +64,10 @@ export async function sessionClearDefaultsLogic(params: Params): Promise(); if ( Object.prototype.hasOwnProperty.call(nextParams, 'projectPath') && @@ -132,7 +132,6 @@ export async function sessionSetDefaultsLogic( hasSimulatorName && nextParams.simulatorName !== current.simulatorName; if (hasSimulatorId && hasSimulatorName) { - // Both provided - keep both, simulatorId takes precedence for tools notices.push( 'Both simulatorId and simulatorName were provided; simulatorId will be used by tools.', ); @@ -212,16 +211,22 @@ export async function sessionSetDefaultsLogic( } const updated = sessionStore.getAll(); - const noticeText = notices.length > 0 ? `\nNotices:\n- ${notices.join('\n- ')}` : ''; - return { - content: [ - { - type: 'text', - text: `Defaults updated:\n${JSON.stringify(updated, null, 2)}${noticeText}`, - }, - ], - isError: false, - }; + const events: PipelineEvent[] = [header('Set Defaults')]; + + const items = Object.entries(updated) + .filter(([, v]) => v !== undefined) + .map(([k, v]) => ({ label: k, value: String(v) })); + if (items.length > 0) { + events.push(detailTree(items)); + } + + if (notices.length > 0) { + events.push(section('Notices', notices)); + } + + events.push(statusLine('success', 'Session defaults updated.')); + + return toolResponse(events); } export const schema = schemaObj.shape; diff --git a/src/mcp/tools/session-management/session_show_defaults.ts b/src/mcp/tools/session-management/session_show_defaults.ts index 03ce99da..f6e4f9d2 100644 --- a/src/mcp/tools/session-management/session_show_defaults.ts +++ b/src/mcp/tools/session-management/session_show_defaults.ts @@ -1,9 +1,30 @@ import { sessionStore } from '../../../utils/session-store.ts'; import type { ToolResponse } from '../../../types/common.ts'; +import { toolResponse } from '../../../utils/tool-response.ts'; +import { header, detailTree, statusLine } from '../../../utils/tool-event-builders.ts'; export const schema = {}; export const handler = async (): Promise => { const current = sessionStore.getAll(); - return { content: [{ type: 'text', text: JSON.stringify(current, null, 2) }], isError: false }; + const activeProfile = sessionStore.getActiveProfile(); + + const items = Object.entries(current) + .filter(([, v]) => v !== undefined) + .map(([k, v]) => ({ label: k, value: String(v) })); + + if (items.length === 0) { + return toolResponse([ + header('Show Defaults'), + statusLine( + 'info', + `No session defaults are set. Active profile: ${activeProfile ?? 'global'}`, + ), + ]); + } + + return toolResponse([ + header('Show Defaults', [{ label: 'Active Profile', value: activeProfile ?? 'global' }]), + detailTree(items), + ]); }; diff --git a/src/mcp/tools/session-management/session_use_defaults_profile.ts b/src/mcp/tools/session-management/session_use_defaults_profile.ts index ab132168..597ff617 100644 --- a/src/mcp/tools/session-management/session_use_defaults_profile.ts +++ b/src/mcp/tools/session-management/session_use_defaults_profile.ts @@ -4,6 +4,9 @@ import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; import { persistActiveSessionDefaultsProfile } from '../../../utils/config-store.ts'; import { sessionStore } from '../../../utils/session-store.ts'; import type { ToolResponse } from '../../../types/common.ts'; +import { toolResponse } from '../../../utils/tool-response.ts'; +import { header, statusLine, detailTree, section } from '../../../utils/tool-event-builders.ts'; +import type { PipelineEvent } from '../../../types/pipeline-events.ts'; const schemaObj = z.object({ profile: z @@ -20,53 +23,37 @@ const schemaObj = z.object({ type Params = z.input; -function normalizeProfileName(profile: string): string { - return profile.trim(); -} - -function errorResponse(text: string): ToolResponse { - return { - content: [{ type: 'text', text }], - isError: true, - }; -} - function resolveProfileToActivate(params: Params): string | null | undefined { if (params.global === true) return null; if (params.profile === undefined) return undefined; - return normalizeProfileName(params.profile); -} - -function validateProfileActivation( - profileToActivate: string | null | undefined, -): ToolResponse | null { - if (profileToActivate === undefined || profileToActivate === null) { - return null; - } - - if (profileToActivate.length === 0) { - return errorResponse('Profile name cannot be empty.'); - } - - const profileExists = sessionStore.listProfiles().includes(profileToActivate); - if (!profileExists) { - return errorResponse(`Profile "${profileToActivate}" does not exist.`); - } - - return null; + return params.profile.trim(); } export async function sessionUseDefaultsProfileLogic(params: Params): Promise { const notices: string[] = []; if (params.global === true && params.profile !== undefined) { - return errorResponse('Provide either global=true or profile, not both.'); + return toolResponse([ + header('Use Defaults Profile'), + statusLine('error', 'Provide either global=true or profile, not both.'), + ]); } const profileToActivate = resolveProfileToActivate(params); - const validationError = validateProfileActivation(profileToActivate); - if (validationError) { - return validationError; + + if (typeof profileToActivate === 'string') { + if (profileToActivate.length === 0) { + return toolResponse([ + header('Use Defaults Profile'), + statusLine('error', 'Profile name cannot be empty.'), + ]); + } + if (!sessionStore.listProfiles().includes(profileToActivate)) { + return toolResponse([ + header('Use Defaults Profile'), + statusLine('error', `Profile "${profileToActivate}" does not exist.`), + ]); + } } if (profileToActivate !== undefined) { @@ -83,20 +70,27 @@ export async function sessionUseDefaultsProfileLogic(params: Params): Promise 0 ? profiles.join(', ') : '(none)'}`, - `Current defaults: ${JSON.stringify(current, null, 2)}`, - ...(notices.length > 0 ? [`Notices:`, ...notices.map((notice) => `- ${notice}`)] : []), - ].join('\n'), - }, - ], - isError: false, - }; + const events: PipelineEvent[] = [ + header('Use Defaults Profile', [ + { label: 'Active Profile', value: activeLabel }, + { label: 'Known Profiles', value: profiles.length > 0 ? profiles.join(', ') : '(none)' }, + ]), + ]; + + const items = Object.entries(current) + .filter(([, v]) => v !== undefined) + .map(([k, v]) => ({ label: k, value: String(v) })); + if (items.length > 0) { + events.push(detailTree(items)); + } + + if (notices.length > 0) { + events.push(section('Notices', notices)); + } + + events.push(statusLine('success', `Active profile: ${activeLabel}`)); + + return toolResponse(events); } export const schema = schemaObj.shape; diff --git a/src/mcp/tools/simulator-management/__tests__/erase_sims.test.ts b/src/mcp/tools/simulator-management/__tests__/erase_sims.test.ts index f830a41b..128702bc 100644 --- a/src/mcp/tools/simulator-management/__tests__/erase_sims.test.ts +++ b/src/mcp/tools/simulator-management/__tests__/erase_sims.test.ts @@ -2,6 +2,14 @@ import { describe, it, expect } from 'vitest'; import * as z from 'zod'; import { schema, erase_simsLogic } from '../erase_sims.ts'; import { createMockExecutor } from '../../../../test-utils/mock-executors.ts'; +import type { ToolResponse } from '../../../../types/common.ts'; + +function allText(result: ToolResponse): string { + return result.content + .filter((c) => c.type === 'text') + .map((c) => c.text) + .join('\n'); +} describe('erase_sims tool (single simulator)', () => { describe('Schema Validation', () => { @@ -16,17 +24,17 @@ describe('erase_sims tool (single simulator)', () => { it('erases a simulator successfully', async () => { const mock = createMockExecutor({ success: true, output: 'OK' }); const res = await erase_simsLogic({ simulatorId: 'UD1' }, mock); - expect(res).toEqual({ - content: [{ type: 'text', text: 'Successfully erased simulator UD1' }], - }); + const text = allText(res); + expect(text).toContain('Simulator UD1 erased'); + expect(res.isError).toBeFalsy(); }); it('returns failure when erase fails', async () => { const mock = createMockExecutor({ success: false, error: 'Booted device' }); const res = await erase_simsLogic({ simulatorId: 'UD1' }, mock); - expect(res).toEqual({ - content: [{ type: 'text', text: 'Failed to erase simulator: Booted device' }], - }); + const text = allText(res); + expect(text).toContain('Failed to erase simulator: Booted device'); + expect(res.isError).toBe(true); }); it('adds tool hint when booted error occurs without shutdownFirst', async () => { @@ -34,8 +42,9 @@ describe('erase_sims tool (single simulator)', () => { 'An error was encountered processing the command (domain=com.apple.CoreSimulator.SimError, code=405):\nUnable to erase contents and settings in current state: Booted\n'; const mock = createMockExecutor({ success: false, error: bootedError }); const res = await erase_simsLogic({ simulatorId: 'UD1' }, mock); - expect((res.content?.[1] as any).text).toContain('Tool hint'); - expect((res.content?.[1] as any).text).toContain('shutdownFirst: true'); + const text = allText(res); + expect(text).toContain('shutdownFirst: true'); + expect(res.isError).toBe(true); }); it('performs shutdown first when shutdownFirst=true', async () => { @@ -49,9 +58,9 @@ describe('erase_sims tool (single simulator)', () => { ['xcrun', 'simctl', 'shutdown', 'UD1'], ['xcrun', 'simctl', 'erase', 'UD1'], ]); - expect(res).toEqual({ - content: [{ type: 'text', text: 'Successfully erased simulator UD1' }], - }); + const text = allText(res); + expect(text).toContain('Simulator UD1 erased'); + expect(res.isError).toBeFalsy(); }); }); }); diff --git a/src/mcp/tools/simulator-management/__tests__/reset_sim_location.test.ts b/src/mcp/tools/simulator-management/__tests__/reset_sim_location.test.ts index 131aeee1..2f7dd258 100644 --- a/src/mcp/tools/simulator-management/__tests__/reset_sim_location.test.ts +++ b/src/mcp/tools/simulator-management/__tests__/reset_sim_location.test.ts @@ -2,6 +2,14 @@ import { describe, it, expect } from 'vitest'; import * as z from 'zod'; import { schema, reset_sim_locationLogic } from '../reset_sim_location.ts'; import { createMockExecutor } from '../../../../test-utils/mock-executors.ts'; +import type { ToolResponse } from '../../../../types/common.ts'; + +function allText(result: ToolResponse): string { + return result.content + .filter((c) => c.type === 'text') + .map((c) => c.text) + .join('\n'); +} describe('reset_sim_location plugin', () => { describe('Schema Validation', () => { @@ -30,14 +38,10 @@ describe('reset_sim_location plugin', () => { mockExecutor, ); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: 'Successfully reset simulator test-uuid-123 location.', - }, - ], - }); + const text = allText(result); + expect(text).toContain('Reset Location'); + expect(text).toContain('Location reset to default'); + expect(result.isError).toBeFalsy(); }); it('should handle command failure', async () => { @@ -53,14 +57,9 @@ describe('reset_sim_location plugin', () => { mockExecutor, ); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: 'Failed to reset simulator location: Command failed', - }, - ], - }); + const text = allText(result); + expect(text).toContain('Failed to reset simulator location: Command failed'); + expect(result.isError).toBe(true); }); it('should handle exception during execution', async () => { @@ -73,14 +72,9 @@ describe('reset_sim_location plugin', () => { mockExecutor, ); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: 'Failed to reset simulator location: Network error', - }, - ], - }); + const text = allText(result); + expect(text).toContain('Failed to reset simulator location: Network error'); + expect(result.isError).toBe(true); }); it('should call correct command', async () => { @@ -92,7 +86,6 @@ describe('reset_sim_location plugin', () => { output: 'Location reset successfully', }); - // Create a wrapper to capture the command arguments const capturingExecutor = async (command: string[], logPrefix?: string) => { capturedCommand = command; capturedLogPrefix = logPrefix; diff --git a/src/mcp/tools/simulator-management/__tests__/set_sim_appearance.test.ts b/src/mcp/tools/simulator-management/__tests__/set_sim_appearance.test.ts index a5b3a0a5..2bc281c4 100644 --- a/src/mcp/tools/simulator-management/__tests__/set_sim_appearance.test.ts +++ b/src/mcp/tools/simulator-management/__tests__/set_sim_appearance.test.ts @@ -5,6 +5,14 @@ import { createMockCommandResponse, createMockExecutor, } from '../../../../test-utils/mock-executors.ts'; +import type { ToolResponse } from '../../../../types/common.ts'; + +function allText(result: ToolResponse): string { + return result.content + .filter((c) => c.type === 'text') + .map((c) => c.text) + .join('\n'); +} describe('set_sim_appearance plugin', () => { describe('Export Field Validation (Literal)', () => { @@ -41,14 +49,10 @@ describe('set_sim_appearance plugin', () => { mockExecutor, ); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: 'Successfully set simulator test-uuid-123 appearance to dark mode', - }, - ], - }); + const text = allText(result); + expect(text).toContain('Set Appearance'); + expect(text).toContain('Appearance set to dark mode'); + expect(result.isError).toBeFalsy(); }); it('should handle appearance change failure', async () => { @@ -65,21 +69,16 @@ describe('set_sim_appearance plugin', () => { mockExecutor, ); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: 'Failed to set simulator appearance: Invalid device: invalid-uuid', - }, - ], - }); + const text = allText(result); + expect(text).toContain('Failed to set simulator appearance: Invalid device: invalid-uuid'); + expect(result.isError).toBe(true); }); it('should surface session default requirement when simulatorId is missing', async () => { const result = await handler({ mode: 'dark' }); const message = result.content?.[0]?.text ?? ''; - expect(message).toContain('Error: Missing required session defaults'); + expect(message).toContain('Missing required session defaults'); expect(message).toContain('simulatorId is required'); expect(result.isError).toBe(true); }); @@ -95,14 +94,9 @@ describe('set_sim_appearance plugin', () => { mockExecutor, ); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: 'Failed to set simulator appearance: Network error', - }, - ], - }); + const text = allText(result); + expect(text).toContain('Failed to set simulator appearance: Network error'); + expect(result.isError).toBe(true); }); it('should call correct command', async () => { @@ -131,7 +125,6 @@ describe('set_sim_appearance plugin', () => { ['xcrun', 'simctl', 'ui', 'test-uuid-123', 'appearance', 'dark'], 'Set Simulator Appearance', false, - undefined, ], ]); }); diff --git a/src/mcp/tools/simulator-management/__tests__/set_sim_location.test.ts b/src/mcp/tools/simulator-management/__tests__/set_sim_location.test.ts index bdffd902..2d00869a 100644 --- a/src/mcp/tools/simulator-management/__tests__/set_sim_location.test.ts +++ b/src/mcp/tools/simulator-management/__tests__/set_sim_location.test.ts @@ -1,9 +1,3 @@ -/** - * Tests for set_sim_location tool - * Following CLAUDE.md testing standards with literal validation - * Using pure dependency injection for deterministic testing - */ - import { describe, it, expect } from 'vitest'; import * as z from 'zod'; import { @@ -12,6 +6,14 @@ import { createNoopExecutor, } from '../../../../test-utils/mock-executors.ts'; import { schema, handler, set_sim_locationLogic } from '../set_sim_location.ts'; +import type { ToolResponse } from '../../../../types/common.ts'; + +function allText(result: ToolResponse): string { + return result.content + .filter((c) => c.type === 'text') + .map((c) => c.text) + .join('\n'); +} describe('set_sim_location tool', () => { describe('Export Field Validation (Literal)', () => { @@ -148,14 +150,10 @@ describe('set_sim_location tool', () => { mockExecutor, ); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: 'Successfully set simulator test-uuid-123 location to 37.7749,-122.4194', - }, - ], - }); + const text = allText(result); + expect(text).toContain('Set Location'); + expect(text).toContain('Location set to 37.7749,-122.4194'); + expect(result.isError).toBeFalsy(); }); it('should handle latitude validation failure', async () => { @@ -168,14 +166,9 @@ describe('set_sim_location tool', () => { createNoopExecutor(), ); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: 'Latitude must be between -90 and 90 degrees', - }, - ], - }); + const text = allText(result); + expect(text).toContain('Latitude must be between -90 and 90 degrees'); + expect(result.isError).toBe(true); }); it('should handle longitude validation failure', async () => { @@ -188,14 +181,9 @@ describe('set_sim_location tool', () => { createNoopExecutor(), ); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: 'Longitude must be between -180 and 180 degrees', - }, - ], - }); + const text = allText(result); + expect(text).toContain('Longitude must be between -180 and 180 degrees'); + expect(result.isError).toBe(true); }); it('should handle command failure', async () => { @@ -214,14 +202,9 @@ describe('set_sim_location tool', () => { mockExecutor, ); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: 'Failed to set simulator location: Simulator not found', - }, - ], - }); + const text = allText(result); + expect(text).toContain('Failed to set simulator location: Simulator not found'); + expect(result.isError).toBe(true); }); it('should handle exception with Error object', async () => { @@ -236,14 +219,9 @@ describe('set_sim_location tool', () => { mockExecutor, ); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: 'Failed to set simulator location: Connection failed', - }, - ], - }); + const text = allText(result); + expect(text).toContain('Failed to set simulator location: Connection failed'); + expect(result.isError).toBe(true); }); it('should handle exception with string error', async () => { @@ -258,14 +236,9 @@ describe('set_sim_location tool', () => { mockExecutor, ); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: 'Failed to set simulator location: String error', - }, - ], - }); + const text = allText(result); + expect(text).toContain('Failed to set simulator location: String error'); + expect(result.isError).toBe(true); }); it('should handle boundary values for coordinates', async () => { @@ -284,14 +257,9 @@ describe('set_sim_location tool', () => { mockExecutor, ); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: 'Successfully set simulator test-uuid-123 location to 90,180', - }, - ], - }); + const text = allText(result); + expect(text).toContain('Location set to 90,180'); + expect(result.isError).toBeFalsy(); }); it('should handle boundary values for negative coordinates', async () => { @@ -310,14 +278,9 @@ describe('set_sim_location tool', () => { mockExecutor, ); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: 'Successfully set simulator test-uuid-123 location to -90,-180', - }, - ], - }); + const text = allText(result); + expect(text).toContain('Location set to -90,-180'); + expect(result.isError).toBeFalsy(); }); it('should handle zero coordinates', async () => { @@ -336,14 +299,9 @@ describe('set_sim_location tool', () => { mockExecutor, ); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: 'Successfully set simulator test-uuid-123 location to 0,0', - }, - ], - }); + const text = allText(result); + expect(text).toContain('Location set to 0,0'); + expect(result.isError).toBeFalsy(); }); it('should verify correct executor arguments', async () => { diff --git a/src/mcp/tools/simulator-management/__tests__/sim_statusbar.test.ts b/src/mcp/tools/simulator-management/__tests__/sim_statusbar.test.ts index 89d576e5..7144991d 100644 --- a/src/mcp/tools/simulator-management/__tests__/sim_statusbar.test.ts +++ b/src/mcp/tools/simulator-management/__tests__/sim_statusbar.test.ts @@ -1,9 +1,3 @@ -/** - * Tests for sim_statusbar plugin - * Following CLAUDE.md testing standards with literal validation - * Using dependency injection for deterministic testing - */ - import { describe, it, expect } from 'vitest'; import * as z from 'zod'; import { @@ -12,6 +6,14 @@ import { type CommandExecutor, } from '../../../../test-utils/mock-executors.ts'; import { schema, sim_statusbarLogic } from '../sim_statusbar.ts'; +import type { ToolResponse } from '../../../../types/common.ts'; + +function allText(result: ToolResponse): string { + return result.content + .filter((c) => c.type === 'text') + .map((c) => c.text) + .join('\n'); +} describe('sim_statusbar tool', () => { describe('Schema Validation', () => { @@ -43,19 +45,13 @@ describe('sim_statusbar tool', () => { mockExecutor, ); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: 'Successfully set simulator test-uuid-123 status bar data network to wifi', - }, - ], - }); + const text = allText(result); + expect(text).toContain('Statusbar'); + expect(text).toContain('Status bar data network set to wifi'); + expect(result.isError).toBeFalsy(); }); it('should handle minimal valid parameters (Zod handles validation)', async () => { - // Note: With createTypedTool, Zod validation happens before the logic function is called - // So we test with a valid minimal parameter set since validation is handled upstream const mockExecutor = createMockExecutor({ success: true, output: 'Status bar set successfully', @@ -69,10 +65,9 @@ describe('sim_statusbar tool', () => { mockExecutor, ); - // The logic function should execute normally with valid parameters - // Zod validation errors are handled by createTypedTool wrapper - expect(result.isError).toBe(undefined); - expect(result.content[0].text).toContain('Successfully set simulator'); + expect(result.isError).toBeFalsy(); + const text = allText(result); + expect(text).toContain('Status bar data network set to wifi'); }); it('should handle command failure', async () => { @@ -89,15 +84,9 @@ describe('sim_statusbar tool', () => { mockExecutor, ); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: 'Failed to set status bar: Simulator not found', - }, - ], - isError: true, - }); + const text = allText(result); + expect(text).toContain('Failed to set status bar: Simulator not found'); + expect(result.isError).toBe(true); }); it('should handle exception with Error object', async () => { @@ -113,15 +102,9 @@ describe('sim_statusbar tool', () => { mockExecutor, ); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: 'Failed to set status bar: Connection failed', - }, - ], - isError: true, - }); + const text = allText(result); + expect(text).toContain('Failed to set status bar: Connection failed'); + expect(result.isError).toBe(true); }); it('should handle exception with string error', async () => { @@ -137,15 +120,9 @@ describe('sim_statusbar tool', () => { mockExecutor, ); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: 'Failed to set status bar: String error', - }, - ], - isError: true, - }); + const text = allText(result); + expect(text).toContain('Failed to set status bar: String error'); + expect(result.isError).toBe(true); }); it('should verify command generation with mock executor for override', async () => { @@ -252,14 +229,9 @@ describe('sim_statusbar tool', () => { mockExecutor, ); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: 'Successfully cleared status bar overrides for simulator test-uuid-123', - }, - ], - }); + const text = allText(result); + expect(text).toContain('Status bar overrides cleared'); + expect(result.isError).toBeFalsy(); }); }); }); diff --git a/src/mcp/tools/simulator-management/erase_sims.ts b/src/mcp/tools/simulator-management/erase_sims.ts index 2fa20467..c21ecd6f 100644 --- a/src/mcp/tools/simulator-management/erase_sims.ts +++ b/src/mcp/tools/simulator-management/erase_sims.ts @@ -7,6 +7,8 @@ import { createSessionAwareTool, getSessionAwareToolSchemaShape, } from '../../../utils/typed-tool-factory.ts'; +import { toolResponse } from '../../../utils/tool-response.ts'; +import { header, section, statusLine } from '../../../utils/tool-event-builders.ts'; const eraseSimsBaseSchema = z .object({ @@ -23,8 +25,13 @@ export async function erase_simsLogic( params: EraseSimsParams, executor: CommandExecutor, ): Promise { + const simulatorId = params.simulatorId; + const headerEvent = header('Erase Simulator', [ + { label: 'Simulator', value: simulatorId }, + ...(params.shutdownFirst ? [{ label: 'Shutdown First', value: 'true' }] : []), + ]); + try { - const simulatorId = params.simulatorId; log( 'info', `Erasing simulator ${simulatorId}${params.shutdownFirst ? ' (shutdownFirst=true)' : ''}`, @@ -50,32 +57,31 @@ export async function erase_simsLogic( undefined, ); if (result.success) { - return { - content: [{ type: 'text', text: `Successfully erased simulator ${simulatorId}` }], - }; + return toolResponse([headerEvent, statusLine('success', `Simulator ${simulatorId} erased`)]); } - // Add tool hint if simulator is booted and shutdownFirst was not requested const errText = result.error ?? 'Unknown error'; if (/Unable to erase contents and settings.*Booted/i.test(errText) && !params.shutdownFirst) { - return { - content: [ - { type: 'text', text: `Failed to erase simulator: ${errText}` }, - { - type: 'text', - text: `Tool hint: The simulator appears to be Booted. Re-run erase_sims with { simulatorId: '${simulatorId}', shutdownFirst: true } to shut it down before erasing.`, - }, - ], - }; + return toolResponse([ + headerEvent, + statusLine('error', `Failed to erase simulator: ${errText}`), + section('Hint', [ + `The simulator appears to be Booted. Re-run erase_sims with { simulatorId: '${simulatorId}', shutdownFirst: true } to shut it down before erasing.`, + ]), + ]); } - return { - content: [{ type: 'text', text: `Failed to erase simulator: ${errText}` }], - }; + return toolResponse([ + headerEvent, + statusLine('error', `Failed to erase simulator: ${errText}`), + ]); } catch (error: unknown) { const message = error instanceof Error ? error.message : String(error); log('error', `Error erasing simulators: ${message}`); - return { content: [{ type: 'text', text: `Failed to erase simulators: ${message}` }] }; + return toolResponse([ + headerEvent, + statusLine('error', `Failed to erase simulator: ${message}`), + ]); } } diff --git a/src/mcp/tools/simulator-management/reset_sim_location.ts b/src/mcp/tools/simulator-management/reset_sim_location.ts index fc7a15c4..ee45bbac 100644 --- a/src/mcp/tools/simulator-management/reset_sim_location.ts +++ b/src/mcp/tools/simulator-management/reset_sim_location.ts @@ -7,85 +7,53 @@ import { createSessionAwareTool, getSessionAwareToolSchemaShape, } from '../../../utils/typed-tool-factory.ts'; +import { toolResponse } from '../../../utils/tool-response.ts'; +import { header, statusLine } from '../../../utils/tool-event-builders.ts'; -// Define schema as ZodObject const resetSimulatorLocationSchema = z.object({ simulatorId: z.uuid().describe('UUID of the simulator to use (obtained from list_simulators)'), }); -// Use z.infer for type safety type ResetSimulatorLocationParams = z.infer; -// Helper function to execute simctl commands and handle responses -async function executeSimctlCommandAndRespond( +export async function reset_sim_locationLogic( params: ResetSimulatorLocationParams, - simctlSubCommand: string[], - operationDescriptionForXcodeCommand: string, - successMessage: string, - failureMessagePrefix: string, - operationLogContext: string, executor: CommandExecutor, - extraValidation?: () => ToolResponse | undefined, ): Promise { - if (extraValidation) { - const validationResult = extraValidation(); - if (validationResult) { - return validationResult; - } - } + log('info', `Resetting simulator ${params.simulatorId} location`); + + const headerEvent = header('Reset Location', [{ label: 'Simulator', value: params.simulatorId }]); try { - const command = ['xcrun', 'simctl', ...simctlSubCommand]; - const result = await executor(command, operationDescriptionForXcodeCommand, false, {}); + const command = ['xcrun', 'simctl', 'location', params.simulatorId, 'clear']; + const result = await executor(command, 'Reset Simulator Location', false, {}); if (!result.success) { - const fullFailureMessage = `${failureMessagePrefix}: ${result.error}`; log( 'error', - `${fullFailureMessage} (operation: ${operationLogContext}, simulator: ${params.simulatorId})`, + `Failed to reset simulator location: ${result.error} (simulator: ${params.simulatorId})`, ); - return { - content: [{ type: 'text', text: fullFailureMessage }], - }; + return toolResponse([ + headerEvent, + statusLine('error', `Failed to reset simulator location: ${result.error}`), + ]); } - log( - 'info', - `${successMessage} (operation: ${operationLogContext}, simulator: ${params.simulatorId})`, - ); - return { - content: [{ type: 'text', text: successMessage }], - }; + log('info', `Reset simulator ${params.simulatorId} location`); + return toolResponse([headerEvent, statusLine('success', 'Location reset to default')]); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); - const fullFailureMessage = `${failureMessagePrefix}: ${errorMessage}`; log( 'error', - `Error during ${operationLogContext} for simulator ${params.simulatorId}: ${errorMessage}`, + `Error during reset simulator location for simulator ${params.simulatorId}: ${errorMessage}`, ); - return { - content: [{ type: 'text', text: fullFailureMessage }], - }; + return toolResponse([ + headerEvent, + statusLine('error', `Failed to reset simulator location: ${errorMessage}`), + ]); } } -export async function reset_sim_locationLogic( - params: ResetSimulatorLocationParams, - executor: CommandExecutor, -): Promise { - log('info', `Resetting simulator ${params.simulatorId} location`); - - return executeSimctlCommandAndRespond( - params, - ['location', params.simulatorId, 'clear'], - 'Reset Simulator Location', - `Successfully reset simulator ${params.simulatorId} location.`, - 'Failed to reset simulator location', - 'reset simulator location', - executor, - ); -} - const publicSchemaObject = z.strictObject( resetSimulatorLocationSchema.omit({ simulatorId: true } as const).shape, ); diff --git a/src/mcp/tools/simulator-management/set_sim_appearance.ts b/src/mcp/tools/simulator-management/set_sim_appearance.ts index cb272b30..4c4f8903 100644 --- a/src/mcp/tools/simulator-management/set_sim_appearance.ts +++ b/src/mcp/tools/simulator-management/set_sim_appearance.ts @@ -7,87 +7,60 @@ import { createSessionAwareTool, getSessionAwareToolSchemaShape, } from '../../../utils/typed-tool-factory.ts'; +import { toolResponse } from '../../../utils/tool-response.ts'; +import { header, statusLine } from '../../../utils/tool-event-builders.ts'; -// Define schema as ZodObject const setSimAppearanceSchema = z.object({ simulatorId: z.uuid().describe('UUID of the simulator to use (obtained from list_simulators)'), mode: z.enum(['dark', 'light']).describe('dark|light'), }); -// Use z.infer for type safety type SetSimAppearanceParams = z.infer; -// Helper function to execute simctl commands and handle responses -async function executeSimctlCommandAndRespond( +export async function set_sim_appearanceLogic( params: SetSimAppearanceParams, - simctlSubCommand: string[], - operationDescriptionForXcodeCommand: string, - successMessage: string, - failureMessagePrefix: string, - operationLogContext: string, - extraValidation?: () => ToolResponse | undefined, - executor: CommandExecutor = getDefaultCommandExecutor(), + executor: CommandExecutor, ): Promise { - if (extraValidation) { - const validationResult = extraValidation(); - if (validationResult) { - return validationResult; - } - } + log('info', `Setting simulator ${params.simulatorId} appearance to ${params.mode} mode`); + + const headerEvent = header('Set Appearance', [ + { label: 'Simulator', value: params.simulatorId }, + { label: 'Mode', value: params.mode }, + ]); try { - const command = ['xcrun', 'simctl', ...simctlSubCommand]; - const result = await executor(command, operationDescriptionForXcodeCommand, false, undefined); + const command = ['xcrun', 'simctl', 'ui', params.simulatorId, 'appearance', params.mode]; + const result = await executor(command, 'Set Simulator Appearance', false); if (!result.success) { - const fullFailureMessage = `${failureMessagePrefix}: ${result.error}`; log( 'error', - `${fullFailureMessage} (operation: ${operationLogContext}, simulator: ${params.simulatorId})`, + `Failed to set simulator appearance: ${result.error} (simulator: ${params.simulatorId})`, ); - return { - content: [{ type: 'text', text: fullFailureMessage }], - }; + return toolResponse([ + headerEvent, + statusLine('error', `Failed to set simulator appearance: ${result.error}`), + ]); } - log( - 'info', - `${successMessage} (operation: ${operationLogContext}, simulator: ${params.simulatorId})`, - ); - return { - content: [{ type: 'text', text: successMessage }], - }; + log('info', `Set simulator ${params.simulatorId} appearance to ${params.mode} mode`); + return toolResponse([ + headerEvent, + statusLine('success', `Appearance set to ${params.mode} mode`), + ]); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); - const fullFailureMessage = `${failureMessagePrefix}: ${errorMessage}`; log( 'error', - `Error during ${operationLogContext} for simulator ${params.simulatorId}: ${errorMessage}`, + `Error during set simulator appearance for simulator ${params.simulatorId}: ${errorMessage}`, ); - return { - content: [{ type: 'text', text: fullFailureMessage }], - }; + return toolResponse([ + headerEvent, + statusLine('error', `Failed to set simulator appearance: ${errorMessage}`), + ]); } } -export async function set_sim_appearanceLogic( - params: SetSimAppearanceParams, - executor: CommandExecutor, -): Promise { - log('info', `Setting simulator ${params.simulatorId} appearance to ${params.mode} mode`); - - return executeSimctlCommandAndRespond( - params, - ['ui', params.simulatorId, 'appearance', params.mode], - 'Set Simulator Appearance', - `Successfully set simulator ${params.simulatorId} appearance to ${params.mode} mode`, - 'Failed to set simulator appearance', - 'set simulator appearance', - undefined, - executor, - ); -} - const publicSchemaObject = z.strictObject( setSimAppearanceSchema.omit({ simulatorId: true } as const).shape, ); diff --git a/src/mcp/tools/simulator-management/set_sim_location.ts b/src/mcp/tools/simulator-management/set_sim_location.ts index f2fc0a99..b368cb55 100644 --- a/src/mcp/tools/simulator-management/set_sim_location.ts +++ b/src/mcp/tools/simulator-management/set_sim_location.ts @@ -7,115 +7,72 @@ import { createSessionAwareTool, getSessionAwareToolSchemaShape, } from '../../../utils/typed-tool-factory.ts'; +import { toolResponse } from '../../../utils/tool-response.ts'; +import { header, statusLine } from '../../../utils/tool-event-builders.ts'; -// Define schema as ZodObject const setSimulatorLocationSchema = z.object({ simulatorId: z.uuid().describe('UUID of the simulator to use (obtained from list_simulators)'), latitude: z.number(), longitude: z.number(), }); -// Use z.infer for type safety type SetSimulatorLocationParams = z.infer; -// Helper function to execute simctl commands and handle responses -async function executeSimctlCommandAndRespond( +export async function set_sim_locationLogic( params: SetSimulatorLocationParams, - simctlSubCommand: string[], - operationDescriptionForXcodeCommand: string, - successMessage: string, - failureMessagePrefix: string, - operationLogContext: string, - executor: CommandExecutor = getDefaultCommandExecutor(), - extraValidation?: () => ToolResponse | null, + executor: CommandExecutor, ): Promise { - if (extraValidation) { - const validationResult = extraValidation(); - if (validationResult) { - return validationResult; - } + const coords = `${params.latitude},${params.longitude}`; + const headerEvent = header('Set Location', [ + { label: 'Simulator', value: params.simulatorId }, + { label: 'Coordinates', value: coords }, + ]); + + if (params.latitude < -90 || params.latitude > 90) { + return toolResponse([ + headerEvent, + statusLine('error', 'Latitude must be between -90 and 90 degrees'), + ]); + } + if (params.longitude < -180 || params.longitude > 180) { + return toolResponse([ + headerEvent, + statusLine('error', 'Longitude must be between -180 and 180 degrees'), + ]); } + log('info', `Setting simulator ${params.simulatorId} location to ${coords}`); + try { - const command = ['xcrun', 'simctl', ...simctlSubCommand]; - const result = await executor(command, operationDescriptionForXcodeCommand, false, {}); + const command = ['xcrun', 'simctl', 'location', params.simulatorId, 'set', coords]; + const result = await executor(command, 'Set Simulator Location', false, {}); if (!result.success) { - const fullFailureMessage = `${failureMessagePrefix}: ${result.error}`; log( 'error', - `${fullFailureMessage} (operation: ${operationLogContext}, simulator: ${params.simulatorId})`, + `Failed to set simulator location: ${result.error} (simulator: ${params.simulatorId})`, ); - return { - content: [{ type: 'text', text: fullFailureMessage }], - }; + return toolResponse([ + headerEvent, + statusLine('error', `Failed to set simulator location: ${result.error}`), + ]); } - log( - 'info', - `${successMessage} (operation: ${operationLogContext}, simulator: ${params.simulatorId})`, - ); - return { - content: [{ type: 'text', text: successMessage }], - }; + log('info', `Set simulator ${params.simulatorId} location to ${coords}`); + return toolResponse([headerEvent, statusLine('success', `Location set to ${coords}`)]); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); - const fullFailureMessage = `${failureMessagePrefix}: ${errorMessage}`; log( 'error', - `Error during ${operationLogContext} for simulator ${params.simulatorId}: ${errorMessage}`, + `Error during set simulator location for simulator ${params.simulatorId}: ${errorMessage}`, ); - return { - content: [{ type: 'text', text: fullFailureMessage }], - }; + return toolResponse([ + headerEvent, + statusLine('error', `Failed to set simulator location: ${errorMessage}`), + ]); } } -export async function set_sim_locationLogic( - params: SetSimulatorLocationParams, - executor: CommandExecutor, -): Promise { - const extraValidation = (): ToolResponse | null => { - if (params.latitude < -90 || params.latitude > 90) { - return { - content: [ - { - type: 'text', - text: 'Latitude must be between -90 and 90 degrees', - }, - ], - }; - } - if (params.longitude < -180 || params.longitude > 180) { - return { - content: [ - { - type: 'text', - text: 'Longitude must be between -180 and 180 degrees', - }, - ], - }; - } - return null; - }; - - log( - 'info', - `Setting simulator ${params.simulatorId} location to ${params.latitude},${params.longitude}`, - ); - - return executeSimctlCommandAndRespond( - params, - ['location', params.simulatorId, 'set', `${params.latitude},${params.longitude}`], - 'Set Simulator Location', - `Successfully set simulator ${params.simulatorId} location to ${params.latitude},${params.longitude}`, - 'Failed to set simulator location', - 'set simulator location', - executor, - extraValidation, - ); -} - const publicSchemaObject = z.strictObject( setSimulatorLocationSchema.omit({ simulatorId: true } as const).shape, ); diff --git a/src/mcp/tools/simulator-management/sim_statusbar.ts b/src/mcp/tools/simulator-management/sim_statusbar.ts index 6ee5390f..45b64dfc 100644 --- a/src/mcp/tools/simulator-management/sim_statusbar.ts +++ b/src/mcp/tools/simulator-management/sim_statusbar.ts @@ -7,8 +7,9 @@ import { createSessionAwareTool, getSessionAwareToolSchemaShape, } from '../../../utils/typed-tool-factory.ts'; +import { toolResponse } from '../../../utils/tool-response.ts'; +import { header, statusLine } from '../../../utils/tool-event-builders.ts'; -// Define schema as ZodObject const simStatusbarSchema = z.object({ simulatorId: z.uuid().describe('UUID of the simulator to use (obtained from list_simulators)'), dataNetwork: z @@ -29,7 +30,6 @@ const simStatusbarSchema = z.object({ .describe('clear|hide|wifi|3g|4g|lte|lte-a|lte+|5g|5g+|5g-uwb|5g-uc'), }); -// Use z.infer for type safety type SimStatusbarParams = z.infer; export async function sim_statusbarLogic( @@ -41,13 +41,16 @@ export async function sim_statusbarLogic( `Setting simulator ${params.simulatorId} status bar data network to ${params.dataNetwork}`, ); + const headerEvent = header('Statusbar', [ + { label: 'Simulator', value: params.simulatorId }, + { label: 'Data Network', value: params.dataNetwork }, + ]); + try { let command: string[]; - let successMessage: string; if (params.dataNetwork === 'clear') { command = ['xcrun', 'simctl', 'status_bar', params.simulatorId, 'clear']; - successMessage = `Successfully cleared status bar overrides for simulator ${params.simulatorId}`; } else { command = [ 'xcrun', @@ -58,32 +61,32 @@ export async function sim_statusbarLogic( '--dataNetwork', params.dataNetwork, ]; - successMessage = `Successfully set simulator ${params.simulatorId} status bar data network to ${params.dataNetwork}`; } - const result = await executor(command, 'Set Status Bar', false, undefined); + const result = await executor(command, 'Set Status Bar', false); if (!result.success) { - const failureMessage = `Failed to set status bar: ${result.error}`; - log('error', `${failureMessage} (simulator: ${params.simulatorId})`); - return { - content: [{ type: 'text', text: failureMessage }], - isError: true, - }; + log('error', `Failed to set status bar: ${result.error} (simulator: ${params.simulatorId})`); + return toolResponse([ + headerEvent, + statusLine('error', `Failed to set status bar: ${result.error}`), + ]); } - log('info', `${successMessage} (simulator: ${params.simulatorId})`); - return { - content: [{ type: 'text', text: successMessage }], - }; + const successMsg = + params.dataNetwork === 'clear' + ? 'Status bar overrides cleared' + : `Status bar data network set to ${params.dataNetwork}`; + + log('info', `${successMsg} (simulator: ${params.simulatorId})`); + return toolResponse([headerEvent, statusLine('success', successMsg)]); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); - const failureMessage = `Failed to set status bar: ${errorMessage}`; log('error', `Error setting status bar for simulator ${params.simulatorId}: ${errorMessage}`); - return { - content: [{ type: 'text', text: failureMessage }], - isError: true, - }; + return toolResponse([ + headerEvent, + statusLine('error', `Failed to set status bar: ${errorMessage}`), + ]); } } diff --git a/src/mcp/tools/simulator/__tests__/boot_sim.test.ts b/src/mcp/tools/simulator/__tests__/boot_sim.test.ts index 1ad047db..60a3d43b 100644 --- a/src/mcp/tools/simulator/__tests__/boot_sim.test.ts +++ b/src/mcp/tools/simulator/__tests__/boot_sim.test.ts @@ -1,8 +1,3 @@ -/** - * Tests for boot_sim plugin (session-aware version) - * Follows CLAUDE.md guidance: dependency injection, no vi-mocks, literal validation. - */ - import { describe, it, expect, beforeEach } from 'vitest'; import * as z from 'zod'; import { @@ -11,6 +6,14 @@ import { } from '../../../../test-utils/mock-executors.ts'; import { sessionStore } from '../../../../utils/session-store.ts'; import { schema, handler, boot_simLogic } from '../boot_sim.ts'; +import type { ToolResponse } from '../../../../types/common.ts'; + +function allText(result: ToolResponse): string { + return result.content + .filter((c) => c.type === 'text') + .map((c) => c.text) + .join('\n'); +} describe('boot_sim tool', () => { beforeEach(() => { @@ -50,18 +53,14 @@ describe('boot_sim tool', () => { const result = await boot_simLogic({ simulatorId: 'test-uuid-123' }, mockExecutor); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: 'Simulator booted successfully.', - }, - ], - nextStepParams: { - open_sim: {}, - install_app_sim: { simulatorId: 'test-uuid-123', appPath: 'PATH_TO_YOUR_APP' }, - launch_app_sim: { simulatorId: 'test-uuid-123', bundleId: 'YOUR_APP_BUNDLE_ID' }, - }, + const text = allText(result); + expect(text).toContain('Boot Simulator'); + expect(text).toContain('Simulator booted successfully'); + expect(result.isError).toBeFalsy(); + expect(result.nextStepParams).toEqual({ + open_sim: {}, + install_app_sim: { simulatorId: 'test-uuid-123', appPath: 'PATH_TO_YOUR_APP' }, + launch_app_sim: { simulatorId: 'test-uuid-123', bundleId: 'YOUR_APP_BUNDLE_ID' }, }); }); @@ -73,14 +72,9 @@ describe('boot_sim tool', () => { const result = await boot_simLogic({ simulatorId: 'invalid-uuid' }, mockExecutor); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: 'Boot simulator operation failed: Simulator not found', - }, - ], - }); + const text = allText(result); + expect(text).toContain('Boot simulator operation failed: Simulator not found'); + expect(result.isError).toBe(true); }); it('should handle exception with Error object', async () => { @@ -90,14 +84,9 @@ describe('boot_sim tool', () => { const result = await boot_simLogic({ simulatorId: 'test-uuid-123' }, mockExecutor); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: 'Boot simulator operation failed: Connection failed', - }, - ], - }); + const text = allText(result); + expect(text).toContain('Boot simulator operation failed: Connection failed'); + expect(result.isError).toBe(true); }); it('should handle exception with string error', async () => { @@ -107,14 +96,9 @@ describe('boot_sim tool', () => { const result = await boot_simLogic({ simulatorId: 'test-uuid-123' }, mockExecutor); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: 'Boot simulator operation failed: String error', - }, - ], - }); + const text = allText(result); + expect(text).toContain('Boot simulator operation failed: String error'); + expect(result.isError).toBe(true); }); it('should verify command generation with mock executor', async () => { diff --git a/src/mcp/tools/simulator/__tests__/build_run_sim.test.ts b/src/mcp/tools/simulator/__tests__/build_run_sim.test.ts index 23cf6801..10793d06 100644 --- a/src/mcp/tools/simulator/__tests__/build_run_sim.test.ts +++ b/src/mcp/tools/simulator/__tests__/build_run_sim.test.ts @@ -246,14 +246,16 @@ describe('build_run_sim tool', () => { expect.objectContaining({ tailEvents: [ expect.objectContaining({ - type: 'notice', - code: 'build-run-result', - data: expect.objectContaining({ - scheme: 'MyScheme', - appPath: '/path/to/build/MyApp.app', - bundleId: 'io.sentry.MyApp', - launchState: 'requested', - }), + type: 'status-line', + level: 'success', + message: 'Build & Run complete', + }), + expect.objectContaining({ + type: 'detail-tree', + items: expect.arrayContaining([ + expect.objectContaining({ label: 'App Path', value: '/path/to/build/MyApp.app' }), + expect.objectContaining({ label: 'Bundle ID', value: 'io.sentry.MyApp' }), + ]), }), ], }), @@ -721,11 +723,6 @@ describe('build_run_sim tool', () => { // Summary expect(textContent).toContain('Build succeeded.'); - // Footer with execution-derived values - expect(textContent).toContain('Build & Run complete'); - expect(textContent).toContain('App Path: /path/to/build/MyApp.app'); - expect(textContent).toContain('Bundle ID: io.sentry.MyApp'); - // No next steps in finalized output (those come from tool invoker) expect(textContent).not.toContain('Next steps:'); }); diff --git a/src/mcp/tools/simulator/__tests__/get_sim_app_path.test.ts b/src/mcp/tools/simulator/__tests__/get_sim_app_path.test.ts index 0c7dbdd6..84cb742f 100644 --- a/src/mcp/tools/simulator/__tests__/get_sim_app_path.test.ts +++ b/src/mcp/tools/simulator/__tests__/get_sim_app_path.test.ts @@ -158,16 +158,15 @@ describe('get_sim_app_path tool', () => { 'platform=iOS Simulator,name=iPhone 17,OS=latest', ]); - expect(result.isError).toBe(false); - const text = result.content[0].text; - expect(text).toContain('\u{1F50D} Get App Path'); - expect(text).toContain('Scheme: MyScheme'); - expect(text).toContain('Workspace: /path/to/workspace.xcworkspace'); - expect(text).toContain('Configuration: Debug'); - expect(text).toContain('Platform: iOS Simulator'); - expect(text).toContain('Simulator: iPhone 17'); - expect(text).toContain('\u{2514} App Path: /tmp/DerivedData/Build/MyApp.app'); - expect(text).not.toContain('\u{2705}'); + expect(result.isError).not.toBe(true); + const text = result.content.map((c) => c.text).join('\n'); + expect(text).toContain('Get App Path'); + expect(text).toContain('MyScheme'); + expect(text).toContain('/path/to/workspace.xcworkspace'); + expect(text).toContain('Debug'); + expect(text).toContain('iOS Simulator'); + expect(text).toContain('iPhone 17'); + expect(text).toContain('/tmp/DerivedData/Build/MyApp.app'); expect(result.nextStepParams).toBeDefined(); }); @@ -188,12 +187,10 @@ describe('get_sim_app_path tool', () => { ); expect(result.isError).toBe(true); - const text = result.content[0].text; - expect(text).toContain('\u{1F50D} Get App Path'); - expect(text).toContain('Scheme: MyScheme'); - expect(text).toContain('Errors ('); - expect(text).toContain('\u{2717}'); - expect(text).toContain('\u{274C} Query failed.'); + const text = result.content.map((c) => c.text).join('\n'); + expect(text).toContain('Get App Path'); + expect(text).toContain('MyScheme'); + expect(text).toContain('Failed to get build settings'); expect(result.nextStepParams).toBeUndefined(); }); }); diff --git a/src/mcp/tools/simulator/__tests__/install_app_sim.test.ts b/src/mcp/tools/simulator/__tests__/install_app_sim.test.ts index b983d856..851d8c8b 100644 --- a/src/mcp/tools/simulator/__tests__/install_app_sim.test.ts +++ b/src/mcp/tools/simulator/__tests__/install_app_sim.test.ts @@ -88,13 +88,11 @@ describe('install_app_sim tool', () => { ['xcrun', 'simctl', 'install', 'test-uuid-123', '/path/to/app.app'], 'Install App in Simulator', false, - undefined, ], [ ['defaults', 'read', '/path/to/app.app/Info', 'CFBundleIdentifier'], 'Extract Bundle ID', false, - undefined, ], ]); }); @@ -129,13 +127,11 @@ describe('install_app_sim tool', () => { ['xcrun', 'simctl', 'install', 'different-uuid-456', '/different/path/MyApp.app'], 'Install App in Simulator', false, - undefined, ], [ ['defaults', 'read', '/different/path/MyApp.app/Info', 'CFBundleIdentifier'], 'Extract Bundle ID', false, - undefined, ], ]); }); @@ -156,15 +152,12 @@ describe('install_app_sim tool', () => { mockFileSystem, ); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: "File not found: '/path/to/app.app'. Please check the path and try again.", - }, - ], - isError: true, - }); + expect(result.isError).toBe(true); + const text = result.content + .filter((i) => i.type === 'text') + .map((i) => i.text) + .join('\n'); + expect(text).toContain("File not found: '/path/to/app.app'"); }); it('should handle bundle id extraction failure gracefully', async () => { @@ -206,17 +199,12 @@ describe('install_app_sim tool', () => { mockFileSystem, ); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: 'App installed successfully in simulator test-uuid-123.', - }, - ], - nextStepParams: { - open_sim: {}, - launch_app_sim: { simulatorId: 'test-uuid-123', bundleId: 'YOUR_APP_BUNDLE_ID' }, - }, + const text = result.content.map((c) => c.text).join('\n'); + expect(text).toContain('App installed successfully'); + expect(text).toContain('test-uuid-123'); + expect(result.nextStepParams).toEqual({ + open_sim: {}, + launch_app_sim: { simulatorId: 'test-uuid-123', bundleId: 'YOUR_APP_BUNDLE_ID' }, }); expect(bundleIdCalls).toHaveLength(2); }); @@ -260,17 +248,12 @@ describe('install_app_sim tool', () => { mockFileSystem, ); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: 'App installed successfully in simulator test-uuid-123.', - }, - ], - nextStepParams: { - open_sim: {}, - launch_app_sim: { simulatorId: 'test-uuid-123', bundleId: 'io.sentry.myapp' }, - }, + const text = result.content.map((c) => c.text).join('\n'); + expect(text).toContain('App installed successfully'); + expect(text).toContain('test-uuid-123'); + expect(result.nextStepParams).toEqual({ + open_sim: {}, + launch_app_sim: { simulatorId: 'test-uuid-123', bundleId: 'io.sentry.myapp' }, }); expect(bundleIdCalls).toHaveLength(2); }); @@ -298,14 +281,10 @@ describe('install_app_sim tool', () => { mockFileSystem, ); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: 'Install app in simulator operation failed: Install failed', - }, - ], - }); + const text = result.content.map((c) => c.text).join('\n'); + expect(text).toContain('Install app in simulator operation failed'); + expect(text).toContain('Install failed'); + expect(result.isError).toBe(true); }); it('should handle exception with Error object', async () => { @@ -324,14 +303,10 @@ describe('install_app_sim tool', () => { mockFileSystem, ); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: 'Install app in simulator operation failed: Command execution failed', - }, - ], - }); + const text = result.content.map((c) => c.text).join('\n'); + expect(text).toContain('Install app in simulator operation failed'); + expect(text).toContain('Command execution failed'); + expect(result.isError).toBe(true); }); it('should handle exception with string error', async () => { @@ -350,14 +325,10 @@ describe('install_app_sim tool', () => { mockFileSystem, ); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: 'Install app in simulator operation failed: String error', - }, - ], - }); + const text = result.content.map((c) => c.text).join('\n'); + expect(text).toContain('Install app in simulator operation failed'); + expect(text).toContain('String error'); + expect(result.isError).toBe(true); }); }); }); diff --git a/src/mcp/tools/simulator/__tests__/launch_app_logs_sim.test.ts b/src/mcp/tools/simulator/__tests__/launch_app_logs_sim.test.ts index 32c9177a..e1818d49 100644 --- a/src/mcp/tools/simulator/__tests__/launch_app_logs_sim.test.ts +++ b/src/mcp/tools/simulator/__tests__/launch_app_logs_sim.test.ts @@ -82,18 +82,16 @@ describe('launch_app_logs_sim tool', () => { logCaptureStub, ); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: 'App launched successfully in simulator test-uuid-123 with log capture enabled.\n\nLog capture session ID: test-session-123\n\nInteract with your app in the simulator, then stop capture to retrieve logs.', - }, - ], - nextStepParams: { - stop_sim_log_cap: { logSessionId: 'test-session-123' }, - }, - isError: false, + const text = result.content.map((c: { text: string }) => c.text).join('\n'); + expect(text).toContain('Launch App'); + expect(text).toContain('App launched successfully'); + expect(text).toContain('test-uuid-123'); + expect(text).toContain('log capture enabled'); + expect(text).toContain('test-session-123'); + expect(result.nextStepParams).toEqual({ + stop_sim_log_cap: { logSessionId: 'test-session-123' }, }); + expect(result.isError).not.toBe(true); expect(capturedParams).toEqual({ simulatorUuid: 'test-uuid-123', @@ -215,15 +213,10 @@ describe('launch_app_logs_sim tool', () => { logCaptureStub, ); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: 'Failed to launch app with log capture: Failed to start log capture', - }, - ], - isError: true, - }); + const text = result.content.map((c: { text: string }) => c.text).join('\n'); + expect(text).toContain('Failed to launch app with log capture'); + expect(text).toContain('Failed to start log capture'); + expect(result.isError).toBe(true); }); }); }); diff --git a/src/mcp/tools/simulator/__tests__/launch_app_sim.test.ts b/src/mcp/tools/simulator/__tests__/launch_app_sim.test.ts index 8ffbd533..06b72132 100644 --- a/src/mcp/tools/simulator/__tests__/launch_app_sim.test.ts +++ b/src/mcp/tools/simulator/__tests__/launch_app_sim.test.ts @@ -97,24 +97,20 @@ describe('launch_app_sim tool', () => { sequencedExecutor, ); - expect(result).toEqual({ - content: [ + const text = result.content.map((c: { text: string }) => c.text).join('\n'); + expect(text).toContain('Launch App'); + expect(text).toContain('App launched successfully'); + expect(text).toContain('test-uuid-123'); + expect(result.nextStepParams).toEqual({ + open_sim: {}, + start_sim_log_cap: [ + { simulatorId: 'test-uuid-123', bundleId: 'io.sentry.testapp' }, { - type: 'text', - text: 'App launched successfully in simulator test-uuid-123.', + simulatorId: 'test-uuid-123', + bundleId: 'io.sentry.testapp', + captureConsole: true, }, ], - nextStepParams: { - open_sim: {}, - start_sim_log_cap: [ - { simulatorId: 'test-uuid-123', bundleId: 'io.sentry.testapp' }, - { - simulatorId: 'test-uuid-123', - bundleId: 'io.sentry.testapp', - captureConsole: true, - }, - ], - }, }); }); @@ -185,24 +181,20 @@ describe('launch_app_sim tool', () => { sequencedExecutor, ); - expect(result).toEqual({ - content: [ + const text = result.content.map((c: { text: string }) => c.text).join('\n'); + expect(text).toContain('Launch App'); + expect(text).toContain('App launched successfully'); + expect(text).toContain('"iPhone 17" (resolved-uuid)'); + expect(result.nextStepParams).toEqual({ + open_sim: {}, + start_sim_log_cap: [ + { simulatorId: 'resolved-uuid', bundleId: 'io.sentry.testapp' }, { - type: 'text', - text: 'App launched successfully in simulator "iPhone 17" (resolved-uuid).', + simulatorId: 'resolved-uuid', + bundleId: 'io.sentry.testapp', + captureConsole: true, }, ], - nextStepParams: { - open_sim: {}, - start_sim_log_cap: [ - { simulatorId: 'resolved-uuid', bundleId: 'io.sentry.testapp' }, - { - simulatorId: 'resolved-uuid', - bundleId: 'io.sentry.testapp', - captureConsole: true, - }, - ], - }, }); }); @@ -232,15 +224,10 @@ describe('launch_app_sim tool', () => { mockExecutor, ); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: `App is not installed on the simulator. Please use install_app_sim before launching.\n\nWorkflow: build โ†’ install โ†’ launch.`, - }, - ], - isError: true, - }); + const text = result.content.map((c: { text: string }) => c.text).join('\n'); + expect(text).toContain('App is not installed on the simulator'); + expect(text).toContain('install_app_sim'); + expect(result.isError).toBe(true); }); it('should return error when install check throws', async () => { @@ -264,15 +251,10 @@ describe('launch_app_sim tool', () => { mockExecutor, ); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: `App is not installed on the simulator (check failed). Please use install_app_sim before launching.\n\nWorkflow: build โ†’ install โ†’ launch.`, - }, - ], - isError: true, - }); + const text = result.content.map((c: { text: string }) => c.text).join('\n'); + expect(text).toContain('App is not installed on the simulator (check failed)'); + expect(text).toContain('install_app_sim'); + expect(result.isError).toBe(true); }); it('should handle launch failure', async () => { @@ -303,14 +285,10 @@ describe('launch_app_sim tool', () => { mockExecutor, ); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: 'Launch app in simulator operation failed: Launch failed', - }, - ], - }); + const text = result.content.map((c: { text: string }) => c.text).join('\n'); + expect(text).toContain('Launch app in simulator operation failed'); + expect(text).toContain('Launch failed'); + expect(result.isError).toBe(true); }); it('should pass env vars with SIMCTL_CHILD_ prefix to executor opts', async () => { diff --git a/src/mcp/tools/simulator/__tests__/list_sims.test.ts b/src/mcp/tools/simulator/__tests__/list_sims.test.ts index bc3bc7f6..ae5da58e 100644 --- a/src/mcp/tools/simulator/__tests__/list_sims.test.ts +++ b/src/mcp/tools/simulator/__tests__/list_sims.test.ts @@ -142,28 +142,20 @@ describe('list_sims tool', () => { env: undefined, }); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: `Available iOS Simulators: - -iOS 17.0: -- iPhone 15 (test-uuid-123) - -Hint: Save a default simulator with session-set-defaults { simulatorId: 'UUID_FROM_ABOVE' } (or simulatorName). -Before running build/run/test/UI automation tools, set the desired simulator identifier in session defaults.`, - }, - ], - nextStepParams: { - boot_sim: { simulatorId: 'UUID_FROM_ABOVE' }, - open_sim: {}, - build_sim: { scheme: 'YOUR_SCHEME', simulatorId: 'UUID_FROM_ABOVE' }, - get_sim_app_path: { - scheme: 'YOUR_SCHEME', - platform: 'iOS Simulator', - simulatorId: 'UUID_FROM_ABOVE', - }, + const text = result.content.map((c) => c.text).join('\n'); + expect(text).toContain('List Simulators'); + expect(text).toContain('iOS 17.0'); + expect(text).toContain('iPhone 15'); + expect(text).toContain('test-uuid-123'); + expect(text).toContain('Shutdown'); + expect(result.nextStepParams).toEqual({ + boot_sim: { simulatorId: 'UUID_FROM_ABOVE' }, + open_sim: {}, + build_sim: { scheme: 'YOUR_SCHEME', simulatorId: 'UUID_FROM_ABOVE' }, + get_sim_app_path: { + scheme: 'YOUR_SCHEME', + platform: 'iOS Simulator', + simulatorId: 'UUID_FROM_ABOVE', }, }); }); @@ -203,28 +195,20 @@ Before running build/run/test/UI automation tools, set the desired simulator ide const result = await list_simsLogic({ enabled: true }, mockExecutor); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: `Available iOS Simulators: - -iOS 17.0: -- iPhone 15 (test-uuid-123) [Booted] - -Hint: Save a default simulator with session-set-defaults { simulatorId: 'UUID_FROM_ABOVE' } (or simulatorName). -Before running build/run/test/UI automation tools, set the desired simulator identifier in session defaults.`, - }, - ], - nextStepParams: { - boot_sim: { simulatorId: 'UUID_FROM_ABOVE' }, - open_sim: {}, - build_sim: { scheme: 'YOUR_SCHEME', simulatorId: 'UUID_FROM_ABOVE' }, - get_sim_app_path: { - scheme: 'YOUR_SCHEME', - platform: 'iOS Simulator', - simulatorId: 'UUID_FROM_ABOVE', - }, + const text = result.content.map((c) => c.text).join('\n'); + expect(text).toContain('List Simulators'); + expect(text).toContain('iOS 17.0'); + expect(text).toContain('iPhone 15'); + expect(text).toContain('test-uuid-123'); + expect(text).toContain('Booted'); + expect(result.nextStepParams).toEqual({ + boot_sim: { simulatorId: 'UUID_FROM_ABOVE' }, + open_sim: {}, + build_sim: { scheme: 'YOUR_SCHEME', simulatorId: 'UUID_FROM_ABOVE' }, + get_sim_app_path: { + scheme: 'YOUR_SCHEME', + platform: 'iOS Simulator', + simulatorId: 'UUID_FROM_ABOVE', }, }); }); @@ -266,32 +250,21 @@ Before running build/run/test/UI automation tools, set the desired simulator ide const result = await list_simsLogic({ enabled: true }, mockExecutor); - // Should contain both iOS 18.6 from JSON and iOS 26.0 from text - expect(result).toEqual({ - content: [ - { - type: 'text', - text: `Available iOS Simulators: - -iOS 18.6: -- iPhone 15 (json-uuid-123) - -iOS 26.0: -- iPhone 17 Pro (text-uuid-456) - -Hint: Save a default simulator with session-set-defaults { simulatorId: 'UUID_FROM_ABOVE' } (or simulatorName). -Before running build/run/test/UI automation tools, set the desired simulator identifier in session defaults.`, - }, - ], - nextStepParams: { - boot_sim: { simulatorId: 'UUID_FROM_ABOVE' }, - open_sim: {}, - build_sim: { scheme: 'YOUR_SCHEME', simulatorId: 'UUID_FROM_ABOVE' }, - get_sim_app_path: { - scheme: 'YOUR_SCHEME', - platform: 'iOS Simulator', - simulatorId: 'UUID_FROM_ABOVE', - }, + const text = result.content.map((c) => c.text).join('\n'); + expect(text).toContain('iOS 18.6'); + expect(text).toContain('iPhone 15'); + expect(text).toContain('json-uuid-123'); + expect(text).toContain('iOS 26.0'); + expect(text).toContain('iPhone 17 Pro'); + expect(text).toContain('text-uuid-456'); + expect(result.nextStepParams).toEqual({ + boot_sim: { simulatorId: 'UUID_FROM_ABOVE' }, + open_sim: {}, + build_sim: { scheme: 'YOUR_SCHEME', simulatorId: 'UUID_FROM_ABOVE' }, + get_sim_app_path: { + scheme: 'YOUR_SCHEME', + platform: 'iOS Simulator', + simulatorId: 'UUID_FROM_ABOVE', }, }); }); @@ -306,14 +279,9 @@ Before running build/run/test/UI automation tools, set the desired simulator ide const result = await list_simsLogic({ enabled: true }, mockExecutor); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: 'Failed to list simulators: Command failed', - }, - ], - }); + const text = result.content.map((c) => c.text).join('\n'); + expect(text).toContain('Failed to list simulators'); + expect(text).toContain('Command failed'); }); it('should handle JSON parse failure and fall back to text parsing', async () => { @@ -341,29 +309,18 @@ Before running build/run/test/UI automation tools, set the desired simulator ide const result = await list_simsLogic({ enabled: true }, mockExecutor); - // Should fall back to text parsing and extract devices - expect(result).toEqual({ - content: [ - { - type: 'text', - text: `Available iOS Simulators: - -iOS 17.0: -- iPhone 15 (test-uuid-456) - -Hint: Save a default simulator with session-set-defaults { simulatorId: 'UUID_FROM_ABOVE' } (or simulatorName). -Before running build/run/test/UI automation tools, set the desired simulator identifier in session defaults.`, - }, - ], - nextStepParams: { - boot_sim: { simulatorId: 'UUID_FROM_ABOVE' }, - open_sim: {}, - build_sim: { scheme: 'YOUR_SCHEME', simulatorId: 'UUID_FROM_ABOVE' }, - get_sim_app_path: { - scheme: 'YOUR_SCHEME', - platform: 'iOS Simulator', - simulatorId: 'UUID_FROM_ABOVE', - }, + const text = result.content.map((c) => c.text).join('\n'); + expect(text).toContain('iOS 17.0'); + expect(text).toContain('iPhone 15'); + expect(text).toContain('test-uuid-456'); + expect(result.nextStepParams).toEqual({ + boot_sim: { simulatorId: 'UUID_FROM_ABOVE' }, + open_sim: {}, + build_sim: { scheme: 'YOUR_SCHEME', simulatorId: 'UUID_FROM_ABOVE' }, + get_sim_app_path: { + scheme: 'YOUR_SCHEME', + platform: 'iOS Simulator', + simulatorId: 'UUID_FROM_ABOVE', }, }); }); @@ -373,14 +330,9 @@ Before running build/run/test/UI automation tools, set the desired simulator ide const result = await list_simsLogic({ enabled: true }, mockExecutor); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: 'Failed to list simulators: Command execution failed', - }, - ], - }); + const text = result.content.map((c) => c.text).join('\n'); + expect(text).toContain('Failed to list simulators'); + expect(text).toContain('Command execution failed'); }); it('should handle exception with string error', async () => { @@ -388,14 +340,9 @@ Before running build/run/test/UI automation tools, set the desired simulator ide const result = await list_simsLogic({ enabled: true }, mockExecutor); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: 'Failed to list simulators: String error', - }, - ], - }); + const text = result.content.map((c) => c.text).join('\n'); + expect(text).toContain('Failed to list simulators'); + expect(text).toContain('String error'); }); }); }); diff --git a/src/mcp/tools/simulator/__tests__/open_sim.test.ts b/src/mcp/tools/simulator/__tests__/open_sim.test.ts index d7dda558..dc3789ef 100644 --- a/src/mcp/tools/simulator/__tests__/open_sim.test.ts +++ b/src/mcp/tools/simulator/__tests__/open_sim.test.ts @@ -1,9 +1,3 @@ -/** - * Tests for open_sim plugin - * Following CLAUDE.md testing standards with literal validation - * Using dependency injection for deterministic testing - */ - import { describe, it, expect } from 'vitest'; import * as z from 'zod'; import { @@ -12,6 +6,14 @@ import { type CommandExecutor, } from '../../../../test-utils/mock-executors.ts'; import { schema, handler, open_simLogic } from '../open_sim.ts'; +import type { ToolResponse } from '../../../../types/common.ts'; + +function allText(result: ToolResponse): string { + return result.content + .filter((c) => c.type === 'text') + .map((c) => c.text) + .join('\n'); +} describe('open_sim tool', () => { describe('Export Field Validation (Literal)', () => { @@ -22,7 +24,6 @@ describe('open_sim tool', () => { it('should have correct schema validation', () => { const schemaObj = z.object(schema); - // Schema is empty, so any object should pass expect(schemaObj.safeParse({}).success).toBe(true); expect( @@ -31,7 +32,6 @@ describe('open_sim tool', () => { }).success, ).toBe(true); - // Empty schema should accept anything expect( schemaObj.safeParse({ enabled: true, @@ -41,7 +41,7 @@ describe('open_sim tool', () => { }); describe('Handler Behavior (Complete Literal Returns)', () => { - it('should return exact successful open simulator response', async () => { + it('should return successful open simulator response', async () => { const mockExecutor = createMockExecutor({ success: true, output: '', @@ -49,25 +49,21 @@ describe('open_sim tool', () => { const result = await open_simLogic({}, mockExecutor); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: 'Simulator app opened successfully.', - }, + const text = allText(result); + expect(text).toContain('Open Simulator'); + expect(text).toContain('Simulator app opened'); + expect(result.isError).toBeFalsy(); + expect(result.nextStepParams).toEqual({ + boot_sim: { simulatorId: 'UUID_FROM_LIST_SIMS' }, + start_sim_log_cap: [ + { simulatorId: 'UUID', bundleId: 'YOUR_APP_BUNDLE_ID' }, + { simulatorId: 'UUID', bundleId: 'YOUR_APP_BUNDLE_ID', captureConsole: true }, ], - nextStepParams: { - boot_sim: { simulatorId: 'UUID_FROM_LIST_SIMS' }, - start_sim_log_cap: [ - { simulatorId: 'UUID', bundleId: 'YOUR_APP_BUNDLE_ID' }, - { simulatorId: 'UUID', bundleId: 'YOUR_APP_BUNDLE_ID', captureConsole: true }, - ], - launch_app_logs_sim: { simulatorId: 'UUID', bundleId: 'YOUR_APP_BUNDLE_ID' }, - }, + launch_app_logs_sim: { simulatorId: 'UUID', bundleId: 'YOUR_APP_BUNDLE_ID' }, }); }); - it('should return exact command failure response', async () => { + it('should return command failure response', async () => { const mockExecutor = createMockExecutor({ success: false, error: 'Command failed', @@ -75,48 +71,33 @@ describe('open_sim tool', () => { const result = await open_simLogic({}, mockExecutor); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: 'Open simulator operation failed: Command failed', - }, - ], - }); + const text = allText(result); + expect(text).toContain('Open simulator operation failed: Command failed'); + expect(result.isError).toBe(true); }); - it('should return exact exception handling response', async () => { + it('should return exception handling response', async () => { const mockExecutor: CommandExecutor = async () => { throw new Error('Test error'); }; const result = await open_simLogic({}, mockExecutor); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: 'Open simulator operation failed: Test error', - }, - ], - }); + const text = allText(result); + expect(text).toContain('Open simulator operation failed: Test error'); + expect(result.isError).toBe(true); }); - it('should return exact string error handling response', async () => { + it('should return string error handling response', async () => { const mockExecutor: CommandExecutor = async () => { throw 'String error'; }; const result = await open_simLogic({}, mockExecutor); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: 'Open simulator operation failed: String error', - }, - ], - }); + const text = allText(result); + expect(text).toContain('Open simulator operation failed: String error'); + expect(result.isError).toBe(true); }); it('should verify command generation with mock executor', async () => { diff --git a/src/mcp/tools/simulator/__tests__/record_sim_video.test.ts b/src/mcp/tools/simulator/__tests__/record_sim_video.test.ts index b02e3345..7efec3dc 100644 --- a/src/mcp/tools/simulator/__tests__/record_sim_video.test.ts +++ b/src/mcp/tools/simulator/__tests__/record_sim_video.test.ts @@ -52,10 +52,6 @@ describe('record_sim_video logic - start behavior', () => { const axe = { areAxeToolsAvailable: () => true, isAxeAtLeastVersion: async () => true, - createAxeNotAvailableResponse: () => ({ - content: [{ type: 'text' as const, text: 'AXe not available' }], - isError: true, - }), }; const fs = createMockFileSystemExecutor(); @@ -73,13 +69,12 @@ describe('record_sim_video logic - start behavior', () => { fs, ); - expect(res.isError).toBe(false); + expect(res.isError).not.toBe(true); const texts = (res.content ?? []).map((c: any) => c.text).join('\n'); - expect(texts).toMatch(/30\s*fps/i); + expect(texts).toContain('30'); expect(texts.toLowerCase()).toContain('outputfile is ignored'); - // Check nextStepParams instead of embedded text expect(res.nextStepParams).toBeDefined(); expect(res.nextStepParams?.record_sim_video).toBeDefined(); expect(res.nextStepParams?.record_sim_video).toHaveProperty('stop', true); @@ -106,10 +101,6 @@ describe('record_sim_video logic - end-to-end stop with rename', () => { const axe = { areAxeToolsAvailable: () => true, isAxeAtLeastVersion: async () => true, - createAxeNotAvailableResponse: () => ({ - content: [{ type: 'text' as const, text: 'AXe not available' }], - isError: true, - }), }; // Start (not strictly required for stop path, but included to mimic flow) @@ -123,7 +114,7 @@ describe('record_sim_video logic - end-to-end stop with rename', () => { video, fs, ); - expect(startRes.isError).toBe(false); + expect(startRes.isError).not.toBe(true); // Stop and rename const outputFile = '/var/videos/final.mp4'; @@ -139,12 +130,11 @@ describe('record_sim_video logic - end-to-end stop with rename', () => { fs, ); - expect(stopRes.isError).toBe(false); + expect(stopRes.isError).not.toBe(true); const texts = (stopRes.content ?? []).map((c: any) => c.text).join('\n'); expect(texts).toContain('Original file: /tmp/recorded.mp4'); expect(texts).toContain(`Saved to: ${outputFile}`); - // _meta should include final saved path expect((stopRes as any)._meta?.outputFile).toBe(outputFile); }); }); @@ -154,10 +144,6 @@ describe('record_sim_video logic - version gate', () => { const axe = { areAxeToolsAvailable: () => true, isAxeAtLeastVersion: async () => false, - createAxeNotAvailableResponse: () => ({ - content: [{ type: 'text' as const, text: 'AXe not available' }], - isError: true, - }), }; const video: any = { @@ -184,7 +170,7 @@ describe('record_sim_video logic - version gate', () => { ); expect(res.isError).toBe(true); - const text = (res.content?.[0] as any)?.text ?? ''; + const text = (res.content ?? []).map((c: any) => c.text).join('\n'); expect(text).toContain('AXe v1.1.0'); }); }); diff --git a/src/mcp/tools/simulator/__tests__/screenshot.test.ts b/src/mcp/tools/simulator/__tests__/screenshot.test.ts index 0f249472..a54a5c96 100644 --- a/src/mcp/tools/simulator/__tests__/screenshot.test.ts +++ b/src/mcp/tools/simulator/__tests__/screenshot.test.ts @@ -12,11 +12,19 @@ import { createCommandMatchingMockExecutor, mockProcess, } from '../../../../test-utils/mock-executors.ts'; +import type { ToolResponse } from '../../../../types/common.ts'; import type { CommandExecutor } from '../../../../utils/execution/index.ts'; import { SystemError } from '../../../../utils/responses/index.ts'; import { sessionStore } from '../../../../utils/session-store.ts'; import { schema, handler, screenshotLogic } from '../../ui-automation/screenshot.ts'; +function allText(result: ToolResponse): string { + return result.content + .filter((c): c is { type: 'text'; text: string } => c.type === 'text') + .map((c) => c.text) + .join('\n'); +} + describe('screenshot plugin', () => { beforeEach(() => { sessionStore.clear(); @@ -313,15 +321,15 @@ describe('screenshot plugin', () => { mockUuidDeps, ); - expect(result).toEqual({ - content: [ - { - type: 'image', - data: mockImageBuffer.toString('base64'), - mimeType: 'image/jpeg', // Now JPEG after optimization - }, - ], - isError: false, + expect(result.isError).toBeUndefined(); + const text = allText(result); + expect(text).toContain('Screenshot'); + expect(text).toContain('Screenshot captured.'); + const imageContent = result.content.find((c) => c.type === 'image'); + expect(imageContent).toEqual({ + type: 'image', + data: mockImageBuffer.toString('base64'), + mimeType: 'image/jpeg', }); }); @@ -361,15 +369,11 @@ describe('screenshot plugin', () => { mockUuidDeps, ); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: 'Error: System error executing screenshot: Failed to capture screenshot: Command failed', - }, - ], - isError: true, - }); + expect(result.isError).toBe(true); + const text = allText(result); + expect(text).toContain( + 'System error executing screenshot: Failed to capture screenshot: Command failed', + ); }); it('should handle file read failure', async () => { @@ -404,15 +408,11 @@ describe('screenshot plugin', () => { mockUuidDeps, ); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: 'Error: Screenshot captured but failed to process image file: File not found', - }, - ], - isError: true, - }); + expect(result.isError).toBe(true); + const text = allText(result); + expect(text).toContain( + 'Screenshot captured but failed to process image file: File not found', + ); }); it('should call correct command with direct execution', async () => { @@ -534,15 +534,9 @@ describe('screenshot plugin', () => { mockUuidDeps, ); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: 'Error: System error executing screenshot: System error occurred', - }, - ], - isError: true, - }); + expect(result.isError).toBe(true); + const text = allText(result); + expect(text).toContain('System error executing screenshot: System error occurred'); }); it('should handle unexpected Error objects', async () => { @@ -567,15 +561,9 @@ describe('screenshot plugin', () => { mockUuidDeps, ); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: 'Error: An unexpected error occurred: Unexpected error', - }, - ], - isError: true, - }); + expect(result.isError).toBe(true); + const text = allText(result); + expect(text).toContain('An unexpected error occurred: Unexpected error'); }); it('should handle unexpected string errors', async () => { @@ -600,15 +588,9 @@ describe('screenshot plugin', () => { mockUuidDeps, ); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: 'Error: An unexpected error occurred: String error', - }, - ], - isError: true, - }); + expect(result.isError).toBe(true); + const text = allText(result); + expect(text).toContain('An unexpected error occurred: String error'); }); it('should handle file read error with fileSystemExecutor', async () => { @@ -643,15 +625,11 @@ describe('screenshot plugin', () => { mockUuidDeps, ); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: 'Error: Screenshot captured but failed to process image file: File system error', - }, - ], - isError: true, - }); + expect(result.isError).toBe(true); + const text = allText(result); + expect(text).toContain( + 'Screenshot captured but failed to process image file: File system error', + ); }); }); }); diff --git a/src/mcp/tools/simulator/__tests__/stop_app_sim.test.ts b/src/mcp/tools/simulator/__tests__/stop_app_sim.test.ts index 5ca7e21c..126b923c 100644 --- a/src/mcp/tools/simulator/__tests__/stop_app_sim.test.ts +++ b/src/mcp/tools/simulator/__tests__/stop_app_sim.test.ts @@ -79,14 +79,11 @@ describe('stop_app_sim tool', () => { mockExecutor, ); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: 'App io.sentry.App stopped successfully in simulator test-uuid', - }, - ], - }); + const text = result.content.map((c) => c.text).join('\n'); + expect(text).toContain('Stop App'); + expect(text).toContain('io.sentry.App'); + expect(text).toContain('stopped successfully'); + expect(text).toContain('test-uuid'); }); it('should display friendly name when simulatorName is provided alongside resolved simulatorId', async () => { @@ -101,14 +98,11 @@ describe('stop_app_sim tool', () => { mockExecutor, ); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: 'App io.sentry.App stopped successfully in simulator "iPhone 17" (resolved-uuid)', - }, - ], - }); + const text = result.content.map((c) => c.text).join('\n'); + expect(text).toContain('Stop App'); + expect(text).toContain('io.sentry.App'); + expect(text).toContain('stopped successfully'); + expect(text).toContain('"iPhone 17" (resolved-uuid)'); }); it('should surface terminate failures', async () => { @@ -126,15 +120,10 @@ describe('stop_app_sim tool', () => { terminateExecutor, ); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: 'Stop app in simulator operation failed: Simulator not found', - }, - ], - isError: true, - }); + const text = result.content.map((c) => c.text).join('\n'); + expect(text).toContain('Stop app in simulator operation failed'); + expect(text).toContain('Simulator not found'); + expect(result.isError).toBe(true); }); it('should handle unexpected exceptions', async () => { @@ -150,15 +139,10 @@ describe('stop_app_sim tool', () => { throwingExecutor, ); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: 'Stop app in simulator operation failed: Unexpected error', - }, - ], - isError: true, - }); + const text = result.content.map((c) => c.text).join('\n'); + expect(text).toContain('Stop app in simulator operation failed'); + expect(text).toContain('Unexpected error'); + expect(result.isError).toBe(true); }); it('should call correct terminate command', async () => { diff --git a/src/mcp/tools/simulator/__tests__/test_sim.test.ts b/src/mcp/tools/simulator/__tests__/test_sim.test.ts index b9151a91..449849db 100644 --- a/src/mcp/tools/simulator/__tests__/test_sim.test.ts +++ b/src/mcp/tools/simulator/__tests__/test_sim.test.ts @@ -208,7 +208,7 @@ final class AppTests: XCTestCase { const responseText = finalizeAndGetText(result); const allOutput = stdoutOutput + responseText; expect(allOutput).toContain('Scheme: App'); - expect(allOutput).toContain('Resolved to 1 test(s):'); + expect(allOutput).toContain('Discovered 1 test(s):'); expect(allOutput).toContain('AppTests/AppTests/testLaunch'); } finally { stdoutWrite.mockRestore(); diff --git a/src/mcp/tools/simulator/boot_sim.ts b/src/mcp/tools/simulator/boot_sim.ts index ba356254..656be8f0 100644 --- a/src/mcp/tools/simulator/boot_sim.ts +++ b/src/mcp/tools/simulator/boot_sim.ts @@ -7,6 +7,8 @@ import { createSessionAwareTool, getSessionAwareToolSchemaShape, } from '../../../utils/typed-tool-factory.ts'; +import { toolResponse } from '../../../utils/tool-response.ts'; +import { header, statusLine } from '../../../utils/tool-event-builders.ts'; const baseSchemaObject = z.object({ simulatorId: z @@ -23,7 +25,6 @@ const baseSchemaObject = z.object({ ), }); -// Internal schema requires simulatorId (factory resolves simulatorName โ†’ simulatorId) const internalSchemaObject = z.object({ simulatorId: z.string(), simulatorName: z.string().optional(), @@ -44,45 +45,33 @@ export async function boot_simLogic( ): Promise { log('info', `Starting xcrun simctl boot request for simulator ${params.simulatorId}`); + const headerEvent = header('Boot Simulator', [{ label: 'Simulator', value: params.simulatorId }]); + try { const command = ['xcrun', 'simctl', 'boot', params.simulatorId]; const result = await executor(command, 'Boot Simulator', false); if (!result.success) { - return { - content: [ - { - type: 'text', - text: `Boot simulator operation failed: ${result.error}`, - }, - ], - }; + return toolResponse([ + headerEvent, + statusLine('error', `Boot simulator operation failed: ${result.error}`), + ]); } - return { - content: [ - { - type: 'text', - text: `Simulator booted successfully.`, - }, - ], + return toolResponse([headerEvent, statusLine('success', 'Simulator booted successfully')], { nextStepParams: { open_sim: {}, install_app_sim: { simulatorId: params.simulatorId, appPath: 'PATH_TO_YOUR_APP' }, launch_app_sim: { simulatorId: params.simulatorId, bundleId: 'YOUR_APP_BUNDLE_ID' }, }, - }; + }); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); log('error', `Error during boot simulator operation: ${errorMessage}`); - return { - content: [ - { - type: 'text', - text: `Boot simulator operation failed: ${errorMessage}`, - }, - ], - }; + return toolResponse([ + headerEvent, + statusLine('error', `Boot simulator operation failed: ${errorMessage}`), + ]); } } diff --git a/src/mcp/tools/simulator/build_run_sim.ts b/src/mcp/tools/simulator/build_run_sim.ts index b96e5aa5..36574198 100644 --- a/src/mcp/tools/simulator/build_run_sim.ts +++ b/src/mcp/tools/simulator/build_run_sim.ts @@ -14,7 +14,6 @@ import { createSessionAwareTool, getSessionAwareToolSchemaShape, } from '../../../utils/typed-tool-factory.ts'; -import { createTextResponse } from '../../../utils/responses/index.ts'; import { executeXcodeBuildCommand } from '../../../utils/build/index.ts'; import type { CommandExecutor } from '../../../utils/execution/index.ts'; import { @@ -24,9 +23,12 @@ import { import { nullifyEmptyStrings } from '../../../utils/schema-helpers.ts'; import { inferPlatform } from '../../../utils/infer-platform.ts'; import { constructDestinationString } from '../../../utils/xcode.ts'; +import { toolResponse } from '../../../utils/tool-response.ts'; +import { header, statusLine } from '../../../utils/tool-event-builders.ts'; import { startBuildPipeline } from '../../../utils/xcodebuild-pipeline.ts'; import { formatToolPreflight } from '../../../utils/build-preflight.ts'; import { + createBuildRunResultEvents, createPendingXcodebuildResponse, emitPipelineError, emitPipelineNotice, @@ -160,18 +162,7 @@ export async function build_run_simLogic( if (params.simulatorId) { const validation = await validateAvailableSimulatorId(params.simulatorId, executor); if (validation.error) { - const errorText = validation.error.content - .filter((item) => item.type === 'text') - .flatMap((item) => item.text.split('\n')) - .map((line) => line.trim()) - .find((line) => line.startsWith('Error:')) - ?.replace(/^Error:\s*/, '') - .trim(); - emitPipelineError( - started, - 'BUILD', - errorText ?? `No available simulator matched: ${params.simulatorId}`, - ); + emitPipelineError(started, 'BUILD', validation.error); return createPendingXcodebuildResponse(started, { content: [], isError: true, @@ -273,11 +264,7 @@ export async function build_run_simLogic( ); if (uuidResult.error) { - const errorMsg = uuidResult.error.content - .filter((item) => item.type === 'text') - .map((item) => item.text) - .join(' '); - emitPipelineError(started, 'BUILD', `Failed to resolve simulator UUID: ${errorMsg}`); + emitPipelineError(started, 'BUILD', `Failed to resolve simulator UUID: ${uuidResult.error}`); return createPendingXcodebuildResponse(started, { content: [], isError: true, @@ -488,30 +475,23 @@ export async function build_run_simLogic( }, }, { - tailEvents: [ - { - type: 'notice', - timestamp: new Date().toISOString(), - operation: 'BUILD', - level: 'success', - message: 'Build & Run complete', - code: 'build-run-result', - data: { - scheme: params.scheme, - platform: displayPlatform, - target: `${platformName} Simulator`, - appPath: appBundlePath, - bundleId, - launchState: 'requested', - }, - }, - ], + tailEvents: createBuildRunResultEvents({ + scheme: params.scheme, + platform: displayPlatform, + target: `${platformName} Simulator`, + appPath: appBundlePath, + bundleId, + launchState: 'requested', + }), }, ); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); log('error', `Error in Simulator build and run: ${errorMessage}`); - return createTextResponse(`Error during simulator build and run: ${errorMessage}`, true); + return toolResponse([ + header('Build & Run Simulator'), + statusLine('error', `Error during simulator build and run: ${errorMessage}`), + ]); } } diff --git a/src/mcp/tools/simulator/get_sim_app_path.ts b/src/mcp/tools/simulator/get_sim_app_path.ts index d0ddbf03..89c168e7 100644 --- a/src/mcp/tools/simulator/get_sim_app_path.ts +++ b/src/mcp/tools/simulator/get_sim_app_path.ts @@ -18,11 +18,8 @@ import { getSessionAwareToolSchemaShape, } from '../../../utils/typed-tool-factory.ts'; import { nullifyEmptyStrings } from '../../../utils/schema-helpers.ts'; -import { formatToolPreflight } from '../../../utils/build-preflight.ts'; -import { - formatQueryError, - formatQueryFailureSummary, -} from '../../../utils/xcodebuild-error-utils.ts'; +import { toolResponse } from '../../../utils/tool-response.ts'; +import { header, statusLine, detailTree } from '../../../utils/tool-event-builders.ts'; const SIMULATOR_PLATFORMS = [ XcodePlatform.iOSSimulator, @@ -80,7 +77,6 @@ const getSimulatorAppPathSchema = z.preprocess( }), ); -// Use z.infer for type safety type GetSimulatorAppPathParams = z.infer; /** @@ -102,16 +98,23 @@ export async function get_sim_app_pathLogic( log('info', `Getting app path for scheme ${params.scheme} on platform ${params.platform}`); - const preflight = formatToolPreflight({ - operation: 'Get App Path', - scheme: params.scheme, - workspacePath: params.workspacePath, - projectPath: params.projectPath, - configuration, - platform: params.platform, - simulatorName: params.simulatorName, - simulatorId: params.simulatorId, - }); + const headerParams: Array<{ label: string; value: string }> = [ + { label: 'Scheme', value: params.scheme }, + ]; + if (params.workspacePath) { + headerParams.push({ label: 'Workspace', value: params.workspacePath }); + } else if (params.projectPath) { + headerParams.push({ label: 'Project', value: params.projectPath }); + } + headerParams.push({ label: 'Configuration', value: configuration }); + headerParams.push({ label: 'Platform', value: params.platform }); + if (params.simulatorName) { + headerParams.push({ label: 'Simulator', value: params.simulatorName }); + } else if (params.simulatorId) { + headerParams.push({ label: 'Simulator', value: params.simulatorId }); + } + + const headerEvent = header('Get App Path', headerParams); try { const command = ['xcodebuild', '-showBuildSettings']; @@ -135,55 +138,58 @@ export async function get_sim_app_pathLogic( if (!result.success) { const rawOutput = [result.error, result.output].filter(Boolean).join('\n'); - const errorBlock = formatQueryError(rawOutput); - const text = [preflight, errorBlock, '', formatQueryFailureSummary()].join('\n'); - return { content: [{ type: 'text', text }], isError: true }; + return toolResponse([ + headerEvent, + statusLine('error', `Failed to get build settings: ${rawOutput}`), + ]); } if (!result.output) { - const errorBlock = formatQueryError( - 'Failed to extract build settings output from the result.', - ); - const text = [preflight, errorBlock, '', formatQueryFailureSummary()].join('\n'); - return { content: [{ type: 'text', text }], isError: true }; + return toolResponse([ + headerEvent, + statusLine('error', 'Failed to extract build settings output from the result.'), + ]); } const builtProductsDirMatch = result.output.match(/^\s*BUILT_PRODUCTS_DIR\s*=\s*(.+)$/m); const fullProductNameMatch = result.output.match(/^\s*FULL_PRODUCT_NAME\s*=\s*(.+)$/m); if (!builtProductsDirMatch || !fullProductNameMatch) { - const errorBlock = formatQueryError( - 'Failed to extract app path from build settings. Make sure the app has been built first.', - ); - const text = [preflight, errorBlock, '', formatQueryFailureSummary()].join('\n'); - return { content: [{ type: 'text', text }], isError: true }; + return toolResponse([ + headerEvent, + statusLine( + 'error', + 'Failed to extract app path from build settings. Make sure the app has been built first.', + ), + ]); } const builtProductsDir = builtProductsDirMatch[1].trim(); const fullProductName = fullProductNameMatch[1].trim(); const appPath = `${builtProductsDir}/${fullProductName}`; - const resultLine = ` \u{2514} App Path: ${appPath}`; - const text = preflight + '\n' + resultLine; - - const nextStepParams: Record> = { - get_app_bundle_id: { appPath }, - boot_sim: { simulatorId: 'SIMULATOR_UUID' }, - install_app_sim: { simulatorId: 'SIMULATOR_UUID', appPath }, - launch_app_sim: { simulatorId: 'SIMULATOR_UUID', bundleId: 'BUNDLE_ID' }, - }; - - return { - content: [{ type: 'text', text }], - nextStepParams, - isError: false, - }; + return toolResponse( + [ + headerEvent, + detailTree([{ label: 'App Path', value: appPath }]), + statusLine('success', 'App path resolved'), + ], + { + nextStepParams: { + get_app_bundle_id: { appPath }, + boot_sim: { simulatorId: 'SIMULATOR_UUID' }, + install_app_sim: { simulatorId: 'SIMULATOR_UUID', appPath }, + launch_app_sim: { simulatorId: 'SIMULATOR_UUID', bundleId: 'BUNDLE_ID' }, + }, + }, + ); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); log('error', `Error retrieving app path: ${errorMessage}`); - const errorBlock = formatQueryError(errorMessage); - const text = [preflight, errorBlock, '', formatQueryFailureSummary()].join('\n'); - return { content: [{ type: 'text', text }], isError: true }; + return toolResponse([ + headerEvent, + statusLine('error', `Error retrieving app path: ${errorMessage}`), + ]); } } diff --git a/src/mcp/tools/simulator/install_app_sim.ts b/src/mcp/tools/simulator/install_app_sim.ts index 144b60df..ad396c9d 100644 --- a/src/mcp/tools/simulator/install_app_sim.ts +++ b/src/mcp/tools/simulator/install_app_sim.ts @@ -8,6 +8,8 @@ import { createSessionAwareTool, getSessionAwareToolSchemaShape, } from '../../../utils/typed-tool-factory.ts'; +import { toolResponse } from '../../../utils/tool-response.ts'; +import { header, statusLine } from '../../../utils/tool-event-builders.ts'; const baseSchemaObject = z.object({ simulatorId: z @@ -25,7 +27,6 @@ const baseSchemaObject = z.object({ appPath: z.string().describe('Path to the .app bundle to install'), }); -// Internal schema requires simulatorId (factory resolves simulatorName โ†’ simulatorId) const internalSchemaObject = z.object({ simulatorId: z.string(), simulatorName: z.string().optional(), @@ -46,26 +47,27 @@ export async function install_app_simLogic( executor: CommandExecutor, fileSystem?: FileSystemExecutor, ): Promise { + const headerEvent = header('Install App', [ + { label: 'Simulator', value: params.simulatorId }, + { label: 'App Path', value: params.appPath }, + ]); + const appPathExistsValidation = validateFileExists(params.appPath, fileSystem); if (!appPathExistsValidation.isValid) { - return appPathExistsValidation.errorResponse!; + return toolResponse([headerEvent, statusLine('error', appPathExistsValidation.errorMessage!)]); } log('info', `Starting xcrun simctl install request for simulator ${params.simulatorId}`); try { const command = ['xcrun', 'simctl', 'install', params.simulatorId, params.appPath]; - const result = await executor(command, 'Install App in Simulator', false, undefined); + const result = await executor(command, 'Install App in Simulator', false); if (!result.success) { - return { - content: [ - { - type: 'text', - text: `Install app in simulator operation failed: ${result.error}`, - }, - ], - }; + return toolResponse([ + headerEvent, + statusLine('error', `Install app in simulator operation failed: ${result.error}`), + ]); } let bundleId = ''; @@ -74,7 +76,6 @@ export async function install_app_simLogic( ['defaults', 'read', `${params.appPath}/Info`, 'CFBundleIdentifier'], 'Extract Bundle ID', false, - undefined, ); if (bundleIdResult.success) { bundleId = bundleIdResult.output.trim(); @@ -83,32 +84,28 @@ export async function install_app_simLogic( log('warn', `Could not extract bundle ID from app: ${error}`); } - return { - content: [ - { - type: 'text', - text: `App installed successfully in simulator ${params.simulatorId}.`, - }, + return toolResponse( + [ + headerEvent, + statusLine('success', `App installed successfully in simulator ${params.simulatorId}`), ], - nextStepParams: { - open_sim: {}, - launch_app_sim: { - simulatorId: params.simulatorId, - bundleId: bundleId || 'YOUR_APP_BUNDLE_ID', + { + nextStepParams: { + open_sim: {}, + launch_app_sim: { + simulatorId: params.simulatorId, + bundleId: bundleId || 'YOUR_APP_BUNDLE_ID', + }, }, }, - }; + ); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); log('error', `Error during install app in simulator operation: ${errorMessage}`); - return { - content: [ - { - type: 'text', - text: `Install app in simulator operation failed: ${errorMessage}`, - }, - ], - }; + return toolResponse([ + headerEvent, + statusLine('error', `Install app in simulator operation failed: ${errorMessage}`), + ]); } } diff --git a/src/mcp/tools/simulator/launch_app_logs_sim.ts b/src/mcp/tools/simulator/launch_app_logs_sim.ts index 69aac0f0..5e76e4ec 100644 --- a/src/mcp/tools/simulator/launch_app_logs_sim.ts +++ b/src/mcp/tools/simulator/launch_app_logs_sim.ts @@ -1,6 +1,5 @@ import * as z from 'zod'; import type { ToolResponse } from '../../../types/common.ts'; -import { createTextContent } from '../../../types/common.ts'; import { log } from '../../../utils/logging/index.ts'; import { startLogCapture } from '../../../utils/log-capture/index.ts'; import type { CommandExecutor } from '../../../utils/execution/index.ts'; @@ -9,6 +8,8 @@ import { createSessionAwareTool, getSessionAwareToolSchemaShape, } from '../../../utils/typed-tool-factory.ts'; +import { toolResponse } from '../../../utils/tool-response.ts'; +import { header, statusLine, detailTree } from '../../../utils/tool-event-builders.ts'; export type LogCaptureFunction = ( params: { @@ -44,7 +45,6 @@ const baseSchemaObject = z.object({ ), }); -// Internal schema requires simulatorId (factory resolves simulatorName โ†’ simulatorId) const internalSchemaObject = z.object({ simulatorId: z.string(), simulatorName: z.string().optional(), @@ -75,6 +75,12 @@ export async function launch_app_logs_simLogic( ): Promise { log('info', `Starting app launch with logs for simulator ${params.simulatorId}`); + const headerEvent = header('Launch App', [ + { label: 'Simulator', value: params.simulatorId }, + { label: 'Bundle ID', value: params.bundleId }, + { label: 'Log Capture', value: 'enabled' }, + ]); + const captureParams = { simulatorUuid: params.simulatorId, bundleId: params.bundleId, @@ -85,23 +91,27 @@ export async function launch_app_logs_simLogic( const { sessionId, error } = await logCaptureFunction(captureParams, executor); if (error) { - return { - content: [createTextContent(`Failed to launch app with log capture: ${error}`)], - isError: true, - }; + return toolResponse([ + headerEvent, + statusLine('error', `Failed to launch app with log capture: ${error}`), + ]); } - return { - content: [ - createTextContent( - `App launched successfully in simulator ${params.simulatorId} with log capture enabled.\n\nLog capture session ID: ${sessionId}\n\nInteract with your app in the simulator, then stop capture to retrieve logs.`, + return toolResponse( + [ + headerEvent, + detailTree([{ label: 'Log Session ID', value: sessionId }]), + statusLine( + 'success', + `App launched successfully in simulator ${params.simulatorId} with log capture enabled`, ), ], - nextStepParams: { - stop_sim_log_cap: { logSessionId: sessionId }, + { + nextStepParams: { + stop_sim_log_cap: { logSessionId: sessionId }, + }, }, - isError: false, - }; + ); } export const schema = getSessionAwareToolSchemaShape({ diff --git a/src/mcp/tools/simulator/launch_app_sim.ts b/src/mcp/tools/simulator/launch_app_sim.ts index 09a1b959..c6474e40 100644 --- a/src/mcp/tools/simulator/launch_app_sim.ts +++ b/src/mcp/tools/simulator/launch_app_sim.ts @@ -8,6 +8,8 @@ import { getSessionAwareToolSchemaShape, } from '../../../utils/typed-tool-factory.ts'; import { normalizeSimctlChildEnv } from '../../../utils/environment.ts'; +import { toolResponse } from '../../../utils/tool-response.ts'; +import { header, statusLine } from '../../../utils/tool-event-builders.ts'; const baseSchemaObject = z.object({ simulatorId: z @@ -32,7 +34,6 @@ const baseSchemaObject = z.object({ ), }); -// Internal schema requires simulatorId (factory resolves simulatorName โ†’ simulatorId) const internalSchemaObject = z.object({ simulatorId: z.string(), simulatorName: z.string().optional(), @@ -59,6 +60,11 @@ export async function launch_app_simLogic( log('info', `Starting xcrun simctl launch request for simulator ${simulatorId}`); + const headerEvent = header('Launch App', [ + { label: 'Simulator', value: simulatorDisplayName }, + { label: 'Bundle ID', value: params.bundleId }, + ]); + try { const getAppContainerCmd = [ 'xcrun', @@ -68,38 +74,29 @@ export async function launch_app_simLogic( params.bundleId, 'app', ]; - const getAppContainerResult = await executor( - getAppContainerCmd, - 'Check App Installed', - false, - undefined, - ); + const getAppContainerResult = await executor(getAppContainerCmd, 'Check App Installed', false); if (!getAppContainerResult.success) { - return { - content: [ - { - type: 'text', - text: `App is not installed on the simulator. Please use install_app_sim before launching.\n\nWorkflow: build โ†’ install โ†’ launch.`, - }, - ], - isError: true, - }; + return toolResponse([ + headerEvent, + statusLine( + 'error', + 'App is not installed on the simulator. Please use install_app_sim before launching. Workflow: build -> install -> launch.', + ), + ]); } } catch { - return { - content: [ - { - type: 'text', - text: `App is not installed on the simulator (check failed). Please use install_app_sim before launching.\n\nWorkflow: build โ†’ install โ†’ launch.`, - }, - ], - isError: true, - }; + return toolResponse([ + headerEvent, + statusLine( + 'error', + 'App is not installed on the simulator (check failed). Please use install_app_sim before launching. Workflow: build -> install -> launch.', + ), + ]); } try { const command = ['xcrun', 'simctl', 'launch', simulatorId, params.bundleId]; - if (params.args && params.args.length > 0) { + if (params.args?.length) { command.push(...params.args); } @@ -107,42 +104,34 @@ export async function launch_app_simLogic( const result = await executor(command, 'Launch App in Simulator', false, execOpts); if (!result.success) { - return { - content: [ - { - type: 'text', - text: `Launch app in simulator operation failed: ${result.error}`, - }, - ], - }; + return toolResponse([ + headerEvent, + statusLine('error', `Launch app in simulator operation failed: ${result.error}`), + ]); } - return { - content: [ - { - type: 'text', - text: `App launched successfully in simulator ${simulatorDisplayName}.`, - }, + return toolResponse( + [ + headerEvent, + statusLine('success', `App launched successfully in simulator ${simulatorDisplayName}`), ], - nextStepParams: { - open_sim: {}, - start_sim_log_cap: [ - { simulatorId, bundleId: params.bundleId }, - { simulatorId, bundleId: params.bundleId, captureConsole: true }, - ], + { + nextStepParams: { + open_sim: {}, + start_sim_log_cap: [ + { simulatorId, bundleId: params.bundleId }, + { simulatorId, bundleId: params.bundleId, captureConsole: true }, + ], + }, }, - }; + ); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); log('error', `Error during launch app in simulator operation: ${errorMessage}`); - return { - content: [ - { - type: 'text', - text: `Launch app in simulator operation failed: ${errorMessage}`, - }, - ], - }; + return toolResponse([ + headerEvent, + statusLine('error', `Launch app in simulator operation failed: ${errorMessage}`), + ]); } } diff --git a/src/mcp/tools/simulator/list_sims.ts b/src/mcp/tools/simulator/list_sims.ts index 2d88b9f8..3d328e66 100644 --- a/src/mcp/tools/simulator/list_sims.ts +++ b/src/mcp/tools/simulator/list_sims.ts @@ -4,13 +4,13 @@ import { log } from '../../../utils/logging/index.ts'; import type { CommandExecutor } from '../../../utils/execution/index.ts'; import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; import { createTypedTool } from '../../../utils/typed-tool-factory.ts'; +import { toolResponse } from '../../../utils/tool-response.ts'; +import { header, statusLine, table } from '../../../utils/tool-event-builders.ts'; -// Define schema as ZodObject const listSimsSchema = z.object({ enabled: z.boolean().optional(), }); -// Use z.infer for type safety type ListSimsParams = z.infer; interface SimulatorDevice { @@ -175,10 +175,11 @@ export async function list_simsLogic( ): Promise { log('info', 'Starting xcrun simctl list devices request'); + const headerEvent = header('List Simulators'); + try { const simulators = await listSimulators(executor); - let responseText = 'Available iOS Simulators:\n\n'; const grouped = new Map(); for (const simulator of simulators) { const runtimeGroup = grouped.get(simulator.runtime) ?? []; @@ -186,61 +187,44 @@ export async function list_simsLogic( grouped.set(simulator.runtime, runtimeGroup); } + const tables = []; for (const [runtime, devices] of grouped.entries()) { if (devices.length === 0) continue; - responseText += `${runtime}:\n`; - for (const device of devices) { - responseText += `- ${device.name} (${device.udid})${device.state === 'Booted' ? ' [Booted]' : ''}\n`; - } - responseText += '\n'; + const rows = devices.map((d) => ({ + Name: d.name, + UUID: d.udid, + State: d.state, + })); + tables.push(table(['Name', 'UUID', 'State'], rows, runtime)); } - responseText += - "Hint: Save a default simulator with session-set-defaults { simulatorId: 'UUID_FROM_ABOVE' } (or simulatorName).\n"; - responseText += - 'Before running build/run/test/UI automation tools, set the desired simulator identifier in session defaults.'; - - return { - content: [ - { - type: 'text', - text: responseText, - }, - ], - nextStepParams: { - boot_sim: { simulatorId: 'UUID_FROM_ABOVE' }, - open_sim: {}, - build_sim: { scheme: 'YOUR_SCHEME', simulatorId: 'UUID_FROM_ABOVE' }, - get_sim_app_path: { - scheme: 'YOUR_SCHEME', - platform: 'iOS Simulator', - simulatorId: 'UUID_FROM_ABOVE', + return toolResponse( + [headerEvent, ...tables, statusLine('success', 'Listed available simulators')], + { + nextStepParams: { + boot_sim: { simulatorId: 'UUID_FROM_ABOVE' }, + open_sim: {}, + build_sim: { scheme: 'YOUR_SCHEME', simulatorId: 'UUID_FROM_ABOVE' }, + get_sim_app_path: { + scheme: 'YOUR_SCHEME', + platform: 'iOS Simulator', + simulatorId: 'UUID_FROM_ABOVE', + }, }, }, - }; + ); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); if (errorMessage.startsWith('Failed to list simulators:')) { - return { - content: [ - { - type: 'text', - text: errorMessage, - }, - ], - }; + return toolResponse([headerEvent, statusLine('error', errorMessage)]); } log('error', `Error listing simulators: ${errorMessage}`); - return { - content: [ - { - type: 'text', - text: `Failed to list simulators: ${errorMessage}`, - }, - ], - }; + return toolResponse([ + headerEvent, + statusLine('error', `Failed to list simulators: ${errorMessage}`), + ]); } } diff --git a/src/mcp/tools/simulator/open_sim.ts b/src/mcp/tools/simulator/open_sim.ts index cfde7a7b..08f475d0 100644 --- a/src/mcp/tools/simulator/open_sim.ts +++ b/src/mcp/tools/simulator/open_sim.ts @@ -4,11 +4,11 @@ import { log } from '../../../utils/logging/index.ts'; import type { CommandExecutor } from '../../../utils/execution/index.ts'; import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; import { createTypedTool } from '../../../utils/typed-tool-factory.ts'; +import { toolResponse } from '../../../utils/tool-response.ts'; +import { header, statusLine } from '../../../utils/tool-event-builders.ts'; -// Define schema as ZodObject const openSimSchema = z.object({}); -// Use z.infer for type safety type OpenSimParams = z.infer; export async function open_simLogic( @@ -17,28 +17,20 @@ export async function open_simLogic( ): Promise { log('info', 'Starting open simulator request'); + const headerEvent = header('Open Simulator'); + try { const command = ['open', '-a', 'Simulator']; const result = await executor(command, 'Open Simulator', false); if (!result.success) { - return { - content: [ - { - type: 'text', - text: `Open simulator operation failed: ${result.error}`, - }, - ], - }; + return toolResponse([ + headerEvent, + statusLine('error', `Open simulator operation failed: ${result.error}`), + ]); } - return { - content: [ - { - type: 'text', - text: `Simulator app opened successfully.`, - }, - ], + return toolResponse([headerEvent, statusLine('success', 'Simulator app opened')], { nextStepParams: { boot_sim: { simulatorId: 'UUID_FROM_LIST_SIMS' }, start_sim_log_cap: [ @@ -47,18 +39,14 @@ export async function open_simLogic( ], launch_app_logs_sim: { simulatorId: 'UUID', bundleId: 'YOUR_APP_BUNDLE_ID' }, }, - }; + }); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); log('error', `Error during open simulator operation: ${errorMessage}`); - return { - content: [ - { - type: 'text', - text: `Open simulator operation failed: ${errorMessage}`, - }, - ], - }; + return toolResponse([ + headerEvent, + statusLine('error', `Open simulator operation failed: ${errorMessage}`), + ]); } } diff --git a/src/mcp/tools/simulator/record_sim_video.ts b/src/mcp/tools/simulator/record_sim_video.ts index 9ec863a3..1e3ddd0e 100644 --- a/src/mcp/tools/simulator/record_sim_video.ts +++ b/src/mcp/tools/simulator/record_sim_video.ts @@ -1,6 +1,5 @@ import * as z from 'zod'; import type { ToolResponse } from '../../../types/common.ts'; -import { createTextResponse } from '../../../utils/responses/index.ts'; import { getDefaultCommandExecutor, getDefaultFileSystemExecutor, @@ -9,7 +8,7 @@ import type { CommandExecutor, FileSystemExecutor } from '../../../utils/executi import { areAxeToolsAvailable, isAxeAtLeastVersion, - createAxeNotAvailableResponse, + AXE_NOT_AVAILABLE_MESSAGE, } from '../../../utils/axe/index.ts'; import { startSimulatorVideoCapture, @@ -20,6 +19,8 @@ import { getSessionAwareToolSchemaShape, } from '../../../utils/typed-tool-factory.ts'; import { dirname } from 'path'; +import { toolResponse } from '../../../utils/tool-response.ts'; +import { header, statusLine, detailTree, section } from '../../../utils/tool-event-builders.ts'; // Base schema object (used for MCP schema exposure) const recordSimVideoSchemaObject = z.object({ @@ -59,11 +60,9 @@ export async function record_sim_videoLogic( axe: { areAxeToolsAvailable(): boolean; isAxeAtLeastVersion(v: string, e: CommandExecutor): Promise; - createAxeNotAvailableResponse(): ToolResponse; } = { areAxeToolsAvailable, isAxeAtLeastVersion, - createAxeNotAvailableResponse, }, video: { startSimulatorVideoCapture: typeof startSimulatorVideoCapture; @@ -74,16 +73,20 @@ export async function record_sim_videoLogic( }, fs: FileSystemExecutor = getDefaultFileSystemExecutor(), ): Promise { - // Preflight checks for AXe availability and version + const headerEvent = header('Record Video', [{ label: 'Simulator', value: params.simulatorId }]); + if (!axe.areAxeToolsAvailable()) { - return axe.createAxeNotAvailableResponse(); + return toolResponse([headerEvent, statusLine('error', AXE_NOT_AVAILABLE_MESSAGE)]); } const hasVersion = await axe.isAxeAtLeastVersion('1.1.0', executor); if (!hasVersion) { - return createTextResponse( - 'AXe v1.1.0 or newer is required for simulator video capture. Please update bundled AXe artifacts.', - true, - ); + return toolResponse([ + headerEvent, + statusLine( + 'error', + 'AXe v1.1.0 or newer is required for simulator video capture. Please update bundled AXe artifacts.', + ), + ]); } if (params.start) { @@ -94,10 +97,13 @@ export async function record_sim_videoLogic( ); if (!startRes.started) { - return createTextResponse( - `Failed to start video recording: ${startRes.error ?? 'Unknown error'}`, - true, - ); + return toolResponse([ + headerEvent, + statusLine( + 'error', + `Failed to start video recording: ${startRes.error ?? 'Unknown error'}`, + ), + ]); } const notes: string[] = []; @@ -110,21 +116,20 @@ export async function record_sim_videoLogic( notes.push(startRes.warning); } - return { - content: [ - { - type: 'text', - text: `Video recording started for simulator ${params.simulatorId} at ${fpsUsed} fps.\nSession: ${startRes.sessionId}`, - }, - ...(notes.length > 0 - ? [ - { - type: 'text' as const, - text: notes.join('\n'), - }, - ] - : []), - ], + const events = [ + headerEvent, + detailTree([ + { label: 'FPS', value: String(fpsUsed) }, + { label: 'Session', value: startRes.sessionId }, + ]), + ...(notes.length > 0 ? [section('Notes', notes)] : []), + statusLine( + 'success', + `Video recording started for simulator ${params.simulatorId} at ${fpsUsed} fps`, + ), + ]; + + return toolResponse(events, { nextStepParams: { record_sim_video: { simulatorId: params.simulatorId, @@ -132,8 +137,7 @@ export async function record_sim_videoLogic( outputFile: '/path/to/output.mp4', }, }, - isError: false, - }; + }); } // params.stop must be true here per schema @@ -143,22 +147,24 @@ export async function record_sim_videoLogic( ); if (!stopRes.stopped) { - return createTextResponse( - `Failed to stop video recording: ${stopRes.error ?? 'Unknown error'}`, - true, - ); + return toolResponse([ + headerEvent, + statusLine('error', `Failed to stop video recording: ${stopRes.error ?? 'Unknown error'}`), + ]); } - // Attempt to move/rename the recording if we parsed a source path and an outputFile was given const outputs: string[] = []; let finalSavedPath = params.outputFile ?? stopRes.parsedPath ?? ''; try { if (params.outputFile) { if (!stopRes.parsedPath) { - return createTextResponse( - `Recording stopped but could not determine the recorded file path from AXe output.\nRaw output:\n${stopRes.stdout ?? '(no output captured)'}`, - true, - ); + return toolResponse([ + headerEvent, + statusLine( + 'error', + `Recording stopped but could not determine the recorded file path from AXe output. Raw output: ${stopRes.stdout ?? '(no output captured)'}`, + ), + ]); } const src = stopRes.parsedPath; @@ -180,38 +186,24 @@ export async function record_sim_videoLogic( } } catch (e) { const msg = e instanceof Error ? e.message : String(e); - return createTextResponse( - `Recording stopped but failed to save/move the video file: ${msg}`, - true, - ); + return toolResponse([ + headerEvent, + statusLine('error', `Recording stopped but failed to save/move the video file: ${msg}`), + ]); } - return { - content: [ - { - type: 'text', - text: `โœ… Video recording stopped for simulator ${params.simulatorId}.`, - }, - ...(outputs.length > 0 - ? [ - { - type: 'text' as const, - text: outputs.join('\n'), - }, - ] - : []), - ...(!outputs.length && stopRes.stdout - ? [ - { - type: 'text' as const, - text: `AXe output:\n${stopRes.stdout}`, - }, - ] - : []), - ], - isError: false, - _meta: finalSavedPath ? { outputFile: finalSavedPath } : undefined, - }; + const stopEvents = [ + headerEvent, + ...(outputs.length > 0 ? [section('Output', outputs)] : []), + ...(!outputs.length && stopRes.stdout ? [section('AXe Output', [stopRes.stdout])] : []), + statusLine('success', `Video recording stopped for simulator ${params.simulatorId}`), + ]; + + const response = toolResponse(stopEvents); + if (finalSavedPath) { + (response as Record)._meta = { outputFile: finalSavedPath }; + } + return response; } const publicSchemaObject = z.strictObject( diff --git a/src/mcp/tools/simulator/stop_app_sim.ts b/src/mcp/tools/simulator/stop_app_sim.ts index 1f7997a7..aba3cb38 100644 --- a/src/mcp/tools/simulator/stop_app_sim.ts +++ b/src/mcp/tools/simulator/stop_app_sim.ts @@ -7,6 +7,8 @@ import { createSessionAwareTool, getSessionAwareToolSchemaShape, } from '../../../utils/typed-tool-factory.ts'; +import { toolResponse } from '../../../utils/tool-response.ts'; +import { header, statusLine } from '../../../utils/tool-event-builders.ts'; const baseSchemaObject = z.object({ simulatorId: z @@ -24,7 +26,6 @@ const baseSchemaObject = z.object({ bundleId: z.string().describe('Bundle identifier of the app to stop'), }); -// Internal schema requires simulatorId (factory resolves simulatorName โ†’ simulatorId) const internalSchemaObject = z.object({ simulatorId: z.string(), simulatorName: z.string().optional(), @@ -44,42 +45,36 @@ export async function stop_app_simLogic( log('info', `Stopping app ${params.bundleId} in simulator ${simulatorId}`); + const headerEvent = header('Stop App', [ + { label: 'Simulator', value: simulatorDisplayName }, + { label: 'Bundle ID', value: params.bundleId }, + ]); + try { const command = ['xcrun', 'simctl', 'terminate', simulatorId, params.bundleId]; - const result = await executor(command, 'Stop App in Simulator', false, undefined); + const result = await executor(command, 'Stop App in Simulator', false); if (!result.success) { - return { - content: [ - { - type: 'text', - text: `Stop app in simulator operation failed: ${result.error}`, - }, - ], - isError: true, - }; + return toolResponse([ + headerEvent, + statusLine('error', `Stop app in simulator operation failed: ${result.error}`), + ]); } - return { - content: [ - { - type: 'text', - text: `App ${params.bundleId} stopped successfully in simulator ${simulatorDisplayName}`, - }, - ], - }; + return toolResponse([ + headerEvent, + statusLine( + 'success', + `App ${params.bundleId} stopped successfully in simulator ${simulatorDisplayName}`, + ), + ]); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); log('error', `Error stopping app in simulator: ${errorMessage}`); - return { - content: [ - { - type: 'text', - text: `Stop app in simulator operation failed: ${errorMessage}`, - }, - ], - isError: true, - }; + return toolResponse([ + headerEvent, + statusLine('error', `Stop app in simulator operation failed: ${errorMessage}`), + ]); } } diff --git a/src/mcp/tools/simulator/test_sim.ts b/src/mcp/tools/simulator/test_sim.ts index 5e717b2f..81339438 100644 --- a/src/mcp/tools/simulator/test_sim.ts +++ b/src/mcp/tools/simulator/test_sim.ts @@ -23,6 +23,8 @@ import { import { inferPlatform } from '../../../utils/infer-platform.ts'; import { resolveTestPreflight } from '../../../utils/test-preflight.ts'; import { resolveSimulatorIdOrName } from '../../../utils/simulator-resolver.ts'; +import { toolResponse } from '../../../utils/tool-response.ts'; +import { header, statusLine } from '../../../utils/tool-event-builders.ts'; const baseSchemaObject = z.object({ projectPath: z @@ -118,10 +120,7 @@ export async function test_simLogic( params.simulatorName, ); if (!simulatorResolution.success) { - return { - content: [{ type: 'text', text: simulatorResolution.error }], - isError: true, - }; + return toolResponse([header('Test Simulator'), statusLine('error', simulatorResolution.error)]); } const destinationName = params.simulatorName ?? simulatorResolution.simulatorName; diff --git a/src/mcp/tools/swift-package/__tests__/swift_package_build.test.ts b/src/mcp/tools/swift-package/__tests__/swift_package_build.test.ts index 7387e8fc..47dd4b15 100644 --- a/src/mcp/tools/swift-package/__tests__/swift_package_build.test.ts +++ b/src/mcp/tools/swift-package/__tests__/swift_package_build.test.ts @@ -164,8 +164,6 @@ describe('swift_package_build plugin', () => { describe('Response Logic Testing', () => { it('should handle missing packagePath parameter (Zod handles validation)', async () => { - // Note: With createTypedTool, Zod validation happens before the logic function is called - // So we test with a valid but minimal parameter set since validation is handled upstream const executor = createMockExecutor({ success: true, output: 'Build succeeded', @@ -173,9 +171,7 @@ describe('swift_package_build plugin', () => { const result = await swift_package_buildLogic({ packagePath: '/test/package' }, executor); - // The logic function should execute normally with valid parameters - // Zod validation errors are handled by createTypedTool wrapper - expect(result.isError).toBe(false); + expect(result.isError).toBeUndefined(); }); it('should return successful build response', async () => { @@ -191,17 +187,11 @@ describe('swift_package_build plugin', () => { executor, ); - expect(result).toEqual({ - content: [ - { type: 'text', text: 'โœ… Swift package build succeeded.' }, - { - type: 'text', - text: '๐Ÿ’ก Next: Run tests with swift_package_test or execute with swift_package_run', - }, - { type: 'text', text: 'Build complete.' }, - ], - isError: false, - }); + expect(result.isError).toBeUndefined(); + const text = result.content.map((c) => c.text).join('\n'); + expect(text).toContain('Swift Package Build'); + expect(text).toContain('Swift package build succeeded'); + expect(text).toContain('Build complete.'); }); it('should return error response for build failure', async () => { @@ -217,15 +207,10 @@ describe('swift_package_build plugin', () => { executor, ); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: 'Error: Swift package build failed\nDetails: Compilation failed: error in main.swift', - }, - ], - isError: true, - }); + expect(result.isError).toBe(true); + const text = result.content.map((c) => c.text).join('\n'); + expect(text).toContain('Swift package build failed'); + expect(text).toContain('Compilation failed: error in main.swift'); }); it('should include stdout diagnostics when stderr is empty on build failure', async () => { @@ -243,15 +228,10 @@ describe('swift_package_build plugin', () => { executor, ); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: "Error: Swift package build failed\nDetails: main.swift:10:25: error: cannot find type 'DOESNOTEXIST' in scope\nlet broken: DOESNOTEXIST = 42", - }, - ], - isError: true, - }); + expect(result.isError).toBe(true); + const text = result.content.map((c) => c.text).join('\n'); + expect(text).toContain('Swift package build failed'); + expect(text).toContain("cannot find type 'DOESNOTEXIST' in scope"); }); it('should handle spawn error', async () => { @@ -266,15 +246,10 @@ describe('swift_package_build plugin', () => { executor, ); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: 'Error: Failed to execute swift build\nDetails: spawn ENOENT', - }, - ], - isError: true, - }); + expect(result.isError).toBe(true); + const text = result.content.map((c) => c.text).join('\n'); + expect(text).toContain('Failed to execute swift build'); + expect(text).toContain('spawn ENOENT'); }); it('should handle successful build with parameters', async () => { @@ -294,17 +269,11 @@ describe('swift_package_build plugin', () => { executor, ); - expect(result).toEqual({ - content: [ - { type: 'text', text: 'โœ… Swift package build succeeded.' }, - { - type: 'text', - text: '๐Ÿ’ก Next: Run tests with swift_package_test or execute with swift_package_run', - }, - { type: 'text', text: 'Build complete.' }, - ], - isError: false, - }); + expect(result.isError).toBeUndefined(); + const text = result.content.map((c) => c.text).join('\n'); + expect(text).toContain('Swift Package Build'); + expect(text).toContain('Swift package build succeeded'); + expect(text).toContain('Build complete.'); }); }); }); diff --git a/src/mcp/tools/swift-package/__tests__/swift_package_clean.test.ts b/src/mcp/tools/swift-package/__tests__/swift_package_clean.test.ts index f739054a..56fc682a 100644 --- a/src/mcp/tools/swift-package/__tests__/swift_package_clean.test.ts +++ b/src/mcp/tools/swift-package/__tests__/swift_package_clean.test.ts @@ -68,7 +68,6 @@ describe('swift_package_clean plugin', () => { describe('Response Logic Testing', () => { it('should handle valid params without validation errors in logic function', async () => { - // Note: The logic function assumes valid params since createTypedTool handles validation const mockExecutor = createMockExecutor({ success: true, output: 'Package cleaned successfully', @@ -81,8 +80,9 @@ describe('swift_package_clean plugin', () => { mockExecutor, ); - expect(result.isError).toBe(false); - expect(result.content[0].text).toBe('โœ… Swift package cleaned successfully.'); + expect(result.isError).toBeUndefined(); + const text = result.content.map((c) => c.text).join('\n'); + expect(text).toContain('Swift package cleaned successfully'); }); it('should return successful clean response', async () => { @@ -98,17 +98,11 @@ describe('swift_package_clean plugin', () => { mockExecutor, ); - expect(result).toEqual({ - content: [ - { type: 'text', text: 'โœ… Swift package cleaned successfully.' }, - { - type: 'text', - text: '๐Ÿ’ก Build artifacts and derived data removed. Ready for fresh build.', - }, - { type: 'text', text: 'Package cleaned successfully' }, - ], - isError: false, - }); + expect(result.isError).toBeUndefined(); + const text = result.content.map((c) => c.text).join('\n'); + expect(text).toContain('Swift Package Clean'); + expect(text).toContain('Swift package cleaned successfully'); + expect(text).toContain('Package cleaned successfully'); }); it('should return successful clean response with no output', async () => { @@ -124,17 +118,10 @@ describe('swift_package_clean plugin', () => { mockExecutor, ); - expect(result).toEqual({ - content: [ - { type: 'text', text: 'โœ… Swift package cleaned successfully.' }, - { - type: 'text', - text: '๐Ÿ’ก Build artifacts and derived data removed. Ready for fresh build.', - }, - { type: 'text', text: '(clean completed silently)' }, - ], - isError: false, - }); + expect(result.isError).toBeUndefined(); + const text = result.content.map((c) => c.text).join('\n'); + expect(text).toContain('Swift Package Clean'); + expect(text).toContain('Swift package cleaned successfully'); }); it('should return error response for clean failure', async () => { @@ -150,15 +137,10 @@ describe('swift_package_clean plugin', () => { mockExecutor, ); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: 'Error: Swift package clean failed\nDetails: Permission denied', - }, - ], - isError: true, - }); + expect(result.isError).toBe(true); + const text = result.content.map((c) => c.text).join('\n'); + expect(text).toContain('Swift package clean failed'); + expect(text).toContain('Permission denied'); }); it('should handle spawn error', async () => { @@ -173,15 +155,10 @@ describe('swift_package_clean plugin', () => { mockExecutor, ); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: 'Error: Failed to execute swift package clean\nDetails: spawn ENOENT', - }, - ], - isError: true, - }); + expect(result.isError).toBe(true); + const text = result.content.map((c) => c.text).join('\n'); + expect(text).toContain('Failed to execute swift package clean'); + expect(text).toContain('spawn ENOENT'); }); }); }); diff --git a/src/mcp/tools/swift-package/__tests__/swift_package_list.test.ts b/src/mcp/tools/swift-package/__tests__/swift_package_list.test.ts index 32a0b22c..23df3d5f 100644 --- a/src/mcp/tools/swift-package/__tests__/swift_package_list.test.ts +++ b/src/mcp/tools/swift-package/__tests__/swift_package_list.test.ts @@ -24,10 +24,7 @@ describe('swift_package_list plugin', () => { describe('Handler Behavior (Complete Literal Returns)', () => { it('should return empty list when no processes are running', async () => { - // Create empty mock process map const mockProcessMap = new Map(); - - // Use pure dependency injection with stub functions const mockArrayFrom = () => []; const mockDateNow = () => Date.now(); @@ -40,19 +37,14 @@ describe('swift_package_list plugin', () => { }, ); - expect(result).toEqual({ - content: [ - { type: 'text', text: 'โ„น๏ธ No Swift Package processes currently running.' }, - { type: 'text', text: '๐Ÿ’ก Use swift_package_run to start an executable.' }, - ], - }); + expect(result.isError).toBeUndefined(); + const text = result.content.map((c) => c.text).join('\n'); + expect(text).toContain('Swift Package List'); + expect(text).toContain('No Swift Package processes currently running'); }); it('should handle empty args object', async () => { - // Create empty mock process map const mockProcessMap = new Map(); - - // Use pure dependency injection with stub functions const mockArrayFrom = () => []; const mockDateNow = () => Date.now(); @@ -65,19 +57,13 @@ describe('swift_package_list plugin', () => { }, ); - expect(result).toEqual({ - content: [ - { type: 'text', text: 'โ„น๏ธ No Swift Package processes currently running.' }, - { type: 'text', text: '๐Ÿ’ก Use swift_package_run to start an executable.' }, - ], - }); + expect(result.isError).toBeUndefined(); + const text = result.content.map((c) => c.text).join('\n'); + expect(text).toContain('No Swift Package processes currently running'); }); it('should handle null args', async () => { - // Create empty mock process map const mockProcessMap = new Map(); - - // Use pure dependency injection with stub functions const mockArrayFrom = () => []; const mockDateNow = () => Date.now(); @@ -87,19 +73,13 @@ describe('swift_package_list plugin', () => { dateNow: mockDateNow, }); - expect(result).toEqual({ - content: [ - { type: 'text', text: 'โ„น๏ธ No Swift Package processes currently running.' }, - { type: 'text', text: '๐Ÿ’ก Use swift_package_run to start an executable.' }, - ], - }); + expect(result.isError).toBeUndefined(); + const text = result.content.map((c) => c.text).join('\n'); + expect(text).toContain('No Swift Package processes currently running'); }); it('should handle undefined args', async () => { - // Create empty mock process map const mockProcessMap = new Map(); - - // Use pure dependency injection with stub functions const mockArrayFrom = () => []; const mockDateNow = () => Date.now(); @@ -109,19 +89,13 @@ describe('swift_package_list plugin', () => { dateNow: mockDateNow, }); - expect(result).toEqual({ - content: [ - { type: 'text', text: 'โ„น๏ธ No Swift Package processes currently running.' }, - { type: 'text', text: '๐Ÿ’ก Use swift_package_run to start an executable.' }, - ], - }); + expect(result.isError).toBeUndefined(); + const text = result.content.map((c) => c.text).join('\n'); + expect(text).toContain('No Swift Package processes currently running'); }); it('should handle args with extra properties', async () => { - // Create empty mock process map const mockProcessMap = new Map(); - - // Use pure dependency injection with stub functions const mockArrayFrom = () => []; const mockDateNow = () => Date.now(); @@ -137,12 +111,9 @@ describe('swift_package_list plugin', () => { }, ); - expect(result).toEqual({ - content: [ - { type: 'text', text: 'โ„น๏ธ No Swift Package processes currently running.' }, - { type: 'text', text: '๐Ÿ’ก Use swift_package_run to start an executable.' }, - ], - }); + expect(result.isError).toBeUndefined(); + const text = result.content.map((c) => c.text).join('\n'); + expect(text).toContain('No Swift Package processes currently running'); }); it('should return single process when one process is running', async () => { @@ -153,12 +124,9 @@ describe('swift_package_list plugin', () => { startedAt: startedAt, }; - // Create mock process map with one process const mockProcessMap = new Map([[12345, mockProcess]]); - - // Use pure dependency injection with stub functions const mockArrayFrom = (mapEntries: any) => Array.from(mapEntries); - const mockDateNow = () => startedAt.getTime() + 5000; // 5 seconds after start + const mockDateNow = () => startedAt.getTime() + 5000; const result = await swift_package_listLogic( {}, @@ -169,13 +137,13 @@ describe('swift_package_list plugin', () => { }, ); - expect(result).toEqual({ - content: [ - { type: 'text', text: '๐Ÿ“‹ Active Swift Package processes (1):' }, - { type: 'text', text: ' โ€ข PID 12345: MyApp (/test/package) - running 5s' }, - { type: 'text', text: '๐Ÿ’ก Use swift_package_stop with a PID to terminate a process.' }, - ], - }); + expect(result.isError).toBeUndefined(); + const text = result.content.map((c) => c.text).join('\n'); + expect(text).toContain('Active Processes (1)'); + expect(text).toContain('12345'); + expect(text).toContain('MyApp'); + expect(text).toContain('/test/package'); + expect(text).toContain('5s'); }); it('should return multiple processes when several are running', async () => { @@ -189,12 +157,11 @@ describe('swift_package_list plugin', () => { }; const mockProcess2 = { - executableName: undefined, // Test default executable name + executableName: undefined, packagePath: '/test/package2', startedAt: startedAt2, }; - // Create mock process map with multiple processes const mockProcessMap = new Map< number, { executableName?: string; packagePath: string; startedAt: Date } @@ -203,9 +170,8 @@ describe('swift_package_list plugin', () => { [12346, mockProcess2], ]); - // Use pure dependency injection with stub functions const mockArrayFrom = (mapEntries: any) => Array.from(mapEntries); - const mockDateNow = () => startedAt1.getTime() + 10000; // 10 seconds after first start + const mockDateNow = () => startedAt1.getTime() + 10000; const result = await swift_package_listLogic( {}, @@ -216,33 +182,34 @@ describe('swift_package_list plugin', () => { }, ); - expect(result).toEqual({ - content: [ - { type: 'text', text: '๐Ÿ“‹ Active Swift Package processes (2):' }, - { type: 'text', text: ' โ€ข PID 12345: MyApp (/test/package1) - running 10s' }, - { type: 'text', text: ' โ€ข PID 12346: default (/test/package2) - running 3s' }, - { type: 'text', text: '๐Ÿ’ก Use swift_package_stop with a PID to terminate a process.' }, - ], - }); + expect(result.isError).toBeUndefined(); + const text = result.content.map((c) => c.text).join('\n'); + expect(text).toContain('Active Processes (2)'); + expect(text).toContain('12345'); + expect(text).toContain('MyApp'); + expect(text).toContain('/test/package1'); + expect(text).toContain('10s'); + expect(text).toContain('12346'); + expect(text).toContain('default'); + expect(text).toContain('/test/package2'); + expect(text).toContain('3s'); }); it('should handle process with missing executableName', async () => { const startedAt = new Date('2023-01-01T10:00:00.000Z'); const mockProcess = { - executableName: undefined, // Test missing executable name + executableName: undefined, packagePath: '/test/package', startedAt: startedAt, }; - // Create mock process map with one process const mockProcessMap = new Map< number, { executableName?: string; packagePath: string; startedAt: Date } >([[12345, mockProcess]]); - // Use pure dependency injection with stub functions const mockArrayFrom = (mapEntries: any) => Array.from(mapEntries); - const mockDateNow = () => startedAt.getTime() + 1000; // 1 second after start + const mockDateNow = () => startedAt.getTime() + 1000; const result = await swift_package_listLogic( {}, @@ -253,29 +220,23 @@ describe('swift_package_list plugin', () => { }, ); - expect(result).toEqual({ - content: [ - { type: 'text', text: '๐Ÿ“‹ Active Swift Package processes (1):' }, - { type: 'text', text: ' โ€ข PID 12345: default (/test/package) - running 1s' }, - { type: 'text', text: '๐Ÿ’ก Use swift_package_stop with a PID to terminate a process.' }, - ], - }); + expect(result.isError).toBeUndefined(); + const text = result.content.map((c) => c.text).join('\n'); + expect(text).toContain('default'); + expect(text).toContain('1s'); }); it('should handle process with empty string executableName', async () => { const startedAt = new Date('2023-01-01T10:00:00.000Z'); const mockProcess = { - executableName: '', // Test empty string executable name + executableName: '', packagePath: '/test/package', startedAt: startedAt, }; - // Create mock process map with one process const mockProcessMap = new Map([[12345, mockProcess]]); - - // Use pure dependency injection with stub functions const mockArrayFrom = (mapEntries: any) => Array.from(mapEntries); - const mockDateNow = () => startedAt.getTime() + 2000; // 2 seconds after start + const mockDateNow = () => startedAt.getTime() + 2000; const result = await swift_package_listLogic( {}, @@ -286,13 +247,10 @@ describe('swift_package_list plugin', () => { }, ); - expect(result).toEqual({ - content: [ - { type: 'text', text: '๐Ÿ“‹ Active Swift Package processes (1):' }, - { type: 'text', text: ' โ€ข PID 12345: default (/test/package) - running 2s' }, - { type: 'text', text: '๐Ÿ’ก Use swift_package_stop with a PID to terminate a process.' }, - ], - }); + expect(result.isError).toBeUndefined(); + const text = result.content.map((c) => c.text).join('\n'); + expect(text).toContain('default'); + expect(text).toContain('2s'); }); it('should handle very recent process (less than 1 second)', async () => { @@ -303,12 +261,9 @@ describe('swift_package_list plugin', () => { startedAt: startedAt, }; - // Create mock process map with one process const mockProcessMap = new Map([[12345, mockProcess]]); - - // Use pure dependency injection with stub functions const mockArrayFrom = (mapEntries: any) => Array.from(mapEntries); - const mockDateNow = () => startedAt.getTime() + 500; // 500ms after start + const mockDateNow = () => startedAt.getTime() + 500; const result = await swift_package_listLogic( {}, @@ -319,13 +274,10 @@ describe('swift_package_list plugin', () => { }, ); - expect(result).toEqual({ - content: [ - { type: 'text', text: '๐Ÿ“‹ Active Swift Package processes (1):' }, - { type: 'text', text: ' โ€ข PID 12345: FastApp (/test/package) - running 1s' }, - { type: 'text', text: '๐Ÿ’ก Use swift_package_stop with a PID to terminate a process.' }, - ], - }); + expect(result.isError).toBeUndefined(); + const text = result.content.map((c) => c.text).join('\n'); + expect(text).toContain('FastApp'); + expect(text).toContain('1s'); }); it('should handle process running for exactly 0 milliseconds', async () => { @@ -336,12 +288,9 @@ describe('swift_package_list plugin', () => { startedAt: startedAt, }; - // Create mock process map with one process const mockProcessMap = new Map([[12345, mockProcess]]); - - // Use pure dependency injection with stub functions const mockArrayFrom = (mapEntries: any) => Array.from(mapEntries); - const mockDateNow = () => startedAt.getTime(); // Same time as start + const mockDateNow = () => startedAt.getTime(); const result = await swift_package_listLogic( {}, @@ -352,13 +301,10 @@ describe('swift_package_list plugin', () => { }, ); - expect(result).toEqual({ - content: [ - { type: 'text', text: '๐Ÿ“‹ Active Swift Package processes (1):' }, - { type: 'text', text: ' โ€ข PID 12345: InstantApp (/test/package) - running 1s' }, - { type: 'text', text: '๐Ÿ’ก Use swift_package_stop with a PID to terminate a process.' }, - ], - }); + expect(result.isError).toBeUndefined(); + const text = result.content.map((c) => c.text).join('\n'); + expect(text).toContain('InstantApp'); + expect(text).toContain('1s'); }); it('should handle process running for a long time', async () => { @@ -369,12 +315,9 @@ describe('swift_package_list plugin', () => { startedAt: startedAt, }; - // Create mock process map with one process const mockProcessMap = new Map([[12345, mockProcess]]); - - // Use pure dependency injection with stub functions const mockArrayFrom = (mapEntries: any) => Array.from(mapEntries); - const mockDateNow = () => startedAt.getTime() + 7200000; // 2 hours later + const mockDateNow = () => startedAt.getTime() + 7200000; const result = await swift_package_listLogic( {}, @@ -385,13 +328,10 @@ describe('swift_package_list plugin', () => { }, ); - expect(result).toEqual({ - content: [ - { type: 'text', text: '๐Ÿ“‹ Active Swift Package processes (1):' }, - { type: 'text', text: ' โ€ข PID 12345: LongRunningApp (/test/package) - running 7200s' }, - { type: 'text', text: '๐Ÿ’ก Use swift_package_stop with a PID to terminate a process.' }, - ], - }); + expect(result.isError).toBeUndefined(); + const text = result.content.map((c) => c.text).join('\n'); + expect(text).toContain('LongRunningApp'); + expect(text).toContain('7200s'); }); }); }); diff --git a/src/mcp/tools/swift-package/__tests__/swift_package_run.test.ts b/src/mcp/tools/swift-package/__tests__/swift_package_run.test.ts index 0e55cb84..1ccc3279 100644 --- a/src/mcp/tools/swift-package/__tests__/swift_package_run.test.ts +++ b/src/mcp/tools/swift-package/__tests__/swift_package_run.test.ts @@ -267,7 +267,6 @@ describe('swift_package_run plugin', () => { }); it('should not call executor for background mode', async () => { - // For background mode, no executor should be called since it uses direct spawn const mockExecutor = createNoopExecutor(); const result = await swift_package_runLogic( @@ -278,26 +277,19 @@ describe('swift_package_run plugin', () => { mockExecutor, ); - // Should return success without calling executor - expect(result.content[0].text).toContain('๐Ÿš€ Started executable in background'); + const text = result.content.map((c) => c.text).join('\n'); + expect(text).toContain('Started executable in background'); }); }); describe('Response Logic Testing', () => { it('should return validation error for missing packagePath', async () => { - // Since the tool now uses createTypedTool, Zod validation happens at the handler level - // Test the handler directly to see Zod validation const result = await handler({}); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: 'Error: Parameter validation failed\nDetails: Invalid parameters:\npackagePath: Invalid input: expected string, received undefined', - }, - ], - isError: true, - }); + expect(result.isError).toBe(true); + const text = result.content.map((c) => c.text).join('\n'); + expect(text).toContain('Parameter validation failed'); + expect(text).toContain('packagePath'); }); it('should return success response for background mode', async () => { @@ -310,8 +302,8 @@ describe('swift_package_run plugin', () => { mockExecutor, ); - expect(result.content[0].text).toContain('๐Ÿš€ Started executable in background'); - expect(result.content[0].text).toContain('๐Ÿ’ก Process is running independently'); + const text = result.content.map((c) => c.text).join('\n'); + expect(text).toContain('Started executable in background'); }); it('should return success response for successful execution', async () => { @@ -327,13 +319,11 @@ describe('swift_package_run plugin', () => { mockExecutor, ); - expect(result).toEqual({ - content: [ - { type: 'text', text: 'โœ… Swift executable completed successfully.' }, - { type: 'text', text: '๐Ÿ’ก Process finished cleanly. Check output for results.' }, - { type: 'text', text: 'Hello, World!' }, - ], - }); + expect(result.isError).toBeUndefined(); + const text = result.content.map((c) => c.text).join('\n'); + expect(text).toContain('Swift Package Run'); + expect(text).toContain('Swift executable completed successfully'); + expect(text).toContain('Hello, World!'); }); it('should return error response for failed execution', async () => { @@ -350,13 +340,10 @@ describe('swift_package_run plugin', () => { mockExecutor, ); - expect(result).toEqual({ - content: [ - { type: 'text', text: 'โŒ Swift executable failed.' }, - { type: 'text', text: '(no output)' }, - { type: 'text', text: 'Errors:\nCompilation failed' }, - ], - }); + expect(result.isError).toBe(true); + const text = result.content.map((c) => c.text).join('\n'); + expect(text).toContain('Swift executable failed'); + expect(text).toContain('Compilation failed'); }); it('should handle executor error', async () => { @@ -369,15 +356,10 @@ describe('swift_package_run plugin', () => { mockExecutor, ); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: 'Error: Failed to execute swift run\nDetails: Command not found', - }, - ], - isError: true, - }); + expect(result.isError).toBe(true); + const text = result.content.map((c) => c.text).join('\n'); + expect(text).toContain('Failed to execute swift run'); + expect(text).toContain('Command not found'); }); }); }); diff --git a/src/mcp/tools/swift-package/__tests__/swift_package_stop.test.ts b/src/mcp/tools/swift-package/__tests__/swift_package_stop.test.ts index c7e69e52..ee837292 100644 --- a/src/mcp/tools/swift-package/__tests__/swift_package_stop.test.ts +++ b/src/mcp/tools/swift-package/__tests__/swift_package_stop.test.ts @@ -27,15 +27,9 @@ describe('swift_package_stop plugin', () => { }), ); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: 'โš ๏ธ No running process found with PID 99999. Use swift_package_run to check active processes.', - }, - ], - isError: true, - }); + expect(result.isError).toBe(true); + const text = result.content.map((c) => c.text).join('\n'); + expect(text).toContain('No running process found with PID 99999'); }); it('returns success response when termination succeeds', async () => { @@ -61,18 +55,9 @@ describe('swift_package_stop plugin', () => { ); expect(terminateTrackedProcess).toHaveBeenCalledWith(12345, 5000); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: 'โœ… Stopped executable (was running since 2023-01-01T10:00:00.000Z)', - }, - { - type: 'text', - text: '๐Ÿ’ก Process terminated. You can now run swift_package_run again if needed.', - }, - ], - }); + expect(result.isError).toBeUndefined(); + const text = result.content.map((c) => c.text).join('\n'); + expect(text).toContain('Stopped executable (was running since 2023-01-01T10:00:00.000Z)'); }); it('returns error response when termination reports an error', async () => { @@ -95,15 +80,10 @@ describe('swift_package_stop plugin', () => { }), ); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: 'Error: Failed to stop process\nDetails: ESRCH: No such process', - }, - ], - isError: true, - }); + expect(result.isError).toBe(true); + const text = result.content.map((c) => c.text).join('\n'); + expect(text).toContain('Failed to stop process'); + expect(text).toContain('ESRCH: No such process'); }); it('uses custom timeout when provided', async () => { @@ -136,7 +116,8 @@ describe('swift_package_stop plugin', () => { const result = await handler({ pid: 'bad' }); expect(result.isError).toBe(true); - expect(result.content[0]?.text).toContain('Parameter validation failed'); + const text = result.content.map((c) => c.text).join('\n'); + expect(text).toContain('Parameter validation failed'); }); }); }); diff --git a/src/mcp/tools/swift-package/__tests__/swift_package_test.test.ts b/src/mcp/tools/swift-package/__tests__/swift_package_test.test.ts index d8a6a13a..ef491108 100644 --- a/src/mcp/tools/swift-package/__tests__/swift_package_test.test.ts +++ b/src/mcp/tools/swift-package/__tests__/swift_package_test.test.ts @@ -155,8 +155,6 @@ describe('swift_package_test plugin', () => { describe('Response Logic Testing', () => { it('should handle empty packagePath parameter', async () => { - // When packagePath is empty, the function should still process it - // but the command execution may fail, which is handled by the executor const mockExecutor = createMockExecutor({ success: true, output: 'Tests completed with empty path', @@ -164,8 +162,9 @@ describe('swift_package_test plugin', () => { const result = await swift_package_testLogic({ packagePath: '' }, mockExecutor); - expect(result.isError).toBe(false); - expect(result.content[0].text).toBe('โœ… Swift package tests completed.'); + expect(result.isError).toBeUndefined(); + const text = result.content.map((c) => c.text).join('\n'); + expect(text).toContain('Swift package tests completed'); }); it('should return successful test response', async () => { @@ -181,17 +180,11 @@ describe('swift_package_test plugin', () => { mockExecutor, ); - expect(result).toEqual({ - content: [ - { type: 'text', text: 'โœ… Swift package tests completed.' }, - { - type: 'text', - text: '๐Ÿ’ก Next: Execute your app with swift_package_run if tests passed', - }, - { type: 'text', text: 'All tests passed.' }, - ], - isError: false, - }); + expect(result.isError).toBeUndefined(); + const text = result.content.map((c) => c.text).join('\n'); + expect(text).toContain('Swift Package Test'); + expect(text).toContain('Swift package tests completed'); + expect(text).toContain('All tests passed.'); }); it('should return error response for test failure', async () => { @@ -207,15 +200,10 @@ describe('swift_package_test plugin', () => { mockExecutor, ); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: 'Error: Swift package tests failed\nDetails: 2 tests failed', - }, - ], - isError: true, - }); + expect(result.isError).toBe(true); + const text = result.content.map((c) => c.text).join('\n'); + expect(text).toContain('Swift package tests failed'); + expect(text).toContain('2 tests failed'); }); it('should include stdout diagnostics when stderr is empty on test failure', async () => { @@ -233,15 +221,10 @@ describe('swift_package_test plugin', () => { mockExecutor, ); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: "Error: Swift package tests failed\nDetails: main.swift:10:25: error: cannot find type 'DOESNOTEXIST' in scope\nlet broken: DOESNOTEXIST = 42", - }, - ], - isError: true, - }); + expect(result.isError).toBe(true); + const text = result.content.map((c) => c.text).join('\n'); + expect(text).toContain('Swift package tests failed'); + expect(text).toContain("cannot find type 'DOESNOTEXIST' in scope"); }); it('should handle spawn error', async () => { @@ -256,15 +239,10 @@ describe('swift_package_test plugin', () => { mockExecutor, ); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: 'Error: Failed to execute swift test\nDetails: spawn ENOENT', - }, - ], - isError: true, - }); + expect(result.isError).toBe(true); + const text = result.content.map((c) => c.text).join('\n'); + expect(text).toContain('Failed to execute swift test'); + expect(text).toContain('spawn ENOENT'); }); it('should handle successful test with parameters', async () => { @@ -286,17 +264,11 @@ describe('swift_package_test plugin', () => { mockExecutor, ); - expect(result).toEqual({ - content: [ - { type: 'text', text: 'โœ… Swift package tests completed.' }, - { - type: 'text', - text: '๐Ÿ’ก Next: Execute your app with swift_package_run if tests passed', - }, - { type: 'text', text: 'Tests completed.' }, - ], - isError: false, - }); + expect(result.isError).toBeUndefined(); + const text = result.content.map((c) => c.text).join('\n'); + expect(text).toContain('Swift Package Test'); + expect(text).toContain('Swift package tests completed'); + expect(text).toContain('Tests completed.'); }); }); }); diff --git a/src/mcp/tools/swift-package/swift_package_build.ts b/src/mcp/tools/swift-package/swift_package_build.ts index 2dd87d93..2f2df2cf 100644 --- a/src/mcp/tools/swift-package/swift_package_build.ts +++ b/src/mcp/tools/swift-package/swift_package_build.ts @@ -1,6 +1,5 @@ import * as z from 'zod'; import path from 'node:path'; -import { createErrorResponse } from '../../../utils/responses/index.ts'; import { log } from '../../../utils/logging/index.ts'; import type { CommandExecutor } from '../../../utils/execution/index.ts'; import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; @@ -9,8 +8,9 @@ import { createSessionAwareTool, getSessionAwareToolSchemaShape, } from '../../../utils/typed-tool-factory.ts'; +import { toolResponse } from '../../../utils/tool-response.ts'; +import { header, statusLine, section } from '../../../utils/tool-event-builders.ts'; -// Define schema as ZodObject const baseSchemaObject = z.object({ packagePath: z.string(), targetName: z.string().optional(), @@ -25,7 +25,6 @@ const publicSchemaObject = baseSchemaObject.omit({ const swiftPackageBuildSchema = baseSchemaObject; -// Use z.infer for type safety type SwiftPackageBuildParams = z.infer; export async function swift_package_buildLogic( @@ -54,28 +53,35 @@ export async function swift_package_buildLogic( } log('info', `Running swift ${swiftArgs.join(' ')}`); + + const headerEvent = header('Swift Package Build', [ + { label: 'Package', value: resolvedPath }, + ...(params.targetName ? [{ label: 'Target', value: params.targetName }] : []), + ...(params.configuration ? [{ label: 'Configuration', value: params.configuration }] : []), + ]); + try { - const result = await executor(['swift', ...swiftArgs], 'Swift Package Build', false, undefined); + const result = await executor(['swift', ...swiftArgs], 'Swift Package Build', false); if (!result.success) { const errorMessage = result.error || result.output || 'Unknown error'; - return createErrorResponse('Swift package build failed', errorMessage); + return toolResponse([ + headerEvent, + statusLine('error', `Swift package build failed: ${errorMessage}`), + ]); } - return { - content: [ - { type: 'text', text: 'โœ… Swift package build succeeded.' }, - { - type: 'text', - text: '๐Ÿ’ก Next: Run tests with swift_package_test or execute with swift_package_run', - }, - { type: 'text', text: result.output }, - ], - isError: false, - }; + return toolResponse([ + headerEvent, + ...(result.output ? [section('Output', [result.output])] : []), + statusLine('success', 'Swift package build succeeded'), + ]); } catch (error) { const message = error instanceof Error ? error.message : String(error); log('error', `Swift package build failed: ${message}`); - return createErrorResponse('Failed to execute swift build', message); + return toolResponse([ + headerEvent, + statusLine('error', `Failed to execute swift build: ${message}`), + ]); } } diff --git a/src/mcp/tools/swift-package/swift_package_clean.ts b/src/mcp/tools/swift-package/swift_package_clean.ts index 1c2e8428..59e70a05 100644 --- a/src/mcp/tools/swift-package/swift_package_clean.ts +++ b/src/mcp/tools/swift-package/swift_package_clean.ts @@ -2,17 +2,16 @@ import * as z from 'zod'; import path from 'node:path'; import type { CommandExecutor } from '../../../utils/execution/index.ts'; import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; -import { createErrorResponse } from '../../../utils/responses/index.ts'; import { log } from '../../../utils/logging/index.ts'; import type { ToolResponse } from '../../../types/common.ts'; import { createTypedTool } from '../../../utils/typed-tool-factory.ts'; +import { toolResponse } from '../../../utils/tool-response.ts'; +import { header, statusLine, section } from '../../../utils/tool-event-builders.ts'; -// Define schema as ZodObject const swiftPackageCleanSchema = z.object({ packagePath: z.string(), }); -// Use z.infer for type safety type SwiftPackageCleanParams = z.infer; export async function swift_package_cleanLogic( @@ -23,28 +22,31 @@ export async function swift_package_cleanLogic( const swiftArgs = ['package', '--package-path', resolvedPath, 'clean']; log('info', `Running swift ${swiftArgs.join(' ')}`); + + const headerEvent = header('Swift Package Clean', [{ label: 'Package', value: resolvedPath }]); + try { - const result = await executor(['swift', ...swiftArgs], 'Swift Package Clean', false, undefined); + const result = await executor(['swift', ...swiftArgs], 'Swift Package Clean', false); if (!result.success) { const errorMessage = result.error || result.output || 'Unknown error'; - return createErrorResponse('Swift package clean failed', errorMessage); + return toolResponse([ + headerEvent, + statusLine('error', `Swift package clean failed: ${errorMessage}`), + ]); } - return { - content: [ - { type: 'text', text: 'โœ… Swift package cleaned successfully.' }, - { - type: 'text', - text: '๐Ÿ’ก Build artifacts and derived data removed. Ready for fresh build.', - }, - { type: 'text', text: result.output || '(clean completed silently)' }, - ], - isError: false, - }; + return toolResponse([ + headerEvent, + ...(result.output ? [section('Output', [result.output])] : []), + statusLine('success', 'Swift package cleaned successfully'), + ]); } catch (error) { const message = error instanceof Error ? error.message : String(error); log('error', `Swift package clean failed: ${message}`); - return createErrorResponse('Failed to execute swift package clean', message); + return toolResponse([ + headerEvent, + statusLine('error', `Failed to execute swift package clean: ${message}`), + ]); } } diff --git a/src/mcp/tools/swift-package/swift_package_list.ts b/src/mcp/tools/swift-package/swift_package_list.ts index 42d14de0..932c1962 100644 --- a/src/mcp/tools/swift-package/swift_package_list.ts +++ b/src/mcp/tools/swift-package/swift_package_list.ts @@ -1,14 +1,10 @@ -// Note: This tool shares the activeProcesses map with swift_package_run -// Since both are in the same workflow directory, they can share state - -// Import the shared activeProcesses map from swift_package_run -// This maintains the same behavior as the original implementation import * as z from 'zod'; import type { ToolResponse } from '../../../types/common.ts'; -import { createTextContent } from '../../../types/common.ts'; import { createTypedTool } from '../../../utils/typed-tool-factory.ts'; import { getDefaultCommandExecutor } from '../../../utils/command.ts'; import { activeProcesses } from './active-processes.ts'; +import { toolResponse } from '../../../utils/tool-response.ts'; +import { header, statusLine, table } from '../../../utils/tool-event-builders.ts'; /** * Process list dependencies for dependency injection @@ -52,37 +48,41 @@ export async function swift_package_listLogic( const processes = arrayFrom(processMap.entries()); + const headerEvent = header('Swift Package List'); + if (processes.length === 0) { - return { - content: [ - createTextContent('โ„น๏ธ No Swift Package processes currently running.'), - createTextContent('๐Ÿ’ก Use swift_package_run to start an executable.'), - ], - }; + return toolResponse([ + headerEvent, + statusLine('info', 'No Swift Package processes currently running.'), + ]); } - const content = [createTextContent(`๐Ÿ“‹ Active Swift Package processes (${processes.length}):`)]; - - for (const [pid, info] of processes) { - // Use logical OR instead of nullish coalescing to treat empty strings as falsy + const rows = processes.map(([pid, info]: [number, ListProcessInfo]) => { // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing const executableName = info.executableName || 'default'; const runtime = Math.max(1, Math.round((dateNow() - info.startedAt.getTime()) / 1000)); const packagePath = info.packagePath ?? 'unknown package'; - content.push( - createTextContent(` โ€ข PID ${pid}: ${executableName} (${packagePath}) - running ${runtime}s`), - ); - } - - content.push(createTextContent('๐Ÿ’ก Use swift_package_stop with a PID to terminate a process.')); + return { + PID: String(pid), + Executable: executableName, + Package: packagePath, + Runtime: `${runtime}s`, + }; + }); - return { content }; + return toolResponse([ + headerEvent, + table( + ['PID', 'Executable', 'Package', 'Runtime'], + rows, + `Active Processes (${processes.length})`, + ), + statusLine('success', `${processes.length} process(es) running`), + ]); } -// Define schema as ZodObject (empty for this tool) const swiftPackageListSchema = z.object({}); -// Use z.infer for type safety type SwiftPackageListParams = z.infer; export const schema = swiftPackageListSchema.shape; diff --git a/src/mcp/tools/swift-package/swift_package_run.ts b/src/mcp/tools/swift-package/swift_package_run.ts index 451733a7..26e14efe 100644 --- a/src/mcp/tools/swift-package/swift_package_run.ts +++ b/src/mcp/tools/swift-package/swift_package_run.ts @@ -1,19 +1,18 @@ import * as z from 'zod'; import path from 'node:path'; -import { createTextResponse, createErrorResponse } from '../../../utils/responses/index.ts'; import { log } from '../../../utils/logging/index.ts'; import type { CommandExecutor } from '../../../utils/execution/index.ts'; import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; import type { ToolResponse } from '../../../types/common.ts'; -import { createTextContent } from '../../../types/common.ts'; import { addProcess } from './active-processes.ts'; import { createSessionAwareTool, getSessionAwareToolSchemaShape, } from '../../../utils/typed-tool-factory.ts'; import { acquireDaemonActivity } from '../../../daemon/activity-registry.ts'; +import { toolResponse } from '../../../utils/tool-response.ts'; +import { header, statusLine, section } from '../../../utils/tool-event-builders.ts'; -// Define schema as ZodObject const baseSchemaObject = z.object({ packagePath: z.string(), executableName: z.string().optional(), @@ -30,7 +29,6 @@ const publicSchemaObject = baseSchemaObject.omit({ const swiftPackageRunSchema = baseSchemaObject; -// Use z.infer for type safety type SwiftPackageRunParams = z.infer; export async function swift_package_runLogic( @@ -45,10 +43,19 @@ export async function swift_package_runLogic( const swiftArgs = ['run', '--package-path', resolvedPath]; + const headerEvent = header('Swift Package Run', [ + { label: 'Package', value: resolvedPath }, + ...(params.executableName ? [{ label: 'Executable', value: params.executableName }] : []), + ...(params.background ? [{ label: 'Mode', value: 'background' }] : []), + ]); + if (params.configuration?.toLowerCase() === 'release') { swiftArgs.push('-c', 'release'); } else if (params.configuration && params.configuration.toLowerCase() !== 'debug') { - return createTextResponse("Invalid configuration. Use 'debug' or 'release'.", true); + return toolResponse([ + headerEvent, + statusLine('error', "Invalid configuration. Use 'debug' or 'release'."), + ]); } if (params.parseAsLibrary) { @@ -71,16 +78,14 @@ export async function swift_package_runLogic( if (params.background) { // Background mode: Use CommandExecutor but don't wait for completion if (isTestEnvironment) { - // In test environment, return mock response without real process const mockPid = 12345; - return { - content: [ - createTextContent( - `๐Ÿš€ Started executable in background (PID: ${mockPid})\n` + - `๐Ÿ’ก Process is running independently. Use swift_package_stop with PID ${mockPid} to terminate when needed.`, - ), - ], - }; + return toolResponse([ + headerEvent, + statusLine('success', `Started executable in background (PID: ${mockPid})`), + section('Next Steps', [ + `Use swift_package_stop with PID ${mockPid} to terminate when needed.`, + ]), + ]); } else { // Production: use CommandExecutor to start the process const command = ['swift', ...swiftArgs]; @@ -119,23 +124,19 @@ export async function swift_package_runLogic( releaseActivity: acquireDaemonActivity('swift-package.background-process'), }); - return { - content: [ - createTextContent( - `๐Ÿš€ Started executable in background (PID: ${result.process.pid})\n` + - `๐Ÿ’ก Process is running independently. Use swift_package_stop with PID ${result.process.pid} to terminate when needed.`, - ), - ], - }; + return toolResponse([ + headerEvent, + statusLine('success', `Started executable in background (PID: ${result.process.pid})`), + section('Next Steps', [ + `Use swift_package_stop with PID ${result.process.pid} to terminate when needed.`, + ]), + ]); } else { - return { - content: [ - createTextContent( - `๐Ÿš€ Started executable in background\n` + - `๐Ÿ’ก Process is running independently. PID not available for this execution.`, - ), - ], - }; + return toolResponse([ + headerEvent, + statusLine('success', 'Started executable in background'), + section('Next Steps', ['PID not available for this execution.']), + ]); } } } else { @@ -143,7 +144,7 @@ export async function swift_package_runLogic( const command = ['swift', ...swiftArgs]; // Create a promise that will either complete with the command result or timeout - const commandPromise = executor(command, 'Swift Package Run', false, undefined); + const commandPromise = executor(command, 'Swift Package Run', false); const timeoutPromise = new Promise<{ success: boolean; @@ -167,57 +168,55 @@ export async function swift_package_runLogic( if ('timedOut' in result && result.timedOut) { // For timeout case, the process may still be running - provide timeout response if (isTestEnvironment) { - // In test environment, return mock response const mockPid = 12345; - return { - content: [ - createTextContent( - `โฑ๏ธ Process timed out after ${timeout / 1000} seconds but may continue running.`, - ), - createTextContent(`PID: ${mockPid} (mock)`), - createTextContent( - `๐Ÿ’ก Process may still be running. Use swift_package_stop with PID ${mockPid} to terminate when needed.`, - ), - createTextContent(result.output || '(no output so far)'), - ], - }; + return toolResponse([ + headerEvent, + statusLine( + 'warning', + `Process timed out after ${timeout / 1000} seconds but may continue running.`, + ), + section('Details', [ + `PID: ${mockPid} (mock)`, + `Use swift_package_stop with PID ${mockPid} to terminate when needed.`, + result.output || '(no output so far)', + ]), + ]); } else { - // Production: timeout occurred, but we don't start a new process - return { - content: [ - createTextContent(`โฑ๏ธ Process timed out after ${timeout / 1000} seconds.`), - createTextContent( - `๐Ÿ’ก Process execution exceeded the timeout limit. Consider using background mode for long-running executables.`, - ), - createTextContent(result.output || '(no output so far)'), - ], - }; + return toolResponse([ + headerEvent, + statusLine('warning', `Process timed out after ${timeout / 1000} seconds.`), + section('Details', [ + 'Process execution exceeded the timeout limit. Consider using background mode for long-running executables.', + result.output || '(no output so far)', + ]), + ]); } } if (result.success) { - return { - content: [ - createTextContent('โœ… Swift executable completed successfully.'), - createTextContent('๐Ÿ’ก Process finished cleanly. Check output for results.'), - createTextContent(result.output || '(no output)'), - ], - }; + return toolResponse([ + headerEvent, + ...(result.output ? [section('Output', [result.output])] : []), + statusLine('success', 'Swift executable completed successfully'), + ]); } else { - const content = [ - createTextContent('โŒ Swift executable failed.'), - createTextContent(result.output || '(no output)'), - ]; - if (result.error) { - content.push(createTextContent(`Errors:\n${result.error}`)); - } - return { content }; + const errorDetail = result.error + ? `${result.output || '(no output)'}\nErrors:\n${result.error}` + : result.output || '(no output)'; + return toolResponse([ + headerEvent, + section('Output', [errorDetail]), + statusLine('error', 'Swift executable failed'), + ]); } } } catch (error) { const message = error instanceof Error ? error.message : String(error); log('error', `Swift run failed: ${message}`); - return createErrorResponse('Failed to execute swift run', message); + return toolResponse([ + headerEvent, + statusLine('error', `Failed to execute swift run: ${message}`), + ]); } } diff --git a/src/mcp/tools/swift-package/swift_package_stop.ts b/src/mcp/tools/swift-package/swift_package_stop.ts index 3366a485..98c30fe3 100644 --- a/src/mcp/tools/swift-package/swift_package_stop.ts +++ b/src/mcp/tools/swift-package/swift_package_stop.ts @@ -1,7 +1,8 @@ import * as z from 'zod'; -import { createTextResponse, createErrorResponse } from '../../../utils/responses/index.ts'; import { getProcess, terminateTrackedProcess, type ProcessInfo } from './active-processes.ts'; import type { ToolResponse } from '../../../types/common.ts'; +import { toolResponse } from '../../../utils/tool-response.ts'; +import { header, statusLine } from '../../../utils/tool-event-builders.ts'; const swiftPackageStopSchema = z.object({ pid: z.number(), @@ -39,44 +40,47 @@ export async function swift_package_stopLogic( processManager: ProcessManager = getDefaultProcessManager(), timeout: number = 5000, ): Promise { + const headerEvent = header('Swift Package Stop', [{ label: 'PID', value: String(params.pid) }]); + const processInfo = processManager.getProcess(params.pid); if (!processInfo) { - return createTextResponse( - `โš ๏ธ No running process found with PID ${params.pid}. Use swift_package_run to check active processes.`, - true, - ); + return toolResponse([ + headerEvent, + statusLine( + 'error', + `No running process found with PID ${params.pid}. Use swift_package_list to check active processes.`, + ), + ]); } try { const result = await processManager.terminateTrackedProcess(params.pid, timeout); if (result.status === 'not-found') { - return createTextResponse( - `โš ๏ธ No running process found with PID ${params.pid}. Use swift_package_run to check active processes.`, - true, - ); + return toolResponse([ + headerEvent, + statusLine( + 'error', + `No running process found with PID ${params.pid}. Use swift_package_list to check active processes.`, + ), + ]); } if (result.error) { - return createErrorResponse('Failed to stop process', result.error); + return toolResponse([ + headerEvent, + statusLine('error', `Failed to stop process: ${result.error}`), + ]); } const startedAt = result.startedAt ?? processInfo.startedAt; - return { - content: [ - { - type: 'text', - text: `โœ… Stopped executable (was running since ${startedAt.toISOString()})`, - }, - { - type: 'text', - text: `๐Ÿ’ก Process terminated. You can now run swift_package_run again if needed.`, - }, - ], - }; + return toolResponse([ + headerEvent, + statusLine('success', `Stopped executable (was running since ${startedAt.toISOString()})`), + ]); } catch (error) { const message = error instanceof Error ? error.message : String(error); - return createErrorResponse('Failed to stop process', message); + return toolResponse([headerEvent, statusLine('error', `Failed to stop process: ${message}`)]); } } @@ -85,10 +89,13 @@ export const schema = swiftPackageStopSchema.shape; export async function handler(args: Record): Promise { const parseResult = swiftPackageStopSchema.safeParse(args); if (!parseResult.success) { - return createErrorResponse( - 'Parameter validation failed', - parseResult.error.issues.map((e) => `${e.path.join('.')}: ${e.message}`).join(', '), - ); + const details = parseResult.error.issues + .map((e) => `${e.path.join('.')}: ${e.message}`) + .join(', '); + return toolResponse([ + header('Swift Package Stop'), + statusLine('error', `Parameter validation failed: ${details}`), + ]); } return swift_package_stopLogic(parseResult.data); diff --git a/src/mcp/tools/swift-package/swift_package_test.ts b/src/mcp/tools/swift-package/swift_package_test.ts index 8d022d02..fcee1ffc 100644 --- a/src/mcp/tools/swift-package/swift_package_test.ts +++ b/src/mcp/tools/swift-package/swift_package_test.ts @@ -2,15 +2,15 @@ import * as z from 'zod'; import path from 'node:path'; import type { CommandExecutor } from '../../../utils/execution/index.ts'; import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; -import { createTextResponse, createErrorResponse } from '../../../utils/responses/index.ts'; import { log } from '../../../utils/logging/index.ts'; import type { ToolResponse } from '../../../types/common.ts'; import { createSessionAwareTool, getSessionAwareToolSchemaShape, } from '../../../utils/typed-tool-factory.ts'; +import { toolResponse } from '../../../utils/tool-response.ts'; +import { header, statusLine, section } from '../../../utils/tool-event-builders.ts'; -// Define schema as ZodObject const baseSchemaObject = z.object({ packagePath: z.string(), testProduct: z.string().optional(), @@ -27,7 +27,6 @@ const publicSchemaObject = baseSchemaObject.omit({ const swiftPackageTestSchema = baseSchemaObject; -// Use z.infer for type safety type SwiftPackageTestParams = z.infer; export async function swift_package_testLogic( @@ -37,10 +36,19 @@ export async function swift_package_testLogic( const resolvedPath = path.resolve(params.packagePath); const swiftArgs = ['test', '--package-path', resolvedPath]; + const headerEvent = header('Swift Package Test', [ + { label: 'Package', value: resolvedPath }, + ...(params.testProduct ? [{ label: 'Test Product', value: params.testProduct }] : []), + ...(params.configuration ? [{ label: 'Configuration', value: params.configuration }] : []), + ]); + if (params.configuration?.toLowerCase() === 'release') { swiftArgs.push('-c', 'release'); } else if (params.configuration && params.configuration.toLowerCase() !== 'debug') { - return createTextResponse("Invalid configuration. Use 'debug' or 'release'.", true); + return toolResponse([ + headerEvent, + statusLine('error', "Invalid configuration. Use 'debug' or 'release'."), + ]); } if (params.testProduct) { @@ -65,27 +73,27 @@ export async function swift_package_testLogic( log('info', `Running swift ${swiftArgs.join(' ')}`); try { - const result = await executor(['swift', ...swiftArgs], 'Swift Package Test', false, undefined); + const result = await executor(['swift', ...swiftArgs], 'Swift Package Test', false); if (!result.success) { const errorMessage = result.error || result.output || 'Unknown error'; - return createErrorResponse('Swift package tests failed', errorMessage); + return toolResponse([ + headerEvent, + statusLine('error', `Swift package tests failed: ${errorMessage}`), + ]); } - return { - content: [ - { type: 'text', text: 'โœ… Swift package tests completed.' }, - { - type: 'text', - text: '๐Ÿ’ก Next: Execute your app with swift_package_run if tests passed', - }, - { type: 'text', text: result.output }, - ], - isError: false, - }; + return toolResponse([ + headerEvent, + ...(result.output ? [section('Output', [result.output])] : []), + statusLine('success', 'Swift package tests completed'), + ]); } catch (error) { const message = error instanceof Error ? error.message : String(error); log('error', `Swift package test failed: ${message}`); - return createErrorResponse('Failed to execute swift test', message); + return toolResponse([ + headerEvent, + statusLine('error', `Failed to execute swift test: ${message}`), + ]); } } diff --git a/src/mcp/tools/ui-automation/__tests__/button.test.ts b/src/mcp/tools/ui-automation/__tests__/button.test.ts index c64d426b..8bf8effa 100644 --- a/src/mcp/tools/ui-automation/__tests__/button.test.ts +++ b/src/mcp/tools/ui-automation/__tests__/button.test.ts @@ -13,6 +13,13 @@ import { schema, handler, buttonLogic } from '../button.ts'; import type { CommandExecutor } from '../../../../utils/execution/index.ts'; import { AXE_NOT_AVAILABLE_MESSAGE } from '../../../../utils/axe-helpers.ts'; +function allText(result: { content: Array<{ type: string; text?: string }> }): string { + return result.content + .filter((c): c is { type: 'text'; text: string } => c.type === 'text') + .map((c) => c.text) + .join('\n'); +} + describe('Button Plugin', () => { describe('Export Field Validation (Literal)', () => { it('should have handler function', () => { @@ -53,10 +60,6 @@ describe('Button Plugin', () => { const mockAxeHelpers = { getAxePath: () => '/usr/local/bin/axe', getBundledAxeEnvironment: () => ({}), - createAxeNotAvailableResponse: () => ({ - content: [{ type: 'text' as const, text: 'axe not available' }], - isError: true, - }), }; await buttonLogic( @@ -91,10 +94,6 @@ describe('Button Plugin', () => { const mockAxeHelpers = { getAxePath: () => '/usr/local/bin/axe', getBundledAxeEnvironment: () => ({}), - createAxeNotAvailableResponse: () => ({ - content: [{ type: 'text' as const, text: 'axe not available' }], - isError: true, - }), }; await buttonLogic( @@ -132,10 +131,6 @@ describe('Button Plugin', () => { const mockAxeHelpers = { getAxePath: () => '/usr/local/bin/axe', getBundledAxeEnvironment: () => ({}), - createAxeNotAvailableResponse: () => ({ - content: [{ type: 'text' as const, text: 'axe not available' }], - isError: true, - }), }; await buttonLogic( @@ -170,10 +165,6 @@ describe('Button Plugin', () => { const mockAxeHelpers = { getAxePath: () => '/path/to/bundled/axe', getBundledAxeEnvironment: () => ({ AXE_PATH: '/some/path' }), - createAxeNotAvailableResponse: () => ({ - content: [{ type: 'text' as const, text: 'axe not available' }], - isError: true, - }), }; await buttonLogic( @@ -200,8 +191,8 @@ describe('Button Plugin', () => { const result = await handler({ buttonType: 'home' }); expect(result.isError).toBe(true); - expect(result.content[0].text).toContain('Missing required session defaults'); - expect(result.content[0].text).toContain('simulatorId is required'); + expect(allText(result)).toContain('Missing required session defaults'); + expect(allText(result)).toContain('simulatorId is required'); }); it('should return error for missing buttonType', async () => { @@ -210,8 +201,8 @@ describe('Button Plugin', () => { }); expect(result.isError).toBe(true); - expect(result.content[0].text).toContain('Parameter validation failed'); - expect(result.content[0].text).toContain( + expect(allText(result)).toContain('Parameter validation failed'); + expect(allText(result)).toContain( 'buttonType: Invalid option: expected one of "apple-pay"|"home"|"lock"|"side-button"|"siri"', ); }); @@ -223,8 +214,8 @@ describe('Button Plugin', () => { }); expect(result.isError).toBe(true); - expect(result.content[0].text).toContain('Parameter validation failed'); - expect(result.content[0].text).toContain('Invalid Simulator UUID format'); + expect(allText(result)).toContain('Parameter validation failed'); + expect(allText(result)).toContain('Invalid Simulator UUID format'); }); it('should return error for invalid buttonType', async () => { @@ -234,7 +225,7 @@ describe('Button Plugin', () => { }); expect(result.isError).toBe(true); - expect(result.content[0].text).toContain('Parameter validation failed'); + expect(allText(result)).toContain('Parameter validation failed'); }); it('should return error for negative duration', async () => { @@ -245,8 +236,8 @@ describe('Button Plugin', () => { }); expect(result.isError).toBe(true); - expect(result.content[0].text).toContain('Parameter validation failed'); - expect(result.content[0].text).toContain('Duration must be non-negative'); + expect(allText(result)).toContain('Parameter validation failed'); + expect(allText(result)).toContain('Duration must be non-negative'); }); it('should return success for valid button press', async () => { @@ -260,10 +251,6 @@ describe('Button Plugin', () => { const mockAxeHelpers = { getAxePath: () => '/usr/local/bin/axe', getBundledAxeEnvironment: () => ({}), - createAxeNotAvailableResponse: () => ({ - content: [{ type: 'text' as const, text: 'axe not available' }], - isError: true, - }), }; const result = await buttonLogic( @@ -275,10 +262,8 @@ describe('Button Plugin', () => { mockAxeHelpers, ); - expect(result).toEqual({ - content: [{ type: 'text' as const, text: "Hardware button 'home' pressed successfully." }], - isError: false, - }); + expect(result.isError).toBeFalsy(); + expect(allText(result)).toContain("Hardware button 'home' pressed successfully."); }); it('should return success for button press with duration', async () => { @@ -292,10 +277,6 @@ describe('Button Plugin', () => { const mockAxeHelpers = { getAxePath: () => '/usr/local/bin/axe', getBundledAxeEnvironment: () => ({}), - createAxeNotAvailableResponse: () => ({ - content: [{ type: 'text' as const, text: 'axe not available' }], - isError: true, - }), }; const result = await buttonLogic( @@ -308,27 +289,14 @@ describe('Button Plugin', () => { mockAxeHelpers, ); - expect(result).toEqual({ - content: [ - { type: 'text' as const, text: "Hardware button 'side-button' pressed successfully." }, - ], - isError: false, - }); + expect(result.isError).toBeFalsy(); + expect(allText(result)).toContain("Hardware button 'side-button' pressed successfully."); }); it('should handle DependencyError when axe is not available', async () => { const mockAxeHelpers = { getAxePath: () => null, getBundledAxeEnvironment: () => ({}), - createAxeNotAvailableResponse: () => ({ - content: [ - { - type: 'text' as const, - text: AXE_NOT_AVAILABLE_MESSAGE, - }, - ], - isError: true, - }), }; const result = await buttonLogic( @@ -340,15 +308,8 @@ describe('Button Plugin', () => { mockAxeHelpers, ); - expect(result).toEqual({ - content: [ - { - type: 'text' as const, - text: AXE_NOT_AVAILABLE_MESSAGE, - }, - ], - isError: true, - }); + expect(result.isError).toBe(true); + expect(allText(result)).toContain(AXE_NOT_AVAILABLE_MESSAGE); }); it('should handle AxeError from failed command execution', async () => { @@ -362,10 +323,6 @@ describe('Button Plugin', () => { const mockAxeHelpers = { getAxePath: () => '/usr/local/bin/axe', getBundledAxeEnvironment: () => ({}), - createAxeNotAvailableResponse: () => ({ - content: [{ type: 'text' as const, text: 'axe not available' }], - isError: true, - }), }; const result = await buttonLogic( @@ -377,15 +334,10 @@ describe('Button Plugin', () => { mockAxeHelpers, ); - expect(result).toEqual({ - content: [ - { - type: 'text' as const, - text: "Error: Failed to press button 'home': axe command 'button' failed.\nDetails: axe command failed", - }, - ], - isError: true, - }); + expect(result.isError).toBe(true); + expect(allText(result)).toContain( + "Failed to press button 'home': axe command 'button' failed.", + ); }); it('should handle SystemError from command execution', async () => { @@ -396,10 +348,6 @@ describe('Button Plugin', () => { const mockAxeHelpers = { getAxePath: () => '/usr/local/bin/axe', getBundledAxeEnvironment: () => ({}), - createAxeNotAvailableResponse: () => ({ - content: [{ type: 'text' as const, text: 'axe not available' }], - isError: true, - }), }; const result = await buttonLogic( @@ -411,8 +359,8 @@ describe('Button Plugin', () => { mockAxeHelpers, ); - expect(result.content[0].text).toMatch( - /^Error: System error executing axe: Failed to execute axe command: ENOENT: no such file or directory/, + expect(allText(result)).toMatch( + /System error executing axe: Failed to execute axe command: ENOENT: no such file or directory/, ); expect(result.isError).toBe(true); }); @@ -425,10 +373,6 @@ describe('Button Plugin', () => { const mockAxeHelpers = { getAxePath: () => '/usr/local/bin/axe', getBundledAxeEnvironment: () => ({}), - createAxeNotAvailableResponse: () => ({ - content: [{ type: 'text' as const, text: 'axe not available' }], - isError: true, - }), }; const result = await buttonLogic( @@ -440,8 +384,8 @@ describe('Button Plugin', () => { mockAxeHelpers, ); - expect(result.content[0].text).toMatch( - /^Error: System error executing axe: Failed to execute axe command: Unexpected error/, + expect(allText(result)).toMatch( + /System error executing axe: Failed to execute axe command: Unexpected error/, ); expect(result.isError).toBe(true); }); @@ -454,10 +398,6 @@ describe('Button Plugin', () => { const mockAxeHelpers = { getAxePath: () => '/usr/local/bin/axe', getBundledAxeEnvironment: () => ({}), - createAxeNotAvailableResponse: () => ({ - content: [{ type: 'text' as const, text: 'axe not available' }], - isError: true, - }), }; const result = await buttonLogic( @@ -469,15 +409,10 @@ describe('Button Plugin', () => { mockAxeHelpers, ); - expect(result).toEqual({ - content: [ - { - type: 'text' as const, - text: 'Error: System error executing axe: Failed to execute axe command: String error', - }, - ], - isError: true, - }); + expect(result.isError).toBe(true); + expect(allText(result)).toContain( + 'System error executing axe: Failed to execute axe command: String error', + ); }); }); }); diff --git a/src/mcp/tools/ui-automation/__tests__/gesture.test.ts b/src/mcp/tools/ui-automation/__tests__/gesture.test.ts index d05e8600..b71a741a 100644 --- a/src/mcp/tools/ui-automation/__tests__/gesture.test.ts +++ b/src/mcp/tools/ui-automation/__tests__/gesture.test.ts @@ -13,6 +13,13 @@ import { sessionStore } from '../../../../utils/session-store.ts'; import { schema, handler, gestureLogic } from '../gesture.ts'; import { AXE_NOT_AVAILABLE_MESSAGE } from '../../../../utils/axe-helpers.ts'; +function allText(result: { content: Array<{ type: string; text?: string }> }): string { + return result.content + .filter((c): c is { type: 'text'; text: string } => c.type === 'text') + .map((c) => c.text) + .join('\n'); +} + describe('Gesture Plugin', () => { beforeEach(() => { sessionStore.clear(); @@ -92,10 +99,6 @@ describe('Gesture Plugin', () => { const mockAxeHelpers = { getAxePath: () => '/usr/local/bin/axe', getBundledAxeEnvironment: () => ({}), - createAxeNotAvailableResponse: () => ({ - content: [{ type: 'text' as const, text: 'AXe CLI is not available.' }], - isError: true, - }), }; await gestureLogic( @@ -131,10 +134,6 @@ describe('Gesture Plugin', () => { const mockAxeHelpers = { getAxePath: () => '/usr/local/bin/axe', getBundledAxeEnvironment: () => ({}), - createAxeNotAvailableResponse: () => ({ - content: [{ type: 'text' as const, text: 'AXe CLI is not available.' }], - isError: true, - }), }; await gestureLogic( @@ -176,10 +175,6 @@ describe('Gesture Plugin', () => { const mockAxeHelpers = { getAxePath: () => '/usr/local/bin/axe', getBundledAxeEnvironment: () => ({}), - createAxeNotAvailableResponse: () => ({ - content: [{ type: 'text' as const, text: 'AXe CLI is not available.' }], - isError: true, - }), }; await gestureLogic( @@ -233,10 +228,6 @@ describe('Gesture Plugin', () => { const mockAxeHelpers = { getAxePath: () => '/usr/local/bin/axe', getBundledAxeEnvironment: () => ({}), - createAxeNotAvailableResponse: () => ({ - content: [{ type: 'text' as const, text: 'AXe CLI is not available.' }], - isError: true, - }), }; await gestureLogic( @@ -274,10 +265,6 @@ describe('Gesture Plugin', () => { const mockAxeHelpers = { getAxePath: () => '/usr/local/bin/axe', getBundledAxeEnvironment: () => ({}), - createAxeNotAvailableResponse: () => ({ - content: [{ type: 'text' as const, text: 'AXe CLI is not available.' }], - isError: true, - }), }; const result = await gestureLogic( @@ -289,10 +276,8 @@ describe('Gesture Plugin', () => { mockAxeHelpers, ); - expect(result).toEqual({ - content: [{ type: 'text' as const, text: "Gesture 'scroll-up' executed successfully." }], - isError: false, - }); + expect(result.isError).toBeFalsy(); + expect(allText(result)).toContain("Gesture 'scroll-up' executed successfully."); }); it('should return success for gesture execution with all optional parameters', async () => { @@ -306,10 +291,6 @@ describe('Gesture Plugin', () => { const mockAxeHelpers = { getAxePath: () => '/usr/local/bin/axe', getBundledAxeEnvironment: () => ({}), - createAxeNotAvailableResponse: () => ({ - content: [{ type: 'text' as const, text: 'AXe CLI is not available.' }], - isError: true, - }), }; const result = await gestureLogic( @@ -327,27 +308,14 @@ describe('Gesture Plugin', () => { mockAxeHelpers, ); - expect(result).toEqual({ - content: [ - { type: 'text' as const, text: "Gesture 'swipe-from-left-edge' executed successfully." }, - ], - isError: false, - }); + expect(result.isError).toBeFalsy(); + expect(allText(result)).toContain("Gesture 'swipe-from-left-edge' executed successfully."); }); it('should handle DependencyError when axe is not available', async () => { const mockAxeHelpers = { getAxePath: () => null, getBundledAxeEnvironment: () => ({}), - createAxeNotAvailableResponse: () => ({ - content: [ - { - type: 'text' as const, - text: AXE_NOT_AVAILABLE_MESSAGE, - }, - ], - isError: true, - }), }; const result = await gestureLogic( @@ -359,15 +327,8 @@ describe('Gesture Plugin', () => { mockAxeHelpers, ); - expect(result).toEqual({ - content: [ - { - type: 'text' as const, - text: AXE_NOT_AVAILABLE_MESSAGE, - }, - ], - isError: true, - }); + expect(result.isError).toBe(true); + expect(allText(result)).toContain(AXE_NOT_AVAILABLE_MESSAGE); }); it('should handle AxeError from failed command execution', async () => { @@ -381,10 +342,6 @@ describe('Gesture Plugin', () => { const mockAxeHelpers = { getAxePath: () => '/usr/local/bin/axe', getBundledAxeEnvironment: () => ({}), - createAxeNotAvailableResponse: () => ({ - content: [{ type: 'text' as const, text: 'AXe CLI is not available.' }], - isError: true, - }), }; const result = await gestureLogic( @@ -396,15 +353,10 @@ describe('Gesture Plugin', () => { mockAxeHelpers, ); - expect(result).toEqual({ - content: [ - { - type: 'text' as const, - text: "Error: Failed to execute gesture 'scroll-up': axe command 'gesture' failed.\nDetails: axe command failed", - }, - ], - isError: true, - }); + expect(result.isError).toBe(true); + expect(allText(result)).toContain( + "Failed to execute gesture 'scroll-up': axe command 'gesture' failed.", + ); }); it('should handle SystemError from command execution', async () => { @@ -413,10 +365,6 @@ describe('Gesture Plugin', () => { const mockAxeHelpers = { getAxePath: () => '/usr/local/bin/axe', getBundledAxeEnvironment: () => ({}), - createAxeNotAvailableResponse: () => ({ - content: [{ type: 'text' as const, text: 'AXe CLI is not available.' }], - isError: true, - }), }; const result = await gestureLogic( @@ -428,8 +376,8 @@ describe('Gesture Plugin', () => { mockAxeHelpers, ); - expect(result.content[0].text).toMatch( - /^Error: System error executing axe: Failed to execute axe command: ENOENT: no such file or directory/, + expect(allText(result)).toMatch( + /System error executing axe: Failed to execute axe command: ENOENT: no such file or directory/, ); expect(result.isError).toBe(true); }); @@ -440,10 +388,6 @@ describe('Gesture Plugin', () => { const mockAxeHelpers = { getAxePath: () => '/usr/local/bin/axe', getBundledAxeEnvironment: () => ({}), - createAxeNotAvailableResponse: () => ({ - content: [{ type: 'text' as const, text: 'AXe CLI is not available.' }], - isError: true, - }), }; const result = await gestureLogic( @@ -455,8 +399,8 @@ describe('Gesture Plugin', () => { mockAxeHelpers, ); - expect(result.content[0].text).toMatch( - /^Error: System error executing axe: Failed to execute axe command: Unexpected error/, + expect(allText(result)).toMatch( + /System error executing axe: Failed to execute axe command: Unexpected error/, ); expect(result.isError).toBe(true); }); @@ -467,10 +411,6 @@ describe('Gesture Plugin', () => { const mockAxeHelpers = { getAxePath: () => '/usr/local/bin/axe', getBundledAxeEnvironment: () => ({}), - createAxeNotAvailableResponse: () => ({ - content: [{ type: 'text' as const, text: 'AXe CLI is not available.' }], - isError: true, - }), }; const result = await gestureLogic( @@ -482,15 +422,10 @@ describe('Gesture Plugin', () => { mockAxeHelpers, ); - expect(result).toEqual({ - content: [ - { - type: 'text' as const, - text: 'Error: System error executing axe: Failed to execute axe command: String error', - }, - ], - isError: true, - }); + expect(result.isError).toBe(true); + expect(allText(result)).toContain( + 'System error executing axe: Failed to execute axe command: String error', + ); }); }); }); diff --git a/src/mcp/tools/ui-automation/__tests__/key_press.test.ts b/src/mcp/tools/ui-automation/__tests__/key_press.test.ts index bb0e8275..536bb036 100644 --- a/src/mcp/tools/ui-automation/__tests__/key_press.test.ts +++ b/src/mcp/tools/ui-automation/__tests__/key_press.test.ts @@ -18,18 +18,16 @@ function createDefaultMockAxeHelpers() { return { getAxePath: () => '/usr/local/bin/axe', getBundledAxeEnvironment: () => ({}), - createAxeNotAvailableResponse: () => ({ - content: [ - { - type: 'text' as const, - text: AXE_NOT_AVAILABLE_MESSAGE, - }, - ], - isError: true, - }), }; } +function allText(result: { content: Array<{ type: string; text?: string }> }): string { + return result.content + .filter((c): c is { type: 'text'; text: string } => c.type === 'text') + .map((c) => c.text) + .join('\n'); +} + describe('Key Press Tool', () => { beforeEach(() => { sessionStore.clear(); @@ -198,15 +196,6 @@ describe('Key Press Tool', () => { const mockAxeHelpers = { getAxePath: () => '/path/to/bundled/axe', getBundledAxeEnvironment: () => ({ AXE_PATH: '/some/path' }), - createAxeNotAvailableResponse: () => ({ - content: [ - { - type: 'text' as const, - text: AXE_NOT_AVAILABLE_MESSAGE, - }, - ], - isError: true, - }), }; await key_pressLogic( @@ -250,10 +239,8 @@ describe('Key Press Tool', () => { mockAxeHelpers, ); - expect(result).toEqual({ - content: [{ type: 'text' as const, text: 'Key press (code: 40) simulated successfully.' }], - isError: false, - }); + expect(result.isError).toBeFalsy(); + expect(allText(result)).toContain('Key press (code: 40) simulated successfully.'); }); it('should return success for key press with duration', async () => { @@ -275,25 +262,14 @@ describe('Key Press Tool', () => { mockAxeHelpers, ); - expect(result).toEqual({ - content: [{ type: 'text' as const, text: 'Key press (code: 42) simulated successfully.' }], - isError: false, - }); + expect(result.isError).toBeFalsy(); + expect(allText(result)).toContain('Key press (code: 42) simulated successfully.'); }); it('should handle DependencyError when axe is not available', async () => { const mockAxeHelpers = { getAxePath: () => null, getBundledAxeEnvironment: () => ({}), - createAxeNotAvailableResponse: () => ({ - content: [ - { - type: 'text' as const, - text: AXE_NOT_AVAILABLE_MESSAGE, - }, - ], - isError: true, - }), }; const result = await key_pressLogic( @@ -305,15 +281,8 @@ describe('Key Press Tool', () => { mockAxeHelpers, ); - expect(result).toEqual({ - content: [ - { - type: 'text' as const, - text: AXE_NOT_AVAILABLE_MESSAGE, - }, - ], - isError: true, - }); + expect(result.isError).toBe(true); + expect(allText(result)).toContain(AXE_NOT_AVAILABLE_MESSAGE); }); it('should handle AxeError from failed command execution', async () => { @@ -334,15 +303,10 @@ describe('Key Press Tool', () => { mockAxeHelpers, ); - expect(result).toEqual({ - content: [ - { - type: 'text' as const, - text: "Error: Failed to simulate key press (code: 40): axe command 'key' failed.\nDetails: axe command failed", - }, - ], - isError: true, - }); + expect(result.isError).toBe(true); + expect(allText(result)).toContain( + "Failed to simulate key press (code: 40): axe command 'key' failed.", + ); }); it('should handle SystemError from command execution', async () => { @@ -362,8 +326,8 @@ describe('Key Press Tool', () => { ); expect(result.isError).toBe(true); - expect(result.content[0].text).toContain( - 'Error: System error executing axe: Failed to execute axe command: System error occurred', + expect(allText(result)).toContain( + 'System error executing axe: Failed to execute axe command: System error occurred', ); }); @@ -384,8 +348,8 @@ describe('Key Press Tool', () => { ); expect(result.isError).toBe(true); - expect(result.content[0].text).toContain( - 'Error: System error executing axe: Failed to execute axe command: Unexpected error', + expect(allText(result)).toContain( + 'System error executing axe: Failed to execute axe command: Unexpected error', ); }); @@ -405,15 +369,10 @@ describe('Key Press Tool', () => { mockAxeHelpers, ); - expect(result).toEqual({ - content: [ - { - type: 'text' as const, - text: 'Error: System error executing axe: Failed to execute axe command: String error', - }, - ], - isError: true, - }); + expect(result.isError).toBe(true); + expect(allText(result)).toContain( + 'System error executing axe: Failed to execute axe command: String error', + ); }); }); }); diff --git a/src/mcp/tools/ui-automation/__tests__/key_sequence.test.ts b/src/mcp/tools/ui-automation/__tests__/key_sequence.test.ts index 47443638..cdd1a57b 100644 --- a/src/mcp/tools/ui-automation/__tests__/key_sequence.test.ts +++ b/src/mcp/tools/ui-automation/__tests__/key_sequence.test.ts @@ -13,6 +13,13 @@ import { sessionStore } from '../../../../utils/session-store.ts'; import { schema, handler, key_sequenceLogic } from '../key_sequence.ts'; import { AXE_NOT_AVAILABLE_MESSAGE } from '../../../../utils/axe-helpers.ts'; +function allText(result: { content: Array<{ type: string; text?: string }> }): string { + return result.content + .filter((c): c is { type: 'text'; text: string } => c.type === 'text') + .map((c) => c.text) + .join('\n'); +} + describe('Key Sequence Tool', () => { beforeEach(() => { sessionStore.clear(); @@ -83,15 +90,6 @@ describe('Key Sequence Tool', () => { const mockAxeHelpers = { getAxePath: () => '/usr/local/bin/axe', getBundledAxeEnvironment: () => ({}), - createAxeNotAvailableResponse: () => ({ - content: [ - { - type: 'text' as const, - text: AXE_NOT_AVAILABLE_MESSAGE, - }, - ], - isError: true, - }), }; await key_sequenceLogic( @@ -128,15 +126,6 @@ describe('Key Sequence Tool', () => { const mockAxeHelpers = { getAxePath: () => '/usr/local/bin/axe', getBundledAxeEnvironment: () => ({}), - createAxeNotAvailableResponse: () => ({ - content: [ - { - type: 'text' as const, - text: AXE_NOT_AVAILABLE_MESSAGE, - }, - ], - isError: true, - }), }; await key_sequenceLogic( @@ -176,15 +165,6 @@ describe('Key Sequence Tool', () => { const mockAxeHelpers = { getAxePath: () => '/usr/local/bin/axe', getBundledAxeEnvironment: () => ({}), - createAxeNotAvailableResponse: () => ({ - content: [ - { - type: 'text' as const, - text: AXE_NOT_AVAILABLE_MESSAGE, - }, - ], - isError: true, - }), }; await key_sequenceLogic( @@ -221,15 +201,6 @@ describe('Key Sequence Tool', () => { const mockAxeHelpers = { getAxePath: () => '/path/to/bundled/axe', getBundledAxeEnvironment: () => ({ AXE_PATH: '/some/path' }), - createAxeNotAvailableResponse: () => ({ - content: [ - { - type: 'text' as const, - text: AXE_NOT_AVAILABLE_MESSAGE, - }, - ], - isError: true, - }), }; await key_sequenceLogic( @@ -260,8 +231,8 @@ describe('Key Sequence Tool', () => { const result = await handler({ keyCodes: [40] }); expect(result.isError).toBe(true); - expect(result.content[0].text).toContain('Missing required session defaults'); - expect(result.content[0].text).toContain('simulatorId is required'); + expect(allText(result)).toContain('Missing required session defaults'); + expect(allText(result)).toContain('simulatorId is required'); }); it('should return success for valid key sequence execution', async () => { @@ -274,15 +245,6 @@ describe('Key Sequence Tool', () => { const mockAxeHelpers = { getAxePath: () => '/usr/local/bin/axe', getBundledAxeEnvironment: () => ({}), - createAxeNotAvailableResponse: () => ({ - content: [ - { - type: 'text' as const, - text: AXE_NOT_AVAILABLE_MESSAGE, - }, - ], - isError: true, - }), }; const result = await key_sequenceLogic( @@ -295,12 +257,8 @@ describe('Key Sequence Tool', () => { mockAxeHelpers, ); - expect(result).toEqual({ - content: [ - { type: 'text' as const, text: 'Key sequence [40,42,44] executed successfully.' }, - ], - isError: false, - }); + expect(result.isError).toBeFalsy(); + expect(allText(result)).toContain('Key sequence [40,42,44] executed successfully.'); }); it('should return success for key sequence without delay', async () => { @@ -313,15 +271,6 @@ describe('Key Sequence Tool', () => { const mockAxeHelpers = { getAxePath: () => '/usr/local/bin/axe', getBundledAxeEnvironment: () => ({}), - createAxeNotAvailableResponse: () => ({ - content: [ - { - type: 'text' as const, - text: AXE_NOT_AVAILABLE_MESSAGE, - }, - ], - isError: true, - }), }; const result = await key_sequenceLogic( @@ -333,25 +282,14 @@ describe('Key Sequence Tool', () => { mockAxeHelpers, ); - expect(result).toEqual({ - content: [{ type: 'text' as const, text: 'Key sequence [40] executed successfully.' }], - isError: false, - }); + expect(result.isError).toBeFalsy(); + expect(allText(result)).toContain('Key sequence [40] executed successfully.'); }); it('should handle DependencyError when axe binary not found', async () => { const mockAxeHelpers = { getAxePath: () => null, getBundledAxeEnvironment: () => ({}), - createAxeNotAvailableResponse: () => ({ - content: [ - { - type: 'text' as const, - text: AXE_NOT_AVAILABLE_MESSAGE, - }, - ], - isError: true, - }), }; const result = await key_sequenceLogic( @@ -363,15 +301,8 @@ describe('Key Sequence Tool', () => { mockAxeHelpers, ); - expect(result).toEqual({ - content: [ - { - type: 'text' as const, - text: AXE_NOT_AVAILABLE_MESSAGE, - }, - ], - isError: true, - }); + expect(result.isError).toBe(true); + expect(allText(result)).toContain(AXE_NOT_AVAILABLE_MESSAGE); }); it('should handle AxeError from command execution', async () => { @@ -384,15 +315,6 @@ describe('Key Sequence Tool', () => { const mockAxeHelpers = { getAxePath: () => '/usr/local/bin/axe', getBundledAxeEnvironment: () => ({}), - createAxeNotAvailableResponse: () => ({ - content: [ - { - type: 'text' as const, - text: AXE_NOT_AVAILABLE_MESSAGE, - }, - ], - isError: true, - }), }; const result = await key_sequenceLogic( @@ -404,15 +326,10 @@ describe('Key Sequence Tool', () => { mockAxeHelpers, ); - expect(result).toEqual({ - content: [ - { - type: 'text' as const, - text: "Error: Failed to execute key sequence: axe command 'key-sequence' failed.\nDetails: Simulator not found", - }, - ], - isError: true, - }); + expect(result.isError).toBe(true); + expect(allText(result)).toContain( + "Failed to execute key sequence: axe command 'key-sequence' failed.", + ); }); it('should handle SystemError from command execution', async () => { @@ -423,15 +340,6 @@ describe('Key Sequence Tool', () => { const mockAxeHelpers = { getAxePath: () => '/usr/local/bin/axe', getBundledAxeEnvironment: () => ({}), - createAxeNotAvailableResponse: () => ({ - content: [ - { - type: 'text' as const, - text: AXE_NOT_AVAILABLE_MESSAGE, - }, - ], - isError: true, - }), }; const result = await key_sequenceLogic( @@ -443,8 +351,8 @@ describe('Key Sequence Tool', () => { mockAxeHelpers, ); - expect(result.content[0].text).toMatch( - /^Error: System error executing axe: Failed to execute axe command: ENOENT: no such file or directory/, + expect(allText(result)).toMatch( + /System error executing axe: Failed to execute axe command: ENOENT: no such file or directory/, ); expect(result.isError).toBe(true); }); @@ -457,15 +365,6 @@ describe('Key Sequence Tool', () => { const mockAxeHelpers = { getAxePath: () => '/usr/local/bin/axe', getBundledAxeEnvironment: () => ({}), - createAxeNotAvailableResponse: () => ({ - content: [ - { - type: 'text' as const, - text: AXE_NOT_AVAILABLE_MESSAGE, - }, - ], - isError: true, - }), }; const result = await key_sequenceLogic( @@ -477,8 +376,8 @@ describe('Key Sequence Tool', () => { mockAxeHelpers, ); - expect(result.content[0].text).toMatch( - /^Error: System error executing axe: Failed to execute axe command: Unexpected error/, + expect(allText(result)).toMatch( + /System error executing axe: Failed to execute axe command: Unexpected error/, ); expect(result.isError).toBe(true); }); @@ -491,15 +390,6 @@ describe('Key Sequence Tool', () => { const mockAxeHelpers = { getAxePath: () => '/usr/local/bin/axe', getBundledAxeEnvironment: () => ({}), - createAxeNotAvailableResponse: () => ({ - content: [ - { - type: 'text' as const, - text: AXE_NOT_AVAILABLE_MESSAGE, - }, - ], - isError: true, - }), }; const result = await key_sequenceLogic( @@ -511,15 +401,10 @@ describe('Key Sequence Tool', () => { mockAxeHelpers, ); - expect(result).toEqual({ - content: [ - { - type: 'text' as const, - text: 'Error: System error executing axe: Failed to execute axe command: String error', - }, - ], - isError: true, - }); + expect(result.isError).toBe(true); + expect(allText(result)).toContain( + 'System error executing axe: Failed to execute axe command: String error', + ); }); }); }); diff --git a/src/mcp/tools/ui-automation/__tests__/long_press.test.ts b/src/mcp/tools/ui-automation/__tests__/long_press.test.ts index 3cbe9dc4..408b11d6 100644 --- a/src/mcp/tools/ui-automation/__tests__/long_press.test.ts +++ b/src/mcp/tools/ui-automation/__tests__/long_press.test.ts @@ -9,6 +9,13 @@ import { sessionStore } from '../../../../utils/session-store.ts'; import { schema, handler, long_pressLogic } from '../long_press.ts'; import { AXE_NOT_AVAILABLE_MESSAGE } from '../../../../utils/axe-helpers.ts'; +function allText(result: { content: Array<{ type: string; text?: string }> }): string { + return result.content + .filter((c): c is { type: 'text'; text: string } => c.type === 'text') + .map((c) => c.text) + .join('\n'); +} + describe('Long Press Plugin', () => { beforeEach(() => { sessionStore.clear(); @@ -112,10 +119,6 @@ describe('Long Press Plugin', () => { const mockAxeHelpers = { getAxePath: () => '/usr/local/bin/axe', getBundledAxeEnvironment: () => ({}), - createAxeNotAvailableResponse: () => ({ - content: [{ type: 'text' as const, text: 'Mock axe not available' }], - isError: true, - }), }; await long_pressLogic( @@ -160,10 +163,6 @@ describe('Long Press Plugin', () => { const mockAxeHelpers = { getAxePath: () => '/usr/local/bin/axe', getBundledAxeEnvironment: () => ({}), - createAxeNotAvailableResponse: () => ({ - content: [{ type: 'text' as const, text: 'Mock axe not available' }], - isError: true, - }), }; await long_pressLogic( @@ -208,10 +207,6 @@ describe('Long Press Plugin', () => { const mockAxeHelpers = { getAxePath: () => '/usr/local/bin/axe', getBundledAxeEnvironment: () => ({}), - createAxeNotAvailableResponse: () => ({ - content: [{ type: 'text' as const, text: 'Mock axe not available' }], - isError: true, - }), }; await long_pressLogic( @@ -256,10 +251,6 @@ describe('Long Press Plugin', () => { const mockAxeHelpers = { getAxePath: () => '/path/to/bundled/axe', getBundledAxeEnvironment: () => ({ AXE_PATH: '/some/path' }), - createAxeNotAvailableResponse: () => ({ - content: [{ type: 'text' as const, text: 'Mock axe not available' }], - isError: true, - }), }; await long_pressLogic( @@ -301,10 +292,6 @@ describe('Long Press Plugin', () => { const mockAxeHelpers = { getAxePath: () => '/usr/local/bin/axe', getBundledAxeEnvironment: () => ({}), - createAxeNotAvailableResponse: () => ({ - content: [{ type: 'text' as const, text: 'Mock axe not available' }], - isError: true, - }), }; const result = await long_pressLogic( @@ -318,15 +305,10 @@ describe('Long Press Plugin', () => { mockAxeHelpers, ); - expect(result).toEqual({ - content: [ - { - type: 'text' as const, - text: 'Long press at (100, 200) for 1500ms simulated successfully.\n\nWarning: snapshot_ui has not been called yet. Consider using snapshot_ui for precise coordinates instead of guessing from screenshots.', - }, - ], - isError: false, - }); + expect(result.isError).toBeFalsy(); + expect(allText(result)).toContain( + 'Long press at (100, 200) for 1500ms simulated successfully.', + ); }); it('should handle DependencyError when axe is not available', async () => { @@ -340,15 +322,6 @@ describe('Long Press Plugin', () => { const mockAxeHelpers = { getAxePath: () => null, // Mock axe not found getBundledAxeEnvironment: () => ({}), - createAxeNotAvailableResponse: () => ({ - content: [ - { - type: 'text' as const, - text: AXE_NOT_AVAILABLE_MESSAGE, - }, - ], - isError: true, - }), }; const result = await long_pressLogic( @@ -362,15 +335,8 @@ describe('Long Press Plugin', () => { mockAxeHelpers, ); - expect(result).toEqual({ - content: [ - { - type: 'text' as const, - text: AXE_NOT_AVAILABLE_MESSAGE, - }, - ], - isError: true, - }); + expect(result.isError).toBe(true); + expect(allText(result)).toContain(AXE_NOT_AVAILABLE_MESSAGE); }); it('should handle AxeError from failed command execution', async () => { @@ -384,10 +350,6 @@ describe('Long Press Plugin', () => { const mockAxeHelpers = { getAxePath: () => '/usr/local/bin/axe', getBundledAxeEnvironment: () => ({}), - createAxeNotAvailableResponse: () => ({ - content: [{ type: 'text' as const, text: 'Mock axe not available' }], - isError: true, - }), }; const result = await long_pressLogic( @@ -401,15 +363,10 @@ describe('Long Press Plugin', () => { mockAxeHelpers, ); - expect(result).toEqual({ - content: [ - { - type: 'text' as const, - text: "Error: Failed to simulate long press at (100, 200): axe command 'touch' failed.\nDetails: axe command failed", - }, - ], - isError: true, - }); + expect(result.isError).toBe(true); + expect(allText(result)).toContain( + "Failed to simulate long press at (100, 200): axe command 'touch' failed.", + ); }); it('should handle SystemError from command execution', async () => { @@ -420,10 +377,6 @@ describe('Long Press Plugin', () => { const mockAxeHelpers = { getAxePath: () => '/usr/local/bin/axe', getBundledAxeEnvironment: () => ({}), - createAxeNotAvailableResponse: () => ({ - content: [{ type: 'text' as const, text: 'Mock axe not available' }], - isError: true, - }), }; const result = await long_pressLogic( @@ -437,17 +390,7 @@ describe('Long Press Plugin', () => { mockAxeHelpers, ); - expect(result).toEqual({ - content: [ - { - type: 'text' as const, - text: expect.stringContaining( - 'Error: System error executing axe: Failed to execute axe command: ENOENT: no such file or directory', - ), - }, - ], - isError: true, - }); + expect(result.isError).toBe(true); }); it('should handle unexpected Error objects', async () => { @@ -458,10 +401,6 @@ describe('Long Press Plugin', () => { const mockAxeHelpers = { getAxePath: () => '/usr/local/bin/axe', getBundledAxeEnvironment: () => ({}), - createAxeNotAvailableResponse: () => ({ - content: [{ type: 'text' as const, text: 'Mock axe not available' }], - isError: true, - }), }; const result = await long_pressLogic( @@ -475,17 +414,7 @@ describe('Long Press Plugin', () => { mockAxeHelpers, ); - expect(result).toEqual({ - content: [ - { - type: 'text' as const, - text: expect.stringContaining( - 'Error: System error executing axe: Failed to execute axe command: Unexpected error', - ), - }, - ], - isError: true, - }); + expect(result.isError).toBe(true); }); it('should handle unexpected string errors', async () => { @@ -496,10 +425,6 @@ describe('Long Press Plugin', () => { const mockAxeHelpers = { getAxePath: () => '/usr/local/bin/axe', getBundledAxeEnvironment: () => ({}), - createAxeNotAvailableResponse: () => ({ - content: [{ type: 'text' as const, text: 'Mock axe not available' }], - isError: true, - }), }; const result = await long_pressLogic( @@ -513,15 +438,10 @@ describe('Long Press Plugin', () => { mockAxeHelpers, ); - expect(result).toEqual({ - content: [ - { - type: 'text' as const, - text: 'Error: System error executing axe: Failed to execute axe command: String error', - }, - ], - isError: true, - }); + expect(result.isError).toBe(true); + expect(allText(result)).toContain( + 'System error executing axe: Failed to execute axe command: String error', + ); }); }); }); diff --git a/src/mcp/tools/ui-automation/__tests__/screenshot.test.ts b/src/mcp/tools/ui-automation/__tests__/screenshot.test.ts index dda945c6..747f138e 100644 --- a/src/mcp/tools/ui-automation/__tests__/screenshot.test.ts +++ b/src/mcp/tools/ui-automation/__tests__/screenshot.test.ts @@ -19,6 +19,13 @@ import { rotateImage, } from '../screenshot.ts'; +function allText(result: { content: Array<{ type: string; text?: string }> }): string { + return result.content + .filter((c): c is { type: 'text'; text: string } => c.type === 'text') + .map((c) => c.text) + .join('\n'); +} + describe('Screenshot Plugin', () => { beforeEach(() => { sessionStore.clear(); @@ -240,8 +247,8 @@ describe('Screenshot Plugin', () => { }), ); - expect(result.isError).toBe(false); - expect(result.content[0].type).toBe('image'); + expect(result.isError).toBeFalsy(); + expect(result.content.some((c) => c.type === 'image')).toBe(true); }); it('should return success for valid screenshot capture', async () => { @@ -265,8 +272,8 @@ describe('Screenshot Plugin', () => { mockFileSystemExecutor, ); - expect(result.isError).toBe(false); - expect(result.content[0].type).toBe('image'); + expect(result.isError).toBeFalsy(); + expect(result.content.some((c) => c.type === 'image')).toBe(true); }); it('should handle command execution failure', async () => { @@ -284,15 +291,10 @@ describe('Screenshot Plugin', () => { createMockFileSystemExecutor(), ); - expect(result).toEqual({ - content: [ - { - type: 'text' as const, - text: 'Error: System error executing screenshot: Failed to capture screenshot: Simulator not found', - }, - ], - isError: true, - }); + expect(result.isError).toBe(true); + expect(allText(result)).toContain( + 'System error executing screenshot: Failed to capture screenshot: Simulator not found', + ); }); it('should handle file reading errors', async () => { @@ -317,15 +319,10 @@ describe('Screenshot Plugin', () => { mockFileSystemExecutor, ); - expect(result).toEqual({ - content: [ - { - type: 'text' as const, - text: 'Error: Screenshot captured but failed to process image file: File not found', - }, - ], - isError: true, - }); + expect(result.isError).toBe(true); + expect(allText(result)).toContain( + 'Screenshot captured but failed to process image file: File not found', + ); }); it('should handle file cleanup errors gracefully', async () => { @@ -353,16 +350,7 @@ describe('Screenshot Plugin', () => { ); // Should still return successful result despite cleanup failure - expect(result).toEqual({ - content: [ - { - type: 'image', - data: 'fake-image-data', - mimeType: 'image/jpeg', - }, - ], - isError: false, - }); + expect(result.isError).toBeFalsy(); }); it('should handle SystemError from command execution', async () => { @@ -378,15 +366,8 @@ describe('Screenshot Plugin', () => { createMockFileSystemExecutor(), ); - expect(result).toEqual({ - content: [ - { - type: 'text' as const, - text: 'Error: System error executing screenshot: System error occurred', - }, - ], - isError: true, - }); + expect(result.isError).toBe(true); + expect(allText(result)).toContain('System error executing screenshot: System error occurred'); }); it('should handle unexpected Error objects', async () => { @@ -402,12 +383,8 @@ describe('Screenshot Plugin', () => { createMockFileSystemExecutor(), ); - expect(result).toEqual({ - content: [ - { type: 'text' as const, text: 'Error: An unexpected error occurred: Unexpected error' }, - ], - isError: true, - }); + expect(result.isError).toBe(true); + expect(allText(result)).toContain('An unexpected error occurred: Unexpected error'); }); it('should handle unexpected string errors', async () => { @@ -423,12 +400,8 @@ describe('Screenshot Plugin', () => { createMockFileSystemExecutor(), ); - expect(result).toEqual({ - content: [ - { type: 'text' as const, text: 'Error: An unexpected error occurred: String error' }, - ], - isError: true, - }); + expect(result.isError).toBe(true); + expect(allText(result)).toContain('An unexpected error occurred: String error'); }); }); @@ -800,7 +773,7 @@ describe('Screenshot Plugin', () => { ); // Should still succeed - expect(result.isError).toBe(false); + expect(result.isError).toBeFalsy(); // Should have: screenshot, list devices, failed orientation detection, optimization expect(capturedCommands.length).toBe(4); }); @@ -870,8 +843,8 @@ describe('Screenshot Plugin', () => { ); // Should still succeed even if rotation failed - expect(result.isError).toBe(false); - expect(result.content[0].type).toBe('image'); + expect(result.isError).toBeFalsy(); + expect(result.content.some((c) => c.type === 'image')).toBe(true); }); }); }); diff --git a/src/mcp/tools/ui-automation/__tests__/snapshot_ui.test.ts b/src/mcp/tools/ui-automation/__tests__/snapshot_ui.test.ts index 6abbf952..66d073fe 100644 --- a/src/mcp/tools/ui-automation/__tests__/snapshot_ui.test.ts +++ b/src/mcp/tools/ui-automation/__tests__/snapshot_ui.test.ts @@ -9,6 +9,13 @@ import type { CommandExecutor } from '../../../../utils/execution/index.ts'; import { schema, handler, snapshot_uiLogic } from '../snapshot_ui.ts'; import { AXE_NOT_AVAILABLE_MESSAGE } from '../../../../utils/axe-helpers.ts'; +function allText(result: { content: Array<{ type: string; text?: string }> }): string { + return result.content + .filter((c): c is { type: 'text'; text: string } => c.type === 'text') + .map((c) => c.text) + .join('\n'); +} + describe('Snapshot UI Plugin', () => { describe('Export Field Validation (Literal)', () => { it('should have handler function', () => { @@ -33,8 +40,8 @@ describe('Snapshot UI Plugin', () => { const result = await handler({}); expect(result.isError).toBe(true); - expect(result.content[0].text).toContain('Missing required session defaults'); - expect(result.content[0].text).toContain('simulatorId is required'); + expect(allText(result)).toContain('Missing required session defaults'); + expect(allText(result)).toContain('simulatorId is required'); }); it('should handle invalid simulatorId format via schema validation', async () => { @@ -44,8 +51,8 @@ describe('Snapshot UI Plugin', () => { }); expect(result.isError).toBe(true); - expect(result.content[0].text).toContain('Parameter validation failed'); - expect(result.content[0].text).toContain('Invalid Simulator UUID format'); + expect(allText(result)).toContain('Parameter validation failed'); + expect(allText(result)).toContain('Invalid Simulator UUID format'); }); it('should return success for valid snapshot_ui execution', async () => { @@ -63,10 +70,6 @@ describe('Snapshot UI Plugin', () => { const mockAxeHelpers = { getAxePath: () => '/usr/local/bin/axe', getBundledAxeEnvironment: () => ({}), - createAxeNotAvailableResponse: () => ({ - content: [{ type: 'text' as const, text: 'axe not available' }], - isError: true, - }), }; // Wrap executor to track calls @@ -91,22 +94,17 @@ describe('Snapshot UI Plugin', () => { { env: {} }, ]); - expect(result).toEqual({ - content: [ - { - type: 'text' as const, - text: 'Accessibility hierarchy retrieved successfully:\n```json\n{"elements": [{"type": "Button", "frame": {"x": 100, "y": 200, "width": 50, "height": 30}}]}\n```', - }, - { - type: 'text' as const, - text: 'Tips:\n- Use frame coordinates for tap/swipe (center: x+width/2, y+height/2)\n- If a debugger is attached, ensure the app is running (not stopped on breakpoints)\n- Screenshots are for visual verification only', - }, - ], - nextStepParams: { - snapshot_ui: { simulatorId: '12345678-1234-4234-8234-123456789012' }, - tap: { simulatorId: '12345678-1234-4234-8234-123456789012', x: 0, y: 0 }, - screenshot: { simulatorId: '12345678-1234-4234-8234-123456789012' }, - }, + expect(result.isError).toBeFalsy(); + const text = allText(result); + expect(text).toContain('Accessibility hierarchy retrieved successfully.'); + expect(text).toContain( + '{"elements": [{"type": "Button", "frame": {"x": 100, "y": 200, "width": 50, "height": 30}}]}', + ); + expect(text).toContain('Use frame coordinates for tap/swipe'); + expect(result.nextStepParams).toEqual({ + snapshot_ui: { simulatorId: '12345678-1234-4234-8234-123456789012' }, + tap: { simulatorId: '12345678-1234-4234-8234-123456789012', x: 0, y: 0 }, + screenshot: { simulatorId: '12345678-1234-4234-8234-123456789012' }, }); }); @@ -115,15 +113,6 @@ describe('Snapshot UI Plugin', () => { const mockAxeHelpers = { getAxePath: () => null, getBundledAxeEnvironment: () => ({}), - createAxeNotAvailableResponse: () => ({ - content: [ - { - type: 'text' as const, - text: AXE_NOT_AVAILABLE_MESSAGE, - }, - ], - isError: true, - }), }; const result = await snapshot_uiLogic( @@ -134,15 +123,8 @@ describe('Snapshot UI Plugin', () => { mockAxeHelpers, ); - expect(result).toEqual({ - content: [ - { - type: 'text' as const, - text: AXE_NOT_AVAILABLE_MESSAGE, - }, - ], - isError: true, - }); + expect(result.isError).toBe(true); + expect(allText(result)).toContain(AXE_NOT_AVAILABLE_MESSAGE); }); it('should handle AxeError from failed command execution', async () => { @@ -157,10 +139,6 @@ describe('Snapshot UI Plugin', () => { const mockAxeHelpers = { getAxePath: () => '/usr/local/bin/axe', getBundledAxeEnvironment: () => ({}), - createAxeNotAvailableResponse: () => ({ - content: [{ type: 'text' as const, text: 'axe not available' }], - isError: true, - }), }; const result = await snapshot_uiLogic( @@ -171,15 +149,10 @@ describe('Snapshot UI Plugin', () => { mockAxeHelpers, ); - expect(result).toEqual({ - content: [ - { - type: 'text' as const, - text: "Error: Failed to get accessibility hierarchy: axe command 'describe-ui' failed.\nDetails: axe command failed", - }, - ], - isError: true, - }); + expect(result.isError).toBe(true); + expect(allText(result)).toContain( + "Failed to get accessibility hierarchy: axe command 'describe-ui' failed.", + ); }); it('should handle SystemError from command execution', async () => { @@ -189,10 +162,6 @@ describe('Snapshot UI Plugin', () => { const mockAxeHelpers = { getAxePath: () => '/usr/local/bin/axe', getBundledAxeEnvironment: () => ({}), - createAxeNotAvailableResponse: () => ({ - content: [{ type: 'text' as const, text: 'axe not available' }], - isError: true, - }), }; const result = await snapshot_uiLogic( @@ -203,17 +172,7 @@ describe('Snapshot UI Plugin', () => { mockAxeHelpers, ); - expect(result).toEqual({ - content: [ - { - type: 'text' as const, - text: expect.stringContaining( - 'Error: System error executing axe: Failed to execute axe command: ENOENT: no such file or directory', - ), - }, - ], - isError: true, - }); + expect(result.isError).toBe(true); }); it('should handle unexpected Error objects', async () => { @@ -223,10 +182,6 @@ describe('Snapshot UI Plugin', () => { const mockAxeHelpers = { getAxePath: () => '/usr/local/bin/axe', getBundledAxeEnvironment: () => ({}), - createAxeNotAvailableResponse: () => ({ - content: [{ type: 'text' as const, text: 'axe not available' }], - isError: true, - }), }; const result = await snapshot_uiLogic( @@ -237,17 +192,7 @@ describe('Snapshot UI Plugin', () => { mockAxeHelpers, ); - expect(result).toEqual({ - content: [ - { - type: 'text' as const, - text: expect.stringContaining( - 'Error: System error executing axe: Failed to execute axe command: Unexpected error', - ), - }, - ], - isError: true, - }); + expect(result.isError).toBe(true); }); it('should handle unexpected string errors', async () => { @@ -257,10 +202,6 @@ describe('Snapshot UI Plugin', () => { const mockAxeHelpers = { getAxePath: () => '/usr/local/bin/axe', getBundledAxeEnvironment: () => ({}), - createAxeNotAvailableResponse: () => ({ - content: [{ type: 'text' as const, text: 'axe not available' }], - isError: true, - }), }; const result = await snapshot_uiLogic( @@ -271,15 +212,10 @@ describe('Snapshot UI Plugin', () => { mockAxeHelpers, ); - expect(result).toEqual({ - content: [ - { - type: 'text' as const, - text: 'Error: System error executing axe: Failed to execute axe command: String error', - }, - ], - isError: true, - }); + expect(result.isError).toBe(true); + expect(allText(result)).toContain( + 'System error executing axe: Failed to execute axe command: String error', + ); }); }); }); diff --git a/src/mcp/tools/ui-automation/__tests__/swipe.test.ts b/src/mcp/tools/ui-automation/__tests__/swipe.test.ts index 165327b6..fb8d3338 100644 --- a/src/mcp/tools/ui-automation/__tests__/swipe.test.ts +++ b/src/mcp/tools/ui-automation/__tests__/swipe.test.ts @@ -16,15 +16,6 @@ function createMockAxeHelpers(): AxeHelpers { return { getAxePath: () => '/mocked/axe/path', getBundledAxeEnvironment: () => ({ SOME_ENV: 'value' }), - createAxeNotAvailableResponse: () => ({ - content: [ - { - type: 'text' as const, - text: AXE_NOT_AVAILABLE_MESSAGE, - }, - ], - isError: true, - }), }; } @@ -33,18 +24,16 @@ function createMockAxeHelpersWithNullPath(): AxeHelpers { return { getAxePath: () => null, getBundledAxeEnvironment: () => ({ SOME_ENV: 'value' }), - createAxeNotAvailableResponse: () => ({ - content: [ - { - type: 'text' as const, - text: AXE_NOT_AVAILABLE_MESSAGE, - }, - ], - isError: true, - }), }; } +function allText(result: { content: Array<{ type: string; text?: string }> }): string { + return result.content + .filter((c): c is { type: 'text'; text: string } => c.type === 'text') + .map((c) => c.text) + .join('\n'); +} + describe('Swipe Tool', () => { beforeEach(() => { sessionStore.clear(); @@ -268,10 +257,6 @@ describe('Swipe Tool', () => { const mockAxeHelpers = { getAxePath: () => '/path/to/bundled/axe', getBundledAxeEnvironment: () => ({ AXE_PATH: '/some/path' }), - createAxeNotAvailableResponse: () => ({ - content: [{ type: 'text' as const, text: 'AXe tools not available' }], - isError: true, - }), }; await swipeLogic( @@ -312,9 +297,9 @@ describe('Swipe Tool', () => { expect(result.isError).toBe(true); expect(result.content[0].type).toBe('text'); - expect(result.content[0].text).toContain('Missing required session defaults'); - expect(result.content[0].text).toContain('simulatorId is required'); - expect(result.content[0].text).toContain('session-set-defaults'); + expect(allText(result)).toContain('Missing required session defaults'); + expect(allText(result)).toContain('simulatorId is required'); + expect(allText(result)).toContain('session-set-defaults'); }); it('should return validation error for missing x1 once simulator default exists', async () => { @@ -328,10 +313,8 @@ describe('Swipe Tool', () => { expect(result.isError).toBe(true); expect(result.content[0].type).toBe('text'); - expect(result.content[0].text).toContain('Parameter validation failed'); - expect(result.content[0].text).toContain( - 'x1: Invalid input: expected number, received undefined', - ); + expect(allText(result)).toContain('Parameter validation failed'); + expect(allText(result)).toContain('x1: Invalid input: expected number, received undefined'); }); it('should return success for valid swipe execution', async () => { @@ -355,15 +338,10 @@ describe('Swipe Tool', () => { mockAxeHelpers, ); - expect(result).toEqual({ - content: [ - { - type: 'text' as const, - text: 'Swipe from (100, 200) to (300, 400) simulated successfully.\n\nWarning: snapshot_ui has not been called yet. Consider using snapshot_ui for precise coordinates instead of guessing from screenshots.', - }, - ], - isError: false, - }); + expect(result.isError).toBeFalsy(); + expect(allText(result)).toContain( + 'Swipe from (100, 200) to (300, 400) simulated successfully.', + ); }); it('should return success for swipe with duration', async () => { @@ -388,15 +366,10 @@ describe('Swipe Tool', () => { mockAxeHelpers, ); - expect(result).toEqual({ - content: [ - { - type: 'text' as const, - text: 'Swipe from (100, 200) to (300, 400) duration=1.5s simulated successfully.\n\nWarning: snapshot_ui has not been called yet. Consider using snapshot_ui for precise coordinates instead of guessing from screenshots.', - }, - ], - isError: false, - }); + expect(result.isError).toBeFalsy(); + expect(allText(result)).toContain( + 'Swipe from (100, 200) to (300, 400) duration=1.5s simulated successfully.', + ); }); it('should handle DependencyError when axe is not available', async () => { @@ -420,15 +393,8 @@ describe('Swipe Tool', () => { mockAxeHelpers, ); - expect(result).toEqual({ - content: [ - { - type: 'text' as const, - text: AXE_NOT_AVAILABLE_MESSAGE, - }, - ], - isError: true, - }); + expect(result.isError).toBe(true); + expect(allText(result)).toContain(AXE_NOT_AVAILABLE_MESSAGE); }); it('should handle AxeError from failed command execution', async () => { @@ -452,15 +418,8 @@ describe('Swipe Tool', () => { mockAxeHelpers, ); - expect(result).toEqual({ - content: [ - { - type: 'text' as const, - text: "Error: Failed to simulate swipe: axe command 'swipe' failed.\nDetails: axe command failed", - }, - ], - isError: true, - }); + expect(result.isError).toBe(true); + expect(allText(result)).toContain("Failed to simulate swipe: axe command 'swipe' failed."); }); it('should handle SystemError from command execution', async () => { @@ -484,11 +443,9 @@ describe('Swipe Tool', () => { ); expect(result.isError).toBe(true); - expect(result.content[0].type).toBe('text'); - expect(result.content[0].text).toContain( - 'Error: System error executing axe: Failed to execute axe command: System error occurred', + expect(allText(result)).toContain( + 'System error executing axe: Failed to execute axe command: System error occurred', ); - expect(result.content[0].text).toContain('Details: SystemError: System error occurred'); }); it('should handle unexpected Error objects', async () => { @@ -512,11 +469,9 @@ describe('Swipe Tool', () => { ); expect(result.isError).toBe(true); - expect(result.content[0].type).toBe('text'); - expect(result.content[0].text).toContain( - 'Error: System error executing axe: Failed to execute axe command: Unexpected error', + expect(allText(result)).toContain( + 'System error executing axe: Failed to execute axe command: Unexpected error', ); - expect(result.content[0].text).toContain('Details: Error: Unexpected error'); }); it('should handle unexpected string errors', async () => { @@ -539,15 +494,10 @@ describe('Swipe Tool', () => { mockAxeHelpers, ); - expect(result).toEqual({ - content: [ - { - type: 'text' as const, - text: 'Error: System error executing axe: Failed to execute axe command: String error', - }, - ], - isError: true, - }); + expect(result.isError).toBe(true); + expect(allText(result)).toContain( + 'System error executing axe: Failed to execute axe command: String error', + ); }); }); }); diff --git a/src/mcp/tools/ui-automation/__tests__/tap.test.ts b/src/mcp/tools/ui-automation/__tests__/tap.test.ts index 9f19de5a..37764bb5 100644 --- a/src/mcp/tools/ui-automation/__tests__/tap.test.ts +++ b/src/mcp/tools/ui-automation/__tests__/tap.test.ts @@ -15,15 +15,6 @@ function createMockAxeHelpers(): AxeHelpers { return { getAxePath: () => '/mocked/axe/path', getBundledAxeEnvironment: () => ({ SOME_ENV: 'value' }), - createAxeNotAvailableResponse: () => ({ - content: [ - { - type: 'text', - text: AXE_NOT_AVAILABLE_MESSAGE, - }, - ], - isError: true, - }), }; } @@ -32,18 +23,16 @@ function createMockAxeHelpersWithNullPath(): AxeHelpers { return { getAxePath: () => null, getBundledAxeEnvironment: () => ({ SOME_ENV: 'value' }), - createAxeNotAvailableResponse: () => ({ - content: [ - { - type: 'text', - text: AXE_NOT_AVAILABLE_MESSAGE, - }, - ], - isError: true, - }), }; } +function allText(result: { content: Array<{ type: string; text?: string }> }): string { + return result.content + .filter((c): c is { type: 'text'; text: string } => c.type === 'text') + .map((c) => c.text) + .join('\n'); +} + describe('Tap Plugin', () => { beforeEach(() => { sessionStore.clear(); @@ -479,15 +468,8 @@ describe('Tap Plugin', () => { mockAxeHelpers, ); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: 'Tap at (100, 200) simulated successfully.\n\nWarning: snapshot_ui has not been called yet. Consider using snapshot_ui for precise coordinates instead of guessing from screenshots.', - }, - ], - isError: false, - }); + expect(result.isError).toBeFalsy(); + expect(allText(result)).toContain('Tap at (100, 200) simulated successfully.'); }); it('should return successful response with coordinate warning when snapshot_ui not called', async () => { @@ -508,15 +490,8 @@ describe('Tap Plugin', () => { mockAxeHelpers, ); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: 'Tap at (150, 300) simulated successfully.\n\nWarning: snapshot_ui has not been called yet. Consider using snapshot_ui for precise coordinates instead of guessing from screenshots.', - }, - ], - isError: false, - }); + expect(result.isError).toBeFalsy(); + expect(allText(result)).toContain('Tap at (150, 300) simulated successfully.'); }); it('should return successful response with delays', async () => { @@ -539,15 +514,8 @@ describe('Tap Plugin', () => { mockAxeHelpers, ); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: 'Tap at (250, 400) simulated successfully.\n\nWarning: snapshot_ui has not been called yet. Consider using snapshot_ui for precise coordinates instead of guessing from screenshots.', - }, - ], - isError: false, - }); + expect(result.isError).toBeFalsy(); + expect(allText(result)).toContain('Tap at (250, 400) simulated successfully.'); }); it('should return successful response with integer coordinates', async () => { @@ -568,15 +536,8 @@ describe('Tap Plugin', () => { mockAxeHelpers, ); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: 'Tap at (0, 0) simulated successfully.\n\nWarning: snapshot_ui has not been called yet. Consider using snapshot_ui for precise coordinates instead of guessing from screenshots.', - }, - ], - isError: false, - }); + expect(result.isError).toBeFalsy(); + expect(allText(result)).toContain('Tap at (0, 0) simulated successfully.'); }); it('should return successful response with large coordinates', async () => { @@ -597,15 +558,8 @@ describe('Tap Plugin', () => { mockAxeHelpers, ); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: 'Tap at (1920, 1080) simulated successfully.\n\nWarning: snapshot_ui has not been called yet. Consider using snapshot_ui for precise coordinates instead of guessing from screenshots.', - }, - ], - isError: false, - }); + expect(result.isError).toBeFalsy(); + expect(allText(result)).toContain('Tap at (1920, 1080) simulated successfully.'); }); it('should return successful response for element id target', async () => { @@ -625,15 +579,8 @@ describe('Tap Plugin', () => { mockAxeHelpers, ); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: 'Tap on element id "loginButton" simulated successfully.', - }, - ], - isError: false, - }); + expect(result.isError).toBeFalsy(); + expect(allText(result)).toContain('Tap on element id "loginButton" simulated successfully.'); }); it('should return successful response for element label target', async () => { @@ -653,15 +600,8 @@ describe('Tap Plugin', () => { mockAxeHelpers, ); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: 'Tap on element label "Log in" simulated successfully.', - }, - ], - isError: false, - }); + expect(result.isError).toBeFalsy(); + expect(allText(result)).toContain('Tap on element label "Log in" simulated successfully.'); }); }); @@ -800,15 +740,8 @@ describe('Tap Plugin', () => { mockAxeHelpers, ); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: AXE_NOT_AVAILABLE_MESSAGE, - }, - ], - isError: true, - }); + expect(result.isError).toBe(true); + expect(allText(result)).toContain(AXE_NOT_AVAILABLE_MESSAGE); }); it('should handle DependencyError when axe binary not found (second test)', async () => { @@ -830,15 +763,8 @@ describe('Tap Plugin', () => { mockAxeHelpers, ); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: AXE_NOT_AVAILABLE_MESSAGE, - }, - ], - isError: true, - }); + expect(result.isError).toBe(true); + expect(allText(result)).toContain(AXE_NOT_AVAILABLE_MESSAGE); }); it('should handle DependencyError when axe binary not found (third test)', async () => { @@ -860,15 +786,8 @@ describe('Tap Plugin', () => { mockAxeHelpers, ); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: AXE_NOT_AVAILABLE_MESSAGE, - }, - ], - isError: true, - }); + expect(result.isError).toBe(true); + expect(allText(result)).toContain(AXE_NOT_AVAILABLE_MESSAGE); }); it('should handle DependencyError when axe binary not found (fourth test)', async () => { @@ -888,15 +807,8 @@ describe('Tap Plugin', () => { mockAxeHelpers, ); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: AXE_NOT_AVAILABLE_MESSAGE, - }, - ], - isError: true, - }); + expect(result.isError).toBe(true); + expect(allText(result)).toContain(AXE_NOT_AVAILABLE_MESSAGE); }); it('should handle DependencyError when axe binary not found (fifth test)', async () => { @@ -916,15 +828,8 @@ describe('Tap Plugin', () => { mockAxeHelpers, ); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: AXE_NOT_AVAILABLE_MESSAGE, - }, - ], - isError: true, - }); + expect(result.isError).toBe(true); + expect(allText(result)).toContain(AXE_NOT_AVAILABLE_MESSAGE); }); it('should handle DependencyError when axe binary not found (sixth test)', async () => { @@ -944,15 +849,8 @@ describe('Tap Plugin', () => { mockAxeHelpers, ); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: AXE_NOT_AVAILABLE_MESSAGE, - }, - ], - isError: true, - }); + expect(result.isError).toBe(true); + expect(allText(result)).toContain(AXE_NOT_AVAILABLE_MESSAGE); }); }); }); diff --git a/src/mcp/tools/ui-automation/__tests__/touch.test.ts b/src/mcp/tools/ui-automation/__tests__/touch.test.ts index 3f83d031..4600435c 100644 --- a/src/mcp/tools/ui-automation/__tests__/touch.test.ts +++ b/src/mcp/tools/ui-automation/__tests__/touch.test.ts @@ -10,6 +10,13 @@ import { sessionStore } from '../../../../utils/session-store.ts'; import { schema, handler, touchLogic } from '../touch.ts'; import { AXE_NOT_AVAILABLE_MESSAGE } from '../../../../utils/axe-helpers.ts'; +function allText(result: { content: Array<{ type: string; text?: string }> }): string { + return result.content + .filter((c): c is { type: 'text'; text: string } => c.type === 'text') + .map((c) => c.text) + .join('\n'); +} + describe('Touch Plugin', () => { beforeEach(() => { sessionStore.clear(); @@ -121,15 +128,6 @@ describe('Touch Plugin', () => { const mockAxeHelpers = { getAxePath: () => '/usr/local/bin/axe', getBundledAxeEnvironment: () => ({}), - createAxeNotAvailableResponse: () => ({ - content: [ - { - type: 'text', - text: AXE_NOT_AVAILABLE_MESSAGE, - }, - ], - isError: true, - }), }; await touchLogic( @@ -171,15 +169,6 @@ describe('Touch Plugin', () => { const mockAxeHelpers = { getAxePath: () => '/usr/local/bin/axe', getBundledAxeEnvironment: () => ({}), - createAxeNotAvailableResponse: () => ({ - content: [ - { - type: 'text', - text: AXE_NOT_AVAILABLE_MESSAGE, - }, - ], - isError: true, - }), }; await touchLogic( @@ -221,15 +210,6 @@ describe('Touch Plugin', () => { const mockAxeHelpers = { getAxePath: () => '/usr/local/bin/axe', getBundledAxeEnvironment: () => ({}), - createAxeNotAvailableResponse: () => ({ - content: [ - { - type: 'text', - text: AXE_NOT_AVAILABLE_MESSAGE, - }, - ], - isError: true, - }), }; await touchLogic( @@ -273,15 +253,6 @@ describe('Touch Plugin', () => { const mockAxeHelpers = { getAxePath: () => '/usr/local/bin/axe', getBundledAxeEnvironment: () => ({}), - createAxeNotAvailableResponse: () => ({ - content: [ - { - type: 'text', - text: AXE_NOT_AVAILABLE_MESSAGE, - }, - ], - isError: true, - }), }; await touchLogic( @@ -364,15 +335,6 @@ describe('Touch Plugin', () => { const mockAxeHelpers = { getAxePath: () => null, getBundledAxeEnvironment: () => ({}), - createAxeNotAvailableResponse: () => ({ - content: [ - { - type: 'text', - text: AXE_NOT_AVAILABLE_MESSAGE, - }, - ], - isError: true, - }), }; const result = await touchLogic( @@ -386,15 +348,8 @@ describe('Touch Plugin', () => { mockAxeHelpers, ); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: AXE_NOT_AVAILABLE_MESSAGE, - }, - ], - isError: true, - }); + expect(result.isError).toBe(true); + expect(allText(result)).toContain(AXE_NOT_AVAILABLE_MESSAGE); }); it('should successfully perform touch down', async () => { @@ -402,15 +357,6 @@ describe('Touch Plugin', () => { const mockAxeHelpers = { getAxePath: () => '/usr/local/bin/axe', getBundledAxeEnvironment: () => ({}), - createAxeNotAvailableResponse: () => ({ - content: [ - { - type: 'text', - text: AXE_NOT_AVAILABLE_MESSAGE, - }, - ], - isError: true, - }), }; const result = await touchLogic( @@ -424,15 +370,10 @@ describe('Touch Plugin', () => { mockAxeHelpers, ); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: 'Touch event (touch down) at (100, 200) executed successfully.\n\nWarning: snapshot_ui has not been called yet. Consider using snapshot_ui for precise coordinates instead of guessing from screenshots.', - }, - ], - isError: false, - }); + expect(result.isError).toBeFalsy(); + expect(allText(result)).toContain( + 'Touch event (touch down) at (100, 200) executed successfully.', + ); }); it('should successfully perform touch up', async () => { @@ -440,15 +381,6 @@ describe('Touch Plugin', () => { const mockAxeHelpers = { getAxePath: () => '/usr/local/bin/axe', getBundledAxeEnvironment: () => ({}), - createAxeNotAvailableResponse: () => ({ - content: [ - { - type: 'text', - text: AXE_NOT_AVAILABLE_MESSAGE, - }, - ], - isError: true, - }), }; const result = await touchLogic( @@ -462,15 +394,10 @@ describe('Touch Plugin', () => { mockAxeHelpers, ); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: 'Touch event (touch up) at (100, 200) executed successfully.\n\nWarning: snapshot_ui has not been called yet. Consider using snapshot_ui for precise coordinates instead of guessing from screenshots.', - }, - ], - isError: false, - }); + expect(result.isError).toBeFalsy(); + expect(allText(result)).toContain( + 'Touch event (touch up) at (100, 200) executed successfully.', + ); }); it('should return error when neither down nor up is specified', async () => { @@ -485,10 +412,8 @@ describe('Touch Plugin', () => { mockExecutor, ); - expect(result).toEqual({ - content: [{ type: 'text', text: 'Error: At least one of "down" or "up" must be true' }], - isError: true, - }); + expect(result.isError).toBe(true); + expect(allText(result)).toContain('At least one of "down" or "up" must be true'); }); it('should return success for touch down event', async () => { @@ -501,15 +426,6 @@ describe('Touch Plugin', () => { const mockAxeHelpers = { getAxePath: () => '/usr/local/bin/axe', getBundledAxeEnvironment: () => ({}), - createAxeNotAvailableResponse: () => ({ - content: [ - { - type: 'text', - text: AXE_NOT_AVAILABLE_MESSAGE, - }, - ], - isError: true, - }), }; const result = await touchLogic( @@ -523,15 +439,10 @@ describe('Touch Plugin', () => { mockAxeHelpers, ); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: 'Touch event (touch down) at (100, 200) executed successfully.\n\nWarning: snapshot_ui has not been called yet. Consider using snapshot_ui for precise coordinates instead of guessing from screenshots.', - }, - ], - isError: false, - }); + expect(result.isError).toBeFalsy(); + expect(allText(result)).toContain( + 'Touch event (touch down) at (100, 200) executed successfully.', + ); }); it('should return success for touch up event', async () => { @@ -544,15 +455,6 @@ describe('Touch Plugin', () => { const mockAxeHelpers = { getAxePath: () => '/usr/local/bin/axe', getBundledAxeEnvironment: () => ({}), - createAxeNotAvailableResponse: () => ({ - content: [ - { - type: 'text', - text: AXE_NOT_AVAILABLE_MESSAGE, - }, - ], - isError: true, - }), }; const result = await touchLogic( @@ -566,15 +468,10 @@ describe('Touch Plugin', () => { mockAxeHelpers, ); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: 'Touch event (touch up) at (100, 200) executed successfully.\n\nWarning: snapshot_ui has not been called yet. Consider using snapshot_ui for precise coordinates instead of guessing from screenshots.', - }, - ], - isError: false, - }); + expect(result.isError).toBeFalsy(); + expect(allText(result)).toContain( + 'Touch event (touch up) at (100, 200) executed successfully.', + ); }); it('should return success for touch down+up event', async () => { @@ -587,15 +484,6 @@ describe('Touch Plugin', () => { const mockAxeHelpers = { getAxePath: () => '/usr/local/bin/axe', getBundledAxeEnvironment: () => ({}), - createAxeNotAvailableResponse: () => ({ - content: [ - { - type: 'text', - text: AXE_NOT_AVAILABLE_MESSAGE, - }, - ], - isError: true, - }), }; const result = await touchLogic( @@ -610,15 +498,10 @@ describe('Touch Plugin', () => { mockAxeHelpers, ); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: 'Touch event (touch down+up) at (100, 200) executed successfully.\n\nWarning: snapshot_ui has not been called yet. Consider using snapshot_ui for precise coordinates instead of guessing from screenshots.', - }, - ], - isError: false, - }); + expect(result.isError).toBeFalsy(); + expect(allText(result)).toContain( + 'Touch event (touch down+up) at (100, 200) executed successfully.', + ); }); it('should handle DependencyError when axe is not available', async () => { @@ -627,15 +510,6 @@ describe('Touch Plugin', () => { const mockAxeHelpers = { getAxePath: () => null, getBundledAxeEnvironment: () => ({}), - createAxeNotAvailableResponse: () => ({ - content: [ - { - type: 'text', - text: AXE_NOT_AVAILABLE_MESSAGE, - }, - ], - isError: true, - }), }; const result = await touchLogic( @@ -649,15 +523,8 @@ describe('Touch Plugin', () => { mockAxeHelpers, ); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: AXE_NOT_AVAILABLE_MESSAGE, - }, - ], - isError: true, - }); + expect(result.isError).toBe(true); + expect(allText(result)).toContain(AXE_NOT_AVAILABLE_MESSAGE); }); it('should handle AxeError from failed command execution', async () => { @@ -670,15 +537,6 @@ describe('Touch Plugin', () => { const mockAxeHelpers = { getAxePath: () => '/usr/local/bin/axe', getBundledAxeEnvironment: () => ({}), - createAxeNotAvailableResponse: () => ({ - content: [ - { - type: 'text', - text: AXE_NOT_AVAILABLE_MESSAGE, - }, - ], - isError: true, - }), }; const result = await touchLogic( @@ -692,15 +550,10 @@ describe('Touch Plugin', () => { mockAxeHelpers, ); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: "Error: Failed to execute touch event: axe command 'touch' failed.\nDetails: axe command failed", - }, - ], - isError: true, - }); + expect(result.isError).toBe(true); + expect(allText(result)).toContain( + "Failed to execute touch event: axe command 'touch' failed.", + ); }); it('should handle SystemError from command execution', async () => { @@ -711,15 +564,6 @@ describe('Touch Plugin', () => { const mockAxeHelpers = { getAxePath: () => '/usr/local/bin/axe', getBundledAxeEnvironment: () => ({}), - createAxeNotAvailableResponse: () => ({ - content: [ - { - type: 'text', - text: AXE_NOT_AVAILABLE_MESSAGE, - }, - ], - isError: true, - }), }; const result = await touchLogic( @@ -733,17 +577,7 @@ describe('Touch Plugin', () => { mockAxeHelpers, ); - expect(result).toMatchObject({ - content: [ - { - type: 'text', - text: expect.stringContaining( - 'Error: System error executing axe: Failed to execute axe command: System error occurred', - ), - }, - ], - isError: true, - }); + expect(result.isError).toBe(true); }); it('should handle unexpected Error objects', async () => { @@ -754,15 +588,6 @@ describe('Touch Plugin', () => { const mockAxeHelpers = { getAxePath: () => '/usr/local/bin/axe', getBundledAxeEnvironment: () => ({}), - createAxeNotAvailableResponse: () => ({ - content: [ - { - type: 'text', - text: AXE_NOT_AVAILABLE_MESSAGE, - }, - ], - isError: true, - }), }; const result = await touchLogic( @@ -776,17 +601,7 @@ describe('Touch Plugin', () => { mockAxeHelpers, ); - expect(result).toMatchObject({ - content: [ - { - type: 'text', - text: expect.stringContaining( - 'Error: System error executing axe: Failed to execute axe command: Unexpected error', - ), - }, - ], - isError: true, - }); + expect(result.isError).toBe(true); }); it('should handle unexpected string errors', async () => { @@ -797,15 +612,6 @@ describe('Touch Plugin', () => { const mockAxeHelpers = { getAxePath: () => '/usr/local/bin/axe', getBundledAxeEnvironment: () => ({}), - createAxeNotAvailableResponse: () => ({ - content: [ - { - type: 'text', - text: AXE_NOT_AVAILABLE_MESSAGE, - }, - ], - isError: true, - }), }; const result = await touchLogic( @@ -819,15 +625,10 @@ describe('Touch Plugin', () => { mockAxeHelpers, ); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: 'Error: System error executing axe: Failed to execute axe command: String error', - }, - ], - isError: true, - }); + expect(result.isError).toBe(true); + expect(allText(result)).toContain( + 'System error executing axe: Failed to execute axe command: String error', + ); }); }); }); diff --git a/src/mcp/tools/ui-automation/__tests__/type_text.test.ts b/src/mcp/tools/ui-automation/__tests__/type_text.test.ts index 3910667a..027ce23c 100644 --- a/src/mcp/tools/ui-automation/__tests__/type_text.test.ts +++ b/src/mcp/tools/ui-automation/__tests__/type_text.test.ts @@ -24,15 +24,6 @@ function createMockAxeHelpers( getAxePath: () => overrides.getAxePathReturn !== undefined ? overrides.getAxePathReturn : '/usr/local/bin/axe', getBundledAxeEnvironment: () => overrides.getBundledAxeEnvironmentReturn ?? {}, - createAxeNotAvailableResponse: () => ({ - content: [ - { - type: 'text', - text: AXE_NOT_AVAILABLE_MESSAGE, - }, - ], - isError: true, - }), }; } @@ -43,6 +34,13 @@ function createRejectingExecutor(error: any) { }; } +function allText(result: { content: Array<{ type: string; text?: string }> }): string { + return result.content + .filter((c): c is { type: 'text'; text: string } => c.type === 'text') + .map((c) => c.text) + .join('\n'); +} + describe('Type Text Tool', () => { beforeEach(() => { sessionStore.clear(); @@ -303,15 +301,8 @@ describe('Type Text Tool', () => { mockAxeHelpers, ); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: AXE_NOT_AVAILABLE_MESSAGE, - }, - ], - isError: true, - }); + expect(result.isError).toBe(true); + expect(allText(result)).toContain(AXE_NOT_AVAILABLE_MESSAGE); }); it('should successfully type text', async () => { @@ -334,10 +325,8 @@ describe('Type Text Tool', () => { mockAxeHelpers, ); - expect(result).toEqual({ - content: [{ type: 'text', text: 'Text typing simulated successfully.' }], - isError: false, - }); + expect(result.isError).toBeFalsy(); + expect(allText(result)).toContain('Text typing simulated successfully.'); }); it('should return success for valid text typing', async () => { @@ -361,10 +350,8 @@ describe('Type Text Tool', () => { mockAxeHelpers, ); - expect(result).toEqual({ - content: [{ type: 'text', text: 'Text typing simulated successfully.' }], - isError: false, - }); + expect(result.isError).toBeFalsy(); + expect(allText(result)).toContain('Text typing simulated successfully.'); }); it('should handle DependencyError when axe binary not found', async () => { @@ -381,15 +368,8 @@ describe('Type Text Tool', () => { mockAxeHelpers, ); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: AXE_NOT_AVAILABLE_MESSAGE, - }, - ], - isError: true, - }); + expect(result.isError).toBe(true); + expect(allText(result)).toContain(AXE_NOT_AVAILABLE_MESSAGE); }); it('should handle AxeError from command execution', async () => { @@ -413,15 +393,10 @@ describe('Type Text Tool', () => { mockAxeHelpers, ); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: "Error: Failed to simulate text typing: axe command 'type' failed.\nDetails: Text field not found", - }, - ], - isError: true, - }); + expect(result.isError).toBe(true); + expect(allText(result)).toContain( + "Failed to simulate text typing: axe command 'type' failed.", + ); }); it('should handle SystemError from command execution', async () => { @@ -441,17 +416,7 @@ describe('Type Text Tool', () => { mockAxeHelpers, ); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: expect.stringContaining( - 'Error: System error executing axe: Failed to execute axe command: ENOENT: no such file or directory', - ), - }, - ], - isError: true, - }); + expect(result.isError).toBe(true); }); it('should handle unexpected Error objects', async () => { @@ -471,17 +436,7 @@ describe('Type Text Tool', () => { mockAxeHelpers, ); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: expect.stringContaining( - 'Error: System error executing axe: Failed to execute axe command: Unexpected error', - ), - }, - ], - isError: true, - }); + expect(result.isError).toBe(true); }); it('should handle unexpected string errors', async () => { @@ -501,15 +456,10 @@ describe('Type Text Tool', () => { mockAxeHelpers, ); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: 'Error: System error executing axe: Failed to execute axe command: String error', - }, - ], - isError: true, - }); + expect(result.isError).toBe(true); + expect(allText(result)).toContain( + 'System error executing axe: Failed to execute axe command: String error', + ); }); }); }); diff --git a/src/mcp/tools/ui-automation/button.ts b/src/mcp/tools/ui-automation/button.ts index 26d36daa..71a4cdbf 100644 --- a/src/mcp/tools/ui-automation/button.ts +++ b/src/mcp/tools/ui-automation/button.ts @@ -1,24 +1,24 @@ import * as z from 'zod'; import type { ToolResponse } from '../../../types/common.ts'; import { log } from '../../../utils/logging/index.ts'; -import { createTextResponse, createErrorResponse } from '../../../utils/responses/index.ts'; import type { CommandExecutor } from '../../../utils/execution/index.ts'; import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; import { getDefaultDebuggerManager } from '../../../utils/debugger/index.ts'; import type { DebuggerManager } from '../../../utils/debugger/debugger-manager.ts'; import { guardUiAutomationAgainstStoppedDebugger } from '../../../utils/debugger/ui-automation-guard.ts'; import { - createAxeNotAvailableResponse, getAxePath, getBundledAxeEnvironment, + AXE_NOT_AVAILABLE_MESSAGE, } from '../../../utils/axe-helpers.ts'; import { DependencyError, AxeError, SystemError } from '../../../utils/errors.ts'; import { createSessionAwareTool, getSessionAwareToolSchemaShape, } from '../../../utils/typed-tool-factory.ts'; +import { toolResponse } from '../../../utils/tool-response.ts'; +import { header, statusLine, section } from '../../../utils/tool-event-builders.ts'; -// Define schema as ZodObject const buttonSchema = z.object({ simulatorId: z.uuid({ message: 'Invalid Simulator UUID format' }), buttonType: z @@ -31,13 +31,11 @@ const buttonSchema = z.object({ .describe('seconds'), }); -// Use z.infer for type safety type ButtonParams = z.infer; export interface AxeHelpers { getAxePath: () => string | null; getBundledAxeEnvironment: () => Record; - createAxeNotAvailableResponse: () => ToolResponse; } const LOG_PREFIX = '[AXe]'; @@ -48,19 +46,21 @@ export async function buttonLogic( axeHelpers: AxeHelpers = { getAxePath, getBundledAxeEnvironment, - createAxeNotAvailableResponse, }, debuggerManager: DebuggerManager = getDefaultDebuggerManager(), ): Promise { const toolName = 'button'; const { simulatorId, buttonType, duration } = params; + const headerEvent = header('Button', [{ label: 'Simulator', value: simulatorId }]); + const guard = await guardUiAutomationAgainstStoppedDebugger({ debugger: debuggerManager, simulatorId, toolName, }); - if (guard.blockedResponse) return guard.blockedResponse; + if (guard.blockedMessage) + return toolResponse([headerEvent, statusLine('error', guard.blockedMessage)]); const commandArgs = ['button', buttonType]; if (duration !== undefined) { @@ -72,29 +72,38 @@ export async function buttonLogic( try { await executeAxeCommand(commandArgs, simulatorId, 'button', executor, axeHelpers); log('info', `${LOG_PREFIX}/${toolName}: Success for ${simulatorId}`); - const message = `Hardware button '${buttonType}' pressed successfully.`; - if (guard.warningText) { - return createTextResponse(`${message}\n\n${guard.warningText}`); - } - return createTextResponse(message); + const events = [ + headerEvent, + statusLine('success', `Hardware button '${buttonType}' pressed successfully.`), + ...(guard.warningText ? [statusLine('warning' as const, guard.warningText)] : []), + ]; + return toolResponse(events); } catch (error) { log('error', `${LOG_PREFIX}/${toolName}: Failed - ${error}`); if (error instanceof DependencyError) { - return axeHelpers.createAxeNotAvailableResponse(); + return toolResponse([headerEvent, statusLine('error', AXE_NOT_AVAILABLE_MESSAGE)]); } else if (error instanceof AxeError) { - return createErrorResponse( - `Failed to press button '${buttonType}': ${error.message}`, - error.axeOutput, - ); + return toolResponse([ + headerEvent, + statusLine('error', `Failed to press button '${buttonType}': ${error.message}`), + ...(error.axeOutput ? [section('Details', [error.axeOutput])] : []), + ]); } else if (error instanceof SystemError) { - return createErrorResponse( - `System error executing axe: ${error.message}`, - error.originalError?.stack, - ); + return toolResponse([ + headerEvent, + statusLine('error', `System error executing axe: ${error.message}`), + ...(error.originalError?.stack + ? [section('Stack Trace', [error.originalError.stack])] + : []), + ]); } - return createErrorResponse( - `An unexpected error occurred: ${error instanceof Error ? error.message : String(error)}`, - ); + return toolResponse([ + headerEvent, + statusLine( + 'error', + `An unexpected error occurred: ${error instanceof Error ? error.message : String(error)}`, + ), + ]); } } @@ -111,7 +120,6 @@ export const handler = createSessionAwareTool({ buttonLogic(params, executor, { getAxePath, getBundledAxeEnvironment, - createAxeNotAvailableResponse, }), getExecutor: getDefaultCommandExecutor, requirements: [{ allOf: ['simulatorId'], message: 'simulatorId is required' }], @@ -123,7 +131,7 @@ async function executeAxeCommand( simulatorId: string, commandName: string, executor: CommandExecutor = getDefaultCommandExecutor(), - axeHelpers: AxeHelpers = { getAxePath, getBundledAxeEnvironment, createAxeNotAvailableResponse }, + axeHelpers: AxeHelpers = { getAxePath, getBundledAxeEnvironment }, ): Promise { // Get the appropriate axe binary path const axeBinary = axeHelpers.getAxePath(); diff --git a/src/mcp/tools/ui-automation/gesture.ts b/src/mcp/tools/ui-automation/gesture.ts index 2cc2c66a..1bbcbd9b 100644 --- a/src/mcp/tools/ui-automation/gesture.ts +++ b/src/mcp/tools/ui-automation/gesture.ts @@ -8,29 +8,24 @@ import * as z from 'zod'; import type { ToolResponse } from '../../../types/common.ts'; import { log } from '../../../utils/logging/index.ts'; -import { - createTextResponse, - createErrorResponse, - DependencyError, - AxeError, - SystemError, -} from '../../../utils/responses/index.ts'; +import { DependencyError, AxeError, SystemError } from '../../../utils/errors.ts'; import type { CommandExecutor } from '../../../utils/execution/index.ts'; import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; import { getDefaultDebuggerManager } from '../../../utils/debugger/index.ts'; import type { DebuggerManager } from '../../../utils/debugger/debugger-manager.ts'; import { guardUiAutomationAgainstStoppedDebugger } from '../../../utils/debugger/ui-automation-guard.ts'; import { - createAxeNotAvailableResponse, getAxePath, getBundledAxeEnvironment, -} from '../../../utils/axe/index.ts'; + AXE_NOT_AVAILABLE_MESSAGE, +} from '../../../utils/axe-helpers.ts'; import { createSessionAwareTool, getSessionAwareToolSchemaShape, } from '../../../utils/typed-tool-factory.ts'; +import { toolResponse } from '../../../utils/tool-response.ts'; +import { header, statusLine, section } from '../../../utils/tool-event-builders.ts'; -// Define schema as ZodObject const gestureSchema = z.object({ simulatorId: z.uuid({ message: 'Invalid Simulator UUID format' }), preset: z @@ -85,13 +80,11 @@ const gestureSchema = z.object({ .describe('Delay after completing the gesture in seconds.'), }); -// Use z.infer for type safety type GestureParams = z.infer; export interface AxeHelpers { getAxePath: () => string | null; getBundledAxeEnvironment: () => Record; - createAxeNotAvailableResponse: () => ToolResponse; } const LOG_PREFIX = '[AXe]'; @@ -102,19 +95,22 @@ export async function gestureLogic( axeHelpers: AxeHelpers = { getAxePath, getBundledAxeEnvironment, - createAxeNotAvailableResponse, }, debuggerManager: DebuggerManager = getDefaultDebuggerManager(), ): Promise { const toolName = 'gesture'; const { simulatorId, preset, screenWidth, screenHeight, duration, delta, preDelay, postDelay } = params; + + const headerEvent = header('Gesture', [{ label: 'Simulator', value: simulatorId }]); + const guard = await guardUiAutomationAgainstStoppedDebugger({ debugger: debuggerManager, simulatorId, toolName, }); - if (guard.blockedResponse) return guard.blockedResponse; + if (guard.blockedMessage) + return toolResponse([headerEvent, statusLine('error', guard.blockedMessage)]); const commandArgs = ['gesture', preset]; if (screenWidth !== undefined) { @@ -141,29 +137,38 @@ export async function gestureLogic( try { await executeAxeCommand(commandArgs, simulatorId, 'gesture', executor, axeHelpers); log('info', `${LOG_PREFIX}/${toolName}: Success for ${simulatorId}`); - const message = `Gesture '${preset}' executed successfully.`; - if (guard.warningText) { - return createTextResponse(`${message}\n\n${guard.warningText}`); - } - return createTextResponse(message); + const events = [ + headerEvent, + statusLine('success', `Gesture '${preset}' executed successfully.`), + ...(guard.warningText ? [statusLine('warning' as const, guard.warningText)] : []), + ]; + return toolResponse(events); } catch (error) { log('error', `${LOG_PREFIX}/${toolName}: Failed - ${error}`); if (error instanceof DependencyError) { - return axeHelpers.createAxeNotAvailableResponse(); + return toolResponse([headerEvent, statusLine('error', AXE_NOT_AVAILABLE_MESSAGE)]); } else if (error instanceof AxeError) { - return createErrorResponse( - `Failed to execute gesture '${preset}': ${error.message}`, - error.axeOutput, - ); + return toolResponse([ + headerEvent, + statusLine('error', `Failed to execute gesture '${preset}': ${error.message}`), + ...(error.axeOutput ? [section('Details', [error.axeOutput])] : []), + ]); } else if (error instanceof SystemError) { - return createErrorResponse( - `System error executing axe: ${error.message}`, - error.originalError?.stack, - ); + return toolResponse([ + headerEvent, + statusLine('error', `System error executing axe: ${error.message}`), + ...(error.originalError?.stack + ? [section('Stack Trace', [error.originalError.stack])] + : []), + ]); } - return createErrorResponse( - `An unexpected error occurred: ${error instanceof Error ? error.message : String(error)}`, - ); + return toolResponse([ + headerEvent, + statusLine( + 'error', + `An unexpected error occurred: ${error instanceof Error ? error.message : String(error)}`, + ), + ]); } } @@ -180,7 +185,6 @@ export const handler = createSessionAwareTool({ gestureLogic(params, executor, { getAxePath, getBundledAxeEnvironment, - createAxeNotAvailableResponse, }), getExecutor: getDefaultCommandExecutor, requirements: [{ allOf: ['simulatorId'], message: 'simulatorId is required' }], @@ -192,7 +196,7 @@ async function executeAxeCommand( simulatorId: string, commandName: string, executor: CommandExecutor = getDefaultCommandExecutor(), - axeHelpers: AxeHelpers = { getAxePath, getBundledAxeEnvironment, createAxeNotAvailableResponse }, + axeHelpers: AxeHelpers = { getAxePath, getBundledAxeEnvironment }, ): Promise { // Get the appropriate axe binary path const axeBinary = axeHelpers.getAxePath(); diff --git a/src/mcp/tools/ui-automation/key_press.ts b/src/mcp/tools/ui-automation/key_press.ts index aaa048a3..742af49e 100644 --- a/src/mcp/tools/ui-automation/key_press.ts +++ b/src/mcp/tools/ui-automation/key_press.ts @@ -1,29 +1,24 @@ import * as z from 'zod'; import type { ToolResponse } from '../../../types/common.ts'; import { log } from '../../../utils/logging/index.ts'; -import { - createTextResponse, - createErrorResponse, - DependencyError, - AxeError, - SystemError, -} from '../../../utils/responses/index.ts'; +import { DependencyError, AxeError, SystemError } from '../../../utils/errors.ts'; import type { CommandExecutor } from '../../../utils/execution/index.ts'; import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; import { getDefaultDebuggerManager } from '../../../utils/debugger/index.ts'; import type { DebuggerManager } from '../../../utils/debugger/debugger-manager.ts'; import { guardUiAutomationAgainstStoppedDebugger } from '../../../utils/debugger/ui-automation-guard.ts'; import { - createAxeNotAvailableResponse, getAxePath, getBundledAxeEnvironment, -} from '../../../utils/axe/index.ts'; + AXE_NOT_AVAILABLE_MESSAGE, +} from '../../../utils/axe-helpers.ts'; import { createSessionAwareTool, getSessionAwareToolSchemaShape, } from '../../../utils/typed-tool-factory.ts'; +import { toolResponse } from '../../../utils/tool-response.ts'; +import { header, statusLine, section } from '../../../utils/tool-event-builders.ts'; -// Define schema as ZodObject const keyPressSchema = z.object({ simulatorId: z.uuid({ message: 'Invalid Simulator UUID format' }), keyCode: z @@ -39,13 +34,11 @@ const keyPressSchema = z.object({ .describe('seconds'), }); -// Use z.infer for type safety type KeyPressParams = z.infer; export interface AxeHelpers { getAxePath: () => string | null; getBundledAxeEnvironment: () => Record; - createAxeNotAvailableResponse: () => ToolResponse; } const LOG_PREFIX = '[AXe]'; @@ -56,19 +49,21 @@ export async function key_pressLogic( axeHelpers: AxeHelpers = { getAxePath, getBundledAxeEnvironment, - createAxeNotAvailableResponse, }, debuggerManager: DebuggerManager = getDefaultDebuggerManager(), ): Promise { const toolName = 'key_press'; const { simulatorId, keyCode, duration } = params; + const headerEvent = header('Key Press', [{ label: 'Simulator', value: simulatorId }]); + const guard = await guardUiAutomationAgainstStoppedDebugger({ debugger: debuggerManager, simulatorId, toolName, }); - if (guard.blockedResponse) return guard.blockedResponse; + if (guard.blockedMessage) + return toolResponse([headerEvent, statusLine('error', guard.blockedMessage)]); const commandArgs = ['key', String(keyCode)]; if (duration !== undefined) { @@ -80,29 +75,38 @@ export async function key_pressLogic( try { await executeAxeCommand(commandArgs, simulatorId, 'key', executor, axeHelpers); log('info', `${LOG_PREFIX}/${toolName}: Success for ${simulatorId}`); - const message = `Key press (code: ${keyCode}) simulated successfully.`; - if (guard.warningText) { - return createTextResponse(`${message}\n\n${guard.warningText}`); - } - return createTextResponse(message); + const events = [ + headerEvent, + statusLine('success', `Key press (code: ${keyCode}) simulated successfully.`), + ...(guard.warningText ? [statusLine('warning' as const, guard.warningText)] : []), + ]; + return toolResponse(events); } catch (error) { log('error', `${LOG_PREFIX}/${toolName}: Failed - ${error}`); if (error instanceof DependencyError) { - return axeHelpers.createAxeNotAvailableResponse(); + return toolResponse([headerEvent, statusLine('error', AXE_NOT_AVAILABLE_MESSAGE)]); } else if (error instanceof AxeError) { - return createErrorResponse( - `Failed to simulate key press (code: ${keyCode}): ${error.message}`, - error.axeOutput, - ); + return toolResponse([ + headerEvent, + statusLine('error', `Failed to simulate key press (code: ${keyCode}): ${error.message}`), + ...(error.axeOutput ? [section('Details', [error.axeOutput])] : []), + ]); } else if (error instanceof SystemError) { - return createErrorResponse( - `System error executing axe: ${error.message}`, - error.originalError?.stack, - ); + return toolResponse([ + headerEvent, + statusLine('error', `System error executing axe: ${error.message}`), + ...(error.originalError?.stack + ? [section('Stack Trace', [error.originalError.stack])] + : []), + ]); } - return createErrorResponse( - `An unexpected error occurred: ${error instanceof Error ? error.message : String(error)}`, - ); + return toolResponse([ + headerEvent, + statusLine( + 'error', + `An unexpected error occurred: ${error instanceof Error ? error.message : String(error)}`, + ), + ]); } } @@ -121,7 +125,6 @@ export const handler = createSessionAwareTool({ key_pressLogic(params, executor, { getAxePath, getBundledAxeEnvironment, - createAxeNotAvailableResponse, }), getExecutor: getDefaultCommandExecutor, requirements: [{ allOf: ['simulatorId'], message: 'simulatorId is required' }], @@ -133,7 +136,7 @@ async function executeAxeCommand( simulatorId: string, commandName: string, executor: CommandExecutor = getDefaultCommandExecutor(), - axeHelpers: AxeHelpers = { getAxePath, getBundledAxeEnvironment, createAxeNotAvailableResponse }, + axeHelpers: AxeHelpers = { getAxePath, getBundledAxeEnvironment }, ): Promise { // Get the appropriate axe binary path const axeBinary = axeHelpers.getAxePath(); diff --git a/src/mcp/tools/ui-automation/key_sequence.ts b/src/mcp/tools/ui-automation/key_sequence.ts index 96bc9d9d..15fda832 100644 --- a/src/mcp/tools/ui-automation/key_sequence.ts +++ b/src/mcp/tools/ui-automation/key_sequence.ts @@ -7,29 +7,24 @@ import * as z from 'zod'; import type { ToolResponse } from '../../../types/common.ts'; import { log } from '../../../utils/logging/index.ts'; -import { - createTextResponse, - createErrorResponse, - DependencyError, - AxeError, - SystemError, -} from '../../../utils/responses/index.ts'; +import { DependencyError, AxeError, SystemError } from '../../../utils/errors.ts'; import type { CommandExecutor } from '../../../utils/execution/index.ts'; import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; import { getDefaultDebuggerManager } from '../../../utils/debugger/index.ts'; import type { DebuggerManager } from '../../../utils/debugger/debugger-manager.ts'; import { guardUiAutomationAgainstStoppedDebugger } from '../../../utils/debugger/ui-automation-guard.ts'; import { - createAxeNotAvailableResponse, getAxePath, getBundledAxeEnvironment, -} from '../../../utils/axe/index.ts'; + AXE_NOT_AVAILABLE_MESSAGE, +} from '../../../utils/axe-helpers.ts'; import { createSessionAwareTool, getSessionAwareToolSchemaShape, } from '../../../utils/typed-tool-factory.ts'; +import { toolResponse } from '../../../utils/tool-response.ts'; +import { header, statusLine, section } from '../../../utils/tool-event-builders.ts'; -// Define schema as ZodObject const keySequenceSchema = z.object({ simulatorId: z.uuid({ message: 'Invalid Simulator UUID format' }), keyCodes: z @@ -39,13 +34,11 @@ const keySequenceSchema = z.object({ delay: z.number().min(0, { message: 'Delay must be non-negative' }).optional(), }); -// Use z.infer for type safety type KeySequenceParams = z.infer; export interface AxeHelpers { getAxePath: () => string | null; getBundledAxeEnvironment: () => Record; - createAxeNotAvailableResponse: () => ToolResponse; } const LOG_PREFIX = '[AXe]'; @@ -56,19 +49,21 @@ export async function key_sequenceLogic( axeHelpers: AxeHelpers = { getAxePath, getBundledAxeEnvironment, - createAxeNotAvailableResponse, }, debuggerManager: DebuggerManager = getDefaultDebuggerManager(), ): Promise { const toolName = 'key_sequence'; const { simulatorId, keyCodes, delay } = params; + const headerEvent = header('Key Sequence', [{ label: 'Simulator', value: simulatorId }]); + const guard = await guardUiAutomationAgainstStoppedDebugger({ debugger: debuggerManager, simulatorId, toolName, }); - if (guard.blockedResponse) return guard.blockedResponse; + if (guard.blockedMessage) + return toolResponse([headerEvent, statusLine('error', guard.blockedMessage)]); const commandArgs = ['key-sequence', '--keycodes', keyCodes.join(',')]; if (delay !== undefined) { @@ -83,29 +78,38 @@ export async function key_sequenceLogic( try { await executeAxeCommand(commandArgs, simulatorId, 'key-sequence', executor, axeHelpers); log('info', `${LOG_PREFIX}/${toolName}: Success for ${simulatorId}`); - const message = `Key sequence [${keyCodes.join(',')}] executed successfully.`; - if (guard.warningText) { - return createTextResponse(`${message}\n\n${guard.warningText}`); - } - return createTextResponse(message); + const events = [ + headerEvent, + statusLine('success', `Key sequence [${keyCodes.join(',')}] executed successfully.`), + ...(guard.warningText ? [statusLine('warning' as const, guard.warningText)] : []), + ]; + return toolResponse(events); } catch (error) { log('error', `${LOG_PREFIX}/${toolName}: Failed - ${error}`); if (error instanceof DependencyError) { - return axeHelpers.createAxeNotAvailableResponse(); + return toolResponse([headerEvent, statusLine('error', AXE_NOT_AVAILABLE_MESSAGE)]); } else if (error instanceof AxeError) { - return createErrorResponse( - `Failed to execute key sequence: ${error.message}`, - error.axeOutput, - ); + return toolResponse([ + headerEvent, + statusLine('error', `Failed to execute key sequence: ${error.message}`), + ...(error.axeOutput ? [section('Details', [error.axeOutput])] : []), + ]); } else if (error instanceof SystemError) { - return createErrorResponse( - `System error executing axe: ${error.message}`, - error.originalError?.stack, - ); + return toolResponse([ + headerEvent, + statusLine('error', `System error executing axe: ${error.message}`), + ...(error.originalError?.stack + ? [section('Stack Trace', [error.originalError.stack])] + : []), + ]); } - return createErrorResponse( - `An unexpected error occurred: ${error instanceof Error ? error.message : String(error)}`, - ); + return toolResponse([ + headerEvent, + statusLine( + 'error', + `An unexpected error occurred: ${error instanceof Error ? error.message : String(error)}`, + ), + ]); } } @@ -124,7 +128,6 @@ export const handler = createSessionAwareTool({ key_sequenceLogic(params, executor, { getAxePath, getBundledAxeEnvironment, - createAxeNotAvailableResponse, }), getExecutor: getDefaultCommandExecutor, requirements: [{ allOf: ['simulatorId'], message: 'simulatorId is required' }], @@ -136,7 +139,7 @@ async function executeAxeCommand( simulatorId: string, commandName: string, executor: CommandExecutor = getDefaultCommandExecutor(), - axeHelpers: AxeHelpers = { getAxePath, getBundledAxeEnvironment, createAxeNotAvailableResponse }, + axeHelpers: AxeHelpers = { getAxePath, getBundledAxeEnvironment }, ): Promise { // Get the appropriate axe binary path const axeBinary = axeHelpers.getAxePath(); diff --git a/src/mcp/tools/ui-automation/long_press.ts b/src/mcp/tools/ui-automation/long_press.ts index f6429729..487ff837 100644 --- a/src/mcp/tools/ui-automation/long_press.ts +++ b/src/mcp/tools/ui-automation/long_press.ts @@ -8,30 +8,25 @@ import * as z from 'zod'; import type { ToolResponse } from '../../../types/common.ts'; import { log } from '../../../utils/logging/index.ts'; -import { - createTextResponse, - createErrorResponse, - DependencyError, - AxeError, - SystemError, -} from '../../../utils/responses/index.ts'; +import { DependencyError, AxeError, SystemError } from '../../../utils/errors.ts'; import type { CommandExecutor } from '../../../utils/execution/index.ts'; import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; import { getDefaultDebuggerManager } from '../../../utils/debugger/index.ts'; import type { DebuggerManager } from '../../../utils/debugger/debugger-manager.ts'; import { guardUiAutomationAgainstStoppedDebugger } from '../../../utils/debugger/ui-automation-guard.ts'; import { - createAxeNotAvailableResponse, getAxePath, getBundledAxeEnvironment, -} from '../../../utils/axe/index.ts'; + AXE_NOT_AVAILABLE_MESSAGE, +} from '../../../utils/axe-helpers.ts'; import { createSessionAwareTool, getSessionAwareToolSchemaShape, } from '../../../utils/typed-tool-factory.ts'; import { getSnapshotUiWarning } from './shared/snapshot-ui-state.ts'; +import { toolResponse } from '../../../utils/tool-response.ts'; +import { header, statusLine, section } from '../../../utils/tool-event-builders.ts'; -// Define schema as ZodObject const longPressSchema = z.object({ simulatorId: z.uuid({ message: 'Invalid Simulator UUID format' }), x: z.number().int({ message: 'X coordinate for the long press' }), @@ -42,7 +37,6 @@ const longPressSchema = z.object({ .describe('milliseconds'), }); -// Use z.infer for type safety type LongPressParams = z.infer; const publicSchemaObject = z.strictObject( @@ -52,7 +46,6 @@ const publicSchemaObject = z.strictObject( export interface AxeHelpers { getAxePath: () => string | null; getBundledAxeEnvironment: () => Record; - createAxeNotAvailableResponse: () => ToolResponse; } const LOG_PREFIX = '[AXe]'; @@ -63,22 +56,23 @@ export async function long_pressLogic( axeHelpers: AxeHelpers = { getAxePath, getBundledAxeEnvironment, - createAxeNotAvailableResponse, }, debuggerManager: DebuggerManager = getDefaultDebuggerManager(), ): Promise { const toolName = 'long_press'; const { simulatorId, x, y, duration } = params; + const headerEvent = header('Long Press', [{ label: 'Simulator', value: simulatorId }]); + const guard = await guardUiAutomationAgainstStoppedDebugger({ debugger: debuggerManager, simulatorId, toolName, }); - if (guard.blockedResponse) return guard.blockedResponse; + if (guard.blockedMessage) + return toolResponse([headerEvent, statusLine('error', guard.blockedMessage)]); - // AXe uses touch command with --down, --up, and --delay for long press - const delayInSeconds = Number(duration) / 1000; // Convert ms to seconds + const delayInSeconds = Number(duration) / 1000; const commandArgs = [ 'touch', '-x', @@ -101,32 +95,40 @@ export async function long_pressLogic( log('info', `${LOG_PREFIX}/${toolName}: Success for ${simulatorId}`); const coordinateWarning = getSnapshotUiWarning(simulatorId); - const message = `Long press at (${x}, ${y}) for ${duration}ms simulated successfully.`; - const warnings = [guard.warningText, coordinateWarning].filter(Boolean).join('\n\n'); - - if (warnings) { - return createTextResponse(`${message}\n\n${warnings}`); - } - - return createTextResponse(message); + const warnings = [guard.warningText, coordinateWarning].filter(Boolean); + const events = [ + headerEvent, + statusLine('success', `Long press at (${x}, ${y}) for ${duration}ms simulated successfully.`), + ...warnings.map((w) => statusLine('warning' as const, w)), + ]; + + return toolResponse(events); } catch (error) { log('error', `${LOG_PREFIX}/${toolName}: Failed - ${error}`); if (error instanceof DependencyError) { - return axeHelpers.createAxeNotAvailableResponse(); + return toolResponse([headerEvent, statusLine('error', AXE_NOT_AVAILABLE_MESSAGE)]); } else if (error instanceof AxeError) { - return createErrorResponse( - `Failed to simulate long press at (${x}, ${y}): ${error.message}`, - error.axeOutput, - ); + return toolResponse([ + headerEvent, + statusLine('error', `Failed to simulate long press at (${x}, ${y}): ${error.message}`), + ...(error.axeOutput ? [section('Details', [error.axeOutput])] : []), + ]); } else if (error instanceof SystemError) { - return createErrorResponse( - `System error executing axe: ${error.message}`, - error.originalError?.stack, - ); + return toolResponse([ + headerEvent, + statusLine('error', `System error executing axe: ${error.message}`), + ...(error.originalError?.stack + ? [section('Stack Trace', [error.originalError.stack])] + : []), + ]); } - return createErrorResponse( - `An unexpected error occurred: ${error instanceof Error ? error.message : String(error)}`, - ); + return toolResponse([ + headerEvent, + statusLine( + 'error', + `An unexpected error occurred: ${error instanceof Error ? error.message : String(error)}`, + ), + ]); } } @@ -141,7 +143,6 @@ export const handler = createSessionAwareTool({ long_pressLogic(params, executor, { getAxePath, getBundledAxeEnvironment, - createAxeNotAvailableResponse, }), getExecutor: getDefaultCommandExecutor, requirements: [{ allOf: ['simulatorId'], message: 'simulatorId is required' }], @@ -153,7 +154,7 @@ async function executeAxeCommand( simulatorId: string, commandName: string, executor: CommandExecutor = getDefaultCommandExecutor(), - axeHelpers: AxeHelpers = { getAxePath, getBundledAxeEnvironment, createAxeNotAvailableResponse }, + axeHelpers: AxeHelpers = { getAxePath, getBundledAxeEnvironment }, ): Promise { // Get the appropriate axe binary path const axeBinary = axeHelpers.getAxePath(); diff --git a/src/mcp/tools/ui-automation/screenshot.ts b/src/mcp/tools/ui-automation/screenshot.ts index 5e4000cd..779b9d8c 100644 --- a/src/mcp/tools/ui-automation/screenshot.ts +++ b/src/mcp/tools/ui-automation/screenshot.ts @@ -13,11 +13,7 @@ import { v4 as uuidv4 } from 'uuid'; import type { ToolResponse } from '../../../types/common.ts'; import { createImageContent } from '../../../types/common.ts'; import { log } from '../../../utils/logging/index.ts'; -import { - createErrorResponse, - createTextResponse, - SystemError, -} from '../../../utils/responses/index.ts'; +import { SystemError } from '../../../utils/errors.ts'; import type { CommandExecutor, FileSystemExecutor } from '../../../utils/execution/index.ts'; import { getDefaultFileSystemExecutor, @@ -27,6 +23,8 @@ import { createSessionAwareTool, getSessionAwareToolSchemaShape, } from '../../../utils/typed-tool-factory.ts'; +import { toolResponse } from '../../../utils/tool-response.ts'; +import { header, statusLine } from '../../../utils/tool-event-builders.ts'; const LOG_PREFIX = '[Screenshot]'; @@ -175,7 +173,6 @@ export async function rotateImage( } } -// Define schema as ZodObject const screenshotSchema = z.object({ simulatorId: z.uuid({ message: 'Invalid Simulator UUID format' }), returnFormat: z @@ -184,7 +181,6 @@ const screenshotSchema = z.object({ .describe('Return image path or base64 data (path|base64)'), }); -// Use z.infer for type safety type ScreenshotParams = z.infer; const publicSchemaObject = z.strictObject( @@ -199,6 +195,7 @@ export async function screenshotLogic( uuidUtils: { v4: () => string } = { v4: uuidv4 }, ): Promise { const { simulatorId } = params; + const headerEvent = header('Screenshot', [{ label: 'Simulator', value: simulatorId }]); const runtime = process.env.XCODEBUILDMCP_RUNTIME; const defaultFormat = runtime === 'cli' || runtime === 'daemon' ? 'path' : 'base64'; const returnFormat = params.returnFormat ?? defaultFormat; @@ -207,7 +204,6 @@ export async function screenshotLogic( const screenshotPath = pathUtils.join(tempDir, screenshotFilename); const optimizedFilename = `screenshot_optimized_${uuidUtils.v4()}.jpg`; const optimizedPath = pathUtils.join(tempDir, optimizedFilename); - // Use xcrun simctl to take screenshot const commandArgs: string[] = [ 'xcrun', 'simctl', @@ -220,7 +216,6 @@ export async function screenshotLogic( log('info', `${LOG_PREFIX}/screenshot: Starting capture to ${screenshotPath} on ${simulatorId}`); try { - // Execute the screenshot command const result = await executor(commandArgs, `${LOG_PREFIX}: screenshot`, false); if (!result.success) { @@ -230,30 +225,26 @@ export async function screenshotLogic( log('info', `${LOG_PREFIX}/screenshot: Success for ${simulatorId}`); try { - // Fix landscape orientation: simctl captures in portrait orientation regardless of device rotation - // Get device name to identify the correct simulator window when multiple are open const deviceName = await getDeviceNameForSimulatorId(simulatorId, executor); - // Detect if simulator window is landscape and rotate the image +90ยฐ to correct const isLandscape = await detectLandscapeMode(executor, deviceName ?? undefined); if (isLandscape) { - log('info', `${LOG_PREFIX}/screenshot: Landscape mode detected, rotating +90ยฐ`); + log('info', `${LOG_PREFIX}/screenshot: Landscape mode detected, rotating +90`); const rotated = await rotateImage(screenshotPath, 90, executor); if (!rotated) { log('warn', `${LOG_PREFIX}/screenshot: Rotation failed, continuing with original`); } } - // Optimize the image for LLM consumption: resize to max 800px width and convert to JPEG const optimizeArgs = [ 'sips', '-Z', - '800', // Resize to max 800px (maintains aspect ratio) + '800', '-s', 'format', - 'jpeg', // Convert to JPEG + 'jpeg', '-s', 'formatOptions', - '75', // 75% quality compression + '75', screenshotPath, '--out', optimizedPath, @@ -264,10 +255,8 @@ export async function screenshotLogic( if (!optimizeResult.success) { log('warn', `${LOG_PREFIX}/screenshot: Image optimization failed, using original PNG`); if (returnFormat === 'base64') { - // Fallback to original PNG if optimization fails const base64Image = await fileSystemExecutor.readFile(screenshotPath, 'base64'); - // Clean up try { await fileSystemExecutor.rm(screenshotPath); } catch (err) { @@ -280,20 +269,23 @@ export async function screenshotLogic( }; } - return createTextResponse( - `Screenshot captured: ${screenshotPath} (image/png, optimization failed)`, - ); + const textResponse = toolResponse([ + headerEvent, + statusLine( + 'success', + `Screenshot captured: ${screenshotPath} (image/png, optimization failed)`, + ), + ]); + return textResponse; } log('info', `${LOG_PREFIX}/screenshot: Image optimized successfully`); if (returnFormat === 'base64') { - // Read the optimized image file as base64 const base64Image = await fileSystemExecutor.readFile(optimizedPath, 'base64'); log('info', `${LOG_PREFIX}/screenshot: Successfully encoded image as Base64`); - // Clean up both temporary files try { await fileSystemExecutor.rm(screenshotPath); await fileSystemExecutor.rm(optimizedPath); @@ -301,38 +293,49 @@ export async function screenshotLogic( log('warn', `${LOG_PREFIX}/screenshot: Failed to delete temporary files: ${err}`); } - // Return the optimized image (JPEG format, smaller size) - return { - content: [createImageContent(base64Image, 'image/jpeg')], - isError: false, - }; + const textResponse = toolResponse([ + headerEvent, + statusLine('success', 'Screenshot captured.'), + ]); + textResponse.content.push(createImageContent(base64Image, 'image/jpeg')); + return textResponse; } - // Keep optimized file on disk for path-based return try { await fileSystemExecutor.rm(screenshotPath); } catch (err) { log('warn', `${LOG_PREFIX}/screenshot: Failed to delete temp file: ${err}`); } - return createTextResponse(`Screenshot captured: ${optimizedPath} (image/jpeg)`); + return toolResponse([ + headerEvent, + statusLine('success', `Screenshot captured: ${optimizedPath} (image/jpeg)`), + ]); } catch (fileError) { log('error', `${LOG_PREFIX}/screenshot: Failed to process image file: ${fileError}`); - return createErrorResponse( - `Screenshot captured but failed to process image file: ${fileError instanceof Error ? fileError.message : String(fileError)}`, - ); + return toolResponse([ + headerEvent, + statusLine( + 'error', + `Screenshot captured but failed to process image file: ${fileError instanceof Error ? fileError.message : String(fileError)}`, + ), + ]); } } catch (_error) { log('error', `${LOG_PREFIX}/screenshot: Failed - ${_error}`); if (_error instanceof SystemError) { - return createErrorResponse( - `System error executing screenshot: ${_error.message}`, - _error.originalError?.stack, - ); + return toolResponse([ + headerEvent, + statusLine('error', `System error executing screenshot: ${_error.message}`), + ]); } - return createErrorResponse( - `An unexpected error occurred: ${_error instanceof Error ? _error.message : String(_error)}`, - ); + return toolResponse([ + headerEvent, + statusLine( + 'error', + `An unexpected error occurred: ${_error instanceof Error ? _error.message : String(_error)}`, + ), + ]); } } diff --git a/src/mcp/tools/ui-automation/snapshot_ui.ts b/src/mcp/tools/ui-automation/snapshot_ui.ts index 6d07efaf..2d4ed545 100644 --- a/src/mcp/tools/ui-automation/snapshot_ui.ts +++ b/src/mcp/tools/ui-automation/snapshot_ui.ts @@ -1,7 +1,6 @@ import * as z from 'zod'; import type { ToolResponse } from '../../../types/common.ts'; import { log } from '../../../utils/logging/index.ts'; -import { createErrorResponse } from '../../../utils/responses/index.ts'; import { DependencyError, AxeError, SystemError } from '../../../utils/errors.ts'; import type { CommandExecutor } from '../../../utils/execution/index.ts'; import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; @@ -9,28 +8,27 @@ import { getDefaultDebuggerManager } from '../../../utils/debugger/index.ts'; import type { DebuggerManager } from '../../../utils/debugger/debugger-manager.ts'; import { guardUiAutomationAgainstStoppedDebugger } from '../../../utils/debugger/ui-automation-guard.ts'; import { - createAxeNotAvailableResponse, getAxePath, getBundledAxeEnvironment, + AXE_NOT_AVAILABLE_MESSAGE, } from '../../../utils/axe-helpers.ts'; import { createSessionAwareTool, getSessionAwareToolSchemaShape, } from '../../../utils/typed-tool-factory.ts'; import { recordSnapshotUiCall } from './shared/snapshot-ui-state.ts'; +import { toolResponse } from '../../../utils/tool-response.ts'; +import { header, statusLine, section } from '../../../utils/tool-event-builders.ts'; -// Define schema as ZodObject const snapshotUiSchema = z.object({ simulatorId: z.uuid({ message: 'Invalid Simulator UUID format' }), }); -// Use z.infer for type safety type SnapshotUiParams = z.infer; export interface AxeHelpers { getAxePath: () => string | null; getBundledAxeEnvironment: () => Record; - createAxeNotAvailableResponse: () => ToolResponse; } const LOG_PREFIX = '[AXe]'; @@ -44,7 +42,6 @@ export async function snapshot_uiLogic( axeHelpers: AxeHelpers = { getAxePath, getBundledAxeEnvironment, - createAxeNotAvailableResponse, }, debuggerManager: DebuggerManager = getDefaultDebuggerManager(), ): Promise { @@ -52,12 +49,15 @@ export async function snapshot_uiLogic( const { simulatorId } = params; const commandArgs = ['describe-ui']; + const headerEvent = header('Snapshot UI', [{ label: 'Simulator', value: simulatorId }]); + const guard = await guardUiAutomationAgainstStoppedDebugger({ debugger: debuggerManager, simulatorId, toolName, }); - if (guard.blockedResponse) return guard.blockedResponse; + if (guard.blockedMessage) + return toolResponse([headerEvent, statusLine('error', guard.blockedMessage)]); log('info', `${LOG_PREFIX}/${toolName}: Starting for ${simulatorId}`); @@ -70,50 +70,53 @@ export async function snapshot_uiLogic( axeHelpers, ); - // Record the snapshot_ui call for warning system recordSnapshotUiCall(simulatorId); log('info', `${LOG_PREFIX}/${toolName}: Success for ${simulatorId}`); - const response: ToolResponse = { - content: [ - { - type: 'text', - text: - 'Accessibility hierarchy retrieved successfully:\n```json\n' + responseText + '\n```', - }, - { - type: 'text', - text: `Tips:\n- Use frame coordinates for tap/swipe (center: x+width/2, y+height/2)\n- If a debugger is attached, ensure the app is running (not stopped on breakpoints)\n- Screenshots are for visual verification only`, - }, - ], + const events = [ + headerEvent, + statusLine('success', 'Accessibility hierarchy retrieved successfully.'), + section('Accessibility Hierarchy', ['```json', responseText, '```']), + section('Tips', [ + '- Use frame coordinates for tap/swipe (center: x+width/2, y+height/2)', + '- If a debugger is attached, ensure the app is running (not stopped on breakpoints)', + '- Screenshots are for visual verification only', + ]), + ...(guard.warningText ? [statusLine('warning' as const, guard.warningText)] : []), + ]; + return toolResponse(events, { nextStepParams: { snapshot_ui: { simulatorId }, tap: { simulatorId, x: 0, y: 0 }, screenshot: { simulatorId }, }, - }; - if (guard.warningText) { - response.content.push({ type: 'text', text: guard.warningText }); - } - return response; + }); } catch (error) { log('error', `${LOG_PREFIX}/${toolName}: Failed - ${error}`); if (error instanceof DependencyError) { - return axeHelpers.createAxeNotAvailableResponse(); + return toolResponse([headerEvent, statusLine('error', AXE_NOT_AVAILABLE_MESSAGE)]); } else if (error instanceof AxeError) { - return createErrorResponse( - `Failed to get accessibility hierarchy: ${error.message}`, - error.axeOutput, - ); + return toolResponse([ + headerEvent, + statusLine('error', `Failed to get accessibility hierarchy: ${error.message}`), + ...(error.axeOutput ? [section('Details', [error.axeOutput])] : []), + ]); } else if (error instanceof SystemError) { - return createErrorResponse( - `System error executing axe: ${error.message}`, - error.originalError?.stack, - ); + return toolResponse([ + headerEvent, + statusLine('error', `System error executing axe: ${error.message}`), + ...(error.originalError?.stack + ? [section('Stack Trace', [error.originalError.stack])] + : []), + ]); } - return createErrorResponse( - `An unexpected error occurred: ${error instanceof Error ? error.message : String(error)}`, - ); + return toolResponse([ + headerEvent, + statusLine( + 'error', + `An unexpected error occurred: ${error instanceof Error ? error.message : String(error)}`, + ), + ]); } } @@ -132,7 +135,6 @@ export const handler = createSessionAwareTool({ snapshot_uiLogic(params, executor, { getAxePath, getBundledAxeEnvironment, - createAxeNotAvailableResponse, }), getExecutor: getDefaultCommandExecutor, requirements: [{ allOf: ['simulatorId'], message: 'simulatorId is required' }], @@ -144,7 +146,7 @@ async function executeAxeCommand( simulatorId: string, commandName: string, executor: CommandExecutor = getDefaultCommandExecutor(), - axeHelpers: AxeHelpers = { getAxePath, getBundledAxeEnvironment, createAxeNotAvailableResponse }, + axeHelpers: AxeHelpers = { getAxePath, getBundledAxeEnvironment }, ): Promise { // Get the appropriate axe binary path const axeBinary = axeHelpers.getAxePath(); diff --git a/src/mcp/tools/ui-automation/swipe.ts b/src/mcp/tools/ui-automation/swipe.ts index 58672d8a..bea02eb7 100644 --- a/src/mcp/tools/ui-automation/swipe.ts +++ b/src/mcp/tools/ui-automation/swipe.ts @@ -7,7 +7,6 @@ import * as z from 'zod'; import type { ToolResponse } from '../../../types/common.ts'; import { log } from '../../../utils/logging/index.ts'; -import { createTextResponse, createErrorResponse } from '../../../utils/responses/index.ts'; import { DependencyError, AxeError, SystemError } from '../../../utils/errors.ts'; import type { CommandExecutor } from '../../../utils/execution/index.ts'; import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; @@ -15,17 +14,18 @@ import { getDefaultDebuggerManager } from '../../../utils/debugger/index.ts'; import type { DebuggerManager } from '../../../utils/debugger/debugger-manager.ts'; import { guardUiAutomationAgainstStoppedDebugger } from '../../../utils/debugger/ui-automation-guard.ts'; import { - createAxeNotAvailableResponse, getAxePath, getBundledAxeEnvironment, + AXE_NOT_AVAILABLE_MESSAGE, } from '../../../utils/axe-helpers.ts'; import { createSessionAwareTool, getSessionAwareToolSchemaShape, } from '../../../utils/typed-tool-factory.ts'; import { getSnapshotUiWarning } from './shared/snapshot-ui-state.ts'; +import { toolResponse } from '../../../utils/tool-response.ts'; +import { header, statusLine, section } from '../../../utils/tool-event-builders.ts'; -// Define schema as ZodObject const swipeSchema = z.object({ simulatorId: z.uuid({ message: 'Invalid Simulator UUID format' }), x1: z.number().int({ message: 'Start X coordinate' }), @@ -50,7 +50,6 @@ const swipeSchema = z.object({ .describe('seconds'), }); -// Use z.infer for type safety export type SwipeParams = z.infer; const publicSchemaObject = z.strictObject(swipeSchema.omit({ simulatorId: true } as const).shape); @@ -58,7 +57,6 @@ const publicSchemaObject = z.strictObject(swipeSchema.omit({ simulatorId: true } export interface AxeHelpers { getAxePath: () => string | null; getBundledAxeEnvironment: () => Record; - createAxeNotAvailableResponse: () => ToolResponse; } const LOG_PREFIX = '[AXe]'; @@ -72,19 +70,21 @@ export async function swipeLogic( axeHelpers: AxeHelpers = { getAxePath, getBundledAxeEnvironment, - createAxeNotAvailableResponse, }, debuggerManager: DebuggerManager = getDefaultDebuggerManager(), ): Promise { const toolName = 'swipe'; const { simulatorId, x1, y1, x2, y2, duration, delta, preDelay, postDelay } = params; + const headerEvent = header('Swipe', [{ label: 'Simulator', value: simulatorId }]); + const guard = await guardUiAutomationAgainstStoppedDebugger({ debugger: debuggerManager, simulatorId, toolName, }); - if (guard.blockedResponse) return guard.blockedResponse; + if (guard.blockedMessage) + return toolResponse([headerEvent, statusLine('error', guard.blockedMessage)]); const commandArgs = [ 'swipe', @@ -121,29 +121,43 @@ export async function swipeLogic( log('info', `${LOG_PREFIX}/${toolName}: Success for ${simulatorId}`); const coordinateWarning = getSnapshotUiWarning(simulatorId); - const message = `Swipe from (${x1}, ${y1}) to (${x2}, ${y2})${optionsText} simulated successfully.`; - const warnings = [guard.warningText, coordinateWarning].filter(Boolean).join('\n\n'); - - if (warnings) { - return createTextResponse(`${message}\n\n${warnings}`); - } - - return createTextResponse(message); + const warnings = [guard.warningText, coordinateWarning].filter(Boolean); + const events = [ + headerEvent, + statusLine( + 'success', + `Swipe from (${x1}, ${y1}) to (${x2}, ${y2})${optionsText} simulated successfully.`, + ), + ...warnings.map((w) => statusLine('warning' as const, w)), + ]; + + return toolResponse(events); } catch (error) { log('error', `${LOG_PREFIX}/${toolName}: Failed - ${error}`); if (error instanceof DependencyError) { - return axeHelpers.createAxeNotAvailableResponse(); + return toolResponse([headerEvent, statusLine('error', AXE_NOT_AVAILABLE_MESSAGE)]); } else if (error instanceof AxeError) { - return createErrorResponse(`Failed to simulate swipe: ${error.message}`, error.axeOutput); + return toolResponse([ + headerEvent, + statusLine('error', `Failed to simulate swipe: ${error.message}`), + ...(error.axeOutput ? [section('Details', [error.axeOutput])] : []), + ]); } else if (error instanceof SystemError) { - return createErrorResponse( - `System error executing axe: ${error.message}`, - error.originalError?.stack, - ); + return toolResponse([ + headerEvent, + statusLine('error', `System error executing axe: ${error.message}`), + ...(error.originalError?.stack + ? [section('Stack Trace', [error.originalError.stack])] + : []), + ]); } - return createErrorResponse( - `An unexpected error occurred: ${error instanceof Error ? error.message : String(error)}`, - ); + return toolResponse([ + headerEvent, + statusLine( + 'error', + `An unexpected error occurred: ${error instanceof Error ? error.message : String(error)}`, + ), + ]); } } @@ -158,7 +172,6 @@ export const handler = createSessionAwareTool({ swipeLogic(params, executor, { getAxePath, getBundledAxeEnvironment, - createAxeNotAvailableResponse, }), getExecutor: getDefaultCommandExecutor, requirements: [{ allOf: ['simulatorId'], message: 'simulatorId is required' }], @@ -170,7 +183,7 @@ async function executeAxeCommand( simulatorId: string, commandName: string, executor: CommandExecutor = getDefaultCommandExecutor(), - axeHelpers: AxeHelpers = { getAxePath, getBundledAxeEnvironment, createAxeNotAvailableResponse }, + axeHelpers: AxeHelpers = { getAxePath, getBundledAxeEnvironment }, ): Promise { // Get the appropriate axe binary path const axeBinary = axeHelpers.getAxePath(); diff --git a/src/mcp/tools/ui-automation/tap.ts b/src/mcp/tools/ui-automation/tap.ts index f033b592..6c469223 100644 --- a/src/mcp/tools/ui-automation/tap.ts +++ b/src/mcp/tools/ui-automation/tap.ts @@ -1,16 +1,15 @@ import * as z from 'zod'; import type { ToolResponse } from '../../../types/common.ts'; import { log } from '../../../utils/logging/index.ts'; -import { createTextResponse, createErrorResponse } from '../../../utils/responses/index.ts'; import type { CommandExecutor } from '../../../utils/execution/index.ts'; import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; import { getDefaultDebuggerManager } from '../../../utils/debugger/index.ts'; import type { DebuggerManager } from '../../../utils/debugger/debugger-manager.ts'; import { guardUiAutomationAgainstStoppedDebugger } from '../../../utils/debugger/ui-automation-guard.ts'; import { - createAxeNotAvailableResponse, getAxePath, getBundledAxeEnvironment, + AXE_NOT_AVAILABLE_MESSAGE, } from '../../../utils/axe-helpers.ts'; import { DependencyError, AxeError, SystemError } from '../../../utils/errors.ts'; import { @@ -18,14 +17,14 @@ import { getSessionAwareToolSchemaShape, } from '../../../utils/typed-tool-factory.ts'; import { getSnapshotUiWarning } from './shared/snapshot-ui-state.ts'; +import { toolResponse } from '../../../utils/tool-response.ts'; +import { header, statusLine, section } from '../../../utils/tool-event-builders.ts'; export interface AxeHelpers { getAxePath: () => string | null; getBundledAxeEnvironment: () => Record; - createAxeNotAvailableResponse: () => ToolResponse; } -// Define schema as ZodObject const baseTapSchema = z.object({ simulatorId: z.uuid({ message: 'Invalid Simulator UUID format' }), x: z @@ -104,7 +103,6 @@ const tapSchema = baseTapSchema.superRefine((values, ctx) => { } }); -// Use z.infer for type safety type TapParams = z.infer; const publicSchemaObject = z.strictObject(baseTapSchema.omit({ simulatorId: true } as const).shape); @@ -117,19 +115,21 @@ export async function tapLogic( axeHelpers: AxeHelpers = { getAxePath, getBundledAxeEnvironment, - createAxeNotAvailableResponse, }, debuggerManager: DebuggerManager = getDefaultDebuggerManager(), ): Promise { const toolName = 'tap'; const { simulatorId, x, y, id, label, preDelay, postDelay } = params; + const headerEvent = header('Tap', [{ label: 'Simulator', value: simulatorId }]); + const guard = await guardUiAutomationAgainstStoppedDebugger({ debugger: debuggerManager, simulatorId, toolName, }); - if (guard.blockedResponse) return guard.blockedResponse; + if (guard.blockedMessage) + return toolResponse([headerEvent, statusLine('error', guard.blockedMessage)]); let targetDescription = ''; let actionDescription = ''; @@ -150,10 +150,10 @@ export async function tapLogic( actionDescription = `Tap on ${targetDescription}`; commandArgs.push('--label', label); } else { - return createErrorResponse( - 'Parameter validation failed', - 'Invalid parameters:\nroot: Missing tap target', - ); + return toolResponse([ + headerEvent, + statusLine('error', 'Parameter validation failed: Missing tap target'), + ]); } if (preDelay !== undefined) { @@ -170,33 +170,41 @@ export async function tapLogic( log('info', `${LOG_PREFIX}/${toolName}: Success for ${simulatorId}`); const coordinateWarning = usesCoordinates ? getSnapshotUiWarning(simulatorId) : null; - const message = `${actionDescription} simulated successfully.`; - const warnings = [guard.warningText, coordinateWarning].filter(Boolean).join('\n\n'); - - if (warnings) { - return createTextResponse(`${message}\n\n${warnings}`); - } + const warnings = [guard.warningText, coordinateWarning].filter(Boolean); + const events = [ + headerEvent, + statusLine('success', `${actionDescription} simulated successfully.`), + ...warnings.map((w) => statusLine('warning' as const, w)), + ]; - return createTextResponse(message); + return toolResponse(events); } catch (error: unknown) { const errorMessage = error instanceof Error ? error.message : String(error); log('error', `${LOG_PREFIX}/${toolName}: Failed - ${errorMessage}`); if (error instanceof DependencyError) { - return axeHelpers.createAxeNotAvailableResponse(); + return toolResponse([headerEvent, statusLine('error', AXE_NOT_AVAILABLE_MESSAGE)]); } else if (error instanceof AxeError) { - return createErrorResponse( - `Failed to simulate ${actionDescription.toLowerCase()}: ${error.message}`, - error.axeOutput, - ); + return toolResponse([ + headerEvent, + statusLine( + 'error', + `Failed to simulate ${actionDescription.toLowerCase()}: ${error.message}`, + ), + ...(error.axeOutput ? [section('Details', [error.axeOutput])] : []), + ]); } else if (error instanceof SystemError) { - return createErrorResponse( - `System error executing axe: ${error.message}`, - error.originalError?.stack, - ); + return toolResponse([ + headerEvent, + statusLine('error', `System error executing axe: ${error.message}`), + ...(error.originalError?.stack + ? [section('Stack Trace', [error.originalError.stack])] + : []), + ]); } - return createErrorResponse( - `An unexpected error occurred: ${error instanceof Error ? error.message : String(error)}`, - ); + return toolResponse([ + headerEvent, + statusLine('error', `An unexpected error occurred: ${errorMessage}`), + ]); } } @@ -211,7 +219,6 @@ export const handler = createSessionAwareTool({ tapLogic(params, executor, { getAxePath, getBundledAxeEnvironment, - createAxeNotAvailableResponse, }), getExecutor: getDefaultCommandExecutor, requirements: [{ allOf: ['simulatorId'], message: 'simulatorId is required' }], @@ -223,7 +230,7 @@ async function executeAxeCommand( simulatorId: string, commandName: string, executor: CommandExecutor = getDefaultCommandExecutor(), - axeHelpers: AxeHelpers = { getAxePath, getBundledAxeEnvironment, createAxeNotAvailableResponse }, + axeHelpers: AxeHelpers = { getAxePath, getBundledAxeEnvironment }, ): Promise { // Get the appropriate axe binary path const axeBinary = axeHelpers.getAxePath(); diff --git a/src/mcp/tools/ui-automation/touch.ts b/src/mcp/tools/ui-automation/touch.ts index beab8c71..e5925eec 100644 --- a/src/mcp/tools/ui-automation/touch.ts +++ b/src/mcp/tools/ui-automation/touch.ts @@ -7,7 +7,6 @@ import * as z from 'zod'; import { log } from '../../../utils/logging/index.ts'; -import { createTextResponse, createErrorResponse } from '../../../utils/responses/index.ts'; import { DependencyError, AxeError, SystemError } from '../../../utils/errors.ts'; import type { CommandExecutor } from '../../../utils/execution/index.ts'; import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; @@ -15,9 +14,9 @@ import { getDefaultDebuggerManager } from '../../../utils/debugger/index.ts'; import type { DebuggerManager } from '../../../utils/debugger/debugger-manager.ts'; import { guardUiAutomationAgainstStoppedDebugger } from '../../../utils/debugger/ui-automation-guard.ts'; import { - createAxeNotAvailableResponse, getAxePath, getBundledAxeEnvironment, + AXE_NOT_AVAILABLE_MESSAGE, } from '../../../utils/axe-helpers.ts'; import type { ToolResponse } from '../../../types/common.ts'; import { @@ -25,8 +24,9 @@ import { getSessionAwareToolSchemaShape, } from '../../../utils/typed-tool-factory.ts'; import { getSnapshotUiWarning } from './shared/snapshot-ui-state.ts'; +import { toolResponse } from '../../../utils/tool-response.ts'; +import { header, statusLine, section } from '../../../utils/tool-event-builders.ts'; -// Define schema as ZodObject const touchSchema = z.object({ simulatorId: z.uuid({ message: 'Invalid Simulator UUID format' }), x: z.number().int({ message: 'X coordinate must be an integer' }), @@ -40,7 +40,6 @@ const touchSchema = z.object({ .describe('seconds'), }); -// Use z.infer for type safety type TouchParams = z.infer; const publicSchemaObject = z.strictObject(touchSchema.omit({ simulatorId: true } as const).shape); @@ -60,12 +59,14 @@ export async function touchLogic( ): Promise { const toolName = 'touch'; - // Params are already validated by createTypedTool - use directly const { simulatorId, x, y, down, up, delay } = params; + const headerEvent = header('Touch', [{ label: 'Simulator', value: simulatorId }]); - // Validate that at least one of down or up is specified if (!down && !up) { - return createErrorResponse('At least one of "down" or "up" must be true'); + return toolResponse([ + headerEvent, + statusLine('error', 'At least one of "down" or "up" must be true'), + ]); } const guard = await guardUiAutomationAgainstStoppedDebugger({ @@ -73,7 +74,8 @@ export async function touchLogic( simulatorId, toolName, }); - if (guard.blockedResponse) return guard.blockedResponse; + if (guard.blockedMessage) + return toolResponse([headerEvent, statusLine('error', guard.blockedMessage)]); const commandArgs = ['touch', '-x', String(x), '-y', String(y)]; if (down) { @@ -97,35 +99,43 @@ export async function touchLogic( log('info', `${LOG_PREFIX}/${toolName}: Success for ${simulatorId}`); const coordinateWarning = getSnapshotUiWarning(simulatorId); - const message = `Touch event (${actionText}) at (${x}, ${y}) executed successfully.`; - const warnings = [guard.warningText, coordinateWarning].filter(Boolean).join('\n\n'); - - if (warnings) { - return createTextResponse(`${message}\n\n${warnings}`); - } - - return createTextResponse(message); + const warnings = [guard.warningText, coordinateWarning].filter(Boolean); + const events = [ + headerEvent, + statusLine('success', `Touch event (${actionText}) at (${x}, ${y}) executed successfully.`), + ...warnings.map((w) => statusLine('warning' as const, w)), + ]; + + return toolResponse(events); } catch (error) { log( 'error', `${LOG_PREFIX}/${toolName}: Failed - ${error instanceof Error ? error.message : String(error)}`, ); if (error instanceof DependencyError) { - return createAxeNotAvailableResponse(); + return toolResponse([headerEvent, statusLine('error', AXE_NOT_AVAILABLE_MESSAGE)]); } else if (error instanceof AxeError) { - return createErrorResponse( - `Failed to execute touch event: ${error.message}`, - error.axeOutput, - ); + return toolResponse([ + headerEvent, + statusLine('error', `Failed to execute touch event: ${error.message}`), + ...(error.axeOutput ? [section('Details', [error.axeOutput])] : []), + ]); } else if (error instanceof SystemError) { - return createErrorResponse( - `System error executing axe: ${error.message}`, - error.originalError?.stack, - ); + return toolResponse([ + headerEvent, + statusLine('error', `System error executing axe: ${error.message}`), + ...(error.originalError?.stack + ? [section('Stack Trace', [error.originalError.stack])] + : []), + ]); } - return createErrorResponse( - `An unexpected error occurred: ${error instanceof Error ? error.message : String(error)}`, - ); + return toolResponse([ + headerEvent, + statusLine( + 'error', + `An unexpected error occurred: ${error instanceof Error ? error.message : String(error)}`, + ), + ]); } } diff --git a/src/mcp/tools/ui-automation/type_text.ts b/src/mcp/tools/ui-automation/type_text.ts index 1a1d6e85..ea18c0e6 100644 --- a/src/mcp/tools/ui-automation/type_text.ts +++ b/src/mcp/tools/ui-automation/type_text.ts @@ -8,7 +8,6 @@ import * as z from 'zod'; import type { ToolResponse } from '../../../types/common.ts'; import { log } from '../../../utils/logging/index.ts'; -import { createTextResponse, createErrorResponse } from '../../../utils/responses/index.ts'; import { DependencyError, AxeError, SystemError } from '../../../utils/errors.ts'; import type { CommandExecutor } from '../../../utils/execution/index.ts'; import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; @@ -16,24 +15,24 @@ import { getDefaultDebuggerManager } from '../../../utils/debugger/index.ts'; import type { DebuggerManager } from '../../../utils/debugger/debugger-manager.ts'; import { guardUiAutomationAgainstStoppedDebugger } from '../../../utils/debugger/ui-automation-guard.ts'; import { - createAxeNotAvailableResponse, getAxePath, getBundledAxeEnvironment, + AXE_NOT_AVAILABLE_MESSAGE, } from '../../../utils/axe-helpers.ts'; import { createSessionAwareTool, getSessionAwareToolSchemaShape, } from '../../../utils/typed-tool-factory.ts'; +import { toolResponse } from '../../../utils/tool-response.ts'; +import { header, statusLine, section } from '../../../utils/tool-event-builders.ts'; const LOG_PREFIX = '[AXe]'; -// Define schema as ZodObject const typeTextSchema = z.object({ simulatorId: z.uuid({ message: 'Invalid Simulator UUID format' }), text: z.string().min(1, { message: 'Text cannot be empty' }), }); -// Use z.infer for type safety type TypeTextParams = z.infer; const publicSchemaObject = z.strictObject( @@ -53,14 +52,16 @@ export async function type_textLogic( ): Promise { const toolName = 'type_text'; - // Params are already validated by the factory, use directly const { simulatorId, text } = params; + const headerEvent = header('Type Text', [{ label: 'Simulator', value: simulatorId }]); + const guard = await guardUiAutomationAgainstStoppedDebugger({ debugger: debuggerManager, simulatorId, toolName, }); - if (guard.blockedResponse) return guard.blockedResponse; + if (guard.blockedMessage) + return toolResponse([headerEvent, statusLine('error', guard.blockedMessage)]); const commandArgs = ['type', text]; @@ -72,32 +73,41 @@ export async function type_textLogic( try { await executeAxeCommand(commandArgs, simulatorId, 'type', executor, axeHelpers); log('info', `${LOG_PREFIX}/${toolName}: Success for ${simulatorId}`); - const message = 'Text typing simulated successfully.'; - if (guard.warningText) { - return createTextResponse(`${message}\n\n${guard.warningText}`); - } - return createTextResponse(message); + const events = [ + headerEvent, + statusLine('success', 'Text typing simulated successfully.'), + ...(guard.warningText ? [statusLine('warning' as const, guard.warningText)] : []), + ]; + return toolResponse(events); } catch (error) { log( 'error', `${LOG_PREFIX}/${toolName}: Failed - ${error instanceof Error ? error.message : String(error)}`, ); if (error instanceof DependencyError) { - return createAxeNotAvailableResponse(); + return toolResponse([headerEvent, statusLine('error', AXE_NOT_AVAILABLE_MESSAGE)]); } else if (error instanceof AxeError) { - return createErrorResponse( - `Failed to simulate text typing: ${error.message}`, - error.axeOutput, - ); + return toolResponse([ + headerEvent, + statusLine('error', `Failed to simulate text typing: ${error.message}`), + ...(error.axeOutput ? [section('Details', [error.axeOutput])] : []), + ]); } else if (error instanceof SystemError) { - return createErrorResponse( - `System error executing axe: ${error.message}`, - error.originalError?.stack, - ); + return toolResponse([ + headerEvent, + statusLine('error', `System error executing axe: ${error.message}`), + ...(error.originalError?.stack + ? [section('Stack Trace', [error.originalError.stack])] + : []), + ]); } - return createErrorResponse( - `An unexpected error occurred: ${error instanceof Error ? error.message : String(error)}`, - ); + return toolResponse([ + headerEvent, + statusLine( + 'error', + `An unexpected error occurred: ${error instanceof Error ? error.message : String(error)}`, + ), + ]); } } diff --git a/src/mcp/tools/utilities/clean.ts b/src/mcp/tools/utilities/clean.ts index 87289a59..553db55b 100644 --- a/src/mcp/tools/utilities/clean.ts +++ b/src/mcp/tools/utilities/clean.ts @@ -1,10 +1,3 @@ -/** - * Utilities Plugin: Clean (Unified) - * - * Cleans build products for either a project or workspace using xcodebuild. - * Accepts mutually exclusive `projectPath` or `workspacePath`. - */ - import * as z from 'zod'; import { createSessionAwareTool, @@ -15,13 +8,13 @@ import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; import { executeXcodeBuildCommand } from '../../../utils/build/index.ts'; import type { ToolResponse, SharedBuildParams } from '../../../types/common.ts'; import { XcodePlatform } from '../../../types/common.ts'; -import { createErrorResponse } from '../../../utils/responses/index.ts'; import { nullifyEmptyStrings } from '../../../utils/schema-helpers.ts'; +import { toolResponse } from '../../../utils/tool-response.ts'; +import { header, statusLine } from '../../../utils/tool-event-builders.ts'; import { startBuildPipeline } from '../../../utils/xcodebuild-pipeline.ts'; import { createPendingXcodebuildResponse } from '../../../utils/xcodebuild-output.ts'; import { formatToolPreflight } from '../../../utils/build-preflight.ts'; -// Unified schema: XOR between projectPath and workspacePath, sharing common options const baseOptions = { scheme: z.string().optional().describe('Optional: The scheme to clean'), configuration: z @@ -73,19 +66,15 @@ export async function cleanLogic( params: CleanParams, executor: CommandExecutor, ): Promise { - // Extra safety: ensure workspace path has a scheme (xcodebuild requires it) if (params.workspacePath && !params.scheme) { - return createErrorResponse( - 'Parameter validation failed', - 'Invalid parameters:\nscheme: scheme is required when workspacePath is provided.', - ); + return toolResponse([ + header('Clean'), + statusLine('error', 'scheme is required when workspacePath is provided.'), + ]); } - // Use provided platform or default to iOS const targetPlatform = params.platform ?? 'iOS'; - // Map human-friendly platform names to XcodePlatform enum values - // This is safer than direct key lookup and handles the space-containing simulator names const platformMap = { macOS: XcodePlatform.macOS, iOS: XcodePlatform.iOS, @@ -100,10 +89,10 @@ export async function cleanLogic( const platformEnum = platformMap[targetPlatform]; if (!platformEnum) { - return createErrorResponse( - 'Parameter validation failed', - `Invalid parameters:\nplatform: unsupported value "${targetPlatform}".`, - ); + return toolResponse([ + header('Clean'), + statusLine('error', `Unsupported platform: "${targetPlatform}".`), + ]); } const hasProjectPath = typeof params.projectPath === 'string'; @@ -111,17 +100,12 @@ export async function cleanLogic( ...(hasProjectPath ? { projectPath: params.projectPath as string } : { workspacePath: params.workspacePath as string }), - // scheme may be omitted for project; when omitted we do not pass -scheme - // Provide empty string to satisfy type, executeXcodeBuildCommand only emits -scheme when non-empty scheme: params.scheme ?? '', configuration: params.configuration ?? 'Debug', derivedDataPath: params.derivedDataPath, extraArgs: params.extraArgs, }; - // For clean operations, simulator platforms should be mapped to their device equivalents - // since clean works at the build product level, not runtime level, and build products - // are shared between device and simulator platforms const cleanPlatformMap: Partial> = { [XcodePlatform.iOSSimulator]: XcodePlatform.iOS, [XcodePlatform.watchOSSimulator]: XcodePlatform.watchOS, diff --git a/src/mcp/tools/workflow-discovery/__tests__/manage_workflows.test.ts b/src/mcp/tools/workflow-discovery/__tests__/manage_workflows.test.ts index e29b1eeb..60c75e0c 100644 --- a/src/mcp/tools/workflow-discovery/__tests__/manage_workflows.test.ts +++ b/src/mcp/tools/workflow-discovery/__tests__/manage_workflows.test.ts @@ -32,6 +32,13 @@ describe('manage_workflows tool', () => { vi.mocked(getRegisteredWorkflows).mockReset(); }); + function allText(result: { content: Array<{ type: string; text: string }> }): string { + return result.content + .filter((c) => c.type === 'text') + .map((c) => c.text) + .join('\n'); + } + it('merges new workflows with current set when enable is true', async () => { vi.mocked(getRegisteredWorkflows).mockReturnValue(['simulator']); vi.mocked(applyWorkflowSelectionFromManifest).mockResolvedValue({ @@ -49,7 +56,8 @@ describe('manage_workflows tool', () => { ['simulator', 'device'], expect.objectContaining({ runtime: 'mcp' }), ); - expect(result.content[0].text).toBe('Workflows enabled: simulator, device'); + const text = allText(result); + expect(text).toContain('Workflows enabled: simulator, device'); }); it('removes requested workflows when enable is false', async () => { @@ -69,7 +77,8 @@ describe('manage_workflows tool', () => { ['simulator'], expect.objectContaining({ runtime: 'mcp' }), ); - expect(result.content[0].text).toBe('Workflows enabled: simulator'); + const text = allText(result); + expect(text).toContain('Workflows enabled: simulator'); }); it('accepts workflowName as an array', async () => { diff --git a/src/mcp/tools/workflow-discovery/manage_workflows.ts b/src/mcp/tools/workflow-discovery/manage_workflows.ts index fc082c26..17510d2b 100644 --- a/src/mcp/tools/workflow-discovery/manage_workflows.ts +++ b/src/mcp/tools/workflow-discovery/manage_workflows.ts @@ -2,13 +2,14 @@ import * as z from 'zod'; import { nullifyEmptyStrings } from '../../../utils/schema-helpers.ts'; import { createTypedTool } from '../../../utils/typed-tool-factory.ts'; import { getDefaultCommandExecutor, type CommandExecutor } from '../../../utils/execution/index.ts'; -import { createTextResponse } from '../../../utils/responses/index.ts'; import type { ToolResponse } from '../../../types/common.ts'; import { applyWorkflowSelectionFromManifest, getRegisteredWorkflows, getMcpPredicateContext, } from '../../../utils/tool-registry.ts'; +import { toolResponse } from '../../../utils/tool-response.ts'; +import { header, statusLine, section } from '../../../utils/tool-event-builders.ts'; const baseSchemaObject = z.object({ workflowNames: z.array(z.string()).describe('Workflow directory name(s).'), @@ -39,7 +40,11 @@ export async function manage_workflowsLogic( const registryState = await applyWorkflowSelectionFromManifest(nextWorkflows, ctx); - return createTextResponse(`Workflows enabled: ${registryState.enabledWorkflows.join(', ')}`); + return toolResponse([ + header('Manage Workflows'), + section('Enabled Workflows', registryState.enabledWorkflows), + statusLine('success', `Workflows enabled: ${registryState.enabledWorkflows.join(', ')}`), + ]); } export const schema = baseSchemaObject.shape; diff --git a/src/mcp/tools/xcode-ide/__tests__/bridge_tools.test.ts b/src/mcp/tools/xcode-ide/__tests__/bridge_tools.test.ts index d825710c..8590c76a 100644 --- a/src/mcp/tools/xcode-ide/__tests__/bridge_tools.test.ts +++ b/src/mcp/tools/xcode-ide/__tests__/bridge_tools.test.ts @@ -36,6 +36,13 @@ import { } from '../../../../integrations/xcode-tools-bridge/core.ts'; describe('xcode-ide bridge tools (standalone fallback)', () => { + function textOf(result: { content: Array<{ type: string; text: string }> }): string { + return result.content + .filter((i) => i.type === 'text') + .map((i) => i.text) + .join('\n'); + } + beforeEach(async () => { await shutdownXcodeToolsBridge(); @@ -81,15 +88,17 @@ describe('xcode-ide bridge tools (standalone fallback)', () => { it('status handler returns bridge status without MCP server instance', async () => { const result = await statusHandler(); - const payload = JSON.parse(result.content[0].text as string); - expect(payload.bridgeAvailable).toBe(true); + const text = textOf(result); + expect(text).toContain('Bridge Status'); + expect(text).toContain('"bridgeAvailable": true'); expect(buildXcodeToolsBridgeStatus).toHaveBeenCalledOnce(); }); it('sync handler uses direct bridge client when MCP server is not initialized', async () => { const result = await syncHandler(); - const payload = JSON.parse(result.content[0].text as string); - expect(payload.sync.total).toBe(2); + const text = textOf(result); + expect(text).toContain('Bridge Sync'); + expect(text).toContain('"total": 2'); expect(clientMocks.connectOnce).toHaveBeenCalledOnce(); expect(clientMocks.listTools).toHaveBeenCalledOnce(); expect(clientMocks.disconnect).toHaveBeenCalledOnce(); @@ -97,16 +106,17 @@ describe('xcode-ide bridge tools (standalone fallback)', () => { it('disconnect handler succeeds without MCP server instance', async () => { const result = await disconnectHandler(); - const payload = JSON.parse(result.content[0].text as string); - expect(payload.connected).toBe(false); + const text = textOf(result); + expect(text).toContain('Bridge Disconnect'); + expect(text).toContain('"connected": false'); expect(clientMocks.disconnect).toHaveBeenCalledOnce(); }); it('list handler returns bridge tools without MCP server instance', async () => { const result = await listHandler({ refresh: true }); - const payload = JSON.parse(result.content[0].text as string); - expect(payload.toolCount).toBe(2); - expect(payload.tools).toHaveLength(2); + const text = textOf(result); + expect(text).toContain('Xcode IDE List Tools'); + expect(text).toContain('"toolCount": 2'); expect(clientMocks.listTools).toHaveBeenCalledOnce(); expect(clientMocks.disconnect).toHaveBeenCalledOnce(); }); diff --git a/src/mcp/tools/xcode-ide/__tests__/sync_xcode_defaults.test.ts b/src/mcp/tools/xcode-ide/__tests__/sync_xcode_defaults.test.ts index 5a1b9ff8..a1067eba 100644 --- a/src/mcp/tools/xcode-ide/__tests__/sync_xcode_defaults.test.ts +++ b/src/mcp/tools/xcode-ide/__tests__/sync_xcode_defaults.test.ts @@ -25,6 +25,13 @@ describe('sync_xcode_defaults tool', () => { }); describe('syncXcodeDefaultsLogic', () => { + function textOf(result: { content: Array<{ type: string; text: string }> }): string { + return result.content + .filter((i) => i.type === 'text') + .map((i) => i.text) + .join('\n'); + } + it('returns error when no project found', async () => { const executor = createCommandMatchingMockExecutor({ whoami: { output: 'testuser\n' }, @@ -34,7 +41,7 @@ describe('sync_xcode_defaults tool', () => { const result = await syncXcodeDefaultsLogic({}, { executor, cwd: '/test/project' }); expect(result.isError).toBe(true); - expect(result.content[0].text).toContain('Failed to read Xcode IDE state'); + expect(textOf(result)).toContain('Failed to read Xcode IDE state'); }); it('returns error when xcuserstate file not found', async () => { @@ -47,7 +54,7 @@ describe('sync_xcode_defaults tool', () => { const result = await syncXcodeDefaultsLogic({}, { executor, cwd: '/test/project' }); expect(result.isError).toBe(true); - expect(result.content[0].text).toContain('Failed to read Xcode IDE state'); + expect(textOf(result)).toContain('Failed to read Xcode IDE state'); }); }); @@ -78,14 +85,16 @@ describe('sync_xcode_defaults tool', () => { { executor, cwd: join(process.cwd(), 'example_projects/iOS') }, ); - expect(result.isError).toBe(false); - expect(result.content[0].text).toContain('Synced session defaults from Xcode IDE'); - expect(result.content[0].text).toContain('Scheme: MCPTest'); - expect(result.content[0].text).toContain( - 'Simulator ID: B38FE93D-578B-454B-BE9A-C6FA0CE5F096', - ); - expect(result.content[0].text).toContain('Simulator Name: Apple Vision Pro'); - expect(result.content[0].text).toContain('Bundle ID: io.sentry.MCPTest'); + expect(result.isError).toBeFalsy(); + const text = result.content + .filter((i: any) => i.type === 'text') + .map((i: any) => i.text) + .join('\n'); + expect(text).toContain('Synced session defaults from Xcode IDE'); + expect(text).toContain('scheme: MCPTest'); + expect(text).toContain('simulatorId: B38FE93D-578B-454B-BE9A-C6FA0CE5F096'); + expect(text).toContain('simulatorName: Apple Vision Pro'); + expect(text).toContain('bundleId: io.sentry.MCPTest'); const defaults = sessionStore.getAll(); expect(defaults.scheme).toBe('MCPTest'); @@ -120,8 +129,12 @@ describe('sync_xcode_defaults tool', () => { }, ); - expect(result.isError).toBe(false); - expect(result.content[0].text).toContain('Scheme: MCPTest'); + expect(result.isError).toBeFalsy(); + const text = result.content + .filter((i: any) => i.type === 'text') + .map((i: any) => i.text) + .join('\n'); + expect(text).toContain('scheme: MCPTest'); const defaults = sessionStore.getAll(); expect(defaults.scheme).toBe('MCPTest'); @@ -158,7 +171,7 @@ describe('sync_xcode_defaults tool', () => { { executor, cwd: join(process.cwd(), 'example_projects/iOS') }, ); - expect(result.isError).toBe(false); + expect(result.isError).toBeFalsy(); const defaults = sessionStore.getAll(); expect(defaults.scheme).toBe('MCPTest'); diff --git a/src/mcp/tools/xcode-ide/shared.ts b/src/mcp/tools/xcode-ide/shared.ts index 15de889d..eb7ab40a 100644 --- a/src/mcp/tools/xcode-ide/shared.ts +++ b/src/mcp/tools/xcode-ide/shared.ts @@ -2,14 +2,19 @@ import type { ToolResponse } from '../../../types/common.ts'; import type { XcodeToolsBridgeToolHandler } from '../../../integrations/xcode-tools-bridge/index.ts'; import { getServer } from '../../../server/server-state.ts'; import { getXcodeToolsBridgeToolHandler } from '../../../integrations/xcode-tools-bridge/index.ts'; -import { createErrorResponse } from '../../../utils/responses/index.ts'; +import { toolResponse } from '../../../utils/tool-response.ts'; +import { header, statusLine } from '../../../utils/tool-event-builders.ts'; export async function withBridgeToolHandler( + operation: string, callback: (bridge: XcodeToolsBridgeToolHandler) => Promise, ): Promise { const bridge = getXcodeToolsBridgeToolHandler(getServer()); if (!bridge) { - return createErrorResponse('Bridge unavailable', 'Unable to initialize xcode tools bridge'); + return toolResponse([ + header(operation), + statusLine('error', 'Unable to initialize xcode tools bridge'), + ]); } return callback(bridge); } diff --git a/src/mcp/tools/xcode-ide/sync_xcode_defaults.ts b/src/mcp/tools/xcode-ide/sync_xcode_defaults.ts index 4c9b91fb..a4a9e60a 100644 --- a/src/mcp/tools/xcode-ide/sync_xcode_defaults.ts +++ b/src/mcp/tools/xcode-ide/sync_xcode_defaults.ts @@ -1,13 +1,3 @@ -/** - * Sync Xcode Defaults Tool - * - * Reads Xcode's IDE state (active scheme and run destination) and updates - * session defaults to match. This allows the agent to re-sync if the user - * changes their selection in Xcode mid-session. - * - * Only visible when running under Xcode's coding agent. - */ - import type { ToolResponse } from '../../../types/common.ts'; import type { CommandExecutor } from '../../../utils/execution/index.ts'; import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; @@ -16,6 +6,8 @@ import { sessionStore } from '../../../utils/session-store.ts'; import { readXcodeIdeState } from '../../../utils/xcode-state-reader.ts'; import { lookupBundleId } from '../../../utils/xcode-state-watcher.ts'; import * as z from 'zod'; +import { toolResponse } from '../../../utils/tool-response.ts'; +import { header, statusLine, detailTree } from '../../../utils/tool-event-builders.ts'; const schemaObj = z.object({}); @@ -40,36 +32,26 @@ export async function syncXcodeDefaultsLogic( }); if (xcodeState.error) { - return { - content: [ - { - type: 'text', - text: `Failed to read Xcode IDE state: ${xcodeState.error}`, - }, - ], - isError: true, - }; + return toolResponse([ + header('Sync Xcode Defaults'), + statusLine('error', `Failed to read Xcode IDE state: ${xcodeState.error}`), + ]); } const synced: Record = {}; - const notices: string[] = []; if (xcodeState.scheme) { synced.scheme = xcodeState.scheme; - notices.push(`Scheme: ${xcodeState.scheme}`); } if (xcodeState.simulatorId) { synced.simulatorId = xcodeState.simulatorId; - notices.push(`Simulator ID: ${xcodeState.simulatorId}`); } if (xcodeState.simulatorName) { synced.simulatorName = xcodeState.simulatorName; - notices.push(`Simulator Name: ${xcodeState.simulatorName}`); } - // Look up bundle ID if we have a scheme if (xcodeState.scheme) { const bundleId = await lookupBundleId( ctx.executor, @@ -79,33 +61,25 @@ export async function syncXcodeDefaultsLogic( ); if (bundleId) { synced.bundleId = bundleId; - notices.push(`Bundle ID: ${bundleId}`); } } if (Object.keys(synced).length === 0) { - return { - content: [ - { - type: 'text', - text: 'No scheme or simulator selection detected in Xcode IDE state.', - }, - ], - isError: false, - }; + return toolResponse([ + header('Sync Xcode Defaults'), + statusLine('info', 'No scheme or simulator selection detected in Xcode IDE state.'), + ]); } sessionStore.setDefaults(synced); - return { - content: [ - { - type: 'text', - text: `Synced session defaults from Xcode IDE:\n- ${notices.join('\n- ')}`, - }, - ], - isError: false, - }; + const items = Object.entries(synced).map(([k, v]) => ({ label: k, value: v })); + + return toolResponse([ + header('Sync Xcode Defaults'), + detailTree(items), + statusLine('success', 'Synced session defaults from Xcode IDE.'), + ]); } export const schema = schemaObj.shape; diff --git a/src/mcp/tools/xcode-ide/xcode_ide_call_tool.ts b/src/mcp/tools/xcode-ide/xcode_ide_call_tool.ts index f15e2694..03a16f4b 100644 --- a/src/mcp/tools/xcode-ide/xcode_ide_call_tool.ts +++ b/src/mcp/tools/xcode-ide/xcode_ide_call_tool.ts @@ -1,8 +1,11 @@ import * as z from 'zod'; import type { ToolResponse } from '../../../types/common.ts'; -import { createErrorResponse } from '../../../utils/responses/index.ts'; +import { toolResponse } from '../../../utils/tool-response.ts'; +import { header, statusLine, section } from '../../../utils/tool-event-builders.ts'; import { withBridgeToolHandler } from './shared.ts'; +const OPERATION = 'Xcode IDE Call Tool'; + const schemaObject = z.object({ remoteTool: z.string().min(1).describe('Exact remote Xcode MCP tool name.'), arguments: z @@ -22,7 +25,7 @@ const schemaObject = z.object({ type Params = z.infer; export async function xcodeIdeCallToolLogic(params: Params): Promise { - return withBridgeToolHandler((bridge) => + return withBridgeToolHandler(OPERATION, (bridge) => bridge.callToolTool({ remoteTool: params.remoteTool, arguments: params.arguments ?? {}, @@ -36,13 +39,15 @@ export const schema = schemaObject.shape; export const handler = async (args: Record = {}): Promise => { const parsed = schemaObject.safeParse(args); if (!parsed.success) { - const details = parsed.error.issues - .map((issue) => { - const path = issue.path.length > 0 ? issue.path.join('.') : 'root'; - return `${path}: ${issue.message}`; - }) - .join('\n'); - return createErrorResponse('Parameter validation failed', details); + const details = parsed.error.issues.map((issue) => { + const path = issue.path.length > 0 ? issue.path.join('.') : 'root'; + return `${path}: ${issue.message}`; + }); + return toolResponse([ + header(OPERATION), + section('Validation Errors', details), + statusLine('error', 'Parameter validation failed'), + ]); } return xcodeIdeCallToolLogic(parsed.data); }; diff --git a/src/mcp/tools/xcode-ide/xcode_ide_list_tools.ts b/src/mcp/tools/xcode-ide/xcode_ide_list_tools.ts index 152d4715..dabb30ec 100644 --- a/src/mcp/tools/xcode-ide/xcode_ide_list_tools.ts +++ b/src/mcp/tools/xcode-ide/xcode_ide_list_tools.ts @@ -1,8 +1,11 @@ import * as z from 'zod'; import type { ToolResponse } from '../../../types/common.ts'; -import { createErrorResponse } from '../../../utils/responses/index.ts'; +import { toolResponse } from '../../../utils/tool-response.ts'; +import { header, statusLine, section } from '../../../utils/tool-event-builders.ts'; import { withBridgeToolHandler } from './shared.ts'; +const OPERATION = 'Xcode IDE List Tools'; + const schemaObject = z.object({ refresh: z .boolean() @@ -13,7 +16,9 @@ const schemaObject = z.object({ type Params = z.infer; export async function xcodeIdeListToolsLogic(params: Params): Promise { - return withBridgeToolHandler(async (bridge) => bridge.listToolsTool({ refresh: params.refresh })); + return withBridgeToolHandler(OPERATION, async (bridge) => + bridge.listToolsTool({ refresh: params.refresh }), + ); } export const schema = schemaObject.shape; @@ -21,13 +26,15 @@ export const schema = schemaObject.shape; export const handler = async (args: Record = {}): Promise => { const parsed = schemaObject.safeParse(args); if (!parsed.success) { - const details = parsed.error.issues - .map((issue) => { - const path = issue.path.length > 0 ? issue.path.join('.') : 'root'; - return `${path}: ${issue.message}`; - }) - .join('\n'); - return createErrorResponse('Parameter validation failed', details); + const details = parsed.error.issues.map((issue) => { + const path = issue.path.length > 0 ? issue.path.join('.') : 'root'; + return `${path}: ${issue.message}`; + }); + return toolResponse([ + header(OPERATION), + section('Validation Errors', details), + statusLine('error', 'Parameter validation failed'), + ]); } return xcodeIdeListToolsLogic(parsed.data); }; diff --git a/src/mcp/tools/xcode-ide/xcode_tools_bridge_disconnect.ts b/src/mcp/tools/xcode-ide/xcode_tools_bridge_disconnect.ts index d81f1750..f31402cb 100644 --- a/src/mcp/tools/xcode-ide/xcode_tools_bridge_disconnect.ts +++ b/src/mcp/tools/xcode-ide/xcode_tools_bridge_disconnect.ts @@ -4,5 +4,5 @@ import { withBridgeToolHandler } from './shared.ts'; export const schema = {}; export const handler = async (): Promise => { - return withBridgeToolHandler(async (bridge) => bridge.disconnectTool()); + return withBridgeToolHandler('Bridge Disconnect', async (bridge) => bridge.disconnectTool()); }; diff --git a/src/mcp/tools/xcode-ide/xcode_tools_bridge_status.ts b/src/mcp/tools/xcode-ide/xcode_tools_bridge_status.ts index f3dae68e..aa367d2a 100644 --- a/src/mcp/tools/xcode-ide/xcode_tools_bridge_status.ts +++ b/src/mcp/tools/xcode-ide/xcode_tools_bridge_status.ts @@ -4,5 +4,5 @@ import { withBridgeToolHandler } from './shared.ts'; export const schema = {}; export const handler = async (): Promise => { - return withBridgeToolHandler(async (bridge) => bridge.statusTool()); + return withBridgeToolHandler('Bridge Status', async (bridge) => bridge.statusTool()); }; diff --git a/src/mcp/tools/xcode-ide/xcode_tools_bridge_sync.ts b/src/mcp/tools/xcode-ide/xcode_tools_bridge_sync.ts index af609325..e93349fc 100644 --- a/src/mcp/tools/xcode-ide/xcode_tools_bridge_sync.ts +++ b/src/mcp/tools/xcode-ide/xcode_tools_bridge_sync.ts @@ -4,5 +4,5 @@ import { withBridgeToolHandler } from './shared.ts'; export const schema = {}; export const handler = async (): Promise => { - return withBridgeToolHandler(async (bridge) => bridge.syncTool()); + return withBridgeToolHandler('Bridge Sync', async (bridge) => bridge.syncTool()); }; diff --git a/src/runtime/__tests__/tool-invoker.test.ts b/src/runtime/__tests__/tool-invoker.test.ts index 265aa14d..d501ed03 100644 --- a/src/runtime/__tests__/tool-invoker.test.ts +++ b/src/runtime/__tests__/tool-invoker.test.ts @@ -241,15 +241,11 @@ describe('DefaultToolInvoker next steps post-processing', () => { const invoker = new DefaultToolInvoker(catalog); const response = await invoker.invoke('snapshot-ui', {}, { runtime: 'cli' }); - expect(response.nextSteps).toEqual([ - { - tool: 'screenshot', - label: 'Take screenshot', - params: { simulatorId: '123' }, - workflow: 'ui-automation', - cliTool: 'screenshot', - }, - ]); + expect(response.nextSteps).toBeUndefined(); + const text = response.content.map((c) => (c.type === 'text' ? c.text : '')).join('\n'); + expect(text).toContain('Next steps:'); + expect(text).toContain('Take screenshot'); + expect(text).toContain('xcodebuildmcp ui-automation screenshot --simulator-id "123"'); }); it('injects manifest template next steps from dynamic nextStepParams when response omits nextSteps', async () => { @@ -297,25 +293,13 @@ describe('DefaultToolInvoker next steps post-processing', () => { const invoker = new DefaultToolInvoker(catalog); const response = await invoker.invoke('snapshot-ui', {}, { runtime: 'cli' }); - expect(response.nextSteps).toEqual([ - { - tool: 'snapshot_ui', - label: 'Refresh', - params: { simulatorId: '12345678-1234-4234-8234-123456789012' }, - workflow: 'ui-automation', - cliTool: 'snapshot-ui', - }, - { - label: 'Visually verify hierarchy output', - }, - { - tool: 'tap', - label: 'Tap on element', - params: { simulatorId: '12345678-1234-4234-8234-123456789012', x: 0, y: 0 }, - workflow: 'ui-automation', - cliTool: 'tap', - }, - ]); + expect(response.nextSteps).toBeUndefined(); + const text = response.content.map((c) => (c.type === 'text' ? c.text : '')).join('\n'); + expect(text).toContain('Refresh'); + expect(text).toContain('snapshot-ui'); + expect(text).toContain('Visually verify hierarchy output'); + expect(text).toContain('Tap on element'); + expect(text).toContain('tap'); }); it('prefers manifest templates over tool-provided next-step labels and tools', async () => { @@ -363,16 +347,11 @@ describe('DefaultToolInvoker next steps post-processing', () => { const invoker = new DefaultToolInvoker(catalog); const response = await invoker.invoke('start-simulator-log-capture', {}, { runtime: 'cli' }); - expect(response.nextSteps).toEqual([ - { - tool: 'stop_sim_log_cap', - label: 'Stop capture and retrieve logs', - params: { logSessionId: 'session-123' }, - priority: 1, - workflow: 'logging', - cliTool: 'stop-simulator-log-capture', - }, - ]); + expect(response.nextSteps).toBeUndefined(); + const text = response.content.map((c) => (c.type === 'text' ? c.text : '')).join('\n'); + expect(text).toContain('Stop capture and retrieve logs'); + expect(text).toContain('stop-simulator-log-capture'); + expect(text).toContain('session-123'); }); it('preserves daemon-provided next-step params when nextStepParams are already consumed', async () => { @@ -424,16 +403,11 @@ describe('DefaultToolInvoker next steps post-processing', () => { }, ); - expect(response.nextSteps).toEqual([ - { - tool: 'stop_sim_log_cap', - label: 'Stop capture and retrieve logs', - params: { logSessionId: 'session-123' }, - priority: 1, - workflow: 'logging', - cliTool: 'stop-simulator-log-capture', - }, - ]); + expect(response.nextSteps).toBeUndefined(); + const text = response.content.map((c) => (c.type === 'text' ? c.text : '')).join('\n'); + expect(text).toContain('Stop capture and retrieve logs'); + expect(text).toContain('stop-simulator-log-capture'); + expect(text).toContain('session-123'); }); it('overrides unresolved template placeholders with dynamic next-step params', async () => { @@ -473,15 +447,11 @@ describe('DefaultToolInvoker next steps post-processing', () => { const invoker = new DefaultToolInvoker(catalog); const response = await invoker.invoke('launch-app-sim', {}, { runtime: 'cli' }); - expect(response.nextSteps).toEqual([ - { - tool: 'boot_sim', - label: 'Boot simulator', - params: { simulatorId: 'ABC-123' }, - workflow: 'simulator', - cliTool: 'boot-sim', - }, - ]); + expect(response.nextSteps).toBeUndefined(); + const text = response.content.map((c) => (c.type === 'text' ? c.text : '')).join('\n'); + expect(text).toContain('Boot simulator'); + expect(text).toContain('boot-sim'); + expect(text).toContain('ABC-123'); }); it('maps dynamic params to the correct template tool after catalog filtering', async () => { @@ -525,16 +495,11 @@ describe('DefaultToolInvoker next steps post-processing', () => { const invoker = new DefaultToolInvoker(catalog); const response = await invoker.invoke('start-simulator-log-capture', {}, { runtime: 'cli' }); - expect(response.nextSteps).toEqual([ - { - tool: 'stop_sim_log_cap', - label: 'Stop capture and retrieve logs', - params: { logSessionId: 'session-123' }, - priority: 1, - workflow: 'logging', - cliTool: 'stop-simulator-log-capture', - }, - ]); + expect(response.nextSteps).toBeUndefined(); + const text = response.content.map((c) => (c.type === 'text' ? c.text : '')).join('\n'); + expect(text).toContain('Stop capture and retrieve logs'); + expect(text).toContain('stop-simulator-log-capture'); + expect(text).toContain('session-123'); }); it('suppresses manifest next steps for structured xcodebuild failures', async () => { @@ -662,23 +627,11 @@ describe('DefaultToolInvoker next steps post-processing', () => { const invoker = new DefaultToolInvoker(catalog); const response = await invoker.invoke('get-app-path', {}, { runtime: 'cli' }); - expect(response.nextSteps).toEqual([ - { - tool: 'get_app_bundle_id', - label: 'Get bundle ID', - params: {}, - priority: 1, - workflow: 'project-discovery', - cliTool: 'get-app-bundle-id', - }, - { - tool: 'boot_sim', - label: 'Boot simulator', - params: {}, - priority: 2, - workflow: 'simulator', - cliTool: 'boot', - }, - ]); + expect(response.nextSteps).toBeUndefined(); + const text = response.content.map((c) => (c.type === 'text' ? c.text : '')).join('\n'); + expect(text).toContain('Get bundle ID'); + expect(text).toContain('get-app-bundle-id'); + expect(text).toContain('Boot simulator'); + expect(text).toContain('boot'); }); }); diff --git a/src/runtime/bootstrap-runtime.ts b/src/runtime/bootstrap-runtime.ts index a536aaa3..3bb0a238 100644 --- a/src/runtime/bootstrap-runtime.ts +++ b/src/runtime/bootstrap-runtime.ts @@ -87,12 +87,11 @@ function logHydrationResult(hydration: MCPSessionHydrationResult): void { return; } - if (hydration.refreshScheduled) { - log('info', '[Session] Hydrated MCP session defaults; simulator metadata refresh scheduled.'); - return; - } - - log('info', '[Session] Hydrated MCP session defaults; simulator metadata refresh not scheduled.'); + const refreshStatus = hydration.refreshScheduled ? 'scheduled' : 'not scheduled'; + log( + 'info', + `[Session] Hydrated MCP session defaults; simulator metadata refresh ${refreshStatus}.`, + ); } export async function bootstrapRuntime( diff --git a/src/runtime/tool-catalog.ts b/src/runtime/tool-catalog.ts index c2a1e9f1..7891cb57 100644 --- a/src/runtime/tool-catalog.ts +++ b/src/runtime/tool-catalog.ts @@ -87,13 +87,6 @@ export function createToolCatalog(tools: ToolDefinition[]): ToolCatalog { }; } -/** - * Get a list of all available tool names for display. - */ -export function listToolNames(catalog: ToolCatalog): string[] { - return catalog.tools.map((t) => t.cliName).sort(); -} - /** * Get tools grouped by workflow for display. */ diff --git a/src/runtime/tool-invoker.ts b/src/runtime/tool-invoker.ts index 95a0ffbe..160e06ba 100644 --- a/src/runtime/tool-invoker.ts +++ b/src/runtime/tool-invoker.ts @@ -1,6 +1,7 @@ import type { ToolCatalog, ToolDefinition, ToolInvoker, InvokeOptions } from './types.ts'; import type { NextStep, NextStepParams, NextStepParamsMap, ToolResponse } from '../types/common.ts'; -import { createErrorResponse } from '../utils/responses/index.ts'; +import { toolResponse } from '../utils/tool-response.ts'; +import { statusLine } from '../utils/tool-event-builders.ts'; import { DaemonClient } from '../cli/daemon-client.ts'; import { ensureDaemonRunning, DEFAULT_DAEMON_STARTUP_TIMEOUT_MS } from '../cli/daemon-control.ts'; import { log } from '../utils/logger.ts'; @@ -12,11 +13,11 @@ import { type SentryToolTransport, } from '../utils/sentry.ts'; import { - appendStructuredEvents, - createNextStepsEvent, finalizePendingXcodebuildResponse, isPendingXcodebuildResponse, } from '../utils/xcodebuild-output.ts'; +import { renderNextStepsSection } from '../utils/responses/next-steps-renderer.ts'; +import type { RuntimeKind } from './types.ts'; type BuiltTemplateNextStep = { step: NextStep; @@ -141,17 +142,32 @@ function normalizeNextSteps(response: ToolResponse, catalog: ToolCatalog): ToolR }; } -function appendNextStepsToStructuredEvents(response: ToolResponse): ToolResponse { +function renderNextStepsIntoContent(response: ToolResponse, runtime: RuntimeKind): ToolResponse { if (!response.nextSteps || response.nextSteps.length === 0) { return response; } - const nextStepsEvent = createNextStepsEvent(response.nextSteps); - if (!nextStepsEvent) { + const section = renderNextStepsSection(response.nextSteps, runtime); + if (!section) { return response; } - return appendStructuredEvents(response, [nextStepsEvent]); + const content = [...response.content]; + let lastTextIndex = -1; + for (let i = content.length - 1; i >= 0; i--) { + if (content[i].type === 'text') { + lastTextIndex = i; + break; + } + } + if (lastTextIndex >= 0) { + const lastItem = content[lastTextIndex]; + content[lastTextIndex] = { ...lastItem, text: `${lastItem.text}\n\n${section}` }; + } else { + content.push({ type: 'text', text: section }); + } + + return { ...response, content }; } export function postProcessToolResponse(params: { @@ -161,7 +177,7 @@ export function postProcessToolResponse(params: { runtime: InvokeOptions['runtime']; applyTemplateNextSteps?: boolean; }): ToolResponse { - const { tool, response, catalog, applyTemplateNextSteps = true } = params; + const { tool, response, catalog, runtime, applyTemplateNextSteps = true } = params; const isError = response.isError === true; const suppressNextStepsForStructuredFailure = @@ -176,8 +192,7 @@ export function postProcessToolResponse(params: { const allTemplateSteps = buildTemplateNextSteps(tool, catalog); const templateSteps = allTemplateSteps.filter((t) => { - const when = t.step.when ?? 'always'; - if (when === 'always') return true; + const when = t.step.when ?? 'success'; if (when === 'success') return !isError; if (when === 'failure') return isError; return true; @@ -195,40 +210,33 @@ export function postProcessToolResponse(params: { : responseForNextSteps; const normalized = normalizeNextSteps(withTemplates, catalog); - const result = isPendingXcodebuildResponse(normalized) + + const finalized = isPendingXcodebuildResponse(normalized) ? finalizePendingXcodebuildResponse(normalized, { nextSteps: normalized.nextSteps, }) - : appendNextStepsToStructuredEvents(normalized); - delete result.nextStepParams; + : renderNextStepsIntoContent(normalized, runtime); + + const { nextSteps: _ns, nextStepParams: _nsp, ...result } = finalized; return result; } function buildDaemonEnvOverrides(opts: InvokeOptions): Record | undefined { - const envOverrides: Record = {}; - - if (opts.logLevel) { - envOverrides.XCODEBUILDMCP_DAEMON_LOG_LEVEL = opts.logLevel; + if (!opts.logLevel) { + return undefined; } - - return Object.keys(envOverrides).length > 0 ? envOverrides : undefined; + return { XCODEBUILDMCP_DAEMON_LOG_LEVEL: opts.logLevel }; } function getErrorKind(error: unknown): string { - if (error instanceof Error) { - return error.name || 'Error'; - } - return typeof error; + return error instanceof Error ? error.name || 'Error' : typeof error; } function mapRuntimeToSentryToolRuntime(runtime: InvokeOptions['runtime']): SentryToolRuntime { - switch (runtime) { - case 'daemon': - case 'mcp': - return runtime; - default: - return 'cli'; + if (runtime === 'daemon' || runtime === 'mcp') { + return runtime; } + return 'cli'; } export class DefaultToolInvoker implements ToolInvoker { @@ -242,17 +250,21 @@ export class DefaultToolInvoker implements ToolInvoker { const resolved = this.catalog.resolve(toolName); if (resolved.ambiguous) { - return createErrorResponse( - 'Ambiguous tool name', - `Multiple tools match '${toolName}'. Use one of:\n- ${resolved.ambiguous.join('\n- ')}`, - ); + return toolResponse([ + statusLine( + 'error', + `Ambiguous tool name: Multiple tools match '${toolName}'. Use one of:\n- ${resolved.ambiguous.join('\n- ')}`, + ), + ]); } if (resolved.notFound || !resolved.tool) { - return createErrorResponse( - 'Tool not found', - `Unknown tool '${toolName}'. Run 'xcodebuildmcp tools' to see available tools.`, - ); + return toolResponse([ + statusLine( + 'error', + `Tool not found: Unknown tool '${toolName}'. Run 'xcodebuildmcp tools' to see available tools.`, + ), + ]); } return this.executeTool(resolved.tool, args, opts); @@ -291,10 +303,12 @@ export class DefaultToolInvoker implements ToolInvoker { const error = new Error('SocketPathMissing'); context.captureInfraErrorMetric(error); context.captureInvocationMetric('infra_error'); - return createErrorResponse( - 'Socket path required', - 'No socket path configured for daemon communication.', - ); + return toolResponse([ + statusLine( + 'error', + 'Socket path required: No socket path configured for daemon communication.', + ), + ]); } const client = new DaemonClient({ socketPath }); @@ -316,12 +330,12 @@ export class DefaultToolInvoker implements ToolInvoker { ); context.captureInfraErrorMetric(error); context.captureInvocationMetric('infra_error'); - return createErrorResponse( - 'Daemon auto-start failed', - (error instanceof Error ? error.message : String(error)) + - '\n\nYou can try starting the daemon manually:\n' + - ' xcodebuildmcp daemon start', - ); + return toolResponse([ + statusLine( + 'error', + `Daemon auto-start failed: ${error instanceof Error ? error.message : String(error)}\n\nYou can try starting the daemon manually:\n xcodebuildmcp daemon start`, + ), + ]); } } @@ -341,10 +355,12 @@ export class DefaultToolInvoker implements ToolInvoker { ); context.captureInfraErrorMetric(error); context.captureInvocationMetric('infra_error'); - return createErrorResponse( - context.errorTitle, - error instanceof Error ? error.message : String(error), - ); + return toolResponse([ + statusLine( + 'error', + `${context.errorTitle}: ${error instanceof Error ? error.message : String(error)}`, + ), + ]); } } @@ -423,7 +439,7 @@ export class DefaultToolInvoker implements ToolInvoker { captureInfraErrorMetric(error); captureInvocationMetric('infra_error'); const message = error instanceof Error ? error.message : String(error); - return createErrorResponse('Tool execution failed', message); + return toolResponse([statusLine('error', `Tool execution failed: ${message}`)]); } } } diff --git a/src/server/mcp-lifecycle.ts b/src/server/mcp-lifecycle.ts index 0716a576..52bf156c 100644 --- a/src/server/mcp-lifecycle.ts +++ b/src/server/mcp-lifecycle.ts @@ -132,23 +132,23 @@ function parseElapsedSeconds(value: string): number | null { const daySplit = trimmed.split('-'); const timePart = daySplit.length === 2 ? daySplit[1] : daySplit[0]; const dayCount = daySplit.length === 2 ? Number(daySplit[0]) : 0; - const parts = timePart.split(':').map((part) => Number(part)); + const parts = timePart.split(':').map(Number); - if (!Number.isFinite(dayCount) || parts.some((part) => !Number.isFinite(part))) { + if (!Number.isFinite(dayCount) || parts.some((p) => !Number.isFinite(p))) { return null; } - if (parts.length === 1) { - return dayCount * 86400 + parts[0]; + const daySeconds = dayCount * 86400; + switch (parts.length) { + case 1: + return daySeconds + parts[0]; + case 2: + return daySeconds + parts[0] * 60 + parts[1]; + case 3: + return daySeconds + parts[0] * 3600 + parts[1] * 60 + parts[2]; + default: + return null; } - if (parts.length === 2) { - return dayCount * 86400 + parts[0] * 60 + parts[1]; - } - if (parts.length === 3) { - return dayCount * 86400 + parts[0] * 3600 + parts[1] * 60 + parts[2]; - } - - return null; } export function classifyMcpLifecycleAnomalies( @@ -178,15 +178,12 @@ export function classifyMcpLifecycleAnomalies( function isLikelyMcpProcessCommand(command: string): boolean { const normalized = command.toLowerCase(); - const hasMcpArg = /(^|\s)mcp(\s|$)/.test(normalized); - if (!hasMcpArg) { + if (!/(^|\s)mcp(\s|$)/.test(normalized)) { return false; } - if (/(^|\s)daemon(\s|$)/.test(normalized)) { return false; } - return ( normalized.includes('xcodebuildmcp') || normalized.includes('build/cli.js') || @@ -199,7 +196,7 @@ function isBrokenPipeLikeError(error: unknown): boolean { return false; } - const code = 'code' in error ? String((error as Error & { code?: unknown }).code ?? '') : ''; + const code = String((error as NodeJS.ErrnoException).code ?? ''); return code === 'EPIPE' || code === 'ERR_STREAM_DESTROYED'; } @@ -267,13 +264,15 @@ async function sampleMcpPeerProcesses( } } +const TRANSPORT_DISCONNECT_REASONS: ReadonlySet = new Set([ + 'stdin-end', + 'stdin-close', + 'stdout-error', + 'stderr-error', +]); + export function isTransportDisconnectReason(reason: McpShutdownReason): boolean { - return ( - reason === 'stdin-end' || - reason === 'stdin-close' || - reason === 'stdout-error' || - reason === 'stderr-error' - ); + return TRANSPORT_DISCONNECT_REASONS.has(reason); } export async function buildMcpLifecycleSnapshot(options: { diff --git a/src/server/mcp-shutdown.ts b/src/server/mcp-shutdown.ts index cf622073..9c923610 100644 --- a/src/server/mcp-shutdown.ts +++ b/src/server/mcp-shutdown.ts @@ -55,12 +55,6 @@ function stringifyError(error: unknown): string { return error instanceof Error ? error.message : String(error); } -function createTimer(timeoutMs: number, callback: () => void): NodeJS.Timeout { - const timer = setTimeout(callback, timeoutMs); - timer.unref?.(); - return timer; -} - async function runStep( name: string, timeoutMs: number, @@ -71,7 +65,8 @@ async function runStep( try { const timeoutPromise = new Promise>((resolve) => { - timeoutHandle = createTimer(timeoutMs, () => resolve({ kind: 'timed_out' })); + timeoutHandle = setTimeout(() => resolve({ kind: 'timed_out' }), timeoutMs); + timeoutHandle.unref?.(); }); const operationOutcome = operation() @@ -111,12 +106,14 @@ async function runStep( } } +const FAILURE_REASONS: ReadonlySet = new Set([ + 'startup-failure', + 'uncaught-exception', + 'unhandled-rejection', +]); + function buildExitCode(reason: McpShutdownReason): number { - return reason === 'startup-failure' || - reason === 'uncaught-exception' || - reason === 'unhandled-rejection' - ? 1 - : 0; + return FAILURE_REASONS.has(reason) ? 1 : 0; } export async function closeServerWithTimeout( diff --git a/src/snapshot-tests/__fixtures__/coverage/get-coverage-report--error-invalid-bundle.txt b/src/snapshot-tests/__fixtures__/coverage/get-coverage-report--error-invalid-bundle.txt new file mode 100644 index 00000000..16262d73 --- /dev/null +++ b/src/snapshot-tests/__fixtures__/coverage/get-coverage-report--error-invalid-bundle.txt @@ -0,0 +1,6 @@ + +๐Ÿ“Š Coverage Report + + xcresult: /invalid.xcresult + +โŒ Failed to get coverage report: Error: Error Domain=XCCovErrorDomain Code=0 "Failed to load result bundle" UserInfo={NSLocalizedDescription=Failed to load result bundle, NSUnderlyingError=0x7b3d88a00 {Error Domain=XCResultStorage.ResultBundleFactory.Error Code=0 "Failed to create a new result bundle reader, underlying error: Info.plist at /invalid.xcresult/Info.plist does not exist, the result bundle might be corrupted or the provided path is not a result bundle"}} diff --git a/src/snapshot-tests/__fixtures__/coverage/get-coverage-report--success.txt b/src/snapshot-tests/__fixtures__/coverage/get-coverage-report--success.txt new file mode 100644 index 00000000..034cde37 --- /dev/null +++ b/src/snapshot-tests/__fixtures__/coverage/get-coverage-report--success.txt @@ -0,0 +1,10 @@ + +๐Ÿ“Š Coverage Report + + xcresult: /TestResults.xcresult + Target Filter: CalculatorAppTests + +โ„น๏ธ Overall: 94.9% (354/373 lines) + +Targets + CalculatorAppTests.xctest: 94.9% (354/373 lines) diff --git a/src/snapshot-tests/__fixtures__/coverage/get-file-coverage--error-invalid-bundle.txt b/src/snapshot-tests/__fixtures__/coverage/get-file-coverage--error-invalid-bundle.txt new file mode 100644 index 00000000..b4f7f41f --- /dev/null +++ b/src/snapshot-tests/__fixtures__/coverage/get-file-coverage--error-invalid-bundle.txt @@ -0,0 +1,7 @@ + +๐Ÿ“Š File Coverage + + xcresult: /invalid.xcresult + File: SomeFile.swift + +โŒ Failed to get file coverage: Error: Error Domain=XCCovErrorDomain Code=0 "Failed to load result bundle" UserInfo={NSLocalizedDescription=Failed to load result bundle, NSUnderlyingError=0xb1a1a1ea0 {Error Domain=XCResultStorage.ResultBundleFactory.Error Code=0 "Failed to create a new result bundle reader, underlying error: Info.plist at /invalid.xcresult/Info.plist does not exist, the result bundle might be corrupted or the provided path is not a result bundle"}} diff --git a/src/snapshot-tests/__fixtures__/coverage/get-file-coverage--success.txt b/src/snapshot-tests/__fixtures__/coverage/get-file-coverage--success.txt new file mode 100644 index 00000000..f0705117 --- /dev/null +++ b/src/snapshot-tests/__fixtures__/coverage/get-file-coverage--success.txt @@ -0,0 +1,25 @@ + +๐Ÿ“Š File Coverage + + xcresult: /TestResults.xcresult + File: CalculatorService.swift + +File: example_projects/iOS_Calculator/CalculatorAppPackage/Sources/CalculatorAppFeature/CalculatorService.swift +โ„น๏ธ Coverage: 83.1% (157/189 lines) + +๐Ÿ”ด Not Covered (7 functions, 22 lines) + L159 CalculatorService.deleteLastDigit() -- 0/16 lines + L58 implicit closure #2 in CalculatorService.inputNumber(_:) -- 0/1 lines + L98 implicit closure #3 in CalculatorService.calculate() -- 0/1 lines + L99 implicit closure #4 in CalculatorService.calculate() -- 0/1 lines + L162 implicit closure #1 in CalculatorService.deleteLastDigit() -- 0/1 lines + L172 implicit closure #2 in CalculatorService.deleteLastDigit() -- 0/1 lines + L214 implicit closure #4 in CalculatorService.formatNumber(_:) -- 0/1 lines + +๐ŸŸก Partial Coverage (4 functions) + L184 CalculatorService.updateExpressionDisplay() -- 80.0% (8/10 lines) + L195 CalculatorService.formatNumber(_:) -- 85.7% (18/21 lines) + L93 CalculatorService.calculate() -- 89.5% (34/38 lines) + L63 CalculatorService.inputDecimal() -- 92.9% (13/14 lines) + +๐ŸŸข Full Coverage (28 functions) -- all at 100% diff --git a/src/snapshot-tests/__fixtures__/debugging/add-breakpoint--error-no-session.txt b/src/snapshot-tests/__fixtures__/debugging/add-breakpoint--error-no-session.txt new file mode 100644 index 00000000..1495a4c5 --- /dev/null +++ b/src/snapshot-tests/__fixtures__/debugging/add-breakpoint--error-no-session.txt @@ -0,0 +1,4 @@ + +๐Ÿ› Add Breakpoint + +โŒ Failed to add breakpoint: No active debug session. Provide debugSessionId or attach first. diff --git a/src/snapshot-tests/__fixtures__/debugging/attach--error-no-process.txt b/src/snapshot-tests/__fixtures__/debugging/attach--error-no-process.txt new file mode 100644 index 00000000..06fc6b03 --- /dev/null +++ b/src/snapshot-tests/__fixtures__/debugging/attach--error-no-process.txt @@ -0,0 +1,4 @@ + +๐Ÿ› Attach Debugger + +โŒ Failed to resolve simulator PID: Invalid device: diff --git a/src/snapshot-tests/__fixtures__/debugging/continue--error-no-session.txt b/src/snapshot-tests/__fixtures__/debugging/continue--error-no-session.txt new file mode 100644 index 00000000..b3d0975a --- /dev/null +++ b/src/snapshot-tests/__fixtures__/debugging/continue--error-no-session.txt @@ -0,0 +1,4 @@ + +๐Ÿ› Continue + +โŒ Failed to resume debugger: No active debug session. Provide debugSessionId or attach first. diff --git a/src/snapshot-tests/__fixtures__/debugging/detach--error-no-session.txt b/src/snapshot-tests/__fixtures__/debugging/detach--error-no-session.txt new file mode 100644 index 00000000..2b4192bf --- /dev/null +++ b/src/snapshot-tests/__fixtures__/debugging/detach--error-no-session.txt @@ -0,0 +1,4 @@ + +๐Ÿ› Detach + +โŒ Failed to detach debugger: No active debug session. Provide debugSessionId or attach first. diff --git a/src/snapshot-tests/__fixtures__/debugging/lldb-command--error-no-session.txt b/src/snapshot-tests/__fixtures__/debugging/lldb-command--error-no-session.txt new file mode 100644 index 00000000..6daf1551 --- /dev/null +++ b/src/snapshot-tests/__fixtures__/debugging/lldb-command--error-no-session.txt @@ -0,0 +1,6 @@ + +๐Ÿ› LLDB Command + + Command: bt + +โŒ Failed to run LLDB command: No active debug session. Provide debugSessionId or attach first. diff --git a/src/snapshot-tests/__fixtures__/debugging/remove-breakpoint--error-no-session.txt b/src/snapshot-tests/__fixtures__/debugging/remove-breakpoint--error-no-session.txt new file mode 100644 index 00000000..83bd0130 --- /dev/null +++ b/src/snapshot-tests/__fixtures__/debugging/remove-breakpoint--error-no-session.txt @@ -0,0 +1,4 @@ + +๐Ÿ› Remove Breakpoint + +โŒ Failed to remove breakpoint: No active debug session. Provide debugSessionId or attach first. diff --git a/src/snapshot-tests/__fixtures__/debugging/stack--error-no-session.txt b/src/snapshot-tests/__fixtures__/debugging/stack--error-no-session.txt new file mode 100644 index 00000000..72172a93 --- /dev/null +++ b/src/snapshot-tests/__fixtures__/debugging/stack--error-no-session.txt @@ -0,0 +1,4 @@ + +๐Ÿ› Stack Trace + +โŒ Failed to get stack: No active debug session. Provide debugSessionId or attach first. diff --git a/src/snapshot-tests/__fixtures__/debugging/variables--error-no-session.txt b/src/snapshot-tests/__fixtures__/debugging/variables--error-no-session.txt new file mode 100644 index 00000000..e76aa900 --- /dev/null +++ b/src/snapshot-tests/__fixtures__/debugging/variables--error-no-session.txt @@ -0,0 +1,4 @@ + +๐Ÿ› Variables + +โŒ Failed to get variables: No active debug session. Provide debugSessionId or attach first. diff --git a/src/snapshot-tests/__fixtures__/device/build--success.txt b/src/snapshot-tests/__fixtures__/device/build--success.txt new file mode 100644 index 00000000..e54c5093 --- /dev/null +++ b/src/snapshot-tests/__fixtures__/device/build--success.txt @@ -0,0 +1,11 @@ + +๐Ÿ”จ Build + + Scheme: CalculatorApp + Configuration: Debug + Platform: iOS + +โœ… Build succeeded. (โฑ๏ธ ) + +Next steps: +1. Get built device app path: xcodebuildmcp device get-app-path --scheme "CalculatorApp" diff --git a/src/snapshot-tests/__fixtures__/device/get-app-path--success.txt b/src/snapshot-tests/__fixtures__/device/get-app-path--success.txt new file mode 100644 index 00000000..4c1f6d89 --- /dev/null +++ b/src/snapshot-tests/__fixtures__/device/get-app-path--success.txt @@ -0,0 +1,10 @@ + +๐Ÿ” Get App Path + + Scheme: CalculatorApp + Workspace: example_projects/iOS_Calculator/CalculatorApp.xcworkspace + Configuration: Debug + Platform: iOS + + โ”” App Path: /Library/Developer/Xcode/DerivedData/CalculatorApp-/Build/Products/Debug-iphoneos/CalculatorApp.app +โœ… App path resolved. diff --git a/src/snapshot-tests/__fixtures__/device/list--success.txt b/src/snapshot-tests/__fixtures__/device/list--success.txt new file mode 100644 index 00000000..3a9848ef --- /dev/null +++ b/src/snapshot-tests/__fixtures__/device/list--success.txt @@ -0,0 +1,43 @@ + +๐Ÿ“ฑ List Devices + +๐ŸŸข Cameronโ€™s Appleย Watch + โ”œ UDID: + โ”œ Model: Watch4,2 + โ”œ Product Type: Watch4,2 + โ”œ Platform: Unknown 10.6.1 + โ”œ CPU Architecture: arm64_32 + โ”œ Connection: + โ”” Developer Mode: disabled + +๐ŸŸข Cameronโ€™s Appleย Watch + โ”œ UDID: + โ”œ Model: Watch7,20 + โ”œ Product Type: Watch7,20 + โ”œ Platform: Unknown 26.1 + โ”œ CPU Architecture: arm64e + โ”œ Connection: localNetwork + โ”” Developer Mode: disabled + +๐ŸŸข Cameronโ€™s iPhone 16 Pro Max + โ”œ UDID: + โ”œ Model: iPhone17,2 + โ”œ Product Type: iPhone17,2 + โ”œ Platform: Unknown 26.3.1 (a) + โ”œ CPU Architecture: arm64e + โ”œ Connection: localNetwork + โ”” Developer Mode: enabled + +๐ŸŸข iPhone + โ”œ UDID: + โ”œ Model: iPhone99,11 + โ”œ Product Type: iPhone99,11 + โ”œ Platform: Unknown 26.1 + โ”œ CPU Architecture: arm64e + โ”” Connection: +โœ… Devices discovered. + +Hints + Use the device ID/UDID from above when required by other tools. + Save a default device with session-set-defaults { deviceId: 'DEVICE_UDID' }. + Before running build/run/test/UI automation tools, set the desired device identifier in session defaults. diff --git a/src/snapshot-tests/__fixtures__/logging/start-sim-log--error.txt b/src/snapshot-tests/__fixtures__/logging/start-sim-log--error.txt new file mode 100644 index 00000000..bd807dc8 --- /dev/null +++ b/src/snapshot-tests/__fixtures__/logging/start-sim-log--error.txt @@ -0,0 +1,14 @@ + +๐Ÿ“ Start Log Capture + + Simulator: + Bundle ID: com.nonexistent.app + +Details + Session ID: + Only structured logs from the app subsystem are being captured. + Interact with your simulator and app, then stop capture to retrieve logs. +โœ… Log capture started. + +Next steps: +1. Stop capture and retrieve logs: stop_sim_log_cap({ logSessionId: "" }) diff --git a/src/snapshot-tests/__fixtures__/logging/stop-sim-log--error.txt b/src/snapshot-tests/__fixtures__/logging/stop-sim-log--error.txt new file mode 100644 index 00000000..e4ef5f62 --- /dev/null +++ b/src/snapshot-tests/__fixtures__/logging/stop-sim-log--error.txt @@ -0,0 +1,6 @@ + +๐Ÿ“ Stop Log Capture + + Session ID: nonexistent-session-id + +โŒ Error stopping log capture session nonexistent-session-id: Log capture session not found: nonexistent-session-id diff --git a/src/snapshot-tests/__fixtures__/macos/build--success.txt b/src/snapshot-tests/__fixtures__/macos/build--success.txt new file mode 100644 index 00000000..4696b2cd --- /dev/null +++ b/src/snapshot-tests/__fixtures__/macos/build--success.txt @@ -0,0 +1,11 @@ + +๐Ÿ”จ Build + + Scheme: MCPTest + Configuration: Debug + Platform: macOS + +โœ… Build succeeded. (โฑ๏ธ ) + +Next steps: +1. Get built macOS app path: xcodebuildmcp macos get-app-path --scheme "MCPTest" diff --git a/src/snapshot-tests/__fixtures__/macos/build-and-run--success.txt b/src/snapshot-tests/__fixtures__/macos/build-and-run--success.txt new file mode 100644 index 00000000..a2045afe --- /dev/null +++ b/src/snapshot-tests/__fixtures__/macos/build-and-run--success.txt @@ -0,0 +1,18 @@ + +๐Ÿš€ Build & Run + + Scheme: MCPTest + Configuration: Debug + Platform: macOS + +โ„น๏ธ Resolving app path +โœ… Resolving app path +โ„น๏ธ Launching app +โœ… Launching app + +โœ… Build succeeded. (โฑ๏ธ ) +โœ… Build & Run complete + โ”” App Path: /Library/Developer/Xcode/DerivedData/MCPTest-/Build/Products/Debug/MCPTest.app + +Next steps: +1. Interact with the launched app in the foreground diff --git a/src/snapshot-tests/__fixtures__/macos/get-app-path--success.txt b/src/snapshot-tests/__fixtures__/macos/get-app-path--success.txt new file mode 100644 index 00000000..1a2d031e --- /dev/null +++ b/src/snapshot-tests/__fixtures__/macos/get-app-path--success.txt @@ -0,0 +1,10 @@ + +๐Ÿ” Get App Path + + Scheme: MCPTest + Project: example_projects/macOS/MCPTest.xcodeproj + Configuration: Debug + Platform: macOS + + โ”” App Path: /Library/Developer/Xcode/DerivedData/MCPTest-/Build/Products/Debug/MCPTest.app +โœ… App path resolved. diff --git a/src/snapshot-tests/__fixtures__/macos/get-macos-bundle-id--success.txt b/src/snapshot-tests/__fixtures__/macos/get-macos-bundle-id--success.txt new file mode 100644 index 00000000..da91a431 --- /dev/null +++ b/src/snapshot-tests/__fixtures__/macos/get-macos-bundle-id--success.txt @@ -0,0 +1,8 @@ + +๐Ÿ” Get macOS Bundle ID + + App: /BundleTest.app + +โŒ Could not extract bundle ID from Info.plist: Print: Entry, ":CFBundleIdentifier", Does Not Exist + +โ„น๏ธ Make sure the path points to a valid macOS app bundle (.app directory). diff --git a/src/snapshot-tests/__fixtures__/macos/launch--error-invalid-app.txt b/src/snapshot-tests/__fixtures__/macos/launch--error-invalid-app.txt new file mode 100644 index 00000000..3007e214 --- /dev/null +++ b/src/snapshot-tests/__fixtures__/macos/launch--error-invalid-app.txt @@ -0,0 +1,6 @@ + +๐Ÿš€ Launch macOS App + + App: /Fake.app + +โœ… App launched successfully. diff --git a/src/snapshot-tests/__fixtures__/macos/stop--error-no-app.txt b/src/snapshot-tests/__fixtures__/macos/stop--error-no-app.txt new file mode 100644 index 00000000..537667a5 --- /dev/null +++ b/src/snapshot-tests/__fixtures__/macos/stop--error-no-app.txt @@ -0,0 +1,6 @@ + +๐Ÿ›‘ Stop macOS App + + Target: NonExistentXBMTestApp + +โœ… App stopped successfully. diff --git a/src/snapshot-tests/__fixtures__/macos/test--success.txt b/src/snapshot-tests/__fixtures__/macos/test--success.txt new file mode 100644 index 00000000..1d0bc50c --- /dev/null +++ b/src/snapshot-tests/__fixtures__/macos/test--success.txt @@ -0,0 +1,8 @@ + +๐Ÿงช Test + + Scheme: MCPTest + Configuration: Debug + Platform: macOS + +โœ… Test succeeded. (, โฑ๏ธ ) diff --git a/src/snapshot-tests/__fixtures__/project-discovery/discover-projs--success.txt b/src/snapshot-tests/__fixtures__/project-discovery/discover-projs--success.txt new file mode 100644 index 00000000..39281470 --- /dev/null +++ b/src/snapshot-tests/__fixtures__/project-discovery/discover-projs--success.txt @@ -0,0 +1,11 @@ + +๐Ÿ” Discover Projects + +โœ… Found 1 project(s) and 1 workspace(s). + +Projects + example_projects/iOS_Calculator/CalculatorApp.xcodeproj + +Workspaces + example_projects/iOS_Calculator/CalculatorApp.xcworkspace +โ„น๏ธ Save a default with session-set-defaults { projectPath: '...' } or { workspacePath: '...' }. diff --git a/src/snapshot-tests/__fixtures__/project-discovery/get-app-bundle-id--success.txt b/src/snapshot-tests/__fixtures__/project-discovery/get-app-bundle-id--success.txt new file mode 100644 index 00000000..5582635c --- /dev/null +++ b/src/snapshot-tests/__fixtures__/project-discovery/get-app-bundle-id--success.txt @@ -0,0 +1,6 @@ + +๐Ÿ” Get Bundle ID + + App: /BundleTest.app + +โœ… Bundle ID: com.test.snapshot diff --git a/src/snapshot-tests/__fixtures__/project-discovery/get-macos-bundle-id--success.txt b/src/snapshot-tests/__fixtures__/project-discovery/get-macos-bundle-id--success.txt new file mode 100644 index 00000000..da91a431 --- /dev/null +++ b/src/snapshot-tests/__fixtures__/project-discovery/get-macos-bundle-id--success.txt @@ -0,0 +1,8 @@ + +๐Ÿ” Get macOS Bundle ID + + App: /BundleTest.app + +โŒ Could not extract bundle ID from Info.plist: Print: Entry, ":CFBundleIdentifier", Does Not Exist + +โ„น๏ธ Make sure the path points to a valid macOS app bundle (.app directory). diff --git a/src/snapshot-tests/__fixtures__/project-discovery/list-schemes--success.txt b/src/snapshot-tests/__fixtures__/project-discovery/list-schemes--success.txt new file mode 100644 index 00000000..73bf9ee8 --- /dev/null +++ b/src/snapshot-tests/__fixtures__/project-discovery/list-schemes--success.txt @@ -0,0 +1,10 @@ + +๐Ÿ” List Schemes + + Workspace: example_projects/iOS_Calculator/CalculatorApp.xcworkspace + +โœ… Found 2 scheme(s). + +Schemes + CalculatorApp + CalculatorAppFeature diff --git a/src/snapshot-tests/__fixtures__/project-discovery/show-build-settings--success.txt b/src/snapshot-tests/__fixtures__/project-discovery/show-build-settings--success.txt new file mode 100644 index 00000000..b36b472f --- /dev/null +++ b/src/snapshot-tests/__fixtures__/project-discovery/show-build-settings--success.txt @@ -0,0 +1,612 @@ + +๐Ÿ” Show Build Settings + + Scheme: CalculatorApp + Workspace: example_projects/iOS_Calculator/CalculatorApp.xcworkspace + +โœ… Build settings retrieved. + +Settings + Build settings for action build and target CalculatorApp: + ACTION = build + AD_HOC_CODE_SIGNING_ALLOWED = NO + AGGREGATE_TRACKED_DOMAINS = YES + ALLOW_BUILD_REQUEST_OVERRIDES = NO + ALLOW_TARGET_PLATFORM_SPECIALIZATION = NO + ALTERNATE_GROUP = staff + ALTERNATE_MODE = u+w,go-w,a+rX + ALTERNATE_OWNER = cameroncooke + ALTERNATIVE_DISTRIBUTION_WEB = NO + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = NO + ALWAYS_SEARCH_USER_PATHS = NO + ALWAYS_USE_SEPARATE_HEADERMAPS = NO + APPLICATION_EXTENSION_API_ONLY = NO + APPLY_RULES_IN_COPY_FILES = NO + APPLY_RULES_IN_COPY_HEADERS = NO + APP_SHORTCUTS_ENABLE_FLEXIBLE_MATCHING = YES + ARCHS = arm64 + ARCHS_BASE = arm64 + ARCHS_STANDARD = arm64 + ARCHS_STANDARD_32_64_BIT = armv7 arm64 + ARCHS_STANDARD_32_BIT = armv7 + ARCHS_STANDARD_64_BIT = arm64 + ARCHS_STANDARD_INCLUDING_64_BIT = arm64 + ARCHS_UNIVERSAL_IPHONE_OS = armv7 arm64 + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor + ASSETCATALOG_FILTER_FOR_DEVICE_MODEL = MacFamily20,1 + ASSETCATALOG_FILTER_FOR_DEVICE_OS_VERSION = 26.3 + ASSETCATALOG_FILTER_FOR_THINNING_DEVICE_CONFIGURATION = MacFamily20,1 + AUTOMATICALLY_MERGE_DEPENDENCIES = NO + AUTOMATION_APPLE_EVENTS = NO + AVAILABLE_PLATFORMS = android appletvos appletvsimulator driverkit freebsd iphoneos iphonesimulator linux macosx none openbsd qnx watchos watchsimulator webassembly xros xrsimulator + BUILD_ACTIVE_RESOURCES_ONLY = YES + BUILD_COMPONENTS = headers build + BUILD_DIR = /Library/Developer/Xcode/DerivedData/CalculatorApp-/Build/Products + BUILD_LIBRARY_FOR_DISTRIBUTION = NO + BUILD_ONLY_KNOWN_LOCALIZATIONS = NO + BUILD_ROOT = /Library/Developer/Xcode/DerivedData/CalculatorApp-/Build/Products + BUILD_STYLE = + BUILD_VARIANTS = normal + BUILT_PRODUCTS_DIR = /Library/Developer/Xcode/DerivedData/CalculatorApp-/Build/Products/Debug-iphoneos + BUNDLE_CONTENTS_FOLDER_PATH_deep = Contents/ + BUNDLE_EXECUTABLE_FOLDER_NAME_deep = MacOS + BUNDLE_EXTENSIONS_FOLDER_PATH = Extensions + BUNDLE_FORMAT = shallow + BUNDLE_FRAMEWORKS_FOLDER_PATH = Frameworks + BUNDLE_PLUGINS_FOLDER_PATH = PlugIns + BUNDLE_PRIVATE_HEADERS_FOLDER_PATH = PrivateHeaders + BUNDLE_PUBLIC_HEADERS_FOLDER_PATH = Headers + CACHE_ROOT = /var/folders/_t/2njffz894t57qpp76v1sw__h0000gn/C/com.apple.DeveloperTools/26.4-17E192/Xcode + CCHROOT = /var/folders/_t/2njffz894t57qpp76v1sw__h0000gn/C/com.apple.DeveloperTools/26.4-17E192/Xcode + CHMOD = /bin/chmod + CHOWN = chown + CLANG_ANALYZER_NONNULL = YES + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE + CLANG_CACHE_FINE_GRAINED_OUTPUTS = YES + CLANG_COVERAGE_MAPPING = YES + CLANG_CXX_LANGUAGE_STANDARD = gnu++20 + CLANG_ENABLE_EXPLICIT_MODULES = YES + CLANG_ENABLE_MODULES = YES + CLANG_ENABLE_OBJC_ARC = YES + CLANG_ENABLE_OBJC_WEAK = YES + CLANG_MODULES_BUILD_SESSION_FILE = /Library/Developer/Xcode/DerivedData/ModuleCache.noindex/Session.modulevalidation + CLANG_PROFILE_DATA_DIRECTORY = /Library/Developer/Xcode/DerivedData/CalculatorApp-/Build/ProfileData + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES + CLANG_WARN_BOOL_CONVERSION = YES + CLANG_WARN_COMMA = YES + CLANG_WARN_CONSTANT_CONVERSION = YES + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR + CLANG_WARN_DOCUMENTATION_COMMENTS = YES + CLANG_WARN_EMPTY_BODY = YES + CLANG_WARN_ENUM_CONVERSION = YES + CLANG_WARN_INFINITE_RECURSION = YES + CLANG_WARN_INT_CONVERSION = YES + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES + CLANG_WARN_STRICT_PROTOTYPES = YES + CLANG_WARN_SUSPICIOUS_MOVE = YES + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE + CLANG_WARN_UNREACHABLE_CODE = YES + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES + CLASS_FILE_DIR = /Library/Developer/Xcode/DerivedData/CalculatorApp-/Build/Intermediates.noindex/CalculatorApp.build/Debug-iphoneos/CalculatorApp.build/JavaClasses + CLEAN_PRECOMPS = YES + CLONE_HEADERS = NO + CODESIGNING_FOLDER_PATH = /Library/Developer/Xcode/DerivedData/CalculatorApp-/Build/Products/Debug-iphoneos/CalculatorApp.app + CODE_SIGNING_ALLOWED = YES + CODE_SIGNING_REQUIRED = YES + CODE_SIGN_CONTEXT_CLASS = XCiPhoneOSCodeSignContext + CODE_SIGN_IDENTITY = Apple Development + CODE_SIGN_INJECT_BASE_ENTITLEMENTS = YES + CODE_SIGN_STYLE = Automatic + COLOR_DIAGNOSTICS = NO + COMBINE_HIDPI_IMAGES = NO + COMPILATION_CACHE_CAS_PATH = /Library/Developer/Xcode/DerivedData/CompilationCache.noindex + COMPILATION_CACHE_KEEP_CAS_DIRECTORY = YES + COMPILER_INDEX_STORE_ENABLE = Default + COMPOSITE_SDK_DIRS = /Library/Developer/Xcode/DerivedData/CalculatorApp-/Build/Intermediates.noindex/CompositeSDKs + COMPRESS_PNG_FILES = YES + CONFIGURATION = Debug + CONFIGURATION_BUILD_DIR = /Library/Developer/Xcode/DerivedData/CalculatorApp-/Build/Products/Debug-iphoneos + CONFIGURATION_TEMP_DIR = /Library/Developer/Xcode/DerivedData/CalculatorApp-/Build/Intermediates.noindex/CalculatorApp.build/Debug-iphoneos + CONTENTS_FOLDER_PATH = CalculatorApp.app + CONTENTS_FOLDER_PATH_SHALLOW_BUNDLE_NO = CalculatorApp.app/Contents + CONTENTS_FOLDER_PATH_SHALLOW_BUNDLE_YES = CalculatorApp.app + COPYING_PRESERVES_HFS_DATA = NO + COPY_HEADERS_RUN_UNIFDEF = NO + COPY_PHASE_STRIP = NO + CORRESPONDING_SIMULATOR_PLATFORM_DIR = /Applications/Xcode-26.4.0.app/Contents/Developer/Platforms/iPhoneSimulator.platform + CORRESPONDING_SIMULATOR_PLATFORM_NAME = iphonesimulator + CORRESPONDING_SIMULATOR_SDK_DIR = /Applications/Xcode-26.4.0.app/Contents/Developer/Platforms/iPhoneSimulator.platform/Developer/SDKs/iPhoneSimulator26.4.sdk + CORRESPONDING_SIMULATOR_SDK_NAME = iphonesimulator26.4 + CP = /bin/cp + CREATE_INFOPLIST_SECTION_IN_BINARY = NO + CURRENT_ARCH = undefined_arch + CURRENT_PROJECT_VERSION = 1 + CURRENT_VARIANT = normal + DEAD_CODE_STRIPPING = YES + DEBUGGING_SYMBOLS = YES + DEBUG_INFORMATION_FORMAT = dwarf + DEBUG_INFORMATION_VERSION = compiler-default + DEFAULT_COMPILER = com.apple.compilers.llvm.clang.1_0 + DEFAULT_DEXT_INSTALL_PATH = /System/Library/DriverExtensions + DEFAULT_KEXT_INSTALL_PATH = /System/Library/Extensions + DEFINES_MODULE = NO + DEPLOYMENT_LOCATION = NO + DEPLOYMENT_POSTPROCESSING = NO + DEPLOYMENT_TARGET_SETTING_NAME = IPHONEOS_DEPLOYMENT_TARGET + DEPLOYMENT_TARGET_SUGGESTED_VALUES = 12.0 12.1 12.2 12.3 12.4 13.0 13.1 13.2 13.3 13.4 13.5 13.6 14.0 14.1 14.2 14.3 14.4 14.5 14.6 14.7 15.0 15.1 15.2 15.3 15.4 15.5 15.6 16.0 16.1 16.2 16.3 16.4 16.5 16.6 17.0 17.1 17.2 17.3 17.4 17.5 17.6 18.0 18.1 18.2 18.3 18.4 18.5 18.6 26.0 26.2 26.3 26.4 + DERIVED_FILES_DIR = /Library/Developer/Xcode/DerivedData/CalculatorApp-/Build/Intermediates.noindex/CalculatorApp.build/Debug-iphoneos/CalculatorApp.build/DerivedSources + DERIVED_FILE_DIR = /Library/Developer/Xcode/DerivedData/CalculatorApp-/Build/Intermediates.noindex/CalculatorApp.build/Debug-iphoneos/CalculatorApp.build/DerivedSources + DERIVED_SOURCES_DIR = /Library/Developer/Xcode/DerivedData/CalculatorApp-/Build/Intermediates.noindex/CalculatorApp.build/Debug-iphoneos/CalculatorApp.build/DerivedSources + DERIVE_MACCATALYST_PRODUCT_BUNDLE_IDENTIFIER = NO + DEVELOPER_APPLICATIONS_DIR = /Applications/Xcode-26.4.0.app/Contents/Developer/Applications + DEVELOPER_BIN_DIR = /Applications/Xcode-26.4.0.app/Contents/Developer/usr/bin + DEVELOPER_DIR = /Applications/Xcode-26.4.0.app/Contents/Developer + DEVELOPER_FRAMEWORKS_DIR = /Applications/Xcode-26.4.0.app/Contents/Developer/Library/Frameworks + DEVELOPER_FRAMEWORKS_DIR_QUOTED = /Applications/Xcode-26.4.0.app/Contents/Developer/Library/Frameworks + DEVELOPER_LIBRARY_DIR = /Applications/Xcode-26.4.0.app/Contents/Developer/Library + DEVELOPER_SDK_DIR = /Applications/Xcode-26.4.0.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs + DEVELOPER_TOOLS_DIR = /Applications/Xcode-26.4.0.app/Contents/Developer/Tools + DEVELOPER_USR_DIR = /Applications/Xcode-26.4.0.app/Contents/Developer/usr + DEVELOPMENT_LANGUAGE = en + DEVELOPMENT_TEAM = BR6WD3M6ZD + DIAGNOSE_MISSING_TARGET_DEPENDENCIES = YES + DIFF = /usr/bin/diff + DOCUMENTATION_FOLDER_PATH = CalculatorApp.app/en.lproj/Documentation + DONT_GENERATE_INFOPLIST_FILE = NO + DRIVERKIT_DEPLOYMENT_TARGET = 25.4 + DSTROOT = /tmp/CalculatorApp.dst + DT_TOOLCHAIN_DIR = /Applications/Xcode-26.4.0.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain + DUMP_DEPENDENCIES = NO + DUMP_DEPENDENCIES_OUTPUT_PATH = /Library/Developer/Xcode/DerivedData/CalculatorApp-/Build/Intermediates.noindex/CalculatorApp.build/Debug-iphoneos/CalculatorApp.build/CalculatorApp-BuildDependencyInfo.json + DWARF_DSYM_FILE_NAME = CalculatorApp.app.dSYM + DWARF_DSYM_FILE_SHOULD_ACCOMPANY_PRODUCT = NO + DWARF_DSYM_FOLDER_PATH = /Library/Developer/Xcode/DerivedData/CalculatorApp-/Build/Products/Debug-iphoneos + DYNAMIC_LIBRARY_EXTENSION = dylib + EAGER_COMPILATION_ALLOW_SCRIPTS = YES + EAGER_LINKING = NO + EFFECTIVE_PLATFORM_NAME = -iphoneos + EFFECTIVE_SWIFT_VERSION = 5 + EMBEDDED_CONTENT_CONTAINS_SWIFT = NO + EMBEDDED_PROFILE_NAME = embedded.mobileprovision + EMBED_ASSET_PACKS_IN_PRODUCT_BUNDLE = NO + ENABLE_APP_SANDBOX = NO + ENABLE_CODE_COVERAGE = YES + ENABLE_COHORT_ARCHS = NO + ENABLE_CPLUSPLUS_BOUNDS_SAFE_BUFFERS = NO + ENABLE_C_BOUNDS_SAFETY = NO + ENABLE_DEBUG_DYLIB = YES + ENABLE_DEFAULT_HEADER_SEARCH_PATHS = YES + ENABLE_DEFAULT_SEARCH_PATHS = YES + ENABLE_ENHANCED_SECURITY = NO + ENABLE_HARDENED_RUNTIME = NO + ENABLE_HEADER_DEPENDENCIES = YES + ENABLE_INCOMING_NETWORK_CONNECTIONS = NO + ENABLE_ON_DEMAND_RESOURCES = YES + ENABLE_OUTGOING_NETWORK_CONNECTIONS = NO + ENABLE_POINTER_AUTHENTICATION = NO + ENABLE_PREVIEWS = YES + ENABLE_RESOURCE_ACCESS_AUDIO_INPUT = NO + ENABLE_RESOURCE_ACCESS_BLUETOOTH = NO + ENABLE_RESOURCE_ACCESS_CALENDARS = NO + ENABLE_RESOURCE_ACCESS_CAMERA = NO + ENABLE_RESOURCE_ACCESS_CONTACTS = NO + ENABLE_RESOURCE_ACCESS_LOCATION = NO + ENABLE_RESOURCE_ACCESS_PHOTO_LIBRARY = NO + ENABLE_RESOURCE_ACCESS_PRINTING = NO + ENABLE_RESOURCE_ACCESS_USB = NO + ENABLE_SDK_IMPORTS = NO + ENABLE_SECURITY_COMPILER_WARNINGS = NO + ENABLE_STRICT_OBJC_MSGSEND = YES + ENABLE_TESTABILITY = YES + ENABLE_TESTING_SEARCH_PATHS = NO + ENABLE_THREAD_SANITIZER = NO + ENABLE_USER_SCRIPT_SANDBOXING = YES + ENFORCE_VALID_ARCHS = YES + ENTITLEMENTS_ALLOWED = YES + ENTITLEMENTS_DESTINATION = Signature + ENTITLEMENTS_REQUIRED = NO + EXCLUDED_INSTALLSRC_SUBDIRECTORY_PATTERNS = .DS_Store .svn .git .hg CVS + EXCLUDED_RECURSIVE_SEARCH_PATH_SUBDIRECTORIES = *.nib *.lproj *.framework *.gch *.xcode* *.xcassets *.icon (*) .DS_Store CVS .svn .git .hg *.pbproj *.pbxproj + EXECUTABLES_FOLDER_PATH = CalculatorApp.app/Executables + EXECUTABLE_FOLDER_PATH = CalculatorApp.app + EXECUTABLE_FOLDER_PATH_SHALLOW_BUNDLE_NO = CalculatorApp.app/MacOS + EXECUTABLE_FOLDER_PATH_SHALLOW_BUNDLE_YES = CalculatorApp.app + EXECUTABLE_NAME = CalculatorApp + EXECUTABLE_PATH = CalculatorApp.app/CalculatorApp + EXTENSIONS_FOLDER_PATH = CalculatorApp.app/Extensions + FILE_LIST = /Library/Developer/Xcode/DerivedData/CalculatorApp-/Build/Intermediates.noindex/CalculatorApp.build/Debug-iphoneos/CalculatorApp.build/Objects/LinkFileList + FIXED_FILES_DIR = /Library/Developer/Xcode/DerivedData/CalculatorApp-/Build/Intermediates.noindex/CalculatorApp.build/Debug-iphoneos/CalculatorApp.build/FixedFiles + FRAMEWORKS_FOLDER_PATH = CalculatorApp.app/Frameworks + FRAMEWORK_FLAG_PREFIX = -framework + FRAMEWORK_SEARCH_PATHS = /Library/Developer/Xcode/DerivedData/CalculatorApp-/Build/Products/Debug-iphoneos + FRAMEWORK_VERSION = A + FULL_PRODUCT_NAME = CalculatorApp.app + FUSE_BUILD_PHASES = YES + FUSE_BUILD_SCRIPT_PHASES = NO + GCC3_VERSION = 3.3 + GCC_C_LANGUAGE_STANDARD = gnu17 + GCC_DYNAMIC_NO_PIC = NO + GCC_INLINES_ARE_PRIVATE_EXTERN = YES + GCC_NO_COMMON_BLOCKS = YES + GCC_OPTIMIZATION_LEVEL = 0 + GCC_PFE_FILE_C_DIALECTS = c objective-c c++ objective-c++ + GCC_PREPROCESSOR_DEFINITIONS = DEBUG=1 + GCC_SYMBOLS_PRIVATE_EXTERN = NO + GCC_THUMB_SUPPORT = YES + GCC_TREAT_WARNINGS_AS_ERRORS = NO + GCC_VERSION = com.apple.compilers.llvm.clang.1_0 + GCC_VERSION_IDENTIFIER = com_apple_compilers_llvm_clang_1_0 + GCC_WARN_64_TO_32_BIT_CONVERSION = YES + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR + GCC_WARN_UNDECLARED_SELECTOR = YES + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE + GCC_WARN_UNUSED_FUNCTION = YES + GCC_WARN_UNUSED_VARIABLE = YES + GENERATED_MODULEMAP_DIR = /Library/Developer/Xcode/DerivedData/CalculatorApp-/Build/Intermediates.noindex/GeneratedModuleMaps-iphoneos + GENERATE_INFOPLIST_FILE = YES + GENERATE_INTERMEDIATE_TEXT_BASED_STUBS = YES + GENERATE_PKGINFO_FILE = YES + GENERATE_PRELINK_OBJECT_FILE = NO + GENERATE_PROFILING_CODE = NO + GENERATE_TEXT_BASED_STUBS = NO + GID = 20 + GROUP = staff + HEADERMAP_INCLUDES_FLAT_ENTRIES_FOR_TARGET_BEING_BUILT = YES + HEADERMAP_INCLUDES_FRAMEWORK_ENTRIES_FOR_ALL_PRODUCT_TYPES = YES + HEADERMAP_INCLUDES_FRAMEWORK_ENTRIES_FOR_TARGETS_NOT_BEING_BUILT = YES + HEADERMAP_INCLUDES_NONPUBLIC_NONPRIVATE_HEADERS = YES + HEADERMAP_INCLUDES_PROJECT_HEADERS = YES + HEADERMAP_USES_FRAMEWORK_PREFIX_ENTRIES = YES + HEADERMAP_USES_VFS = NO + HEADER_SEARCH_PATHS = /Library/Developer/Xcode/DerivedData/CalculatorApp-/Build/Products/Debug-iphoneos/include + HOME = + HOST_ARCH = arm64 + HOST_PLATFORM = macosx + ICONV = /usr/bin/iconv + IMPLICIT_DEPENDENCY_DOMAIN = default + INDEX_STORE_COMPRESS = NO + INDEX_STORE_ONLY_PROJECT_FILES = NO + INFOPLIST_ENABLE_CFBUNDLEICONS_MERGE = YES + INFOPLIST_EXPAND_BUILD_SETTINGS = YES + INFOPLIST_KEY_CFBundleDisplayName = Calculator + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES + INFOPLIST_KEY_UILaunchScreen_Generation = YES + INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationLandscapeRight UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationPortrait + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = UIInterfaceOrientationLandscapeRight UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown + INFOPLIST_OUTPUT_FORMAT = binary + INFOPLIST_PATH = CalculatorApp.app/Info.plist + INFOPLIST_PREPROCESS = NO + INFOSTRINGS_PATH = CalculatorApp.app/en.lproj/InfoPlist.strings + INLINE_PRIVATE_FRAMEWORKS = NO + INSTALLAPI_IGNORE_SKIP_INSTALL = YES + INSTALLHDRS_COPY_PHASE = NO + INSTALLHDRS_SCRIPT_PHASE = NO + INSTALL_DIR = /tmp/CalculatorApp.dst/Applications + INSTALL_GROUP = staff + INSTALL_MODE_FLAG = u+w,go-w,a+rX + INSTALL_OWNER = cameroncooke + INSTALL_PATH = /Applications + INSTALL_ROOT = /tmp/CalculatorApp.dst + IPHONEOS_DEPLOYMENT_TARGET = 17.0 + IS_UNOPTIMIZED_BUILD = YES + JAVAC_DEFAULT_FLAGS = -J-Xms64m -J-XX:NewSize=4M -J-Dfile.encoding=UTF8 + JAVA_APP_STUB = /System/Library/Frameworks/JavaVM.framework/Resources/MacOS/JavaApplicationStub + JAVA_ARCHIVE_CLASSES = YES + JAVA_ARCHIVE_TYPE = JAR + JAVA_COMPILER = /usr/bin/javac + JAVA_FOLDER_PATH = CalculatorApp.app/Java + JAVA_FRAMEWORK_RESOURCES_DIRS = Resources + JAVA_JAR_FLAGS = cv + JAVA_SOURCE_SUBDIR = . + JAVA_USE_DEPENDENCIES = YES + JAVA_ZIP_FLAGS = -urg + JIKES_DEFAULT_FLAGS = +E +OLDCSO + KASAN_CFLAGS_CLASSIC = -DKASAN=1 -DKASAN_CLASSIC=1 -fsanitize=address -mllvm -asan-globals-live-support -mllvm -asan-force-dynamic-shadow + KASAN_CFLAGS_TBI = -DKASAN=1 -DKASAN_TBI=1 -fsanitize=kernel-hwaddress -mllvm -hwasan-recover=0 -mllvm -hwasan-instrument-atomics=0 -mllvm -hwasan-instrument-stack=1 -mllvm -hwasan-generate-tags-with-calls=1 -mllvm -hwasan-instrument-with-calls=1 -mllvm -hwasan-use-short-granules=0 -mllvm -hwasan-memory-access-callback-prefix=__asan_ + KASAN_DEFAULT_CFLAGS = -DKASAN=1 -DKASAN_CLASSIC=1 -fsanitize=address -mllvm -asan-globals-live-support -mllvm -asan-force-dynamic-shadow + KEEP_PRIVATE_EXTERNS = NO + LD_DEPENDENCY_INFO_FILE = /Library/Developer/Xcode/DerivedData/CalculatorApp-/Build/Intermediates.noindex/CalculatorApp.build/Debug-iphoneos/CalculatorApp.build/Objects-normal/undefined_arch/CalculatorApp_dependency_info.dat + LD_EXPORT_GLOBAL_SYMBOLS = YES + LD_EXPORT_SYMBOLS = YES + LD_GENERATE_MAP_FILE = NO + LD_MAP_FILE_PATH = /Library/Developer/Xcode/DerivedData/CalculatorApp-/Build/Intermediates.noindex/CalculatorApp.build/Debug-iphoneos/CalculatorApp.build/CalculatorApp-LinkMap-normal-undefined_arch.txt + LD_NO_PIE = NO + LD_QUOTE_LINKER_ARGUMENTS_FOR_COMPILER_DRIVER = YES + LD_RUNPATH_SEARCH_PATHS = @executable_path/Frameworks + LD_RUNPATH_SEARCH_PATHS_YES = @loader_path/../Frameworks + LD_SHARED_CACHE_ELIGIBLE = Automatic + LD_WARN_DUPLICATE_LIBRARIES = NO + LD_WARN_UNUSED_DYLIBS = NO + LEGACY_DEVELOPER_DIR = /Applications/Xcode-26.4.0.app/Contents/PlugIns/Xcode3Core.ideplugin/Contents/SharedSupport/Developer + LEX = lex + LIBRARY_DEXT_INSTALL_PATH = /Library/DriverExtensions + LIBRARY_FLAG_NOSPACE = YES + LIBRARY_FLAG_PREFIX = -l + LIBRARY_KEXT_INSTALL_PATH = /Library/Extensions + LIBRARY_SEARCH_PATHS = /Library/Developer/Xcode/DerivedData/CalculatorApp-/Build/Products/Debug-iphoneos + LINKER_DISPLAYS_MANGLED_NAMES = NO + LINK_FILE_LIST_normal_arm64 = /Library/Developer/Xcode/DerivedData/CalculatorApp-/Build/Intermediates.noindex/CalculatorApp.build/Debug-iphoneos/CalculatorApp.build/Objects-normal/arm64/CalculatorApp.LinkFileList + LINK_OBJC_RUNTIME = YES + LINK_WITH_STANDARD_LIBRARIES = YES + LLVM_TARGET_TRIPLE_OS_VERSION = ios17.0 + LLVM_TARGET_TRIPLE_VENDOR = apple + LM_AUX_CONST_METADATA_LIST_PATH_normal_arm64 = /Library/Developer/Xcode/DerivedData/CalculatorApp-/Build/Intermediates.noindex/CalculatorApp.build/Debug-iphoneos/CalculatorApp.build/Objects-normal/arm64/CalculatorApp.SwiftConstValuesFileList + LOCALIZATION_EXPORT_SUPPORTED = YES + LOCALIZATION_PREFERS_STRING_CATALOGS = YES + LOCALIZED_RESOURCES_FOLDER_PATH = CalculatorApp.app/en.lproj + LOCALIZED_STRING_CODE_COMMENTS = NO + LOCALIZED_STRING_MACRO_NAMES = NSLocalizedString CFCopyLocalizedString + LOCALIZED_STRING_SWIFTUI_SUPPORT = YES + LOCAL_ADMIN_APPS_DIR = /Applications/Utilities + LOCAL_APPS_DIR = /Applications + LOCAL_DEVELOPER_DIR = /Library/Developer + LOCAL_LIBRARY_DIR = /Library + LOCROOT = /example_projects/iOS_Calculator + LOCSYMROOT = /example_projects/iOS_Calculator + MACH_O_TYPE = mh_execute + MACOSX_DEPLOYMENT_TARGET = 26.4 + MAC_OS_X_PRODUCT_BUILD_VERSION = 25D2128 + MAC_OS_X_VERSION_ACTUAL = 260301 + MAC_OS_X_VERSION_MAJOR = 260000 + MAC_OS_X_VERSION_MINOR = 260300 + MAKE_MERGEABLE = NO + MARKETING_VERSION = 1.0 + MERGEABLE_LIBRARY = NO + MERGED_BINARY_TYPE = none + MERGE_LINKED_LIBRARIES = NO + METAL_LIBRARY_FILE_BASE = default + METAL_LIBRARY_OUTPUT_DIR = /Library/Developer/Xcode/DerivedData/CalculatorApp-/Build/Products/Debug-iphoneos/CalculatorApp.app + MODULES_FOLDER_PATH = CalculatorApp.app/Modules + MODULE_CACHE_DIR = /Library/Developer/Xcode/DerivedData/ModuleCache.noindex + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE + MTL_FAST_MATH = YES + NATIVE_ARCH = arm64 + NATIVE_ARCH_32_BIT = arm + NATIVE_ARCH_64_BIT = arm64 + NATIVE_ARCH_ACTUAL = arm64 + NO_COMMON = YES + OBJECT_FILE_DIR = /Library/Developer/Xcode/DerivedData/CalculatorApp-/Build/Intermediates.noindex/CalculatorApp.build/Debug-iphoneos/CalculatorApp.build/Objects + OBJECT_FILE_DIR_normal = /Library/Developer/Xcode/DerivedData/CalculatorApp-/Build/Intermediates.noindex/CalculatorApp.build/Debug-iphoneos/CalculatorApp.build/Objects-normal + OBJROOT = /Library/Developer/Xcode/DerivedData/CalculatorApp-/Build/Intermediates.noindex + ONLY_ACTIVE_ARCH = YES + OS = MACOS + OSAC = /usr/bin/osacompile + PACKAGE_TYPE = com.apple.package-type.wrapper.application + PASCAL_STRINGS = YES + PATH = /Applications/Xcode-26.4.0.app/Contents/SharedFrameworks/SwiftBuild.framework/Versions/A/PlugIns/SWBBuildService.bundle/Contents/PlugIns/SWBUniversalPlatformPlugin.bundle/Contents/Frameworks/SWBUniversalPlatform.framework/Resources:/Applications/Xcode-26.4.0.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin:/Applications/Xcode-26.4.0.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/local/bin:/Applications/Xcode-26.4.0.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/libexec:/Applications/Xcode-26.4.0.app/Contents/Developer/Platforms/iPhoneOS.platform/usr/bin:/Applications/Xcode-26.4.0.app/Contents/Developer/Platforms/iPhoneOS.platform/usr/local/bin:/Applications/Xcode-26.4.0.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/usr/bin:/Applications/Xcode-26.4.0.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/usr/local/bin:/Applications/Xcode-26.4.0.app/Contents/Developer/usr/bin:/Applications/Xcode-26.4.0.app/Contents/Developer/usr/local/bin:/node_modules/.bin:/.codex/worktrees/43f4/node_modules/.bin:/.codex/worktrees/node_modules/.bin:/.codex/node_modules/.bin:/node_modules/.bin:/Users/node_modules/.bin:/node_modules/.bin:/opt/homebrew/lib/node_modules/npm/node_modules/@npmcli/run-script/lib/node-gyp-bin:/bin:/.local/share/sentry-devenv/bin:/.antigravity/antigravity/bin:/.local/bin:/.opencode/bin:/.codeium/windsurf/bin:/opt/homebrew/bin:/perl5/bin:/.nvm/versions/node/v22.21.1/bin:/Developer/xcodemake:/.npm-global/bin:/.rbenv/shims:/.dotfiles/bin:/usr/local/bin:/usr/local/sbin:/.oh-my-zsh/bin:/opt/homebrew/sbin:/System/Cryptexes/App/usr/bin:/usr/bin:/bin:/usr/sbin:/sbin:/var/run/com.apple.security.cryptexd/codex.system/bootstrap/usr/local/bin:/var/run/com.apple.security.cryptexd/codex.system/bootstrap/usr/bin:/var/run/com.apple.security.cryptexd/codex.system/bootstrap/usr/appleinternal/bin:/opt/pmk/env/global/bin:/Library/Apple/usr/bin:/usr/local/MacGPG2/bin:/.cargo/bin:/Applications/iTerm.app/Contents/Resources/utilities:/.lmstudio/bin + PATH_PREFIXES_EXCLUDED_FROM_HEADER_DEPENDENCIES = /usr/include /usr/local/include /System/Library/Frameworks /System/Library/PrivateFrameworks /Applications/Xcode-26.4.0.app/Contents/Developer/Headers /Applications/Xcode-26.4.0.app/Contents/Developer/SDKs /Applications/Xcode-26.4.0.app/Contents/Developer/Platforms + PBDEVELOPMENTPLIST_PATH = CalculatorApp.app/pbdevelopment.plist + PER_ARCH_MODULE_FILE_DIR = /Library/Developer/Xcode/DerivedData/CalculatorApp-/Build/Intermediates.noindex/CalculatorApp.build/Debug-iphoneos/CalculatorApp.build/Objects-normal/undefined_arch + PER_ARCH_OBJECT_FILE_DIR = /Library/Developer/Xcode/DerivedData/CalculatorApp-/Build/Intermediates.noindex/CalculatorApp.build/Debug-iphoneos/CalculatorApp.build/Objects-normal/undefined_arch + PER_VARIANT_OBJECT_FILE_DIR = /Library/Developer/Xcode/DerivedData/CalculatorApp-/Build/Intermediates.noindex/CalculatorApp.build/Debug-iphoneos/CalculatorApp.build/Objects-normal + PKGINFO_FILE_PATH = /Library/Developer/Xcode/DerivedData/CalculatorApp-/Build/Intermediates.noindex/CalculatorApp.build/Debug-iphoneos/CalculatorApp.build/PkgInfo + PKGINFO_PATH = CalculatorApp.app/PkgInfo + PLATFORM_DEVELOPER_APPLICATIONS_DIR = /Applications/Xcode-26.4.0.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/Applications + PLATFORM_DEVELOPER_BIN_DIR = /Applications/Xcode-26.4.0.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/usr/bin + PLATFORM_DEVELOPER_LIBRARY_DIR = /Applications/Xcode-26.4.0.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/Library + PLATFORM_DEVELOPER_SDK_DIR = /Applications/Xcode-26.4.0.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs + PLATFORM_DEVELOPER_TOOLS_DIR = /Applications/Xcode-26.4.0.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/Tools + PLATFORM_DEVELOPER_USR_DIR = /Applications/Xcode-26.4.0.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/usr + PLATFORM_DIR = /Applications/Xcode-26.4.0.app/Contents/Developer/Platforms/iPhoneOS.platform + PLATFORM_DISPLAY_NAME = iOS + PLATFORM_FAMILY_NAME = iOS + PLATFORM_NAME = iphoneos + PLATFORM_PREFERRED_ARCH = arm64 + PLATFORM_PRODUCT_BUILD_VERSION = 23E237 + PLATFORM_REQUIRES_SWIFT_AUTOLINK_EXTRACT = NO + PLATFORM_REQUIRES_SWIFT_MODULEWRAP = NO + PLATFORM_USES_DSYMS = YES + PLIST_FILE_OUTPUT_FORMAT = binary + PLUGINS_FOLDER_PATH = CalculatorApp.app/PlugIns + PRECOMPS_INCLUDE_HEADERS_FROM_BUILT_PRODUCTS_DIR = YES + PRECOMP_DESTINATION_DIR = /Library/Developer/Xcode/DerivedData/CalculatorApp-/Build/Intermediates.noindex/CalculatorApp.build/Debug-iphoneos/CalculatorApp.build/PrefixHeaders + PRIVATE_HEADERS_FOLDER_PATH = CalculatorApp.app/PrivateHeaders + PROCESSED_INFOPLIST_PATH = /Library/Developer/Xcode/DerivedData/CalculatorApp-/Build/Intermediates.noindex/CalculatorApp.build/Debug-iphoneos/CalculatorApp.build/Objects-normal/undefined_arch/Processed-Info.plist + PRODUCT_BUNDLE_IDENTIFIER = io.sentry.calculatorapp + PRODUCT_BUNDLE_PACKAGE_TYPE = APPL + PRODUCT_DISPLAY_NAME = Calculator + PRODUCT_MODULE_NAME = CalculatorApp + PRODUCT_NAME = CalculatorApp + PRODUCT_SETTINGS_PATH = + PRODUCT_TYPE = com.apple.product-type.application + PROFILING_CODE = NO + PROJECT = CalculatorApp + PROJECT_DERIVED_FILE_DIR = /Library/Developer/Xcode/DerivedData/CalculatorApp-/Build/Intermediates.noindex/CalculatorApp.build/DerivedSources + PROJECT_DIR = /example_projects/iOS_Calculator + PROJECT_FILE_PATH = /example_projects/iOS_Calculator/CalculatorApp.xcodeproj + PROJECT_GUID = 5f13bb9ad2ee840212986da3cd4b87b0 + PROJECT_NAME = CalculatorApp + PROJECT_TEMP_DIR = /Library/Developer/Xcode/DerivedData/CalculatorApp-/Build/Intermediates.noindex/CalculatorApp.build + PROJECT_TEMP_ROOT = /Library/Developer/Xcode/DerivedData/CalculatorApp-/Build/Intermediates.noindex + PROVISIONING_PROFILE_REQUIRED = YES + PROVISIONING_PROFILE_REQUIRED_YES_YES = YES + PROVISIONING_PROFILE_SUPPORTED = YES + PUBLIC_HEADERS_FOLDER_PATH = CalculatorApp.app/Headers + RECOMMENDED_IPHONEOS_DEPLOYMENT_TARGET = 15.0 + RECURSIVE_SEARCH_PATHS_FOLLOW_SYMLINKS = YES + REMOVE_CVS_FROM_RESOURCES = YES + REMOVE_GIT_FROM_RESOURCES = YES + REMOVE_HEADERS_FROM_EMBEDDED_BUNDLES = YES + REMOVE_HG_FROM_RESOURCES = YES + REMOVE_STATIC_EXECUTABLES_FROM_EMBEDDED_BUNDLES = YES + REMOVE_SVN_FROM_RESOURCES = YES + RESCHEDULE_INDEPENDENT_HEADERS_PHASES = YES + REZ_COLLECTOR_DIR = /Library/Developer/Xcode/DerivedData/CalculatorApp-/Build/Intermediates.noindex/CalculatorApp.build/Debug-iphoneos/CalculatorApp.build/ResourceManagerResources + REZ_OBJECTS_DIR = /Library/Developer/Xcode/DerivedData/CalculatorApp-/Build/Intermediates.noindex/CalculatorApp.build/Debug-iphoneos/CalculatorApp.build/ResourceManagerResources/Objects + REZ_SEARCH_PATHS = /Library/Developer/Xcode/DerivedData/CalculatorApp-/Build/Products/Debug-iphoneos + RPATH_ORIGIN = @loader_path + RUNTIME_EXCEPTION_ALLOW_DYLD_ENVIRONMENT_VARIABLES = NO + RUNTIME_EXCEPTION_ALLOW_JIT = NO + RUNTIME_EXCEPTION_ALLOW_UNSIGNED_EXECUTABLE_MEMORY = NO + RUNTIME_EXCEPTION_DEBUGGING_TOOL = NO + RUNTIME_EXCEPTION_DISABLE_EXECUTABLE_PAGE_PROTECTION = NO + RUNTIME_EXCEPTION_DISABLE_LIBRARY_VALIDATION = NO + SCANNING_PCM_KEEP_CACHE_DIRECTORY = YES + SCAN_ALL_SOURCE_FILES_FOR_INCLUDES = NO + SCRIPTS_FOLDER_PATH = CalculatorApp.app/Scripts + SDKROOT = /Applications/Xcode-26.4.0.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS26.4.sdk + SDK_DIR = /Applications/Xcode-26.4.0.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS26.4.sdk + SDK_DIR_iphoneos = /Applications/Xcode-26.4.0.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS26.4.sdk + SDK_DIR_iphoneos26_4 = /Applications/Xcode-26.4.0.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS26.4.sdk + SDK_NAME = iphoneos26.4 + SDK_NAMES = iphoneos26.4 + SDK_PRODUCT_BUILD_VERSION = 23E237 + SDK_STAT_CACHE_DIR = /Library/Developer/Xcode/DerivedData + SDK_STAT_CACHE_ENABLE = YES + SDK_STAT_CACHE_PATH = /Library/Developer/Xcode/DerivedData/SDKStatCaches.noindex/iphoneos26.4-23E237-c1e9a37d8fcda5dee89abd67dc927a23.sdkstatcache + SDK_VERSION = 26.4 + SDK_VERSION_ACTUAL = 260400 + SDK_VERSION_MAJOR = 260000 + SDK_VERSION_MINOR = 260400 + SED = /usr/bin/sed + SEPARATE_STRIP = NO + SEPARATE_SYMBOL_EDIT = NO + SET_DIR_MODE_OWNER_GROUP = YES + SET_FILE_MODE_OWNER_GROUP = NO + SHALLOW_BUNDLE = YES + SHALLOW_BUNDLE_TRIPLE = ios + SHALLOW_BUNDLE_ios_macabi = NO + SHALLOW_BUNDLE_macos = NO + SHARED_DERIVED_FILE_DIR = /Library/Developer/Xcode/DerivedData/CalculatorApp-/Build/Products/Debug-iphoneos/DerivedSources + SHARED_FRAMEWORKS_FOLDER_PATH = CalculatorApp.app/SharedFrameworks + SHARED_PRECOMPS_DIR = /Library/Developer/Xcode/DerivedData/CalculatorApp-/Build/Intermediates.noindex/PrecompiledHeaders + SHARED_SUPPORT_FOLDER_PATH = CalculatorApp.app/SharedSupport + SKIP_INSTALL = NO + SKIP_MERGEABLE_LIBRARY_BUNDLE_HOOK = NO + SOURCE_ROOT = /example_projects/iOS_Calculator + SRCROOT = /example_projects/iOS_Calculator + STRINGSDATA_DIR = /Library/Developer/Xcode/DerivedData/CalculatorApp-/Build/Intermediates.noindex/CalculatorApp.build/Debug-iphoneos/CalculatorApp.build/Objects-normal/undefined_arch + STRINGSDATA_ROOT = /Library/Developer/Xcode/DerivedData/CalculatorApp-/Build/Intermediates.noindex/CalculatorApp.build/Debug-iphoneos/CalculatorApp.build + STRINGS_FILE_INFOPLIST_RENAME = YES + STRINGS_FILE_OUTPUT_ENCODING = binary + STRING_CATALOG_GENERATE_SYMBOLS = NO + STRIP_BITCODE_FROM_COPIED_FILES = YES + STRIP_INSTALLED_PRODUCT = NO + STRIP_STYLE = all + STRIP_SWIFT_SYMBOLS = YES + SUPPORTED_DEVICE_FAMILIES = 1,2 + SUPPORTED_PLATFORMS = iphoneos iphonesimulator + SUPPORTS_MACCATALYST = NO + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = YES + SUPPORTS_ON_DEMAND_RESOURCES = YES + SUPPORTS_TEXT_BASED_API = NO + SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = YES + SUPPRESS_WARNINGS = NO + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG + SWIFT_EMIT_CONST_VALUE_PROTOCOLS = AnyResolverProviding AppEntity AppEnum AppExtension AppIntent AppIntentsPackage AppShortcutProviding AppShortcutsProvider AppUnionValue AppUnionValueCasesProviding DynamicOptionsProvider EntityQuery ExtensionPointDefining IntentValueQuery Resolver TransientEntity _AssistantIntentsProvider _GenerativeFunctionExtractable _IntentValueRepresentable + SWIFT_EMIT_LOC_STRINGS = YES + SWIFT_ENABLE_EXPLICIT_MODULES = YES + SWIFT_OPTIMIZATION_LEVEL = -Onone + SWIFT_PLATFORM_TARGET_PREFIX = ios + SWIFT_RESPONSE_FILE_PATH_normal_arm64 = /Library/Developer/Xcode/DerivedData/CalculatorApp-/Build/Intermediates.noindex/CalculatorApp.build/Debug-iphoneos/CalculatorApp.build/Objects-normal/arm64/CalculatorApp.SwiftFileList + SWIFT_VERSION = 5.0 + SYMROOT = /Library/Developer/Xcode/DerivedData/CalculatorApp-/Build/Products + SYSTEM_ADMIN_APPS_DIR = /Applications/Utilities + SYSTEM_APPS_DIR = /Applications + SYSTEM_CORE_SERVICES_DIR = /System/Library/CoreServices + SYSTEM_DEMOS_DIR = /Applications/Extras + SYSTEM_DEVELOPER_APPS_DIR = /Applications/Xcode-26.4.0.app/Contents/Developer/Applications + SYSTEM_DEVELOPER_BIN_DIR = /Applications/Xcode-26.4.0.app/Contents/Developer/usr/bin + SYSTEM_DEVELOPER_DEMOS_DIR = /Applications/Xcode-26.4.0.app/Contents/Developer/Applications/Utilities/Built Examples + SYSTEM_DEVELOPER_DIR = /Applications/Xcode-26.4.0.app/Contents/Developer + SYSTEM_DEVELOPER_DOC_DIR = /Applications/Xcode-26.4.0.app/Contents/Developer/ADC Reference Library + SYSTEM_DEVELOPER_GRAPHICS_TOOLS_DIR = /Applications/Xcode-26.4.0.app/Contents/Developer/Applications/Graphics Tools + SYSTEM_DEVELOPER_JAVA_TOOLS_DIR = /Applications/Xcode-26.4.0.app/Contents/Developer/Applications/Java Tools + SYSTEM_DEVELOPER_PERFORMANCE_TOOLS_DIR = /Applications/Xcode-26.4.0.app/Contents/Developer/Applications/Performance Tools + SYSTEM_DEVELOPER_RELEASENOTES_DIR = /Applications/Xcode-26.4.0.app/Contents/Developer/ADC Reference Library/releasenotes + SYSTEM_DEVELOPER_TOOLS = /Applications/Xcode-26.4.0.app/Contents/Developer/Tools + SYSTEM_DEVELOPER_TOOLS_DOC_DIR = /Applications/Xcode-26.4.0.app/Contents/Developer/ADC Reference Library/documentation/DeveloperTools + SYSTEM_DEVELOPER_TOOLS_RELEASENOTES_DIR = /Applications/Xcode-26.4.0.app/Contents/Developer/ADC Reference Library/releasenotes/DeveloperTools + SYSTEM_DEVELOPER_USR_DIR = /Applications/Xcode-26.4.0.app/Contents/Developer/usr + SYSTEM_DEVELOPER_UTILITIES_DIR = /Applications/Xcode-26.4.0.app/Contents/Developer/Applications/Utilities + SYSTEM_DEXT_INSTALL_PATH = /System/Library/DriverExtensions + SYSTEM_DOCUMENTATION_DIR = /Library/Documentation + SYSTEM_EXTENSIONS_FOLDER_PATH = CalculatorApp.app/SystemExtensions + SYSTEM_EXTENSIONS_FOLDER_PATH_SHALLOW_BUNDLE_NO = CalculatorApp.app/Library/SystemExtensions + SYSTEM_EXTENSIONS_FOLDER_PATH_SHALLOW_BUNDLE_YES = CalculatorApp.app/SystemExtensions + SYSTEM_KEXT_INSTALL_PATH = /System/Library/Extensions + SYSTEM_LIBRARY_DIR = /System/Library + TAPI_DEMANGLE = YES + TAPI_ENABLE_PROJECT_HEADERS = NO + TAPI_LANGUAGE = objective-c + TAPI_LANGUAGE_STANDARD = compiler-default + TAPI_USE_SRCROOT = YES + TAPI_VERIFY_MODE = Pedantic + TARGETED_DEVICE_FAMILY = 1,2 + TARGETNAME = CalculatorApp + TARGET_BUILD_DIR = /Library/Developer/Xcode/DerivedData/CalculatorApp-/Build/Products/Debug-iphoneos + TARGET_DEVICE_IDENTIFIER = 00006040-001849590220801C + TARGET_DEVICE_MODEL = Mac16,8 + TARGET_DEVICE_OS_VERSION = 26.3.1 + TARGET_DEVICE_PLATFORM_NAME = macosx + TARGET_NAME = CalculatorApp + TARGET_TEMP_DIR = /Library/Developer/Xcode/DerivedData/CalculatorApp-/Build/Intermediates.noindex/CalculatorApp.build/Debug-iphoneos/CalculatorApp.build + TEMP_DIR = /Library/Developer/Xcode/DerivedData/CalculatorApp-/Build/Intermediates.noindex/CalculatorApp.build/Debug-iphoneos/CalculatorApp.build + TEMP_FILES_DIR = /Library/Developer/Xcode/DerivedData/CalculatorApp-/Build/Intermediates.noindex/CalculatorApp.build/Debug-iphoneos/CalculatorApp.build + TEMP_FILE_DIR = /Library/Developer/Xcode/DerivedData/CalculatorApp-/Build/Intermediates.noindex/CalculatorApp.build/Debug-iphoneos/CalculatorApp.build + TEMP_ROOT = /Library/Developer/Xcode/DerivedData/CalculatorApp-/Build/Intermediates.noindex + TEMP_SANDBOX_DIR = /Library/Developer/Xcode/DerivedData/CalculatorApp-/Build/Intermediates.noindex/TemporaryTaskSandboxes + TEST_FRAMEWORK_SEARCH_PATHS = /Applications/Xcode-26.4.0.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/Library/Frameworks /Applications/Xcode-26.4.0.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS26.4.sdk/Developer/Library/Frameworks + TEST_LIBRARY_SEARCH_PATHS = /Applications/Xcode-26.4.0.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/usr/lib + TOOLCHAINS = com.apple.dt.toolchain.XcodeDefault + TOOLCHAIN_DIR = /Applications/Xcode-26.4.0.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain + TREAT_MISSING_BASELINES_AS_TEST_FAILURES = NO + TREAT_MISSING_SCRIPT_PHASE_OUTPUTS_AS_ERRORS = NO + TVOS_DEPLOYMENT_TARGET = 26.4 + UID = 501 + UNINSTALLED_PRODUCTS_DIR = /Library/Developer/Xcode/DerivedData/CalculatorApp-/Build/Intermediates.noindex/UninstalledProducts + UNLOCALIZED_RESOURCES_FOLDER_PATH = CalculatorApp.app + UNLOCALIZED_RESOURCES_FOLDER_PATH_SHALLOW_BUNDLE_NO = CalculatorApp.app/Resources + UNLOCALIZED_RESOURCES_FOLDER_PATH_SHALLOW_BUNDLE_YES = CalculatorApp.app + UNSTRIPPED_PRODUCT = NO + USER = cameroncooke + USER_APPS_DIR = /Applications + USER_LIBRARY_DIR = /Library + USE_DYNAMIC_NO_PIC = YES + USE_HEADERMAP = YES + USE_HEADER_SYMLINKS = NO + VALIDATE_DEVELOPMENT_ASSET_PATHS = YES_ERROR + VALIDATE_PRODUCT = NO + VALID_ARCHS = arm64 arm64e armv7 armv7s + VERBOSE_PBXCP = NO + VERSIONPLIST_PATH = CalculatorApp.app/version.plist + VERSION_INFO_BUILDER = cameroncooke + VERSION_INFO_FILE = CalculatorApp_vers.c + VERSION_INFO_STRING = "@(#)PROGRAM:CalculatorApp PROJECT:CalculatorApp-1" + WATCHOS_DEPLOYMENT_TARGET = 26.4 + WORKSPACE_DIR = /example_projects/iOS_Calculator + WRAPPER_EXTENSION = app + WRAPPER_NAME = CalculatorApp.app + WRAPPER_SUFFIX = .app + WRAP_ASSET_PACKS_IN_SEPARATE_DIRECTORIES = NO + XCODE_APP_SUPPORT_DIR = /Applications/Xcode-26.4.0.app/Contents/Developer/Library/Xcode + XCODE_PRODUCT_BUILD_VERSION = 17E192 + XCODE_VERSION_ACTUAL = 2640 + XCODE_VERSION_MAJOR = 2600 + XCODE_VERSION_MINOR = 2640 + XPCSERVICES_FOLDER_PATH = CalculatorApp.app/XPCServices + XROS_DEPLOYMENT_TARGET = 26.4 + YACC = yacc + _DISCOVER_COMMAND_LINE_LINKER_INPUTS = YES + _DISCOVER_COMMAND_LINE_LINKER_INPUTS_INCLUDE_WL = YES + _LD_MULTIARCH = YES + _WRAPPER_CONTENTS_DIR_SHALLOW_BUNDLE_NO = /Contents + _WRAPPER_PARENT_PATH_SHALLOW_BUNDLE_NO = /.. + _WRAPPER_RESOURCES_DIR_SHALLOW_BUNDLE_NO = /Resources + __DIAGNOSE_DEPRECATED_ARCHS = YES + __IS_NOT_MACOS = YES + __IS_NOT_MACOS_macosx = NO + __IS_NOT_SIMULATOR = YES + __IS_NOT_SIMULATOR_simulator = NO + __ORIGINAL_SDK_DEFINED_LLVM_TARGET_TRIPLE_SYS = ios + arch = undefined_arch + variant = normal diff --git a/src/snapshot-tests/__fixtures__/project-scaffolding/scaffold-ios--error-existing.txt b/src/snapshot-tests/__fixtures__/project-scaffolding/scaffold-ios--error-existing.txt new file mode 100644 index 00000000..4b265ca2 --- /dev/null +++ b/src/snapshot-tests/__fixtures__/project-scaffolding/scaffold-ios--error-existing.txt @@ -0,0 +1,8 @@ + +๐Ÿ“ Scaffold iOS Project + + Name: SnapshotTestApp + Path: /ios-existing + Platform: iOS + +โŒ Xcode project files already exist in /ios-existing diff --git a/src/snapshot-tests/__fixtures__/project-scaffolding/scaffold-ios--success.txt b/src/snapshot-tests/__fixtures__/project-scaffolding/scaffold-ios--success.txt new file mode 100644 index 00000000..7ed76a2b --- /dev/null +++ b/src/snapshot-tests/__fixtures__/project-scaffolding/scaffold-ios--success.txt @@ -0,0 +1,8 @@ + +๐Ÿ“ Scaffold iOS Project + + Name: SnapshotTestApp + Path: /ios + Platform: iOS + +โœ… Project scaffolded successfully at /ios. diff --git a/src/snapshot-tests/__fixtures__/project-scaffolding/scaffold-macos--success.txt b/src/snapshot-tests/__fixtures__/project-scaffolding/scaffold-macos--success.txt new file mode 100644 index 00000000..6f2941b4 --- /dev/null +++ b/src/snapshot-tests/__fixtures__/project-scaffolding/scaffold-macos--success.txt @@ -0,0 +1,8 @@ + +๐Ÿ“ Scaffold macOS Project + + Name: SnapshotTestMacApp + Path: /macos + Platform: macOS + +โœ… Project scaffolded successfully at /macos. diff --git a/src/snapshot-tests/__fixtures__/session-management/session-clear-defaults--success.txt b/src/snapshot-tests/__fixtures__/session-management/session-clear-defaults--success.txt new file mode 100644 index 00000000..e3386962 --- /dev/null +++ b/src/snapshot-tests/__fixtures__/session-management/session-clear-defaults--success.txt @@ -0,0 +1,4 @@ + +โš™๏ธ Clear Defaults + +โœ… Session defaults cleared. diff --git a/src/snapshot-tests/__fixtures__/session-management/session-set-defaults--success.txt b/src/snapshot-tests/__fixtures__/session-management/session-set-defaults--success.txt new file mode 100644 index 00000000..03d702e1 --- /dev/null +++ b/src/snapshot-tests/__fixtures__/session-management/session-set-defaults--success.txt @@ -0,0 +1,6 @@ + +โš™๏ธ Set Defaults + + โ”œ workspacePath: example_projects/iOS_Calculator/CalculatorApp.xcworkspace + โ”” scheme: CalculatorApp +โœ… Session defaults updated. diff --git a/src/snapshot-tests/__fixtures__/session-management/session-show-defaults--success.txt b/src/snapshot-tests/__fixtures__/session-management/session-show-defaults--success.txt new file mode 100644 index 00000000..7ad2fbd5 --- /dev/null +++ b/src/snapshot-tests/__fixtures__/session-management/session-show-defaults--success.txt @@ -0,0 +1,7 @@ + +โš™๏ธ Show Defaults + + Active Profile: global + + โ”œ workspacePath: example_projects/iOS_Calculator/CalculatorApp.xcworkspace + โ”” scheme: CalculatorApp diff --git a/src/snapshot-tests/__fixtures__/session-management/session-sync-xcode-defaults--success.txt b/src/snapshot-tests/__fixtures__/session-management/session-sync-xcode-defaults--success.txt new file mode 100644 index 00000000..90ac1f09 --- /dev/null +++ b/src/snapshot-tests/__fixtures__/session-management/session-sync-xcode-defaults--success.txt @@ -0,0 +1,5 @@ + +โš™๏ธ Sync Xcode Defaults + + โ”” scheme: CalculatorApp +โœ… Synced session defaults from Xcode IDE. diff --git a/src/snapshot-tests/__fixtures__/session-management/session-use-defaults-profile--success.txt b/src/snapshot-tests/__fixtures__/session-management/session-use-defaults-profile--success.txt new file mode 100644 index 00000000..9618a34a --- /dev/null +++ b/src/snapshot-tests/__fixtures__/session-management/session-use-defaults-profile--success.txt @@ -0,0 +1,7 @@ + +โš™๏ธ Use Defaults Profile + + Active Profile: global + Known Profiles: (none) + +โœ… Active profile: global diff --git a/src/snapshot-tests/__fixtures__/simulator-management/boot--error-invalid-id.txt b/src/snapshot-tests/__fixtures__/simulator-management/boot--error-invalid-id.txt new file mode 100644 index 00000000..578d8123 --- /dev/null +++ b/src/snapshot-tests/__fixtures__/simulator-management/boot--error-invalid-id.txt @@ -0,0 +1,6 @@ + +๐Ÿ“ฑ Boot Simulator + + Simulator: + +โŒ Boot simulator operation failed: Invalid device or device pair: diff --git a/src/snapshot-tests/__fixtures__/simulator-management/list--success.txt b/src/snapshot-tests/__fixtures__/simulator-management/list--success.txt new file mode 100644 index 00000000..69b81c81 --- /dev/null +++ b/src/snapshot-tests/__fixtures__/simulator-management/list--success.txt @@ -0,0 +1,59 @@ + +๐Ÿ“ฑ List Simulators + +com.apple.CoreSimulator.SimRuntime.iOS-26-4 + +Name UUID State +--------------------- ------------------------------------ -------- +iPhone 17 Pro Shutdown +iPhone 17 Pro Max Shutdown +iPhone 17e Shutdown +iPhone Air Shutdown +iPhone 17 Booted +iPad Pro 13-inch (M5) Shutdown +iPad Pro 11-inch (M5) Shutdown +iPad mini (A17 Pro) Shutdown +iPad Air 13-inch (M4) Shutdown +iPad Air 11-inch (M4) Shutdown +iPad (A16) Shutdown + +com.apple.CoreSimulator.SimRuntime.xrOS-26-2 + +Name UUID State +---------------- ------------------------------------ -------- +Apple Vision Pro Shutdown + +com.apple.CoreSimulator.SimRuntime.watchOS-26-2 + +Name UUID State +---------------------------- ------------------------------------ -------- +Apple Watch Series 11 (46mm) Shutdown +Apple Watch Series 11 (42mm) Shutdown +Apple Watch Ultra 3 (49mm) Shutdown +Apple Watch SE 3 (44mm) Shutdown +Apple Watch SE 3 (40mm) Shutdown + +com.apple.CoreSimulator.SimRuntime.tvOS-26-2 + +Name UUID State +--------------------------------------- ------------------------------------ -------- +Apple TV 4K (3rd generation) Shutdown +Apple TV 4K (3rd generation) (at 1080p) Shutdown +Apple TV Shutdown + +com.apple.CoreSimulator.SimRuntime.iOS-26-2 + +Name UUID State +--------------------- ------------------------------------ -------- +iPhone 17 Pro Shutdown +iPhone 17 Pro Max Shutdown +iPhone Air Shutdown +iPhone 17 Shutdown +iPhone 16e Shutdown +iPad Pro 13-inch (M5) Shutdown +iPad Pro 11-inch (M5) Shutdown +iPad mini (A17 Pro) Shutdown +iPad (A16) Shutdown +iPad Air 13-inch (M3) Shutdown +iPad Air 11-inch (M3) Shutdown +โœ… Listed available simulators diff --git a/src/snapshot-tests/__fixtures__/simulator-management/open--success.txt b/src/snapshot-tests/__fixtures__/simulator-management/open--success.txt new file mode 100644 index 00000000..d8114a00 --- /dev/null +++ b/src/snapshot-tests/__fixtures__/simulator-management/open--success.txt @@ -0,0 +1,4 @@ + +๐Ÿ“ฑ Open Simulator + +โœ… Simulator app opened diff --git a/src/snapshot-tests/__fixtures__/simulator-management/reset-location--success.txt b/src/snapshot-tests/__fixtures__/simulator-management/reset-location--success.txt new file mode 100644 index 00000000..2271d799 --- /dev/null +++ b/src/snapshot-tests/__fixtures__/simulator-management/reset-location--success.txt @@ -0,0 +1,6 @@ + +๐Ÿ“ Reset Location + + Simulator: + +โœ… Location reset to default diff --git a/src/snapshot-tests/__fixtures__/simulator-management/set-appearance--success.txt b/src/snapshot-tests/__fixtures__/simulator-management/set-appearance--success.txt new file mode 100644 index 00000000..826960cb --- /dev/null +++ b/src/snapshot-tests/__fixtures__/simulator-management/set-appearance--success.txt @@ -0,0 +1,7 @@ + +๐ŸŽจ Set Appearance + + Simulator: + Mode: dark + +โœ… Appearance set to dark mode diff --git a/src/snapshot-tests/__fixtures__/simulator-management/set-location--success.txt b/src/snapshot-tests/__fixtures__/simulator-management/set-location--success.txt new file mode 100644 index 00000000..136e8186 --- /dev/null +++ b/src/snapshot-tests/__fixtures__/simulator-management/set-location--success.txt @@ -0,0 +1,7 @@ + +๐Ÿ“ Set Location + + Simulator: + Coordinates: 37.7749,-122.4194 + +โœ… Location set to 37.7749,-122.4194 diff --git a/src/snapshot-tests/__fixtures__/simulator-management/statusbar--success.txt b/src/snapshot-tests/__fixtures__/simulator-management/statusbar--success.txt new file mode 100644 index 00000000..20c323ed --- /dev/null +++ b/src/snapshot-tests/__fixtures__/simulator-management/statusbar--success.txt @@ -0,0 +1,7 @@ + +๐Ÿ“ฑ Statusbar + + Simulator: + Data Network: wifi + +โœ… Status bar data network set to wifi diff --git a/src/snapshot-tests/__fixtures__/simulator/build--error-wrong-scheme.txt b/src/snapshot-tests/__fixtures__/simulator/build--error-wrong-scheme.txt new file mode 100644 index 00000000..efb30273 --- /dev/null +++ b/src/snapshot-tests/__fixtures__/simulator/build--error-wrong-scheme.txt @@ -0,0 +1,18 @@ + +๐Ÿ”จ Build + + Scheme: NONEXISTENT + Configuration: Debug + Platform: iOS Simulator + +โš™๏ธ iOS Simulator Build build + + Scheme: NONEXISTENT + Platform: iOS Simulator + Configuration: Debug + +โŒ iOS Simulator Build build failed for scheme NONEXISTENT. + +Errors (1): + +โŒ Build failed. (โฑ๏ธ ) diff --git a/src/snapshot-tests/__fixtures__/simulator/build--success.txt b/src/snapshot-tests/__fixtures__/simulator/build--success.txt new file mode 100644 index 00000000..d1edf812 --- /dev/null +++ b/src/snapshot-tests/__fixtures__/simulator/build--success.txt @@ -0,0 +1,11 @@ + +๐Ÿ”จ Build + + Scheme: CalculatorApp + Configuration: Debug + Platform: iOS Simulator + +โœ… Build succeeded. (โฑ๏ธ ) + +Next steps: +1. Get built app path in simulator derived data: xcodebuildmcp simulator get-app-path --simulator-name "iPhone 17" --scheme "CalculatorApp" --platform "iOS Simulator" diff --git a/src/snapshot-tests/__fixtures__/simulator/build-and-run--success.txt b/src/snapshot-tests/__fixtures__/simulator/build-and-run--success.txt new file mode 100644 index 00000000..3b949498 --- /dev/null +++ b/src/snapshot-tests/__fixtures__/simulator/build-and-run--success.txt @@ -0,0 +1,25 @@ + +๐Ÿš€ Build & Run + + Scheme: CalculatorApp + Configuration: Debug + Platform: iOS Simulator + +โ„น๏ธ Resolving app path +โœ… Resolving app path +โ„น๏ธ Booting simulator +โœ… Booting simulator +โ„น๏ธ Installing app +โœ… Installing app +โ„น๏ธ Launching app + +โœ… Build succeeded. (โฑ๏ธ ) +โœ… Build & Run complete + โ”œ App Path: /Library/Developer/Xcode/DerivedData/CalculatorApp-/Build/Products/Debug-iphonesimulator/CalculatorApp.app + โ”” Bundle ID: io.sentry.calculatorapp + +Next steps: +1. Capture structured logs (app continues running): xcodebuildmcp logging start-simulator-log-capture --simulator-id "" --bundle-id "io.sentry.calculatorapp" +2. Stop app in simulator: xcodebuildmcp simulator stop --simulator-id "" --bundle-id "io.sentry.calculatorapp" +3. Capture console + structured logs (app restarts): xcodebuildmcp logging start-simulator-log-capture --simulator-id "" --bundle-id "io.sentry.calculatorapp" --capture-console +4. Launch app with logs in one step: xcodebuildmcp simulator launch-app-with-logs --simulator-id "" --bundle-id "io.sentry.calculatorapp" diff --git a/src/snapshot-tests/__fixtures__/simulator/get-app-path--success.txt b/src/snapshot-tests/__fixtures__/simulator/get-app-path--success.txt new file mode 100644 index 00000000..3e638872 --- /dev/null +++ b/src/snapshot-tests/__fixtures__/simulator/get-app-path--success.txt @@ -0,0 +1,11 @@ + +๐Ÿ” Get App Path + + Scheme: CalculatorApp + Workspace: example_projects/iOS_Calculator/CalculatorApp.xcworkspace + Configuration: Debug + Platform: iOS Simulator + Simulator: iPhone 17 + + โ”” App Path: /Library/Developer/Xcode/DerivedData/CalculatorApp-/Build/Products/Debug-iphonesimulator/CalculatorApp.app +โœ… App path resolved diff --git a/src/snapshot-tests/__fixtures__/simulator/install--error-invalid-app.txt b/src/snapshot-tests/__fixtures__/simulator/install--error-invalid-app.txt new file mode 100644 index 00000000..03eddd8e --- /dev/null +++ b/src/snapshot-tests/__fixtures__/simulator/install--error-invalid-app.txt @@ -0,0 +1,12 @@ + +๐Ÿ“ฆ Install App + + Simulator: + App Path: /NotAnApp.app + +โŒ Install app in simulator operation failed: An error was encountered processing the command (domain=IXErrorDomain, code=13): +Simulator device failed to install the application. +Missing bundle ID. +Underlying error (domain=IXErrorDomain, code=13): + Failed to get bundle ID from /NotAnApp.app + Missing bundle ID. diff --git a/src/snapshot-tests/__fixtures__/simulator/launch-app--success.txt b/src/snapshot-tests/__fixtures__/simulator/launch-app--success.txt new file mode 100644 index 00000000..e8e660f2 --- /dev/null +++ b/src/snapshot-tests/__fixtures__/simulator/launch-app--success.txt @@ -0,0 +1,7 @@ + +๐Ÿš€ Launch App + + Simulator: + Bundle ID: io.sentry.calculatorapp + +โœ… App launched successfully in simulator diff --git a/src/snapshot-tests/__fixtures__/simulator/launch-app-with-logs--success.txt b/src/snapshot-tests/__fixtures__/simulator/launch-app-with-logs--success.txt new file mode 100644 index 00000000..acc18e0c --- /dev/null +++ b/src/snapshot-tests/__fixtures__/simulator/launch-app-with-logs--success.txt @@ -0,0 +1,12 @@ + +๐Ÿš€ Launch App + + Simulator: + Bundle ID: io.sentry.calculatorapp + Log Capture: enabled + + โ”” Log Session ID: +โœ… App launched successfully in simulator with log capture enabled + +Next steps: +1. Stop capture and retrieve logs: stop_sim_log_cap({ logSessionId: "" }) diff --git a/src/snapshot-tests/__fixtures__/simulator/list--success.txt b/src/snapshot-tests/__fixtures__/simulator/list--success.txt new file mode 100644 index 00000000..69b81c81 --- /dev/null +++ b/src/snapshot-tests/__fixtures__/simulator/list--success.txt @@ -0,0 +1,59 @@ + +๐Ÿ“ฑ List Simulators + +com.apple.CoreSimulator.SimRuntime.iOS-26-4 + +Name UUID State +--------------------- ------------------------------------ -------- +iPhone 17 Pro Shutdown +iPhone 17 Pro Max Shutdown +iPhone 17e Shutdown +iPhone Air Shutdown +iPhone 17 Booted +iPad Pro 13-inch (M5) Shutdown +iPad Pro 11-inch (M5) Shutdown +iPad mini (A17 Pro) Shutdown +iPad Air 13-inch (M4) Shutdown +iPad Air 11-inch (M4) Shutdown +iPad (A16) Shutdown + +com.apple.CoreSimulator.SimRuntime.xrOS-26-2 + +Name UUID State +---------------- ------------------------------------ -------- +Apple Vision Pro Shutdown + +com.apple.CoreSimulator.SimRuntime.watchOS-26-2 + +Name UUID State +---------------------------- ------------------------------------ -------- +Apple Watch Series 11 (46mm) Shutdown +Apple Watch Series 11 (42mm) Shutdown +Apple Watch Ultra 3 (49mm) Shutdown +Apple Watch SE 3 (44mm) Shutdown +Apple Watch SE 3 (40mm) Shutdown + +com.apple.CoreSimulator.SimRuntime.tvOS-26-2 + +Name UUID State +--------------------------------------- ------------------------------------ -------- +Apple TV 4K (3rd generation) Shutdown +Apple TV 4K (3rd generation) (at 1080p) Shutdown +Apple TV Shutdown + +com.apple.CoreSimulator.SimRuntime.iOS-26-2 + +Name UUID State +--------------------- ------------------------------------ -------- +iPhone 17 Pro Shutdown +iPhone 17 Pro Max Shutdown +iPhone Air Shutdown +iPhone 17 Shutdown +iPhone 16e Shutdown +iPad Pro 13-inch (M5) Shutdown +iPad Pro 11-inch (M5) Shutdown +iPad mini (A17 Pro) Shutdown +iPad (A16) Shutdown +iPad Air 13-inch (M3) Shutdown +iPad Air 11-inch (M3) Shutdown +โœ… Listed available simulators diff --git a/src/snapshot-tests/__fixtures__/simulator/screenshot--success.txt b/src/snapshot-tests/__fixtures__/simulator/screenshot--success.txt new file mode 100644 index 00000000..f62d0ee6 --- /dev/null +++ b/src/snapshot-tests/__fixtures__/simulator/screenshot--success.txt @@ -0,0 +1,6 @@ + +๐Ÿ“ท Screenshot + + Simulator: + +โœ… Screenshot captured: /var/folders/_t/2njffz894t57qpp76v1sw__h0000gn/T/screenshot_optimized_.jpg (image/jpeg) diff --git a/src/snapshot-tests/__fixtures__/simulator/stop--error-no-app.txt b/src/snapshot-tests/__fixtures__/simulator/stop--error-no-app.txt new file mode 100644 index 00000000..f31b56f1 --- /dev/null +++ b/src/snapshot-tests/__fixtures__/simulator/stop--error-no-app.txt @@ -0,0 +1,12 @@ + +๐Ÿ›‘ Stop App + + Simulator: + Bundle ID: com.nonexistent.app + +โŒ Stop app in simulator operation failed: An error was encountered processing the command (domain=NSPOSIXErrorDomain, code=3): +Simulator device failed to terminate com.nonexistent.app. +found nothing to terminate +Underlying error (domain=NSPOSIXErrorDomain, code=3): + The request to terminate "com.nonexistent.app" failed. found nothing to terminate + found nothing to terminate diff --git a/src/snapshot-tests/__fixtures__/simulator/test--success.txt b/src/snapshot-tests/__fixtures__/simulator/test--success.txt new file mode 100644 index 00000000..eab86fbd --- /dev/null +++ b/src/snapshot-tests/__fixtures__/simulator/test--success.txt @@ -0,0 +1,16 @@ + +๐Ÿงช Test + + Scheme: CalculatorApp + Configuration: Debug + Platform: iOS Simulator + +โš™๏ธ Test Run test-without-building + + Scheme: CalculatorApp + Platform: iOS Simulator + Configuration: Debug + +โŒ Test Run test-without-building failed for scheme CalculatorApp. + +โŒ Test failed. (, โฑ๏ธ ) diff --git a/src/snapshot-tests/__fixtures__/swift-package/build--error-bad-path.txt b/src/snapshot-tests/__fixtures__/swift-package/build--error-bad-path.txt new file mode 100644 index 00000000..b4c04985 --- /dev/null +++ b/src/snapshot-tests/__fixtures__/swift-package/build--error-bad-path.txt @@ -0,0 +1,6 @@ + +๐Ÿ“ฆ Swift Package Build + + Package: /example_projects/NONEXISTENT + +โŒ Swift package build failed: error: chdir error: No such file or directory (2): /example_projects/NONEXISTENT diff --git a/src/snapshot-tests/__fixtures__/swift-package/build--success.txt b/src/snapshot-tests/__fixtures__/swift-package/build--success.txt new file mode 100644 index 00000000..e081a6cc --- /dev/null +++ b/src/snapshot-tests/__fixtures__/swift-package/build--success.txt @@ -0,0 +1,10 @@ + +๐Ÿ“ฆ Swift Package Build + + Package: /example_projects/spm + +Output + [0/1] Planning build +Build complete! () + +โœ… Swift package build succeeded diff --git a/src/snapshot-tests/__fixtures__/swift-package/clean--success.txt b/src/snapshot-tests/__fixtures__/swift-package/clean--success.txt new file mode 100644 index 00000000..43c95a7a --- /dev/null +++ b/src/snapshot-tests/__fixtures__/swift-package/clean--success.txt @@ -0,0 +1,6 @@ + +๐Ÿงน Swift Package Clean + + Package: /example_projects/spm + +โœ… Swift package cleaned successfully diff --git a/src/snapshot-tests/__fixtures__/swift-package/list--success.txt b/src/snapshot-tests/__fixtures__/swift-package/list--success.txt new file mode 100644 index 00000000..3b51e358 --- /dev/null +++ b/src/snapshot-tests/__fixtures__/swift-package/list--success.txt @@ -0,0 +1,4 @@ + +๐Ÿ“ฆ Swift Package List + +โ„น๏ธ No Swift Package processes currently running. diff --git a/src/snapshot-tests/__fixtures__/swift-package/run--success.txt b/src/snapshot-tests/__fixtures__/swift-package/run--success.txt new file mode 100644 index 00000000..11526a4c --- /dev/null +++ b/src/snapshot-tests/__fixtures__/swift-package/run--success.txt @@ -0,0 +1,10 @@ + +๐Ÿš€ Swift Package Run + + Package: /example_projects/spm + Executable: spm + +Output + Hello, world! + +โœ… Swift executable completed successfully diff --git a/src/snapshot-tests/__fixtures__/swift-package/stop--error-no-process.txt b/src/snapshot-tests/__fixtures__/swift-package/stop--error-no-process.txt new file mode 100644 index 00000000..631d63f8 --- /dev/null +++ b/src/snapshot-tests/__fixtures__/swift-package/stop--error-no-process.txt @@ -0,0 +1,6 @@ + +๐Ÿ›‘ Swift Package Stop + + PID: 999999 + +โŒ No running process found with PID 999999. Use swift_package_list to check active processes. diff --git a/src/snapshot-tests/__fixtures__/swift-package/test--success.txt b/src/snapshot-tests/__fixtures__/swift-package/test--success.txt new file mode 100644 index 00000000..7ebcba4e --- /dev/null +++ b/src/snapshot-tests/__fixtures__/swift-package/test--success.txt @@ -0,0 +1,25 @@ + +๐Ÿงช Swift Package Test + + Package: /example_projects/spm + +Output + Test Suite 'All tests' started at . +Test Suite 'All tests' passed at . + Executed 0 tests, with 0 failures (0 unexpected) in 0.000 () seconds +โ—‡ Test run started. +โ†ณ Testing Library Version: 1743 +โ†ณ Target Platform: arm64e-apple-macos14.0 +โ—‡ Test "Array operations" started. +โ—‡ Test "Basic math operations" started. +โ—‡ Test "Basic truth assertions" started. +โ—‡ Test "Optional handling" started. +โ—‡ Test "String operations" started. +โœ” Test "Array operations" passed after seconds. +โœ” Test "Basic math operations" passed after seconds. +โœ” Test "Basic truth assertions" passed after seconds. +โœ” Test "Optional handling" passed after seconds. +โœ” Test "String operations" passed after seconds. +โœ” Test run with 5 tests in 0 suites passed after seconds. + +โœ… Swift package tests completed diff --git a/src/snapshot-tests/__fixtures__/ui-automation/button--success.txt b/src/snapshot-tests/__fixtures__/ui-automation/button--success.txt new file mode 100644 index 00000000..eeaa4102 --- /dev/null +++ b/src/snapshot-tests/__fixtures__/ui-automation/button--success.txt @@ -0,0 +1,6 @@ + +๐Ÿ‘† Button + + Simulator: + +โœ… Hardware button 'home' pressed successfully. diff --git a/src/snapshot-tests/__fixtures__/ui-automation/gesture--success.txt b/src/snapshot-tests/__fixtures__/ui-automation/gesture--success.txt new file mode 100644 index 00000000..1b1916d8 --- /dev/null +++ b/src/snapshot-tests/__fixtures__/ui-automation/gesture--success.txt @@ -0,0 +1,6 @@ + +๐Ÿ‘† Gesture + + Simulator: + +โœ… Gesture 'scroll-down' executed successfully. diff --git a/src/snapshot-tests/__fixtures__/ui-automation/key-press--success.txt b/src/snapshot-tests/__fixtures__/ui-automation/key-press--success.txt new file mode 100644 index 00000000..4f27c677 --- /dev/null +++ b/src/snapshot-tests/__fixtures__/ui-automation/key-press--success.txt @@ -0,0 +1,6 @@ + +โŒจ๏ธ Key Press + + Simulator: + +โœ… Key press (code: 4) simulated successfully. diff --git a/src/snapshot-tests/__fixtures__/ui-automation/key-sequence--success.txt b/src/snapshot-tests/__fixtures__/ui-automation/key-sequence--success.txt new file mode 100644 index 00000000..aff32c81 --- /dev/null +++ b/src/snapshot-tests/__fixtures__/ui-automation/key-sequence--success.txt @@ -0,0 +1,6 @@ + +โŒจ๏ธ Key Sequence + + Simulator: + +โœ… Key sequence [4,5,6] executed successfully. diff --git a/src/snapshot-tests/__fixtures__/ui-automation/long-press--success.txt b/src/snapshot-tests/__fixtures__/ui-automation/long-press--success.txt new file mode 100644 index 00000000..21d45ff1 --- /dev/null +++ b/src/snapshot-tests/__fixtures__/ui-automation/long-press--success.txt @@ -0,0 +1,7 @@ + +๐Ÿ‘† Long Press + + Simulator: + +โœ… Long press at (100, 400) for 500ms simulated successfully. +โš ๏ธ Warning: snapshot_ui has not been called yet. Consider using snapshot_ui for precise coordinates instead of guessing from screenshots. diff --git a/src/snapshot-tests/__fixtures__/ui-automation/snapshot-ui--success.txt b/src/snapshot-tests/__fixtures__/ui-automation/snapshot-ui--success.txt new file mode 100644 index 00000000..fa3f708e --- /dev/null +++ b/src/snapshot-tests/__fixtures__/ui-automation/snapshot-ui--success.txt @@ -0,0 +1,583 @@ + +๐Ÿ“ท Snapshot UI + + Simulator: + +โœ… Accessibility hierarchy retrieved successfully. + +Accessibility Hierarchy + ```json + [ + { + "AXFrame" : "{{0, 0}, {402, 874}}", + "AXUniqueId" : null, + "frame" : { + "y" : 0, + "x" : 0, + "width" : 402, + "height" : 874 + }, + "role_description" : "application", + "AXLabel" : "Calculator", + "content_required" : false, + "type" : "Application", + "title" : null, + "help" : null, + "custom_actions" : [ + + ], + "AXValue" : null, + "enabled" : true, + "role" : "AXApplication", + "children" : [ + { + "AXFrame" : "{{344, 250.5}, {34, 67}}", + "AXUniqueId" : null, + "frame" : { + "y" : 250.5, + "x" : 344, + "width" : 34, + "height" : 67 + }, + "role_description" : "text", + "AXLabel" : "0", + "content_required" : false, + "type" : "StaticText", + "title" : null, + "help" : null, + "custom_actions" : [ + + ], + "AXValue" : null, + "enabled" : true, + "role" : "AXStaticText", + "children" : [ + + ], + "subrole" : null, + "pid" : + }, + { + "AXFrame" : "{{19.5, 357.5}, {82.666664123535156, 81}}", + "AXUniqueId" : null, + "frame" : { + "y" : 357.5, + "x" : 19.5, + "width" : 82.7, + "height" : 81 + }, + "role_description" : "button", + "AXLabel" : "C", + "content_required" : false, + "type" : "Button", + "title" : null, + "help" : null, + "custom_actions" : [ + + ], + "AXValue" : null, + "enabled" : true, + "role" : "AXButton", + "children" : [ + + ], + "subrole" : null, + "pid" : + }, + { + "AXFrame" : "{{113.16666412353516, 357.5}, {82.333335876464844, 81}}", + "AXUniqueId" : null, + "frame" : { + "y" : 357.5, + "x" : 113.2, + "width" : 82.3, + "height" : 81 + }, + "role_description" : "button", + "AXLabel" : "ยฑ", + "content_required" : false, + "type" : "Button", + "title" : null, + "help" : null, + "custom_actions" : [ + + ], + "AXValue" : null, + "enabled" : true, + "role" : "AXButton", + "children" : [ + + ], + "subrole" : null, + "pid" : + }, + { + "AXFrame" : "{{206.5, 357.5}, {82.666656494140625, 81}}", + "AXUniqueId" : null, + "frame" : { + "y" : 357.5, + "x" : 206.5, + "width" : 82.7, + "height" : 81 + }, + "role_description" : "button", + "AXLabel" : "%", + "content_required" : false, + "type" : "Button", + "title" : null, + "help" : null, + "custom_actions" : [ + + ], + "AXValue" : null, + "enabled" : true, + "role" : "AXButton", + "children" : [ + + ], + "subrole" : null, + "pid" : + }, + { + "AXFrame" : "{{300.16665649414062, 357.5}, {82.333343505859375, 81}}", + "AXUniqueId" : null, + "frame" : { + "y" : 357.5, + "x" : 300.2, + "width" : 82.3, + "height" : 81 + }, + "role_description" : "button", + "AXLabel" : "รท", + "content_required" : false, + "type" : "Button", + "title" : null, + "help" : null, + "custom_actions" : [ + + ], + "AXValue" : null, + "enabled" : true, + "role" : "AXButton", + "children" : [ + + ], + "subrole" : null, + "pid" : + }, + { + "AXFrame" : "{{19.5, 449.5}, {82.666664123535156, 81}}", + "AXUniqueId" : null, + "frame" : { + "y" : 449.5, + "x" : 19.5, + "width" : 82.7, + "height" : 81 + }, + "role_description" : "button", + "AXLabel" : "7", + "content_required" : false, + "type" : "Button", + "title" : null, + "help" : null, + "custom_actions" : [ + + ], + "AXValue" : null, + "enabled" : true, + "role" : "AXButton", + "children" : [ + + ], + "subrole" : null, + "pid" : + }, + { + "AXFrame" : "{{113.16666412353516, 449.5}, {82.333335876464844, 81}}", + "AXUniqueId" : null, + "frame" : { + "y" : 449.5, + "x" : 113.2, + "width" : 82.3, + "height" : 81 + }, + "role_description" : "button", + "AXLabel" : "8", + "content_required" : false, + "type" : "Button", + "title" : null, + "help" : null, + "custom_actions" : [ + + ], + "AXValue" : null, + "enabled" : true, + "role" : "AXButton", + "children" : [ + + ], + "subrole" : null, + "pid" : + }, + { + "AXFrame" : "{{206.5, 449.5}, {82.666656494140625, 81}}", + "AXUniqueId" : null, + "frame" : { + "y" : 449.5, + "x" : 206.5, + "width" : 82.7, + "height" : 81 + }, + "role_description" : "button", + "AXLabel" : "9", + "content_required" : false, + "type" : "Button", + "title" : null, + "help" : null, + "custom_actions" : [ + + ], + "AXValue" : null, + "enabled" : true, + "role" : "AXButton", + "children" : [ + + ], + "subrole" : null, + "pid" : + }, + { + "AXFrame" : "{{300.16665649414062, 449.5}, {82.333343505859375, 81}}", + "AXUniqueId" : null, + "frame" : { + "y" : 449.5, + "x" : 300.2, + "width" : 82.3, + "height" : 81 + }, + "role_description" : "button", + "AXLabel" : "ร—", + "content_required" : false, + "type" : "Button", + "title" : null, + "help" : null, + "custom_actions" : [ + + ], + "AXValue" : null, + "enabled" : true, + "role" : "AXButton", + "children" : [ + + ], + "subrole" : null, + "pid" : + }, + { + "AXFrame" : "{{19.5, 541.5}, {82.666664123535156, 81}}", + "AXUniqueId" : null, + "frame" : { + "y" : 541.5, + "x" : 19.5, + "width" : 82.7, + "height" : 81 + }, + "role_description" : "button", + "AXLabel" : "4", + "content_required" : false, + "type" : "Button", + "title" : null, + "help" : null, + "custom_actions" : [ + + ], + "AXValue" : null, + "enabled" : true, + "role" : "AXButton", + "children" : [ + + ], + "subrole" : null, + "pid" : + }, + { + "AXFrame" : "{{113.16666412353516, 541.5}, {82.333335876464844, 81}}", + "AXUniqueId" : null, + "frame" : { + "y" : 541.5, + "x" : 113.2, + "width" : 82.3, + "height" : 81 + }, + "role_description" : "button", + "AXLabel" : "5", + "content_required" : false, + "type" : "Button", + "title" : null, + "help" : null, + "custom_actions" : [ + + ], + "AXValue" : null, + "enabled" : true, + "role" : "AXButton", + "children" : [ + + ], + "subrole" : null, + "pid" : + }, + { + "AXFrame" : "{{206.5, 541.5}, {82.666656494140625, 81}}", + "AXUniqueId" : null, + "frame" : { + "y" : 541.5, + "x" : 206.5, + "width" : 82.7, + "height" : 81 + }, + "role_description" : "button", + "AXLabel" : "6", + "content_required" : false, + "type" : "Button", + "title" : null, + "help" : null, + "custom_actions" : [ + + ], + "AXValue" : null, + "enabled" : true, + "role" : "AXButton", + "children" : [ + + ], + "subrole" : null, + "pid" : + }, + { + "AXFrame" : "{{300.16665649414062, 541.5}, {82.333343505859375, 81}}", + "AXUniqueId" : null, + "frame" : { + "y" : 541.5, + "x" : 300.2, + "width" : 82.3, + "height" : 81 + }, + "role_description" : "button", + "AXLabel" : "-", + "content_required" : false, + "type" : "Button", + "title" : null, + "help" : null, + "custom_actions" : [ + + ], + "AXValue" : null, + "enabled" : true, + "role" : "AXButton", + "children" : [ + + ], + "subrole" : null, + "pid" : + }, + { + "AXFrame" : "{{19.5, 633.5}, {82.666664123535156, 81}}", + "AXUniqueId" : null, + "frame" : { + "y" : 633.5, + "x" : 19.5, + "width" : 82.7, + "height" : 81 + }, + "role_description" : "button", + "AXLabel" : "1", + "content_required" : false, + "type" : "Button", + "title" : null, + "help" : null, + "custom_actions" : [ + + ], + "AXValue" : null, + "enabled" : true, + "role" : "AXButton", + "children" : [ + + ], + "subrole" : null, + "pid" : + }, + { + "AXFrame" : "{{113.16666412353516, 633.5}, {82.333335876464844, 81}}", + "AXUniqueId" : null, + "frame" : { + "y" : 633.5, + "x" : 113.2, + "width" : 82.3, + "height" : 81 + }, + "role_description" : "button", + "AXLabel" : "2", + "content_required" : false, + "type" : "Button", + "title" : null, + "help" : null, + "custom_actions" : [ + + ], + "AXValue" : null, + "enabled" : true, + "role" : "AXButton", + "children" : [ + + ], + "subrole" : null, + "pid" : + }, + { + "AXFrame" : "{{206.5, 633.5}, {82.666656494140625, 81}}", + "AXUniqueId" : null, + "frame" : { + "y" : 633.5, + "x" : 206.5, + "width" : 82.7, + "height" : 81 + }, + "role_description" : "button", + "AXLabel" : "3", + "content_required" : false, + "type" : "Button", + "title" : null, + "help" : null, + "custom_actions" : [ + + ], + "AXValue" : null, + "enabled" : true, + "role" : "AXButton", + "children" : [ + + ], + "subrole" : null, + "pid" : + }, + { + "AXFrame" : "{{300.16665649414062, 633.5}, {82.333343505859375, 81}}", + "AXUniqueId" : null, + "frame" : { + "y" : 633.5, + "x" : 300.2, + "width" : 82.3, + "height" : 81 + }, + "role_description" : "button", + "AXLabel" : "+", + "content_required" : false, + "type" : "Button", + "title" : null, + "help" : null, + "custom_actions" : [ + + ], + "AXValue" : null, + "enabled" : true, + "role" : "AXButton", + "children" : [ + + ], + "subrole" : null, + "pid" : + }, + { + "AXFrame" : "{{113.16666412353516, 725.5}, {82.333335876464844, 81}}", + "AXUniqueId" : null, + "frame" : { + "y" : 725.5, + "x" : 113.2, + "width" : 82.3, + "height" : 81 + }, + "role_description" : "button", + "AXLabel" : "0", + "content_required" : false, + "type" : "Button", + "title" : null, + "help" : null, + "custom_actions" : [ + + ], + "AXValue" : null, + "enabled" : true, + "role" : "AXButton", + "children" : [ + + ], + "subrole" : null, + "pid" : + }, + { + "AXFrame" : "{{206.5, 725.5}, {82.666656494140625, 81}}", + "AXUniqueId" : null, + "frame" : { + "y" : 725.5, + "x" : 206.5, + "width" : 82.7, + "height" : 81 + }, + "role_description" : "button", + "AXLabel" : ".", + "content_required" : false, + "type" : "Button", + "title" : null, + "help" : null, + "custom_actions" : [ + + ], + "AXValue" : null, + "enabled" : true, + "role" : "AXButton", + "children" : [ + + ], + "subrole" : null, + "pid" : + }, + { + "AXFrame" : "{{300.16665649414062, 725.5}, {82.333343505859375, 81}}", + "AXUniqueId" : null, + "frame" : { + "y" : 725.5, + "x" : 300.2, + "width" : 82.3, + "height" : 81 + }, + "role_description" : "button", + "AXLabel" : "=", + "content_required" : false, + "type" : "Button", + "title" : null, + "help" : null, + "custom_actions" : [ + + ], + "AXValue" : null, + "enabled" : true, + "role" : "AXButton", + "children" : [ + + ], + "subrole" : null, + "pid" : + } + ], + "subrole" : null, + "pid" : + } +] + ``` + +Tips + - Use frame coordinates for tap/swipe (center: x+width/2, y+height/2) + - If a debugger is attached, ensure the app is running (not stopped on breakpoints) + - Screenshots are for visual verification only diff --git a/src/snapshot-tests/__fixtures__/ui-automation/swipe--success.txt b/src/snapshot-tests/__fixtures__/ui-automation/swipe--success.txt new file mode 100644 index 00000000..fb08f794 --- /dev/null +++ b/src/snapshot-tests/__fixtures__/ui-automation/swipe--success.txt @@ -0,0 +1,7 @@ + +๐Ÿ‘† Swipe + + Simulator: + +โœ… Swipe from (200, 400) to (200, 200) simulated successfully. +โš ๏ธ Warning: snapshot_ui has not been called yet. Consider using snapshot_ui for precise coordinates instead of guessing from screenshots. diff --git a/src/snapshot-tests/__fixtures__/ui-automation/tap--error-no-simulator.txt b/src/snapshot-tests/__fixtures__/ui-automation/tap--error-no-simulator.txt new file mode 100644 index 00000000..ca372fe6 --- /dev/null +++ b/src/snapshot-tests/__fixtures__/ui-automation/tap--error-no-simulator.txt @@ -0,0 +1,9 @@ + +๐Ÿ‘† Tap + + Simulator: + +โŒ Failed to simulate tap at (100, 100): axe command 'tap' failed. + +Details + Error: CLIError(errorDescription: "Simulator with UDID not found in set.") diff --git a/src/snapshot-tests/__fixtures__/ui-automation/tap--success.txt b/src/snapshot-tests/__fixtures__/ui-automation/tap--success.txt new file mode 100644 index 00000000..ec35a6a8 --- /dev/null +++ b/src/snapshot-tests/__fixtures__/ui-automation/tap--success.txt @@ -0,0 +1,7 @@ + +๐Ÿ‘† Tap + + Simulator: + +โœ… Tap at (100, 400) simulated successfully. +โš ๏ธ Warning: snapshot_ui has not been called yet. Consider using snapshot_ui for precise coordinates instead of guessing from screenshots. diff --git a/src/snapshot-tests/__fixtures__/ui-automation/touch--success.txt b/src/snapshot-tests/__fixtures__/ui-automation/touch--success.txt new file mode 100644 index 00000000..f7da4a0a --- /dev/null +++ b/src/snapshot-tests/__fixtures__/ui-automation/touch--success.txt @@ -0,0 +1,7 @@ + +๐Ÿ‘† Touch + + Simulator: + +โœ… Touch event (touch down+up) at (100, 400) executed successfully. +โš ๏ธ Warning: snapshot_ui has not been called yet. Consider using snapshot_ui for precise coordinates instead of guessing from screenshots. diff --git a/src/snapshot-tests/__fixtures__/ui-automation/type-text--success.txt b/src/snapshot-tests/__fixtures__/ui-automation/type-text--success.txt new file mode 100644 index 00000000..212f95c7 --- /dev/null +++ b/src/snapshot-tests/__fixtures__/ui-automation/type-text--success.txt @@ -0,0 +1,6 @@ + +โŒจ๏ธ Type Text + + Simulator: + +โœ… Text typing simulated successfully. diff --git a/src/snapshot-tests/__fixtures__/utilities/clean--success.txt b/src/snapshot-tests/__fixtures__/utilities/clean--success.txt new file mode 100644 index 00000000..c9a5e007 --- /dev/null +++ b/src/snapshot-tests/__fixtures__/utilities/clean--success.txt @@ -0,0 +1,8 @@ + +๐Ÿงน Clean + + Scheme: CalculatorApp + Configuration: Debug + Platform: iOS + +โœ… Build succeeded. (โฑ๏ธ ) diff --git a/src/snapshot-tests/__fixtures_designed__/coverage/get-coverage-report--error-invalid-bundle.txt b/src/snapshot-tests/__fixtures_designed__/coverage/get-coverage-report--error-invalid-bundle.txt new file mode 100644 index 00000000..704b5a11 --- /dev/null +++ b/src/snapshot-tests/__fixtures_designed__/coverage/get-coverage-report--error-invalid-bundle.txt @@ -0,0 +1,7 @@ +๐Ÿ“Š Coverage Report + + xcresult: /invalid.xcresult + +โŒ Failed to get coverage report: Failed to load result bundle. + +Hint: Run tests with coverage enabled (e.g., xcodebuild test -enableCodeCoverage YES). diff --git a/src/snapshot-tests/__fixtures_designed__/coverage/get-coverage-report--success.txt b/src/snapshot-tests/__fixtures_designed__/coverage/get-coverage-report--success.txt new file mode 100644 index 00000000..5bf9fff4 --- /dev/null +++ b/src/snapshot-tests/__fixtures_designed__/coverage/get-coverage-report--success.txt @@ -0,0 +1,12 @@ +๐Ÿ“Š Coverage Report + + xcresult: /TestResults.xcresult + Target Filter: CalculatorAppTests + +Overall: 94.9% (354/373 lines) + +Targets: + CalculatorAppTests.xctest โ€” 94.9% (354/373 lines) + +Next steps: +1. View file-level coverage: xcodebuildmcp coverage get-file-coverage --xcresult-path "/TestResults.xcresult" diff --git a/src/snapshot-tests/__fixtures_designed__/coverage/get-file-coverage--error-invalid-bundle.txt b/src/snapshot-tests/__fixtures_designed__/coverage/get-file-coverage--error-invalid-bundle.txt new file mode 100644 index 00000000..3b5579ad --- /dev/null +++ b/src/snapshot-tests/__fixtures_designed__/coverage/get-file-coverage--error-invalid-bundle.txt @@ -0,0 +1,8 @@ +๐Ÿ“Š File Coverage + + xcresult: /invalid.xcresult + File: SomeFile.swift + +โŒ Failed to get file coverage: Failed to load result bundle. + +Hint: Make sure the xcresult bundle contains coverage data for "SomeFile.swift". diff --git a/src/snapshot-tests/__fixtures_designed__/coverage/get-file-coverage--success.txt b/src/snapshot-tests/__fixtures_designed__/coverage/get-file-coverage--success.txt new file mode 100644 index 00000000..7f8d0b50 --- /dev/null +++ b/src/snapshot-tests/__fixtures_designed__/coverage/get-file-coverage--success.txt @@ -0,0 +1,27 @@ +๐Ÿ“Š File Coverage + + xcresult: /TestResults.xcresult + File: CalculatorService.swift + +File: example_projects/iOS_Calculator/CalculatorAppPackage/Sources/CalculatorAppFeature/CalculatorService.swift +Coverage: 83.1% (157/189 lines) + +๐Ÿ”ด Not Covered (7 functions, 22 lines) + L159 CalculatorService.deleteLastDigit() โ€” 0/16 lines + L58 implicit closure #2 in CalculatorService.inputNumber(_:) โ€” 0/1 lines + L98 implicit closure #3 in CalculatorService.calculate() โ€” 0/1 lines + L99 implicit closure #4 in CalculatorService.calculate() โ€” 0/1 lines + L162 implicit closure #1 in CalculatorService.deleteLastDigit() โ€” 0/1 lines + L172 implicit closure #2 in CalculatorService.deleteLastDigit() โ€” 0/1 lines + L214 implicit closure #4 in CalculatorService.formatNumber(_:) โ€” 0/1 lines + +๐ŸŸก Partial Coverage (4 functions) + L184 CalculatorService.updateExpressionDisplay() โ€” 80.0% (8/10 lines) + L195 CalculatorService.formatNumber(_:) โ€” 85.7% (18/21 lines) + L93 CalculatorService.calculate() โ€” 89.5% (34/38 lines) + L63 CalculatorService.inputDecimal() โ€” 92.9% (13/14 lines) + +๐ŸŸข Full Coverage (28 functions) โ€” all at 100% + +Next steps: +1. View overall coverage: xcodebuildmcp coverage get-coverage-report --xcresult-path "/TestResults.xcresult" diff --git a/src/snapshot-tests/__fixtures_designed__/debugging/add-breakpoint--success.txt b/src/snapshot-tests/__fixtures_designed__/debugging/add-breakpoint--success.txt new file mode 100644 index 00000000..2af60ec9 --- /dev/null +++ b/src/snapshot-tests/__fixtures_designed__/debugging/add-breakpoint--success.txt @@ -0,0 +1,11 @@ +๐Ÿ› Add Breakpoint + + File: ContentView.swift + Line: 42 + +โœ… Breakpoint 1 set. + +Next steps: +1. Continue execution: xcodebuildmcp debugging continue +2. View stack trace: xcodebuildmcp debugging stack +3. View variables: xcodebuildmcp debugging variables diff --git a/src/snapshot-tests/__fixtures_designed__/debugging/attach--success.txt b/src/snapshot-tests/__fixtures_designed__/debugging/attach--success.txt new file mode 100644 index 00000000..67fa6264 --- /dev/null +++ b/src/snapshot-tests/__fixtures_designed__/debugging/attach--success.txt @@ -0,0 +1,13 @@ +๐Ÿ› Attach Debugger + + Simulator: + +โœ… Attached LLDB to simulator process (). + + โ”œ Debug Session: + โ”” Status: Execution resumed after attach. + +Next steps: +1. Add breakpoint: xcodebuildmcp debugging add-breakpoint --file "..." --line 42 +2. View stack trace: xcodebuildmcp debugging stack +3. View variables: xcodebuildmcp debugging variables diff --git a/src/snapshot-tests/__fixtures_designed__/debugging/continue--error-no-session.txt b/src/snapshot-tests/__fixtures_designed__/debugging/continue--error-no-session.txt new file mode 100644 index 00000000..b0ef189a --- /dev/null +++ b/src/snapshot-tests/__fixtures_designed__/debugging/continue--error-no-session.txt @@ -0,0 +1,3 @@ +๐Ÿ› Continue + +โŒ No active debug session. Provide debugSessionId or attach first. diff --git a/src/snapshot-tests/__fixtures_designed__/debugging/continue--success.txt b/src/snapshot-tests/__fixtures_designed__/debugging/continue--success.txt new file mode 100644 index 00000000..1e29c603 --- /dev/null +++ b/src/snapshot-tests/__fixtures_designed__/debugging/continue--success.txt @@ -0,0 +1,7 @@ +๐Ÿ› Continue + +โœ… Resumed debugger session. + +Next steps: +1. View stack trace: xcodebuildmcp debugging stack +2. View variables: xcodebuildmcp debugging variables diff --git a/src/snapshot-tests/__fixtures_designed__/debugging/detach--success.txt b/src/snapshot-tests/__fixtures_designed__/debugging/detach--success.txt new file mode 100644 index 00000000..faaba1a5 --- /dev/null +++ b/src/snapshot-tests/__fixtures_designed__/debugging/detach--success.txt @@ -0,0 +1,3 @@ +๐Ÿ› Detach + +โœ… Detached debugger session. diff --git a/src/snapshot-tests/__fixtures_designed__/debugging/lldb-command--success.txt b/src/snapshot-tests/__fixtures_designed__/debugging/lldb-command--success.txt new file mode 100644 index 00000000..13656f5d --- /dev/null +++ b/src/snapshot-tests/__fixtures_designed__/debugging/lldb-command--success.txt @@ -0,0 +1,5 @@ +๐Ÿ› LLDB Command + + Command: po self + + diff --git a/src/snapshot-tests/__fixtures_designed__/debugging/remove-breakpoint--success.txt b/src/snapshot-tests/__fixtures_designed__/debugging/remove-breakpoint--success.txt new file mode 100644 index 00000000..f4b7c82a --- /dev/null +++ b/src/snapshot-tests/__fixtures_designed__/debugging/remove-breakpoint--success.txt @@ -0,0 +1,5 @@ +๐Ÿ› Remove Breakpoint + + Breakpoint: 1 + +โœ… Breakpoint 1 removed. diff --git a/src/snapshot-tests/__fixtures_designed__/debugging/stack--success.txt b/src/snapshot-tests/__fixtures_designed__/debugging/stack--success.txt new file mode 100644 index 00000000..ccafd304 --- /dev/null +++ b/src/snapshot-tests/__fixtures_designed__/debugging/stack--success.txt @@ -0,0 +1,6 @@ +๐Ÿ› Stack Trace + +* thread #1, queue = 'com.apple.main-thread', stop reason = breakpoint 1.1 + * frame #0: CalculatorApp`ContentView.body.getter at ContentView.swift:42 + frame #1: SwiftUI`ViewGraph.updateOutputs() + frame #2: SwiftUI`ViewRendererHost.render() diff --git a/src/snapshot-tests/__fixtures_designed__/debugging/variables--success.txt b/src/snapshot-tests/__fixtures_designed__/debugging/variables--success.txt new file mode 100644 index 00000000..94e138eb --- /dev/null +++ b/src/snapshot-tests/__fixtures_designed__/debugging/variables--success.txt @@ -0,0 +1,9 @@ +๐Ÿ› Variables + +(CalculatorService) self = { + โ”œ display = "0" + โ”œ expressionDisplay = "" + โ”œ currentValue = 0 + โ”œ previousValue = 0 + โ”” currentOperation = nil +} diff --git a/src/snapshot-tests/__fixtures_designed__/device/build--failure-compilation.txt b/src/snapshot-tests/__fixtures_designed__/device/build--failure-compilation.txt new file mode 100644 index 00000000..f00856a1 --- /dev/null +++ b/src/snapshot-tests/__fixtures_designed__/device/build--failure-compilation.txt @@ -0,0 +1,11 @@ +๐Ÿ”จ Build + + Scheme: CalculatorApp + Workspace: example_projects/iOS_Calculator/CalculatorApp.xcworkspace + Configuration: Debug + Platform: iOS + +Errors (1): + โœ— CalculatorApp/CompileError.swift:3: Cannot convert value of type 'String' to specified type 'Int' + +โŒ Build failed. (โฑ๏ธ ) diff --git a/src/snapshot-tests/__fixtures_designed__/device/build--success.txt b/src/snapshot-tests/__fixtures_designed__/device/build--success.txt new file mode 100644 index 00000000..73de2e9c --- /dev/null +++ b/src/snapshot-tests/__fixtures_designed__/device/build--success.txt @@ -0,0 +1,11 @@ +๐Ÿ”จ Build + + Scheme: CalculatorApp + Workspace: example_projects/iOS_Calculator/CalculatorApp.xcworkspace + Configuration: Debug + Platform: iOS + +โœ… Build succeeded. (โฑ๏ธ ) + +Next steps: +1. Get built device app path: xcodebuildmcp device get-app-path --scheme "CalculatorApp" diff --git a/src/snapshot-tests/__fixtures_designed__/device/get-app-path--success.txt b/src/snapshot-tests/__fixtures_designed__/device/get-app-path--success.txt new file mode 100644 index 00000000..a8241601 --- /dev/null +++ b/src/snapshot-tests/__fixtures_designed__/device/get-app-path--success.txt @@ -0,0 +1,13 @@ +๐Ÿ” Get App Path + + Scheme: CalculatorApp + Workspace: example_projects/iOS_Calculator/CalculatorApp.xcworkspace + Configuration: Debug + Platform: iOS + + โ”” App Path: /Library/Developer/Xcode/DerivedData/CalculatorApp-/Build/Products/Debug-iphoneos/CalculatorApp.app + +Next steps: +1. Get bundle ID: xcodebuildmcp project-discovery get-app-bundle-id --app-path "..." +2. Install on device: xcodebuildmcp device install --app-path "..." +3. Launch on device: xcodebuildmcp device launch --bundle-id "BUNDLE_ID" diff --git a/src/snapshot-tests/__fixtures_designed__/device/list--success.txt b/src/snapshot-tests/__fixtures_designed__/device/list--success.txt new file mode 100644 index 00000000..516d894b --- /dev/null +++ b/src/snapshot-tests/__fixtures_designed__/device/list--success.txt @@ -0,0 +1,37 @@ +๐Ÿ“ฑ List Devices + +โœ… Available Devices: + + Cameron's Apple Watch + โ”œ UDID: + โ”œ Model: Watch4,2 + โ”œ Platform: Unknown 10.6.1 + โ”œ CPU: arm64_32 + โ”” Developer Mode: disabled + + Cameron's Apple Watch + โ”œ UDID: + โ”œ Model: Watch7,20 + โ”œ Platform: Unknown 26.1 + โ”œ CPU: arm64e + โ”œ Connection: localNetwork + โ”” Developer Mode: disabled + + Cameron's iPhone 16 Pro Max + โ”œ UDID: + โ”œ Model: iPhone17,2 + โ”œ Platform: Unknown 26.3.1 + โ”œ CPU: arm64e + โ”œ Connection: localNetwork + โ”” Developer Mode: enabled + + iPhone + โ”œ UDID: + โ”œ Model: iPhone99,11 + โ”œ Platform: Unknown 26.1 + โ”” CPU: arm64e + +Next steps: +1. Build for device: xcodebuildmcp device build --scheme "SCHEME" --device-id "DEVICE_UDID" +2. Run tests on device: xcodebuildmcp device test --scheme "SCHEME" --device-id "DEVICE_UDID" +3. Get app path: xcodebuildmcp device get-app-path --scheme "SCHEME" diff --git a/src/snapshot-tests/__fixtures_designed__/macos/build--failure-compilation.txt b/src/snapshot-tests/__fixtures_designed__/macos/build--failure-compilation.txt new file mode 100644 index 00000000..ee50e3c1 --- /dev/null +++ b/src/snapshot-tests/__fixtures_designed__/macos/build--failure-compilation.txt @@ -0,0 +1,11 @@ +๐Ÿ”จ Build + + Scheme: MCPTest + Project: example_projects/macOS/MCPTest.xcodeproj + Configuration: Debug + Platform: macOS + +Errors (1): + โœ— MCPTest/CompileError.swift:3: Cannot convert value of type 'String' to specified type 'Int' + +โŒ Build failed. (โฑ๏ธ ) diff --git a/src/snapshot-tests/__fixtures_designed__/macos/build--success.txt b/src/snapshot-tests/__fixtures_designed__/macos/build--success.txt new file mode 100644 index 00000000..6a909bec --- /dev/null +++ b/src/snapshot-tests/__fixtures_designed__/macos/build--success.txt @@ -0,0 +1,11 @@ +๐Ÿ”จ Build + + Scheme: MCPTest + Project: example_projects/macOS/MCPTest.xcodeproj + Configuration: Debug + Platform: macOS + +โœ… Build succeeded. (โฑ๏ธ ) + +Next steps: +1. Get built macOS app path: xcodebuildmcp macos get-app-path --scheme "MCPTest" diff --git a/src/snapshot-tests/__fixtures_designed__/macos/build-and-run--success.txt b/src/snapshot-tests/__fixtures_designed__/macos/build-and-run--success.txt new file mode 100644 index 00000000..bf2135f0 --- /dev/null +++ b/src/snapshot-tests/__fixtures_designed__/macos/build-and-run--success.txt @@ -0,0 +1,18 @@ +๐Ÿš€ Build & Run + + Scheme: MCPTest + Project: example_projects/macOS/MCPTest.xcodeproj + Configuration: Debug + Platform: macOS + +โœ“ Resolving app path +โœ“ Launching app + +โœ… Build succeeded. (โฑ๏ธ ) + +โœ… Build & Run complete + + โ”” App Path: /Library/Developer/Xcode/DerivedData/MCPTest-/Build/Products/Debug/MCPTest.app + +Next steps: +1. Interact with the launched app in the foreground diff --git a/src/snapshot-tests/__fixtures_designed__/macos/get-app-path--success.txt b/src/snapshot-tests/__fixtures_designed__/macos/get-app-path--success.txt new file mode 100644 index 00000000..a18ab390 --- /dev/null +++ b/src/snapshot-tests/__fixtures_designed__/macos/get-app-path--success.txt @@ -0,0 +1,12 @@ +๐Ÿ” Get App Path + + Scheme: MCPTest + Project: example_projects/macOS/MCPTest.xcodeproj + Configuration: Debug + Platform: macOS + + โ”” App Path: /Library/Developer/Xcode/DerivedData/MCPTest-/Build/Products/Debug/MCPTest.app + +Next steps: +1. Get bundle ID: xcodebuildmcp macos get-macos-bundle-id --app-path "/Library/Developer/Xcode/DerivedData/MCPTest-/Build/Products/Debug/MCPTest.app" +2. Launch app: xcodebuildmcp macos launch --app-path "/Library/Developer/Xcode/DerivedData/MCPTest-/Build/Products/Debug/MCPTest.app" diff --git a/src/snapshot-tests/__fixtures_designed__/macos/launch--error-invalid-app.txt b/src/snapshot-tests/__fixtures_designed__/macos/launch--error-invalid-app.txt new file mode 100644 index 00000000..e6cc518a --- /dev/null +++ b/src/snapshot-tests/__fixtures_designed__/macos/launch--error-invalid-app.txt @@ -0,0 +1,5 @@ +๐Ÿš€ Launch macOS App + + App: /Fake.app + +โŒ Launch failed: The application cannot be opened because its executable is missing. diff --git a/src/snapshot-tests/__fixtures_designed__/macos/test--failure.txt b/src/snapshot-tests/__fixtures_designed__/macos/test--failure.txt new file mode 100644 index 00000000..ea76a321 --- /dev/null +++ b/src/snapshot-tests/__fixtures_designed__/macos/test--failure.txt @@ -0,0 +1,13 @@ +๐Ÿงช Test + + Scheme: MCPTest + Project: example_projects/macOS/MCPTest.xcodeproj + Configuration: Debug + Platform: macOS + +Resolved to test(s) + +Failures (1): + โœ— MCPTestTests.testIntentionalFailure โ€” Expectation failed + +โŒ Test failed. (, โฑ๏ธ ) diff --git a/src/snapshot-tests/__fixtures_designed__/macos/test--success.txt b/src/snapshot-tests/__fixtures_designed__/macos/test--success.txt new file mode 100644 index 00000000..273ba47a --- /dev/null +++ b/src/snapshot-tests/__fixtures_designed__/macos/test--success.txt @@ -0,0 +1,10 @@ +๐Ÿงช Test + + Scheme: MCPTest + Project: example_projects/macOS/MCPTest.xcodeproj + Configuration: Debug + Platform: macOS + +Resolved to test(s) + +โœ… Test succeeded. (, โฑ๏ธ ) diff --git a/src/snapshot-tests/__fixtures_designed__/project-discovery/discover-projs--success.txt b/src/snapshot-tests/__fixtures_designed__/project-discovery/discover-projs--success.txt new file mode 100644 index 00000000..08800e2b --- /dev/null +++ b/src/snapshot-tests/__fixtures_designed__/project-discovery/discover-projs--success.txt @@ -0,0 +1,12 @@ +๐Ÿ” Discover Projects + + Search Path: . + +Workspaces: + example_projects/iOS_Calculator/CalculatorApp.xcworkspace + +Projects: + example_projects/iOS_Calculator/CalculatorApp.xcodeproj + +Next steps: +1. Build and run: xcodebuildmcp simulator build-and-run diff --git a/src/snapshot-tests/__fixtures_designed__/project-discovery/list-schemes--success.txt b/src/snapshot-tests/__fixtures_designed__/project-discovery/list-schemes--success.txt new file mode 100644 index 00000000..7b351fa1 --- /dev/null +++ b/src/snapshot-tests/__fixtures_designed__/project-discovery/list-schemes--success.txt @@ -0,0 +1,13 @@ +๐Ÿ” List Schemes + + Workspace: example_projects/iOS_Calculator/CalculatorApp.xcworkspace + +Schemes: + CalculatorApp + CalculatorAppFeature + +Next steps: +1. Build for macOS: xcodebuildmcp macos build --workspace-path "example_projects/iOS_Calculator/CalculatorApp.xcworkspace" --scheme "CalculatorApp" +2. Build and run on simulator: xcodebuildmcp simulator build-and-run --workspace-path "example_projects/iOS_Calculator/CalculatorApp.xcworkspace" --scheme "CalculatorApp" --simulator-name "iPhone 17" +3. Build for simulator: xcodebuildmcp simulator build --workspace-path "example_projects/iOS_Calculator/CalculatorApp.xcworkspace" --scheme "CalculatorApp" --simulator-name "iPhone 17" +4. Show build settings: xcodebuildmcp device show-build-settings --workspace-path "example_projects/iOS_Calculator/CalculatorApp.xcworkspace" --scheme "CalculatorApp" diff --git a/src/snapshot-tests/__fixtures_designed__/project-discovery/show-build-settings--success.txt b/src/snapshot-tests/__fixtures_designed__/project-discovery/show-build-settings--success.txt new file mode 100644 index 00000000..5a72bbd9 --- /dev/null +++ b/src/snapshot-tests/__fixtures_designed__/project-discovery/show-build-settings--success.txt @@ -0,0 +1,21 @@ +๐Ÿ” Show Build Settings + + Scheme: CalculatorApp + Workspace: example_projects/iOS_Calculator/CalculatorApp.xcworkspace + +Key Settings: + โ”œ PRODUCT_NAME: CalculatorApp + โ”œ PRODUCT_BUNDLE_IDENTIFIER: io.sentry.calculatorapp + โ”œ SDKROOT: iphoneos + โ”œ SUPPORTED_PLATFORMS: iphonesimulator iphoneos + โ”œ ARCHS: arm64 + โ”œ SWIFT_VERSION: 6.0 + โ”œ IPHONEOS_DEPLOYMENT_TARGET: 18.0 + โ”œ CODE_SIGNING_ALLOWED: YES + โ”œ CODE_SIGN_IDENTITY: Apple Development + โ”œ CONFIGURATION: Debug + โ”œ BUILD_DIR: /Library/Developer/Xcode/DerivedData/CalculatorApp-/Build/Products + โ”” BUILT_PRODUCTS_DIR: /Library/Developer/Xcode/DerivedData/CalculatorApp-/Build/Products/Debug-iphoneos + +Next steps: +1. Build for simulator: xcodebuildmcp simulator build --workspace-path "example_projects/iOS_Calculator/CalculatorApp.xcworkspace" --scheme "CalculatorApp" diff --git a/src/snapshot-tests/__fixtures_designed__/project-scaffolding/scaffold-ios--error-existing.txt b/src/snapshot-tests/__fixtures_designed__/project-scaffolding/scaffold-ios--error-existing.txt new file mode 100644 index 00000000..0e2ee713 --- /dev/null +++ b/src/snapshot-tests/__fixtures_designed__/project-scaffolding/scaffold-ios--error-existing.txt @@ -0,0 +1,5 @@ +๐Ÿ—๏ธ Scaffold iOS Project + + Path: /ios-existing + +โŒ Xcode project files already exist in /ios-existing. diff --git a/src/snapshot-tests/__fixtures_designed__/project-scaffolding/scaffold-ios--success.txt b/src/snapshot-tests/__fixtures_designed__/project-scaffolding/scaffold-ios--success.txt new file mode 100644 index 00000000..79ccb214 --- /dev/null +++ b/src/snapshot-tests/__fixtures_designed__/project-scaffolding/scaffold-ios--success.txt @@ -0,0 +1,12 @@ +๐Ÿ—๏ธ Scaffold iOS Project + + Name: SnapshotTestApp + Path: /ios + Platform: iOS + +โœ… Project scaffolded successfully. + +Next steps: +1. Read the README.md in the workspace root directory before working on the project. +2. Build for simulator: xcodebuildmcp simulator build --workspace-path "/ios/SnapshotTestApp.xcworkspace" --scheme "SnapshotTestApp" --simulator-name "iPhone 17" +3. Build and run on simulator: xcodebuildmcp simulator build-and-run --workspace-path "/ios/SnapshotTestApp.xcworkspace" --scheme "SnapshotTestApp" --simulator-name "iPhone 17" diff --git a/src/snapshot-tests/__fixtures_designed__/project-scaffolding/scaffold-macos--success.txt b/src/snapshot-tests/__fixtures_designed__/project-scaffolding/scaffold-macos--success.txt new file mode 100644 index 00000000..e3b89387 --- /dev/null +++ b/src/snapshot-tests/__fixtures_designed__/project-scaffolding/scaffold-macos--success.txt @@ -0,0 +1,11 @@ +๐Ÿ—๏ธ Scaffold macOS Project + + Name: SnapshotTestApp + Path: /macos + Platform: macOS + +โœ… Project scaffolded successfully. + +Next steps: +1. Build for macOS: xcodebuildmcp macos build --project-path "/macos/SnapshotTestApp.xcodeproj" --scheme "SnapshotTestApp" +2. Build and run on macOS: xcodebuildmcp macos build-and-run --project-path "/macos/SnapshotTestApp.xcodeproj" --scheme "SnapshotTestApp" diff --git a/src/snapshot-tests/__fixtures_designed__/session-management/session-clear-defaults--success.txt b/src/snapshot-tests/__fixtures_designed__/session-management/session-clear-defaults--success.txt new file mode 100644 index 00000000..2883c82c --- /dev/null +++ b/src/snapshot-tests/__fixtures_designed__/session-management/session-clear-defaults--success.txt @@ -0,0 +1,3 @@ +โš™๏ธ Clear Defaults + +โœ… Session defaults cleared. diff --git a/src/snapshot-tests/__fixtures_designed__/session-management/session-set-defaults--success.txt b/src/snapshot-tests/__fixtures_designed__/session-management/session-set-defaults--success.txt new file mode 100644 index 00000000..a9bd0cc7 --- /dev/null +++ b/src/snapshot-tests/__fixtures_designed__/session-management/session-set-defaults--success.txt @@ -0,0 +1,6 @@ +โš™๏ธ Set Defaults + + Workspace: example_projects/iOS_Calculator/CalculatorApp.xcworkspace + Scheme: CalculatorApp + +โœ… Session defaults updated. diff --git a/src/snapshot-tests/__fixtures_designed__/session-management/session-show-defaults--success.txt b/src/snapshot-tests/__fixtures_designed__/session-management/session-show-defaults--success.txt new file mode 100644 index 00000000..0b4d4692 --- /dev/null +++ b/src/snapshot-tests/__fixtures_designed__/session-management/session-show-defaults--success.txt @@ -0,0 +1,4 @@ +โš™๏ธ Show Defaults + + Workspace: example_projects/iOS_Calculator/CalculatorApp.xcworkspace + Scheme: CalculatorApp diff --git a/src/snapshot-tests/__fixtures_designed__/simulator-management/boot--error-invalid-id.txt b/src/snapshot-tests/__fixtures_designed__/simulator-management/boot--error-invalid-id.txt new file mode 100644 index 00000000..6b6a0052 --- /dev/null +++ b/src/snapshot-tests/__fixtures_designed__/simulator-management/boot--error-invalid-id.txt @@ -0,0 +1,10 @@ +๐Ÿ”Œ Boot Simulator + + Simulator: + +โŒ Failed to boot simulator: Invalid device or device pair: + +Next steps: +1. Open Simulator UI: xcodebuildmcp simulator-management open +2. Install app: xcodebuildmcp simulator install --simulator-id "SIMULATOR_UUID" --app-path "PATH_TO_YOUR_APP" +3. Launch app: xcodebuildmcp simulator launch-app --simulator-id "SIMULATOR_UUID" --bundle-id "YOUR_APP_BUNDLE_ID" diff --git a/src/snapshot-tests/__fixtures_designed__/simulator-management/list--success.txt b/src/snapshot-tests/__fixtures_designed__/simulator-management/list--success.txt new file mode 100644 index 00000000..50fefa97 --- /dev/null +++ b/src/snapshot-tests/__fixtures_designed__/simulator-management/list--success.txt @@ -0,0 +1,35 @@ +๐Ÿ“ฑ List Simulators + +iOS 26.2: + iPhone 17 Pro Booted + iPhone 17 Pro Max + iPhone Air + iPhone 17 Booted + iPhone 16e + iPad Pro 13-inch (M5) + iPad Pro 11-inch (M5) + iPad mini (A17 Pro) + iPad (A16) + iPad Air 13-inch (M3) + iPad Air 11-inch (M3) + +watchOS 26.2: + Apple Watch Series 11 (46mm) + Apple Watch Series 11 (42mm) + Apple Watch Ultra 3 (49mm) + Apple Watch SE 3 (44mm) + Apple Watch SE 3 (40mm) + +tvOS 26.2: + Apple TV 4K (3rd generation) + Apple TV 4K (3rd generation) (at 1080p) + Apple TV + +xrOS 26.2: + Apple Vision Pro + +Next steps: +1. Boot simulator: xcodebuildmcp simulator-management boot --simulator-id "UUID_FROM_ABOVE" +2. Open Simulator UI: xcodebuildmcp simulator-management open +3. Build for simulator: xcodebuildmcp simulator build --scheme "YOUR_SCHEME" --simulator-id "UUID_FROM_ABOVE" +4. Get app path: xcodebuildmcp simulator get-app-path --scheme "YOUR_SCHEME" --platform "iOS Simulator" --simulator-id "UUID_FROM_ABOVE" diff --git a/src/snapshot-tests/__fixtures_designed__/simulator-management/open--success.txt b/src/snapshot-tests/__fixtures_designed__/simulator-management/open--success.txt new file mode 100644 index 00000000..b333136b --- /dev/null +++ b/src/snapshot-tests/__fixtures_designed__/simulator-management/open--success.txt @@ -0,0 +1,8 @@ +๐Ÿ“ฑ Open Simulator + +โœ… Simulator app opened successfully. + +Next steps: +1. Boot simulator: xcodebuildmcp simulator-management boot --simulator-id "UUID_FROM_LIST_SIMS" +2. Start log capture: xcodebuildmcp logging start-simulator-log-capture --simulator-id "UUID" --bundle-id "YOUR_APP_BUNDLE_ID" +3. Launch app with logs: xcodebuildmcp simulator launch-app-with-logs --simulator-id "UUID" --bundle-id "YOUR_APP_BUNDLE_ID" diff --git a/src/snapshot-tests/__fixtures_designed__/simulator-management/reset-location--success.txt b/src/snapshot-tests/__fixtures_designed__/simulator-management/reset-location--success.txt new file mode 100644 index 00000000..f12e1967 --- /dev/null +++ b/src/snapshot-tests/__fixtures_designed__/simulator-management/reset-location--success.txt @@ -0,0 +1,5 @@ +๐Ÿ“ Reset Location + + Simulator: + +โœ… Location reset to default. diff --git a/src/snapshot-tests/__fixtures_designed__/simulator-management/set-appearance--success.txt b/src/snapshot-tests/__fixtures_designed__/simulator-management/set-appearance--success.txt new file mode 100644 index 00000000..3f8d13db --- /dev/null +++ b/src/snapshot-tests/__fixtures_designed__/simulator-management/set-appearance--success.txt @@ -0,0 +1,6 @@ +๐ŸŽจ Set Appearance + + Simulator: + Mode: dark + +โœ… Appearance set to dark mode. diff --git a/src/snapshot-tests/__fixtures_designed__/simulator-management/set-location--success.txt b/src/snapshot-tests/__fixtures_designed__/simulator-management/set-location--success.txt new file mode 100644 index 00000000..431cc6e8 --- /dev/null +++ b/src/snapshot-tests/__fixtures_designed__/simulator-management/set-location--success.txt @@ -0,0 +1,7 @@ +๐Ÿ“ Set Location + + Simulator: + Latitude: 37.7749 + Longitude: -122.4194 + +โœ… Location set to 37.7749, -122.4194. diff --git a/src/snapshot-tests/__fixtures_designed__/simulator/build--error-wrong-scheme.txt b/src/snapshot-tests/__fixtures_designed__/simulator/build--error-wrong-scheme.txt new file mode 100644 index 00000000..8e38688d --- /dev/null +++ b/src/snapshot-tests/__fixtures_designed__/simulator/build--error-wrong-scheme.txt @@ -0,0 +1,12 @@ +๐Ÿ”จ Build + + Scheme: NONEXISTENT + Workspace: example_projects/iOS_Calculator/CalculatorApp.xcworkspace + Configuration: Debug + Platform: iOS Simulator + Simulator: iPhone 17 + +Errors (1): + โœ— Scheme NONEXISTENT is not currently configured for the build action. + +โŒ Build failed. (โฑ๏ธ ) diff --git a/src/snapshot-tests/__fixtures_designed__/simulator/build--failure-compilation.txt b/src/snapshot-tests/__fixtures_designed__/simulator/build--failure-compilation.txt new file mode 100644 index 00000000..73442dc6 --- /dev/null +++ b/src/snapshot-tests/__fixtures_designed__/simulator/build--failure-compilation.txt @@ -0,0 +1,12 @@ +๐Ÿ”จ Build + + Scheme: CalculatorApp + Workspace: example_projects/iOS_Calculator/CalculatorApp.xcworkspace + Configuration: Debug + Platform: iOS Simulator + Simulator: iPhone 17 + +Errors (1): + โœ— CalculatorApp/CompileError.swift:3: Cannot convert value of type 'String' to specified type 'Int' + +โŒ Build failed. (โฑ๏ธ ) diff --git a/src/snapshot-tests/__fixtures_designed__/simulator/build--success.txt b/src/snapshot-tests/__fixtures_designed__/simulator/build--success.txt new file mode 100644 index 00000000..fadee5fe --- /dev/null +++ b/src/snapshot-tests/__fixtures_designed__/simulator/build--success.txt @@ -0,0 +1,12 @@ +๐Ÿ”จ Build + + Scheme: CalculatorApp + Workspace: example_projects/iOS_Calculator/CalculatorApp.xcworkspace + Configuration: Debug + Platform: iOS Simulator + Simulator: iPhone 17 + +โœ… Build succeeded. (โฑ๏ธ ) + +Next steps: +1. Get built app path in simulator derived data: xcodebuildmcp simulator get-app-path --simulator-name "iPhone 17" --scheme "CalculatorApp" --platform "iOS Simulator" diff --git a/src/snapshot-tests/__fixtures_designed__/simulator/build-and-run--success.txt b/src/snapshot-tests/__fixtures_designed__/simulator/build-and-run--success.txt new file mode 100644 index 00000000..77e6cb2c --- /dev/null +++ b/src/snapshot-tests/__fixtures_designed__/simulator/build-and-run--success.txt @@ -0,0 +1,24 @@ +๐Ÿš€ Build & Run + + Scheme: CalculatorApp + Workspace: example_projects/iOS_Calculator/CalculatorApp.xcworkspace + Configuration: Debug + Platform: iOS Simulator + Simulator: iPhone 17 + +โœ“ Resolving app path +โœ“ Booting simulator +โœ“ Installing app + +โœ… Build succeeded. (โฑ๏ธ ) + +โœ… Build & Run complete + + โ”œ App Path: /Library/Developer/Xcode/DerivedData/CalculatorApp-/Build/Products/Debug-iphonesimulator/CalculatorApp.app + โ”” Bundle ID: io.sentry.calculatorapp + +Next steps: +1. Capture structured logs (app continues running): xcodebuildmcp logging start-simulator-log-capture --simulator-id "" --bundle-id "io.sentry.calculatorapp" +2. Stop app in simulator: xcodebuildmcp simulator stop --simulator-id "" --bundle-id "io.sentry.calculatorapp" +3. Capture console + structured logs (app restarts): xcodebuildmcp logging start-simulator-log-capture --simulator-id "" --bundle-id "io.sentry.calculatorapp" --capture-console +4. Launch app with logs in one step: xcodebuildmcp simulator launch-app-with-logs --simulator-id "" --bundle-id "io.sentry.calculatorapp" diff --git a/src/snapshot-tests/__fixtures_designed__/simulator/get-app-path--success.txt b/src/snapshot-tests/__fixtures_designed__/simulator/get-app-path--success.txt new file mode 100644 index 00000000..5d68130e --- /dev/null +++ b/src/snapshot-tests/__fixtures_designed__/simulator/get-app-path--success.txt @@ -0,0 +1,14 @@ +๐Ÿ” Get App Path + + Scheme: CalculatorApp + Workspace: example_projects/iOS_Calculator/CalculatorApp.xcworkspace + Configuration: Debug + Platform: iOS Simulator + + โ”” App Path: /Library/Developer/Xcode/DerivedData/CalculatorApp-/Build/Products/Debug-iphonesimulator/CalculatorApp.app + +Next steps: +1. Get bundle ID: xcodebuildmcp project-discovery get-app-bundle-id --app-path "/Library/Developer/Xcode/DerivedData/CalculatorApp-/Build/Products/Debug-iphonesimulator/CalculatorApp.app" +2. Boot simulator: xcodebuildmcp simulator-management boot --simulator-id "" +3. Install on simulator: xcodebuildmcp simulator install --simulator-id "" --app-path "/Library/Developer/Xcode/DerivedData/CalculatorApp-/Build/Products/Debug-iphonesimulator/CalculatorApp.app" +4. Launch on simulator: xcodebuildmcp simulator launch-app --simulator-id "" --bundle-id "BUNDLE_ID" diff --git a/src/snapshot-tests/__fixtures_designed__/simulator/list--success.txt b/src/snapshot-tests/__fixtures_designed__/simulator/list--success.txt new file mode 100644 index 00000000..50fefa97 --- /dev/null +++ b/src/snapshot-tests/__fixtures_designed__/simulator/list--success.txt @@ -0,0 +1,35 @@ +๐Ÿ“ฑ List Simulators + +iOS 26.2: + iPhone 17 Pro Booted + iPhone 17 Pro Max + iPhone Air + iPhone 17 Booted + iPhone 16e + iPad Pro 13-inch (M5) + iPad Pro 11-inch (M5) + iPad mini (A17 Pro) + iPad (A16) + iPad Air 13-inch (M3) + iPad Air 11-inch (M3) + +watchOS 26.2: + Apple Watch Series 11 (46mm) + Apple Watch Series 11 (42mm) + Apple Watch Ultra 3 (49mm) + Apple Watch SE 3 (44mm) + Apple Watch SE 3 (40mm) + +tvOS 26.2: + Apple TV 4K (3rd generation) + Apple TV 4K (3rd generation) (at 1080p) + Apple TV + +xrOS 26.2: + Apple Vision Pro + +Next steps: +1. Boot simulator: xcodebuildmcp simulator-management boot --simulator-id "UUID_FROM_ABOVE" +2. Open Simulator UI: xcodebuildmcp simulator-management open +3. Build for simulator: xcodebuildmcp simulator build --scheme "YOUR_SCHEME" --simulator-id "UUID_FROM_ABOVE" +4. Get app path: xcodebuildmcp simulator get-app-path --scheme "YOUR_SCHEME" --platform "iOS Simulator" --simulator-id "UUID_FROM_ABOVE" diff --git a/src/snapshot-tests/__fixtures_designed__/simulator/stop--error-no-app.txt b/src/snapshot-tests/__fixtures_designed__/simulator/stop--error-no-app.txt new file mode 100644 index 00000000..206a8fa1 --- /dev/null +++ b/src/snapshot-tests/__fixtures_designed__/simulator/stop--error-no-app.txt @@ -0,0 +1,6 @@ +๐Ÿ›‘ Stop App + + Simulator: + Bundle ID: com.nonexistent.app + +โŒ Failed to stop app: An error was encountered processing the command (domain=com.apple.CoreSimulator.SimError, code=164): found nothing to terminate diff --git a/src/snapshot-tests/__fixtures_designed__/simulator/test--failure.txt b/src/snapshot-tests/__fixtures_designed__/simulator/test--failure.txt new file mode 100644 index 00000000..22884302 --- /dev/null +++ b/src/snapshot-tests/__fixtures_designed__/simulator/test--failure.txt @@ -0,0 +1,14 @@ +๐Ÿงช Test + + Scheme: CalculatorApp + Workspace: example_projects/iOS_Calculator/CalculatorApp.xcworkspace + Configuration: Debug + Platform: iOS Simulator + Simulator: iPhone 17 + +Resolved to test(s) + +Failures (1): + โœ— CalculatorAppTests.testCalculatorServiceFailure โ€” XCTAssertEqual failed: ("0") is not equal to ("999") + +โŒ Test failed. (, โฑ๏ธ ) diff --git a/src/snapshot-tests/__fixtures_designed__/simulator/test--success.txt b/src/snapshot-tests/__fixtures_designed__/simulator/test--success.txt new file mode 100644 index 00000000..52c90664 --- /dev/null +++ b/src/snapshot-tests/__fixtures_designed__/simulator/test--success.txt @@ -0,0 +1,14 @@ +๐Ÿงช Test + + Scheme: CalculatorApp + Workspace: example_projects/iOS_Calculator/CalculatorApp.xcworkspace + Configuration: Debug + Platform: iOS Simulator + Simulator: iPhone 17 + +Resolved to test(s) + +โœ… Test succeeded. (, โฑ๏ธ ) + +Next steps: +1. View test coverage: xcodebuildmcp coverage get-coverage-report --xcresult-path "XCRESULT_PATH" diff --git a/src/snapshot-tests/__fixtures_designed__/swift-package/build--error-bad-path.txt b/src/snapshot-tests/__fixtures_designed__/swift-package/build--error-bad-path.txt new file mode 100644 index 00000000..0c31b3c0 --- /dev/null +++ b/src/snapshot-tests/__fixtures_designed__/swift-package/build--error-bad-path.txt @@ -0,0 +1,5 @@ +๐Ÿ“ฆ Swift Package Build + + Package: example_projects/NONEXISTENT + +โŒ Build failed: No such file or directory: example_projects/NONEXISTENT diff --git a/src/snapshot-tests/__fixtures_designed__/swift-package/build--failure-compilation.txt b/src/snapshot-tests/__fixtures_designed__/swift-package/build--failure-compilation.txt new file mode 100644 index 00000000..3ec6a253 --- /dev/null +++ b/src/snapshot-tests/__fixtures_designed__/swift-package/build--failure-compilation.txt @@ -0,0 +1,8 @@ +๐Ÿ“ฆ Swift Package Build + + Package: example_projects/SwiftPackage + +Errors (1): + โœ— Sources/CompileError.swift:3: Cannot convert value of type 'String' to specified type 'Int' + +โŒ Build failed. () diff --git a/src/snapshot-tests/__fixtures_designed__/swift-package/build--success.txt b/src/snapshot-tests/__fixtures_designed__/swift-package/build--success.txt new file mode 100644 index 00000000..6f94a0fa --- /dev/null +++ b/src/snapshot-tests/__fixtures_designed__/swift-package/build--success.txt @@ -0,0 +1,5 @@ +๐Ÿ“ฆ Swift Package Build + + Package: example_projects/SwiftPackage + +โœ… Build succeeded. () diff --git a/src/snapshot-tests/__fixtures_designed__/swift-package/clean--success.txt b/src/snapshot-tests/__fixtures_designed__/swift-package/clean--success.txt new file mode 100644 index 00000000..486e8e85 --- /dev/null +++ b/src/snapshot-tests/__fixtures_designed__/swift-package/clean--success.txt @@ -0,0 +1,5 @@ +๐Ÿงน Swift Package Clean + + Package: example_projects/SwiftPackage + +โœ… Clean succeeded. Build artifacts removed. diff --git a/src/snapshot-tests/__fixtures_designed__/swift-package/list--success.txt b/src/snapshot-tests/__fixtures_designed__/swift-package/list--success.txt new file mode 100644 index 00000000..7be85700 --- /dev/null +++ b/src/snapshot-tests/__fixtures_designed__/swift-package/list--success.txt @@ -0,0 +1,3 @@ +๐Ÿ“ฆ Swift Package List + +โ„น๏ธ No Swift Package processes currently running. diff --git a/src/snapshot-tests/__fixtures_designed__/swift-package/run--success.txt b/src/snapshot-tests/__fixtures_designed__/swift-package/run--success.txt new file mode 100644 index 00000000..aa9902fd --- /dev/null +++ b/src/snapshot-tests/__fixtures_designed__/swift-package/run--success.txt @@ -0,0 +1,8 @@ +๐Ÿ“ฆ Swift Package Run + + Package: example_projects/SwiftPackage + +โœ… Executable completed successfully. + +Output: + Hello, world! diff --git a/src/snapshot-tests/__fixtures_designed__/swift-package/test--failure.txt b/src/snapshot-tests/__fixtures_designed__/swift-package/test--failure.txt new file mode 100644 index 00000000..319ea4d6 --- /dev/null +++ b/src/snapshot-tests/__fixtures_designed__/swift-package/test--failure.txt @@ -0,0 +1,8 @@ +๐Ÿงช Swift Package Test + + Package: example_projects/SwiftPackage + +Failures (1): + โœ— IntentionalFailureTests.testShouldFail โ€” #expect failed + +โŒ Tests failed. (1 failure, ) diff --git a/src/snapshot-tests/__fixtures_designed__/swift-package/test--success.txt b/src/snapshot-tests/__fixtures_designed__/swift-package/test--success.txt new file mode 100644 index 00000000..6f185cca --- /dev/null +++ b/src/snapshot-tests/__fixtures_designed__/swift-package/test--success.txt @@ -0,0 +1,12 @@ +๐Ÿงช Swift Package Test + + Package: example_projects/SwiftPackage + +โœ… All tests passed. (5 tests, ) + +Tests: + โœ” Array operations + โœ” Basic math operations + โœ” Basic truth assertions + โœ” Optional handling + โœ” String operations diff --git a/src/snapshot-tests/__fixtures_designed__/ui-automation/snapshot-ui--success.txt b/src/snapshot-tests/__fixtures_designed__/ui-automation/snapshot-ui--success.txt new file mode 100644 index 00000000..4c1f2e0c --- /dev/null +++ b/src/snapshot-tests/__fixtures_designed__/ui-automation/snapshot-ui--success.txt @@ -0,0 +1,573 @@ +๐Ÿ” Snapshot UI + + Simulator: + +[ + { + "AXFrame" : "{{0, 0}, {402, 874}}", + "AXUniqueId" : null, + "frame" : { + "y" : 0, + "x" : 0, + "width" : 402, + "height" : 874 + }, + "role_description" : "application", + "AXLabel" : "Calculator", + "content_required" : false, + "type" : "Application", + "title" : null, + "help" : null, + "custom_actions" : [ + + ], + "AXValue" : null, + "enabled" : true, + "role" : "AXApplication", + "children" : [ + { + "AXFrame" : "{{344, 250.5}, {34, 67}}", + "AXUniqueId" : null, + "frame" : { + "y" : 250.5, + "x" : 344, + "width" : 34, + "height" : 67 + }, + "role_description" : "text", + "AXLabel" : "0", + "content_required" : false, + "type" : "StaticText", + "title" : null, + "help" : null, + "custom_actions" : [ + + ], + "AXValue" : null, + "enabled" : true, + "role" : "AXStaticText", + "children" : [ + + ], + "subrole" : null, + "pid" : + }, + { + "AXFrame" : "{{19.5, 357.5}, {82.666664123535156, 81}}", + "AXUniqueId" : null, + "frame" : { + "y" : 357.5, + "x" : 19.5, + "width" : 82.7, + "height" : 81 + }, + "role_description" : "button", + "AXLabel" : "C", + "content_required" : false, + "type" : "Button", + "title" : null, + "help" : null, + "custom_actions" : [ + + ], + "AXValue" : null, + "enabled" : true, + "role" : "AXButton", + "children" : [ + + ], + "subrole" : null, + "pid" : + }, + { + "AXFrame" : "{{113.16666412353516, 357.5}, {82.333335876464844, 81}}", + "AXUniqueId" : null, + "frame" : { + "y" : 357.5, + "x" : 113.2, + "width" : 82.3, + "height" : 81 + }, + "role_description" : "button", + "AXLabel" : "ยฑ", + "content_required" : false, + "type" : "Button", + "title" : null, + "help" : null, + "custom_actions" : [ + + ], + "AXValue" : null, + "enabled" : true, + "role" : "AXButton", + "children" : [ + + ], + "subrole" : null, + "pid" : + }, + { + "AXFrame" : "{{206.5, 357.5}, {82.666656494140625, 81}}", + "AXUniqueId" : null, + "frame" : { + "y" : 357.5, + "x" : 206.5, + "width" : 82.7, + "height" : 81 + }, + "role_description" : "button", + "AXLabel" : "%", + "content_required" : false, + "type" : "Button", + "title" : null, + "help" : null, + "custom_actions" : [ + + ], + "AXValue" : null, + "enabled" : true, + "role" : "AXButton", + "children" : [ + + ], + "subrole" : null, + "pid" : + }, + { + "AXFrame" : "{{300.16665649414062, 357.5}, {82.333343505859375, 81}}", + "AXUniqueId" : null, + "frame" : { + "y" : 357.5, + "x" : 300.2, + "width" : 82.3, + "height" : 81 + }, + "role_description" : "button", + "AXLabel" : "รท", + "content_required" : false, + "type" : "Button", + "title" : null, + "help" : null, + "custom_actions" : [ + + ], + "AXValue" : null, + "enabled" : true, + "role" : "AXButton", + "children" : [ + + ], + "subrole" : null, + "pid" : + }, + { + "AXFrame" : "{{19.5, 449.5}, {82.666664123535156, 81}}", + "AXUniqueId" : null, + "frame" : { + "y" : 449.5, + "x" : 19.5, + "width" : 82.7, + "height" : 81 + }, + "role_description" : "button", + "AXLabel" : "7", + "content_required" : false, + "type" : "Button", + "title" : null, + "help" : null, + "custom_actions" : [ + + ], + "AXValue" : null, + "enabled" : true, + "role" : "AXButton", + "children" : [ + + ], + "subrole" : null, + "pid" : + }, + { + "AXFrame" : "{{113.16666412353516, 449.5}, {82.333335876464844, 81}}", + "AXUniqueId" : null, + "frame" : { + "y" : 449.5, + "x" : 113.2, + "width" : 82.3, + "height" : 81 + }, + "role_description" : "button", + "AXLabel" : "8", + "content_required" : false, + "type" : "Button", + "title" : null, + "help" : null, + "custom_actions" : [ + + ], + "AXValue" : null, + "enabled" : true, + "role" : "AXButton", + "children" : [ + + ], + "subrole" : null, + "pid" : + }, + { + "AXFrame" : "{{206.5, 449.5}, {82.666656494140625, 81}}", + "AXUniqueId" : null, + "frame" : { + "y" : 449.5, + "x" : 206.5, + "width" : 82.7, + "height" : 81 + }, + "role_description" : "button", + "AXLabel" : "9", + "content_required" : false, + "type" : "Button", + "title" : null, + "help" : null, + "custom_actions" : [ + + ], + "AXValue" : null, + "enabled" : true, + "role" : "AXButton", + "children" : [ + + ], + "subrole" : null, + "pid" : + }, + { + "AXFrame" : "{{300.16665649414062, 449.5}, {82.333343505859375, 81}}", + "AXUniqueId" : null, + "frame" : { + "y" : 449.5, + "x" : 300.2, + "width" : 82.3, + "height" : 81 + }, + "role_description" : "button", + "AXLabel" : "ร—", + "content_required" : false, + "type" : "Button", + "title" : null, + "help" : null, + "custom_actions" : [ + + ], + "AXValue" : null, + "enabled" : true, + "role" : "AXButton", + "children" : [ + + ], + "subrole" : null, + "pid" : + }, + { + "AXFrame" : "{{19.5, 541.5}, {82.666664123535156, 81}}", + "AXUniqueId" : null, + "frame" : { + "y" : 541.5, + "x" : 19.5, + "width" : 82.7, + "height" : 81 + }, + "role_description" : "button", + "AXLabel" : "4", + "content_required" : false, + "type" : "Button", + "title" : null, + "help" : null, + "custom_actions" : [ + + ], + "AXValue" : null, + "enabled" : true, + "role" : "AXButton", + "children" : [ + + ], + "subrole" : null, + "pid" : + }, + { + "AXFrame" : "{{113.16666412353516, 541.5}, {82.333335876464844, 81}}", + "AXUniqueId" : null, + "frame" : { + "y" : 541.5, + "x" : 113.2, + "width" : 82.3, + "height" : 81 + }, + "role_description" : "button", + "AXLabel" : "5", + "content_required" : false, + "type" : "Button", + "title" : null, + "help" : null, + "custom_actions" : [ + + ], + "AXValue" : null, + "enabled" : true, + "role" : "AXButton", + "children" : [ + + ], + "subrole" : null, + "pid" : + }, + { + "AXFrame" : "{{206.5, 541.5}, {82.666656494140625, 81}}", + "AXUniqueId" : null, + "frame" : { + "y" : 541.5, + "x" : 206.5, + "width" : 82.7, + "height" : 81 + }, + "role_description" : "button", + "AXLabel" : "6", + "content_required" : false, + "type" : "Button", + "title" : null, + "help" : null, + "custom_actions" : [ + + ], + "AXValue" : null, + "enabled" : true, + "role" : "AXButton", + "children" : [ + + ], + "subrole" : null, + "pid" : + }, + { + "AXFrame" : "{{300.16665649414062, 541.5}, {82.333343505859375, 81}}", + "AXUniqueId" : null, + "frame" : { + "y" : 541.5, + "x" : 300.2, + "width" : 82.3, + "height" : 81 + }, + "role_description" : "button", + "AXLabel" : "-", + "content_required" : false, + "type" : "Button", + "title" : null, + "help" : null, + "custom_actions" : [ + + ], + "AXValue" : null, + "enabled" : true, + "role" : "AXButton", + "children" : [ + + ], + "subrole" : null, + "pid" : + }, + { + "AXFrame" : "{{19.5, 633.5}, {82.666664123535156, 81}}", + "AXUniqueId" : null, + "frame" : { + "y" : 633.5, + "x" : 19.5, + "width" : 82.7, + "height" : 81 + }, + "role_description" : "button", + "AXLabel" : "1", + "content_required" : false, + "type" : "Button", + "title" : null, + "help" : null, + "custom_actions" : [ + + ], + "AXValue" : null, + "enabled" : true, + "role" : "AXButton", + "children" : [ + + ], + "subrole" : null, + "pid" : + }, + { + "AXFrame" : "{{113.16666412353516, 633.5}, {82.333335876464844, 81}}", + "AXUniqueId" : null, + "frame" : { + "y" : 633.5, + "x" : 113.2, + "width" : 82.3, + "height" : 81 + }, + "role_description" : "button", + "AXLabel" : "2", + "content_required" : false, + "type" : "Button", + "title" : null, + "help" : null, + "custom_actions" : [ + + ], + "AXValue" : null, + "enabled" : true, + "role" : "AXButton", + "children" : [ + + ], + "subrole" : null, + "pid" : + }, + { + "AXFrame" : "{{206.5, 633.5}, {82.666656494140625, 81}}", + "AXUniqueId" : null, + "frame" : { + "y" : 633.5, + "x" : 206.5, + "width" : 82.7, + "height" : 81 + }, + "role_description" : "button", + "AXLabel" : "3", + "content_required" : false, + "type" : "Button", + "title" : null, + "help" : null, + "custom_actions" : [ + + ], + "AXValue" : null, + "enabled" : true, + "role" : "AXButton", + "children" : [ + + ], + "subrole" : null, + "pid" : + }, + { + "AXFrame" : "{{300.16665649414062, 633.5}, {82.333343505859375, 81}}", + "AXUniqueId" : null, + "frame" : { + "y" : 633.5, + "x" : 300.2, + "width" : 82.3, + "height" : 81 + }, + "role_description" : "button", + "AXLabel" : "+", + "content_required" : false, + "type" : "Button", + "title" : null, + "help" : null, + "custom_actions" : [ + + ], + "AXValue" : null, + "enabled" : true, + "role" : "AXButton", + "children" : [ + + ], + "subrole" : null, + "pid" : + }, + { + "AXFrame" : "{{113.16666412353516, 725.5}, {82.333335876464844, 81}}", + "AXUniqueId" : null, + "frame" : { + "y" : 725.5, + "x" : 113.2, + "width" : 82.3, + "height" : 81 + }, + "role_description" : "button", + "AXLabel" : "0", + "content_required" : false, + "type" : "Button", + "title" : null, + "help" : null, + "custom_actions" : [ + + ], + "AXValue" : null, + "enabled" : true, + "role" : "AXButton", + "children" : [ + + ], + "subrole" : null, + "pid" : + }, + { + "AXFrame" : "{{206.5, 725.5}, {82.666656494140625, 81}}", + "AXUniqueId" : null, + "frame" : { + "y" : 725.5, + "x" : 206.5, + "width" : 82.7, + "height" : 81 + }, + "role_description" : "button", + "AXLabel" : ".", + "content_required" : false, + "type" : "Button", + "title" : null, + "help" : null, + "custom_actions" : [ + + ], + "AXValue" : null, + "enabled" : true, + "role" : "AXButton", + "children" : [ + + ], + "subrole" : null, + "pid" : + }, + { + "AXFrame" : "{{300.16665649414062, 725.5}, {82.333343505859375, 81}}", + "AXUniqueId" : null, + "frame" : { + "y" : 725.5, + "x" : 300.2, + "width" : 82.3, + "height" : 81 + }, + "role_description" : "button", + "AXLabel" : "=", + "content_required" : false, + "type" : "Button", + "title" : null, + "help" : null, + "custom_actions" : [ + + ], + "AXValue" : null, + "enabled" : true, + "role" : "AXButton", + "children" : [ + + ], + "subrole" : null, + "pid" : + } + ], + "subrole" : null, + "pid" : + } +] + diff --git a/src/snapshot-tests/__fixtures_designed__/ui-automation/tap--error-no-simulator.txt b/src/snapshot-tests/__fixtures_designed__/ui-automation/tap--error-no-simulator.txt new file mode 100644 index 00000000..0fa96ecc --- /dev/null +++ b/src/snapshot-tests/__fixtures_designed__/ui-automation/tap--error-no-simulator.txt @@ -0,0 +1,6 @@ +๐Ÿ‘† Tap + + Simulator: + Position: (100, 100) + +โŒ Failed to simulate tap: Simulator with UDID not found. diff --git a/src/snapshot-tests/__fixtures_designed__/utilities/clean--success.txt b/src/snapshot-tests/__fixtures_designed__/utilities/clean--success.txt new file mode 100644 index 00000000..11e4683d --- /dev/null +++ b/src/snapshot-tests/__fixtures_designed__/utilities/clean--success.txt @@ -0,0 +1,8 @@ +๐Ÿงน Clean + + Scheme: CalculatorApp + Workspace: example_projects/iOS_Calculator/CalculatorApp.xcworkspace + Configuration: Debug + Platform: iOS + +โœ… Build succeeded. () diff --git a/src/snapshot-tests/__tests__/coverage.snapshot.test.ts b/src/snapshot-tests/__tests__/coverage.snapshot.test.ts new file mode 100644 index 00000000..5a471139 --- /dev/null +++ b/src/snapshot-tests/__tests__/coverage.snapshot.test.ts @@ -0,0 +1,107 @@ +import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; +import { execSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { createSnapshotHarness, ensureSimulatorBooted } from '../harness.ts'; +import { expectMatchesFixture } from '../fixture-io.ts'; +import type { SnapshotHarness } from '../harness.ts'; + +const WORKSPACE = 'example_projects/iOS_Calculator/CalculatorApp.xcworkspace'; + +describe('coverage workflow', () => { + let harness: SnapshotHarness; + let xcresultPath: string; + let invalidXcresultPath: string; + + beforeAll(async () => { + vi.setConfig({ testTimeout: 120_000 }); + harness = await createSnapshotHarness(); + await ensureSimulatorBooted('iPhone 17'); + + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'coverage-snapshot-')); + xcresultPath = path.join(tmpDir, 'TestResults.xcresult'); + const derivedDataPath = path.join(tmpDir, 'DerivedData'); + + // Create a fake .xcresult directory that passes file-exists validation + // but makes xcrun xccov fail with a real executable error + invalidXcresultPath = path.join(tmpDir, 'invalid.xcresult'); + fs.mkdirSync(invalidXcresultPath); + + // Uses a fresh derived data path to ensure a fully clean build so coverage + // targets are deterministic. The Calculator example app has an intentionally + // failing test, so xcodebuild exits non-zero but the xcresult is still produced. + try { + execSync( + [ + 'xcodebuild test', + `-workspace ${WORKSPACE}`, + '-scheme CalculatorApp', + "-destination 'platform=iOS Simulator,name=iPhone 17'", + '-enableCodeCoverage YES', + `-derivedDataPath ${derivedDataPath}`, + `-resultBundlePath ${xcresultPath}`, + '-quiet', + ].join(' '), + { encoding: 'utf8', timeout: 120_000, stdio: 'pipe' }, + ); + } catch { + // Expected: test suite has an intentional failure + } + + if (!fs.existsSync(xcresultPath)) { + throw new Error(`Failed to generate xcresult at ${xcresultPath}`); + } + }, 120_000); + + afterAll(() => { + harness.cleanup(); + if (xcresultPath) { + const tmpDir = path.dirname(xcresultPath); + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + + describe('get-coverage-report', () => { + it('success', async () => { + // Filter to CalculatorAppTests which is always present and deterministic. + // The unfiltered report can include SPM framework targets non-deterministically. + const { text, isError } = await harness.invoke('coverage', 'get-coverage-report', { + xcresultPath, + target: 'CalculatorAppTests', + }); + expect(isError).toBe(false); + expect(text.length).toBeGreaterThan(10); + expectMatchesFixture(text, __filename, 'get-coverage-report--success'); + }); + + it('error - invalid bundle', async () => { + const { text, isError } = await harness.invoke('coverage', 'get-coverage-report', { + xcresultPath: invalidXcresultPath, + }); + expect(isError).toBe(true); + expectMatchesFixture(text, __filename, 'get-coverage-report--error-invalid-bundle'); + }); + }); + + describe('get-file-coverage', () => { + it('success', async () => { + const { text, isError } = await harness.invoke('coverage', 'get-file-coverage', { + xcresultPath, + file: 'CalculatorService.swift', + }); + expect(isError).toBe(false); + expect(text.length).toBeGreaterThan(10); + expectMatchesFixture(text, __filename, 'get-file-coverage--success'); + }); + + it('error - invalid bundle', async () => { + const { text, isError } = await harness.invoke('coverage', 'get-file-coverage', { + xcresultPath: invalidXcresultPath, + file: 'SomeFile.swift', + }); + expect(isError).toBe(true); + expectMatchesFixture(text, __filename, 'get-file-coverage--error-invalid-bundle'); + }); + }); +}); diff --git a/src/snapshot-tests/__tests__/debugging.snapshot.test.ts b/src/snapshot-tests/__tests__/debugging.snapshot.test.ts new file mode 100644 index 00000000..efb24eeb --- /dev/null +++ b/src/snapshot-tests/__tests__/debugging.snapshot.test.ts @@ -0,0 +1,90 @@ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { createSnapshotHarness } from '../harness.ts'; +import { expectMatchesFixture } from '../fixture-io.ts'; +import type { SnapshotHarness } from '../harness.ts'; + +describe('debugging workflow', () => { + let harness: SnapshotHarness; + + beforeAll(async () => { + harness = await createSnapshotHarness(); + }); + + afterAll(() => { + harness.cleanup(); + }); + + describe('continue', () => { + it('error - no session', async () => { + const { text, isError } = await harness.invoke('debugging', 'continue', {}); + expect(isError).toBe(true); + expectMatchesFixture(text, __filename, 'continue--error-no-session'); + }, 30_000); + }); + + describe('detach', () => { + it('error - no session', async () => { + const { text, isError } = await harness.invoke('debugging', 'detach', {}); + expect(isError).toBe(true); + expectMatchesFixture(text, __filename, 'detach--error-no-session'); + }, 30_000); + }); + + describe('stack', () => { + it('error - no session', async () => { + const { text, isError } = await harness.invoke('debugging', 'stack', {}); + expect(isError).toBe(true); + expectMatchesFixture(text, __filename, 'stack--error-no-session'); + }, 30_000); + }); + + describe('variables', () => { + it('error - no session', async () => { + const { text, isError } = await harness.invoke('debugging', 'variables', {}); + expect(isError).toBe(true); + expectMatchesFixture(text, __filename, 'variables--error-no-session'); + }, 30_000); + }); + + describe('add-breakpoint', () => { + it('error - no session', async () => { + const { text, isError } = await harness.invoke('debugging', 'add-breakpoint', { + file: 'test.swift', + line: 1, + }); + expect(isError).toBe(true); + expectMatchesFixture(text, __filename, 'add-breakpoint--error-no-session'); + }, 30_000); + }); + + describe('remove-breakpoint', () => { + it('error - no session', async () => { + const { text, isError } = await harness.invoke('debugging', 'remove-breakpoint', { + breakpointId: 1, + }); + expect(isError).toBe(true); + expectMatchesFixture(text, __filename, 'remove-breakpoint--error-no-session'); + }, 30_000); + }); + + describe('lldb-command', () => { + it('error - no session', async () => { + const { text, isError } = await harness.invoke('debugging', 'lldb-command', { + command: 'bt', + }); + expect(isError).toBe(true); + expectMatchesFixture(text, __filename, 'lldb-command--error-no-session'); + }, 30_000); + }); + + describe('attach', () => { + it('error - no process', async () => { + const { text, isError } = await harness.invoke('debugging', 'attach', { + simulatorId: '00000000-0000-0000-0000-000000000000', + bundleId: 'com.nonexistent.app', + }); + expect(isError).toBe(true); + expectMatchesFixture(text, __filename, 'attach--error-no-process'); + }, 30_000); + }); +}); diff --git a/src/snapshot-tests/__tests__/device.snapshot.test.ts b/src/snapshot-tests/__tests__/device.snapshot.test.ts new file mode 100644 index 00000000..b22566fb --- /dev/null +++ b/src/snapshot-tests/__tests__/device.snapshot.test.ts @@ -0,0 +1,115 @@ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { createSnapshotHarness } from '../harness.ts'; +import { expectMatchesFixture } from '../fixture-io.ts'; +import type { SnapshotHarness } from '../harness.ts'; + +const WORKSPACE = 'example_projects/iOS_Calculator/CalculatorApp.xcworkspace'; + +describe('device workflow', () => { + let harness: SnapshotHarness; + + beforeAll(async () => { + harness = await createSnapshotHarness(); + }); + + afterAll(() => { + harness.cleanup(); + }); + + describe('build', () => { + it( + 'success', + async () => { + const { text, isError } = await harness.invoke('device', 'build', { + workspacePath: WORKSPACE, + scheme: 'CalculatorApp', + }); + expect(isError).toBe(false); + expect(text.length).toBeGreaterThan(10); + expectMatchesFixture(text, __filename, 'build--success'); + }, + { timeout: 120000 }, + ); + }); + + describe('get-app-path', () => { + it( + 'success', + async () => { + const { text, isError } = await harness.invoke('device', 'get-app-path', { + workspacePath: WORKSPACE, + scheme: 'CalculatorApp', + }); + expect(isError).toBe(false); + expect(text.length).toBeGreaterThan(10); + expectMatchesFixture(text, __filename, 'get-app-path--success'); + }, + { timeout: 120000 }, + ); + }); + + describe('list', () => { + it( + 'success', + async () => { + const { text, isError } = await harness.invoke('device', 'list', {}); + expect(isError).toBe(false); + expectMatchesFixture(text, __filename, 'list--success'); + }, + { timeout: 120000 }, + ); + }); + + describe.runIf(process.env.DEVICE_ID)('build-and-run (requires device)', () => { + it( + 'success', + async () => { + const { text, isError } = await harness.invoke('device', 'build-and-run', { + workspacePath: WORKSPACE, + scheme: 'CalculatorApp', + deviceId: process.env.DEVICE_ID, + }); + expect(isError).toBe(false); + expect(text.length).toBeGreaterThan(10); + expectMatchesFixture(text, __filename, 'build-and-run--success'); + }, + { timeout: 120000 }, + ); + }); + + describe.runIf(process.env.DEVICE_ID)('test (requires device)', () => { + it( + 'success', + async () => { + const { text, isError } = await harness.invoke('device', 'test', { + workspacePath: WORKSPACE, + scheme: 'CalculatorApp', + deviceId: process.env.DEVICE_ID, + }); + expect(text.length).toBeGreaterThan(10); + expectMatchesFixture(text, __filename, 'test--success'); + }, + { timeout: 120000 }, + ); + }); + + describe.runIf(process.env.DEVICE_ID)('install (requires device)', () => { + it.skip('success - requires dynamic built app path', async () => {}); + }); + + describe.runIf(process.env.DEVICE_ID)('launch (requires device)', () => { + it.skip('success - requires installed app', async () => {}); + }); + + describe.runIf(process.env.DEVICE_ID)('stop (requires device)', () => { + it.skip('success - requires running app', async () => {}); + }); + + describe.runIf(process.env.DEVICE_ID)('start-device-log-capture (requires device)', () => { + it.skip('success - requires running app', async () => {}); + }); + + describe.runIf(process.env.DEVICE_ID)('stop-device-log-capture (requires device)', () => { + it.skip('success - requires active log session', async () => {}); + }); +}); diff --git a/src/snapshot-tests/__tests__/doctor.snapshot.test.ts b/src/snapshot-tests/__tests__/doctor.snapshot.test.ts new file mode 100644 index 00000000..07b9d551 --- /dev/null +++ b/src/snapshot-tests/__tests__/doctor.snapshot.test.ts @@ -0,0 +1,19 @@ +import { describe, it, beforeAll, afterAll } from 'vitest'; +import { createSnapshotHarness } from '../harness.ts'; +import type { SnapshotHarness } from '../harness.ts'; + +describe('doctor workflow', () => { + let harness: SnapshotHarness; + + beforeAll(async () => { + harness = await createSnapshotHarness(); + }); + + afterAll(() => { + harness.cleanup(); + }); + + describe('doctor', () => { + it.skip('not exposed as CLI command; output is heavily dynamic (versions, paths)', async () => {}); + }); +}); diff --git a/src/snapshot-tests/__tests__/logging.snapshot.test.ts b/src/snapshot-tests/__tests__/logging.snapshot.test.ts new file mode 100644 index 00000000..6603b332 --- /dev/null +++ b/src/snapshot-tests/__tests__/logging.snapshot.test.ts @@ -0,0 +1,37 @@ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { createSnapshotHarness } from '../harness.ts'; +import { expectMatchesFixture } from '../fixture-io.ts'; +import type { SnapshotHarness } from '../harness.ts'; + +describe('logging workflow', () => { + let harness: SnapshotHarness; + + beforeAll(async () => { + harness = await createSnapshotHarness(); + }); + + afterAll(() => { + harness.cleanup(); + }); + + describe('start-simulator-log-capture', () => { + it('error - invalid session params', async () => { + const { text } = await harness.invoke('logging', 'start-simulator-log-capture', { + simulatorId: '00000000-0000-0000-0000-000000000000', + bundleId: 'com.nonexistent.app', + }); + expect(text.length).toBeGreaterThan(0); + expectMatchesFixture(text, __filename, 'start-sim-log--error'); + }, 30_000); + }); + + describe('stop-simulator-log-capture', () => { + it('error - no session', async () => { + const { text, isError } = await harness.invoke('logging', 'stop-simulator-log-capture', { + logSessionId: 'nonexistent-session-id', + }); + expect(isError).toBe(true); + expectMatchesFixture(text, __filename, 'stop-sim-log--error'); + }, 30_000); + }); +}); diff --git a/src/snapshot-tests/__tests__/macos.snapshot.test.ts b/src/snapshot-tests/__tests__/macos.snapshot.test.ts new file mode 100644 index 00000000..115b1c13 --- /dev/null +++ b/src/snapshot-tests/__tests__/macos.snapshot.test.ts @@ -0,0 +1,124 @@ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { createSnapshotHarness } from '../harness.ts'; +import { expectMatchesFixture } from '../fixture-io.ts'; +import type { SnapshotHarness } from '../harness.ts'; + +const PROJECT = 'example_projects/macOS/MCPTest.xcodeproj'; + +describe('macos workflow', () => { + let harness: SnapshotHarness; + let tmpDir: string; + let fakeAppPath: string; + let bundleIdAppPath: string; + + beforeAll(async () => { + harness = await createSnapshotHarness(); + + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'macos-snapshot-')); + + fakeAppPath = path.join(tmpDir, 'Fake.app'); + fs.mkdirSync(fakeAppPath); + + bundleIdAppPath = path.join(tmpDir, 'BundleTest.app'); + fs.mkdirSync(bundleIdAppPath); + fs.writeFileSync( + path.join(bundleIdAppPath, 'Info.plist'), + ` + + + + CFBundleIdentifier + com.test.snapshot-macos + +`, + ); + }); + + afterAll(() => { + harness.cleanup(); + if (tmpDir) { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + + describe('build', () => { + it('success', { timeout: 120000 }, async () => { + const { text, isError } = await harness.invoke('macos', 'build', { + projectPath: PROJECT, + scheme: 'MCPTest', + }); + expect(isError).toBe(false); + expect(text.length).toBeGreaterThan(10); + expectMatchesFixture(text, __filename, 'build--success'); + }); + }); + + describe('build-and-run', () => { + it('success', { timeout: 120000 }, async () => { + const { text, isError } = await harness.invoke('macos', 'build-and-run', { + projectPath: PROJECT, + scheme: 'MCPTest', + }); + expect(isError).toBe(false); + expect(text.length).toBeGreaterThan(10); + expectMatchesFixture(text, __filename, 'build-and-run--success'); + }); + }); + + describe('test', () => { + it('success', { timeout: 120000 }, async () => { + const { text, isError } = await harness.invoke('macos', 'test', { + projectPath: PROJECT, + scheme: 'MCPTest', + }); + expect(isError).toBe(false); + expect(text.length).toBeGreaterThan(10); + expectMatchesFixture(text, __filename, 'test--success'); + }); + }); + + describe('get-app-path', () => { + it('success', { timeout: 120000 }, async () => { + const { text, isError } = await harness.invoke('macos', 'get-app-path', { + projectPath: PROJECT, + scheme: 'MCPTest', + }); + expect(isError).toBe(false); + expect(text.length).toBeGreaterThan(10); + expectMatchesFixture(text, __filename, 'get-app-path--success'); + }); + }); + + describe('launch', () => { + it('error - invalid app', { timeout: 120000 }, async () => { + const { text } = await harness.invoke('macos', 'launch', { + appPath: fakeAppPath, + }); + expect(text.length).toBeGreaterThan(0); + expectMatchesFixture(text, __filename, 'launch--error-invalid-app'); + }); + }); + + describe('stop', () => { + it('error - no app', { timeout: 120000 }, async () => { + const { text } = await harness.invoke('macos', 'stop', { + appName: 'NonExistentXBMTestApp', + }); + expect(text.length).toBeGreaterThan(0); + expectMatchesFixture(text, __filename, 'stop--error-no-app'); + }); + }); + + describe('get-macos-bundle-id', () => { + it('success', { timeout: 120000 }, async () => { + const { text } = await harness.invoke('macos', 'get-macos-bundle-id', { + appPath: bundleIdAppPath, + }); + expect(text.length).toBeGreaterThan(0); + expectMatchesFixture(text, __filename, 'get-macos-bundle-id--success'); + }); + }); +}); diff --git a/src/snapshot-tests/__tests__/project-discovery.snapshot.test.ts b/src/snapshot-tests/__tests__/project-discovery.snapshot.test.ts new file mode 100644 index 00000000..1fac39ad --- /dev/null +++ b/src/snapshot-tests/__tests__/project-discovery.snapshot.test.ts @@ -0,0 +1,95 @@ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { createSnapshotHarness } from '../harness.ts'; +import { expectMatchesFixture } from '../fixture-io.ts'; +import type { SnapshotHarness } from '../harness.ts'; + +const WORKSPACE = 'example_projects/iOS_Calculator/CalculatorApp.xcworkspace'; + +describe('project-discovery workflow', () => { + let harness: SnapshotHarness; + let tmpDir: string; + let bundleIdAppPath: string; + + beforeAll(async () => { + harness = await createSnapshotHarness(); + + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'proj-discovery-')); + bundleIdAppPath = path.join(tmpDir, 'BundleTest.app'); + fs.mkdirSync(bundleIdAppPath); + fs.writeFileSync( + path.join(bundleIdAppPath, 'Info.plist'), + ` + + + + CFBundleIdentifier + com.test.snapshot + +`, + ); + }); + + afterAll(() => { + harness.cleanup(); + if (tmpDir) { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + + describe('list-schemes', () => { + it('success', async () => { + const { text, isError } = await harness.invoke('project-discovery', 'list-schemes', { + workspacePath: WORKSPACE, + }); + expect(isError).toBe(false); + expect(text.length).toBeGreaterThan(10); + expectMatchesFixture(text, __filename, 'list-schemes--success'); + }); + }); + + describe('show-build-settings', () => { + it('success', async () => { + const { text, isError } = await harness.invoke('project-discovery', 'show-build-settings', { + workspacePath: WORKSPACE, + scheme: 'CalculatorApp', + }); + expect(isError).toBe(false); + expect(text.length).toBeGreaterThan(10); + expectMatchesFixture(text, __filename, 'show-build-settings--success'); + }); + }); + + describe('discover-projs', () => { + it('success', async () => { + const { text, isError } = await harness.invoke('project-discovery', 'discover-projects', { + workspaceRoot: 'example_projects/iOS_Calculator', + }); + expect(isError).toBe(false); + expectMatchesFixture(text, __filename, 'discover-projs--success'); + }); + }); + + describe('get-app-bundle-id', () => { + it('success', async () => { + const { text, isError } = await harness.invoke('project-discovery', 'get-app-bundle-id', { + appPath: bundleIdAppPath, + }); + expect(isError).toBe(false); + expect(text.length).toBeGreaterThan(0); + expectMatchesFixture(text, __filename, 'get-app-bundle-id--success'); + }); + }); + + describe('get-macos-bundle-id', () => { + it('success', async () => { + const { text } = await harness.invoke('project-discovery', 'get-macos-bundle-id', { + appPath: bundleIdAppPath, + }); + expect(text.length).toBeGreaterThan(0); + expectMatchesFixture(text, __filename, 'get-macos-bundle-id--success'); + }); + }); +}); diff --git a/src/snapshot-tests/__tests__/project-scaffolding.snapshot.test.ts b/src/snapshot-tests/__tests__/project-scaffolding.snapshot.test.ts new file mode 100644 index 00000000..83e3c31e --- /dev/null +++ b/src/snapshot-tests/__tests__/project-scaffolding.snapshot.test.ts @@ -0,0 +1,76 @@ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { mkdtempSync, rmSync, mkdirSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { createSnapshotHarness } from '../harness.ts'; +import { expectMatchesFixture } from '../fixture-io.ts'; +import type { SnapshotHarness } from '../harness.ts'; + +function normalizeTmpDir(text: string, tmpDir: string): string { + const escaped = tmpDir.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + return text.replace(new RegExp(escaped, 'g'), ''); +} + +describe('project-scaffolding workflow', () => { + let harness: SnapshotHarness; + let tmpDir: string; + + beforeAll(async () => { + harness = await createSnapshotHarness(); + tmpDir = mkdtempSync(join(tmpdir(), 'xbm-scaffold-')); + }); + + afterAll(() => { + harness.cleanup(); + rmSync(tmpDir, { recursive: true, force: true }); + }); + + describe('scaffold-ios', () => { + it('success', async () => { + const outputPath = join(tmpDir, 'ios'); + const { text, isError } = await harness.invoke('project-scaffolding', 'scaffold-ios', { + projectName: 'SnapshotTestApp', + outputPath, + }); + expect(isError).toBe(false); + expect(text.length).toBeGreaterThan(10); + expectMatchesFixture(normalizeTmpDir(text, tmpDir), __filename, 'scaffold-ios--success'); + }, 120000); + + it('error - existing project', async () => { + const outputPath = join(tmpDir, 'ios-existing'); + mkdirSync(outputPath, { recursive: true }); + + // Scaffold once to create the project files + await harness.invoke('project-scaffolding', 'scaffold-ios', { + projectName: 'SnapshotTestApp', + outputPath, + }); + + // Scaffold again into the same directory to trigger the error + const { text, isError } = await harness.invoke('project-scaffolding', 'scaffold-ios', { + projectName: 'SnapshotTestApp', + outputPath, + }); + expect(isError).toBe(true); + expectMatchesFixture( + normalizeTmpDir(text, tmpDir), + __filename, + 'scaffold-ios--error-existing', + ); + }, 120000); + }); + + describe('scaffold-macos', () => { + it('success', async () => { + const outputPath = join(tmpDir, 'macos'); + const { text, isError } = await harness.invoke('project-scaffolding', 'scaffold-macos', { + projectName: 'SnapshotTestMacApp', + outputPath, + }); + expect(isError).toBe(false); + expect(text.length).toBeGreaterThan(10); + expectMatchesFixture(normalizeTmpDir(text, tmpDir), __filename, 'scaffold-macos--success'); + }, 120000); + }); +}); diff --git a/src/snapshot-tests/__tests__/session-management.snapshot.test.ts b/src/snapshot-tests/__tests__/session-management.snapshot.test.ts new file mode 100644 index 00000000..6f919be2 --- /dev/null +++ b/src/snapshot-tests/__tests__/session-management.snapshot.test.ts @@ -0,0 +1,64 @@ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { createSnapshotHarness } from '../harness.ts'; +import { expectMatchesFixture } from '../fixture-io.ts'; +import type { SnapshotHarness } from '../harness.ts'; + +const WORKSPACE = 'example_projects/iOS_Calculator/CalculatorApp.xcworkspace'; + +describe('session-management workflow', () => { + let harness: SnapshotHarness; + + beforeAll(async () => { + harness = await createSnapshotHarness(); + }); + + afterAll(() => { + harness.cleanup(); + }); + + describe('session-set-defaults', () => { + it('success', async () => { + const { text, isError } = await harness.invoke('session-management', 'set-defaults', { + scheme: 'CalculatorApp', + workspacePath: WORKSPACE, + }); + expect(isError).toBe(false); + expect(text.length).toBeGreaterThan(10); + expectMatchesFixture(text, __filename, 'session-set-defaults--success'); + }); + }); + + describe('session-show-defaults', () => { + it('success', async () => { + const { text, isError } = await harness.invoke('session-management', 'show-defaults', {}); + expect(isError).toBe(false); + expectMatchesFixture(text, __filename, 'session-show-defaults--success'); + }); + }); + + describe('session-clear-defaults', () => { + it('success', async () => { + const { text, isError } = await harness.invoke('session-management', 'clear-defaults', {}); + expect(isError).toBe(false); + expectMatchesFixture(text, __filename, 'session-clear-defaults--success'); + }); + }); + + describe('session-use-defaults-profile', () => { + it('success', async () => { + const { text } = await harness.invoke('session-management', 'use-defaults-profile', { + global: true, + }); + expect(text.length).toBeGreaterThan(0); + expectMatchesFixture(text, __filename, 'session-use-defaults-profile--success'); + }); + }); + + describe('session-sync-xcode-defaults', () => { + it('success', async () => { + const { text } = await harness.invoke('session-management', 'sync-xcode-defaults', {}); + expect(text.length).toBeGreaterThan(0); + expectMatchesFixture(text, __filename, 'session-sync-xcode-defaults--success'); + }); + }); +}); diff --git a/src/snapshot-tests/__tests__/simulator-management.snapshot.test.ts b/src/snapshot-tests/__tests__/simulator-management.snapshot.test.ts new file mode 100644 index 00000000..ad5c6f15 --- /dev/null +++ b/src/snapshot-tests/__tests__/simulator-management.snapshot.test.ts @@ -0,0 +1,92 @@ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { createSnapshotHarness, ensureSimulatorBooted } from '../harness.ts'; +import { expectMatchesFixture } from '../fixture-io.ts'; +import type { SnapshotHarness } from '../harness.ts'; + +describe('simulator-management workflow', () => { + let harness: SnapshotHarness; + let simulatorUdid: string; + + beforeAll(async () => { + simulatorUdid = await ensureSimulatorBooted('iPhone 17'); + harness = await createSnapshotHarness(); + }); + + afterAll(() => { + harness.cleanup(); + }); + + describe('list', () => { + it('success', async () => { + const { text, isError } = await harness.invoke('simulator-management', 'list', {}); + expect(isError).toBe(false); + expect(text.length).toBeGreaterThan(10); + expectMatchesFixture(text, __filename, 'list--success'); + }); + }); + + describe('boot', () => { + it('error - invalid id', async () => { + const { text } = await harness.invoke('simulator-management', 'boot', { + simulatorId: '00000000-0000-0000-0000-000000000000', + }); + expectMatchesFixture(text, __filename, 'boot--error-invalid-id'); + }); + }); + + describe('open', () => { + it('success', async () => { + const { text, isError } = await harness.invoke('simulator-management', 'open', {}); + expect(isError).toBe(false); + expectMatchesFixture(text, __filename, 'open--success'); + }); + }); + + describe('set-appearance', () => { + it('success', async () => { + const { text, isError } = await harness.invoke('simulator-management', 'set-appearance', { + simulatorId: simulatorUdid, + mode: 'dark', + }); + expect(isError).toBe(false); + expectMatchesFixture(text, __filename, 'set-appearance--success'); + }); + }); + + describe('set-location', () => { + it('success', async () => { + const { text, isError } = await harness.invoke('simulator-management', 'set-location', { + simulatorId: simulatorUdid, + latitude: 37.7749, + longitude: -122.4194, + }); + expect(isError).toBe(false); + expectMatchesFixture(text, __filename, 'set-location--success'); + }); + }); + + describe('reset-location', () => { + it('success', async () => { + const { text, isError } = await harness.invoke('simulator-management', 'reset-location', { + simulatorId: simulatorUdid, + }); + expect(isError).toBe(false); + expectMatchesFixture(text, __filename, 'reset-location--success'); + }); + }); + + describe('statusbar', () => { + it('success', async () => { + const { text, isError } = await harness.invoke('simulator-management', 'statusbar', { + simulatorId: simulatorUdid, + dataNetwork: 'wifi', + }); + expect(isError).toBe(false); + expectMatchesFixture(text, __filename, 'statusbar--success'); + }); + }); + + describe('erase', () => { + it.skip('destructive - erases simulator content', () => {}); + }); +}); diff --git a/src/snapshot-tests/__tests__/simulator.snapshot.test.ts b/src/snapshot-tests/__tests__/simulator.snapshot.test.ts new file mode 100644 index 00000000..58a0f2eb --- /dev/null +++ b/src/snapshot-tests/__tests__/simulator.snapshot.test.ts @@ -0,0 +1,164 @@ +import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; +import { createSnapshotHarness, ensureSimulatorBooted } from '../harness.ts'; +import { expectMatchesFixture } from '../fixture-io.ts'; +import type { SnapshotHarness } from '../harness.ts'; + +const WORKSPACE = 'example_projects/iOS_Calculator/CalculatorApp.xcworkspace'; + +describe('simulator workflow', () => { + let harness: SnapshotHarness; + let simulatorUdid: string; + + beforeAll(async () => { + vi.setConfig({ testTimeout: 120_000 }); + harness = await createSnapshotHarness(); + simulatorUdid = await ensureSimulatorBooted('iPhone 17'); + }, 120_000); + + afterAll(() => { + harness.cleanup(); + }); + + describe('build', () => { + it('success', async () => { + const { text, isError } = await harness.invoke('simulator', 'build', { + workspacePath: WORKSPACE, + scheme: 'CalculatorApp', + simulatorName: 'iPhone 17', + }); + expect(isError).toBe(false); + expect(text.length).toBeGreaterThan(10); + expectMatchesFixture(text, __filename, 'build--success'); + }, 120_000); + + it('error - wrong scheme', async () => { + const { text, isError } = await harness.invoke('simulator', 'build', { + workspacePath: WORKSPACE, + scheme: 'NONEXISTENT', + simulatorName: 'iPhone 17', + }); + expect(isError).toBe(true); + expectMatchesFixture(text, __filename, 'build--error-wrong-scheme'); + }, 120_000); + }); + + describe('build-and-run', () => { + it('success', async () => { + const { text, isError } = await harness.invoke('simulator', 'build-and-run', { + workspacePath: WORKSPACE, + scheme: 'CalculatorApp', + simulatorName: 'iPhone 17', + }); + expect(isError).toBe(false); + expect(text.length).toBeGreaterThan(10); + expectMatchesFixture(text, __filename, 'build-and-run--success'); + }, 120_000); + }); + + describe('test', () => { + it('success', async () => { + const { text, isError } = await harness.invoke('simulator', 'test', { + workspacePath: WORKSPACE, + scheme: 'CalculatorApp', + simulatorName: 'iPhone 17', + }); + expect(isError).toBe(true); + expect(text.length).toBeGreaterThan(10); + expectMatchesFixture(text, __filename, 'test--success'); + }, 120_000); + }); + + describe('get-app-path', () => { + it('success', async () => { + const { text, isError } = await harness.invoke('simulator', 'get-app-path', { + workspacePath: WORKSPACE, + scheme: 'CalculatorApp', + platform: 'iOS Simulator', + simulatorName: 'iPhone 17', + }); + expect(isError).toBe(false); + expect(text.length).toBeGreaterThan(10); + expectMatchesFixture(text, __filename, 'get-app-path--success'); + }, 120_000); + }); + + describe('list', () => { + it('success', async () => { + const { text, isError } = await harness.invoke('simulator', 'list', {}); + expect(isError).toBe(false); + expect(text.length).toBeGreaterThan(10); + expectMatchesFixture(text, __filename, 'list--success'); + }, 120_000); + }); + + describe('install', () => { + it.skip('success - requires dynamic built app path', async () => {}); + + it('error - invalid app', async () => { + const fs = await import('node:fs'); + const os = await import('node:os'); + const path = await import('node:path'); + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'sim-install-')); + const fakeApp = path.join(tmpDir, 'NotAnApp.app'); + fs.mkdirSync(fakeApp); + try { + const { text } = await harness.invoke('simulator', 'install', { + simulatorId: simulatorUdid, + appPath: fakeApp, + }); + expect(text.length).toBeGreaterThan(0); + expectMatchesFixture(text, __filename, 'install--error-invalid-app'); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }, 120_000); + }); + + describe('launch-app', () => { + it('success', async () => { + const { text, isError } = await harness.invoke('simulator', 'launch-app', { + simulatorId: simulatorUdid, + bundleId: 'io.sentry.calculatorapp', + }); + expect(isError).toBe(false); + expectMatchesFixture(text, __filename, 'launch-app--success'); + }, 120_000); + }); + + describe('launch-app-with-logs', () => { + it('success', async () => { + const { text, isError } = await harness.invoke('simulator', 'launch-app-with-logs', { + simulatorId: simulatorUdid, + bundleId: 'io.sentry.calculatorapp', + }); + expect(isError).toBe(false); + expectMatchesFixture(text, __filename, 'launch-app-with-logs--success'); + }, 120_000); + }); + + describe('screenshot', () => { + it('success', async () => { + const { text, isError } = await harness.invoke('simulator', 'screenshot', { + simulatorId: simulatorUdid, + returnFormat: 'path', + }); + expect(isError).toBe(false); + expectMatchesFixture(text, __filename, 'screenshot--success'); + }, 120_000); + }); + + describe('record-video', () => { + it.skip('requires start/stop lifecycle', async () => {}); + }); + + describe('stop', () => { + it('error - no app', async () => { + const { text, isError } = await harness.invoke('simulator', 'stop', { + simulatorId: simulatorUdid, + bundleId: 'com.nonexistent.app', + }); + expect(isError).toBe(true); + expectMatchesFixture(text, __filename, 'stop--error-no-app'); + }, 120_000); + }); +}); diff --git a/src/snapshot-tests/__tests__/swift-package.snapshot.test.ts b/src/snapshot-tests/__tests__/swift-package.snapshot.test.ts new file mode 100644 index 00000000..3ccf372e --- /dev/null +++ b/src/snapshot-tests/__tests__/swift-package.snapshot.test.ts @@ -0,0 +1,89 @@ +import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; +import { createSnapshotHarness } from '../harness.ts'; +import { expectMatchesFixture } from '../fixture-io.ts'; +import type { SnapshotHarness } from '../harness.ts'; + +const PACKAGE_PATH = 'example_projects/spm'; + +describe('swift-package workflow', () => { + let harness: SnapshotHarness; + + beforeAll(async () => { + vi.setConfig({ testTimeout: 120_000 }); + harness = await createSnapshotHarness(); + }, 120_000); + + afterAll(() => { + harness.cleanup(); + }); + + describe('build', () => { + it('success', async () => { + const { text, isError } = await harness.invoke('swift-package', 'build', { + packagePath: PACKAGE_PATH, + }); + expect(isError).toBe(false); + expect(text.length).toBeGreaterThan(10); + expectMatchesFixture(text, __filename, 'build--success'); + }, 120_000); + + it('error - bad path', async () => { + const { text, isError } = await harness.invoke('swift-package', 'build', { + packagePath: 'example_projects/NONEXISTENT', + }); + expect(isError).toBe(true); + expectMatchesFixture(text, __filename, 'build--error-bad-path'); + }); + }); + + describe('test', () => { + it('success', async () => { + const { text, isError } = await harness.invoke('swift-package', 'test', { + packagePath: PACKAGE_PATH, + }); + expect(isError).toBe(false); + expect(text.length).toBeGreaterThan(10); + expectMatchesFixture(text, __filename, 'test--success'); + }, 120_000); + }); + + describe('clean', () => { + it('success', async () => { + const { text, isError } = await harness.invoke('swift-package', 'clean', { + packagePath: PACKAGE_PATH, + }); + expect(isError).toBe(false); + expectMatchesFixture(text, __filename, 'clean--success'); + }); + }); + + describe('run', () => { + it('success', async () => { + const { text, isError } = await harness.invoke('swift-package', 'run', { + packagePath: PACKAGE_PATH, + executableName: 'spm', + }); + expect(isError).toBe(false); + expect(text.length).toBeGreaterThan(0); + expectMatchesFixture(text, __filename, 'run--success'); + }, 120_000); + }); + + describe('list', () => { + it('success', async () => { + const { text, isError } = await harness.invoke('swift-package', 'list', {}); + expect(isError).toBe(false); + expectMatchesFixture(text, __filename, 'list--success'); + }); + }); + + describe('stop', () => { + it('error - no process', async () => { + const { text, isError } = await harness.invoke('swift-package', 'stop', { + pid: 999999, + }); + expect(isError).toBe(true); + expectMatchesFixture(text, __filename, 'stop--error-no-process'); + }); + }); +}); diff --git a/src/snapshot-tests/__tests__/ui-automation.snapshot.test.ts b/src/snapshot-tests/__tests__/ui-automation.snapshot.test.ts new file mode 100644 index 00000000..25ea2b32 --- /dev/null +++ b/src/snapshot-tests/__tests__/ui-automation.snapshot.test.ts @@ -0,0 +1,166 @@ +import { execSync } from 'node:child_process'; +import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; +import { createSnapshotHarness, ensureSimulatorBooted } from '../harness.ts'; +import { expectMatchesFixture } from '../fixture-io.ts'; +import type { SnapshotHarness } from '../harness.ts'; + +const WORKSPACE = 'example_projects/iOS_Calculator/CalculatorApp.xcworkspace'; +const BUNDLE_ID = 'io.sentry.calculatorapp'; +const INVALID_SIMULATOR_ID = '00000000-0000-0000-0000-000000000000'; + +describe('ui-automation workflow', () => { + let harness: SnapshotHarness; + let simulatorUdid: string; + + beforeAll(async () => { + vi.setConfig({ testTimeout: 120_000 }); + simulatorUdid = await ensureSimulatorBooted('iPhone 17'); + harness = await createSnapshotHarness(); + + await harness.invoke('simulator', 'build-and-run', { + workspacePath: WORKSPACE, + scheme: 'CalculatorApp', + simulatorName: 'iPhone 17', + }); + + try { + execSync(`xcrun simctl launch ${simulatorUdid} ${BUNDLE_ID}`, { encoding: 'utf8' }); + } catch { + // App may already be running + } + await new Promise((resolve) => setTimeout(resolve, 3000)); + }); + + afterAll(() => { + harness.cleanup(); + }); + + describe('snapshot-ui', () => { + it('success - calculator app', async () => { + const { text, isError } = await harness.invoke('ui-automation', 'snapshot-ui', { + simulatorId: simulatorUdid, + }); + expect(isError).toBe(false); + expect(text.length).toBeGreaterThan(100); + expectMatchesFixture(text, __filename, 'snapshot-ui--success'); + }); + }); + + describe('tap', () => { + it('success', async () => { + const { text, isError } = await harness.invoke('ui-automation', 'tap', { + simulatorId: simulatorUdid, + x: 100, + y: 400, + }); + expect(isError).toBe(false); + expectMatchesFixture(text, __filename, 'tap--success'); + }); + + it('error - invalid simulator', async () => { + const { text, isError } = await harness.invoke('ui-automation', 'tap', { + simulatorId: INVALID_SIMULATOR_ID, + x: 100, + y: 100, + }); + expect(isError).toBe(true); + expectMatchesFixture(text, __filename, 'tap--error-no-simulator'); + }); + }); + + describe('touch', () => { + it('success', async () => { + const { text, isError } = await harness.invoke('ui-automation', 'touch', { + simulatorId: simulatorUdid, + x: 100, + y: 400, + down: true, + up: true, + }); + expect(isError).toBe(false); + expectMatchesFixture(text, __filename, 'touch--success'); + }); + }); + + describe('long-press', () => { + it('success', async () => { + const { text, isError } = await harness.invoke('ui-automation', 'long-press', { + simulatorId: simulatorUdid, + x: 100, + y: 400, + duration: 500, + }); + expect(isError).toBe(false); + expectMatchesFixture(text, __filename, 'long-press--success'); + }); + }); + + describe('swipe', () => { + it('success', async () => { + const { text, isError } = await harness.invoke('ui-automation', 'swipe', { + simulatorId: simulatorUdid, + x1: 200, + y1: 400, + x2: 200, + y2: 200, + }); + expect(isError).toBe(false); + expectMatchesFixture(text, __filename, 'swipe--success'); + }); + }); + + describe('gesture', () => { + it('success', async () => { + const { text, isError } = await harness.invoke('ui-automation', 'gesture', { + simulatorId: simulatorUdid, + preset: 'scroll-down', + }); + expect(isError).toBe(false); + expectMatchesFixture(text, __filename, 'gesture--success'); + }); + }); + + describe('button', () => { + it('success', async () => { + const { text, isError } = await harness.invoke('ui-automation', 'button', { + simulatorId: simulatorUdid, + buttonType: 'home', + }); + expect(isError).toBe(false); + expectMatchesFixture(text, __filename, 'button--success'); + }); + }); + + describe('key-press', () => { + it('success', async () => { + const { text, isError } = await harness.invoke('ui-automation', 'key-press', { + simulatorId: simulatorUdid, + keyCode: 4, + }); + expect(isError).toBe(false); + expectMatchesFixture(text, __filename, 'key-press--success'); + }); + }); + + describe('key-sequence', () => { + it('success', async () => { + const { text, isError } = await harness.invoke('ui-automation', 'key-sequence', { + simulatorId: simulatorUdid, + keyCodes: [4, 5, 6], + }); + expect(isError).toBe(false); + expectMatchesFixture(text, __filename, 'key-sequence--success'); + }); + }); + + describe('type-text', () => { + it('success', async () => { + const { text, isError } = await harness.invoke('ui-automation', 'type-text', { + simulatorId: simulatorUdid, + text: 'hello', + }); + expect(isError).toBe(false); + expectMatchesFixture(text, __filename, 'type-text--success'); + }); + }); +}); diff --git a/src/snapshot-tests/__tests__/utilities.snapshot.test.ts b/src/snapshot-tests/__tests__/utilities.snapshot.test.ts new file mode 100644 index 00000000..6906a9d5 --- /dev/null +++ b/src/snapshot-tests/__tests__/utilities.snapshot.test.ts @@ -0,0 +1,30 @@ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { createSnapshotHarness } from '../harness.ts'; +import { expectMatchesFixture } from '../fixture-io.ts'; +import type { SnapshotHarness } from '../harness.ts'; + +const WORKSPACE = 'example_projects/iOS_Calculator/CalculatorApp.xcworkspace'; + +describe('utilities workflow', () => { + let harness: SnapshotHarness; + + beforeAll(async () => { + harness = await createSnapshotHarness(); + }); + + afterAll(() => { + harness.cleanup(); + }); + + describe('clean', () => { + it('success', async () => { + const { text, isError } = await harness.invoke('utilities', 'clean', { + workspacePath: WORKSPACE, + scheme: 'CalculatorApp', + }); + expect(isError).toBe(false); + expect(text.length).toBeGreaterThan(10); + expectMatchesFixture(text, __filename, 'clean--success'); + }, 120000); + }); +}); diff --git a/src/snapshot-tests/__tests__/workflow-discovery.snapshot.test.ts b/src/snapshot-tests/__tests__/workflow-discovery.snapshot.test.ts new file mode 100644 index 00000000..3f4c6d68 --- /dev/null +++ b/src/snapshot-tests/__tests__/workflow-discovery.snapshot.test.ts @@ -0,0 +1,20 @@ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { createSnapshotHarness } from '../harness.ts'; +import { expectMatchesFixture } from '../fixture-io.ts'; +import type { SnapshotHarness } from '../harness.ts'; + +describe('workflow-discovery workflow', () => { + let harness: SnapshotHarness; + + beforeAll(async () => { + harness = await createSnapshotHarness(); + }); + + afterAll(() => { + harness.cleanup(); + }); + + describe('manage-workflows', () => { + it.skip('requires MCP runtime (tool registry must be initialized)', async () => {}); + }); +}); diff --git a/src/snapshot-tests/__tests__/xcode-ide.snapshot.test.ts b/src/snapshot-tests/__tests__/xcode-ide.snapshot.test.ts new file mode 100644 index 00000000..c2fd51b0 --- /dev/null +++ b/src/snapshot-tests/__tests__/xcode-ide.snapshot.test.ts @@ -0,0 +1,62 @@ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { createSnapshotHarness } from '../harness.ts'; +import { expectMatchesFixture } from '../fixture-io.ts'; +import type { SnapshotHarness } from '../harness.ts'; + +const ENABLED = !!process.env.SNAPSHOT_XCODE_IDE; + +describe.skipIf(!ENABLED)('xcode-ide workflow', () => { + let harness: SnapshotHarness; + + beforeAll(async () => { + harness = await createSnapshotHarness(); + }, 30000); + + afterAll(() => { + harness.cleanup(); + }); + + describe('list-tools', () => { + it('success', async () => { + const { text, isError } = await harness.invoke('xcode-ide', 'list-tools', {}); + expect(isError).toBe(false); + expect(text.length).toBeGreaterThan(0); + expectMatchesFixture(text, __filename, 'list-tools--success'); + }, 30000); + }); + + describe('call-tool', () => { + it('error - unknown tool', async () => { + const { text, isError } = await harness.invoke('xcode-ide', 'call-tool', { + remoteTool: 'nonexistent', + }); + expect(isError).toBe(true); + expect(text.length).toBeGreaterThan(0); + expectMatchesFixture(text, __filename, 'call-tool--error-unknown-tool'); + }, 60000); + }); + + describe('bridge-status', () => { + it('success', async () => { + const { text } = await harness.invoke('xcode-ide', 'bridge-status', {}); + expect(text.length).toBeGreaterThan(0); + expectMatchesFixture(text, __filename, 'bridge-status--success'); + }, 30000); + }); + + describe('bridge-sync', () => { + it('success', async () => { + const { text } = await harness.invoke('xcode-ide', 'bridge-sync', {}); + expect(text.length).toBeGreaterThan(0); + expectMatchesFixture(text, __filename, 'bridge-sync--success'); + }, 60000); + }); + + describe('bridge-disconnect', () => { + it('success', async () => { + const { text } = await harness.invoke('xcode-ide', 'bridge-disconnect', {}); + expect(text.length).toBeGreaterThan(0); + expectMatchesFixture(text, __filename, 'bridge-disconnect--success'); + }, 30000); + }); +}); diff --git a/src/snapshot-tests/fixture-io.ts b/src/snapshot-tests/fixture-io.ts new file mode 100644 index 00000000..7f0b6cb6 --- /dev/null +++ b/src/snapshot-tests/fixture-io.ts @@ -0,0 +1,35 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { expect } from 'vitest'; + +const FIXTURES_DIR = path.resolve(process.cwd(), 'src/snapshot-tests/__fixtures__'); + +function shouldUpdateSnapshots(): boolean { + return process.env.UPDATE_SNAPSHOTS === '1' || process.env.UPDATE_SNAPSHOTS === 'true'; +} + +export function fixturePathFor(testFilePath: string, scenario: string): string { + const workflow = path.basename(testFilePath, '.snapshot.test.ts'); + return path.join(FIXTURES_DIR, workflow, `${scenario}.txt`); +} + +export function expectMatchesFixture(actual: string, testFilePath: string, scenario: string): void { + const fixturePath = fixturePathFor(testFilePath, scenario); + + if (shouldUpdateSnapshots()) { + const dir = path.dirname(fixturePath); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(fixturePath, actual, 'utf8'); + return; + } + + if (!fs.existsSync(fixturePath)) { + throw new Error( + `Fixture missing: ${path.relative(process.cwd(), fixturePath)}\n` + + 'Run with UPDATE_SNAPSHOTS=1 to generate it.', + ); + } + + const expected = fs.readFileSync(fixturePath, 'utf8'); + expect(actual).toBe(expected); +} diff --git a/src/snapshot-tests/harness.ts b/src/snapshot-tests/harness.ts new file mode 100644 index 00000000..f9ef6806 --- /dev/null +++ b/src/snapshot-tests/harness.ts @@ -0,0 +1,140 @@ +import { spawnSync, execSync } from 'node:child_process'; +import path from 'node:path'; +import { normalizeSnapshotOutput } from './normalize.ts'; +import { loadManifest } from '../core/manifest/load-manifest.ts'; +import { getEffectiveCliName } from '../core/manifest/schema.ts'; +import { importToolModule } from '../core/manifest/import-tool-module.ts'; +import type { ToolResponse } from '../types/common.ts'; + +const CLI_PATH = path.resolve(process.cwd(), 'build/cli.js'); + +export interface SnapshotHarness { + invoke( + workflow: string, + cliToolName: string, + args: Record, + ): Promise; + cleanup(): void; +} + +export interface SnapshotResult { + text: string; + isError: boolean; +} + +function resolveToolManifest( + workflowId: string, + cliToolName: string, +): { toolModulePath: string; isMcpOnly: boolean } | null { + const manifest = loadManifest(); + const workflow = manifest.workflows.get(workflowId); + if (!workflow) return null; + + const isMcpOnly = !workflow.availability.cli; + + for (const toolId of workflow.tools) { + const tool = manifest.tools.get(toolId); + if (!tool) continue; + if (getEffectiveCliName(tool) === cliToolName) { + return { toolModulePath: tool.module, isMcpOnly }; + } + } + + return null; +} + +function toolResponseToText(response: ToolResponse): string { + const parts: string[] = []; + for (const item of response.content ?? []) { + if (item.type === 'text') { + parts.push(item.text); + } + } + return parts.join('\n') + '\n'; +} + +export async function createSnapshotHarness(): Promise { + async function invoke( + workflow: string, + cliToolName: string, + args: Record, + ): Promise { + const resolved = resolveToolManifest(workflow, cliToolName); + + if (resolved?.isMcpOnly) { + return invokeDirect(resolved.toolModulePath, args); + } + + return invokeCli(workflow, cliToolName, args); + } + + async function invokeCli( + workflow: string, + cliToolName: string, + args: Record, + ): Promise { + const jsonArg = JSON.stringify(args); + const { VITEST, NODE_ENV, ...cleanEnv } = process.env; + const result = spawnSync('node', [CLI_PATH, workflow, cliToolName, '--json', jsonArg], { + encoding: 'utf8', + timeout: 120000, + cwd: process.cwd(), + env: cleanEnv, + }); + + const stdout = result.stdout ?? ''; + return { + text: normalizeSnapshotOutput(stdout), + isError: result.status !== 0, + }; + } + + async function invokeDirect( + toolModulePath: string, + args: Record, + ): Promise { + const toolModule = await importToolModule(toolModulePath); + const prev = process.env.SNAPSHOT_TEST_REAL_EXECUTOR; + process.env.SNAPSHOT_TEST_REAL_EXECUTOR = '1'; + try { + const response = (await toolModule.handler(args)) as ToolResponse; + const rawText = toolResponseToText(response); + return { + text: normalizeSnapshotOutput(rawText), + isError: response.isError === true, + }; + } finally { + if (prev === undefined) { + delete process.env.SNAPSHOT_TEST_REAL_EXECUTOR; + } else { + process.env.SNAPSHOT_TEST_REAL_EXECUTOR = prev; + } + } + } + + function cleanup(): void {} + + return { invoke, cleanup }; +} + +export async function ensureSimulatorBooted(simulatorName: string): Promise { + const listOutput = execSync('xcrun simctl list devices available --json', { + encoding: 'utf8', + }); + const data = JSON.parse(listOutput) as { + devices: Record>; + }; + + for (const runtime of Object.values(data.devices)) { + for (const device of runtime) { + if (device.name === simulatorName) { + if (device.state !== 'Booted') { + execSync(`xcrun simctl boot ${device.udid}`, { encoding: 'utf8' }); + } + return device.udid; + } + } + } + + throw new Error(`Simulator "${simulatorName}" not found`); +} diff --git a/src/snapshot-tests/normalize.ts b/src/snapshot-tests/normalize.ts new file mode 100644 index 00000000..f1366a10 --- /dev/null +++ b/src/snapshot-tests/normalize.ts @@ -0,0 +1,104 @@ +import os from 'node:os'; +import path from 'node:path'; + +const ANSI_REGEX = /\x1B\[[0-9;]*[mK]/g; +const ISO_TIMESTAMP_REGEX = /\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?Z/g; +const UUID_REGEX = /[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}/g; +const DURATION_REGEX = /\d+\.\d+s\b/g; +const PID_NUMBER_REGEX = /pid:\s*\d+/g; +const PID_JSON_REGEX = /"pid"\s*:\s*\d+/g; +const PROCESS_ID_REGEX = /Process ID: \d+/g; +const DERIVED_DATA_HASH_REGEX = /(DerivedData\/[A-Za-z0-9_]+)-[a-z]{28}\b/g; +const PROGRESS_LINE_REGEX = /^โ€บ.*\n?/gm; +const WARNINGS_BLOCK_REGEX = /Warnings \(\d+\):\n(?:\n? *โš [^\n]*\n?)*/g; +const TEST_DISCOVERY_REGEX = + /Resolved to \d+ test\(s\):\n(?:\s*-\s+[^\n]+\n)*(?:\s*\.\.\. and \d+ more\n)?/g; +const TEST_FAILURE_BLOCK_REGEX = /^ {2}โœ— [^\n]+\n(?: {4}[^\n]+\n)*/gm; +const XCODE_INFRA_ERRORS_REGEX = + /Compiler Errors \(\d+\):\n(?:\n? *โœ— (?:unable to rename temporary|failed to emit precompiled|accessing build database)[^\n]*\n?(?:\n? {4}[^\n]*\n?)*)*/g; +const SPM_STEP_LINE_REGEX = /^\[\d+\/\d+\] .+\n?/gm; +const SPM_PLANNING_LINE_REGEX = /^Building for (?:debugging|release)\.\.\.\n?/gm; +const LOCAL_TIMESTAMP_REGEX = /\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d{3}/g; +const XCTEST_PARENS_DURATION_REGEX = /\(\d+\.\d+\) seconds/g; +const SWIFT_TESTING_DURATION_REGEX = /after \d+\.\d+ seconds/g; +const TEST_SUMMARY_COUNTS_REGEX = + /\(Total: \d+(?:, Passed: \d+)?(?:, Failed: \d+)?(?:, Skipped: \d+)?, /g; +const COVERAGE_CALL_COUNT_REGEX = /called \d+x\)/g; +const TRAILING_WHITESPACE_REGEX = /[ \t]+$/gm; + +function sortLinesInBlock(text: string, marker: RegExp): string { + const lines = text.split('\n'); + const blocks: { start: number; end: number }[] = []; + let blockStart = -1; + for (let i = 0; i < lines.length; i++) { + if (marker.test(lines[i]!)) { + if (blockStart === -1) blockStart = i; + } else if (blockStart !== -1) { + blocks.push({ start: blockStart, end: i }); + blockStart = -1; + } + } + if (blockStart !== -1) blocks.push({ start: blockStart, end: lines.length }); + for (const block of blocks) { + const slice = lines.slice(block.start, block.end); + slice.sort(); + lines.splice(block.start, block.end - block.start, ...slice); + } + return lines.join('\n'); +} + +function escapeRegex(str: string): string { + return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +export function normalizeSnapshotOutput(text: string): string { + let normalized = text; + + normalized = normalized.replace(ANSI_REGEX, ''); + + const projectRoot = path.resolve(process.cwd()); + normalized = normalized.replace(new RegExp(escapeRegex(projectRoot), 'g'), ''); + + const home = os.homedir(); + normalized = normalized.replace(new RegExp(escapeRegex(home), 'g'), ''); + + const tmpDir = os.tmpdir(); + normalized = normalized.replace( + new RegExp(escapeRegex(tmpDir) + '/[A-Za-z0-9._-]+/', 'g'), + '/', + ); + + normalized = normalized.replace(DERIVED_DATA_HASH_REGEX, '$1-'); + normalized = normalized.replace(ISO_TIMESTAMP_REGEX, ''); + normalized = normalized.replace(UUID_REGEX, ''); + normalized = normalized.replace(DURATION_REGEX, ''); + normalized = normalized.replace(PID_NUMBER_REGEX, 'pid: '); + normalized = normalized.replace(PID_JSON_REGEX, '"pid" : '); + normalized = normalized.replace(PROCESS_ID_REGEX, 'Process ID: '); + normalized = normalized.replace(PROGRESS_LINE_REGEX, ''); + normalized = normalized.replace(WARNINGS_BLOCK_REGEX, ''); + normalized = normalized.replace(TEST_DISCOVERY_REGEX, 'Resolved to test(s)\n'); + normalized = normalized.replace(XCODE_INFRA_ERRORS_REGEX, ''); + normalized = normalized.replace(TEST_FAILURE_BLOCK_REGEX, ''); + + normalized = normalized.replace(SPM_STEP_LINE_REGEX, ''); + normalized = normalized.replace(SPM_PLANNING_LINE_REGEX, ''); + normalized = normalized.replace(LOCAL_TIMESTAMP_REGEX, ''); + normalized = normalized.replace(XCTEST_PARENS_DURATION_REGEX, '() seconds'); + normalized = normalized.replace(SWIFT_TESTING_DURATION_REGEX, 'after seconds'); + normalized = normalized.replace(TEST_SUMMARY_COUNTS_REGEX, '(, '); + + normalized = normalized.replace(COVERAGE_CALL_COUNT_REGEX, 'called x)'); + + normalized = normalized.replace(/"(?:x|y|width|height)"\s*:\s*(\d+\.\d{2,})/g, (match, num) => { + return match.replace(num, parseFloat(num).toFixed(1)); + }); + + normalized = sortLinesInBlock(normalized, /^[โ—‡โœ”โœ˜] Test "/); + + normalized = normalized.replace(/\n{3,}/g, '\n\n'); + normalized = normalized.replace(TRAILING_WHITESPACE_REGEX, ''); + normalized = normalized.replace(/\n*$/, '\n'); + + return normalized; +} diff --git a/src/types/common.ts b/src/types/common.ts index 9f83901f..a3210295 100644 --- a/src/types/common.ts +++ b/src/types/common.ts @@ -104,8 +104,7 @@ export function createImageContent( */ export interface ValidationResult { isValid: boolean; - errorResponse?: ToolResponse; - warningResponse?: ToolResponse; + errorMessage?: string; } /** diff --git a/src/types/xcodebuild-events.ts b/src/types/pipeline-events.ts similarity index 60% rename from src/types/xcodebuild-events.ts rename to src/types/pipeline-events.ts index 34fd060f..744abccd 100644 --- a/src/types/xcodebuild-events.ts +++ b/src/types/pipeline-events.ts @@ -23,74 +23,86 @@ interface BaseEvent { timestamp: string; } -export interface StartEvent extends BaseEvent { - type: 'start'; - operation: XcodebuildOperation; - toolName: string; - params: Record; - message: string; +// --- Canonical types (used by ALL tools) --- + +export interface HeaderEvent extends BaseEvent { + type: 'header'; + operation: string; + params: Array<{ label: string; value: string }>; } -export interface StatusEvent extends BaseEvent { - type: 'status'; - operation: XcodebuildOperation; - stage: XcodebuildStage; +export interface StatusLineEvent extends BaseEvent { + type: 'status-line'; + level: 'success' | 'error' | 'info' | 'warning'; message: string; } -export type NoticeLevel = 'info' | 'success' | 'warning'; +export interface SummaryEvent extends BaseEvent { + type: 'summary'; + operation?: string; + status: 'SUCCEEDED' | 'FAILED'; + totalTests?: number; + passedTests?: number; + failedTests?: number; + skippedTests?: number; + durationMs?: number; +} -export type BuildRunStepName = - | 'resolve-app-path' - | 'resolve-simulator' - | 'boot-simulator' - | 'install-app' - | 'extract-bundle-id' - | 'launch-app'; +export interface SectionEvent extends BaseEvent { + type: 'section'; + title: string; + icon?: 'red-circle' | 'yellow-circle' | 'green-circle' | 'checkmark' | 'cross' | 'info'; + lines: string[]; +} -export type BuildRunStepStatus = 'started' | 'succeeded'; +export interface DetailTreeEvent extends BaseEvent { + type: 'detail-tree'; + items: Array<{ label: string; value: string }>; +} -export interface BuildRunStepNoticeData { - step: BuildRunStepName; - status: BuildRunStepStatus; - appPath?: string; +export interface TableEvent extends BaseEvent { + type: 'table'; + heading?: string; + columns: string[]; + rows: Array>; } -export interface BuildRunResultNoticeData { - scheme: string; - platform: string; - target: string; - appPath: string; - launchState: 'requested' | 'running'; - bundleId?: string; - appId?: string; - processId?: number; +export interface FileRefEvent extends BaseEvent { + type: 'file-ref'; + label?: string; + path: string; } -export type NoticeCode = 'build-run-step' | 'build-run-result'; +export interface NextStepsEvent extends BaseEvent { + type: 'next-steps'; + steps: Array<{ + label?: string; + tool?: string; + workflow?: string; + cliTool?: string; + params?: Record; + }>; +} + +// --- Xcodebuild-specific types --- -export interface NoticeEvent extends BaseEvent { - type: 'notice'; +export interface BuildStageEvent extends BaseEvent { + type: 'build-stage'; operation: XcodebuildOperation; - level: NoticeLevel; + stage: XcodebuildStage; message: string; - code?: NoticeCode; - data?: - | Record - | BuildRunStepNoticeData - | BuildRunResultNoticeData; } -export interface WarningEvent extends BaseEvent { - type: 'warning'; +export interface CompilerWarningEvent extends BaseEvent { + type: 'compiler-warning'; operation: XcodebuildOperation; message: string; location?: string; rawLine: string; } -export interface ErrorEvent extends BaseEvent { - type: 'error'; +export interface CompilerErrorEvent extends BaseEvent { + type: 'compiler-error'; operation: XcodebuildOperation; message: string; location?: string; @@ -124,36 +136,57 @@ export interface TestFailureEvent extends BaseEvent { durationMs?: number; } -export interface SummaryEvent extends BaseEvent { - type: 'summary'; - operation: XcodebuildOperation; - status: 'SUCCEEDED' | 'FAILED'; - totalTests?: number; - passedTests?: number; - failedTests?: number; - skippedTests?: number; - durationMs?: number; +// --- Union type --- + +export type PipelineEvent = + | HeaderEvent + | StatusLineEvent + | SummaryEvent + | SectionEvent + | DetailTreeEvent + | TableEvent + | FileRefEvent + | NextStepsEvent + | BuildStageEvent + | CompilerWarningEvent + | CompilerErrorEvent + | TestDiscoveryEvent + | TestProgressEvent + | TestFailureEvent; + +// --- Backward compatibility alias --- + +export type XcodebuildEvent = PipelineEvent; + +// --- Build-run notice types (used by xcodebuild pipeline internals) --- + +export type NoticeLevel = 'info' | 'success' | 'warning'; + +export type BuildRunStepName = + | 'resolve-app-path' + | 'resolve-simulator' + | 'boot-simulator' + | 'install-app' + | 'extract-bundle-id' + | 'launch-app'; + +export type BuildRunStepStatus = 'started' | 'succeeded'; + +export interface BuildRunStepNoticeData { + step: BuildRunStepName; + status: BuildRunStepStatus; + appPath?: string; } -export interface NextStepsEvent extends BaseEvent { - type: 'next-steps'; - steps: Array<{ - label?: string; - tool?: string; - workflow?: string; - cliTool?: string; - params?: Record; - }>; +export interface BuildRunResultNoticeData { + scheme: string; + platform: string; + target: string; + appPath: string; + launchState: 'requested' | 'running'; + bundleId?: string; + appId?: string; + processId?: number; } -export type XcodebuildEvent = - | StartEvent - | StatusEvent - | NoticeEvent - | WarningEvent - | ErrorEvent - | TestDiscoveryEvent - | TestProgressEvent - | TestFailureEvent - | SummaryEvent - | NextStepsEvent; +export type NoticeCode = 'build-run-step' | 'build-run-result'; diff --git a/src/utils/CommandExecutor.ts b/src/utils/CommandExecutor.ts index b618b88c..ba1df30b 100644 --- a/src/utils/CommandExecutor.ts +++ b/src/utils/CommandExecutor.ts @@ -10,9 +10,6 @@ export interface CommandExecOptions { onStderr?: (chunk: string) => void; } -/** - * Command executor function type for dependency injection - */ /** * NOTE: `detached` only changes when the promise resolves; it does not detach/unref * the OS process. Callers must still manage lifecycle and open streams. @@ -24,9 +21,6 @@ export type CommandExecutor = ( opts?: CommandExecOptions, detached?: boolean, ) => Promise; -/** - * Command execution response interface - */ export interface CommandResponse { success: boolean; diff --git a/src/utils/FileSystemExecutor.ts b/src/utils/FileSystemExecutor.ts index 4baf499c..0d3a732e 100644 --- a/src/utils/FileSystemExecutor.ts +++ b/src/utils/FileSystemExecutor.ts @@ -1,7 +1,3 @@ -/** - * File system executor interface for dependency injection - */ - import type { WriteStream } from 'fs'; // Runtime marker to prevent empty output in unbundled builds diff --git a/src/utils/__tests__/build-utils.test.ts b/src/utils/__tests__/build-utils.test.ts index ce77e0fa..fa885e97 100644 --- a/src/utils/__tests__/build-utils.test.ts +++ b/src/utils/__tests__/build-utils.test.ts @@ -42,7 +42,7 @@ describe('build-utils Sentry Classification', () => { 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[2].text).toContain('Test Build build failed for scheme TestScheme'); }); }); @@ -64,7 +64,7 @@ describe('build-utils Sentry Classification', () => { 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[2].text).toContain('Test Build build failed for scheme TestScheme'); }); it('should not trigger Sentry logging for exit code 66 (file not found)', async () => { @@ -83,7 +83,8 @@ describe('build-utils Sentry Classification', () => { ); expect(result.isError).toBe(true); - expect(result.content[0].text).toContain('โŒ [stderr] project.xcodeproj cannot be opened'); + const stderrItem = result.content.find((c) => c.text.includes('[stderr]')); + expect(stderrItem?.text).toContain('โŒ [stderr] project.xcodeproj cannot be opened'); }); it('should not trigger Sentry logging for exit code 70 (destination error)', async () => { @@ -102,7 +103,8 @@ describe('build-utils Sentry Classification', () => { ); expect(result.isError).toBe(true); - expect(result.content[0].text).toContain('โŒ [stderr] Unable to find a destination matching'); + const stderrItem = result.content.find((c) => c.text.includes('[stderr]')); + expect(stderrItem?.text).toContain('โŒ [stderr] Unable to find a destination matching'); }); it('should not trigger Sentry logging for exit code 1 (general build failure)', async () => { @@ -121,7 +123,8 @@ describe('build-utils Sentry Classification', () => { ); expect(result.isError).toBe(true); - expect(result.content[0].text).toContain('โŒ [stderr] Build failed with errors'); + const stderrItem = result.content.find((c) => c.text.includes('[stderr]')); + expect(stderrItem?.text).toContain('โŒ [stderr] Build failed with errors'); }); }); @@ -145,9 +148,8 @@ describe('build-utils Sentry Classification', () => { ); expect(result.isError).toBe(true); - expect(result.content[0].text).toContain( - 'Error during Test Build build: spawn xcodebuild ENOENT', - ); + const errorItem = result.content.find((c) => c.text.includes('Error during')); + expect(errorItem?.text).toContain('Error during Test Build build: spawn xcodebuild ENOENT'); }); it('should not trigger Sentry logging for EACCES spawn error', async () => { @@ -169,9 +171,8 @@ describe('build-utils Sentry Classification', () => { ); expect(result.isError).toBe(true); - expect(result.content[0].text).toContain( - 'Error during Test Build build: spawn xcodebuild EACCES', - ); + const errorItem = result.content.find((c) => c.text.includes('Error during')); + expect(errorItem?.text).toContain('Error during Test Build build: spawn xcodebuild EACCES'); }); it('should not trigger Sentry logging for EPERM spawn error', async () => { @@ -193,9 +194,8 @@ describe('build-utils Sentry Classification', () => { ); expect(result.isError).toBe(true); - expect(result.content[0].text).toContain( - 'Error during Test Build build: spawn xcodebuild EPERM', - ); + const errorItem = result.content.find((c) => c.text.includes('Error during')); + expect(errorItem?.text).toContain('Error during Test Build build: spawn xcodebuild EPERM'); }); it('should trigger Sentry logging for non-spawn exceptions', async () => { @@ -216,9 +216,8 @@ describe('build-utils Sentry Classification', () => { ); expect(result.isError).toBe(true); - expect(result.content[0].text).toContain( - 'Error during Test Build build: Unexpected internal error', - ); + const errorItem = result.content.find((c) => c.text.includes('Error during')); + expect(errorItem?.text).toContain('Error during Test Build build: Unexpected internal error'); }); }); @@ -262,7 +261,8 @@ describe('build-utils Sentry Classification', () => { ); expect(result.isError).toBe(true); - expect(result.content[0].text).toContain('โŒ [stderr] Some error without exit code'); + const stderrItem = result.content.find((c) => c.text.includes('[stderr]')); + expect(stderrItem?.text).toContain('โŒ [stderr] Some error without exit code'); }); }); 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-utils.test.ts b/src/utils/__tests__/simulator-utils.test.ts index b67f347e..da92b93c 100644 --- a/src/utils/__tests__/simulator-utils.test.ts +++ b/src/utils/__tests__/simulator-utils.test.ts @@ -120,7 +120,7 @@ describe('determineSimulatorUuid', () => { expect(result.uuid).toBeUndefined(); expect(result.error).toBeDefined(); - expect(result.error?.content[0].text).toContain('exists but is not available'); + expect(result.error).toContain('exists but is not available'); }); it('should error for non-existent simulator', async () => { @@ -133,7 +133,7 @@ describe('determineSimulatorUuid', () => { expect(result.uuid).toBeUndefined(); expect(result.error).toBeDefined(); - expect(result.error?.content[0].text).toContain('not found'); + expect(result.error).toContain('not found'); }); it('should handle simctl list failure', async () => { @@ -146,7 +146,7 @@ describe('determineSimulatorUuid', () => { expect(result.uuid).toBeUndefined(); expect(result.error).toBeDefined(); - expect(result.error?.content[0].text).toContain('Failed to list simulators'); + expect(result.error).toContain('Failed to list simulators'); }); it('should handle invalid JSON from simctl', async () => { @@ -159,7 +159,7 @@ describe('determineSimulatorUuid', () => { expect(result.uuid).toBeUndefined(); expect(result.error).toBeDefined(); - expect(result.error?.content[0].text).toContain('Failed to parse simulator list'); + expect(result.error).toContain('Failed to parse simulator list'); }); }); @@ -173,7 +173,7 @@ describe('determineSimulatorUuid', () => { expect(result.uuid).toBeUndefined(); expect(result.error).toBeDefined(); - expect(result.error?.content[0].text).toContain('No simulator identifier provided'); + expect(result.error).toContain('No simulator identifier provided'); }); }); }); diff --git a/src/utils/__tests__/typed-tool-factory-consolidation.test.ts b/src/utils/__tests__/typed-tool-factory-consolidation.test.ts deleted file mode 100644 index 4d8cb9f0..00000000 --- a/src/utils/__tests__/typed-tool-factory-consolidation.test.ts +++ /dev/null @@ -1,113 +0,0 @@ -/** - * Wiring tests: tool factory handlers must consolidate multi-block output under Claude Code. - * - * These tests mock the environment detector so that isRunningUnderClaudeCode() returns true, - * then verify the factory-produced handlers consolidate multi-block responses into a single - * text block. This is the centralised location for consolidation โ€” individual logic functions - * no longer call consolidateContentForClaudeCode themselves. - */ -import { describe, it, expect, vi } from 'vitest'; -import * as z from 'zod'; -import { createMockExecutor } from '../../test-utils/mock-executors.ts'; -import type { ToolResponse } from '../../types/common.ts'; -import type { CommandExecutor } from '../command.ts'; - -vi.mock('../environment.ts', async (importOriginal) => { - const actual = (await importOriginal()) as Record; - return { - ...actual, - getDefaultEnvironmentDetector: () => ({ - isRunningUnderClaudeCode: () => true, - }), - }; -}); - -const { createTypedTool, createSessionAwareTool } = await import('../typed-tool-factory.ts'); - -const testSchema = z.object({ - name: z.string(), -}); - -type TestParams = z.infer; - -function multiBlockResponse(): ToolResponse { - return { - content: [ - { type: 'text', text: 'Block 1' }, - { type: 'text', text: 'Block 2' }, - { type: 'text', text: 'Block 3' }, - ], - }; -} - -function singleBlockResponse(): ToolResponse { - return { - content: [{ type: 'text', text: 'Only block' }], - }; -} - -describe('createTypedTool โ€” Claude Code consolidation wiring', () => { - it('should consolidate multi-block response into a single text block', async () => { - const handler = createTypedTool( - testSchema, - async (_params: TestParams, _executor: CommandExecutor) => multiBlockResponse(), - () => createMockExecutor({ success: true, output: '' }), - ); - - const result = await handler({ name: 'test' }); - - expect(result.content).toHaveLength(1); - expect(result.content[0].type).toBe('text'); - const text = (result.content[0] as { type: 'text'; text: string }).text; - expect(text).toContain('Block 1'); - expect(text).toContain('Block 2'); - expect(text).toContain('Block 3'); - }); - - it('should leave single-block response unchanged', async () => { - const handler = createTypedTool( - testSchema, - async (_params: TestParams, _executor: CommandExecutor) => singleBlockResponse(), - () => createMockExecutor({ success: true, output: '' }), - ); - - const result = await handler({ name: 'test' }); - - expect(result.content).toHaveLength(1); - expect((result.content[0] as { type: 'text'; text: string }).text).toBe('Only block'); - }); -}); - -describe('createSessionAwareTool โ€” Claude Code consolidation wiring', () => { - it('should consolidate multi-block response into a single text block', async () => { - const handler = createSessionAwareTool({ - internalSchema: testSchema, - logicFunction: async (_params: TestParams, _executor: CommandExecutor) => - multiBlockResponse(), - getExecutor: () => createMockExecutor({ success: true, output: '' }), - }); - - const result = await handler({ name: 'test' }); - - expect(result.content).toHaveLength(1); - expect(result.content[0].type).toBe('text'); - const text = (result.content[0] as { type: 'text'; text: string }).text; - expect(text).toContain('Block 1'); - expect(text).toContain('Block 2'); - expect(text).toContain('Block 3'); - }); - - it('should leave single-block response unchanged', async () => { - const handler = createSessionAwareTool({ - internalSchema: testSchema, - logicFunction: async (_params: TestParams, _executor: CommandExecutor) => - singleBlockResponse(), - getExecutor: () => createMockExecutor({ success: true, output: '' }), - }); - - const result = await handler({ name: 'test' }); - - expect(result.content).toHaveLength(1); - expect((result.content[0] as { type: 'text'; text: string }).text).toBe('Only block'); - }); -}); diff --git a/src/utils/__tests__/xcodebuild-event-parser.test.ts b/src/utils/__tests__/xcodebuild-event-parser.test.ts index 7f1bddce..15bf2fa4 100644 --- a/src/utils/__tests__/xcodebuild-event-parser.test.ts +++ b/src/utils/__tests__/xcodebuild-event-parser.test.ts @@ -1,12 +1,12 @@ import { describe, expect, it } from 'vitest'; import { createXcodebuildEventParser } from '../xcodebuild-event-parser.ts'; -import type { XcodebuildEvent } from '../../types/xcodebuild-events.ts'; +import type { PipelineEvent } from '../../types/pipeline-events.ts'; function collectEvents( operation: 'BUILD' | 'TEST', lines: { source: 'stdout' | 'stderr'; text: string }[], -): XcodebuildEvent[] { - const events: XcodebuildEvent[] = []; +): PipelineEvent[] { + const events: PipelineEvent[] = []; const parser = createXcodebuildEventParser({ operation, onEvent: (event) => events.push(event), @@ -30,7 +30,7 @@ describe('xcodebuild-event-parser', () => { expect(events).toHaveLength(1); expect(events[0]).toMatchObject({ - type: 'status', + type: 'build-stage', operation: 'TEST', stage: 'RESOLVING_PACKAGES', message: 'Resolving packages', @@ -44,7 +44,7 @@ describe('xcodebuild-event-parser', () => { expect(events).toHaveLength(1); expect(events[0]).toMatchObject({ - type: 'status', + type: 'build-stage', operation: 'BUILD', stage: 'COMPILING', message: 'Compiling', @@ -58,7 +58,7 @@ describe('xcodebuild-event-parser', () => { expect(events).toHaveLength(1); expect(events[0]).toMatchObject({ - type: 'status', + type: 'build-stage', operation: 'BUILD', stage: 'LINKING', message: 'Linking', @@ -70,7 +70,7 @@ describe('xcodebuild-event-parser', () => { expect(events).toHaveLength(1); expect(events[0]).toMatchObject({ - type: 'status', + type: 'build-stage', stage: 'RUN_TESTS', }); }); @@ -129,10 +129,10 @@ describe('xcodebuild-event-parser', () => { }, ]); - const errors = events.filter((e) => e.type === 'error'); + const errors = events.filter((e) => e.type === 'compiler-error'); expect(errors).toHaveLength(1); expect(errors[0]).toMatchObject({ - type: 'error', + type: 'compiler-error', location: '/tmp/App.swift:8', message: "cannot convert value of type 'String' to specified type 'Int'", }); @@ -143,10 +143,10 @@ describe('xcodebuild-event-parser', () => { { source: 'stdout', text: 'error: emit-module command failed with exit code 1\n' }, ]); - const errors = events.filter((e) => e.type === 'error'); + const errors = events.filter((e) => e.type === 'compiler-error'); expect(errors).toHaveLength(1); expect(errors[0]).toMatchObject({ - type: 'error', + type: 'compiler-error', message: 'emit-module command failed with exit code 1', }); }); @@ -161,10 +161,10 @@ describe('xcodebuild-event-parser', () => { { source: 'stderr', text: '\n' }, ]); - const errors = events.filter((e) => e.type === 'error'); + const errors = events.filter((e) => e.type === 'compiler-error'); expect(errors).toHaveLength(1); expect(errors[0]).toMatchObject({ - type: 'error', + type: 'compiler-error', message: 'Unable to find a device matching the provided destination specifier:\n{ platform:iOS Simulator, name:iPhone 22, OS:latest }', }); @@ -175,10 +175,10 @@ describe('xcodebuild-event-parser', () => { { source: 'stdout', text: '/tmp/App.swift:10:5: warning: variable unused\n' }, ]); - const warnings = events.filter((e) => e.type === 'warning'); + const warnings = events.filter((e) => e.type === 'compiler-warning'); expect(warnings).toHaveLength(1); expect(warnings[0]).toMatchObject({ - type: 'warning', + type: 'compiler-warning', location: '/tmp/App.swift:10', message: 'variable unused', }); @@ -189,16 +189,16 @@ describe('xcodebuild-event-parser', () => { { source: 'stdout', text: 'ld: warning: directory not found for option\n' }, ]); - const warnings = events.filter((e) => e.type === 'warning'); + const warnings = events.filter((e) => e.type === 'compiler-warning'); expect(warnings).toHaveLength(1); expect(warnings[0]).toMatchObject({ - type: 'warning', + type: 'compiler-warning', message: 'directory not found for option', }); }); it('handles split chunks across buffer boundaries', () => { - const events: XcodebuildEvent[] = []; + const events: PipelineEvent[] = []; const parser = createXcodebuildEventParser({ operation: 'TEST', onEvent: (event) => events.push(event), @@ -209,7 +209,7 @@ describe('xcodebuild-event-parser', () => { parser.flush(); expect(events).toHaveLength(1); - expect(events[0]).toMatchObject({ type: 'status', stage: 'RESOLVING_PACKAGES' }); + expect(events[0]).toMatchObject({ type: 'build-stage', stage: 'RESOLVING_PACKAGES' }); }); it('processes full test lifecycle', () => { @@ -229,7 +229,7 @@ describe('xcodebuild-event-parser', () => { ]); const types = events.map((e) => e.type); - expect(types).toContain('status'); + expect(types).toContain('build-stage'); expect(types).toContain('test-progress'); expect(types).toContain('test-failure'); }); @@ -241,7 +241,7 @@ describe('xcodebuild-event-parser', () => { ]); // Test Suite 'All tests' started triggers RUN_TESTS status; 'passed' is noise - const statusEvents = events.filter((e) => e.type === 'status'); + const statusEvents = events.filter((e) => e.type === 'build-stage'); expect(statusEvents.length).toBeLessThanOrEqual(1); }); }); diff --git a/src/utils/__tests__/xcodebuild-output.test.ts b/src/utils/__tests__/xcodebuild-output.test.ts index aa0003f3..66d0c3ff 100644 --- a/src/utils/__tests__/xcodebuild-output.test.ts +++ b/src/utils/__tests__/xcodebuild-output.test.ts @@ -18,11 +18,11 @@ describe('xcodebuild-output', () => { operation: 'BUILD', toolName: 'build_run_macos', params: { scheme: 'MyApp' }, - message: '๐Ÿš€ Build & Run\n\n Scheme: MyApp\n\n', + message: '\u{1F680} Build & Run\n\n Scheme: MyApp\n\n', }); started.pipeline.emitEvent({ - type: 'error', + type: 'compiler-error', timestamp: '2026-03-20T12:00:00.500Z', operation: 'BUILD', message: 'unterminated string literal', @@ -47,7 +47,7 @@ describe('xcodebuild-output', () => { .join('\n'); expect(textContent).toContain('Compiler Errors (1):'); - expect(textContent).toContain(' โœ— unterminated string literal'); + expect(textContent).toContain(' \u2717 unterminated string literal'); expect(textContent).toContain(' /tmp/MyApp.swift:10:1'); expect(textContent).not.toContain('error: unterminated string literal'); expect(textContent).not.toContain('Legacy fallback error block'); @@ -58,7 +58,7 @@ describe('xcodebuild-output', () => { operation: 'BUILD', toolName: 'build_run_macos', params: { scheme: 'MyApp' }, - message: '๐Ÿš€ Build & Run\n\n Scheme: MyApp\n\n', + message: '\u{1F680} Build & Run\n\n Scheme: MyApp\n\n', }); const pending = createPendingXcodebuildResponse( @@ -86,11 +86,11 @@ describe('xcodebuild-output', () => { operation: 'BUILD', toolName: 'build_run_macos', params: { scheme: 'MyApp' }, - message: '๐Ÿš€ Build & Run\n\n Scheme: MyApp\n\n', + message: '\u{1F680} Build & Run\n\n Scheme: MyApp\n\n', }); started.pipeline.emitEvent({ - type: 'error', + type: 'compiler-error', timestamp: '2026-03-20T12:00:00.500Z', operation: 'BUILD', message: 'unterminated string literal', @@ -129,7 +129,7 @@ describe('xcodebuild-output', () => { operation: 'BUILD', toolName: 'build_run_macos', params: { scheme: 'MyApp' }, - message: '๐Ÿš€ Build & Run\n\n Scheme: MyApp', + message: '\u{1F680} Build & Run\n\n Scheme: MyApp', }); const pending = createPendingXcodebuildResponse( @@ -141,19 +141,15 @@ describe('xcodebuild-output', () => { { tailEvents: [ { - type: 'notice', + type: 'status-line', timestamp: '2026-03-20T12:00:01.000Z', - operation: 'BUILD', level: 'success', message: 'Build & Run complete', - code: 'build-run-result', - data: { - scheme: 'MyApp', - platform: 'macOS', - target: 'macOS', - appPath: '/tmp/build/MyApp.app', - launchState: 'requested', - }, + }, + { + type: 'detail-tree', + timestamp: '2026-03-20T12:00:01.000Z', + items: [{ label: 'App Path', value: '/tmp/build/MyApp.app' }], }, ], }, @@ -165,18 +161,18 @@ describe('xcodebuild-output', () => { ], }); - const events = (finalized._meta?.events ?? []) as Array<{ type: string; code?: string }>; - expect(events.slice(-3)).toEqual([ - expect.objectContaining({ type: 'summary' }), - expect.objectContaining({ type: 'notice', code: 'build-run-result' }), - expect.objectContaining({ type: 'next-steps' }), - ]); + const events = (finalized._meta?.events ?? []) as Array<{ type: string }>; + const lastThreeTypes = events.slice(-4).map((e) => e.type); + expect(lastThreeTypes).toContain('summary'); + expect(lastThreeTypes).toContain('status-line'); + expect(lastThreeTypes).toContain('detail-tree'); + expect(lastThreeTypes).toContain('next-steps'); const textContent = finalized.content .filter((item) => item.type === 'text') .map((item) => item.text); expect(textContent.at(-1)).toContain('Next steps:'); - expect(textContent.at(-2)).toContain('โœ… Build & Run complete'); + expect(textContent.some((t) => t.includes('\u{2705} Build & Run complete'))).toBe(true); }); }); diff --git a/src/utils/__tests__/xcodebuild-pipeline.test.ts b/src/utils/__tests__/xcodebuild-pipeline.test.ts index c54aa20b..067758bb 100644 --- a/src/utils/__tests__/xcodebuild-pipeline.test.ts +++ b/src/utils/__tests__/xcodebuild-pipeline.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; import { createXcodebuildPipeline } from '../xcodebuild-pipeline.ts'; -import { STAGE_RANK } from '../../types/xcodebuild-events.ts'; +import { STAGE_RANK } from '../../types/pipeline-events.ts'; describe('xcodebuild-pipeline', () => { const originalEnv = { ...process.env }; @@ -22,12 +22,10 @@ describe('xcodebuild-pipeline', () => { }); pipeline.emitEvent({ - type: 'start', + type: 'header', timestamp: '2025-01-01T00:00:00.000Z', - operation: 'TEST', - toolName: 'test_sim', - params: { scheme: 'MyApp' }, - message: 'Starting test run for MyApp', + operation: 'Test', + params: [{ label: 'Scheme', value: 'MyApp' }], }); pipeline.onStdout('Resolve Package Graph\n'); @@ -48,14 +46,14 @@ describe('xcodebuild-pipeline', () => { const texts = result.mcpContent .filter((c) => c.type === 'text') .map((c) => (c as { text: string }).text); - expect(texts).toContain('\nStarting test run for MyApp'); + expect(texts.some((t) => t.includes('Test'))).toBe(true); expect(texts.some((t) => t.includes('Resolving packages'))).toBe(true); // Events array should contain all events expect(result.events.length).toBeGreaterThan(0); const eventTypes = result.events.map((e) => e.type); - expect(eventTypes).toContain('start'); - expect(eventTypes).toContain('status'); + expect(eventTypes).toContain('header'); + expect(eventTypes).toContain('build-stage'); expect(eventTypes).toContain('test-progress'); expect(eventTypes).toContain('summary'); }); diff --git a/src/utils/__tests__/xcodebuild-run-state.test.ts b/src/utils/__tests__/xcodebuild-run-state.test.ts index a5dcd3f3..a5df298f 100644 --- a/src/utils/__tests__/xcodebuild-run-state.test.ts +++ b/src/utils/__tests__/xcodebuild-run-state.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest'; import { createXcodebuildRunState } from '../xcodebuild-run-state.ts'; -import type { XcodebuildEvent } from '../../types/xcodebuild-events.ts'; -import { STAGE_RANK } from '../../types/xcodebuild-events.ts'; +import type { PipelineEvent } from '../../types/pipeline-events.ts'; +import { STAGE_RANK } from '../../types/pipeline-events.ts'; function ts(): string { return '2025-01-01T00:00:00.000Z'; @@ -9,28 +9,28 @@ function ts(): string { describe('xcodebuild-run-state', () => { it('accepts status events and tracks milestones in order', () => { - const forwarded: XcodebuildEvent[] = []; + const forwarded: PipelineEvent[] = []; const state = createXcodebuildRunState({ operation: 'TEST', onEvent: (e) => forwarded.push(e), }); state.push({ - type: 'status', + type: 'build-stage', timestamp: ts(), operation: 'TEST', stage: 'RESOLVING_PACKAGES', message: 'Resolving packages', }); state.push({ - type: 'status', + type: 'build-stage', timestamp: ts(), operation: 'TEST', stage: 'COMPILING', message: 'Compiling', }); state.push({ - type: 'status', + type: 'build-stage', timestamp: ts(), operation: 'TEST', stage: 'RUN_TESTS', @@ -52,14 +52,14 @@ describe('xcodebuild-run-state', () => { const state = createXcodebuildRunState({ operation: 'BUILD' }); state.push({ - type: 'status', + type: 'build-stage', timestamp: ts(), operation: 'BUILD', stage: 'RESOLVING_PACKAGES', message: 'Resolving packages', }); state.push({ - type: 'status', + type: 'build-stage', timestamp: ts(), operation: 'BUILD', stage: 'COMPILING', @@ -67,14 +67,14 @@ describe('xcodebuild-run-state', () => { }); // Duplicate: should be ignored state.push({ - type: 'status', + type: 'build-stage', timestamp: ts(), operation: 'BUILD', stage: 'RESOLVING_PACKAGES', message: 'Resolving packages', }); state.push({ - type: 'status', + type: 'build-stage', timestamp: ts(), operation: 'BUILD', stage: 'COMPILING', @@ -93,14 +93,14 @@ describe('xcodebuild-run-state', () => { // These should be suppressed because they're at or below COMPILING rank state.push({ - type: 'status', + type: 'build-stage', timestamp: ts(), operation: 'TEST', stage: 'RESOLVING_PACKAGES', message: 'Resolving packages', }); state.push({ - type: 'status', + type: 'build-stage', timestamp: ts(), operation: 'TEST', stage: 'COMPILING', @@ -108,7 +108,7 @@ describe('xcodebuild-run-state', () => { }); // This should be accepted state.push({ - type: 'status', + type: 'build-stage', timestamp: ts(), operation: 'TEST', stage: 'RUN_TESTS', @@ -123,8 +123,8 @@ describe('xcodebuild-run-state', () => { it('deduplicates error diagnostics by location+message', () => { const state = createXcodebuildRunState({ operation: 'BUILD' }); - const error: XcodebuildEvent = { - type: 'error', + const error: PipelineEvent = { + type: 'compiler-error', timestamp: ts(), operation: 'BUILD', message: 'type mismatch', @@ -142,7 +142,7 @@ describe('xcodebuild-run-state', () => { it('deduplicates test failures by location+message', () => { const state = createXcodebuildRunState({ operation: 'TEST' }); - const failure: XcodebuildEvent = { + const failure: PipelineEvent = { type: 'test-failure', timestamp: ts(), operation: 'TEST', @@ -162,8 +162,8 @@ describe('xcodebuild-run-state', () => { it('deduplicates warnings by location+message', () => { const state = createXcodebuildRunState({ operation: 'BUILD' }); - const warning: XcodebuildEvent = { - type: 'warning', + const warning: PipelineEvent = { + type: 'compiler-warning', timestamp: ts(), operation: 'BUILD', message: 'unused variable', @@ -213,7 +213,7 @@ describe('xcodebuild-run-state', () => { }); it('auto-inserts RUN_TESTS milestone on first test-progress', () => { - const forwarded: XcodebuildEvent[] = []; + const forwarded: PipelineEvent[] = []; const state = createXcodebuildRunState({ operation: 'TEST', onEvent: (e) => forwarded.push(e), @@ -236,7 +236,7 @@ describe('xcodebuild-run-state', () => { }); it('finalize emits summary event and sets final status', () => { - const forwarded: XcodebuildEvent[] = []; + const forwarded: PipelineEvent[] = []; const state = createXcodebuildRunState({ operation: 'TEST', onEvent: (e) => forwarded.push(e), @@ -273,14 +273,14 @@ describe('xcodebuild-run-state', () => { const state = createXcodebuildRunState({ operation: 'TEST' }); state.push({ - type: 'status', + type: 'build-stage', timestamp: ts(), operation: 'TEST', stage: 'RESOLVING_PACKAGES', message: 'Resolving packages', }); state.push({ - type: 'status', + type: 'build-stage', timestamp: ts(), operation: 'TEST', stage: 'COMPILING', @@ -290,20 +290,18 @@ describe('xcodebuild-run-state', () => { expect(state.highestStageRank()).toBe(STAGE_RANK.COMPILING); }); - it('passes through start and next-steps events', () => { - const forwarded: XcodebuildEvent[] = []; + it('passes through header and next-steps events', () => { + const forwarded: PipelineEvent[] = []; const state = createXcodebuildRunState({ operation: 'TEST', onEvent: (e) => forwarded.push(e), }); state.push({ - type: 'start', + type: 'header', timestamp: ts(), - operation: 'TEST', - toolName: 'test_sim', - params: {}, - message: 'Starting test run', + operation: 'Test', + params: [], }); state.push({ type: 'next-steps', @@ -312,7 +310,7 @@ describe('xcodebuild-run-state', () => { }); expect(forwarded).toHaveLength(2); - expect(forwarded[0].type).toBe('start'); + expect(forwarded[0].type).toBe('header'); expect(forwarded[1].type).toBe('next-steps'); }); }); diff --git a/src/utils/axe-helpers.ts b/src/utils/axe-helpers.ts index 79a38756..3ae6e4ae 100644 --- a/src/utils/axe-helpers.ts +++ b/src/utils/axe-helpers.ts @@ -7,8 +7,6 @@ import { accessSync, constants, existsSync } from 'fs'; import { delimiter, join, resolve } from 'path'; -import { createTextResponse } from './validation.ts'; -import type { ToolResponse } from '../types/common.ts'; import type { CommandExecutor } from './execution/index.ts'; import { getDefaultCommandExecutor } from './execution/index.ts'; import { getConfig } from './config-store.ts'; @@ -122,10 +120,6 @@ export const AXE_NOT_AVAILABLE_MESSAGE = 'Install AXe (brew tap cameroncooke/axe && brew install axe) or set XCODEBUILDMCP_AXE_PATH.\n' + 'Ensure bundled artifacts are included or PATH is configured.'; -export function createAxeNotAvailableResponse(): ToolResponse { - return createTextResponse(AXE_NOT_AVAILABLE_MESSAGE, true); -} - /** * Compare two semver strings a and b. * Returns 1 if a > b, -1 if a < b, 0 if equal. diff --git a/src/utils/axe/index.ts b/src/utils/axe/index.ts index b53b8b66..ccdc2f8d 100644 --- a/src/utils/axe/index.ts +++ b/src/utils/axe/index.ts @@ -1,5 +1,5 @@ export { - createAxeNotAvailableResponse, + AXE_NOT_AVAILABLE_MESSAGE, getAxePath, getBundledAxeEnvironment, areAxeToolsAvailable, diff --git a/src/utils/build-preflight.ts b/src/utils/build-preflight.ts index 41500a14..34dd0772 100644 --- a/src/utils/build-preflight.ts +++ b/src/utils/build-preflight.ts @@ -8,7 +8,9 @@ export interface ToolPreflightParams { | 'Test' | 'List Schemes' | 'Show Build Settings' - | 'Get App Path'; + | 'Get App Path' + | 'Coverage Report' + | 'File Coverage'; scheme?: string; workspacePath?: string; projectPath?: string; @@ -18,9 +20,12 @@ export interface ToolPreflightParams { simulatorId?: string; deviceId?: string; arch?: string; + xcresultPath?: string; + file?: string; + targetFilter?: string; } -function displayPath(filePath: string): string { +export function displayPath(filePath: string): string { const cwd = process.cwd(); const relative = path.relative(cwd, filePath); if (relative.startsWith('..') || path.isAbsolute(relative)) { @@ -37,6 +42,8 @@ const OPERATION_EMOJI: Record = { '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 { @@ -74,6 +81,18 @@ export function formatToolPreflight(params: ToolPreflightParams): string { 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 5783b33a..f592fbcb 100644 --- a/src/utils/build-utils.ts +++ b/src/utils/build-utils.ts @@ -1,27 +1,9 @@ -/** - * 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 { toolResponse } from './tool-response.ts'; +import { header, statusLine } from './tool-event-builders.ts'; import { isXcodemakeEnabled, isXcodemakeAvailable, @@ -47,28 +29,20 @@ function getDefaultSwiftPackageCachePath(): string { return path.join(os.homedir(), 'Library', 'Caches', 'org.swift.swiftpm'); } -function grepWarningsAndErrors(text: string): { type: 'warning' | 'error'; content: string }[] { - 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 }[]; +type DiagnosticLine = { type: 'warning' | 'error'; content: string }; + +function grepWarningsAndErrors(text: string): DiagnosticLine[] { + return text.split('\n').flatMap((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 []; + }); } -/** - * 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 - */ export async function executeXcodeBuildCommand( params: SharedBuildParams, platformOptions: PlatformBuildOptions, @@ -93,7 +67,6 @@ export async function executeXcodeBuildCommand( log('info', `Starting ${platformOptions.logPrefix} ${buildAction} for scheme ${params.scheme}`); - // Check if xcodemake is enabled and available const isXcodemakeEnabledFlag = isXcodemakeEnabled(); let xcodemakeAvailableFlag = false; @@ -117,6 +90,12 @@ export async function executeXcodeBuildCommand( } } + const useXcodemake = + isXcodemakeEnabledFlag && + xcodemakeAvailableFlag && + buildAction === 'build' && + !preferXcodebuild; + try { const command = ['xcodebuild']; const workspacePath = params.workspacePath @@ -140,7 +119,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, @@ -164,10 +142,16 @@ export async function executeXcodeBuildCommand( platformOptions.useLatestOS, ); } else { - return createTextResponse( - `For ${platformOptions.platform} platform, either simulatorId or simulatorName must be provided`, - true, - ); + return toolResponse([ + header(`${platformOptions.logPrefix} ${buildAction}`, [ + { label: 'Scheme', value: params.scheme }, + { label: 'Platform', value: String(platformOptions.platform) }, + ]), + statusLine( + 'error', + `For ${platformOptions.platform} platform, either simulatorId or simulatorName must be provided`, + ), + ]); } } else if (platformOptions.platform === XcodePlatform.macOS) { destinationString = constructDestinationString( @@ -192,7 +176,13 @@ export async function executeXcodeBuildCommand( destinationString = `generic/platform=${platformName}`; } } else { - return createTextResponse(`Unsupported platform: ${platformOptions.platform}`, true); + return toolResponse([ + header(`${platformOptions.logPrefix} ${buildAction}`, [ + { label: 'Scheme', value: params.scheme }, + { label: 'Platform', value: String(platformOptions.platform) }, + ]), + statusLine('error', `Unsupported platform: ${platformOptions.platform}`), + ]); } command.push('-destination', destinationString); @@ -220,12 +210,7 @@ export async function executeXcodeBuildCommand( command.push(buildAction); let result; - if ( - isXcodemakeEnabledFlag && - xcodemakeAvailableFlag && - buildAction === 'build' && - !preferXcodebuild - ) { + if (useXcodemake) { const makefileExists = doesMakefileExist(projectDir); log('debug', 'Makefile exists: ' + makefileExists); @@ -251,7 +236,6 @@ export async function executeXcodeBuildCommand( } : {}; - // Pass projectDir as cwd to ensure CocoaPods relative paths resolve correctly result = await executor(command, platformOptions.logPrefix, false, { ...execOpts, cwd: projectDir, @@ -259,7 +243,6 @@ export async function executeXcodeBuildCommand( }); } - // When pipeline is active, skip warning/error grepping - the parser handles it let warningOrErrorLines: { type: 'warning' | 'error'; content: string }[] = []; if (!pipeline) { warningOrErrorLines = grepWarningsAndErrors(result.output); @@ -291,22 +274,23 @@ export async function executeXcodeBuildCommand( `${platformOptions.logPrefix} ${buildAction} failed: ${result.error}`, { sentry: isMcpError }, ); - const errorResponse = createTextResponse( - `โŒ ${platformOptions.logPrefix} ${buildAction} failed for scheme ${params.scheme}.`, - true, - ); + const errorResponse = toolResponse([ + header(`${platformOptions.logPrefix} ${buildAction}`, [ + { label: 'Scheme', value: params.scheme }, + { label: 'Platform', value: String(platformOptions.platform) }, + { label: 'Configuration', value: params.configuration }, + ]), + statusLine( + 'error', + `${platformOptions.logPrefix} ${buildAction} failed for scheme ${params.scheme}.`, + ), + ]); if (buildMessages.length > 0 && errorResponse.content) { errorResponse.content.unshift(...buildMessages); } - if ( - warningOrErrorLines.length === 0 && - isXcodemakeEnabledFlag && - xcodemakeAvailableFlag && - buildAction === 'build' && - !preferXcodebuild - ) { + if (warningOrErrorLines.length === 0 && useXcodemake) { errorResponse.content.push({ type: 'text', text: `๐Ÿ’ก Incremental build using xcodemake failed, suggest using preferXcodebuild option to try build again using slower xcodebuild command.`, @@ -320,12 +304,7 @@ export async function executeXcodeBuildCommand( let additionalInfo = ''; - if ( - isXcodemakeEnabledFlag && - xcodemakeAvailableFlag && - buildAction === 'build' && - !preferXcodebuild - ) { + if (useXcodemake) { additionalInfo += `xcodemake: Using faster incremental builds with xcodemake. Future builds will use the generated Makefile for improved performance. @@ -385,9 +364,15 @@ Future builds will use the generated Makefile for improved performance. sentry: !isSpawnError, }); - return createTextResponse( - `Error during ${platformOptions.logPrefix} ${buildAction}: ${errorMessage}`, - true, - ); + return toolResponse([ + header(`${platformOptions.logPrefix} ${buildAction}`, [ + { label: 'Scheme', value: params.scheme }, + { label: 'Platform', value: String(platformOptions.platform) }, + ]), + statusLine( + 'error', + `Error during ${platformOptions.logPrefix} ${buildAction}: ${errorMessage}`, + ), + ]); } } diff --git a/src/utils/command.ts b/src/utils/command.ts index d23edb04..a02a5cb2 100644 --- a/src/utils/command.ts +++ b/src/utils/command.ts @@ -1,35 +1,14 @@ -/** - * Command Utilities - Generic command execution utilities - * - * This utility module provides functions for executing shell commands. - * It serves as a foundation for other utility modules that need to execute commands. - * - * Responsibilities: - * - Executing shell commands with proper argument handling - * - Managing process spawning, output capture, and error handling - */ - import { spawn } from 'child_process'; import { createWriteStream, existsSync } from 'fs'; +import * as fsPromises from 'fs/promises'; import { tmpdir as osTmpdir } from 'os'; import { log } from './logger.ts'; import type { FileSystemExecutor } from './FileSystemExecutor.ts'; import type { CommandExecutor, CommandResponse, CommandExecOptions } from './CommandExecutor.ts'; -// Re-export types for backward compatibility export type { CommandExecutor, CommandResponse, CommandExecOptions } from './CommandExecutor.ts'; export type { FileSystemExecutor } from './FileSystemExecutor.ts'; -/** - * Default executor implementation using spawn (current production behavior) - * Private instance - use getDefaultCommandExecutor() for access - * @param command An array of command and arguments - * @param logPrefix Prefix for logging - * @param useShell Whether to use shell execution (true) or direct execution (false) - * @param opts Optional execution options (env: environment variables to merge with process.env, cwd: working directory) - * @param detached Whether to resolve without waiting for completion (does not detach/unref the process) - * @returns Promise resolving to command response with the process - */ async function defaultExecutor( command: string[], logPrefix?: string, @@ -37,15 +16,11 @@ async function defaultExecutor( opts?: CommandExecOptions, detached: boolean = false, ): Promise { - // Properly escape arguments for shell let escapedCommand = command; if (useShell) { - // For shell execution, we need to format as ['/bin/sh', '-c', 'full command string'] const commandString = command .map((arg) => { - // Shell metacharacters that require quoting: space, quotes, equals, dollar, backticks, semicolons, pipes, etc. if (/[\s,"'=$`;&|<>(){}[\]\\*?~]/.test(arg) && !/^".*"$/.test(arg)) { - // Escape all quotes and backslashes, then wrap in double quotes return `"${arg.replace(/(["\\])/g, '\\$1')}"`; } return arg; @@ -67,13 +42,12 @@ async function defaultExecutor( } } - // Log the actual command that will be executed const displayCommand = useShell && escapedCommand.length === 3 ? escapedCommand[2] : [executable, ...args].join(' '); log('debug', `Executing ${logPrefix ?? ''} command: ${displayCommand}`); const spawnOpts: Parameters[2] = { - stdio: ['ignore', 'pipe', 'pipe'], // ignore stdin, pipe stdout/stderr + stdio: ['ignore', 'pipe', 'pipe'], env: { ...process.env, ...(opts?.env ?? {}) }, cwd: opts?.cwd, }; @@ -110,9 +84,7 @@ async function defaultExecutor( opts?.onStderr?.(chunk); }); - // For detached processes, handle differently to avoid race conditions if (detached) { - // For detached processes, only wait for spawn success/failure let resolved = false; childProcess.on('error', (err) => { @@ -123,14 +95,13 @@ async function defaultExecutor( } }); - // Give a small delay to ensure the process starts successfully setTimeout(() => { if (!resolved) { resolved = true; if (childProcess.pid) { resolve({ success: true, - output: '', // No output for detached processes + output: '', process: childProcess, }); } else { @@ -144,7 +115,6 @@ async function defaultExecutor( } }, 100); } else { - // For non-detached processes, handle normally childProcess.on('close', (code) => { const success = code === 0; const response: CommandResponse = { @@ -166,25 +136,17 @@ async function defaultExecutor( }); } -/** - * Default file system executor implementation using Node.js fs/promises - * Private instance - use getDefaultFileSystemExecutor() for access - */ const defaultFileSystemExecutor: FileSystemExecutor = { async mkdir(path: string, options?: { recursive?: boolean }): Promise { - const fs = await import('fs/promises'); - await fs.mkdir(path, options); + await fsPromises.mkdir(path, options); }, async readFile(path: string, encoding: BufferEncoding = 'utf8'): Promise { - const fs = await import('fs/promises'); - const content = await fs.readFile(path, encoding); - return content; + return await fsPromises.readFile(path, encoding); }, async writeFile(path: string, content: string, encoding: BufferEncoding = 'utf8'): Promise { - const fs = await import('fs/promises'); - await fs.writeFile(path, content, encoding); + await fsPromises.writeFile(path, content, encoding); }, createWriteStream(path: string, options?: { flags?: string }) { @@ -192,18 +154,15 @@ const defaultFileSystemExecutor: FileSystemExecutor = { }, async cp(source: string, destination: string, options?: { recursive?: boolean }): Promise { - const fs = await import('fs/promises'); - await fs.cp(source, destination, options); + await fsPromises.cp(source, destination, options); }, async readdir(path: string, options?: { withFileTypes?: boolean }): Promise { - const fs = await import('fs/promises'); - return await fs.readdir(path, options as Record); + return await fsPromises.readdir(path, options as Record); }, async rm(path: string, options?: { recursive?: boolean; force?: boolean }): Promise { - const fs = await import('fs/promises'); - await fs.rm(path, options); + await fsPromises.rm(path, options); }, existsSync(path: string): boolean { @@ -211,13 +170,11 @@ const defaultFileSystemExecutor: FileSystemExecutor = { }, async stat(path: string): Promise<{ isDirectory(): boolean; mtimeMs: number }> { - const fs = await import('fs/promises'); - return await fs.stat(path); + return await fsPromises.stat(path); }, async mkdtemp(prefix: string): Promise { - const fs = await import('fs/promises'); - return await fs.mkdtemp(prefix); + return await fsPromises.mkdtemp(prefix); }, tmpdir(): string { @@ -241,12 +198,17 @@ export function __clearTestExecutorOverrides(): void { _testFileSystemExecutorOverride = null; } -/** - * Get default command executor with test safety - * Throws error if used in test environment to ensure proper mocking - */ +export function __getRealCommandExecutor(): CommandExecutor { + return defaultExecutor; +} + +export function __getRealFileSystemExecutor(): FileSystemExecutor { + return defaultFileSystemExecutor; +} + export function getDefaultCommandExecutor(): CommandExecutor { if (process.env.VITEST === 'true' || process.env.NODE_ENV === 'test') { + if (process.env.SNAPSHOT_TEST_REAL_EXECUTOR === '1') return defaultExecutor; if (_testCommandExecutorOverride) return _testCommandExecutorOverride; throw new Error( `๐Ÿšจ REAL SYSTEM EXECUTOR DETECTED IN TEST! ๐Ÿšจ\n` + @@ -259,12 +221,9 @@ export function getDefaultCommandExecutor(): CommandExecutor { return defaultExecutor; } -/** - * Get default file system executor with test safety - * Throws error if used in test environment to ensure proper mocking - */ export function getDefaultFileSystemExecutor(): FileSystemExecutor { if (process.env.VITEST === 'true' || process.env.NODE_ENV === 'test') { + if (process.env.SNAPSHOT_TEST_REAL_EXECUTOR === '1') return defaultFileSystemExecutor; if (_testFileSystemExecutorOverride) return _testFileSystemExecutorOverride; throw new Error( `๐Ÿšจ REAL FILESYSTEM EXECUTOR DETECTED IN TEST! ๐Ÿšจ\n` + diff --git a/src/utils/debugger/ui-automation-guard.ts b/src/utils/debugger/ui-automation-guard.ts index f6f469af..05434d4f 100644 --- a/src/utils/debugger/ui-automation-guard.ts +++ b/src/utils/debugger/ui-automation-guard.ts @@ -1,12 +1,10 @@ -import type { ToolResponse } from '../../types/common.ts'; -import { createErrorResponse } from '../responses/index.ts'; import { log } from '../logging/index.ts'; import { getUiDebuggerGuardMode } from '../environment.ts'; import type { DebugExecutionState } from './types.ts'; import type { DebuggerManager } from './debugger-manager.ts'; -type GuardResult = { - blockedResponse?: ToolResponse; +export type GuardResult = { + blockedMessage?: string; warningText?: string; }; @@ -51,10 +49,7 @@ export async function guardUiAutomationAgainstStoppedDebugger(opts: { } return { - blockedResponse: createErrorResponse( - 'UI automation blocked: app is paused in debugger', - details, - ), + blockedMessage: `UI automation blocked: app is paused in debugger\n${details}`, }; } diff --git a/src/utils/environment.ts b/src/utils/environment.ts index 8af6226e..b1986b63 100644 --- a/src/utils/environment.ts +++ b/src/utils/environment.ts @@ -1,29 +1,12 @@ -/** - * Environment Detection Utilities - * - * Provides abstraction for environment detection to enable testability - * while maintaining production functionality. - */ - import { execSync } from 'child_process'; import { log } from './logger.ts'; import { getConfig } from './config-store.ts'; import type { UiDebuggerGuardMode } from './runtime-config-types.ts'; -/** - * Interface for environment detection abstraction - */ export interface EnvironmentDetector { - /** - * Detects if the MCP server is running under Claude Code - * @returns true if Claude Code is detected, false otherwise - */ isRunningUnderClaudeCode(): boolean; } -/** - * Production implementation of environment detection - */ export class ProductionEnvironmentDetector implements EnvironmentDetector { private cachedResult: boolean | undefined; @@ -32,19 +15,16 @@ export class ProductionEnvironmentDetector implements EnvironmentDetector { return this.cachedResult; } - // Disable Claude Code detection during tests for environment-agnostic testing if (process.env.NODE_ENV === 'test' || process.env.VITEST === 'true') { this.cachedResult = false; return false; } - // Method 1: Check for Claude Code environment variables if (process.env.CLAUDECODE === '1' || process.env.CLAUDE_CODE_ENTRYPOINT === 'cli') { this.cachedResult = true; return true; } - // Method 2: Check parent process name try { const parentPid = process.ppid; if (parentPid) { @@ -58,7 +38,6 @@ export class ProductionEnvironmentDetector implements EnvironmentDetector { } } } catch (error) { - // If process detection fails, fall back to environment variables only log('debug', `Failed to detect parent process: ${error}`); } @@ -67,22 +46,12 @@ export class ProductionEnvironmentDetector implements EnvironmentDetector { } } -/** - * Default environment detector instance for production use - */ -export const defaultEnvironmentDetector = new ProductionEnvironmentDetector(); +const defaultEnvironmentDetector = new ProductionEnvironmentDetector(); -/** - * Gets the default environment detector for production use - */ export function getDefaultEnvironmentDetector(): EnvironmentDetector { return defaultEnvironmentDetector; } -/** - * Global opt-out for session defaults in MCP tool schemas. - * When enabled, tools re-expose all parameters instead of hiding session-managed fields. - */ export function isSessionDefaultsOptOutEnabled(): boolean { return getConfig().disableSessionDefaults; } @@ -91,38 +60,29 @@ export function getUiDebuggerGuardMode(): UiDebuggerGuardMode { return getConfig().uiDebuggerGuardMode; } -/** - * Normalizes a set of user-provided environment variables by ensuring they are - * prefixed with TEST_RUNNER_. Variables already prefixed are preserved. - * - * Example: - * normalizeTestRunnerEnv({ FOO: '1', TEST_RUNNER_BAR: '2' }) - * => { TEST_RUNNER_FOO: '1', TEST_RUNNER_BAR: '2' } - */ -export function normalizeTestRunnerEnv(vars: Record): Record { +function normalizeEnvWithPrefix( + prefix: string, + vars: Record, +): Record { const normalized: Record = {}; for (const [key, value] of Object.entries(vars ?? {})) { if (value == null) continue; - const prefixedKey = key.startsWith('TEST_RUNNER_') ? key : `TEST_RUNNER_${key}`; + const prefixedKey = key.startsWith(prefix) ? key : `${prefix}${key}`; normalized[prefixedKey] = value; } return normalized; } /** - * Normalizes a set of user-provided environment variables by ensuring they are - * prefixed with SIMCTL_CHILD_. Variables already prefixed are preserved. - * - * Example: - * normalizeSimctlChildEnv({ FOO: '1', SIMCTL_CHILD_BAR: '2' }) - * => { SIMCTL_CHILD_FOO: '1', SIMCTL_CHILD_BAR: '2' } + * Normalizes environment variables by ensuring they are prefixed with TEST_RUNNER_. + */ +export function normalizeTestRunnerEnv(vars: Record): Record { + return normalizeEnvWithPrefix('TEST_RUNNER_', vars); +} + +/** + * Normalizes environment variables by ensuring they are prefixed with SIMCTL_CHILD_. */ export function normalizeSimctlChildEnv(vars: Record): Record { - const normalized: Record = {}; - for (const [key, value] of Object.entries(vars ?? {})) { - if (value == null) continue; - const prefixedKey = key.startsWith('SIMCTL_CHILD_') ? key : `SIMCTL_CHILD_${key}`; - normalized[prefixedKey] = value; - } - return normalized; + return normalizeEnvWithPrefix('SIMCTL_CHILD_', vars); } diff --git a/src/utils/errors.ts b/src/utils/errors.ts index 70997b59..6e744650 100644 --- a/src/utils/errors.ts +++ b/src/utils/errors.ts @@ -1,45 +1,11 @@ -import type { ToolResponse } from '../types/common.ts'; - -/** - * Error Utilities - Type-safe error hierarchy for the application - * - * This utility module defines a structured error hierarchy for the application, - * providing specialized error types for different failure scenarios. Using these - * typed errors enables more precise error handling, improves debugging, and - * provides better error messages to users. - * - * Responsibilities: - * - Providing a base error class (XcodeBuildMCPError) for all application errors - * - Defining specialized error subtypes for different error categories: - * - ValidationError: Parameter validation failures - * - SystemError: Underlying system/OS issues - * - ConfigurationError: Application configuration problems - * - SimulatorError: iOS simulator-specific failures - * - AxeError: axe-specific errors - * - * The structured hierarchy allows error consumers to handle errors with the - * appropriate level of specificity using instanceof checks or catch clauses. - */ - -/** - * Custom error types for XcodeBuildMCP - */ - -/** - * Base error class for XcodeBuildMCP errors - */ export class XcodeBuildMCPError extends Error { constructor(message: string) { super(message); this.name = 'XcodeBuildMCPError'; - // This is necessary for proper inheritance in TypeScript Object.setPrototypeOf(this, XcodeBuildMCPError.prototype); } } -/** - * Error thrown when validation of parameters fails - */ export class ValidationError extends XcodeBuildMCPError { constructor( message: string, @@ -51,9 +17,6 @@ export class ValidationError extends XcodeBuildMCPError { } } -/** - * Error thrown for system-level errors (file access, permissions, etc.) - */ export class SystemError extends XcodeBuildMCPError { constructor( message: string, @@ -65,9 +28,6 @@ export class SystemError extends XcodeBuildMCPError { } } -/** - * Error thrown for configuration issues - */ export class ConfigurationError extends XcodeBuildMCPError { constructor(message: string) { super(message); @@ -76,9 +36,6 @@ export class ConfigurationError extends XcodeBuildMCPError { } } -/** - * Error thrown for simulator-specific errors - */ export class SimulatorError extends XcodeBuildMCPError { constructor( message: string, @@ -91,14 +48,11 @@ export class SimulatorError extends XcodeBuildMCPError { } } -/** - * Error thrown for axe-specific errors - */ export class AxeError extends XcodeBuildMCPError { constructor( message: string, - public command?: string, // The axe command that failed - public axeOutput?: string, // Output from axe + public command?: string, + public axeOutput?: string, public simulatorId?: string, ) { super(message); @@ -107,23 +61,6 @@ export class AxeError extends XcodeBuildMCPError { } } -// Helper to create a standard error response -export function createErrorResponse(message: string, details?: string): ToolResponse { - const detailText = details ? `\nDetails: ${details}` : ''; - return { - content: [ - { - type: 'text', - text: `Error: ${message}${detailText}`, - }, - ], - isError: true, - }; -} - -/** - * Error class for missing dependencies - */ export class DependencyError extends ConfigurationError { constructor( message: string, diff --git a/src/utils/renderers/__tests__/cli-text-renderer.test.ts b/src/utils/renderers/__tests__/cli-text-renderer.test.ts index 5bf824c9..47a353d2 100644 --- a/src/utils/renderers/__tests__/cli-text-renderer.test.ts +++ b/src/utils/renderers/__tests__/cli-text-renderer.test.ts @@ -39,24 +39,19 @@ describe('cli-text-renderer', () => { const renderer = createCliTextRenderer({ interactive: false }); renderer.onEvent({ - type: 'start', + type: 'header', timestamp: '2026-03-20T12:00:00.000Z', - operation: 'BUILD', - toolName: 'build_run_macos', - params: {}, - message: [ - '๐Ÿš€ Build & Run', - '', - ' Scheme: MyApp', - ' Project: /tmp/MyApp.xcodeproj', - ' Configuration: Debug', - ' Platform: macOS', - '', - ].join('\n'), + operation: 'Build & Run', + params: [ + { label: 'Scheme', value: 'MyApp' }, + { label: 'Project', value: '/tmp/MyApp.xcodeproj' }, + { label: 'Configuration', value: 'Debug' }, + { label: 'Platform', value: 'macOS' }, + ], }); renderer.onEvent({ - type: 'status', + type: 'build-stage', timestamp: '2026-03-20T12:00:01.000Z', operation: 'BUILD', stage: 'COMPILING', @@ -64,7 +59,7 @@ describe('cli-text-renderer', () => { }); const output = stdoutWrite.mock.calls.flat().join(''); - expect(output).toContain(' Platform: macOS\n\nโ€บ Compiling\n'); + expect(output).toContain(' Platform: macOS\n\n\u203A Compiling\n'); }); it('uses transient interactive updates for active phases and durable writes for lasting events', () => { @@ -72,16 +67,14 @@ describe('cli-text-renderer', () => { const renderer = createCliTextRenderer({ interactive: true }); renderer.onEvent({ - type: 'start', + type: 'header', timestamp: '2026-03-20T12:00:00.000Z', - operation: 'BUILD', - toolName: 'build_run_macos', - params: {}, - message: '๐Ÿš€ Build & Run\n\n Scheme: MyApp\n\n', + operation: 'Build & Run', + params: [{ label: 'Scheme', value: 'MyApp' }], }); renderer.onEvent({ - type: 'status', + type: 'build-stage', timestamp: '2026-03-20T12:00:01.000Z', operation: 'BUILD', stage: 'COMPILING', @@ -89,17 +82,14 @@ describe('cli-text-renderer', () => { }); renderer.onEvent({ - type: 'notice', + type: 'status-line', timestamp: '2026-03-20T12:00:02.000Z', - operation: 'BUILD', level: 'info', message: 'Resolving app path', - code: 'build-run-step', - data: { step: 'resolve-app-path', status: 'started' }, }); renderer.onEvent({ - type: 'warning', + type: 'compiler-warning', timestamp: '2026-03-20T12:00:03.000Z', operation: 'BUILD', message: 'unused variable', @@ -107,13 +97,10 @@ describe('cli-text-renderer', () => { }); renderer.onEvent({ - type: 'notice', + type: 'status-line', timestamp: '2026-03-20T12:00:04.000Z', - operation: 'BUILD', level: 'success', - message: 'App path resolved', - code: 'build-run-step', - data: { step: 'resolve-app-path', status: 'succeeded', appPath: '/tmp/build/MyApp.app' }, + message: 'Resolving app path', }); renderer.onEvent({ @@ -127,11 +114,10 @@ describe('cli-text-renderer', () => { expect(reporter.update).toHaveBeenCalledWith('Resolving app path...'); const output = stdoutWrite.mock.calls.flat().join(''); - expect(output).not.toContain('โ€บ Compiling\n'); - expect(output).not.toContain('โ€บ Resolving app path\n'); + expect(output).not.toContain('\u203A Compiling\n'); expect(output).toContain('Warnings (1):'); expect(output).toContain('unused variable'); - expect(output).toContain('โœ“ Resolving app path\n'); + expect(output).toContain('\u{2705} Resolving app path\n'); }); it('renders grouped sad-path diagnostics before the failed summary', () => { @@ -139,25 +125,20 @@ describe('cli-text-renderer', () => { const renderer = createCliTextRenderer({ interactive: false }); renderer.onEvent({ - type: 'start', + type: 'header', timestamp: '2026-03-20T12:00:00.000Z', - operation: 'BUILD', - toolName: 'build_run_sim', - params: {}, - message: [ - '๐Ÿš€ Build & Run', - '', - ' Scheme: MyApp', - ' Project: /tmp/MyApp.xcodeproj', - ' Configuration: Debug', - ' Platform: iOS Simulator', - ' Simulator: INVALID-SIM-ID-123', - '', - ].join('\n'), + operation: 'Build & Run', + params: [ + { label: 'Scheme', value: 'MyApp' }, + { label: 'Project', value: '/tmp/MyApp.xcodeproj' }, + { label: 'Configuration', value: 'Debug' }, + { label: 'Platform', value: 'iOS Simulator' }, + { label: 'Simulator', value: 'INVALID-SIM-ID-123' }, + ], }); renderer.onEvent({ - type: 'error', + type: 'compiler-error', timestamp: '2026-03-20T12:00:01.000Z', operation: 'BUILD', message: 'No available simulator matched: INVALID-SIM-ID-123', @@ -174,8 +155,8 @@ describe('cli-text-renderer', () => { const output = stdoutWrite.mock.calls.flat().join(''); expect(output).toContain('Errors (1):'); - expect(output).toContain(' โœ— No available simulator matched: INVALID-SIM-ID-123'); - expect(output).toContain('โŒ Build failed. (โฑ๏ธ 1.2s)'); + expect(output).toContain(' \u2717 No available simulator matched: INVALID-SIM-ID-123'); + expect(output).toContain('\u{274C} Build failed. (\u{23F1}\u{FE0F} 1.2s)'); }); it('groups compiler diagnostics under a nested failure header before the failed summary', () => { @@ -183,24 +164,19 @@ describe('cli-text-renderer', () => { const renderer = createCliTextRenderer({ interactive: false }); renderer.onEvent({ - type: 'start', + type: 'header', timestamp: '2026-03-20T12:00:00.000Z', - operation: 'BUILD', - toolName: 'build_run_macos', - params: {}, - message: [ - '๐Ÿš€ Build & Run', - '', - ' Scheme: MyApp', - ' Project: /tmp/MyApp.xcodeproj', - ' Configuration: Debug', - ' Platform: macOS', - '', - ].join('\n'), + operation: 'Build & Run', + params: [ + { label: 'Scheme', value: 'MyApp' }, + { label: 'Project', value: '/tmp/MyApp.xcodeproj' }, + { label: 'Configuration', value: 'Debug' }, + { label: 'Platform', value: 'macOS' }, + ], }); renderer.onEvent({ - type: 'status', + type: 'build-stage', timestamp: '2026-03-20T12:00:01.000Z', operation: 'BUILD', stage: 'COMPILING', @@ -208,7 +184,7 @@ describe('cli-text-renderer', () => { }); renderer.onEvent({ - type: 'error', + type: 'compiler-error', timestamp: '2026-03-20T12:00:02.000Z', operation: 'BUILD', message: 'unterminated string literal', @@ -225,10 +201,10 @@ describe('cli-text-renderer', () => { const output = stdoutWrite.mock.calls.flat().join(''); expect(output).toContain( - 'โ€บ Compiling\n\nCompiler Errors (1):\n\n โœ— unterminated string literal\n /tmp/MCPTest/ContentView.swift:16:18', + '\u203A Compiling\n\nCompiler Errors (1):\n\n \u2717 unterminated string literal\n /tmp/MCPTest/ContentView.swift:16:18', ); expect(output).not.toContain('error: unterminated string literal\n ContentView.swift:16:18'); - expect(output).toContain('\n\nโŒ Build failed. (โฑ๏ธ 4.0s)'); + expect(output).toContain('\n\n\u{274C} Build failed. (\u{23F1}\u{FE0F} 4.0s)'); }); it('uses exactly one blank-line boundary between front matter and compiler errors when no runtime line rendered', () => { @@ -236,24 +212,19 @@ describe('cli-text-renderer', () => { const renderer = createCliTextRenderer({ interactive: false }); renderer.onEvent({ - type: 'start', + type: 'header', timestamp: '2026-03-20T12:00:00.000Z', - operation: 'BUILD', - toolName: 'build_run_macos', - params: {}, - message: [ - '๐Ÿš€ Build & Run', - '', - ' Scheme: MyApp', - ' Project: /tmp/MyApp.xcodeproj', - ' Configuration: Debug', - ' Platform: macOS', - '', - ].join('\n'), + operation: 'Build & Run', + params: [ + { label: 'Scheme', value: 'MyApp' }, + { label: 'Project', value: '/tmp/MyApp.xcodeproj' }, + { label: 'Configuration', value: 'Debug' }, + { label: 'Platform', value: 'macOS' }, + ], }); renderer.onEvent({ - type: 'error', + type: 'compiler-error', timestamp: '2026-03-20T12:00:01.000Z', operation: 'BUILD', message: 'unterminated string literal', @@ -270,7 +241,7 @@ describe('cli-text-renderer', () => { const output = stdoutWrite.mock.calls.flat().join(''); expect(output).toContain( - ' Platform: macOS\n\nCompiler Errors (1):\n\n โœ— unterminated string literal\n /tmp/MCPTest/ContentView.swift:16:18', + ' Platform: macOS\n\nCompiler Errors (1):\n\n \u2717 unterminated string literal\n /tmp/MCPTest/ContentView.swift:16:18', ); expect(output).not.toContain(' Platform: macOS\n\n\nCompiler Errors (1):'); }); @@ -280,16 +251,14 @@ describe('cli-text-renderer', () => { const renderer = createCliTextRenderer({ interactive: true }); renderer.onEvent({ - type: 'start', + type: 'header', timestamp: '2026-03-20T12:00:00.000Z', - operation: 'BUILD', - toolName: 'build_run_macos', - params: {}, - message: '๐Ÿš€ Build & Run\n\n Scheme: MyApp\n\n', + operation: 'Build & Run', + params: [{ label: 'Scheme', value: 'MyApp' }], }); renderer.onEvent({ - type: 'status', + type: 'build-stage', timestamp: '2026-03-20T12:00:01.000Z', operation: 'BUILD', stage: 'COMPILING', @@ -297,7 +266,7 @@ describe('cli-text-renderer', () => { }); renderer.onEvent({ - type: 'status', + type: 'build-stage', timestamp: '2026-03-20T12:00:02.000Z', operation: 'BUILD', stage: 'LINKING', @@ -305,7 +274,7 @@ describe('cli-text-renderer', () => { }); renderer.onEvent({ - type: 'error', + type: 'compiler-error', timestamp: '2026-03-20T12:00:03.000Z', operation: 'BUILD', message: 'unterminated string literal', @@ -325,7 +294,7 @@ describe('cli-text-renderer', () => { const output = stdoutWrite.mock.calls.flat().join(''); expect(output).toContain( - 'โ€บ Linking\n\nCompiler Errors (1):\n\n โœ— unterminated string literal\n /tmp/MCPTest/ContentView.swift:16:18', + '\u203A Linking\n\nCompiler Errors (1):\n\n \u2717 unterminated string literal\n /tmp/MCPTest/ContentView.swift:16:18', ); }); @@ -342,19 +311,16 @@ describe('cli-text-renderer', () => { }); renderer.onEvent({ - type: 'notice', + type: 'status-line', timestamp: '2026-03-20T12:00:06.000Z', - operation: 'BUILD', level: 'success', message: 'Build & Run complete', - code: 'build-run-result', - data: { - scheme: 'MyApp', - platform: 'macOS', - target: 'macOS', - appPath: '/tmp/build/MyApp.app', - launchState: 'requested', - }, + }); + + renderer.onEvent({ + type: 'detail-tree', + timestamp: '2026-03-20T12:00:06.000Z', + items: [{ label: 'App Path', value: '/tmp/build/MyApp.app' }], }); renderer.onEvent({ @@ -364,13 +330,14 @@ describe('cli-text-renderer', () => { }); const output = stdoutWrite.mock.calls.flat().join(''); - const summaryIndex = output.indexOf('โœ… Build succeeded.'); - const footerIndex = output.indexOf('โœ… Build & Run complete'); + const summaryIndex = output.indexOf('\u{2705} Build succeeded.'); + const footerIndex = output.indexOf('\u{2705} Build & Run complete'); const nextStepsIndex = output.indexOf('Next steps:'); expect(summaryIndex).toBeGreaterThanOrEqual(0); expect(footerIndex).toBeGreaterThan(summaryIndex); expect(nextStepsIndex).toBeGreaterThan(footerIndex); - expect(output).toContain('โœ… Build & Run complete\n\n โ”” App Path: /tmp/build/MyApp.app'); + expect(output).toContain('\u{2705} Build & Run complete'); + expect(output).toContain('\u2514 App Path: /tmp/build/MyApp.app'); }); }); diff --git a/src/utils/renderers/__tests__/event-formatting.test.ts b/src/utils/renderers/__tests__/event-formatting.test.ts index 8d29f791..a4387504 100644 --- a/src/utils/renderers/__tests__/event-formatting.test.ts +++ b/src/utils/renderers/__tests__/event-formatting.test.ts @@ -3,45 +3,44 @@ import { describe, expect, it } from 'vitest'; import { extractGroupedCompilerError, formatGroupedCompilerErrors, - formatHumanErrorEvent, - formatHumanWarningEvent, - formatNoticeEvent, - formatStartEvent, - formatStatusEvent, - formatTransientNoticeEvent, - formatTransientStatusEvent, + formatHumanCompilerErrorEvent, + formatHumanCompilerWarningEvent, + formatHeaderEvent, + formatBuildStageEvent, + formatTransientBuildStageEvent, + formatStatusLineEvent, + formatDetailTreeEvent, + formatTransientStatusLineEvent, } from '../event-formatting.ts'; describe('event formatting', () => { - it('formats start events as the provided preflight block', () => { + it('formats header events with emoji, operation, and params', () => { expect( - formatStartEvent({ - type: 'start', + formatHeaderEvent({ + type: 'header', timestamp: '2026-03-20T12:00:00.000Z', - operation: 'BUILD', - toolName: 'build_run_macos', - params: {}, - message: '๐Ÿš€ Build & Run\n\n Scheme: MyApp', + operation: 'Build & Run', + params: [{ label: 'Scheme', value: 'MyApp' }], }), - ).toBe('๐Ÿš€ Build & Run\n\n Scheme: MyApp'); + ).toBe('\u{1F680} Build & Run\n\n Scheme: MyApp\n'); }); - it('formats status events as durable phase lines', () => { + it('formats build-stage events as durable phase lines', () => { expect( - formatStatusEvent({ - type: 'status', + formatBuildStageEvent({ + type: 'build-stage', timestamp: '2026-03-20T12:00:00.000Z', operation: 'BUILD', stage: 'COMPILING', message: 'Compiling', }), - ).toBe('โ€บ Compiling'); + ).toBe('\u203A Compiling'); }); - it('formats transient status events for interactive runtime updates', () => { + it('formats transient build-stage events for interactive runtime updates', () => { expect( - formatTransientStatusEvent({ - type: 'status', + formatTransientBuildStageEvent({ + type: 'build-stage', timestamp: '2026-03-20T12:00:00.000Z', operation: 'BUILD', stage: 'COMPILING', @@ -54,9 +53,9 @@ describe('event formatting', () => { const projectBaseDir = join(process.cwd(), 'example_projects/macOS'); expect( - formatHumanErrorEvent( + formatHumanCompilerErrorEvent( { - type: 'error', + type: 'compiler-error', timestamp: '2026-03-20T12:00:00.000Z', operation: 'BUILD', message: 'unterminated string literal', @@ -74,8 +73,8 @@ describe('event formatting', () => { it('keeps compiler-style error paths absolute when they are outside cwd', () => { expect( - formatHumanErrorEvent({ - type: 'error', + formatHumanCompilerErrorEvent({ + type: 'compiler-error', timestamp: '2026-03-20T12:00:00.000Z', operation: 'BUILD', message: 'unterminated string literal', @@ -88,8 +87,8 @@ describe('event formatting', () => { it('formats tool-originated errors in xcodebuild-style form', () => { expect( - formatHumanErrorEvent({ - type: 'error', + formatHumanCompilerErrorEvent({ + type: 'compiler-error', timestamp: '2026-03-20T12:00:00.000Z', operation: 'BUILD', message: 'No available simulator matched: INVALID-SIM-ID-123', @@ -102,7 +101,7 @@ describe('event formatting', () => { expect( extractGroupedCompilerError( { - type: 'error', + type: 'compiler-error', timestamp: '2026-03-20T12:00:00.000Z', operation: 'BUILD', message: 'unterminated string literal', @@ -121,7 +120,7 @@ describe('event formatting', () => { formatGroupedCompilerErrors( [ { - type: 'error', + type: 'compiler-error', timestamp: '2026-03-20T12:00:00.000Z', operation: 'BUILD', message: 'unterminated string literal', @@ -134,7 +133,7 @@ describe('event formatting', () => { [ 'Compiler Errors (1):', '', - ' โœ— unterminated string literal', + ' \u2717 unterminated string literal', ' example_projects/macOS/MCPTest/ContentView.swift:16:18', ].join('\n'), ); @@ -142,8 +141,8 @@ describe('event formatting', () => { it('formats tool-originated warnings with warning emoji', () => { expect( - formatHumanWarningEvent({ - type: 'warning', + formatHumanCompilerWarningEvent({ + type: 'compiler-warning', timestamp: '2026-03-20T12:00:00.000Z', operation: 'BUILD', message: 'Using cached build products', @@ -152,123 +151,73 @@ describe('event formatting', () => { ).toBe(' \u{26A0} Using cached build products'); }); - it('formats structured build-run step notices', () => { + it('formats status-line events with level emojis', () => { expect( - formatNoticeEvent({ - type: 'notice', + formatStatusLineEvent({ + type: 'status-line', timestamp: '2026-03-20T12:00:00.000Z', - operation: 'BUILD', level: 'info', message: 'Resolving app path', - code: 'build-run-step', - data: { step: 'resolve-app-path', status: 'started' }, }), - ).toBe('โ€บ Resolving app path'); + ).toBe('\u{2139}\u{FE0F} Resolving app path'); + + expect( + formatStatusLineEvent({ + type: 'status-line', + timestamp: '2026-03-20T12:00:00.000Z', + level: 'success', + message: 'Build & Run complete', + }), + ).toBe('\u{2705} Build & Run complete'); }); - it('formats transient build-run step notices only for started steps', () => { + it('formats transient status-line events for info level', () => { expect( - formatTransientNoticeEvent({ - type: 'notice', + formatTransientStatusLineEvent({ + type: 'status-line', timestamp: '2026-03-20T12:00:00.000Z', - operation: 'BUILD', level: 'info', message: 'Resolving app path', - code: 'build-run-step', - data: { step: 'resolve-app-path', status: 'started' }, }), ).toBe('Resolving app path...'); expect( - formatTransientNoticeEvent({ - type: 'notice', + formatTransientStatusLineEvent({ + type: 'status-line', timestamp: '2026-03-20T12:00:00.000Z', - operation: 'BUILD', level: 'success', message: 'App path resolved', - code: 'build-run-step', - data: { step: 'resolve-app-path', status: 'succeeded', appPath: '/tmp/build/MyApp.app' }, }), ).toBeNull(); }); - it('formats structured build-run result notices as a summary block', () => { - expect( - formatNoticeEvent({ - type: 'notice', - timestamp: '2026-03-20T12:00:00.000Z', - operation: 'BUILD', - level: 'success', - message: 'Build & Run complete', - code: 'build-run-result', - data: { - scheme: 'MyApp', - platform: 'macOS', - target: 'macOS', - appPath: '/tmp/build/MyApp.app', - launchState: 'requested', - }, - }), - ).toBe(['โœ… Build & Run complete', '', ' โ”” App Path: /tmp/build/MyApp.app'].join('\n')); - }); - - it('does not duplicate front-matter fields in the final build-run footer', () => { - const rendered = formatNoticeEvent({ - type: 'notice', + it('formats detail-tree events as a tree section', () => { + const rendered = formatDetailTreeEvent({ + type: 'detail-tree', timestamp: '2026-03-20T12:00:00.000Z', - operation: 'BUILD', - level: 'success', - message: 'Build & Run complete', - code: 'build-run-result', - data: { - scheme: 'MyApp', - platform: 'macOS', - target: 'macOS', - appPath: '/tmp/build/MyApp.app', - launchState: 'requested', - }, + items: [ + { label: 'App Path', value: '/tmp/build/MyApp.app' }, + { label: 'Bundle ID', value: 'com.example.myapp' }, + { label: 'App ID', value: 'A1B2C3D4' }, + { label: 'Process ID', value: '12345' }, + { label: 'Launch', value: 'Running' }, + ], }); - expect(rendered).toContain('\n\n โ”” App Path: /tmp/build/MyApp.app'); - expect(rendered).not.toContain('Scheme:'); - expect(rendered).not.toContain('Platform:'); - expect(rendered).not.toContain('Target:'); - expect(rendered).not.toContain('Configuration:'); - expect(rendered).not.toContain('Project:'); - expect(rendered).not.toContain('Workspace:'); + expect(rendered).toContain(' \u251C App Path: /tmp/build/MyApp.app'); + expect(rendered).toContain(' \u251C Bundle ID: com.example.myapp'); + expect(rendered).toContain(' \u251C App ID: A1B2C3D4'); + expect(rendered).toContain(' \u251C Process ID: 12345'); + expect(rendered).toContain(' \u2514 Launch: Running'); }); - it('renders all execution-derived footer values as a tree section', () => { - const rendered = formatNoticeEvent({ - type: 'notice', - timestamp: '2026-03-20T12:00:00.000Z', - operation: 'BUILD', - level: 'success', - message: 'Build & Run complete', - code: 'build-run-result', - data: { - scheme: 'MyApp', - platform: 'macOS', - target: 'macOS', - appPath: '/tmp/build/MyApp.app', - bundleId: 'com.example.myapp', - appId: 'A1B2C3D4', - processId: 12345, - launchState: 'running', - }, - }); - - expect(rendered).toContain('โœ… Build & Run complete\n\n'); - expect(rendered).toContain(' โ”œ App Path: /tmp/build/MyApp.app'); - expect(rendered).toContain(' โ”œ Bundle ID: com.example.myapp'); - expect(rendered).toContain(' โ”œ App ID: A1B2C3D4'); - expect(rendered).toContain(' โ”œ Process ID: 12345'); - expect(rendered).toContain(' โ”” Launch: Running'); - expect(rendered).not.toContain('Scheme:'); - expect(rendered).not.toContain('Platform:'); - expect(rendered).not.toContain('Target:'); - expect(rendered).not.toContain('Configuration:'); - expect(rendered).not.toContain('Project:'); - expect(rendered).not.toContain('Workspace:'); + it('formats detail-tree with single item using end branch', () => { + expect( + formatDetailTreeEvent({ + type: 'detail-tree', + timestamp: '2026-03-20T12:00:00.000Z', + items: [{ label: 'App Path', value: '/tmp/build/MyApp.app' }], + }), + ).toBe(' \u2514 App Path: /tmp/build/MyApp.app'); }); }); diff --git a/src/utils/renderers/__tests__/mcp-renderer.test.ts b/src/utils/renderers/__tests__/mcp-renderer.test.ts index 56abb13d..0df2912b 100644 --- a/src/utils/renderers/__tests__/mcp-renderer.test.ts +++ b/src/utils/renderers/__tests__/mcp-renderer.test.ts @@ -6,16 +6,14 @@ describe('mcp-renderer', () => { const renderer = createMcpRenderer(); renderer.onEvent({ - type: 'start', + type: 'header', timestamp: '2026-03-20T12:00:00.000Z', - operation: 'BUILD', - toolName: 'build_run_sim', - params: {}, - message: '๐Ÿš€ Build & Run\n\n Scheme: MyApp\n\n', + operation: 'Build & Run', + params: [{ label: 'Scheme', value: 'MyApp' }], }); renderer.onEvent({ - type: 'error', + type: 'compiler-error', timestamp: '2026-03-20T12:00:01.000Z', operation: 'BUILD', message: 'No available simulator matched: INVALID-SIM-ID-123', @@ -35,28 +33,26 @@ describe('mcp-renderer', () => { .filter((item) => item.type === 'text') .map((item) => item.text); - expect(textItems[0]).toContain('๐Ÿš€ Build & Run'); + expect(textItems[0]).toContain('Build & Run'); const allText = textItems.join('\n'); expect(allText).toContain('Errors (1):'); - expect(allText).toContain(' โœ— No available simulator matched: INVALID-SIM-ID-123'); - expect(allText).toContain('โŒ Build failed. (โฑ๏ธ 1.2s)'); + expect(allText).toContain(' \u2717 No available simulator matched: INVALID-SIM-ID-123'); + expect(allText).toContain('\u{274C} Build failed. (\u{23F1}\u{FE0F} 1.2s)'); }); it('buffers grouped compiler diagnostics before the failed summary', () => { const renderer = createMcpRenderer(); renderer.onEvent({ - type: 'start', + type: 'header', timestamp: '2026-03-20T12:00:00.000Z', - operation: 'BUILD', - toolName: 'build_run_macos', - params: {}, - message: '๐Ÿš€ Build & Run\n\n Scheme: MyApp\n\n', + operation: 'Build & Run', + params: [{ label: 'Scheme', value: 'MyApp' }], }); renderer.onEvent({ - type: 'error', + type: 'compiler-error', timestamp: '2026-03-20T12:00:01.000Z', operation: 'BUILD', message: 'unterminated string literal', @@ -77,26 +73,24 @@ describe('mcp-renderer', () => { .map((item) => item.text); expect(textItems[1]).toContain('Compiler Errors (1):'); - expect(textItems[1]).toContain(' โœ— unterminated string literal'); + expect(textItems[1]).toContain(' \u2717 unterminated string literal'); expect(textItems[1]).toContain(' /tmp/MCPTest/ContentView.swift:16:18'); expect(textItems[1]).not.toContain('error: unterminated string literal'); - expect(textItems[2]).toContain('โŒ Build failed. (โฑ๏ธ 4.0s)'); + expect(textItems[2]).toContain('\u{274C} Build failed. (\u{23F1}\u{FE0F} 4.0s)'); }); it('buffers the same formatted sections in order and keeps next steps last', () => { const renderer = createMcpRenderer(); renderer.onEvent({ - type: 'start', + type: 'header', timestamp: '2026-03-20T12:00:00.000Z', - operation: 'BUILD', - toolName: 'build_run_macos', - params: {}, - message: '๐Ÿš€ Build & Run\n\n Scheme: MyApp', + operation: 'Build & Run', + params: [{ label: 'Scheme', value: 'MyApp' }], }); renderer.onEvent({ - type: 'status', + type: 'build-stage', timestamp: '2026-03-20T12:00:01.000Z', operation: 'BUILD', stage: 'COMPILING', @@ -112,19 +106,16 @@ describe('mcp-renderer', () => { }); renderer.onEvent({ - type: 'notice', + type: 'status-line', timestamp: '2026-03-20T12:00:03.000Z', - operation: 'BUILD', level: 'success', message: 'Build & Run complete', - code: 'build-run-result', - data: { - scheme: 'MyApp', - platform: 'macOS', - target: 'macOS', - appPath: '/tmp/build/MyApp.app', - launchState: 'requested', - }, + }); + + renderer.onEvent({ + type: 'detail-tree', + timestamp: '2026-03-20T12:00:03.000Z', + items: [{ label: 'App Path', value: '/tmp/build/MyApp.app' }], }); renderer.onEvent({ @@ -138,13 +129,11 @@ describe('mcp-renderer', () => { .filter((item) => item.type === 'text') .map((item) => item.text); - expect(textItems[0]).toContain('๐Ÿš€ Build & Run'); - expect(textItems[1]).toBe('โ€บ Compiling'); - expect(textItems[2]).toContain('โœ… Build succeeded.'); - expect(textItems[3]).toContain('โœ… Build & Run complete'); - expect(textItems[3]).toContain('\n\n โ”” App Path: /tmp/build/MyApp.app'); - expect(textItems[3]).not.toContain('Scheme:'); - expect(textItems[3]).not.toContain('Target:'); + expect(textItems[0]).toContain('Build & Run'); + expect(textItems[1]).toBe('\u203A Compiling'); + expect(textItems[2]).toContain('\u{2705} Build succeeded.'); + expect(textItems[3]).toContain('\u{2705} Build & Run complete'); + expect(textItems[4]).toContain('\u2514 App Path: /tmp/build/MyApp.app'); expect(textItems.at(-1)).toContain('Next steps:'); }); }); diff --git a/src/utils/renderers/cli-jsonl-renderer.ts b/src/utils/renderers/cli-jsonl-renderer.ts index 2a025783..a29778e7 100644 --- a/src/utils/renderers/cli-jsonl-renderer.ts +++ b/src/utils/renderers/cli-jsonl-renderer.ts @@ -1,9 +1,9 @@ -import type { XcodebuildEvent } from '../../types/xcodebuild-events.ts'; +import type { PipelineEvent } from '../../types/pipeline-events.ts'; import type { XcodebuildRenderer } from './index.ts'; export function createCliJsonlRenderer(): XcodebuildRenderer { return { - onEvent(event: XcodebuildEvent): void { + onEvent(event: PipelineEvent): void { process.stdout.write(JSON.stringify(event) + '\n'); }, finalize(): void { diff --git a/src/utils/renderers/cli-text-renderer.ts b/src/utils/renderers/cli-text-renderer.ts index 819f5a1d..2d73131f 100644 --- a/src/utils/renderers/cli-text-renderer.ts +++ b/src/utils/renderers/cli-text-renderer.ts @@ -1,14 +1,22 @@ -import type { XcodebuildEvent } from '../../types/xcodebuild-events.ts'; +import type { + CompilerErrorEvent, + CompilerWarningEvent, + PipelineEvent, +} from '../../types/pipeline-events.ts'; import { createCliProgressReporter } from '../cli-progress-reporter.ts'; import { formatCliTextLine } from '../terminal-output.ts'; import { deriveDiagnosticBaseDir } from './index.ts'; import type { XcodebuildRenderer } from './index.ts'; import { - formatStartEvent, - formatStatusEvent, - formatTransientStatusEvent, - formatNoticeEvent, - formatTransientNoticeEvent, + formatHeaderEvent, + formatBuildStageEvent, + formatTransientBuildStageEvent, + formatStatusLineEvent, + formatTransientStatusLineEvent, + formatSectionEvent, + formatDetailTreeEvent, + formatTableEvent, + formatFileRefEvent, formatGroupedCompilerErrors, formatGroupedWarnings, formatTestFailureEvent, @@ -26,8 +34,8 @@ function formatCliTextBlock(text: string): string { export function createCliTextRenderer(options: { interactive: boolean }): XcodebuildRenderer { const { interactive } = options; const reporter = createCliProgressReporter(); - const groupedCompilerErrors: Extract[] = []; - const groupedWarnings: Extract[] = []; + const groupedCompilerErrors: CompilerErrorEvent[] = []; + const groupedWarnings: CompilerWarningEvent[] = []; let pendingTransientRuntimeLine: string | null = null; let diagnosticBaseDir: string | null = null; let hasDurableRuntimeContent = false; @@ -46,52 +54,69 @@ export function createCliTextRenderer(options: { interactive: boolean }): Xcodeb } function flushPendingTransientRuntimeLine(): void { - if (!pendingTransientRuntimeLine) { - return; + if (pendingTransientRuntimeLine) { + writeDurable(pendingTransientRuntimeLine); } - - const line = pendingTransientRuntimeLine; - writeDurable(line); } return { - onEvent(event: XcodebuildEvent): void { + onEvent(event: PipelineEvent): void { switch (event.type) { - case 'start': { + case 'header': { diagnosticBaseDir = deriveDiagnosticBaseDir(event); hasDurableRuntimeContent = false; - writeSection(formatStartEvent(event)); + writeSection(formatHeaderEvent(event)); break; } - case 'status': { + case 'build-stage': { if (interactive) { - pendingTransientRuntimeLine = formatStatusEvent(event); - reporter.update(formatTransientStatusEvent(event)); + pendingTransientRuntimeLine = formatBuildStageEvent(event); + reporter.update(formatTransientBuildStageEvent(event)); } else { - writeDurable(formatStatusEvent(event)); + writeDurable(formatBuildStageEvent(event)); } break; } - case 'notice': { - const transientNotice = interactive ? formatTransientNoticeEvent(event) : null; - if (transientNotice) { - pendingTransientRuntimeLine = formatNoticeEvent(event); - reporter.update(transientNotice); + case 'status-line': { + const transient = interactive ? formatTransientStatusLineEvent(event) : null; + if (transient) { + pendingTransientRuntimeLine = formatStatusLineEvent(event); + reporter.update(transient); break; } - writeDurable(formatNoticeEvent(event)); + writeDurable(formatStatusLineEvent(event)); + break; + } + + case 'section': { + writeSection(formatSectionEvent(event)); + break; + } + + case 'detail-tree': { + writeDurable(formatDetailTreeEvent(event)); + break; + } + + case 'table': { + writeSection(formatTableEvent(event)); + break; + } + + case 'file-ref': { + writeDurable(formatFileRefEvent(event)); break; } - case 'warning': { + case 'compiler-warning': { groupedWarnings.push(event); break; } - case 'error': { + case 'compiler-error': { groupedCompilerErrors.push(event); break; } diff --git a/src/utils/renderers/event-formatting.ts b/src/utils/renderers/event-formatting.ts index 0f473da1..8ac45cbe 100644 --- a/src/utils/renderers/event-formatting.ts +++ b/src/utils/renderers/event-formatting.ts @@ -1,16 +1,108 @@ import { existsSync } from 'node:fs'; import path from 'node:path'; import { globSync } from 'glob'; -import type { ErrorEvent, WarningEvent, XcodebuildEvent } from '../../types/xcodebuild-events.ts'; +import type { + CompilerErrorEvent, + CompilerWarningEvent, + BuildStageEvent, + HeaderEvent, + StatusLineEvent, + SectionEvent, + TableEvent, + FileRefEvent, + DetailTreeEvent, + SummaryEvent, + TestDiscoveryEvent, + TestFailureEvent, + NextStepsEvent, +} from '../../types/pipeline-events.ts'; +import { displayPath } from '../build-preflight.ts'; import { renderNextStepsSection } from '../responses/next-steps-renderer.ts'; -function formatDetailTree(details: Array<{ label: string; value: string }>): string[] { +// --- Operation emoji map --- + +export 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}', + 'List Simulators': '\u{1F4F1}', + 'Boot Simulator': '\u{1F4F1}', + 'Open Simulator': '\u{1F4F1}', + 'Set Appearance': '\u{1F3A8}', + 'Set Location': '\u{1F4CD}', + 'Reset Location': '\u{1F4CD}', + Statusbar: '\u{1F4F1}', + 'Erase Simulator': '\u{1F5D1}', + 'List Devices': '\u{1F4F1}', + 'Install App': '\u{1F4E6}', + 'Launch App': '\u{1F680}', + 'Stop App': '\u{1F6D1}', + 'Launch macOS App': '\u{1F680}', + 'Stop macOS App': '\u{1F6D1}', + 'Discover Projects': '\u{1F50D}', + 'Get Bundle ID': '\u{1F50D}', + 'Get macOS Bundle ID': '\u{1F50D}', + 'Scaffold iOS Project': '\u{1F4DD}', + 'Scaffold macOS Project': '\u{1F4DD}', + 'Set Defaults': '\u{2699}\u{FE0F}', + 'Show Defaults': '\u{2699}\u{FE0F}', + 'Clear Defaults': '\u{2699}\u{FE0F}', + 'Use Defaults Profile': '\u{2699}\u{FE0F}', + 'Sync Xcode Defaults': '\u{2699}\u{FE0F}', + 'Start Log Capture': '\u{1F4DD}', + 'Stop Log Capture': '\u{1F4DD}', + 'Attach Debugger': '\u{1F41B}', + 'Add Breakpoint': '\u{1F41B}', + 'Remove Breakpoint': '\u{1F41B}', + Continue: '\u{1F41B}', + Detach: '\u{1F41B}', + 'LLDB Command': '\u{1F41B}', + 'Stack Trace': '\u{1F41B}', + Variables: '\u{1F41B}', + Tap: '\u{1F446}', + Swipe: '\u{1F446}', + 'Type Text': '\u{2328}\u{FE0F}', + Screenshot: '\u{1F4F7}', + 'Snapshot UI': '\u{1F4F7}', + Button: '\u{1F446}', + Gesture: '\u{1F446}', + 'Key Press': '\u{2328}\u{FE0F}', + 'Key Sequence': '\u{2328}\u{FE0F}', + 'Long Press': '\u{1F446}', + Touch: '\u{1F446}', + 'Swift Package Build': '\u{1F4E6}', + 'Swift Package Test': '\u{1F9EA}', + 'Swift Package Clean': '\u{1F9F9}', + 'Swift Package Run': '\u{1F680}', + 'Swift Package List': '\u{1F4E6}', + 'Swift Package Stop': '\u{1F6D1}', + 'Xcode IDE Call Tool': '\u{1F527}', + 'Xcode IDE List Tools': '\u{1F527}', + 'Bridge Disconnect': '\u{1F527}', + 'Bridge Status': '\u{1F527}', + 'Bridge Sync': '\u{1F527}', + Doctor: '\u{1FA7A}', + 'Manage Workflows': '\u{2699}\u{FE0F}', + 'Record Video': '\u{1F3AC}', +}; + +// --- Detail tree formatting --- + +function formatDetailTreeLines(details: Array<{ label: string; value: string }>): string[] { return details.map((detail, index) => { - const branch = index === details.length - 1 ? 'โ””' : 'โ”œ'; + const branch = index === details.length - 1 ? '\u2514' : '\u251C'; return ` ${branch} ${detail.label}: ${detail.value}`; }); } +// --- Diagnostic path resolution --- + const FILE_DIAGNOSTIC_REGEX = /^(?.+?):(?\d+)(?::(?\d+))?:\s*(?warning|error):\s*(?.+)$/i; const TOOLCHAIN_DIAGNOSTIC_REGEX = /^(warning|error):\s+.+$/i; @@ -87,7 +179,7 @@ function formatDiagnosticFilePath(filePath: string, options?: DiagnosticFormatti } function parseHumanDiagnostic( - event: WarningEvent | ErrorEvent, + event: CompilerWarningEvent | CompilerErrorEvent, kind: 'warning' | 'error', options?: DiagnosticFormattingOptions, ): GroupedDiagnosticEntry { @@ -114,45 +206,99 @@ function parseHumanDiagnostic( return { message: `${kind}: ${event.message}` }; } -function isBuildRunStepNotice( - event: Extract, -): event is Extract & { - code: 'build-run-step'; - data: { step: string; status: string; appPath?: string }; -} { - return event.code === 'build-run-step' && typeof event.data === 'object' && event.data !== null; -} +// --- Canonical event formatters --- + +export function formatHeaderEvent(event: HeaderEvent): string { + const emoji = OPERATION_EMOJI[event.operation] ?? '\u{2699}\u{FE0F}'; + const lines: string[] = [`${emoji} ${event.operation}`, '']; + + for (const param of event.params) { + lines.push(` ${param.label}: ${param.value}`); + } -function isBuildRunResultNotice( - event: Extract, -): event is Extract & { - code: 'build-run-result'; - data: { scheme: string; platform: string; target: string; appPath: string; launchState: string }; -} { - return event.code === 'build-run-result' && typeof event.data === 'object' && event.data !== null; + lines.push(''); + return lines.join('\n'); } -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'; +export function formatStatusLineEvent(event: StatusLineEvent): string { + switch (event.level) { + case 'success': + return `\u{2705} ${event.message}`; + case 'error': + return `\u{274C} ${event.message}`; + case 'warning': + return `\u{26A0}\u{FE0F} ${event.message}`; default: - return 'Running step'; + return `\u{2139}\u{FE0F} ${event.message}`; + } +} + +const SECTION_ICON_MAP: Record, string> = { + 'red-circle': '\u{1F534}', + 'yellow-circle': '\u{1F7E1}', + 'green-circle': '\u{1F7E2}', + checkmark: '\u{2705}', + cross: '\u{274C}', + info: '\u{2139}\u{FE0F}', +}; + +export function formatSectionEvent(event: SectionEvent): string { + const icon = event.icon ? `${SECTION_ICON_MAP[event.icon]} ` : ''; + const header = `${icon}${event.title}`; + if (event.lines.length === 0) { + return header; } + const indented = event.lines.map((line) => ` ${line}`); + return [header, ...indented].join('\n'); } +export function formatTableEvent(event: TableEvent): string { + const lines: string[] = []; + if (event.heading) { + lines.push(event.heading); + lines.push(''); + } + + if (event.columns.length === 0 || event.rows.length === 0) { + return lines.join('\n'); + } + + const colWidths = event.columns.map((col) => col.length); + for (const row of event.rows) { + for (let i = 0; i < event.columns.length; i++) { + const value = row[event.columns[i]] ?? ''; + colWidths[i] = Math.max(colWidths[i], value.length); + } + } + + const headerLine = event.columns.map((col, i) => col.padEnd(colWidths[i])).join(' '); + lines.push(headerLine); + lines.push(colWidths.map((w) => '-'.repeat(w)).join(' ')); + + for (const row of event.rows) { + const rowLine = event.columns.map((col, i) => (row[col] ?? '').padEnd(colWidths[i])).join(' '); + lines.push(rowLine); + } + + return lines.join('\n'); +} + +export function formatFileRefEvent(event: FileRefEvent): string { + const displayed = displayPath(event.path); + if (event.label) { + return `${event.label}: ${displayed}`; + } + return displayed; +} + +export function formatDetailTreeEvent(event: DetailTreeEvent): string { + return formatDetailTreeLines(event.items).join('\n'); +} + +// --- Xcodebuild-specific formatters --- + export function extractGroupedCompilerError( - event: ErrorEvent, + event: CompilerErrorEvent, options?: DiagnosticFormattingOptions, ): GroupedDiagnosticEntry | null { const firstRawLine = event.rawLine.split('\n')[0].trim(); @@ -179,7 +325,7 @@ export function extractGroupedCompilerError( } export function formatGroupedCompilerErrors( - events: ErrorEvent[], + events: CompilerErrorEvent[], options?: DiagnosticFormattingOptions, ): string { const hasFileLocated = events.some((e) => extractGroupedCompilerError(e, options) !== null); @@ -191,13 +337,13 @@ export function formatGroupedCompilerErrors( for (const event of events) { const fileDiagnostic = extractGroupedCompilerError(event, options); if (fileDiagnostic) { - lines.push(` โœ— ${fileDiagnostic.message}`); + lines.push(` \u2717 ${fileDiagnostic.message}`); if (fileDiagnostic.location) { lines.push(` ${fileDiagnostic.location}`); } } else { const messageLines = event.message.split('\n'); - lines.push(` โœ— ${messageLines[0]}`); + lines.push(` \u2717 ${messageLines[0]}`); for (let i = 1; i < messageLines.length; i++) { lines.push(` ${messageLines[i]}`); } @@ -212,32 +358,26 @@ export function formatGroupedCompilerErrors( return lines.join('\n'); } -export function formatStartEvent(event: Extract): string { - return event.message; -} - -export function formatStatusEvent(event: Extract): string { +export function formatBuildStageEvent(event: BuildStageEvent): string { switch (event.stage) { case 'RESOLVING_PACKAGES': - return 'โ€บ Resolving packages'; + return '\u203A Resolving packages'; case 'COMPILING': - return 'โ€บ Compiling'; + return '\u203A Compiling'; case 'LINKING': - return 'โ€บ Linking'; + return '\u203A Linking'; case 'PREPARING_TESTS': - return 'โ€บ Preparing tests'; + return '\u203A Preparing tests'; case 'RUN_TESTS': - return 'โ€บ Running tests'; + return '\u203A Running tests'; case 'ARCHIVING': - return 'โ€บ Archiving'; + return '\u203A Archiving'; case 'COMPLETED': return event.message; } } -export function formatTransientStatusEvent( - event: Extract, -): string { +export function formatTransientBuildStageEvent(event: BuildStageEvent): string { switch (event.stage) { case 'RESOLVING_PACKAGES': return 'Resolving packages...'; @@ -256,8 +396,8 @@ export function formatTransientStatusEvent( } } -export function formatHumanWarningEvent( - event: Extract, +export function formatHumanCompilerWarningEvent( + event: CompilerWarningEvent, options?: DiagnosticFormattingOptions, ): string { const diagnostic = parseHumanDiagnostic(event, 'warning', options); @@ -269,7 +409,7 @@ export function formatHumanWarningEvent( } export function formatGroupedWarnings( - events: Extract[], + events: CompilerWarningEvent[], options?: DiagnosticFormattingOptions, ): string { const heading = `Warnings (${events.length}):`; @@ -291,8 +431,8 @@ export function formatGroupedWarnings( return lines.join('\n'); } -export function formatHumanErrorEvent( - event: Extract, +export function formatHumanCompilerErrorEvent( + event: CompilerErrorEvent, options?: DiagnosticFormattingOptions, ): string { const diagnostic = parseHumanDiagnostic(event, 'error', options); @@ -301,57 +441,15 @@ export function formatHumanErrorEvent( : diagnostic.message; } -export function formatNoticeEvent(event: Extract): string { - if (isBuildRunStepNotice(event)) { - const stepLabel = formatBuildRunStepLabel(event.data.step); - return event.data.status === 'succeeded' ? `โœ“ ${stepLabel}` : `โ€บ ${stepLabel}`; - } - - if (isBuildRunResultNotice(event)) { - const details = [{ label: 'App Path', value: event.data.appPath }]; - - if ('bundleId' in event.data && typeof event.data.bundleId === 'string') { - details.push({ label: 'Bundle ID', value: event.data.bundleId }); - } - - if ('appId' in event.data && typeof event.data.appId === 'string') { - details.push({ label: 'App ID', value: event.data.appId }); - } - - if ('processId' in event.data && typeof event.data.processId === 'number') { - details.push({ label: 'Process ID', value: String(event.data.processId) }); - } - - if (event.data.launchState !== 'requested') { - details.push({ label: 'Launch', value: 'Running' }); - } - - return ['โœ… Build & Run complete', '', ...formatDetailTree(details)].join('\n'); - } - - switch (event.level) { - case 'success': - return `\u{2705} ${event.message}`; - case 'warning': - return `\u{26A0}\u{FE0F} ${event.message}`; - default: - return `\u{2139}\u{FE0F} ${event.message}`; - } -} - -export function formatTransientNoticeEvent( - event: Extract, -): string | null { - if (!isBuildRunStepNotice(event) || event.data.status !== 'started') { - return null; +export function formatTransientStatusLineEvent(event: StatusLineEvent): string | null { + if (event.level === 'info') { + return `${event.message}...`; } - - const stepLabel = formatBuildRunStepLabel(event.data.step); - return `${stepLabel}...`; + return null; } export function formatTestFailureEvent( - event: Extract, + event: TestFailureEvent, options?: DiagnosticFormattingOptions, ): string { const parts: string[] = []; @@ -375,8 +473,10 @@ export function formatTestFailureEvent( return lines.join('\n'); } -export function formatSummaryEvent(event: Extract): string { - const op = event.operation[0] + event.operation.slice(1).toLowerCase(); +export function formatSummaryEvent(event: SummaryEvent): string { + const op = event.operation + ? event.operation[0] + event.operation.slice(1).toLowerCase() + : 'Operation'; const succeeded = event.status === 'SUCCEEDED'; const statusEmoji = succeeded ? '\u{2705}' : '\u{274C}'; const statusWord = succeeded ? 'succeeded' : 'failed'; @@ -405,17 +505,12 @@ export function formatSummaryEvent(event: Extract, -): string { +export function formatTestDiscoveryEvent(event: TestDiscoveryEvent): string { const testList = event.tests.join(', '); const truncation = event.truncated ? ` (and more)` : ''; return `Discovered ${event.total} test(s): ${testList}${truncation}`; } -export function formatNextStepsEvent( - event: Extract, - runtime: 'cli' | 'mcp', -): string { +export function formatNextStepsEvent(event: NextStepsEvent, runtime: 'cli' | 'mcp'): string { return renderNextStepsSection(event.steps, runtime); } diff --git a/src/utils/renderers/index.ts b/src/utils/renderers/index.ts index 963b9ea7..678bf7f2 100644 --- a/src/utils/renderers/index.ts +++ b/src/utils/renderers/index.ts @@ -1,29 +1,42 @@ import path from 'node:path'; -import type { StartEvent, XcodebuildEvent } from '../../types/xcodebuild-events.ts'; +import type { HeaderEvent, PipelineEvent } from '../../types/pipeline-events.ts'; +import { createMcpRenderer } from './mcp-renderer.ts'; +import { createCliTextRenderer } from './cli-text-renderer.ts'; +import { createCliJsonlRenderer } from './cli-jsonl-renderer.ts'; export interface XcodebuildRenderer { - onEvent(event: XcodebuildEvent): void; + onEvent(event: PipelineEvent): void; finalize(): void; } -export function deriveDiagnosticBaseDir(event: StartEvent): string | null { - let paramsProjectPath: string | null = null; - if (typeof event.params.projectPath === 'string') { - paramsProjectPath = event.params.projectPath; - } else if (typeof event.params.workspacePath === 'string') { - paramsProjectPath = event.params.workspacePath; +export function deriveDiagnosticBaseDir(event: HeaderEvent): string | null { + for (const param of event.params) { + if (param.label === 'Workspace' || param.label === 'Project') { + return path.dirname(path.resolve(process.cwd(), param.value)); + } } + return null; +} - if (paramsProjectPath) { - return path.dirname(path.resolve(process.cwd(), paramsProjectPath)); - } +export function resolveRenderers(): { + renderers: XcodebuildRenderer[]; + mcpRenderer: ReturnType; +} { + const mcpRenderer = createMcpRenderer(); + const renderers: XcodebuildRenderer[] = [mcpRenderer]; + + const runtime = process.env.XCODEBUILDMCP_RUNTIME; + const outputFormat = process.env.XCODEBUILDMCP_CLI_OUTPUT_FORMAT; - const messageMatch = event.message.match(/^ (Project|Workspace): (.+)$/mu); - if (!messageMatch) { - return null; + if (runtime === 'cli') { + if (outputFormat === 'json') { + renderers.push(createCliJsonlRenderer()); + } else { + renderers.push(createCliTextRenderer({ interactive: process.stdout.isTTY === true })); + } } - return path.dirname(path.resolve(process.cwd(), messageMatch[2])); + return { renderers, mcpRenderer }; } export { createMcpRenderer } from './mcp-renderer.ts'; diff --git a/src/utils/renderers/mcp-renderer.ts b/src/utils/renderers/mcp-renderer.ts index 79bbd5fa..f2b8aed3 100644 --- a/src/utils/renderers/mcp-renderer.ts +++ b/src/utils/renderers/mcp-renderer.ts @@ -1,12 +1,20 @@ -import type { XcodebuildEvent } from '../../types/xcodebuild-events.ts'; +import type { + CompilerErrorEvent, + CompilerWarningEvent, + PipelineEvent, +} from '../../types/pipeline-events.ts'; import type { ToolResponseContent } from '../../types/common.ts'; import { sessionStore } from '../session-store.ts'; import { deriveDiagnosticBaseDir } from './index.ts'; import type { XcodebuildRenderer } from './index.ts'; import { - formatStartEvent, - formatStatusEvent, - formatNoticeEvent, + formatHeaderEvent, + formatBuildStageEvent, + formatStatusLineEvent, + formatSectionEvent, + formatDetailTreeEvent, + formatTableEvent, + formatFileRefEvent, formatGroupedCompilerErrors, formatGroupedWarnings, formatTestFailureEvent, @@ -20,8 +28,8 @@ export function createMcpRenderer(): XcodebuildRenderer & { } { const content: ToolResponseContent[] = []; const suppressWarnings = sessionStore.get('suppressWarnings'); - const groupedCompilerErrors: Extract[] = []; - const groupedWarnings: Extract[] = []; + const groupedCompilerErrors: CompilerErrorEvent[] = []; + const groupedWarnings: CompilerWarningEvent[] = []; let diagnosticBaseDir: string | null = null; function pushText(text: string): void { @@ -33,25 +41,45 @@ export function createMcpRenderer(): XcodebuildRenderer & { } return { - onEvent(event: XcodebuildEvent): void { + onEvent(event: PipelineEvent): void { switch (event.type) { - case 'start': { + case 'header': { diagnosticBaseDir = deriveDiagnosticBaseDir(event); - pushSection(formatStartEvent(event)); + pushSection(formatHeaderEvent(event)); break; } - case 'status': { - pushText(formatStatusEvent(event)); + case 'build-stage': { + pushText(formatBuildStageEvent(event)); break; } - case 'notice': { - pushText(formatNoticeEvent(event)); + case 'status-line': { + pushText(formatStatusLineEvent(event)); break; } - case 'warning': { + case 'section': { + pushSection(formatSectionEvent(event)); + break; + } + + case 'detail-tree': { + pushText(formatDetailTreeEvent(event)); + break; + } + + case 'table': { + pushSection(formatTableEvent(event)); + break; + } + + case 'file-ref': { + pushText(formatFileRefEvent(event)); + break; + } + + case 'compiler-warning': { if (suppressWarnings) { return; } @@ -59,7 +87,7 @@ export function createMcpRenderer(): XcodebuildRenderer & { break; } - case 'error': { + case 'compiler-error': { groupedCompilerErrors.push(event); break; } diff --git a/src/utils/responses/__tests__/next-steps-renderer.test.ts b/src/utils/responses/__tests__/next-steps-renderer.test.ts index b1e807eb..d81cef3a 100644 --- a/src/utils/responses/__tests__/next-steps-renderer.test.ts +++ b/src/utils/responses/__tests__/next-steps-renderer.test.ts @@ -1,10 +1,6 @@ import { describe, it, expect } from 'vitest'; -import { - renderNextStep, - renderNextStepsSection, - processToolResponse, -} from '../next-steps-renderer.ts'; -import type { NextStep, ToolResponse } from '../../../types/common.ts'; +import { renderNextStep, renderNextStepsSection } from '../next-steps-renderer.ts'; +import type { NextStep } from '../../../types/common.ts'; describe('next-steps-renderer', () => { describe('renderNextStep', () => { @@ -248,110 +244,4 @@ describe('next-steps-renderer', () => { expect(result).toContain('xcodebuildmcp take-screenshot'); }); }); - - describe('processToolResponse', () => { - it('should pass through response with no nextSteps', () => { - const response: ToolResponse = { - content: [{ type: 'text', text: 'Success!' }], - }; - - const result = processToolResponse(response, 'cli', 'normal'); - expect(result).toEqual({ - content: [{ type: 'text', text: 'Success!' }], - }); - }); - - it('should strip nextSteps in minimal style', () => { - const response: ToolResponse = { - content: [{ type: 'text', text: 'Success!' }], - nextSteps: [{ tool: 'foo', cliTool: 'foo', label: 'Do foo', params: {} }], - }; - - const result = processToolResponse(response, 'cli', 'minimal'); - expect(result).toEqual({ - content: [{ type: 'text', text: 'Success!' }], - }); - expect(result.nextSteps).toBeUndefined(); - }); - - it('should append next steps to last text content in normal style', () => { - const response: ToolResponse = { - content: [{ type: 'text', text: 'Simulator booted.' }], - nextSteps: [ - { - tool: 'open_sim', - cliTool: 'open-sim', - label: 'Open Simulator', - params: {}, - priority: 1, - }, - ], - }; - - const result = processToolResponse(response, 'cli', 'normal'); - expect(result.content[0].text).toBe( - 'Simulator booted.\n\nNext steps:\n1. Open Simulator: xcodebuildmcp open-sim', - ); - expect(result.nextSteps).toBeUndefined(); - }); - - it('should render MCP-style for MCP runtime', () => { - const response: ToolResponse = { - content: [{ type: 'text', text: 'Simulator booted.' }], - nextSteps: [{ tool: 'open_sim', label: 'Open Simulator', params: {}, priority: 1 }], - }; - - const result = processToolResponse(response, 'mcp', 'normal'); - expect(result.content[0].text).toBe( - 'Simulator booted.\n\nNext steps:\n1. Open Simulator: open_sim()', - ); - }); - - it('should handle response with empty nextSteps array', () => { - const response: ToolResponse = { - content: [{ type: 'text', text: 'Done.' }], - nextSteps: [], - }; - - const result = processToolResponse(response, 'cli', 'normal'); - expect(result).toEqual({ - content: [{ type: 'text', text: 'Done.' }], - }); - }); - - it('should preserve other response properties', () => { - const response: ToolResponse = { - content: [{ type: 'text', text: 'Error!' }], - isError: true, - _meta: { foo: 'bar' }, - nextSteps: [{ tool: 'retry', cliTool: 'retry', label: 'Retry', params: {} }], - }; - - const result = processToolResponse(response, 'cli', 'minimal'); - expect(result.isError).toBe(true); - expect(result._meta).toEqual({ foo: 'bar' }); - }); - - it('should not mutate original response', () => { - const response: ToolResponse = { - content: [{ type: 'text', text: 'Original' }], - nextSteps: [{ tool: 'foo', cliTool: 'foo', label: 'Foo', params: {} }], - }; - - processToolResponse(response, 'cli', 'normal'); - - expect(response.content[0].text).toBe('Original'); - expect(response.nextSteps).toHaveLength(1); - }); - - it('should default to normal style when not specified', () => { - const response: ToolResponse = { - content: [{ type: 'text', text: 'Success!' }], - nextSteps: [{ tool: 'foo', cliTool: 'foo', label: 'Do foo', params: {} }], - }; - - const result = processToolResponse(response, 'cli'); - expect(result.content[0].text).toContain('Next steps:'); - }); - }); }); diff --git a/src/utils/responses/index.ts b/src/utils/responses/index.ts index 1707ea06..276aadbd 100644 --- a/src/utils/responses/index.ts +++ b/src/utils/responses/index.ts @@ -1,20 +1,3 @@ -/** - * Focused responses facade. - * Prefer importing from 'utils/responses/index.js' instead of the legacy utils barrel. - */ -export { createTextResponse } from '../validation.ts'; -export { - createErrorResponse, - DependencyError, - AxeError, - SystemError, - ValidationError, -} from '../errors.ts'; -export { - processToolResponse, - renderNextStep, - renderNextStepsSection, -} from './next-steps-renderer.ts'; +export { DependencyError, AxeError, SystemError, ValidationError } from '../errors.ts'; -// Types -export type { ToolResponse, NextStep, OutputStyle } from '../../types/common.ts'; +export type { ToolResponse } from '../../types/common.ts'; diff --git a/src/utils/responses/next-steps-renderer.ts b/src/utils/responses/next-steps-renderer.ts index 3ab4db6a..ebe30394 100644 --- a/src/utils/responses/next-steps-renderer.ts +++ b/src/utils/responses/next-steps-renderer.ts @@ -1,15 +1,6 @@ import type { RuntimeKind } from '../../runtime/types.ts'; -import type { NextStep, OutputStyle, ToolResponse } from '../../types/common.ts'; - -/** - * Convert a string to kebab-case for CLI flag names. - */ -function toKebabCase(name: string): string { - return name - .replace(/_/g, '-') - .replace(/([a-z])([A-Z])/g, '$1-$2') - .toLowerCase(); -} +import type { NextStep } from '../../types/common.ts'; +import { toKebabCase } from '../../runtime/naming.ts'; function resolveLabel(step: NextStep): string { if (step.label?.trim()) return step.label; @@ -31,7 +22,6 @@ function formatNextStepForCli(step: NextStep): string { const cliTool = step.cliTool ?? toKebabCase(step.tool); const params = step.params ?? {}; - // Include workflow as subcommand if provided if (step.workflow) { parts.push(step.workflow); } @@ -79,9 +69,6 @@ function formatNextStepForMcp(step: NextStep): string { return `${step.tool}({ ${paramsStr} })`; } -/** - * Render a single next step based on runtime. - */ export function renderNextStep(step: NextStep, runtime: RuntimeKind): string { if (!step.tool) { return resolveLabel(step); @@ -93,10 +80,6 @@ export function renderNextStep(step: NextStep, runtime: RuntimeKind): string { return `${step.label}: ${formatted}`; } -/** - * Render the full next steps section. - * Returns empty string if no steps. - */ export function renderNextStepsSection(steps: NextStep[], runtime: RuntimeKind): string { if (steps.length === 0) { return ''; @@ -107,43 +90,3 @@ export function renderNextStepsSection(steps: NextStep[], runtime: RuntimeKind): return `Next steps:\n${lines.join('\n')}`; } - -/** - * Process a tool response, applying next steps rendering based on runtime and style. - * - * - In 'minimal' style, nextSteps are stripped entirely - * - In 'normal' style, nextSteps are rendered and appended to text content - * - * Returns a new response object (does not mutate the original). - */ -export function processToolResponse( - response: ToolResponse, - runtime: RuntimeKind, - style: OutputStyle = 'normal', -): ToolResponse { - const { nextSteps, ...rest } = response; - - // If no nextSteps or minimal style, strip nextSteps and return - if (!nextSteps || nextSteps.length === 0 || style === 'minimal') { - return { ...rest }; - } - - // Render next steps section - const nextStepsSection = renderNextStepsSection(nextSteps, runtime); - - // Append to the last text content item - 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; - }); - - // If no text content existed, add one with just the next steps - const hasTextContent = response.content.some((item) => item.type === 'text'); - if (!hasTextContent && nextStepsSection) { - processedContent.push({ type: 'text', text: nextStepsSection }); - } - - return { ...rest, content: processedContent }; -} diff --git a/src/utils/schema-helpers.ts b/src/utils/schema-helpers.ts index 3d43b9d2..df073be7 100644 --- a/src/utils/schema-helpers.ts +++ b/src/utils/schema-helpers.ts @@ -1,16 +1,3 @@ -/** - * Schema Helper Utilities - * - * Shared utility functions for schema validation and preprocessing. - */ - -/** - * Convert empty strings to undefined in an object (shallow transformation) - * Used for preprocessing Zod schemas with optional fields - * - * @param value - The value to process - * @returns The processed value with empty strings converted to undefined - */ export function nullifyEmptyStrings(value: unknown): unknown { if (value && typeof value === 'object' && !Array.isArray(value)) { const copy: Record = { ...(value as Record) }; diff --git a/src/utils/simulator-resolver.ts b/src/utils/simulator-resolver.ts index 80839336..f0b9b580 100644 --- a/src/utils/simulator-resolver.ts +++ b/src/utils/simulator-resolver.ts @@ -1,8 +1,3 @@ -/** - * Shared utility for resolving simulator names to UUIDs. - * Centralizes the lookup logic used across multiple tools. - */ - import type { CommandExecutor } from './execution/index.ts'; import { log } from './logger.ts'; @@ -10,19 +5,11 @@ export type SimulatorResolutionResult = | { success: true; simulatorId: string; simulatorName: string } | { success: false; error: string }; -/** - * Resolves a simulator name to its UUID by querying simctl. - * - * @param executor - Command executor for running simctl - * @param simulatorName - The human-readable simulator name (e.g., "iPhone 17") - * @returns Resolution result with simulatorId on success, or error message on failure - */ -export async function resolveSimulatorNameToId( - executor: CommandExecutor, - simulatorName: string, -): Promise { - log('info', `Looking up simulator by name: ${simulatorName}`); +type SimulatorDevice = { udid: string; name: string }; +async function fetchSimulatorDevices( + executor: CommandExecutor, +): Promise<{ devices: Record } | { error: string }> { const result = await executor( ['xcrun', 'simctl', 'list', 'devices', 'available', '--json'], 'List Simulators', @@ -30,33 +17,42 @@ export async function resolveSimulatorNameToId( ); if (!result.success) { - return { - success: false, - error: `Failed to list simulators: ${result.error}`, - }; + return { error: `Failed to list simulators: ${result.error}` }; } - let simulatorsData: { devices: Record> }; try { - simulatorsData = JSON.parse(result.output) as typeof simulatorsData; + return JSON.parse(result.output) as { devices: Record }; } catch (parseError) { - return { - success: false, - error: `Failed to parse simulator list: ${parseError}`, - }; + return { error: `Failed to parse simulator list: ${parseError}` }; } +} +function findSimulator( + simulatorsData: { devices: Record }, + predicate: (device: SimulatorDevice) => boolean, +): SimulatorDevice | undefined { for (const runtime in simulatorsData.devices) { - const devices = simulatorsData.devices[runtime]; - const simulator = devices.find((device) => device.name === simulatorName); - if (simulator) { - log('info', `Resolved simulator "${simulatorName}" to UUID: ${simulator.udid}`); - return { - success: true, - simulatorId: simulator.udid, - simulatorName: simulator.name, - }; - } + const found = simulatorsData.devices[runtime].find(predicate); + if (found) return found; + } + return undefined; +} + +/** + * Resolves a simulator name to its UUID by querying simctl. + */ +export async function resolveSimulatorNameToId( + executor: CommandExecutor, + simulatorName: string, +): Promise { + log('info', `Looking up simulator by name: ${simulatorName}`); + const data = await fetchSimulatorDevices(executor); + if ('error' in data) return { success: false, error: data.error }; + + const simulator = findSimulator(data, (d) => d.name === simulatorName); + if (simulator) { + log('info', `Resolved simulator "${simulatorName}" to UUID: ${simulator.udid}`); + return { success: true, simulatorId: simulator.udid, simulatorName: simulator.name }; } return { @@ -67,51 +63,19 @@ export async function resolveSimulatorNameToId( /** * Resolves a simulator UUID to its name by querying simctl. - * - * @param executor - Command executor for running simctl - * @param simulatorId - The simulator UUID - * @returns Resolution result with simulatorName on success, or error message on failure */ export async function resolveSimulatorIdToName( executor: CommandExecutor, simulatorId: string, ): Promise { log('info', `Looking up simulator by UUID: ${simulatorId}`); + const data = await fetchSimulatorDevices(executor); + if ('error' in data) return { success: false, error: data.error }; - const result = await executor( - ['xcrun', 'simctl', 'list', 'devices', 'available', '--json'], - 'List Simulators', - false, - ); - - if (!result.success) { - return { - success: false, - error: `Failed to list simulators: ${result.error}`, - }; - } - - let simulatorsData: { devices: Record> }; - try { - simulatorsData = JSON.parse(result.output) as typeof simulatorsData; - } catch (parseError) { - return { - success: false, - error: `Failed to parse simulator list: ${parseError}`, - }; - } - - for (const runtime in simulatorsData.devices) { - const devices = simulatorsData.devices[runtime]; - const simulator = devices.find((device) => device.udid === simulatorId); - if (simulator) { - log('info', `Resolved simulator UUID "${simulatorId}" to name: ${simulator.name}`); - return { - success: true, - simulatorId: simulator.udid, - simulatorName: simulator.name, - }; - } + const simulator = findSimulator(data, (d) => d.udid === simulatorId); + if (simulator) { + log('info', `Resolved simulator UUID "${simulatorId}" to name: ${simulator.name}`); + return { success: true, simulatorId: simulator.udid, simulatorName: simulator.name }; } return { @@ -121,14 +85,9 @@ export async function resolveSimulatorIdToName( } /** - * Helper to resolve simulatorId from either simulatorId or simulatorName. + * Resolves a simulator from either simulatorId or simulatorName. * If simulatorId is provided, returns it directly. - * If only simulatorName is provided, resolves it to simulatorId. - * - * @param executor - Command executor for running simctl - * @param simulatorId - Optional simulator UUID - * @param simulatorName - Optional simulator name - * @returns Resolution result with simulatorId, or error if neither provided or lookup fails + * If only simulatorName is provided, resolves it via simctl. */ export async function resolveSimulatorIdOrName( executor: CommandExecutor, diff --git a/src/utils/simulator-utils.ts b/src/utils/simulator-utils.ts index 70ba5850..f553abf4 100644 --- a/src/utils/simulator-utils.ts +++ b/src/utils/simulator-utils.ts @@ -1,21 +1,12 @@ -/** - * Simulator utility functions for name to UUID resolution - */ - import type { CommandExecutor } from './execution/index.ts'; -import type { ToolResponse } from '../types/common.ts'; import { log } from './logging/index.ts'; -import { createErrorResponse } from './responses/index.ts'; -/** - * UUID regex pattern to check if a string looks like a UUID - */ const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; export async function validateAvailableSimulatorId( simulatorId: string, executor: CommandExecutor, -): Promise<{ error?: ToolResponse }> { +): Promise<{ error?: string }> { const listResult = await executor( ['xcrun', 'simctl', 'list', 'devices', 'available', '-j'], 'List available simulators', @@ -23,7 +14,7 @@ export async function validateAvailableSimulatorId( if (!listResult.success) { return { - error: createErrorResponse('Failed to list simulators', listResult.error ?? 'Unknown error'), + error: `Failed to list simulators: ${listResult.error ?? 'Unknown error'}`, }; } @@ -40,48 +31,27 @@ export async function validateAvailableSimulatorId( } return { - error: createErrorResponse( - `No available simulator matched: ${simulatorId}`, - 'Tip: run "xcrun simctl list devices available" to see names and UDIDs.', - ), + error: `No available simulator matched: ${simulatorId}. Tip: run "xcrun simctl list devices available" to see names and UDIDs.`, }; } catch (parseError) { return { - error: createErrorResponse( - 'Failed to parse simulator list', - parseError instanceof Error ? parseError.message : String(parseError), - ), + error: `Failed to parse simulator list: ${parseError instanceof Error ? parseError.message : String(parseError)}`, }; } } -/** - * Determines the simulator UUID from either a UUID or name. - * - * Behavior: - * - If simulatorUuid provided: return it directly - * - Else if simulatorName looks like a UUID (regex): treat it as UUID and return it - * - Else: resolve name โ†’ UUID via simctl and return the match (isAvailable === true) - * - * @param params Object containing optional simulatorUuid or simulatorName - * @param executor Command executor for running simctl commands - * @returns Object with uuid, optional warning, or error - */ export async function determineSimulatorUuid( params: { simulatorUuid?: string; simulatorId?: string; simulatorName?: string }, executor: CommandExecutor, -): Promise<{ uuid?: string; warning?: string; error?: ToolResponse }> { +): Promise<{ uuid?: string; warning?: string; error?: string }> { const directUuid = params.simulatorUuid ?? params.simulatorId; - // If UUID is provided directly, use it if (directUuid) { log('info', `Using provided simulator UUID: ${directUuid}`); return { uuid: directUuid }; } - // If name is provided, check if it's actually a UUID if (params.simulatorName) { - // Check if the "name" is actually a UUID string if (UUID_REGEX.test(params.simulatorName)) { log( 'info', @@ -93,7 +63,6 @@ export async function determineSimulatorUuid( }; } - // Resolve name to UUID via simctl log('info', `Looking up simulator UUID for name: ${params.simulatorName}`); const listResult = await executor( @@ -103,10 +72,7 @@ export async function determineSimulatorUuid( if (!listResult.success) { return { - error: createErrorResponse( - 'Failed to list simulators', - listResult.error ?? 'Unknown error', - ), + error: `Failed to list simulators: ${listResult.error ?? 'Unknown error'}`, }; } @@ -123,63 +89,33 @@ export async function determineSimulatorUuid( const devicesData = JSON.parse(listResult.output ?? '{}') as DevicesData; - // Search through all runtime sections for the named device - for (const runtime of Object.keys(devicesData.devices)) { - const devices = devicesData.devices[runtime]; - if (!Array.isArray(devices)) continue; + const allDevices = Object.values(devicesData.devices).filter(Array.isArray).flat(); - // Look for exact name match with isAvailable === true - const device = devices.find( - (d) => d.name === params.simulatorName && d.isAvailable === true, - ); + const namedDevices = allDevices.filter((d) => d.name === params.simulatorName); - if (device) { - log('info', `Found simulator '${params.simulatorName}' with UUID: ${device.udid}`); - return { uuid: device.udid }; - } + const availableDevice = namedDevices.find((d) => d.isAvailable); + if (availableDevice) { + log('info', `Found simulator '${params.simulatorName}' with UUID: ${availableDevice.udid}`); + return { uuid: availableDevice.udid }; } - // If no available device found, check if device exists but is unavailable - for (const runtime of Object.keys(devicesData.devices)) { - const devices = devicesData.devices[runtime]; - if (!Array.isArray(devices)) continue; - - const unavailableDevice = devices.find( - (d) => d.name === params.simulatorName && d.isAvailable === false, - ); - - if (unavailableDevice) { - return { - error: createErrorResponse( - `Simulator '${params.simulatorName}' exists but is not available`, - 'The simulator may need to be downloaded or is incompatible with the current Xcode version', - ), - }; - } + if (namedDevices.length > 0) { + return { + error: `Simulator '${params.simulatorName}' exists but is not available. The simulator may need to be downloaded or is incompatible with the current Xcode version`, + }; } - // Device not found at all return { - error: createErrorResponse( - `Simulator '${params.simulatorName}' not found`, - 'Please check the simulator name or use "xcrun simctl list devices" to see available simulators', - ), + error: `Simulator '${params.simulatorName}' not found. Please check the simulator name or use "xcrun simctl list devices" to see available simulators`, }; } catch (parseError) { return { - error: createErrorResponse( - 'Failed to parse simulator list', - parseError instanceof Error ? parseError.message : String(parseError), - ), + error: `Failed to parse simulator list: ${parseError instanceof Error ? parseError.message : String(parseError)}`, }; } } - // Neither UUID nor name provided return { - error: createErrorResponse( - 'No simulator identifier provided', - 'Either simulatorUuid or simulatorName is required', - ), + error: 'No simulator identifier provided. Either simulatorUuid or simulatorName is required', }; } diff --git a/src/utils/terminal-output.ts b/src/utils/terminal-output.ts index 092b4794..039d7c2a 100644 --- a/src/utils/terminal-output.ts +++ b/src/utils/terminal-output.ts @@ -19,22 +19,6 @@ function colorYellow(text: string): string { return `${ANSI_YELLOW}${text}${ANSI_RESET}`; } -function colorizeWarningTriangleLine(line: string): string { - return line.replace(/^(\s*)(โš  )/u, (_match, indent: string, prefix: string) => { - return `${indent}${colorYellow(prefix)}`; - }); -} - -function colorizeSummaryCrossLine(line: string): string { - return line.replace(/^(\s*)(โœ— )/u, (_match, indent: string, prefix: string) => { - return `${indent}${colorRed(prefix)}`; - }); -} - -function colorizeFailureIconLine(line: string): string { - return line.replace(/^(โŒ )/u, (_match, prefix: string) => colorRed(prefix)); -} - export function formatCliTextLine(line: string): string { if (!shouldUseCliColor()) { return line; @@ -45,15 +29,21 @@ export function formatCliTextLine(line: string): string { } if (/^\s*โš  /u.test(line)) { - return colorizeWarningTriangleLine(line); + return line.replace( + /^(\s*)(โš  )/u, + (_m, indent: string, prefix: string) => `${indent}${colorYellow(prefix)}`, + ); } if (/^\s*โœ— /u.test(line)) { - return colorizeSummaryCrossLine(line); + return line.replace( + /^(\s*)(โœ— )/u, + (_m, indent: string, prefix: string) => `${indent}${colorRed(prefix)}`, + ); } if (/^โŒ /u.test(line)) { - return colorizeFailureIconLine(line); + return line.replace(/^(โŒ )/u, (_m, prefix: string) => colorRed(prefix)); } return line; diff --git a/src/utils/test-common.ts b/src/utils/test-common.ts index c4c1222b..55026671 100644 --- a/src/utils/test-common.ts +++ b/src/utils/test-common.ts @@ -12,7 +12,8 @@ import { log } from './logger.ts'; import type { XcodePlatform } from './xcode.ts'; import { executeXcodeBuildCommand } from './build/index.ts'; -import { createTextResponse } from './validation.ts'; +import { toolResponse } from './tool-response.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'; @@ -195,6 +196,12 @@ export async function handleTestLogic( } 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); + return toolResponse([ + header('Test Run', [ + { label: 'Scheme', value: params.scheme }, + { label: 'Platform', value: String(params.platform) }, + ]), + statusLine('error', `Error during test run: ${errorMessage}`), + ]); } } diff --git a/src/utils/tool-event-builders.ts b/src/utils/tool-event-builders.ts new file mode 100644 index 00000000..be90e452 --- /dev/null +++ b/src/utils/tool-event-builders.ts @@ -0,0 +1,103 @@ +import type { + HeaderEvent, + SectionEvent, + StatusLineEvent, + FileRefEvent, + TableEvent, + DetailTreeEvent, + SummaryEvent, +} from '../types/pipeline-events.ts'; + +function now(): string { + return new Date().toISOString(); +} + +export function header( + operation: string, + params?: Array<{ label: string; value: string }>, +): HeaderEvent { + return { + type: 'header', + timestamp: now(), + operation, + params: params ?? [], + }; +} + +export function section( + title: string, + lines: string[], + opts?: { icon?: SectionEvent['icon'] }, +): SectionEvent { + return { + type: 'section', + timestamp: now(), + title, + icon: opts?.icon, + lines, + }; +} + +export function statusLine(level: StatusLineEvent['level'], message: string): StatusLineEvent { + return { + type: 'status-line', + timestamp: now(), + level, + message, + }; +} + +export function fileRef(path: string, label?: string): FileRefEvent { + return { + type: 'file-ref', + timestamp: now(), + label, + path, + }; +} + +export function table( + columns: string[], + rows: Array>, + heading?: string, +): TableEvent { + return { + type: 'table', + timestamp: now(), + heading, + columns, + rows, + }; +} + +export function detailTree(items: Array<{ label: string; value: string }>): DetailTreeEvent { + return { + type: 'detail-tree', + timestamp: now(), + items, + }; +} + +export function summary( + status: 'SUCCEEDED' | 'FAILED', + opts?: { + operation?: string; + durationMs?: number; + totalTests?: number; + passedTests?: number; + failedTests?: number; + skippedTests?: number; + }, +): SummaryEvent { + return { + type: 'summary', + timestamp: now(), + status, + operation: opts?.operation, + durationMs: opts?.durationMs, + totalTests: opts?.totalTests, + passedTests: opts?.passedTests, + failedTests: opts?.failedTests, + skippedTests: opts?.skippedTests, + }; +} diff --git a/src/utils/tool-registry.ts b/src/utils/tool-registry.ts index 8a09f693..80a1bcc9 100644 --- a/src/utils/tool-registry.ts +++ b/src/utils/tool-registry.ts @@ -3,7 +3,6 @@ import { server } from '../server/server-state.ts'; import type { ToolResponse } from '../types/common.ts'; import type { ToolCatalog, ToolDefinition } from '../runtime/types.ts'; import { log } from './logger.ts'; -import { processToolResponse } from './responses/index.ts'; import { loadManifest, type ResolvedManifest } from '../core/manifest/load-manifest.ts'; import { importToolModule } from '../core/manifest/import-tool-module.ts'; import { getEffectiveCliName, type WorkflowManifestEntry } from '../core/manifest/schema.ts'; @@ -22,9 +21,7 @@ export interface RuntimeToolInfo { const registryState: { tools: Map; enabledWorkflows: Set; - /** Current MCP predicate context (stored for use by manage_workflows) */ currentContext: PredicateContext | null; - /** Catalog of currently registered MCP tools for next-step template resolution */ catalog: ToolCatalog | null; } = { tools: new Map(), @@ -151,18 +148,10 @@ function defaultPredicateContext(): PredicateContext { }; } -/** - * Get the current MCP predicate context. - * Returns the context used for the most recent workflow registration, - * or a default context if not yet initialized. - */ export function getMcpPredicateContext(): PredicateContext { return registryState.currentContext ?? defaultPredicateContext(); } -/** - * Apply workflow selection using the manifest system. - */ export async function applyWorkflowSelectionFromManifest( requestedWorkflows: string[] | undefined, ctx: PredicateContext, @@ -171,7 +160,6 @@ export async function applyWorkflowSelectionFromManifest( throw new Error('Tool registry has not been initialized.'); } - // Store the context for later use (e.g., by manage_workflows) registryState.currentContext = ctx; const manifest = loadManifest(); @@ -182,12 +170,10 @@ export async function applyWorkflowSelectionFromManifest( } const allWorkflows = [...manifest.workflows.values(), ...customSelection.workflows]; - // Normalize requested workflows for consistent matching const normalizedRequestedWorkflows = requestedWorkflows ?.map(normalizeName) .filter((name) => name.length > 0); - // Select workflows using manifest-driven rules const selectedWorkflows = selectWorkflowsForMcp(allWorkflows, normalizedRequestedWorkflows, ctx); const knownWorkflowIds = new Set(allWorkflows.map((workflow) => workflow.id)); const unknownRequestedWorkflows = (normalizedRequestedWorkflows ?? []).filter( @@ -214,7 +200,6 @@ export async function applyWorkflowSelectionFromManifest( const toolManifest = manifest.tools.get(toolId); if (!toolManifest) continue; - // Check tool visibility using predicates if (!isToolExposedForRuntime(toolManifest, ctx)) { continue; } @@ -279,7 +264,7 @@ export async function applyWorkflowSelectionFromManifest( durationMs: Date.now() - startedAt, }); - return processToolResponse(postProcessedResponse, 'mcp', 'normal'); + return postProcessedResponse; } catch (error) { recordInternalErrorMetric({ component: 'mcp-tool-registry', @@ -304,7 +289,6 @@ export async function applyWorkflowSelectionFromManifest( registryState.catalog = createToolCatalog(catalogTools); - // Unregister tools no longer in selection for (const [toolName, registeredTool] of registryState.tools.entries()) { if (!desiredToolNames.has(toolName)) { registeredTool.remove(); @@ -323,9 +307,6 @@ export async function applyWorkflowSelectionFromManifest( }; } -/** - * Register workflows using manifest system. - */ export async function registerWorkflowsFromManifest( workflowNames?: string[], ctx?: PredicateContext, @@ -333,16 +314,6 @@ export async function registerWorkflowsFromManifest( await applyWorkflowSelectionFromManifest(workflowNames, ctx ?? defaultPredicateContext()); } -/** - * Update workflows using manifest system. - */ -export async function updateWorkflowsFromManifest( - workflowNames?: string[], - ctx?: PredicateContext, -): Promise { - await registerWorkflowsFromManifest(workflowNames, ctx); -} - export function __resetToolRegistryForTests(): void { for (const tool of registryState.tools.values()) { try { diff --git a/src/utils/tool-response.ts b/src/utils/tool-response.ts new file mode 100644 index 00000000..f972f066 --- /dev/null +++ b/src/utils/tool-response.ts @@ -0,0 +1,35 @@ +import type { ToolResponse, NextStepParamsMap } from '../types/common.ts'; +import type { PipelineEvent } from '../types/pipeline-events.ts'; +import { resolveRenderers } from './renderers/index.ts'; + +export interface ToolResponseOptions { + nextStepParams?: NextStepParamsMap; +} + +export function toolResponse(events: PipelineEvent[], options?: ToolResponseOptions): ToolResponse { + const { renderers, mcpRenderer } = resolveRenderers(); + const hasCliRenderer = renderers.length > 1; + + for (const event of events) { + for (const renderer of renderers) { + renderer.onEvent(event); + } + } + + for (const renderer of renderers) { + renderer.finalize(); + } + + const hasError = events.some( + (e) => + (e.type === 'status-line' && e.level === 'error') || + (e.type === 'summary' && e.status === 'FAILED'), + ); + + return { + content: mcpRenderer.getContent(), + isError: hasError || undefined, + nextStepParams: options?.nextStepParams, + ...(hasCliRenderer ? { _meta: { pipelineStreamMode: 'complete' } } : {}), + }; +} diff --git a/src/utils/typed-tool-factory.ts b/src/utils/typed-tool-factory.ts index bb11d106..294e887f 100644 --- a/src/utils/typed-tool-factory.ts +++ b/src/utils/typed-tool-factory.ts @@ -1,19 +1,9 @@ -/** - * Type-safe tool factory for XcodeBuildMCP - * - * This module provides a factory function to create MCP tool handlers that safely - * convert from the generic Record signature required by the MCP SDK - * to strongly-typed parameters using runtime validation with Zod. - * - * This eliminates the need for unsafe type assertions while maintaining full - * compatibility with the MCP SDK's tool handler signature requirements. - */ - import * as z from 'zod'; import type { ToolResponse } from '../types/common.ts'; import type { CommandExecutor } from './execution/index.ts'; -import { createErrorResponse } from './responses/index.ts'; -import { consolidateContentForClaudeCode } from './validation.ts'; +import { toolResponse } from './tool-response.ts'; +import { statusLine } from './tool-event-builders.ts'; + import { sessionStore, type SessionDefaults } from './session-store.ts'; import { isSessionDefaultsOptOutEnabled } from './environment.ts'; import { mergeSessionDefaultArgs } from './session-default-args.ts'; @@ -28,28 +18,18 @@ function createValidatedHandler( const validatedParams = schema.parse(args); const response = await logicFunction(validatedParams, getContext()); - return consolidateContentForClaudeCode(response); + return response; } catch (error) { if (error instanceof z.ZodError) { const details = `Invalid parameters:\n${formatZodIssues(error)}`; - return createErrorResponse('Parameter validation failed', details); + return toolResponse([statusLine('error', `Parameter validation failed: ${details}`)]); } - // Re-throw unexpected errors (they'll be caught by the MCP framework) throw error; } }; } -/** - * Creates a type-safe tool handler that validates parameters at runtime - * before passing them to the typed logic function. - * - * @param schema - Zod schema for parameter validation - * @param logicFunction - The typed logic function to execute - * @param getExecutor - Function to get the command executor (must be provided) - * @returns A handler function compatible with MCP SDK requirements - */ export function createTypedTool( schema: z.ZodType, logicFunction: (params: TParams, executor: CommandExecutor) => Promise, @@ -105,7 +85,7 @@ export function createSessionAwareTool(opts: { logicFunction: (params: TParams, executor: CommandExecutor) => Promise; getExecutor: () => CommandExecutor; requirements?: SessionRequirement[]; - exclusivePairs?: (keyof SessionDefaults)[][]; // when args provide one side, drop conflicting session-default side(s) + exclusivePairs?: (keyof SessionDefaults)[][]; }): (rawArgs: Record) => Promise { return createSessionAwareHandler({ internalSchema: opts.internalSchema, @@ -143,7 +123,6 @@ function createSessionAwareHandler(opts: { return async (rawArgs: Record): Promise => { try { - // Sanitize args: treat null/undefined as "not provided" so they don't override session defaults const sanitizedArgs: Record = {}; for (const [k, v] of Object.entries(rawArgs)) { if (v === null || v === undefined) continue; @@ -151,17 +130,15 @@ function createSessionAwareHandler(opts: { sanitizedArgs[k] = v; } - // Factory-level mutual exclusivity check: if user provides multiple explicit values - // within an exclusive group, reject early even if tool schema doesn't enforce XOR. for (const pair of exclusivePairs) { const provided = pair.filter((k) => Object.prototype.hasOwnProperty.call(sanitizedArgs, k)); if (provided.length >= 2) { - return createErrorResponse( - 'Parameter validation failed', - `Invalid parameters:\nMutually exclusive parameters provided: ${provided.join( - ', ', - )}. Provide only one.`, - ); + return toolResponse([ + statusLine( + 'error', + `Parameter validation failed: Invalid parameters:\nMutually exclusive parameters provided: ${provided.join(', ')}. Provide only one.`, + ), + ]); } } @@ -172,7 +149,6 @@ function createSessionAwareHandler(opts: { exclusivePairs, }); - // Check requirements first (before expensive simulator resolution) for (const req of requirements) { if ('allOf' in req) { const missing = missingFromMerged(req.allOf, merged); @@ -185,7 +161,7 @@ function createSessionAwareHandler(opts: { setHint, optOutEnabled: isSessionDefaultsOptOutEnabled(), }); - return createErrorResponse(title, body); + return toolResponse([statusLine('error', `${title}: ${body}`)]); } } else if ('oneOf' in req) { const satisfied = req.oneOf.some((k) => merged[k] != null); @@ -199,18 +175,18 @@ function createSessionAwareHandler(opts: { setHint: `Set with: ${setHints}`, optOutEnabled: isSessionDefaultsOptOutEnabled(), }); - return createErrorResponse(title, body); + return toolResponse([statusLine('error', `${title}: ${body}`)]); } } } const validated = internalSchema.parse(merged); const response = await logicFunction(validated, getContext()); - return consolidateContentForClaudeCode(response); + return response; } catch (error) { if (error instanceof z.ZodError) { const details = `Invalid parameters:\n${formatZodIssues(error)}`; - return createErrorResponse('Parameter validation failed', details); + return toolResponse([statusLine('error', `Parameter validation failed: ${details}`)]); } throw error; } diff --git a/src/utils/validation.ts b/src/utils/validation.ts index 9d40b9fd..87f1dfb7 100644 --- a/src/utils/validation.ts +++ b/src/utils/validation.ts @@ -1,134 +1,7 @@ -/** - * Validation Utilities - Input validation and error response generation - * - * This utility module provides a comprehensive set of validation functions to ensure - * that tool inputs meet expected requirements. It centralizes validation logic, - * error message formatting, and response generation for consistent error handling - * across the application. - * - * Responsibilities: - * - Validating required parameters (validateRequiredParam) - * - Checking parameters against allowed values (validateAllowedValues, validateEnumParam) - * - Verifying file existence (validateFileExists) - * - Validating logical conditions (validateCondition) - * - Ensuring at least one of multiple parameters is provided (validateAtLeastOneParam) - * - Creating standardized response objects for tools (createTextResponse) - * - * Using these validation utilities ensures consistent error messaging and helps - * provide clear feedback to users when their inputs don't meet requirements. - * The functions return ValidationResult objects that make it easy to chain - * validations and generate appropriate responses. - */ - import * as fs from 'fs'; -import { log } from './logger.ts'; -import type { ToolResponse, ValidationResult } from '../types/common.ts'; +import type { ValidationResult } from '../types/common.ts'; import type { FileSystemExecutor } from './FileSystemExecutor.ts'; -import { getDefaultEnvironmentDetector } from './environment.ts'; -import type { EnvironmentDetector } from './environment.ts'; - -/** - * Creates a text response with the given message - * @param message The message to include in the response - * @param isError Whether this is an error response - * @returns A ToolResponse object with the message - */ -export function createTextResponse(message: string, isError = false): ToolResponse { - return { - content: [ - { - type: 'text', - text: message, - }, - ], - isError, - }; -} - -/** - * Validates that a required parameter is present - * @param paramName Name of the parameter - * @param paramValue Value of the parameter - * @param helpfulMessage Optional helpful message to include in the error response - * @returns Validation result - */ -export function validateRequiredParam( - paramName: string, - paramValue: unknown, - helpfulMessage = `Required parameter '${paramName}' is missing. Please provide a value for this parameter.`, -): ValidationResult { - if (paramValue === undefined || paramValue === null) { - log('warn', `Required parameter '${paramName}' is missing`); - return { - isValid: false, - errorResponse: createTextResponse(helpfulMessage, true), - }; - } - - return { isValid: true }; -} - -/** - * Validates that a parameter value is one of the allowed values - * @param paramName Name of the parameter - * @param paramValue Value of the parameter - * @param allowedValues Array of allowed values - * @returns Validation result - */ -export function validateAllowedValues( - paramName: string, - paramValue: T, - allowedValues: T[], -): ValidationResult { - if (!allowedValues.includes(paramValue)) { - log( - 'warn', - `Parameter '${paramName}' has invalid value '${paramValue}'. Allowed values: ${allowedValues.join( - ', ', - )}`, - ); - return { - isValid: false, - errorResponse: createTextResponse( - `Parameter '${paramName}' must be one of: ${allowedValues.join(', ')}. You provided: '${paramValue}'.`, - true, - ), - }; - } - - return { isValid: true }; -} - -/** - * Validates that a condition is true - * @param condition Condition to validate - * @param message Message to include in the warning response - * @param logWarning Whether to log a warning message - * @returns Validation result - */ -export function validateCondition( - condition: boolean, - message: string, - logWarning: boolean = true, -): ValidationResult { - if (!condition) { - if (logWarning) { - log('warn', message); - } - return { - isValid: false, - warningResponse: createTextResponse(message), - }; - } - - return { isValid: true }; -} -/** - * Validates that a file exists - * @param filePath Path to check - * @returns Validation result - */ export function validateFileExists( filePath: string, fileSystem?: FileSystemExecutor, @@ -137,132 +10,9 @@ export function validateFileExists( if (!exists) { return { isValid: false, - errorResponse: createTextResponse( - `File not found: '${filePath}'. Please check the path and try again.`, - true, - ), + errorMessage: `File not found: '${filePath}'. Please check the path and try again.`, }; } return { isValid: true }; } - -/** - * Validates that at least one of two parameters is provided - * @param param1Name Name of the first parameter - * @param param1Value Value of the first parameter - * @param param2Name Name of the second parameter - * @param param2Value Value of the second parameter - * @returns Validation result - */ -export function validateAtLeastOneParam( - param1Name: string, - param1Value: unknown, - param2Name: string, - param2Value: unknown, -): ValidationResult { - if ( - (param1Value === undefined || param1Value === null) && - (param2Value === undefined || param2Value === null) - ) { - log('warn', `At least one of '${param1Name}' or '${param2Name}' must be provided`); - return { - isValid: false, - errorResponse: createTextResponse( - `At least one of '${param1Name}' or '${param2Name}' must be provided.`, - true, - ), - }; - } - - return { isValid: true }; -} - -/** - * Validates that a parameter value is one of the allowed enum values - * @param paramName Name of the parameter - * @param paramValue Value of the parameter - * @param allowedValues Array of allowed enum values - * @returns Validation result - */ -export function validateEnumParam( - paramName: string, - paramValue: T, - allowedValues: T[], -): ValidationResult { - if (!allowedValues.includes(paramValue)) { - log( - 'warn', - `Parameter '${paramName}' has invalid value '${paramValue}'. Allowed values: ${allowedValues.join( - ', ', - )}`, - ); - return { - isValid: false, - errorResponse: createTextResponse( - `Parameter '${paramName}' must be one of: ${allowedValues.join(', ')}. You provided: '${paramValue}'.`, - true, - ), - }; - } - - return { isValid: true }; -} - -/** - * Consolidates multiple content blocks into a single text response for Claude Code compatibility - * - * Claude Code violates the MCP specification by only showing the first content block. - * This function provides a workaround by concatenating all text content into a single block. - * Detection is automatic - no environment variable configuration required. - * - * @param response The original ToolResponse with multiple content blocks - * @returns A new ToolResponse with consolidated content - */ -export function consolidateContentForClaudeCode( - response: ToolResponse, - detector?: EnvironmentDetector, -): ToolResponse { - // Automatically detect if running under Claude Code - const shouldConsolidate = ( - detector ?? getDefaultEnvironmentDetector() - ).isRunningUnderClaudeCode(); - - if (!shouldConsolidate || !response.content || response.content.length <= 1) { - return response; - } - - // Extract all text content and concatenate with separators - const textParts: string[] = []; - - response.content.forEach((item, index) => { - if (item.type === 'text') { - // Add a separator between content blocks (except for the first one) - if (index > 0 && textParts.length > 0) { - textParts.push('\n---\n'); - } - textParts.push(item.text); - } - // Note: Image content is not handled in this workaround as it requires special formatting - }); - - // If no text content was found, return the original response to preserve non-text content - if (textParts.length === 0) { - return response; - } - - const consolidatedText = textParts.join(''); - - return { - ...response, - content: [ - { - type: 'text', - text: consolidatedText, - }, - ], - }; -} - -// Export the ToolResponse type for use in other files -export type { ToolResponse, ValidationResult }; diff --git a/src/utils/validation/index.ts b/src/utils/validation/index.ts index 8b1303dd..a5f99827 100644 --- a/src/utils/validation/index.ts +++ b/src/utils/validation/index.ts @@ -1,5 +1 @@ -/** - * 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 index dc1de3da..2e007146 100644 --- a/src/utils/workflow-selection.ts +++ b/src/utils/workflow-selection.ts @@ -24,14 +24,7 @@ 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( +function resolveSelectedWorkflowNames( workflowNames: WorkflowName[] = [], availableWorkflowNames: WorkflowName[] = [], ): { @@ -49,11 +42,9 @@ export function resolveSelectedWorkflowNames( 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), ); @@ -61,14 +52,6 @@ export function resolveSelectedWorkflowNames( 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, @@ -86,17 +69,3 @@ export function resolveSelectedWorkflows( 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/xcode-state-reader.ts b/src/utils/xcode-state-reader.ts index 16405e81..75b92595 100644 --- a/src/utils/xcode-state-reader.ts +++ b/src/utils/xcode-state-reader.ts @@ -1,13 +1,3 @@ -/** - * Xcode IDE State Reader - * - * Reads Xcode's UserInterfaceState.xcuserstate file to extract the currently - * selected scheme and run destination (simulator/device). - * - * This enables XcodeBuildMCP to auto-sync with Xcode's IDE selection when - * running under Xcode's coding agent. - */ - import { dirname, resolve, sep } from 'node:path'; import { log } from './logger.ts'; import { parseXcuserstate } from './nskeyedarchiver-parser.ts'; @@ -23,27 +13,11 @@ export interface XcodeStateResult { export interface XcodeStateReaderContext { executor: CommandExecutor; cwd: string; - /** Optional boundary for parent-directory fallback search (typically workspace root) */ searchRoot?: string; - /** Optional pre-configured workspace path to use directly */ workspacePath?: string; - /** Optional pre-configured project path to use directly */ projectPath?: string; } -/** - * Finds the UserInterfaceState.xcuserstate file for the workspace/project. - * - * Search order: - * 1. Use configured workspacePath/projectPath if provided - * 2. Search for .xcworkspace/.xcodeproj under cwd - * 3. If none (or to broaden candidates), search direct children of parent directories - * up to searchRoot (workspace boundary) - * - * For each found project: - * - .xcworkspace: /xcuserdata/.xcuserdatad/UserInterfaceState.xcuserstate - * - .xcodeproj: /project.xcworkspace/xcuserdata/.xcuserdatad/UserInterfaceState.xcuserstate - */ function buildFindProjectsCommand(root: string, maxDepth: number): string[] { return [ 'find', @@ -110,7 +84,6 @@ export async function findXcodeStateFile( ): Promise { const { executor, cwd, searchRoot, workspacePath, projectPath } = ctx; - // Get current username const userResult = await executor(['whoami'], 'Get username', false); if (!userResult.success) { log('warn', `[xcode-state] Failed to get username: ${userResult.error}`); @@ -118,7 +91,6 @@ export async function findXcodeStateFile( } const username = userResult.output.trim(); - // If workspacePath or projectPath is configured, use it directly if (workspacePath || projectPath) { const basePath = workspacePath ?? projectPath; const xcuserstatePath = buildXcuserstatePath(basePath!, username); @@ -136,7 +108,6 @@ export async function findXcodeStateFile( const discoveredPaths = new Set(); - // Search descendants from cwd with increased depth (projects can be nested deeper). const descendantsResult = await executor( buildFindProjectsCommand(cwd, 6), 'Find Xcode project/workspace in cwd descendants', @@ -148,9 +119,6 @@ export async function findXcodeStateFile( } } - // Also search direct children of parent directories to support nested cwd usage. - // Example: cwd=/repo/feature/subdir, project=/repo/App.xcodeproj - // Parent traversal stops at searchRoot (workspace boundary). const parentSearchBoundary = searchRoot ?? cwd; for (const parentDir of listParentDirectories(cwd, parentSearchBoundary)) { const parentResult = await executor( @@ -176,11 +144,9 @@ export async function findXcodeStateFile( const paths = [...discoveredPaths]; - // Filter out nested workspaces inside xcodeproj and sort const filteredPaths = paths .filter((p) => !p.includes('.xcodeproj/project.xcworkspace')) .sort((a, b) => { - // Prefer .xcworkspace over .xcodeproj const aIsWorkspace = a.endsWith('.xcworkspace'); const bIsWorkspace = b.endsWith('.xcworkspace'); if (aIsWorkspace && !bIsWorkspace) return -1; @@ -188,13 +154,10 @@ export async function findXcodeStateFile( return 0; }); - // Collect all candidate xcuserstate files with their mtimes const candidates: Array<{ path: string; mtime: number }> = []; for (const projectPath of filteredPaths) { const xcuserstatePath = buildXcuserstatePath(projectPath, username); - - // Check if file exists and get mtime const statResult = await executor( ['stat', '-f', '%m', xcuserstatePath], 'Get xcuserstate mtime', @@ -212,7 +175,6 @@ export async function findXcodeStateFile( return undefined; } - // If multiple candidates, pick the one with the newest mtime (most recently active) if (candidates.length > 1) { candidates.sort((a, b) => b.mtime - a.mtime); log( @@ -225,21 +187,13 @@ export async function findXcodeStateFile( return candidates[0].path; } -/** - * Builds the path to the xcuserstate file for a given project/workspace path. - */ function buildXcuserstatePath(projectPath: string, username: string): string { - if (projectPath.endsWith('.xcworkspace')) { - return `${projectPath}/xcuserdata/${username}.xcuserdatad/UserInterfaceState.xcuserstate`; - } else { - // .xcodeproj - look in embedded workspace - return `${projectPath}/project.xcworkspace/xcuserdata/${username}.xcuserdatad/UserInterfaceState.xcuserstate`; - } + const base = projectPath.endsWith('.xcworkspace') + ? projectPath + : `${projectPath}/project.xcworkspace`; + return `${base}/xcuserdata/${username}.xcuserdatad/UserInterfaceState.xcuserstate`; } -/** - * Looks up a simulator name by its UUID. - */ export async function lookupSimulatorName( ctx: XcodeStateReaderContext, simulatorId: string, @@ -276,26 +230,13 @@ export async function lookupSimulatorName( return undefined; } -/** - * Reads Xcode's IDE state and extracts the active scheme and simulator. - * - * Uses bplist-parser for robust binary plist parsing of the xcuserstate file, - * navigating the NSKeyedArchiver object graph to extract: - * - ActiveScheme -> IDENameString (scheme name) - * - ActiveRunDestination -> targetDeviceLocation (simulator/device UUID) - * - * @param ctx Context with command executor and working directory - * @returns The extracted Xcode state or an error - */ export async function readXcodeIdeState(ctx: XcodeStateReaderContext): Promise { try { - // Find the xcuserstate file const xcuserstatePath = await findXcodeStateFile(ctx); if (!xcuserstatePath) { return { error: 'No Xcode project/workspace found in working directory' }; } - // Parse the state file using bplist-parser const state = parseXcuserstate(xcuserstatePath); const result: XcodeStateResult = {}; @@ -308,7 +249,6 @@ export async function readXcodeIdeState(ctx: XcodeStateReaderContext): Promise !NOISE_PATTERNS.some((pattern) => pattern.test(line.trim()))) diff --git a/src/utils/xcodebuild-event-parser.ts b/src/utils/xcodebuild-event-parser.ts index c7585b78..1194128d 100644 --- a/src/utils/xcodebuild-event-parser.ts +++ b/src/utils/xcodebuild-event-parser.ts @@ -1,8 +1,8 @@ import type { XcodebuildOperation, - XcodebuildEvent, + PipelineEvent, XcodebuildStage, -} from '../types/xcodebuild-events.ts'; +} from '../types/pipeline-events.ts'; import { packageResolutionPatterns, compilePatterns, @@ -62,7 +62,7 @@ function now(): string { export interface EventParserOptions { operation: XcodebuildOperation; - onEvent: (event: XcodebuildEvent) => void; + onEvent: (event: PipelineEvent) => void; } export interface XcodebuildEventParser { @@ -92,7 +92,7 @@ export function createXcodebuildEventParser(options: EventParserOptions): Xcodeb return; } onEvent({ - type: 'error', + type: 'compiler-error', timestamp: pendingError.timestamp, operation, message: pendingError.message, @@ -109,7 +109,6 @@ export function createXcodebuildEventParser(options: EventParserOptions): Xcodeb return; } - // Indented lines following a build error are continuations if (pendingError && /^\s/u.test(rawLine)) { pendingError.message += `\n${line}`; pendingError.rawLines.push(rawLine); @@ -178,7 +177,7 @@ export function createXcodebuildEventParser(options: EventParserOptions): Xcodeb const stage = resolveStageFromLine(line); if (stage) { onEvent({ - type: 'status', + type: 'build-stage', timestamp: now(), operation, stage, @@ -201,7 +200,7 @@ export function createXcodebuildEventParser(options: EventParserOptions): Xcodeb const warning = parseWarningLine(line); if (warning) { onEvent({ - type: 'warning', + type: 'compiler-warning', timestamp: now(), operation, message: warning.message, @@ -211,37 +210,27 @@ export function createXcodebuildEventParser(options: EventParserOptions): Xcodeb return; } - // Skip known noise lines if (/^Test Suite /u.test(line)) { return; } } - function drainBuffer(chunk: string, source: 'stdout' | 'stderr'): void { - if (source === 'stdout') { - stdoutBuffer += chunk; - const lines = stdoutBuffer.split(/\r?\n/u); - stdoutBuffer = lines.pop() ?? ''; - for (const line of lines) { - processLine(line); - } - return; - } - - stderrBuffer += chunk; - const lines = stderrBuffer.split(/\r?\n/u); - stderrBuffer = lines.pop() ?? ''; + function drainLines(buffer: string, chunk: string): string { + const combined = buffer + chunk; + const lines = combined.split(/\r?\n/u); + const remainder = lines.pop() ?? ''; for (const line of lines) { processLine(line); } + return remainder; } return { onStdout(chunk: string): void { - drainBuffer(chunk, 'stdout'); + stdoutBuffer = drainLines(stdoutBuffer, chunk); }, onStderr(chunk: string): void { - drainBuffer(chunk, 'stderr'); + stderrBuffer = drainLines(stderrBuffer, chunk); }, flush(): void { if (stdoutBuffer.trim()) { diff --git a/src/utils/xcodebuild-output.ts b/src/utils/xcodebuild-output.ts index 856441ca..c675c3b6 100644 --- a/src/utils/xcodebuild-output.ts +++ b/src/utils/xcodebuild-output.ts @@ -4,16 +4,16 @@ import type { BuildRunStepNoticeData, NoticeCode, NoticeLevel, - XcodebuildEvent, + PipelineEvent, XcodebuildOperation, -} from '../types/xcodebuild-events.ts'; +} from '../types/pipeline-events.ts'; import type { StartedPipeline } from './xcodebuild-pipeline.ts'; -export interface PipelineOutputMetaExtras { +interface PipelineOutputMetaExtras { [key: string]: unknown; } -export type XcodebuildStreamMode = 'complete' | 'legacy'; +type XcodebuildStreamMode = 'complete' | 'legacy'; interface PendingXcodebuildState { kind: 'pending-xcodebuild'; @@ -21,12 +21,12 @@ interface PendingXcodebuildState { emitSummary: boolean; extras: PipelineOutputMetaExtras; fallbackContent: ToolResponseContent[]; - tailEvents: XcodebuildEvent[]; + tailEvents: PipelineEvent[]; errorFallbackPolicy: ErrorFallbackPolicy; } -export function createPipelineOutputMeta( - events: XcodebuildEvent[], +function createPipelineOutputMeta( + events: PipelineEvent[], streamedContentCount: number, extras: PipelineOutputMetaExtras = {}, streamMode: XcodebuildStreamMode = 'legacy', @@ -40,12 +40,12 @@ export function createPipelineOutputMeta( }; } -export function createStructuredErrorEvent( +function createStructuredErrorEvent( operation: XcodebuildOperation, message: string, -): XcodebuildEvent { +): PipelineEvent { return { - type: 'error', + type: 'compiler-error', timestamp: new Date().toISOString(), operation, message, @@ -53,6 +53,33 @@ export function createStructuredErrorEvent( }; } +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 mapNoticeLevelToStatusLineLevel( + level: NoticeLevel, +): 'success' | 'error' | 'info' | 'warning' { + if (level === 'success') return 'success'; + if (level === 'warning') return 'warning'; + return 'info'; +} + export function createNoticeEvent( operation: XcodebuildOperation, message: string, @@ -64,19 +91,66 @@ export function createNoticeEvent( | BuildRunStepNoticeData | BuildRunResultNoticeData; } = {}, -): XcodebuildEvent { +): 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, + }; + } + return { - type: 'notice', + type: 'status-line', timestamp: new Date().toISOString(), - operation, - level, + level: mapNoticeLevelToStatusLineLevel(level), message, - code: options.code, - data: options.data, }; } -export function createNextStepsEvent(steps: NextStep[]): XcodebuildEvent | null { +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: 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.launchState !== 'requested') { + items.push({ label: 'Launch', value: 'Running' }); + } + + events.push({ + type: 'detail-tree', + timestamp: new Date().toISOString(), + items, + }); + + return events; +} + +function createNextStepsEvent(steps: NextStep[]): PipelineEvent | null { if (steps.length === 0) { return null; } @@ -84,33 +158,16 @@ export function createNextStepsEvent(steps: NextStep[]): XcodebuildEvent | null return { type: 'next-steps', timestamp: new Date().toISOString(), - steps: steps.map((step) => ({ - label: step.label, - tool: step.tool, - workflow: step.workflow, - cliTool: step.cliTool, - params: step.params, + steps: steps.map(({ label, tool, workflow, cliTool, params }) => ({ + label, + tool, + workflow, + cliTool, + params, })), }; } -export function appendStructuredEvents( - response: ToolResponse, - extraEvents: XcodebuildEvent[], -): ToolResponse { - const existingEvents = Array.isArray(response._meta?.events) - ? (response._meta.events as XcodebuildEvent[]) - : []; - - return { - ...response, - _meta: { - ...(response._meta ?? {}), - events: [...existingEvents, ...extraEvents], - }, - }; -} - export function emitPipelineNotice( started: StartedPipeline, operation: XcodebuildOperation, @@ -124,6 +181,13 @@ export function emitPipelineNotice( | 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)); } @@ -135,12 +199,12 @@ export function emitPipelineError( started.pipeline.emitEvent(createStructuredErrorEvent(operation, message)); } -export type ErrorFallbackPolicy = 'always' | 'if-no-structured-diagnostics'; +type ErrorFallbackPolicy = 'always' | 'if-no-structured-diagnostics'; -export interface PendingXcodebuildResponseOptions { +interface PendingXcodebuildResponseOptions { extras?: PipelineOutputMetaExtras; emitSummary?: boolean; - tailEvents?: XcodebuildEvent[]; + tailEvents?: PipelineEvent[]; errorFallbackPolicy?: ErrorFallbackPolicy; } @@ -168,13 +232,11 @@ export function createPendingXcodebuildResponse( } export function isPendingXcodebuildResponse(response: ToolResponse): boolean { + const pending = response._meta?.pendingXcodebuild; return ( - typeof response._meta === 'object' && - response._meta !== null && - 'pendingXcodebuild' in response._meta && - typeof response._meta.pendingXcodebuild === 'object' && - response._meta.pendingXcodebuild !== null && - (response._meta.pendingXcodebuild as PendingXcodebuildState).kind === 'pending-xcodebuild' + typeof pending === 'object' && + pending !== null && + (pending as PendingXcodebuildState).kind === 'pending-xcodebuild' ); } diff --git a/src/utils/xcodebuild-pipeline.ts b/src/utils/xcodebuild-pipeline.ts index 1e58ddec..31f1cea6 100644 --- a/src/utils/xcodebuild-pipeline.ts +++ b/src/utils/xcodebuild-pipeline.ts @@ -1,18 +1,15 @@ import type { XcodebuildOperation, XcodebuildStage, - XcodebuildEvent, -} from '../types/xcodebuild-events.ts'; + PipelineEvent, +} from '../types/pipeline-events.ts'; import type { ToolResponseContent } from '../types/common.ts'; import { createXcodebuildEventParser } from './xcodebuild-event-parser.ts'; import { createXcodebuildRunState } from './xcodebuild-run-state.ts'; import type { XcodebuildRunState } from './xcodebuild-run-state.ts'; -import { - createMcpRenderer, - createCliTextRenderer, - createCliJsonlRenderer, -} from './renderers/index.ts'; +import { resolveRenderers } from './renderers/index.ts'; import type { XcodebuildRenderer } from './renderers/index.ts'; +import { displayPath } from './build-preflight.ts'; export interface PipelineOptions { operation: XcodebuildOperation; @@ -24,18 +21,18 @@ export interface PipelineOptions { export interface PipelineResult { state: XcodebuildRunState; mcpContent: ToolResponseContent[]; - events: XcodebuildEvent[]; + events: PipelineEvent[]; } export interface PipelineFinalizeOptions { emitSummary?: boolean; - tailEvents?: XcodebuildEvent[]; + tailEvents?: PipelineEvent[]; } export interface XcodebuildPipeline { onStdout(chunk: string): void; onStderr(chunk: string): void; - emitEvent(event: XcodebuildEvent): void; + emitEvent(event: PipelineEvent): void; finalize( succeeded: boolean, durationMs?: number, @@ -44,34 +41,51 @@ export interface XcodebuildPipeline { highestStageRank(): number; } -function resolveRenderers(): { - renderers: XcodebuildRenderer[]; - mcpRenderer: ReturnType; -} { - const mcpRenderer = createMcpRenderer(); - const renderers: XcodebuildRenderer[] = [mcpRenderer]; - - const runtime = process.env.XCODEBUILDMCP_RUNTIME; - const outputFormat = process.env.XCODEBUILDMCP_CLI_OUTPUT_FORMAT; - - if (runtime === 'cli') { - if (outputFormat === 'json') { - renderers.push(createCliJsonlRenderer()); - } else { - renderers.push(createCliTextRenderer({ interactive: process.stdout.isTTY === true })); - } - } - - return { renderers, mcpRenderer }; -} - export interface StartedPipeline { pipeline: XcodebuildPipeline; startedAt: number; } +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', + xcresultPath: 'xcresult', + file: 'File', + targetFilter: 'Target Filter', + }; + + const pathKeys = new Set(['workspacePath', 'projectPath', '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; + } + const displayValue = pathKeys.has(key) ? displayPath(value) : value; + result.push({ label, value: displayValue }); + } + } + + return result; +} + /** - * Creates a pipeline, emits the initial 'start' event, and captures the start + * 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. */ @@ -81,12 +95,13 @@ export function startBuildPipeline( const pipeline = createXcodebuildPipeline(options); pipeline.emitEvent({ - type: 'start', + type: 'header', timestamp: new Date().toISOString(), - operation: options.operation, - toolName: options.toolName, - params: options.params, - message: options.message, + operation: options.message + .replace(/^[^\p{L}]+/u, '') + .split('\n')[0] + .trim(), + params: buildHeaderParams(options.params), }); return { pipeline, startedAt: Date.now() }; @@ -98,7 +113,7 @@ export function createXcodebuildPipeline(options: PipelineOptions): XcodebuildPi const runState = createXcodebuildRunState({ operation: options.operation, minimumStage: options.minimumStage, - onEvent: (event: XcodebuildEvent) => { + onEvent: (event: PipelineEvent) => { for (const renderer of renderers) { renderer.onEvent(event); } @@ -107,7 +122,7 @@ export function createXcodebuildPipeline(options: PipelineOptions): XcodebuildPi const parser = createXcodebuildEventParser({ operation: options.operation, - onEvent: (event: XcodebuildEvent) => { + onEvent: (event: PipelineEvent) => { runState.push(event); }, }); @@ -121,7 +136,7 @@ export function createXcodebuildPipeline(options: PipelineOptions): XcodebuildPi parser.onStderr(chunk); }, - emitEvent(event: XcodebuildEvent): void { + emitEvent(event: PipelineEvent): void { runState.push(event); }, diff --git a/src/utils/xcodebuild-run-state.ts b/src/utils/xcodebuild-run-state.ts index b3b45ac9..31db5538 100644 --- a/src/utils/xcodebuild-run-state.ts +++ b/src/utils/xcodebuild-run-state.ts @@ -1,33 +1,33 @@ import type { XcodebuildOperation, XcodebuildStage, - XcodebuildEvent, - StatusEvent, - WarningEvent, - ErrorEvent, + PipelineEvent, + BuildStageEvent, + CompilerWarningEvent, + CompilerErrorEvent, TestFailureEvent, -} from '../types/xcodebuild-events.ts'; -import { STAGE_RANK } from '../types/xcodebuild-events.ts'; +} from '../types/pipeline-events.ts'; +import { STAGE_RANK } from '../types/pipeline-events.ts'; export interface XcodebuildRunState { operation: XcodebuildOperation; currentStage: XcodebuildStage | null; - milestones: StatusEvent[]; - warnings: WarningEvent[]; - errors: ErrorEvent[]; + milestones: BuildStageEvent[]; + warnings: CompilerWarningEvent[]; + errors: CompilerErrorEvent[]; testFailures: TestFailureEvent[]; completedTests: number; failedTests: number; skippedTests: number; finalStatus: 'SUCCEEDED' | 'FAILED' | null; wallClockDurationMs: number | null; - events: XcodebuildEvent[]; + events: PipelineEvent[]; } export interface RunStateOptions { operation: XcodebuildOperation; minimumStage?: XcodebuildStage; - onEvent?: (event: XcodebuildEvent) => void; + onEvent?: (event: PipelineEvent) => void; } function normalizeDiagnosticKey(location: string | undefined, message: string): string { @@ -36,11 +36,11 @@ function normalizeDiagnosticKey(location: string | undefined, message: string): export interface FinalizeOptions { emitSummary?: boolean; - tailEvents?: XcodebuildEvent[]; + tailEvents?: PipelineEvent[]; } export interface XcodebuildRunStateHandle { - push(event: XcodebuildEvent): void; + push(event: PipelineEvent): void; finalize(succeeded: boolean, durationMs?: number, options?: FinalizeOptions): XcodebuildRunState; snapshot(): Readonly; highestStageRank(): number; @@ -67,15 +67,28 @@ export function createXcodebuildRunState(options: RunStateOptions): XcodebuildRu let highestRank = options.minimumStage !== undefined ? STAGE_RANK[options.minimumStage] : -1; const seenDiagnostics = new Set(); - function accept(event: XcodebuildEvent): void { + function accept(event: PipelineEvent): void { state.events.push(event); onEvent?.(event); } + function acceptDedupedDiagnostic( + event: PipelineEvent & T, + collection: T[], + ): void { + const key = normalizeDiagnosticKey(event.location, event.message); + if (seenDiagnostics.has(key)) { + return; + } + seenDiagnostics.add(key); + collection.push(event); + accept(event); + } + return { - push(event: XcodebuildEvent): void { + push(event: PipelineEvent): void { switch (event.type) { - case 'status': { + case 'build-stage': { const rank = STAGE_RANK[event.stage]; if (rank <= highestRank) { return; @@ -87,36 +100,18 @@ export function createXcodebuildRunState(options: RunStateOptions): XcodebuildRu break; } - case 'warning': { - const key = normalizeDiagnosticKey(event.location, event.message); - if (seenDiagnostics.has(key)) { - return; - } - seenDiagnostics.add(key); - state.warnings.push(event); - accept(event); + case 'compiler-warning': { + acceptDedupedDiagnostic(event, state.warnings); break; } - case 'error': { - const key = normalizeDiagnosticKey(event.location, event.message); - if (seenDiagnostics.has(key)) { - return; - } - seenDiagnostics.add(key); - state.errors.push(event); - accept(event); + case 'compiler-error': { + acceptDedupedDiagnostic(event, state.errors); break; } case 'test-failure': { - const key = normalizeDiagnosticKey(event.location, event.message); - if (seenDiagnostics.has(key)) { - return; - } - seenDiagnostics.add(key); - state.testFailures.push(event); - accept(event); + acceptDedupedDiagnostic(event, state.testFailures); break; } @@ -125,10 +120,9 @@ export function createXcodebuildRunState(options: RunStateOptions): XcodebuildRu state.failedTests = event.failed; state.skippedTests = event.skipped; - // Ensure RUN_TESTS milestone when we see test progress if (highestRank < STAGE_RANK.RUN_TESTS) { - const runTestsEvent: StatusEvent = { - type: 'status', + const runTestsEvent: BuildStageEvent = { + type: 'build-stage', timestamp: event.timestamp, operation: 'TEST', stage: 'RUN_TESTS', @@ -144,8 +138,12 @@ export function createXcodebuildRunState(options: RunStateOptions): XcodebuildRu break; } - case 'start': - case 'notice': + case 'header': + case 'status-line': + case 'section': + case 'detail-tree': + case 'table': + case 'file-ref': case 'test-discovery': case 'summary': case 'next-steps': { @@ -164,7 +162,7 @@ export function createXcodebuildRunState(options: RunStateOptions): XcodebuildRu state.wallClockDurationMs = durationMs ?? null; if (options?.emitSummary !== false) { - const summaryEvent: XcodebuildEvent = { + const summaryEvent: PipelineEvent = { type: 'summary', timestamp: new Date().toISOString(), operation, diff --git a/src/utils/xcodemake.ts b/src/utils/xcodemake.ts index 0cb03eff..cb0c22cb 100644 --- a/src/utils/xcodemake.ts +++ b/src/utils/xcodemake.ts @@ -1,18 +1,3 @@ -/** - * xcodemake Utilities - Support for using xcodemake as an alternative build strategy - * - * This utility module provides functions for using xcodemake (https://github.com/johnno1962/xcodemake) - * as an alternative build strategy for Xcode projects. xcodemake logs xcodebuild output to generate - * a Makefile for an Xcode project, allowing for faster incremental builds using the "make" command. - * - * Responsibilities: - * - Checking if xcodemake is enabled via environment variable - * - Executing xcodemake commands with proper argument handling - * - Converting xcodebuild arguments to xcodemake arguments - * - Handling xcodemake-specific output and error reporting - * - Auto-downloading xcodemake if enabled but not found - */ - import { log } from './logger.ts'; import type { CommandResponse } from './command.ts'; import { getDefaultCommandExecutor } from './command.ts'; @@ -22,24 +7,12 @@ import * as os from 'os'; import * as fs from 'fs/promises'; import { getConfig } from './config-store.ts'; -// Environment variable to control xcodemake usage -export const XCODEMAKE_ENV_VAR = 'INCREMENTAL_BUILDS_ENABLED'; - -// Store the overridden path for xcodemake if needed let overriddenXcodemakePath: string | null = null; -/** - * Check if xcodemake is enabled via environment variable - * @returns boolean indicating if xcodemake should be used - */ export function isXcodemakeEnabled(): boolean { return getConfig().incrementalBuildsEnabled; } -/** - * Get the xcodemake command to use - * @returns The command string for xcodemake - */ function getXcodemakeCommand(): string { return overriddenXcodemakePath ?? 'xcodemake'; } @@ -53,10 +26,6 @@ function isExecutable(path: string): boolean { } } -/** - * Check whether xcodemake binary is currently resolvable without side effects. - * This does not attempt download or installation. - */ export function isXcodemakeBinaryAvailable(): boolean { if (overriddenXcodemakePath && isExecutable(overriddenXcodemakePath)) { return true; @@ -74,19 +43,11 @@ export function isXcodemakeBinaryAvailable(): boolean { return false; } -/** - * Override the xcodemake command path - * @param path Path to the xcodemake executable - */ function overrideXcodemakeCommand(path: string): void { overriddenXcodemakePath = path; log('info', `Using overridden xcodemake path: ${path}`); } -/** - * Install xcodemake by downloading it from GitHub - * @returns Promise resolving to boolean indicating if installation was successful - */ async function installXcodemake(): Promise { const tempDir = os.tmpdir(); const xcodemakeDir = path.join(tempDir, 'xcodebuildmcp'); @@ -95,10 +56,8 @@ async function installXcodemake(): Promise { log('info', `Attempting to install xcodemake to ${xcodemakePath}`); try { - // Create directory if it doesn't exist await fs.mkdir(xcodemakeDir, { recursive: true }); - // Download the script log('info', 'Downloading xcodemake from GitHub...'); const response = await fetch( 'https://raw.githubusercontent.com/cameroncooke/xcodemake/main/xcodemake', @@ -111,11 +70,9 @@ async function installXcodemake(): Promise { const scriptContent = await response.text(); await fs.writeFile(xcodemakePath, scriptContent, 'utf8'); - // Make executable await fs.chmod(xcodemakePath, 0o755); log('info', 'Made xcodemake executable'); - // Override the command to use the direct path overrideXcodemakeCommand(xcodemakePath); return true; @@ -128,25 +85,18 @@ async function installXcodemake(): Promise { } } -/** - * Check if xcodemake is installed and available. If enabled but not available, attempts to download it. - * @returns Promise resolving to boolean indicating if xcodemake is available - */ export async function isXcodemakeAvailable(): Promise { - // First check if xcodemake is enabled, if not, no need to check or install if (!isXcodemakeEnabled()) { log('debug', 'xcodemake is not enabled, skipping availability check'); return false; } try { - // Check if we already have an overridden path if (overriddenXcodemakePath && existsSync(overriddenXcodemakePath)) { log('debug', `xcodemake found at overridden path: ${overriddenXcodemakePath}`); return true; } - // Check if xcodemake is available in PATH const result = await getDefaultCommandExecutor()(['which', 'xcodemake']); if (result.success) { log('debug', 'xcodemake found in PATH'); @@ -171,28 +121,16 @@ export async function isXcodemakeAvailable(): Promise { } } -/** - * Check if a Makefile exists in the current directory - * @returns boolean indicating if a Makefile exists - */ export function doesMakefileExist(projectDir: string): boolean { return existsSync(`${projectDir}/Makefile`); } -/** - * Check if a Makefile log exists in the current directory - * @param projectDir Directory containing the Makefile - * @param command Command array to check for log file - * @returns boolean indicating if a Makefile log exists - */ export function doesMakeLogFileExist(projectDir: string, command: string[]): boolean { - // Change to the project directory as xcodemake requires being in the project dir const originalDir = process.cwd(); try { process.chdir(projectDir); - // Construct the expected log filename const xcodemakeCommand = ['xcodemake', ...command.slice(1)]; const escapedCommand = xcodemakeCommand.map((arg) => { // Remove projectDir from arguments if present at the start @@ -206,38 +144,27 @@ export function doesMakeLogFileExist(projectDir: string, command: string[]): boo const logFileName = `${commandString}.log`; log('debug', `Checking for Makefile log: ${logFileName} in directory: ${process.cwd()}`); - // Read directory contents and check if the file exists const files = readdirSync('.'); const exists = files.includes(logFileName); log('debug', `Makefile log ${exists ? 'exists' : 'does not exist'}: ${logFileName}`); return exists; } catch (error) { - // Log potential errors like directory not found, permissions issues, etc. log( 'error', `Error checking for Makefile log: ${error instanceof Error ? error.message : String(error)}`, ); return false; } finally { - // Always restore the original directory process.chdir(originalDir); } } -/** - * Execute an xcodemake command to generate a Makefile - * @param buildArgs Build arguments to pass to xcodemake (without the 'xcodebuild' command) - * @param logPrefix Prefix for logging - * @returns Promise resolving to command response - */ export async function executeXcodemakeCommand( projectDir: string, buildArgs: string[], logPrefix: string, ): Promise { const xcodemakeCommand = [getXcodemakeCommand(), ...buildArgs]; - - // Remove projectDir from arguments if present at the start const prefix = projectDir + '/'; const command = xcodemakeCommand.map((arg) => { if (arg.startsWith(prefix)) { @@ -249,12 +176,6 @@ export async function executeXcodemakeCommand( return getDefaultCommandExecutor()(command, logPrefix, false, { cwd: projectDir }); } -/** - * Execute a make command for incremental builds - * @param projectDir Directory containing the Makefile - * @param logPrefix Prefix for logging - * @returns Promise resolving to command response - */ export async function executeMakeCommand( projectDir: string, logPrefix: string, From ab55874bc27a618e5526eb4014f09eb1f4d6ef8d Mon Sep 17 00:00:00 2001 From: Cameron Cooke Date: Thu, 26 Mar 2026 09:02:53 +0000 Subject: [PATCH 04/50] chore(test): Separate unit, snapshot, and smoke test scripts Add vitest.snapshot.config.ts for snapshot integration tests. Exclude src/snapshot-tests/ from the default vitest config so `npm test` runs only unit tests. Update package.json scripts so each test suite has its own config with no overlap: - npm test: unit tests only (vitest.config.ts) - npm run test:snapshot: snapshot integration tests (vitest.snapshot.config.ts) - npm run test:smoke: smoke tests (vitest.smoke.config.ts) --- package.json | 4 ++-- vitest.config.ts | 1 + vitest.snapshot.config.ts | 26 ++++++++++++++++++++++++++ 3 files changed, 29 insertions(+), 2 deletions(-) create mode 100644 vitest.snapshot.config.ts diff --git a/package.json b/package.json index 68ffd112..716b85ad 100644 --- a/package.json +++ b/package.json @@ -45,8 +45,8 @@ "license:check": "npx -y license-checker --production --onlyAllow 'MIT;ISC;BSD-2-Clause;BSD-3-Clause;Apache-2.0;Unlicense;FSL-1.1-MIT'", "knip": "knip", "test": "vitest run", - "test:snapshot": "npm run build && vitest run src/snapshot-tests", - "test:snapshot:update": "npm run build && UPDATE_SNAPSHOTS=1 vitest run src/snapshot-tests", + "test:snapshot": "npm run build && vitest run --config vitest.snapshot.config.ts", + "test:snapshot:update": "npm run build && UPDATE_SNAPSHOTS=1 vitest run --config vitest.snapshot.config.ts", "test:smoke": "npm run build && vitest run --config vitest.smoke.config.ts", "test:watch": "vitest", "test:ui": "vitest --ui", diff --git a/vitest.config.ts b/vitest.config.ts index 87ac5a2e..c5196ab5 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -21,6 +21,7 @@ export default defineConfig({ '**/__pycache__/**', '**/dist/**', 'src/smoke-tests/**', + 'src/snapshot-tests/**', ], pool: 'threads', poolOptions: { diff --git a/vitest.snapshot.config.ts b/vitest.snapshot.config.ts new file mode 100644 index 00000000..e74ab302 --- /dev/null +++ b/vitest.snapshot.config.ts @@ -0,0 +1,26 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + environment: 'node', + globals: true, + include: ['src/snapshot-tests/__tests__/**/*.test.ts'], + pool: 'forks', + poolOptions: { + forks: { + maxForks: 1, + }, + }, + env: { + NODE_OPTIONS: '--max-old-space-size=4096', + }, + testTimeout: 120000, + hookTimeout: 120000, + teardownTimeout: 10000, + }, + resolve: { + alias: { + '^(\\.{1,2}/.*)\\.js$': '$1', + }, + }, +}); From e8c602c6b9d2baf33f18e13eaee78956a0b488ae Mon Sep 17 00:00:00 2001 From: Cameron Cooke Date: Thu, 26 Mar 2026 09:03:12 +0000 Subject: [PATCH 05/50] refactor: Simplify and deduplicate post-migration code 10 rounds of code simplification after the pipeline migration: - Extract shared allText() test helper, replacing 52 duplicate definitions (allText/textOf/joinText) across 48 test files - Extract shared executeAxeCommand to ui-automation/shared/axe-command.ts, removing ~500 lines of identical code across 10 UI automation tools - Deduplicate header() construction across 22 tool files - Deduplicate build stage label maps in event-formatting.ts - Reuse formatHumanCompilerWarningEvent in formatGroupedWarnings - Eliminate 2 barrel re-export files (responses/index.ts, validation/index.ts) - Remove dead exports (summary builder, XcodebuildEvent alias) - Remove dead doctor showAsciiLogo parameter - Remove ~100+ redundant comments across test and tool files - Normalize assertion patterns (toBeFalsy for success, toBe(true) for error) - Fix formatting across all modified files Net: -1700 lines, no behavioral changes. --- .../CalculatorApp/CalculatorApp.swift | 1 - src/doctor-cli.ts | 3 +- .../xcode-tools-bridge/manager.ts | 1 - .../__tests__/get_coverage_report.test.ts | 14 +- .../__tests__/get_file_coverage.test.ts | 14 +- src/mcp/tools/coverage/get_coverage_report.ts | 9 +- src/mcp/tools/coverage/get_file_coverage.ts | 4 +- .../__tests__/debugging-tools.test.ts | 78 ++++++----- .../device/__tests__/build_device.test.ts | 6 - .../device/__tests__/build_run_device.test.ts | 5 - .../__tests__/get_device_app_path.test.ts | 6 - .../__tests__/install_app_device.test.ts | 33 +---- .../__tests__/launch_app_device.test.ts | 40 ++---- .../device/__tests__/list_devices.test.ts | 37 +----- .../tools/device/__tests__/re-exports.test.ts | 5 - .../device/__tests__/stop_app_device.test.ts | 33 +---- .../device/__tests__/test_device.test.ts | 6 - src/mcp/tools/device/get_device_app_path.ts | 6 +- src/mcp/tools/device/install_app_device.ts | 22 +--- src/mcp/tools/device/launch_app_device.ts | 20 ++- src/mcp/tools/device/list_devices.ts | 11 +- src/mcp/tools/device/stop_app_device.ts | 22 +--- src/mcp/tools/doctor/__tests__/doctor.test.ts | 14 +- src/mcp/tools/doctor/doctor.ts | 43 +++---- .../__tests__/start_device_log_cap.test.ts | 22 +--- .../__tests__/start_sim_log_cap.test.ts | 25 +--- .../__tests__/stop_device_log_cap.test.ts | 18 +-- .../__tests__/stop_sim_log_cap.test.ts | 24 +--- src/mcp/tools/logging/start_device_log_cap.ts | 14 +- src/mcp/tools/logging/start_sim_log_cap.ts | 22 +--- src/mcp/tools/logging/stop_device_log_cap.ts | 12 +- src/mcp/tools/logging/stop_sim_log_cap.ts | 9 +- .../tools/macos/__tests__/build_macos.test.ts | 10 +- .../macos/__tests__/build_run_macos.test.ts | 3 +- .../macos/__tests__/get_mac_app_path.test.ts | 28 ++-- .../macos/__tests__/launch_mac_app.test.ts | 30 +---- .../tools/macos/__tests__/re-exports.test.ts | 6 - .../macos/__tests__/stop_mac_app.test.ts | 40 +----- .../tools/macos/__tests__/test_macos.test.ts | 5 - src/mcp/tools/macos/get_mac_app_path.ts | 15 +-- src/mcp/tools/macos/launch_mac_app.ts | 2 +- src/mcp/tools/macos/stop_mac_app.ts | 12 +- .../__tests__/discover_projs.test.ts | 43 ++----- .../__tests__/get_app_bundle_id.test.ts | 30 +---- .../__tests__/get_mac_bundle_id.test.ts | 20 +-- .../__tests__/list_schemes.test.ts | 28 ++-- .../__tests__/show_build_settings.test.ts | 20 +-- .../tools/project-discovery/discover_projs.ts | 9 +- .../project-discovery/get_app_bundle_id.ts | 27 ++-- .../project-discovery/get_mac_bundle_id.ts | 23 ++-- .../tools/project-discovery/list_schemes.ts | 13 +- .../project-discovery/show_build_settings.ts | 16 +-- .../__tests__/scaffold_ios_project.test.ts | 61 ++------- .../__tests__/scaffold_macos_project.test.ts | 71 ++-------- .../scaffold_ios_project.ts | 2 +- .../scaffold_macos_project.ts | 2 +- .../__tests__/session_clear_defaults.test.ts | 24 ++-- .../__tests__/session_set_defaults.test.ts | 50 ++++---- .../__tests__/session_show_defaults.test.ts | 14 +- .../session_use_defaults_profile.test.ts | 18 +-- .../session_set_defaults.ts | 10 +- .../session_use_defaults_profile.ts | 10 +- .../__tests__/erase_sims.test.ts | 9 +- .../__tests__/reset_sim_location.test.ts | 9 +- .../__tests__/set_sim_appearance.test.ts | 9 +- .../__tests__/set_sim_location.test.ts | 9 +- .../__tests__/sim_statusbar.test.ts | 9 +- .../tools/simulator-management/erase_sims.ts | 4 +- .../simulator/__tests__/boot_sim.test.ts | 9 +- .../simulator/__tests__/build_run_sim.test.ts | 5 - .../simulator/__tests__/build_sim.test.ts | 5 - .../__tests__/get_sim_app_path.test.ts | 7 +- .../__tests__/launch_app_logs_sim.test.ts | 7 +- .../simulator/__tests__/list_sims.test.ts | 9 -- .../simulator/__tests__/open_sim.test.ts | 9 +- .../__tests__/record_sim_video.test.ts | 13 +- .../simulator/__tests__/screenshot.test.ts | 52 +------- .../simulator/__tests__/test_sim.test.ts | 5 - src/mcp/tools/simulator/install_app_sim.ts | 2 +- src/mcp/tools/simulator/open_sim.ts | 2 +- .../__tests__/active-processes.test.ts | 5 - .../__tests__/swift_package_build.test.ts | 6 - .../__tests__/swift_package_clean.test.ts | 6 - .../__tests__/swift_package_list.test.ts | 6 - .../__tests__/swift_package_run.test.ts | 6 - .../__tests__/swift_package_test.test.ts | 6 - .../tools/swift-package/swift_package_list.ts | 4 +- .../ui-automation/__tests__/button.test.ts | 12 +- .../ui-automation/__tests__/gesture.test.ts | 12 +- .../ui-automation/__tests__/key_press.test.ts | 12 +- .../__tests__/key_sequence.test.ts | 12 +- .../__tests__/long_press.test.ts | 12 +- .../__tests__/screenshot.test.ts | 14 +- .../__tests__/snapshot_ui.test.ts | 12 +- .../ui-automation/__tests__/swipe.test.ts | 16 +-- .../tools/ui-automation/__tests__/tap.test.ts | 14 +- .../ui-automation/__tests__/touch.test.ts | 13 +- .../ui-automation/__tests__/type_text.test.ts | 12 +- src/mcp/tools/ui-automation/button.ts | 92 +------------ src/mcp/tools/ui-automation/gesture.ts | 92 +------------ src/mcp/tools/ui-automation/key_press.ts | 92 +------------ src/mcp/tools/ui-automation/key_sequence.ts | 92 +------------ src/mcp/tools/ui-automation/long_press.ts | 93 +------------- src/mcp/tools/ui-automation/screenshot.ts | 8 +- .../tools/ui-automation/shared/axe-command.ts | 70 ++++++++++ src/mcp/tools/ui-automation/snapshot_ui.ts | 121 ++++-------------- src/mcp/tools/ui-automation/swipe.ts | 97 ++------------ src/mcp/tools/ui-automation/tap.ts | 94 ++------------ src/mcp/tools/ui-automation/touch.ts | 88 +------------ src/mcp/tools/ui-automation/type_text.ts | 87 +------------ .../tools/utilities/__tests__/clean.test.ts | 13 +- src/mcp/tools/utilities/clean.ts | 6 +- .../__tests__/manage_workflows.test.ts | 8 +- .../xcode-ide/__tests__/bridge_tools.test.ts | 16 +-- .../__tests__/sync_xcode_defaults.test.ts | 17 +-- .../tools/xcode-ide/sync_xcode_defaults.ts | 8 +- src/test-utils/test-helpers.ts | 17 +++ src/types/pipeline-events.ts | 4 - src/utils/capabilities.ts | 0 src/utils/renderers/event-formatting.ts | 51 +++----- src/utils/responses/index.ts | 3 - src/utils/tool-event-builders.ts | 25 ---- src/utils/typed-tool-factory.ts | 7 +- src/utils/validation/index.ts | 1 - src/utils/xcodebuild-output.ts | 20 +-- 125 files changed, 586 insertions(+), 2211 deletions(-) create mode 100644 src/mcp/tools/ui-automation/shared/axe-command.ts create mode 100644 src/test-utils/test-helpers.ts delete mode 100644 src/utils/capabilities.ts delete mode 100644 src/utils/responses/index.ts delete mode 100644 src/utils/validation/index.ts diff --git a/example_projects/iOS_Calculator/CalculatorApp/CalculatorApp.swift b/example_projects/iOS_Calculator/CalculatorApp/CalculatorApp.swift index d03531b4..b8cd4d0b 100644 --- a/example_projects/iOS_Calculator/CalculatorApp/CalculatorApp.swift +++ b/example_projects/iOS_Calculator/CalculatorApp/CalculatorApp.swift @@ -1,7 +1,6 @@ import SwiftUI import CalculatorAppFeature -@main struct CalculatorApp: App { var body: some Scene { WindowGroup { diff --git a/src/doctor-cli.ts b/src/doctor-cli.ts index 2f191135..48611ee3 100644 --- a/src/doctor-cli.ts +++ b/src/doctor-cli.ts @@ -31,9 +31,8 @@ async function runDoctor(): Promise { ); } - // Run the doctor tool logic directly with CLI flag enabled const executor = getDefaultCommandExecutor(); - const result = await doctorLogic({ nonRedacted }, executor, true); // showAsciiLogo = true for CLI + const result = await doctorLogic({ nonRedacted }, executor); // Output the doctor information if (result.content && result.content.length > 0) { diff --git a/src/integrations/xcode-tools-bridge/manager.ts b/src/integrations/xcode-tools-bridge/manager.ts index a2019e5c..15524289 100644 --- a/src/integrations/xcode-tools-bridge/manager.ts +++ b/src/integrations/xcode-tools-bridge/manager.ts @@ -85,7 +85,6 @@ export class XcodeToolsBridgeManager { } this.lastError = null; - // Notify clients that our own tool list changed. this.server.sendToolListChanged(); return sync; diff --git a/src/mcp/tools/coverage/__tests__/get_coverage_report.test.ts b/src/mcp/tools/coverage/__tests__/get_coverage_report.test.ts index 224bd0a6..f857e86c 100644 --- a/src/mcp/tools/coverage/__tests__/get_coverage_report.test.ts +++ b/src/mcp/tools/coverage/__tests__/get_coverage_report.test.ts @@ -1,8 +1,3 @@ -/** - * Tests for get_coverage_report tool - * Covers happy-path, target filtering, showFiles, and failure paths - */ - import { afterEach, describe, it, expect } from 'vitest'; import { createMockExecutor, createMockFileSystemExecutor } from '../../../../test-utils/mock-executors.ts'; import { @@ -10,15 +5,8 @@ import { __setTestFileSystemExecutorOverride, __clearTestExecutorOverrides, } from '../../../../utils/execution/index.ts'; -import type { ToolResponse } from '../../../../types/common.ts'; import { schema, handler, get_coverage_reportLogic } from '../get_coverage_report.ts'; - -function allText(result: ToolResponse): string { - return result.content - .filter((c): c is { type: 'text'; text: string } => c.type === 'text') - .map((c) => c.text) - .join('\n'); -} +import { allText } from '../../../../test-utils/test-helpers.ts'; const sampleTargets = [ { name: 'MyApp.app', coveredLines: 100, executableLines: 200, lineCoverage: 0.5 }, diff --git a/src/mcp/tools/coverage/__tests__/get_file_coverage.test.ts b/src/mcp/tools/coverage/__tests__/get_file_coverage.test.ts index 9803ae8e..1cca1999 100644 --- a/src/mcp/tools/coverage/__tests__/get_file_coverage.test.ts +++ b/src/mcp/tools/coverage/__tests__/get_file_coverage.test.ts @@ -1,8 +1,3 @@ -/** - * Tests for get_file_coverage tool - * Covers happy-path, showLines, uncovered line parsing, and failure paths - */ - import { afterEach, describe, it, expect } from 'vitest'; import { createMockExecutor, @@ -14,15 +9,8 @@ import { __setTestFileSystemExecutorOverride, __clearTestExecutorOverrides, } from '../../../../utils/execution/index.ts'; -import type { ToolResponse } from '../../../../types/common.ts'; import { schema, handler, get_file_coverageLogic } from '../get_file_coverage.ts'; - -function allText(result: ToolResponse): string { - return result.content - .filter((c): c is { type: 'text'; text: string } => c.type === 'text') - .map((c) => c.text) - .join('\n'); -} +import { allText } from '../../../../test-utils/test-helpers.ts'; const sampleFunctionsJson = [ { diff --git a/src/mcp/tools/coverage/get_coverage_report.ts b/src/mcp/tools/coverage/get_coverage_report.ts index 10b768cc..d352a8ce 100644 --- a/src/mcp/tools/coverage/get_coverage_report.ts +++ b/src/mcp/tools/coverage/get_coverage_report.ts @@ -7,9 +7,8 @@ import * as z from 'zod'; import type { ToolResponse } from '../../../types/common.ts'; -import type { PipelineEvent } from '../../../types/pipeline-events.ts'; import { log } from '../../../utils/logging/index.ts'; -import { validateFileExists } from '../../../utils/validation/index.ts'; +import { validateFileExists } from '../../../utils/validation.ts'; import type { CommandExecutor, FileSystemExecutor } from '../../../utils/execution/index.ts'; import { getDefaultCommandExecutor, getDefaultFileSystemExecutor } from '../../../utils/execution/index.ts'; import { createTypedToolWithContext } from '../../../utils/typed-tool-factory.ts'; @@ -168,13 +167,11 @@ export async function get_coverage_reportLogic( } } - const events: PipelineEvent[] = [ + return toolResponse([ headerEvent, statusLine('info', `Overall: ${overallPct.toFixed(1)}% (${totalCovered}/${totalExecutable} lines)`), section('Targets', targetLines), - ]; - - return toolResponse(events, { + ], { nextStepParams: { get_file_coverage: { xcresultPath }, }, diff --git a/src/mcp/tools/coverage/get_file_coverage.ts b/src/mcp/tools/coverage/get_file_coverage.ts index 25193f7a..fddc9ed2 100644 --- a/src/mcp/tools/coverage/get_file_coverage.ts +++ b/src/mcp/tools/coverage/get_file_coverage.ts @@ -9,7 +9,7 @@ import * as z from 'zod'; import type { ToolResponse } from '../../../types/common.ts'; import type { PipelineEvent } from '../../../types/pipeline-events.ts'; import { log } from '../../../utils/logging/index.ts'; -import { validateFileExists } from '../../../utils/validation/index.ts'; +import { validateFileExists } from '../../../utils/validation.ts'; import type { CommandExecutor, FileSystemExecutor } from '../../../utils/execution/index.ts'; import { getDefaultCommandExecutor, getDefaultFileSystemExecutor } from '../../../utils/execution/index.ts'; import { createTypedToolWithContext } from '../../../utils/typed-tool-factory.ts'; @@ -97,7 +97,6 @@ export async function get_file_coverageLogic( ['xcrun', 'xccov', 'view', '--report', '--functions-for-file', file, '--json', xcresultPath], 'Get File Function Coverage', false, - undefined, ); if (!funcResult.success) { @@ -210,7 +209,6 @@ export async function get_file_coverageLogic( ['xcrun', 'xccov', 'view', '--archive', '--file', filePath, xcresultPath], 'Get File Line Coverage', false, - undefined, ); if (archiveResult.success && archiveResult.output) { diff --git a/src/mcp/tools/debugging/__tests__/debugging-tools.test.ts b/src/mcp/tools/debugging/__tests__/debugging-tools.test.ts index 882d1275..c646988f 100644 --- a/src/mcp/tools/debugging/__tests__/debugging-tools.test.ts +++ b/src/mcp/tools/debugging/__tests__/debugging-tools.test.ts @@ -1,8 +1,7 @@ import { describe, it, expect, beforeEach } from 'vitest'; import { createMockExecutor } from '../../../../test-utils/mock-executors.ts'; import { sessionStore } from '../../../../utils/session-store.ts'; -import { DebuggerManager } from '../../../../utils/debugger/index.ts'; -import type { DebuggerToolContext } from '../../../../utils/debugger/index.ts'; +import { DebuggerManager, type DebuggerToolContext } from '../../../../utils/debugger/index.ts'; import type { DebuggerBackend } from '../../../../utils/debugger/backends/DebuggerBackend.ts'; import type { BreakpointSpec, DebugSessionInfo } from '../../../../utils/debugger/types.ts'; @@ -46,10 +45,7 @@ import { handler as variablesHandler, debug_variablesLogic, } from '../debug_variables.ts'; - -function joinText(result: { content: Array<{ text: string }> }): string { - return result.content.map((c) => c.text).join('\n'); -} +import { allText } from '../../../../test-utils/test-helpers.ts'; function createMockBackend(overrides: Partial = {}): DebuggerBackend { return { @@ -145,7 +141,7 @@ describe('debug_attach_sim', () => { ); expect(result.isError).toBeFalsy(); - const text = joinText(result); + const text = allText(result); expect(text).toContain('Attached'); expect(text).toContain('1234'); expect(text).toContain('test-sim-uuid'); @@ -166,7 +162,7 @@ describe('debug_attach_sim', () => { ); expect(result.isError).toBeFalsy(); - const text = joinText(result); + const text = allText(result); expect(text).toContain('Execution is paused'); }); @@ -188,7 +184,7 @@ describe('debug_attach_sim', () => { ); expect(result.isError).toBe(true); - const text = joinText(result); + const text = allText(result); expect(text).toContain('Failed to attach debugger'); expect(text).toContain('LLDB attach failed'); }); @@ -211,7 +207,7 @@ describe('debug_attach_sim', () => { ); expect(result.isError).toBe(true); - const text = joinText(result); + const text = allText(result); expect(text).toContain('Failed to resume debugger after attach'); }); @@ -257,7 +253,7 @@ describe('debug_attach_sim', () => { ); expect(result.isError).toBe(true); - const text = joinText(result); + const text = allText(result); expect(text).toContain('Failed to resolve simulator PID'); }); @@ -326,7 +322,7 @@ describe('debug_breakpoint_add', () => { const result = await debug_breakpoint_addLogic({ file: 'main.swift', line: 10 }, ctx); expect(result.isError).toBe(true); - const text = joinText(result); + const text = allText(result); expect(text).toContain('No active debug session'); }); }); @@ -341,7 +337,7 @@ describe('debug_breakpoint_add', () => { ); expect(result.isError).toBeFalsy(); - const text = joinText(result); + const text = allText(result); expect(text).toContain('Breakpoint'); expect(text).toContain('set'); }); @@ -355,7 +351,7 @@ describe('debug_breakpoint_add', () => { ); expect(result.isError).toBeFalsy(); - const text = joinText(result); + const text = allText(result); expect(text).toContain('Breakpoint'); }); @@ -373,7 +369,7 @@ describe('debug_breakpoint_add', () => { ); expect(result.isError).toBeFalsy(); - const text = joinText(result); + const text = allText(result); expect(text).toContain('Breakpoint'); }); @@ -390,7 +386,7 @@ describe('debug_breakpoint_add', () => { ); expect(result.isError).toBe(true); - const text = joinText(result); + const text = allText(result); expect(text).toContain('Failed to add breakpoint'); expect(text).toContain('Invalid file path'); }); @@ -401,7 +397,7 @@ describe('debug_breakpoint_add', () => { const result = await debug_breakpoint_addLogic({ file: 'main.swift', line: 10 }, ctx); expect(result.isError).toBeFalsy(); - const text = joinText(result); + const text = allText(result); expect(text).toContain('Breakpoint'); }); }); @@ -434,7 +430,7 @@ describe('debug_breakpoint_remove', () => { const result = await debug_breakpoint_removeLogic({ breakpointId: 1 }, ctx); expect(result.isError).toBe(true); - const text = joinText(result); + const text = allText(result); expect(text).toContain('No active debug session'); }); }); @@ -449,7 +445,7 @@ describe('debug_breakpoint_remove', () => { ); expect(result.isError).toBeFalsy(); - const text = joinText(result); + const text = allText(result); expect(text).toContain('Breakpoint 1 removed'); }); @@ -466,7 +462,7 @@ describe('debug_breakpoint_remove', () => { ); expect(result.isError).toBe(true); - const text = joinText(result); + const text = allText(result); expect(text).toContain('Failed to remove breakpoint'); expect(text).toContain('Breakpoint not found'); }); @@ -477,7 +473,7 @@ describe('debug_breakpoint_remove', () => { const result = await debug_breakpoint_removeLogic({ breakpointId: 1 }, ctx); expect(result.isError).toBeFalsy(); - const text = joinText(result); + const text = allText(result); expect(text).toContain('Breakpoint 1 removed'); }); }); @@ -509,7 +505,7 @@ describe('debug_continue', () => { const result = await debug_continueLogic({}, ctx); expect(result.isError).toBe(true); - const text = joinText(result); + const text = allText(result); expect(text).toContain('No active debug session'); }); }); @@ -521,7 +517,7 @@ describe('debug_continue', () => { const result = await debug_continueLogic({ debugSessionId: session.id }, ctx); expect(result.isError).toBeFalsy(); - const text = joinText(result); + const text = allText(result); expect(text).toContain('Resumed debugger session'); expect(text).toContain(session.id); }); @@ -532,7 +528,7 @@ describe('debug_continue', () => { const result = await debug_continueLogic({}, ctx); expect(result.isError).toBeFalsy(); - const text = joinText(result); + const text = allText(result); expect(text).toContain('Resumed debugger session'); }); @@ -546,7 +542,7 @@ describe('debug_continue', () => { const result = await debug_continueLogic({ debugSessionId: session.id }, ctx); expect(result.isError).toBe(true); - const text = joinText(result); + const text = allText(result); expect(text).toContain('Failed to resume debugger'); expect(text).toContain('Process terminated'); }); @@ -579,7 +575,7 @@ describe('debug_detach', () => { const result = await debug_detachLogic({}, ctx); expect(result.isError).toBe(true); - const text = joinText(result); + const text = allText(result); expect(text).toContain('No active debug session'); }); }); @@ -591,7 +587,7 @@ describe('debug_detach', () => { const result = await debug_detachLogic({ debugSessionId: session.id }, ctx); expect(result.isError).toBeFalsy(); - const text = joinText(result); + const text = allText(result); expect(text).toContain('Detached debugger session'); expect(text).toContain(session.id); }); @@ -602,7 +598,7 @@ describe('debug_detach', () => { const result = await debug_detachLogic({}, ctx); expect(result.isError).toBeFalsy(); - const text = joinText(result); + const text = allText(result); expect(text).toContain('Detached debugger session'); }); @@ -616,7 +612,7 @@ describe('debug_detach', () => { const result = await debug_detachLogic({ debugSessionId: session.id }, ctx); expect(result.isError).toBe(true); - const text = joinText(result); + const text = allText(result); expect(text).toContain('Failed to detach debugger'); expect(text).toContain('Connection lost'); }); @@ -651,7 +647,7 @@ describe('debug_lldb_command', () => { const result = await debug_lldb_commandLogic({ command: 'bt' }, ctx); expect(result.isError).toBe(true); - const text = joinText(result); + const text = allText(result); expect(text).toContain('No active debug session'); }); }); @@ -668,7 +664,7 @@ describe('debug_lldb_command', () => { ); expect(result.isError).toBeFalsy(); - const text = joinText(result); + const text = allText(result); expect(text).toContain('frame #0: main'); }); @@ -702,7 +698,7 @@ describe('debug_lldb_command', () => { ); expect(result.isError).toBe(true); - const text = joinText(result); + const text = allText(result); expect(text).toContain('Failed to run LLDB command'); expect(text).toContain('Command timed out'); }); @@ -715,7 +711,7 @@ describe('debug_lldb_command', () => { const result = await debug_lldb_commandLogic({ command: 'po self' }, ctx); expect(result.isError).toBeFalsy(); - const text = joinText(result); + const text = allText(result); expect(text).toContain('result'); }); }); @@ -749,7 +745,7 @@ describe('debug_stack', () => { const result = await debug_stackLogic({}, ctx); expect(result.isError).toBe(true); - const text = joinText(result); + const text = allText(result); expect(text).toContain('No active debug session'); }); }); @@ -764,7 +760,7 @@ describe('debug_stack', () => { const result = await debug_stackLogic({ debugSessionId: session.id }, ctx); expect(result.isError).toBeFalsy(); - const text = joinText(result); + const text = allText(result); expect(text).toContain(stackOutput.trim()); }); @@ -793,7 +789,7 @@ describe('debug_stack', () => { const result = await debug_stackLogic({ debugSessionId: session.id }, ctx); expect(result.isError).toBe(true); - const text = joinText(result); + const text = allText(result); expect(text).toContain('Failed to get stack'); expect(text).toContain('Process not stopped'); }); @@ -806,7 +802,7 @@ describe('debug_stack', () => { const result = await debug_stackLogic({}, ctx); expect(result.isError).toBeFalsy(); - const text = joinText(result); + const text = allText(result); expect(text).toContain('frame #0: main'); }); }); @@ -839,7 +835,7 @@ describe('debug_variables', () => { const result = await debug_variablesLogic({}, ctx); expect(result.isError).toBe(true); - const text = joinText(result); + const text = allText(result); expect(text).toContain('No active debug session'); }); }); @@ -854,7 +850,7 @@ describe('debug_variables', () => { const result = await debug_variablesLogic({ debugSessionId: session.id }, ctx); expect(result.isError).toBeFalsy(); - const text = joinText(result); + const text = allText(result); expect(text).toContain(variablesOutput.trim()); }); @@ -885,7 +881,7 @@ describe('debug_variables', () => { ); expect(result.isError).toBe(true); - const text = joinText(result); + const text = allText(result); expect(text).toContain('Failed to get variables'); expect(text).toContain('Frame index out of range'); }); @@ -898,7 +894,7 @@ describe('debug_variables', () => { const result = await debug_variablesLogic({}, ctx); expect(result.isError).toBeFalsy(); - const text = joinText(result); + const text = allText(result); expect(text).toContain('y = 99'); }); }); diff --git a/src/mcp/tools/device/__tests__/build_device.test.ts b/src/mcp/tools/device/__tests__/build_device.test.ts index 44a982f0..c45b874c 100644 --- a/src/mcp/tools/device/__tests__/build_device.test.ts +++ b/src/mcp/tools/device/__tests__/build_device.test.ts @@ -1,9 +1,3 @@ -/** - * Tests for build_device plugin (unified) - * Following CLAUDE.md testing standards with literal validation - * Using dependency injection for deterministic testing - */ - import { describe, it, expect, beforeEach } from 'vitest'; import * as z from 'zod'; import { diff --git a/src/mcp/tools/device/__tests__/build_run_device.test.ts b/src/mcp/tools/device/__tests__/build_run_device.test.ts index a75ebe0b..3cd755a9 100644 --- a/src/mcp/tools/device/__tests__/build_run_device.test.ts +++ b/src/mcp/tools/device/__tests__/build_run_device.test.ts @@ -1,8 +1,3 @@ -/** - * Tests for build_run_device plugin (unified) - * Following the canonical pending pipeline pattern from build_run_macos / build_run_sim. - */ - import { describe, it, expect, beforeEach } from 'vitest'; import * as z from 'zod'; import { diff --git a/src/mcp/tools/device/__tests__/get_device_app_path.test.ts b/src/mcp/tools/device/__tests__/get_device_app_path.test.ts index 20c5f7f2..30029910 100644 --- a/src/mcp/tools/device/__tests__/get_device_app_path.test.ts +++ b/src/mcp/tools/device/__tests__/get_device_app_path.test.ts @@ -1,9 +1,3 @@ -/** - * Tests for get_device_app_path plugin (unified) - * Following CLAUDE.md testing standards with literal validation - * Using dependency injection for deterministic testing - */ - import { describe, it, expect, beforeEach } from 'vitest'; import * as z from 'zod'; import { diff --git a/src/mcp/tools/device/__tests__/install_app_device.test.ts b/src/mcp/tools/device/__tests__/install_app_device.test.ts index 8ad1c342..c859a8a1 100644 --- a/src/mcp/tools/device/__tests__/install_app_device.test.ts +++ b/src/mcp/tools/device/__tests__/install_app_device.test.ts @@ -1,14 +1,9 @@ -/** - * Tests for install_app_device plugin (device-shared) - * Following CLAUDE.md testing standards with literal validation - * Using dependency injection for deterministic testing - */ - import { describe, it, expect, beforeEach } from 'vitest'; import * as z from 'zod'; import { createMockExecutor } from '../../../../test-utils/mock-executors.ts'; import { schema, handler, install_app_deviceLogic } from '../install_app_device.ts'; import { sessionStore } from '../../../../utils/session-store.ts'; +import { allText } from '../../../../test-utils/test-helpers.ts'; describe('install_app_device plugin', () => { beforeEach(() => { @@ -161,13 +156,6 @@ describe('install_app_device plugin', () => { }); describe('Success Path Tests', () => { - function textOf(result: { content: Array<{ type: string; text: string }> }): string { - return result.content - .filter((i) => i.type === 'text') - .map((i) => i.text) - .join('\n'); - } - it('should return successful installation response', async () => { const mockExecutor = createMockExecutor({ success: true, @@ -183,7 +171,7 @@ describe('install_app_device plugin', () => { ); expect(result.isError).toBeFalsy(); - const text = textOf(result); + const text = allText(result); expect(text).toContain('Install App'); expect(text).toContain('test-device-123'); expect(text).toContain('/path/to/test.app'); @@ -206,7 +194,7 @@ describe('install_app_device plugin', () => { ); expect(result.isError).toBeFalsy(); - const text = textOf(result); + const text = allText(result); expect(text).toContain('Install App'); expect(text).toContain('App installed successfully'); }); @@ -226,20 +214,13 @@ describe('install_app_device plugin', () => { ); expect(result.isError).toBeFalsy(); - const text = textOf(result); + const text = allText(result); expect(text).toContain('Install App'); expect(text).toContain('App installed successfully'); }); }); describe('Error Handling', () => { - function textOf(result: { content: Array<{ type: string; text: string }> }): string { - return result.content - .filter((i) => i.type === 'text') - .map((i) => i.text) - .join('\n'); - } - it('should return installation failure response', async () => { const mockExecutor = createMockExecutor({ success: false, @@ -255,7 +236,7 @@ describe('install_app_device plugin', () => { ); expect(result.isError).toBe(true); - const text = textOf(result); + const text = allText(result); expect(text).toContain('Failed to install app: Installation failed: App not found'); }); @@ -271,7 +252,7 @@ describe('install_app_device plugin', () => { ); expect(result.isError).toBe(true); - const text = textOf(result); + const text = allText(result); expect(text).toContain('Failed to install app on device: Network error'); }); @@ -287,7 +268,7 @@ describe('install_app_device plugin', () => { ); expect(result.isError).toBe(true); - const text = textOf(result); + const text = allText(result); expect(text).toContain('Failed to install app on device: String error'); }); }); diff --git a/src/mcp/tools/device/__tests__/launch_app_device.test.ts b/src/mcp/tools/device/__tests__/launch_app_device.test.ts index 121ec5fc..222164f3 100644 --- a/src/mcp/tools/device/__tests__/launch_app_device.test.ts +++ b/src/mcp/tools/device/__tests__/launch_app_device.test.ts @@ -1,12 +1,3 @@ -/** - * Pure dependency injection test for launch_app_device plugin (device-shared) - * - * Tests plugin structure and app launching functionality including parameter validation, - * command generation, file operations, and response formatting. - * - * Uses createMockExecutor for command execution and manual stubs for file operations. - */ - import { describe, it, expect, beforeEach } from 'vitest'; import * as z from 'zod'; import { @@ -15,6 +6,7 @@ import { } from '../../../../test-utils/mock-executors.ts'; import { schema, handler, launch_app_deviceLogic } from '../launch_app_device.ts'; import { sessionStore } from '../../../../utils/session-store.ts'; +import { allText } from '../../../../test-utils/test-helpers.ts'; describe('launch_app_device plugin (device-shared)', () => { beforeEach(() => { @@ -198,13 +190,6 @@ describe('launch_app_device plugin (device-shared)', () => { }); describe('Success Path Tests', () => { - function textOf(result: { content: Array<{ type: string; text: string }> }): string { - return result.content - .filter((i) => i.type === 'text') - .map((i) => i.text) - .join('\n'); - } - it('should return successful launch response without process ID', async () => { const mockExecutor = createMockExecutor({ success: true, @@ -221,7 +206,7 @@ describe('launch_app_device plugin (device-shared)', () => { ); expect(result.isError).toBeFalsy(); - const text = textOf(result); + const text = allText(result); expect(text).toContain('Launch App'); expect(text).toContain('test-device-123'); expect(text).toContain('io.sentry.app'); @@ -244,7 +229,7 @@ describe('launch_app_device plugin (device-shared)', () => { ); expect(result.isError).toBeFalsy(); - const text = textOf(result); + const text = allText(result); expect(text).toContain('Launch App'); expect(text).toContain('App launched successfully'); }); @@ -277,7 +262,7 @@ describe('launch_app_device plugin (device-shared)', () => { ); expect(result.isError).toBeFalsy(); - const text = textOf(result); + const text = allText(result); expect(text).toContain('Launch App'); expect(text).toContain('Process ID: 12345'); expect(text).toContain('App launched successfully'); @@ -302,20 +287,13 @@ describe('launch_app_device plugin (device-shared)', () => { ); expect(result.isError).toBeFalsy(); - const text = textOf(result); + const text = allText(result); expect(text).toContain('Launch App'); expect(text).toContain('App launched successfully'); }); }); describe('Error Handling', () => { - function textOf(result: { content: Array<{ type: string; text: string }> }): string { - return result.content - .filter((i) => i.type === 'text') - .map((i) => i.text) - .join('\n'); - } - it('should return launch failure response', async () => { const mockExecutor = createMockExecutor({ success: false, @@ -332,7 +310,7 @@ describe('launch_app_device plugin (device-shared)', () => { ); expect(result.isError).toBe(true); - const text = textOf(result); + const text = allText(result); expect(text).toContain('Failed to launch app: Launch failed: App not found'); }); @@ -352,7 +330,7 @@ describe('launch_app_device plugin (device-shared)', () => { ); expect(result.isError).toBe(true); - const text = textOf(result); + const text = allText(result); expect(text).toContain('Failed to launch app: Device not found: test-device-invalid'); }); @@ -369,7 +347,7 @@ describe('launch_app_device plugin (device-shared)', () => { ); expect(result.isError).toBe(true); - const text = textOf(result); + const text = allText(result); expect(text).toContain('Failed to launch app on device: Network error'); }); @@ -386,7 +364,7 @@ describe('launch_app_device plugin (device-shared)', () => { ); expect(result.isError).toBe(true); - const text = textOf(result); + const text = allText(result); expect(text).toContain('Failed to launch app on device: String error'); }); }); diff --git a/src/mcp/tools/device/__tests__/list_devices.test.ts b/src/mcp/tools/device/__tests__/list_devices.test.ts index 6e928c12..9e1e4425 100644 --- a/src/mcp/tools/device/__tests__/list_devices.test.ts +++ b/src/mcp/tools/device/__tests__/list_devices.test.ts @@ -1,19 +1,11 @@ -/** - * Tests for list_devices plugin (device-shared) - * This tests the re-exported plugin from device-workspace - * Following CLAUDE.md testing standards with literal validation - * - * Note: This is a re-export test. Comprehensive handler tests are in device-workspace/list_devices.test.ts - */ - import { describe, it, expect } from 'vitest'; import { createMockCommandResponse, createMockExecutor, } from '../../../../test-utils/mock-executors.ts'; -// Import the logic function and named exports import { schema, handler, list_devicesLogic } from '../list_devices.ts'; +import { allText } from '../../../../test-utils/test-helpers.ts'; describe('list_devices plugin (device-shared)', () => { describe('Export Field Validation (Literal)', () => { @@ -56,7 +48,6 @@ describe('list_devices plugin (device-shared)', () => { }, }; - // Track command calls const commandCalls: Array<{ command: string[]; logPrefix?: string; @@ -64,13 +55,11 @@ describe('list_devices plugin (device-shared)', () => { env?: Record; }> = []; - // Create mock executor const mockExecutor = createMockExecutor({ success: true, output: '', }); - // Wrap to track calls const trackingExecutor = async ( command: string[], logPrefix?: string, @@ -82,13 +71,11 @@ describe('list_devices plugin (device-shared)', () => { return mockExecutor(command, logPrefix, useShell, opts, _detached); }; - // Create mock path dependencies const mockPathDeps = { tmpdir: () => '/tmp', join: (...paths: string[]) => paths.join('/'), }; - // Create mock filesystem with specific behavior const mockFsDeps = { readFile: async (_path: string, _encoding?: string) => JSON.stringify(devicectlJson), unlink: async () => {}, @@ -111,7 +98,6 @@ describe('list_devices plugin (device-shared)', () => { }); it('should generate correct xctrace fallback command', async () => { - // Track command calls const commandCalls: Array<{ command: string[]; logPrefix?: string; @@ -119,7 +105,6 @@ describe('list_devices plugin (device-shared)', () => { env?: Record; }> = []; - // Create tracking executor with call count behavior let callCount = 0; const trackingExecutor = async ( command: string[], @@ -132,14 +117,12 @@ describe('list_devices plugin (device-shared)', () => { commandCalls.push({ command, logPrefix, useShell, env: opts?.env }); if (callCount === 1) { - // First call fails (devicectl) return createMockCommandResponse({ success: false, output: '', error: 'devicectl failed', }); } else { - // Second call succeeds (xctrace) return createMockCommandResponse({ success: true, output: 'iPhone 15 (12345678-1234-1234-1234-123456789012)', @@ -148,13 +131,11 @@ describe('list_devices plugin (device-shared)', () => { } }; - // Create mock path dependencies const mockPathDeps = { tmpdir: () => '/tmp', join: (...paths: string[]) => paths.join('/'), }; - // Create mock filesystem that throws for readFile const mockFsDeps = { readFile: async () => { throw new Error('File not found'); @@ -173,13 +154,6 @@ describe('list_devices plugin (device-shared)', () => { }); describe('Success Path Tests', () => { - function textOf(result: { content: Array<{ type: string; text: string }> }): string { - return result.content - .filter((i) => i.type === 'text') - .map((i) => i.text) - .join('\n'); - } - it('should return successful devicectl response with parsed devices', async () => { const devicectlJson = { result: { @@ -223,7 +197,7 @@ describe('list_devices plugin (device-shared)', () => { const result = await list_devicesLogic({}, mockExecutor, mockPathDeps, mockFsDeps); expect(result.isError).toBeFalsy(); - const text = textOf(result); + const text = allText(result); expect(text).toContain('List Devices'); expect(text).toContain('Test iPhone'); expect(text).toContain('test-device-123'); @@ -279,7 +253,7 @@ describe('list_devices plugin (device-shared)', () => { const result = await list_devicesLogic({}, mockExecutor, mockPathDeps, mockFsDeps); expect(result.isError).toBeFalsy(); - const text = textOf(result); + const text = allText(result); expect(text).toContain('List Devices'); expect(text).toContain('xctrace output'); expect(text).toContain('iPhone 15 (12345678-1234-1234-1234-123456789012)'); @@ -330,13 +304,10 @@ describe('list_devices plugin (device-shared)', () => { const result = await list_devicesLogic({}, mockExecutor, mockPathDeps, mockFsDeps); expect(result.isError).toBeFalsy(); - const text = textOf(result); + const text = allText(result); expect(text).toContain('List Devices'); expect(text).toContain('xctrace output'); expect(text).toContain('Xcode 15'); }); }); - - // Note: Handler functionality is thoroughly tested in device-workspace/list_devices.test.ts - // This test file only verifies the re-export works correctly }); diff --git a/src/mcp/tools/device/__tests__/re-exports.test.ts b/src/mcp/tools/device/__tests__/re-exports.test.ts index 8da20225..c6df73f3 100644 --- a/src/mcp/tools/device/__tests__/re-exports.test.ts +++ b/src/mcp/tools/device/__tests__/re-exports.test.ts @@ -1,10 +1,5 @@ -/** - * Tests for device tool named exports - * Verifies that device tools export schema and handler as named exports - */ import { describe, it, expect } from 'vitest'; -// Import all tools as modules to check named exports import * as launchAppDevice from '../launch_app_device.ts'; import * as stopAppDevice from '../stop_app_device.ts'; import * as listDevices from '../list_devices.ts'; diff --git a/src/mcp/tools/device/__tests__/stop_app_device.test.ts b/src/mcp/tools/device/__tests__/stop_app_device.test.ts index d6b470ae..35b9d0ee 100644 --- a/src/mcp/tools/device/__tests__/stop_app_device.test.ts +++ b/src/mcp/tools/device/__tests__/stop_app_device.test.ts @@ -1,14 +1,9 @@ -/** - * Tests for stop_app_device plugin (device-shared) - * Following CLAUDE.md testing standards with literal validation - * Using dependency injection for deterministic testing - */ - import { describe, it, expect, beforeEach } from 'vitest'; import * as z from 'zod'; import { createMockExecutor } from '../../../../test-utils/mock-executors.ts'; import { schema, handler, stop_app_deviceLogic } from '../stop_app_device.ts'; import { sessionStore } from '../../../../utils/session-store.ts'; +import { allText } from '../../../../test-utils/test-helpers.ts'; describe('stop_app_device plugin', () => { beforeEach(() => { @@ -162,13 +157,6 @@ describe('stop_app_device plugin', () => { }); describe('Success Path Tests', () => { - function textOf(result: { content: Array<{ type: string; text: string }> }): string { - return result.content - .filter((i) => i.type === 'text') - .map((i) => i.text) - .join('\n'); - } - it('should return successful stop response', async () => { const mockExecutor = createMockExecutor({ success: true, @@ -184,7 +172,7 @@ describe('stop_app_device plugin', () => { ); expect(result.isError).toBeFalsy(); - const text = textOf(result); + const text = allText(result); expect(text).toContain('Stop App'); expect(text).toContain('test-device-123'); expect(text).toContain('12345'); @@ -206,7 +194,7 @@ describe('stop_app_device plugin', () => { ); expect(result.isError).toBeFalsy(); - const text = textOf(result); + const text = allText(result); expect(text).toContain('Stop App'); expect(text).toContain('App stopped successfully'); }); @@ -226,20 +214,13 @@ describe('stop_app_device plugin', () => { ); expect(result.isError).toBeFalsy(); - const text = textOf(result); + const text = allText(result); expect(text).toContain('Stop App'); expect(text).toContain('App stopped successfully'); }); }); describe('Error Handling', () => { - function textOf(result: { content: Array<{ type: string; text: string }> }): string { - return result.content - .filter((i) => i.type === 'text') - .map((i) => i.text) - .join('\n'); - } - it('should return stop failure response', async () => { const mockExecutor = createMockExecutor({ success: false, @@ -255,7 +236,7 @@ describe('stop_app_device plugin', () => { ); expect(result.isError).toBe(true); - const text = textOf(result); + const text = allText(result); expect(text).toContain('Failed to stop app: Terminate failed: Process not found'); }); @@ -271,7 +252,7 @@ describe('stop_app_device plugin', () => { ); expect(result.isError).toBe(true); - const text = textOf(result); + const text = allText(result); expect(text).toContain('Failed to stop app on device: Network error'); }); @@ -287,7 +268,7 @@ describe('stop_app_device plugin', () => { ); expect(result.isError).toBe(true); - const text = textOf(result); + const text = allText(result); expect(text).toContain('Failed to stop app on device: String error'); }); }); diff --git a/src/mcp/tools/device/__tests__/test_device.test.ts b/src/mcp/tools/device/__tests__/test_device.test.ts index 0d89539f..667098a8 100644 --- a/src/mcp/tools/device/__tests__/test_device.test.ts +++ b/src/mcp/tools/device/__tests__/test_device.test.ts @@ -1,9 +1,3 @@ -/** - * Tests for test_device plugin - * Following CLAUDE.md testing standards with literal validation - * Using dependency injection for deterministic testing - */ - import { describe, it, expect, beforeEach } from 'vitest'; import * as z from 'zod'; import { diff --git a/src/mcp/tools/device/get_device_app_path.ts b/src/mcp/tools/device/get_device_app_path.ts index 02f7a5a5..c5a2ee7f 100644 --- a/src/mcp/tools/device/get_device_app_path.ts +++ b/src/mcp/tools/device/get_device_app_path.ts @@ -77,7 +77,7 @@ export async function get_device_app_pathLogic( ): Promise { const platform = mapDevicePlatform(params.platform); const configuration = params.configuration ?? 'Debug'; - const headerParams = buildHeaderParams(params, configuration, platform); + const headerEvent = header('Get App Path', buildHeaderParams(params, configuration, platform)); log('info', `Getting app path for scheme ${params.scheme} on platform ${platform}`); @@ -95,7 +95,7 @@ export async function get_device_app_pathLogic( return toolResponse( [ - header('Get App Path', headerParams), + headerEvent, detailTree([{ label: 'App Path', value: appPath }]), statusLine('success', 'App path resolved.'), ], @@ -111,7 +111,7 @@ export async function get_device_app_pathLogic( const errorMessage = error instanceof Error ? error.message : String(error); log('error', `Error retrieving app path: ${errorMessage}`); - return toolResponse([header('Get App Path', headerParams), statusLine('error', errorMessage)]); + return toolResponse([headerEvent, statusLine('error', errorMessage)]); } } diff --git a/src/mcp/tools/device/install_app_device.ts b/src/mcp/tools/device/install_app_device.ts index f70c2946..0fc09018 100644 --- a/src/mcp/tools/device/install_app_device.ts +++ b/src/mcp/tools/device/install_app_device.ts @@ -34,6 +34,10 @@ export async function install_app_deviceLogic( executor: CommandExecutor, ): Promise { const { deviceId, appPath } = params; + const headerEvent = header('Install App', [ + { label: 'Device', value: deviceId }, + { label: 'App', value: appPath }, + ]); log('info', `Installing app on device ${deviceId}`); @@ -46,29 +50,17 @@ export async function install_app_deviceLogic( if (!result.success) { return toolResponse([ - header('Install App', [ - { label: 'Device', value: deviceId }, - { label: 'App', value: appPath }, - ]), + headerEvent, statusLine('error', `Failed to install app: ${result.error}`), ]); } - return toolResponse([ - header('Install App', [ - { label: 'Device', value: deviceId }, - { label: 'App', value: appPath }, - ]), - statusLine('success', 'App installed successfully.'), - ]); + return toolResponse([headerEvent, statusLine('success', 'App installed successfully.')]); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); log('error', `Error installing app on device: ${errorMessage}`); return toolResponse([ - header('Install App', [ - { label: 'Device', value: deviceId }, - { label: 'App', value: appPath }, - ]), + headerEvent, statusLine('error', `Failed to install app on device: ${errorMessage}`), ]); } diff --git a/src/mcp/tools/device/launch_app_device.ts b/src/mcp/tools/device/launch_app_device.ts index c2341459..f84bd205 100644 --- a/src/mcp/tools/device/launch_app_device.ts +++ b/src/mcp/tools/device/launch_app_device.ts @@ -54,8 +54,12 @@ export async function launch_app_deviceLogic( log('info', `Launching app ${bundleId} on device ${deviceId}`); + const headerEvent = header('Launch App', [ + { label: 'Device', value: deviceId }, + { label: 'Bundle ID', value: bundleId }, + ]); + try { - // Use JSON output to capture process ID const tempJsonPath = join(fileSystem.tmpdir(), `launch-${Date.now()}.json`); const command = [ @@ -79,14 +83,9 @@ export async function launch_app_deviceLogic( const result = await executor(command, 'Launch app on device', false); - const headerParams: Array<{ label: string; value: string }> = [ - { label: 'Device', value: deviceId }, - { label: 'Bundle ID', value: bundleId }, - ]; - if (!result.success) { return toolResponse([ - header('Launch App', headerParams), + headerEvent, statusLine('error', `Failed to launch app: ${result.error}`), ]); } @@ -105,7 +104,7 @@ export async function launch_app_deviceLogic( await fileSystem.rm(tempJsonPath, { force: true }).catch(() => {}); } - const events = [header('Launch App', headerParams)]; + const events = [headerEvent]; if (processId) { events.push(detailTree([{ label: 'Process ID', value: processId.toString() }])); @@ -121,10 +120,7 @@ export async function launch_app_deviceLogic( const errorMessage = error instanceof Error ? error.message : String(error); log('error', `Error launching app on device: ${errorMessage}`); return toolResponse([ - header('Launch App', [ - { label: 'Device', value: deviceId }, - { label: 'Bundle ID', value: bundleId }, - ]), + headerEvent, statusLine('error', `Failed to launch app on device: ${errorMessage}`), ]); } diff --git a/src/mcp/tools/device/list_devices.ts b/src/mcp/tools/device/list_devices.ts index 8f31451f..e8697911 100644 --- a/src/mcp/tools/device/list_devices.ts +++ b/src/mcp/tools/device/list_devices.ts @@ -61,6 +61,7 @@ export async function list_devicesLogic( }, ): Promise { log('info', 'Starting device discovery'); + const headerEvent = header('List Devices'); try { // Try modern devicectl with JSON output first (iOS 17+, Xcode 15+) @@ -77,7 +78,6 @@ export async function list_devicesLogic( ['xcrun', 'devicectl', 'list', 'devices', '--json-output', tempJsonPath], 'List Devices (devicectl with JSON)', false, - undefined, ); if (result.success) { @@ -178,12 +178,11 @@ export async function list_devicesLogic( ['xcrun', 'xctrace', 'list', 'devices'], 'List Devices (xctrace)', false, - undefined, ); if (!result.success) { return toolResponse([ - header('List Devices'), + headerEvent, statusLine('error', `Failed to list devices: ${result.error}`), section('Troubleshooting', [ 'Make sure Xcode is installed and devices are connected and trusted.', @@ -192,7 +191,7 @@ export async function list_devicesLogic( } return toolResponse([ - header('List Devices'), + headerEvent, section('Device listing (xctrace output)', [result.output]), statusLine( 'info', @@ -205,7 +204,7 @@ export async function list_devicesLogic( (device, index, self) => index === self.findIndex((d) => d.identifier === device.identifier), ); - const events: PipelineEvent[] = [header('List Devices')]; + const events: PipelineEvent[] = [headerEvent]; if (uniqueDevices.length === 0) { events.push( @@ -312,7 +311,7 @@ export async function list_devicesLogic( const errorMessage = error instanceof Error ? error.message : String(error); log('error', `Error listing devices: ${errorMessage}`); return toolResponse([ - header('List Devices'), + headerEvent, statusLine('error', `Failed to list devices: ${errorMessage}`), ]); } diff --git a/src/mcp/tools/device/stop_app_device.ts b/src/mcp/tools/device/stop_app_device.ts index 175c0485..052bc269 100644 --- a/src/mcp/tools/device/stop_app_device.ts +++ b/src/mcp/tools/device/stop_app_device.ts @@ -31,6 +31,10 @@ export async function stop_app_deviceLogic( executor: CommandExecutor, ): Promise { const { deviceId, processId } = params; + const headerEvent = header('Stop App', [ + { label: 'Device', value: deviceId }, + { label: 'PID', value: processId.toString() }, + ]); log('info', `Stopping app with PID ${processId} on device ${deviceId}`); @@ -53,29 +57,17 @@ export async function stop_app_deviceLogic( if (!result.success) { return toolResponse([ - header('Stop App', [ - { label: 'Device', value: deviceId }, - { label: 'PID', value: processId.toString() }, - ]), + headerEvent, statusLine('error', `Failed to stop app: ${result.error}`), ]); } - return toolResponse([ - header('Stop App', [ - { label: 'Device', value: deviceId }, - { label: 'PID', value: processId.toString() }, - ]), - statusLine('success', 'App stopped successfully.'), - ]); + return toolResponse([headerEvent, statusLine('success', 'App stopped successfully.')]); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); log('error', `Error stopping app on device: ${errorMessage}`); return toolResponse([ - header('Stop App', [ - { label: 'Device', value: deviceId }, - { label: 'PID', value: processId.toString() }, - ]), + headerEvent, statusLine('error', `Failed to stop app on device: ${errorMessage}`), ]); } diff --git a/src/mcp/tools/doctor/__tests__/doctor.test.ts b/src/mcp/tools/doctor/__tests__/doctor.test.ts index 83625beb..33ee3852 100644 --- a/src/mcp/tools/doctor/__tests__/doctor.test.ts +++ b/src/mcp/tools/doctor/__tests__/doctor.test.ts @@ -1,13 +1,8 @@ -/** - * Tests for doctor plugin - * Following CLAUDE.md testing standards with literal validation - * Using dependency injection for deterministic testing - */ - import { describe, it, expect } from 'vitest'; import * as z from 'zod'; import { schema, runDoctor, type DoctorDependencies } from '../doctor.ts'; import { createMockExecutor } from '../../../../test-utils/mock-executors.ts'; +import { allText } from '../../../../test-utils/test-helpers.ts'; function createDeps(overrides?: Partial): DoctorDependencies { const base: DoctorDependencies = { @@ -137,13 +132,6 @@ describe('doctor tool', () => { }); describe('Handler Behavior (Complete Literal Returns)', () => { - function allText(result: { content: Array<{ type: string; text: string }> }): string { - return result.content - .filter((c) => c.type === 'text') - .map((c) => c.text) - .join('\n'); - } - it('should handle successful doctor execution', async () => { const deps = createDeps(); const result = await runDoctor({}, deps); diff --git a/src/mcp/tools/doctor/doctor.ts b/src/mcp/tools/doctor/doctor.ts index 7706221c..02865db8 100644 --- a/src/mcp/tools/doctor/doctor.ts +++ b/src/mcp/tools/doctor/doctor.ts @@ -20,7 +20,6 @@ import { getMcpBridgeAvailability } from '../../../integrations/xcode-tools-brid import { toolResponse } from '../../../utils/tool-response.ts'; import { header, statusLine, section, detailTree } from '../../../utils/tool-event-builders.ts'; -// Constants const LOG_PREFIX = '[Doctor]'; const USER_HOME_PATH_PATTERN = /\/Users\/[^/\s]+/g; const SENSITIVE_KEY_PATTERN = @@ -168,7 +167,6 @@ async function getXcodeToolsBridgeDoctorInfo( export async function runDoctor( params: DoctorParams, deps: DoctorDependencies, - showAsciiLogo = false, ): Promise { const prevSilence = process.env.XCODEBUILDMCP_SILENCE_LOGS; process.env.XCODEBUILDMCP_SILENCE_LOGS = 'true'; @@ -356,12 +354,12 @@ export async function runDoctor( events.push(section('UI Automation (axe)', axeLines)); // Incremental Builds - const makefileStatus = - doctorInfo.features.xcodemake.makefileExists === null - ? '(not checked: incremental builds disabled)' - : doctorInfo.features.xcodemake.makefileExists - ? 'Yes' - : 'No'; + let makefileStatus: string; + if (doctorInfo.features.xcodemake.makefileExists === null) { + makefileStatus = '(not checked: incremental builds disabled)'; + } else { + makefileStatus = doctorInfo.features.xcodemake.makefileExists ? 'Yes' : 'No'; + } events.push( section('Incremental Builds', [ `Enabled: ${doctorInfo.features.xcodemake.enabled ? 'Yes' : 'No'}`, @@ -446,12 +444,14 @@ export async function runDoctor( // Tool Availability Summary const buildToolsAvailable = !('error' in doctorInfo.xcode); - const incrementalStatus = - doctorInfo.features.xcodemake.binaryAvailable && doctorInfo.features.xcodemake.enabled - ? 'Available & Enabled' - : doctorInfo.features.xcodemake.binaryAvailable - ? 'Available but Disabled' - : 'Not available'; + let incrementalStatus: string; + if (doctorInfo.features.xcodemake.binaryAvailable && doctorInfo.features.xcodemake.enabled) { + incrementalStatus = 'Available & Enabled'; + } else if (doctorInfo.features.xcodemake.binaryAvailable) { + incrementalStatus = 'Available but Disabled'; + } else { + incrementalStatus = 'Not available'; + } events.push( section('Tool Availability Summary', [ `Build Tools: ${buildToolsAvailable ? 'Available' : 'Not available'}`, @@ -492,22 +492,13 @@ export async function runDoctor( export async function doctorLogic( params: DoctorParams, executor: CommandExecutor, - showAsciiLogo = false, ): Promise { const deps = createDoctorDependencies(executor); - return runDoctor(params, deps, showAsciiLogo); -} - -// MCP wrapper that ensures ASCII logo is never shown for MCP server calls -async function doctorMcpHandler( - params: DoctorParams, - executor: CommandExecutor, -): Promise { - return doctorLogic(params, executor, false); // Always false for MCP + return runDoctor(params, deps); } -export const schema = doctorSchema.shape; // MCP SDK compatibility +export const schema = doctorSchema.shape; -export const handler = createTypedTool(doctorSchema, doctorMcpHandler, getDefaultCommandExecutor); +export const handler = createTypedTool(doctorSchema, doctorLogic, getDefaultCommandExecutor); export type { DoctorDependencies } from './lib/doctor.deps.ts'; diff --git a/src/mcp/tools/logging/__tests__/start_device_log_cap.test.ts b/src/mcp/tools/logging/__tests__/start_device_log_cap.test.ts index 0d03c1a7..5e889b22 100644 --- a/src/mcp/tools/logging/__tests__/start_device_log_cap.test.ts +++ b/src/mcp/tools/logging/__tests__/start_device_log_cap.test.ts @@ -1,7 +1,3 @@ -/** - * Tests for start_device_log_cap plugin - * Following CLAUDE.md testing standards with pure dependency injection - */ import { describe, it, expect, beforeEach } from 'vitest'; import { EventEmitter } from 'events'; import { Readable } from 'stream'; @@ -19,7 +15,7 @@ import { initConfigStore, type RuntimeConfigOverrides, } from '../../../../utils/config-store.ts'; -import type { ToolResponse } from '../../../../types/common.ts'; +import { allText } from '../../../../test-utils/test-helpers.ts'; const cwd = '/repo'; @@ -28,13 +24,6 @@ async function initConfigStoreForTest(overrides?: RuntimeConfigOverrides): Promi await initConfigStore({ cwd, fs: createMockFileSystemExecutor(), overrides }); } -function allText(response: ToolResponse): string { - return response.content - .filter((item) => item.type === 'text') - .map((item) => item.text) - .join('\n'); -} - type Mutable = { -readonly [K in keyof T]: T[K]; }; @@ -45,7 +34,6 @@ type MockChildProcess = Mutable & { }; describe('start_device_log_cap plugin', () => { - // Mock state tracking let commandCalls: Array<{ command: string[]; logPrefix?: string; @@ -68,11 +56,9 @@ describe('start_device_log_cap plugin', () => { }); it('should have correct schema structure', () => { - // Schema should be a plain object for MCP protocol compliance expect(typeof schema).toBe('object'); expect(Object.keys(schema)).toEqual([]); - // Validate that schema fields are Zod types that can be used for validation const schemaObj = z.strictObject(schema); expect(schemaObj.safeParse({ bundleId: 'com.test.app' }).success).toBe(false); expect(schemaObj.safeParse({}).success).toBe(true); @@ -96,7 +82,6 @@ describe('start_device_log_cap plugin', () => { describe('Handler Functionality', () => { it('should start log capture successfully', async () => { - // Mock successful command execution const mockExecutor = createMockExecutor({ success: true, output: 'App launched successfully', @@ -127,7 +112,6 @@ describe('start_device_log_cap plugin', () => { }); it('should include next steps in success response', async () => { - // Mock successful command execution const mockExecutor = createMockExecutor({ success: true, output: 'App launched successfully', @@ -401,7 +385,6 @@ describe('start_device_log_cap plugin', () => { }); it('should handle directory creation failure', async () => { - // Mock mkdir to fail const mockExecutor = createMockExecutor({ success: false, output: '', @@ -431,7 +414,6 @@ describe('start_device_log_cap plugin', () => { }); it('should handle file write failure', async () => { - // Mock writeFile to fail const mockExecutor = createMockExecutor({ success: false, output: '', @@ -464,7 +446,6 @@ describe('start_device_log_cap plugin', () => { }); it('should handle spawn process error', async () => { - // Mock spawn to throw error const mockExecutor = createMockExecutor(new Error('Command not found')); const mockFileSystemExecutor = createMockFileSystemExecutor({ @@ -492,7 +473,6 @@ describe('start_device_log_cap plugin', () => { }); it('should handle string error objects', async () => { - // Mock mkdir to fail with string error const mockExecutor = createMockExecutor('String error message'); const mockFileSystemExecutor = createMockFileSystemExecutor({ diff --git a/src/mcp/tools/logging/__tests__/start_sim_log_cap.test.ts b/src/mcp/tools/logging/__tests__/start_sim_log_cap.test.ts index cde77ade..1f81887a 100644 --- a/src/mcp/tools/logging/__tests__/start_sim_log_cap.test.ts +++ b/src/mcp/tools/logging/__tests__/start_sim_log_cap.test.ts @@ -1,22 +1,10 @@ -/** - * Tests for start_sim_log_cap plugin - */ import { describe, it, expect } from 'vitest'; import * as z from 'zod'; import { schema, handler, start_sim_log_capLogic } from '../start_sim_log_cap.ts'; import { createMockExecutor } from '../../../../test-utils/mock-executors.ts'; -import type { ToolResponse } from '../../../../types/common.ts'; - -function allText(response: ToolResponse): string { - return response.content - .filter((item) => item.type === 'text') - .map((item) => item.text) - .join('\n'); -} +import { allText } from '../../../../test-utils/test-helpers.ts'; describe('start_sim_log_cap plugin', () => { - // Reset any test state if needed - describe('Export Field Validation (Literal)', () => { it('should export schema and handler', () => { expect(schema).toBeDefined(); @@ -36,16 +24,13 @@ describe('start_sim_log_cap plugin', () => { it('should validate schema with subsystemFilter parameter', () => { const schemaObj = z.object(schema); - // Valid enum values expect(schemaObj.safeParse({ subsystemFilter: 'app' }).success).toBe(true); expect(schemaObj.safeParse({ subsystemFilter: 'all' }).success).toBe(true); expect(schemaObj.safeParse({ subsystemFilter: 'swiftui' }).success).toBe(true); - // Valid array of subsystems expect(schemaObj.safeParse({ subsystemFilter: ['com.apple.UIKit'] }).success).toBe(true); expect( schemaObj.safeParse({ subsystemFilter: ['com.apple.UIKit', 'com.apple.CoreData'] }).success, ).toBe(true); - // Invalid values expect(schemaObj.safeParse({ subsystemFilter: [] }).success).toBe(false); expect(schemaObj.safeParse({ subsystemFilter: 'invalid' }).success).toBe(false); expect(schemaObj.safeParse({ subsystemFilter: 123 }).success).toBe(false); @@ -63,9 +48,6 @@ describe('start_sim_log_cap plugin', () => { }); describe('Handler Behavior (Complete Literal Returns)', () => { - // Note: Parameter validation is now handled by createTypedTool wrapper - // Invalid parameters will not reach the logic function, so we test valid scenarios - it('should return error when log capture fails', async () => { const mockExecutor = createMockExecutor({ success: true, output: '' }); const logCaptureStub = (params: any, executor: any) => { @@ -241,7 +223,6 @@ describe('start_sim_log_cap plugin', () => { const logCaptureStub = (params: any, executor: any) => { if (params.captureConsole) { - // Record the console capture spawn call spawnCalls.push({ command: 'xcrun', args: [ @@ -254,7 +235,6 @@ describe('start_sim_log_cap plugin', () => { ], }); } - // Record the structured log capture spawn call spawnCalls.push({ command: 'xcrun', args: [ @@ -288,7 +268,6 @@ describe('start_sim_log_cap plugin', () => { logCaptureStub, ); - // Should spawn both console capture and structured log capture expect(spawnCalls).toHaveLength(2); expect(spawnCalls[0]).toEqual({ command: 'xcrun', @@ -324,7 +303,6 @@ describe('start_sim_log_cap plugin', () => { }> = []; const logCaptureStub = (params: any, executor: any) => { - // Record the structured log capture spawn call only spawnCalls.push({ command: 'xcrun', args: [ @@ -358,7 +336,6 @@ describe('start_sim_log_cap plugin', () => { logCaptureStub, ); - // Should only spawn structured log capture expect(spawnCalls).toHaveLength(1); expect(spawnCalls[0]).toEqual({ command: 'xcrun', diff --git a/src/mcp/tools/logging/__tests__/stop_device_log_cap.test.ts b/src/mcp/tools/logging/__tests__/stop_device_log_cap.test.ts index f212a18e..1522db68 100644 --- a/src/mcp/tools/logging/__tests__/stop_device_log_cap.test.ts +++ b/src/mcp/tools/logging/__tests__/stop_device_log_cap.test.ts @@ -1,6 +1,3 @@ -/** - * Tests for stop_device_log_cap plugin - */ import { describe, it, expect, beforeEach } from 'vitest'; import { EventEmitter } from 'events'; import * as z from 'zod'; @@ -10,20 +7,10 @@ import { type DeviceLogSession, } from '../../../../utils/log-capture/device-log-sessions.ts'; import { createMockFileSystemExecutor } from '../../../../test-utils/mock-executors.ts'; -import type { ToolResponse } from '../../../../types/common.ts'; - -// Note: Logger is allowed to execute normally (integration testing pattern) - -function allText(response: ToolResponse): string { - return response.content - .filter((item) => item.type === 'text') - .map((item) => item.text) - .join('\n'); -} +import { allText } from '../../../../test-utils/test-helpers.ts'; describe('stop_device_log_cap plugin', () => { beforeEach(() => { - // Clear actual active sessions before each test activeDeviceLogSessions.clear(); }); @@ -34,11 +21,9 @@ describe('stop_device_log_cap plugin', () => { }); it('should have correct schema structure', () => { - // Schema should be a plain object for MCP protocol compliance expect(typeof schema).toBe('object'); expect(schema).toHaveProperty('logSessionId'); - // Validate that schema fields are Zod types that can be used for validation const schemaObj = z.object(schema); expect(schemaObj.safeParse({ logSessionId: 'test-session-id' }).success).toBe(true); expect(schemaObj.safeParse({ logSessionId: 123 }).success).toBe(false); @@ -50,7 +35,6 @@ describe('stop_device_log_cap plugin', () => { }); describe('Handler Functionality', () => { - // Helper function to create a test process function createTestProcess( options: { killed?: boolean; diff --git a/src/mcp/tools/logging/__tests__/stop_sim_log_cap.test.ts b/src/mcp/tools/logging/__tests__/stop_sim_log_cap.test.ts index f288e6f0..c8b9f2fa 100644 --- a/src/mcp/tools/logging/__tests__/stop_sim_log_cap.test.ts +++ b/src/mcp/tools/logging/__tests__/stop_sim_log_cap.test.ts @@ -1,16 +1,3 @@ -/** - * stop_sim_log_cap Plugin Tests - Test coverage for stop_sim_log_cap plugin - * - * This test file provides complete coverage for the stop_sim_log_cap plugin: - * - Plugin structure validation - * - Handler functionality (stop log capture session and retrieve captured logs) - * - Error handling for validation and log capture failures - * - * Tests follow the canonical testing patterns from CLAUDE.md with deterministic - * response validation and comprehensive parameter testing. - * Converted to pure dependency injection without vitest mocking. - */ - import { describe, it, expect } from 'vitest'; import * as z from 'zod'; import { schema, handler, stop_sim_log_capLogic } from '../stop_sim_log_cap.ts'; @@ -18,14 +5,7 @@ import { createMockExecutor, createMockFileSystemExecutor, } from '../../../../test-utils/mock-executors.ts'; -import type { ToolResponse } from '../../../../types/common.ts'; - -function allText(response: ToolResponse): string { - return response.content - .filter((item) => item.type === 'text') - .map((item) => item.text) - .join('\n'); -} +import { allText } from '../../../../test-utils/test-helpers.ts'; describe('stop_sim_log_cap plugin', () => { const mockExecutor = createMockExecutor({ success: true, output: '' }); @@ -40,11 +20,9 @@ describe('stop_sim_log_cap plugin', () => { }); it('should have correct schema structure', () => { - // Schema should be a plain object for MCP protocol compliance expect(typeof schema).toBe('object'); expect(schema).toHaveProperty('logSessionId'); - // Validate that schema fields are Zod types that can be used for validation const schemaObj = z.object(schema); expect(schemaObj.safeParse({ logSessionId: 'test-session-id' }).success).toBe(true); expect(schemaObj.safeParse({ logSessionId: 123 }).success).toBe(false); diff --git a/src/mcp/tools/logging/start_device_log_cap.ts b/src/mcp/tools/logging/start_device_log_cap.ts index 728d6faa..16176498 100644 --- a/src/mcp/tools/logging/start_device_log_cap.ts +++ b/src/mcp/tools/logging/start_device_log_cap.ts @@ -624,6 +624,10 @@ export async function start_device_log_capLogic( fileSystemExecutor?: FileSystemExecutor, ): Promise { const { deviceId, bundleId } = params; + const headerEvent = header('Start Log Capture', [ + { label: 'Device', value: deviceId }, + { label: 'Bundle ID', value: bundleId }, + ]); const resolvedFileSystemExecutor = fileSystemExecutor ?? getDefaultFileSystemExecutor(); @@ -635,20 +639,14 @@ export async function start_device_log_capLogic( if (error) { return toolResponse([ - header('Start Log Capture', [ - { label: 'Device', value: deviceId }, - { label: 'Bundle ID', value: bundleId }, - ]), + headerEvent, statusLine('error', `Failed to start device log capture: ${error}`), ]); } return toolResponse( [ - header('Start Log Capture', [ - { label: 'Device', value: deviceId }, - { label: 'Bundle ID', value: bundleId }, - ]), + headerEvent, section('Details', [ `Session ID: ${sessionId}`, 'The app has been launched on the device with console output capture enabled.', diff --git a/src/mcp/tools/logging/start_sim_log_cap.ts b/src/mcp/tools/logging/start_sim_log_cap.ts index c159d611..e5c4c730 100644 --- a/src/mcp/tools/logging/start_sim_log_cap.ts +++ b/src/mcp/tools/logging/start_sim_log_cap.ts @@ -55,6 +55,11 @@ export async function start_sim_log_capLogic( ): Promise { const { bundleId, simulatorId, subsystemFilter } = params; const captureConsole = params.captureConsole ?? false; + const headerEvent = header('Start Log Capture', [ + { label: 'Simulator', value: simulatorId }, + { label: 'Bundle ID', value: bundleId }, + ]); + const logCaptureParams: Parameters[0] = { simulatorUuid: simulatorId, bundleId, @@ -63,13 +68,7 @@ export async function start_sim_log_capLogic( }; const { sessionId, error } = await logCaptureFunction(logCaptureParams, _executor); if (error) { - return toolResponse([ - header('Start Log Capture', [ - { label: 'Simulator', value: simulatorId }, - { label: 'Bundle ID', value: bundleId }, - ]), - statusLine('error', `Error starting log capture: ${error}`), - ]); + return toolResponse([headerEvent, statusLine('error', `Error starting log capture: ${error}`)]); } const filterDescription = buildSubsystemFilterDescription(subsystemFilter); @@ -83,14 +82,7 @@ export async function start_sim_log_capLogic( lines.push('Interact with your simulator and app, then stop capture to retrieve logs.'); return toolResponse( - [ - header('Start Log Capture', [ - { label: 'Simulator', value: simulatorId }, - { label: 'Bundle ID', value: bundleId }, - ]), - section('Details', lines), - statusLine('success', 'Log capture started.'), - ], + [headerEvent, section('Details', lines), statusLine('success', 'Log capture started.')], { nextStepParams: { stop_sim_log_cap: { logSessionId: sessionId }, diff --git a/src/mcp/tools/logging/stop_device_log_cap.ts b/src/mcp/tools/logging/stop_device_log_cap.ts index d449a46e..272025e7 100644 --- a/src/mcp/tools/logging/stop_device_log_cap.ts +++ b/src/mcp/tools/logging/stop_device_log_cap.ts @@ -28,6 +28,7 @@ export async function stop_device_log_capLogic( fileSystemExecutor: FileSystemExecutor, ): Promise { const { logSessionId } = params; + const headerEvent = header('Stop Log Capture', [{ label: 'Session ID', value: logSessionId }]); try { log('info', `Attempting to stop device log capture session: ${logSessionId}`); @@ -40,7 +41,7 @@ export async function stop_device_log_capLogic( if (result.error) { log('error', `Failed to stop device log capture session ${logSessionId}: ${result.error}`); return toolResponse([ - header('Stop Log Capture', [{ label: 'Session ID', value: logSessionId }]), + headerEvent, statusLine( 'error', `Failed to stop device log capture session ${logSessionId}: ${result.error}`, @@ -49,7 +50,7 @@ export async function stop_device_log_capLogic( } return toolResponse([ - header('Stop Log Capture', [{ label: 'Session ID', value: logSessionId }]), + headerEvent, section('Captured Logs', [result.logContent]), statusLine('success', 'Log capture stopped.'), ]); @@ -57,7 +58,7 @@ export async function stop_device_log_capLogic( const message = error instanceof Error ? error.message : String(error); log('error', `Failed to stop device log capture session ${logSessionId}: ${message}`); return toolResponse([ - header('Stop Log Capture', [{ label: 'Session ID', value: logSessionId }]), + headerEvent, statusLine('error', `Failed to stop device log capture session ${logSessionId}: ${message}`), ]); } @@ -69,8 +70,7 @@ export const schema = stopDeviceLogCapSchema.shape; export const handler = createTypedTool( stopDeviceLogCapSchema, - (params: StopDeviceLogCapParams) => { - return stop_device_log_capLogic(params, getDefaultFileSystemExecutor()); - }, + (params: StopDeviceLogCapParams) => + stop_device_log_capLogic(params, getDefaultFileSystemExecutor()), getDefaultCommandExecutor, ); diff --git a/src/mcp/tools/logging/stop_sim_log_cap.ts b/src/mcp/tools/logging/stop_sim_log_cap.ts index e7f73f55..727ad2b1 100644 --- a/src/mcp/tools/logging/stop_sim_log_cap.ts +++ b/src/mcp/tools/logging/stop_sim_log_cap.ts @@ -30,19 +30,22 @@ export type StopLogCaptureFunction = ( export async function stop_sim_log_capLogic( params: StopSimLogCapParams, - neverExecutor: CommandExecutor = getDefaultCommandExecutor(), + _executor: CommandExecutor = getDefaultCommandExecutor(), stopLogCaptureFunction: StopLogCaptureFunction = _stopLogCapture, fileSystem: FileSystemExecutor = getDefaultFileSystemExecutor(), ): Promise { + const headerEvent = header('Stop Log Capture', [ + { label: 'Session ID', value: params.logSessionId }, + ]); const { logContent, error } = await stopLogCaptureFunction(params.logSessionId, fileSystem); if (error) { return toolResponse([ - header('Stop Log Capture', [{ label: 'Session ID', value: params.logSessionId }]), + headerEvent, statusLine('error', `Error stopping log capture session ${params.logSessionId}: ${error}`), ]); } return toolResponse([ - header('Stop Log Capture', [{ label: 'Session ID', value: params.logSessionId }]), + headerEvent, section('Captured Logs', [logContent]), statusLine('success', 'Log capture stopped.'), ]); diff --git a/src/mcp/tools/macos/__tests__/build_macos.test.ts b/src/mcp/tools/macos/__tests__/build_macos.test.ts index 10f078b7..a92ceb9e 100644 --- a/src/mcp/tools/macos/__tests__/build_macos.test.ts +++ b/src/mcp/tools/macos/__tests__/build_macos.test.ts @@ -1,16 +1,8 @@ -/** - * Tests for build_macos plugin (unified) - * Following CLAUDE.md testing standards with literal validation - * Using pure dependency injection for deterministic testing - * NO VITEST MOCKING ALLOWED - Only createMockExecutor and createMockFileSystemExecutor - */ - import { describe, it, expect, beforeEach } from 'vitest'; import * as z from 'zod'; import { createMockExecutor } from '../../../../test-utils/mock-executors.ts'; import { sessionStore } from '../../../../utils/session-store.ts'; -import { schema, handler } from '../build_macos.ts'; -import { buildMacOSLogic } from '../build_macos.ts'; +import { schema, handler, buildMacOSLogic } from '../build_macos.ts'; function expectPendingBuildResponse( result: Awaited>, diff --git a/src/mcp/tools/macos/__tests__/build_run_macos.test.ts b/src/mcp/tools/macos/__tests__/build_run_macos.test.ts index 49d3e5a9..726c682f 100644 --- a/src/mcp/tools/macos/__tests__/build_run_macos.test.ts +++ b/src/mcp/tools/macos/__tests__/build_run_macos.test.ts @@ -2,8 +2,7 @@ import { describe, it, expect, beforeEach } from 'vitest'; import * as z from 'zod'; import { createMockExecutor, mockProcess } from '../../../../test-utils/mock-executors.ts'; import { sessionStore } from '../../../../utils/session-store.ts'; -import { schema, handler } from '../build_run_macos.ts'; -import { buildRunMacOSLogic } from '../build_run_macos.ts'; +import { schema, handler, buildRunMacOSLogic } from '../build_run_macos.ts'; function expectPendingBuildRunResponse( result: Awaited>, diff --git a/src/mcp/tools/macos/__tests__/get_mac_app_path.test.ts b/src/mcp/tools/macos/__tests__/get_mac_app_path.test.ts index efda9ef3..c58568b3 100644 --- a/src/mcp/tools/macos/__tests__/get_mac_app_path.test.ts +++ b/src/mcp/tools/macos/__tests__/get_mac_app_path.test.ts @@ -1,8 +1,3 @@ -/** - * Tests for get_mac_app_path plugin (unified project/workspace) - * Following CLAUDE.md testing standards with literal validation - * Using dependency injection for deterministic testing - */ import { describe, it, expect, beforeEach } from 'vitest'; import * as z from 'zod'; import { @@ -11,8 +6,8 @@ import { type CommandExecutor, } from '../../../../test-utils/mock-executors.ts'; import { sessionStore } from '../../../../utils/session-store.ts'; -import { schema, handler } from '../get_mac_app_path.ts'; -import { get_mac_app_pathLogic } from '../get_mac_app_path.ts'; +import { schema, handler, get_mac_app_pathLogic } from '../get_mac_app_path.ts'; +import { allText } from '../../../../test-utils/test-helpers.ts'; describe('get_mac_app_path plugin', () => { beforeEach(() => { @@ -337,20 +332,13 @@ describe('get_mac_app_path plugin', () => { }); describe('Handler Behavior (Complete Literal Returns)', () => { - function textOf(result: { content: Array<{ type: string; text: string }> }): string { - return result.content - .filter((i) => i.type === 'text') - .map((i) => i.text) - .join('\n'); - } - it('should return Zod validation error for missing scheme', async () => { const result = await handler({ workspacePath: '/path/to/MyProject.xcworkspace', }); expect(result.isError).toBe(true); - const text = textOf(result); + const text = allText(result); expect(text).toContain('scheme is required'); expect(text).toContain('session-set-defaults'); }); @@ -376,7 +364,7 @@ FULL_PRODUCT_NAME = MyApp.app '/Users/test/Library/Developer/Xcode/DerivedData/MyApp-abc123/Build/Products/Debug/MyApp.app'; expect(result.isError).toBeFalsy(); - const text = textOf(result); + const text = allText(result); expect(text).toContain('Get App Path'); expect(text).toContain('Scheme: MyScheme'); expect(text).toContain('Workspace: /path/to/MyProject.xcworkspace'); @@ -410,7 +398,7 @@ FULL_PRODUCT_NAME = MyApp.app '/Users/test/Library/Developer/Xcode/DerivedData/MyApp-abc123/Build/Products/Debug/MyApp.app'; expect(result.isError).toBeFalsy(); - const text = textOf(result); + const text = allText(result); expect(text).toContain('Get App Path'); expect(text).toContain('Scheme: MyScheme'); expect(text).toContain('Project: /path/to/MyProject.xcodeproj'); @@ -438,7 +426,7 @@ FULL_PRODUCT_NAME = MyApp.app ); expect(result.isError).toBe(true); - const text = textOf(result); + const text = allText(result); expect(text).toContain('Get App Path'); expect(text).toContain('Scheme: MyScheme'); expect(text).toContain('No such scheme'); @@ -460,7 +448,7 @@ FULL_PRODUCT_NAME = MyApp.app ); expect(result.isError).toBe(true); - const text = textOf(result); + const text = allText(result); expect(text).toContain('Get App Path'); expect(text).toContain('Could not extract app path from build settings'); expect(result.nextStepParams).toBeUndefined(); @@ -480,7 +468,7 @@ FULL_PRODUCT_NAME = MyApp.app ); expect(result.isError).toBe(true); - const text = textOf(result); + const text = allText(result); expect(text).toContain('Get App Path'); expect(text).toContain('Network error'); expect(result.nextStepParams).toBeUndefined(); diff --git a/src/mcp/tools/macos/__tests__/launch_mac_app.test.ts b/src/mcp/tools/macos/__tests__/launch_mac_app.test.ts index 0a5c935c..3cc7750a 100644 --- a/src/mcp/tools/macos/__tests__/launch_mac_app.test.ts +++ b/src/mcp/tools/macos/__tests__/launch_mac_app.test.ts @@ -1,20 +1,11 @@ -/** - * Pure dependency injection test for launch_mac_app plugin - * - * Tests plugin structure and macOS app launching functionality including parameter validation, - * command generation, file validation, and response formatting. - * - * Uses manual call tracking and createMockFileSystemExecutor for file operations. - */ - import { describe, it, expect } from 'vitest'; import * as z from 'zod'; import { createMockCommandResponse, createMockFileSystemExecutor, } from '../../../../test-utils/mock-executors.ts'; -import { schema, handler } from '../launch_mac_app.ts'; -import { launch_mac_appLogic } from '../launch_mac_app.ts'; +import { schema, handler, launch_mac_appLogic } from '../launch_mac_app.ts'; +import { allText } from '../../../../test-utils/test-helpers.ts'; describe('launch_mac_app plugin', () => { describe('Export Field Validation (Literal)', () => { @@ -181,13 +172,6 @@ describe('launch_mac_app plugin', () => { }); describe('Response Processing', () => { - function textOf(result: { content: Array<{ type: string; text: string }> }): string { - return result.content - .filter((i) => i.type === 'text') - .map((i) => i.text) - .join('\n'); - } - it('should return successful launch response', async () => { const mockExecutor = async () => Promise.resolve(createMockCommandResponse()); @@ -204,7 +188,7 @@ describe('launch_mac_app plugin', () => { ); expect(result.isError).toBeFalsy(); - const text = textOf(result); + const text = allText(result); expect(text).toContain('Launch macOS App'); expect(text).toContain('/path/to/MyApp.app'); expect(text).toContain('App launched successfully'); @@ -227,7 +211,7 @@ describe('launch_mac_app plugin', () => { ); expect(result.isError).toBeFalsy(); - const text = textOf(result); + const text = allText(result); expect(text).toContain('App launched successfully'); }); @@ -249,7 +233,7 @@ describe('launch_mac_app plugin', () => { ); expect(result.isError).toBe(true); - const text = textOf(result); + const text = allText(result); expect(text).toContain('Launch macOS app operation failed: App not found'); }); @@ -271,7 +255,7 @@ describe('launch_mac_app plugin', () => { ); expect(result.isError).toBe(true); - const text = textOf(result); + const text = allText(result); expect(text).toContain('Launch macOS app operation failed: Permission denied'); }); @@ -293,7 +277,7 @@ describe('launch_mac_app plugin', () => { ); expect(result.isError).toBe(true); - const text = textOf(result); + const text = allText(result); expect(text).toContain('Launch macOS app operation failed: 123'); }); }); diff --git a/src/mcp/tools/macos/__tests__/re-exports.test.ts b/src/mcp/tools/macos/__tests__/re-exports.test.ts index ee4540c2..c35cfe8c 100644 --- a/src/mcp/tools/macos/__tests__/re-exports.test.ts +++ b/src/mcp/tools/macos/__tests__/re-exports.test.ts @@ -1,11 +1,5 @@ -/** - * Tests for macos tool module exports - * Validates that tools export the required named exports (schema, handler) - * Note: name and description are now defined in manifests, not in modules - */ import { describe, it, expect } from 'vitest'; -// Import all tool modules using named exports import * as testMacos from '../test_macos.ts'; import * as buildMacos from '../build_macos.ts'; import * as buildRunMacos from '../build_run_macos.ts'; diff --git a/src/mcp/tools/macos/__tests__/stop_mac_app.test.ts b/src/mcp/tools/macos/__tests__/stop_mac_app.test.ts index f3ebd726..f52f32f6 100644 --- a/src/mcp/tools/macos/__tests__/stop_mac_app.test.ts +++ b/src/mcp/tools/macos/__tests__/stop_mac_app.test.ts @@ -1,18 +1,6 @@ -/** - * Pure dependency injection test for stop_mac_app plugin - * - * Tests plugin structure and macOS app stopping functionality including parameter validation, - * command generation, and response formatting. - * - * Uses manual call tracking instead of vitest mocking. - * NO VITEST MOCKING ALLOWED - Only manual stubs - */ - import { describe, it, expect } from 'vitest'; -import * as z from 'zod'; - -import { schema, handler } from '../stop_mac_app.ts'; -import { stop_mac_appLogic } from '../stop_mac_app.ts'; +import { schema, handler, stop_mac_appLogic } from '../stop_mac_app.ts'; +import { allText } from '../../../../test-utils/test-helpers.ts'; describe('stop_mac_app plugin', () => { describe('Export Field Validation (Literal)', () => { @@ -35,19 +23,12 @@ describe('stop_mac_app plugin', () => { }); describe('Input Validation', () => { - function textOf(result: { content: Array<{ type: string; text: string }> }): string { - return result.content - .filter((i) => i.type === 'text') - .map((i) => i.text) - .join('\n'); - } - it('should return exact validation error for missing parameters', async () => { const mockExecutor = async () => ({ success: true, output: '', process: {} as any }); const result = await stop_mac_appLogic({}, mockExecutor); expect(result.isError).toBe(true); - expect(textOf(result)).toContain('Either appName or processId must be provided.'); + expect(allText(result)).toContain('Either appName or processId must be provided.'); }); }); @@ -113,13 +94,6 @@ describe('stop_mac_app plugin', () => { }); describe('Response Processing', () => { - function textOf(result: { content: Array<{ type: string; text: string }> }): string { - return result.content - .filter((i) => i.type === 'text') - .map((i) => i.text) - .join('\n'); - } - it('should return exact successful stop response by app name', async () => { const mockExecutor = async () => ({ success: true, output: '', process: {} as any }); @@ -131,7 +105,7 @@ describe('stop_mac_app plugin', () => { ); expect(result.isError).toBeFalsy(); - const text = textOf(result); + const text = allText(result); expect(text).toContain('Stop macOS App'); expect(text).toContain('Calculator'); expect(text).toContain('App stopped successfully'); @@ -148,7 +122,7 @@ describe('stop_mac_app plugin', () => { ); expect(result.isError).toBeFalsy(); - const text = textOf(result); + const text = allText(result); expect(text).toContain('PID 1234'); expect(text).toContain('App stopped successfully'); }); @@ -165,7 +139,7 @@ describe('stop_mac_app plugin', () => { ); expect(result.isError).toBeFalsy(); - const text = textOf(result); + const text = allText(result); expect(text).toContain('PID 1234'); expect(text).toContain('App stopped successfully'); }); @@ -183,7 +157,7 @@ describe('stop_mac_app plugin', () => { ); expect(result.isError).toBe(true); - const text = textOf(result); + const text = allText(result); expect(text).toContain('Stop macOS app operation failed: Process not found'); }); }); diff --git a/src/mcp/tools/macos/__tests__/test_macos.test.ts b/src/mcp/tools/macos/__tests__/test_macos.test.ts index 7850252b..bc09b290 100644 --- a/src/mcp/tools/macos/__tests__/test_macos.test.ts +++ b/src/mcp/tools/macos/__tests__/test_macos.test.ts @@ -1,8 +1,3 @@ -/** - * Tests for test_macos plugin (unified project/workspace) - * Following CLAUDE.md testing standards with literal validation - * Using dependency injection for deterministic testing - */ import { describe, it, expect, beforeEach } from 'vitest'; import * as z from 'zod'; import { diff --git a/src/mcp/tools/macos/get_mac_app_path.ts b/src/mcp/tools/macos/get_mac_app_path.ts index 71f41e74..4d8f274d 100644 --- a/src/mcp/tools/macos/get_mac_app_path.ts +++ b/src/mcp/tools/macos/get_mac_app_path.ts @@ -78,7 +78,7 @@ export async function get_mac_app_pathLogic( executor: CommandExecutor, ): Promise { const configuration = params.configuration ?? 'Debug'; - const headerParams = buildHeaderParams(params, configuration); + const headerEvent = header('Get App Path', buildHeaderParams(params, configuration)); log('info', `Getting app path for scheme ${params.scheme} on platform macOS`); @@ -110,15 +110,12 @@ export async function get_mac_app_pathLogic( const result = await executor(command, 'Get App Path', false); if (!result.success) { - return toolResponse([ - header('Get App Path', headerParams), - statusLine('error', result.error ?? 'Unknown error'), - ]); + return toolResponse([headerEvent, statusLine('error', result.error ?? 'Unknown error')]); } if (!result.output) { return toolResponse([ - header('Get App Path', headerParams), + headerEvent, statusLine('error', 'Failed to extract build settings output from the result'), ]); } @@ -128,7 +125,7 @@ export async function get_mac_app_pathLogic( if (!builtProductsDirMatch || !fullProductNameMatch) { return toolResponse([ - header('Get App Path', headerParams), + headerEvent, statusLine('error', 'Could not extract app path from build settings'), ]); } @@ -139,7 +136,7 @@ export async function get_mac_app_pathLogic( return toolResponse( [ - header('Get App Path', headerParams), + headerEvent, detailTree([{ label: 'App Path', value: appPath }]), statusLine('success', 'App path resolved.'), ], @@ -153,7 +150,7 @@ export async function get_mac_app_pathLogic( } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); log('error', `Error retrieving app path: ${errorMessage}`); - return toolResponse([header('Get App Path', headerParams), statusLine('error', errorMessage)]); + return toolResponse([headerEvent, statusLine('error', errorMessage)]); } } diff --git a/src/mcp/tools/macos/launch_mac_app.ts b/src/mcp/tools/macos/launch_mac_app.ts index 19347ecb..c9594311 100644 --- a/src/mcp/tools/macos/launch_mac_app.ts +++ b/src/mcp/tools/macos/launch_mac_app.ts @@ -7,7 +7,7 @@ import * as z from 'zod'; import { log } from '../../../utils/logging/index.ts'; -import { validateFileExists } from '../../../utils/validation/index.ts'; +import { validateFileExists } from '../../../utils/validation.ts'; import type { ToolResponse } from '../../../types/common.ts'; import type { CommandExecutor, FileSystemExecutor } from '../../../utils/execution/index.ts'; import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; diff --git a/src/mcp/tools/macos/stop_mac_app.ts b/src/mcp/tools/macos/stop_mac_app.ts index a41cf2d6..733f9d3d 100644 --- a/src/mcp/tools/macos/stop_mac_app.ts +++ b/src/mcp/tools/macos/stop_mac_app.ts @@ -18,8 +18,6 @@ export async function stop_mac_appLogic( params: StopMacAppParams, executor: CommandExecutor, ): Promise { - const target = params.processId ? `PID ${params.processId}` : params.appName; - if (!params.appName && !params.processId) { return toolResponse([ header('Stop macOS App'), @@ -27,6 +25,9 @@ export async function stop_mac_appLogic( ]); } + const target = params.processId ? `PID ${params.processId}` : params.appName!; + const headerEvent = header('Stop macOS App', [{ label: 'Target', value: target }]); + log('info', `Stopping macOS app: ${target}`); try { @@ -44,15 +45,12 @@ export async function stop_mac_appLogic( await executor(command, 'Stop macOS App'); - return toolResponse([ - header('Stop macOS App', [{ label: 'Target', value: target! }]), - statusLine('success', 'App stopped successfully.'), - ]); + return toolResponse([headerEvent, statusLine('success', 'App stopped successfully.')]); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); log('error', `Error stopping macOS app: ${errorMessage}`); return toolResponse([ - header('Stop macOS App', [{ label: 'Target', value: target! }]), + headerEvent, statusLine('error', `Stop macOS app operation failed: ${errorMessage}`), ]); } diff --git a/src/mcp/tools/project-discovery/__tests__/discover_projs.test.ts b/src/mcp/tools/project-discovery/__tests__/discover_projs.test.ts index 02f8ed2a..d2ebf0dd 100644 --- a/src/mcp/tools/project-discovery/__tests__/discover_projs.test.ts +++ b/src/mcp/tools/project-discovery/__tests__/discover_projs.test.ts @@ -1,16 +1,8 @@ -/** - * Pure dependency injection test for discover_projs plugin - * - * Tests the plugin structure and project discovery functionality - * including parameter validation, file system operations, and response formatting. - * - * Uses createMockFileSystemExecutor for file system operations. - */ - import { describe, it, expect, beforeEach } from 'vitest'; import * as z from 'zod'; import { schema, handler, discover_projsLogic, discoverProjects } from '../discover_projs.ts'; import { createMockFileSystemExecutor } from '../../../../test-utils/mock-executors.ts'; +import { allText } from '../../../../test-utils/test-helpers.ts'; describe('discover_projs plugin', () => { let mockFileSystemExecutor: any; @@ -58,13 +50,6 @@ describe('discover_projs plugin', () => { }); describe('Handler Behavior (Complete Literal Returns)', () => { - function textOf(result: { content: Array<{ type: string; text: string }> }): string { - return result.content - .filter((i) => i.type === 'text') - .map((i) => i.text) - .join('\n'); - } - it('returns structured discovery results for setup flows', async () => { mockFileSystemExecutor.stat = async () => ({ isDirectory: () => true, mtimeMs: 0 }); mockFileSystemExecutor.readdir = async () => [ @@ -90,7 +75,7 @@ describe('discover_projs plugin', () => { ); expect(result.isError).toBeFalsy(); - const text = textOf(result); + const text = allText(result); expect(text).toContain('Discover Projects'); expect(text).toContain('Found 0 project(s) and 0 workspace(s).'); }); @@ -110,7 +95,7 @@ describe('discover_projs plugin', () => { ); expect(result.isError).toBe(true); - const text = textOf(result); + const text = allText(result); expect(text).toContain('Discover Projects'); expect(text).toContain( 'Failed to access scan path: /workspace. Error: ENOENT: no such file or directory', @@ -130,7 +115,7 @@ describe('discover_projs plugin', () => { ); expect(result.isError).toBe(true); - const text = textOf(result); + const text = allText(result); expect(text).toContain('Scan path is not a directory: /workspace'); }); @@ -148,7 +133,7 @@ describe('discover_projs plugin', () => { ); expect(result.isError).toBeFalsy(); - const text = textOf(result); + const text = allText(result); expect(text).toContain('Found 0 project(s) and 0 workspace(s).'); }); @@ -169,7 +154,7 @@ describe('discover_projs plugin', () => { ); expect(result.isError).toBeFalsy(); - const text = textOf(result); + const text = allText(result); expect(text).toContain('Found 1 project(s) and 1 workspace(s).'); expect(text).toContain('/workspace/MyApp.xcodeproj'); expect(text).toContain('/workspace/MyWorkspace.xcworkspace'); @@ -193,7 +178,7 @@ describe('discover_projs plugin', () => { ); expect(result.isError).toBe(true); - const text = textOf(result); + const text = allText(result); expect(text).toContain('Failed to access scan path: /workspace. Error: Permission denied'); }); @@ -212,7 +197,7 @@ describe('discover_projs plugin', () => { ); expect(result.isError).toBe(true); - const text = textOf(result); + const text = allText(result); expect(text).toContain('Failed to access scan path: /workspace. Error: String error'); }); @@ -228,7 +213,7 @@ describe('discover_projs plugin', () => { ); expect(result.isError).toBeFalsy(); - const text = textOf(result); + const text = allText(result); expect(text).toContain('Found 0 project(s) and 0 workspace(s).'); }); @@ -246,7 +231,7 @@ describe('discover_projs plugin', () => { ); expect(result.isError).toBeFalsy(); - const text = textOf(result); + const text = allText(result); expect(text).toContain('Found 0 project(s) and 0 workspace(s).'); }); @@ -269,7 +254,7 @@ describe('discover_projs plugin', () => { ); expect(result.isError).toBe(true); - const text = textOf(result); + const text = allText(result); expect(text).toContain('Failed to access scan path: /workspace. Error: Access denied'); }); @@ -301,7 +286,7 @@ describe('discover_projs plugin', () => { ); expect(result.isError).toBeFalsy(); - const text = textOf(result); + const text = allText(result); expect(text).toContain('Found 0 project(s) and 0 workspace(s).'); }); @@ -324,7 +309,7 @@ describe('discover_projs plugin', () => { ); expect(result.isError).toBeFalsy(); - const text = textOf(result); + const text = allText(result); expect(text).toContain('Found 0 project(s) and 0 workspace(s).'); }); @@ -346,7 +331,7 @@ describe('discover_projs plugin', () => { ); expect(result.isError).toBeFalsy(); - const text = textOf(result); + const text = allText(result); expect(text).toContain('Found 0 project(s) and 0 workspace(s).'); }); }); diff --git a/src/mcp/tools/project-discovery/__tests__/get_app_bundle_id.test.ts b/src/mcp/tools/project-discovery/__tests__/get_app_bundle_id.test.ts index e1aecabb..aab78119 100644 --- a/src/mcp/tools/project-discovery/__tests__/get_app_bundle_id.test.ts +++ b/src/mcp/tools/project-discovery/__tests__/get_app_bundle_id.test.ts @@ -1,13 +1,3 @@ -/** - * Test for get_app_bundle_id plugin - Dependency Injection Architecture - * - * Tests the plugin structure and exported components for get_app_bundle_id tool. - * Uses pure dependency injection with createMockFileSystemExecutor. - * NO VITEST MOCKING ALLOWED - Only createMockFileSystemExecutor - * - * Plugin location: plugins/project-discovery/get_app_bundle_id.ts - */ - import { describe, it, expect } from 'vitest'; import * as z from 'zod'; import { schema, handler, get_app_bundle_idLogic } from '../get_app_bundle_id.ts'; @@ -15,15 +5,9 @@ import { createMockFileSystemExecutor, createCommandMatchingMockExecutor, } from '../../../../test-utils/mock-executors.ts'; +import { allText } from '../../../../test-utils/test-helpers.ts'; describe('get_app_bundle_id plugin', () => { - function textOf(result: { content: Array<{ type: string; text: string }> }): string { - return result.content - .filter((i) => i.type === 'text') - .map((i) => i.text) - .join('\n'); - } - const createMockExecutorForCommands = (results: Record) => { return createCommandMatchingMockExecutor( Object.fromEntries( @@ -79,7 +63,7 @@ describe('get_app_bundle_id plugin', () => { ); expect(result.isError).toBe(true); - const text = textOf(result); + const text = allText(result); expect(text).toContain('Get Bundle ID'); expect(text).toContain("File not found: '/path/to/MyApp.app'"); }); @@ -99,7 +83,7 @@ describe('get_app_bundle_id plugin', () => { ); expect(result.isError).toBeFalsy(); - const text = textOf(result); + const text = allText(result); expect(text).toContain('Get Bundle ID'); expect(text).toContain('Bundle ID: io.sentry.MyApp'); expect(result.nextStepParams).toEqual({ @@ -129,7 +113,7 @@ describe('get_app_bundle_id plugin', () => { ); expect(result.isError).toBeFalsy(); - const text = textOf(result); + const text = allText(result); expect(text).toContain('Bundle ID: io.sentry.MyApp'); expect(result.nextStepParams).toEqual({ install_app_sim: { simulatorId: 'SIMULATOR_UUID', appPath: '/path/to/MyApp.app' }, @@ -158,7 +142,7 @@ describe('get_app_bundle_id plugin', () => { ); expect(result.isError).toBe(true); - const text = textOf(result); + const text = allText(result); expect(text).toContain('Could not extract bundle ID from Info.plist: Command failed'); expect(text).toContain('Make sure the path points to a valid app bundle (.app directory).'); }); @@ -182,7 +166,7 @@ describe('get_app_bundle_id plugin', () => { ); expect(result.isError).toBe(true); - const text = textOf(result); + const text = allText(result); expect(text).toContain('Could not extract bundle ID from Info.plist: Custom error message'); expect(text).toContain('Make sure the path points to a valid app bundle (.app directory).'); }); @@ -206,7 +190,7 @@ describe('get_app_bundle_id plugin', () => { ); expect(result.isError).toBe(true); - const text = textOf(result); + const text = allText(result); expect(text).toContain('Could not extract bundle ID from Info.plist: String error'); expect(text).toContain('Make sure the path points to a valid app bundle (.app directory).'); }); diff --git a/src/mcp/tools/project-discovery/__tests__/get_mac_bundle_id.test.ts b/src/mcp/tools/project-discovery/__tests__/get_mac_bundle_id.test.ts index 32e46a4a..8a0ba368 100644 --- a/src/mcp/tools/project-discovery/__tests__/get_mac_bundle_id.test.ts +++ b/src/mcp/tools/project-discovery/__tests__/get_mac_bundle_id.test.ts @@ -5,15 +5,9 @@ import { createMockFileSystemExecutor, createCommandMatchingMockExecutor, } from '../../../../test-utils/mock-executors.ts'; +import { allText } from '../../../../test-utils/test-helpers.ts'; describe('get_mac_bundle_id plugin', () => { - function textOf(result: { content: Array<{ type: string; text: string }> }): string { - return result.content - .filter((i) => i.type === 'text') - .map((i) => i.text) - .join('\n'); - } - const createMockExecutorForCommands = (results: Record) => { return createCommandMatchingMockExecutor( Object.fromEntries( @@ -61,7 +55,7 @@ describe('get_mac_bundle_id plugin', () => { ); expect(result.isError).toBe(true); - const text = textOf(result); + const text = allText(result); expect(text).toContain('Get macOS Bundle ID'); expect(text).toContain("File not found: '/Applications/MyApp.app'"); }); @@ -82,7 +76,7 @@ describe('get_mac_bundle_id plugin', () => { ); expect(result.isError).toBeFalsy(); - const text = textOf(result); + const text = allText(result); expect(text).toContain('Get macOS Bundle ID'); expect(text).toContain('Bundle ID: io.sentry.MyMacApp'); expect(result.nextStepParams).toEqual({ @@ -110,7 +104,7 @@ describe('get_mac_bundle_id plugin', () => { ); expect(result.isError).toBeFalsy(); - const text = textOf(result); + const text = allText(result); expect(text).toContain('Bundle ID: io.sentry.MyMacApp'); expect(result.nextStepParams).toEqual({ launch_mac_app: { appPath: '/Applications/MyApp.app' }, @@ -137,7 +131,7 @@ describe('get_mac_bundle_id plugin', () => { ); expect(result.isError).toBe(true); - const text = textOf(result); + const text = allText(result); expect(text).toContain('Could not extract bundle ID from Info.plist'); expect(text).toContain('Command failed'); expect(text).toContain( @@ -164,7 +158,7 @@ describe('get_mac_bundle_id plugin', () => { ); expect(result.isError).toBe(true); - const text = textOf(result); + const text = allText(result); expect(text).toContain('Could not extract bundle ID from Info.plist'); expect(text).toContain('Custom error message'); expect(text).toContain( @@ -191,7 +185,7 @@ describe('get_mac_bundle_id plugin', () => { ); expect(result.isError).toBe(true); - const text = textOf(result); + const text = allText(result); expect(text).toContain('Could not extract bundle ID from Info.plist'); expect(text).toContain('String error'); expect(text).toContain( diff --git a/src/mcp/tools/project-discovery/__tests__/list_schemes.test.ts b/src/mcp/tools/project-discovery/__tests__/list_schemes.test.ts index e2a6f2bb..e0c6e97d 100644 --- a/src/mcp/tools/project-discovery/__tests__/list_schemes.test.ts +++ b/src/mcp/tools/project-discovery/__tests__/list_schemes.test.ts @@ -1,9 +1,3 @@ -/** - * Tests for list_schemes plugin - * Following CLAUDE.md testing standards with literal validation - * Using dependency injection for deterministic testing - */ - import { describe, it, expect, beforeEach } from 'vitest'; import * as z from 'zod'; import { @@ -12,15 +6,9 @@ import { } from '../../../../test-utils/mock-executors.ts'; import { schema, handler, listSchemes, listSchemesLogic } from '../list_schemes.ts'; import { sessionStore } from '../../../../utils/session-store.ts'; +import { allText } from '../../../../test-utils/test-helpers.ts'; describe('list_schemes plugin', () => { - function textOf(result: { content: Array<{ type: string; text: string }> }): string { - return result.content - .filter((i) => i.type === 'text') - .map((i) => i.text) - .join('\n'); - } - beforeEach(() => { sessionStore.clear(); }); @@ -67,7 +55,7 @@ describe('list_schemes plugin', () => { ); expect(result.isError).toBeFalsy(); - const text = textOf(result); + const text = allText(result); expect(text).toContain('List Schemes'); expect(text).toContain('Project: /path/to/MyProject.xcodeproj'); expect(text).toContain('MyProject'); @@ -100,7 +88,7 @@ describe('list_schemes plugin', () => { ); expect(result.isError).toBe(true); - const text = textOf(result); + const text = allText(result); expect(text).toContain('List Schemes'); expect(text).toContain('Project: /path/to/MyProject.xcodeproj'); expect(text).toContain('Project not found'); @@ -119,7 +107,7 @@ describe('list_schemes plugin', () => { ); expect(result.isError).toBe(true); - const text = textOf(result); + const text = allText(result); expect(text).toContain('List Schemes'); expect(text).toContain('No schemes found in the output'); expect(result.nextStepParams).toBeUndefined(); @@ -147,7 +135,7 @@ describe('list_schemes plugin', () => { ); expect(result.isError).toBeFalsy(); - const text = textOf(result); + const text = allText(result); expect(text).toContain('List Schemes'); expect(text).toContain('(none)'); expect(result.nextStepParams).toBeUndefined(); @@ -164,7 +152,7 @@ describe('list_schemes plugin', () => { ); expect(result.isError).toBe(true); - const text = textOf(result); + const text = allText(result); expect(text).toContain('List Schemes'); expect(text).toContain('Command execution failed'); expect(result.nextStepParams).toBeUndefined(); @@ -181,7 +169,7 @@ describe('list_schemes plugin', () => { ); expect(result.isError).toBe(true); - const text = textOf(result); + const text = allText(result); expect(text).toContain('List Schemes'); expect(text).toContain('String error'); expect(result.nextStepParams).toBeUndefined(); @@ -294,7 +282,7 @@ describe('list_schemes plugin', () => { ); expect(result.isError).toBeFalsy(); - const text = textOf(result); + const text = allText(result); expect(text).toContain('List Schemes'); expect(text).toContain('Workspace: /path/to/MyProject.xcworkspace'); expect(text).toContain('MyApp'); diff --git a/src/mcp/tools/project-discovery/__tests__/show_build_settings.test.ts b/src/mcp/tools/project-discovery/__tests__/show_build_settings.test.ts index d168f584..a5d1b8f1 100644 --- a/src/mcp/tools/project-discovery/__tests__/show_build_settings.test.ts +++ b/src/mcp/tools/project-discovery/__tests__/show_build_settings.test.ts @@ -3,15 +3,9 @@ import * as z from 'zod'; import { createMockExecutor, type CommandExecutor } from '../../../../test-utils/mock-executors.ts'; import { schema, handler, showBuildSettingsLogic } from '../show_build_settings.ts'; import { sessionStore } from '../../../../utils/session-store.ts'; +import { allText } from '../../../../test-utils/test-helpers.ts'; describe('show_build_settings plugin', () => { - function textOf(result: { content: Array<{ type: string; text: string }> }): string { - return result.content - .filter((i) => i.type === 'text') - .map((i) => i.text) - .join('\n'); - } - beforeEach(() => { sessionStore.clear(); }); @@ -43,7 +37,7 @@ describe('show_build_settings plugin', () => { mockExecutor, ); expect(result.isError).toBeFalsy(); - const text = textOf(result); + const text = allText(result); expect(text).toContain('Show Build Settings'); expect(text).toContain('Scheme: MyScheme'); }); @@ -108,7 +102,7 @@ Build settings for action build and target MyApp: ]); expect(result.isError).toBeFalsy(); - const text = textOf(result); + const text = allText(result); expect(text).toContain('Show Build Settings'); expect(text).toContain('Scheme: MyScheme'); expect(text).toContain('Project: /path/to/MyProject.xcodeproj'); @@ -143,7 +137,7 @@ Build settings for action build and target MyApp: ); expect(result.isError).toBe(true); - const text = textOf(result); + const text = allText(result); expect(text).toContain('Show Build Settings'); expect(text).toContain( 'The workspace named "App" does not contain a scheme named "InvalidScheme".', @@ -165,7 +159,7 @@ Build settings for action build and target MyApp: ); expect(result.isError).toBe(true); - const text = textOf(result); + const text = allText(result); expect(text).toContain('Show Build Settings'); expect(text).toContain('Command execution failed'); expect(result.nextStepParams).toBeUndefined(); @@ -206,7 +200,7 @@ Build settings for action build and target MyApp: ); expect(result.isError).toBeFalsy(); - const text = textOf(result); + const text = allText(result); expect(text).toContain('Show Build Settings'); expect(text).toContain('Scheme: MyScheme'); }); @@ -223,7 +217,7 @@ Build settings for action build and target MyApp: ); expect(result.isError).toBeFalsy(); - const text = textOf(result); + const text = allText(result); expect(text).toContain('Show Build Settings'); expect(text).toContain('Workspace:'); }); diff --git a/src/mcp/tools/project-discovery/discover_projs.ts b/src/mcp/tools/project-discovery/discover_projs.ts index 9c88d824..44acb933 100644 --- a/src/mcp/tools/project-discovery/discover_projs.ts +++ b/src/mcp/tools/project-discovery/discover_projs.ts @@ -228,9 +228,10 @@ export async function discover_projsLogic( params: DiscoverProjsParams, fileSystemExecutor: FileSystemExecutor, ): Promise { + const headerEvent = header('Discover Projects'); const results = await discoverProjectsOrError(params, fileSystemExecutor); if ('error' in results) { - return toolResponse([header('Discover Projects'), statusLine('error', results.error)]); + return toolResponse([headerEvent, statusLine('error', results.error)]); } log( @@ -239,7 +240,7 @@ export async function discover_projsLogic( ); const events = [ - header('Discover Projects'), + headerEvent, statusLine( 'success', `Found ${results.projects.length} project(s) and ${results.workspaces.length} workspace(s).`, @@ -270,8 +271,6 @@ export const schema = discoverProjsSchema.shape; export const handler = createTypedTool( discoverProjsSchema, - (params: DiscoverProjsParams) => { - return discover_projsLogic(params, getDefaultFileSystemExecutor()); - }, + (params: DiscoverProjsParams) => discover_projsLogic(params, getDefaultFileSystemExecutor()), getDefaultCommandExecutor, ); diff --git a/src/mcp/tools/project-discovery/get_app_bundle_id.ts b/src/mcp/tools/project-discovery/get_app_bundle_id.ts index d046d0e9..f21ba9ac 100644 --- a/src/mcp/tools/project-discovery/get_app_bundle_id.ts +++ b/src/mcp/tools/project-discovery/get_app_bundle_id.ts @@ -32,10 +32,11 @@ export async function get_app_bundle_idLogic( fileSystemExecutor: FileSystemExecutor, ): Promise { const appPath = params.appPath; + const headerEvent = header('Get Bundle ID', [{ label: 'App', value: appPath }]); if (!fileSystemExecutor.existsSync(appPath)) { return toolResponse([ - header('Get Bundle ID', [{ label: 'App', value: appPath }]), + headerEvent, statusLine('error', `File not found: '${appPath}'. Please check the path and try again.`), ]); } @@ -55,27 +56,21 @@ export async function get_app_bundle_idLogic( log('info', `Extracted app bundle ID: ${bundleId}`); - return toolResponse( - [ - header('Get Bundle ID', [{ label: 'App', value: appPath }]), - statusLine('success', `Bundle ID: ${bundleId}`), - ], - { - nextStepParams: { - install_app_sim: { simulatorId: 'SIMULATOR_UUID', appPath }, - launch_app_sim: { simulatorId: 'SIMULATOR_UUID', bundleId: bundleId.trim() }, - install_app_device: { deviceId: 'DEVICE_UDID', appPath }, - launch_app_device: { deviceId: 'DEVICE_UDID', bundleId: bundleId.trim() }, - }, + return toolResponse([headerEvent, statusLine('success', `Bundle ID: ${bundleId}`)], { + nextStepParams: { + install_app_sim: { simulatorId: 'SIMULATOR_UUID', appPath }, + launch_app_sim: { simulatorId: 'SIMULATOR_UUID', bundleId: bundleId.trim() }, + install_app_device: { deviceId: 'DEVICE_UDID', appPath }, + launch_app_device: { deviceId: 'DEVICE_UDID', bundleId: bundleId.trim() }, }, - ); + }); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); log('error', `Error extracting app bundle ID: ${errorMessage}`); return toolResponse([ - header('Get Bundle ID', [{ label: 'App', value: appPath }]), - statusLine('error', `${errorMessage}`), + headerEvent, + statusLine('error', errorMessage), statusLine('info', 'Make sure the path points to a valid app bundle (.app directory).'), ]); } diff --git a/src/mcp/tools/project-discovery/get_mac_bundle_id.ts b/src/mcp/tools/project-discovery/get_mac_bundle_id.ts index 3452b4ca..b73af8aa 100644 --- a/src/mcp/tools/project-discovery/get_mac_bundle_id.ts +++ b/src/mcp/tools/project-discovery/get_mac_bundle_id.ts @@ -37,10 +37,11 @@ export async function get_mac_bundle_idLogic( fileSystemExecutor: FileSystemExecutor, ): Promise { const appPath = params.appPath; + const headerEvent = header('Get macOS Bundle ID', [{ label: 'App', value: appPath }]); if (!fileSystemExecutor.existsSync(appPath)) { return toolResponse([ - header('Get macOS Bundle ID', [{ label: 'App', value: appPath }]), + headerEvent, statusLine('error', `File not found: '${appPath}'. Please check the path and try again.`), ]); } @@ -70,25 +71,19 @@ export async function get_mac_bundle_idLogic( log('info', `Extracted macOS bundle ID: ${bundleId}`); - return toolResponse( - [ - header('Get macOS Bundle ID', [{ label: 'App', value: appPath }]), - statusLine('success', `Bundle ID: ${bundleId}`), - ], - { - nextStepParams: { - launch_mac_app: { appPath }, - build_macos: { scheme: 'SCHEME_NAME' }, - }, + return toolResponse([headerEvent, statusLine('success', `Bundle ID: ${bundleId}`)], { + nextStepParams: { + launch_mac_app: { appPath }, + build_macos: { scheme: 'SCHEME_NAME' }, }, - ); + }); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); log('error', `Error extracting macOS bundle ID: ${errorMessage}`); return toolResponse([ - header('Get macOS Bundle ID', [{ label: 'App', value: appPath }]), - statusLine('error', `${errorMessage}`), + headerEvent, + statusLine('error', errorMessage), statusLine('info', 'Make sure the path points to a valid macOS app bundle (.app directory).'), ]); } diff --git a/src/mcp/tools/project-discovery/list_schemes.ts b/src/mcp/tools/project-discovery/list_schemes.ts index 720b0c63..4d79c776 100644 --- a/src/mcp/tools/project-discovery/list_schemes.ts +++ b/src/mcp/tools/project-discovery/list_schemes.ts @@ -84,9 +84,12 @@ export async function listSchemesLogic( const projectOrWorkspace = hasProjectPath ? 'project' : 'workspace'; const pathValue = hasProjectPath ? params.projectPath : params.workspacePath; - const headerParams = hasProjectPath - ? [{ label: 'Project', value: pathValue! }] - : [{ label: 'Workspace', value: pathValue! }]; + const headerEvent = header( + 'List Schemes', + hasProjectPath + ? [{ label: 'Project', value: pathValue! }] + : [{ label: 'Workspace', value: pathValue! }], + ); try { const schemes = await listSchemes(params, executor); @@ -116,7 +119,7 @@ export async function listSchemesLogic( return toolResponse( [ - header('List Schemes', headerParams), + headerEvent, statusLine('success', `Found ${schemes.length} scheme(s).`), section('Schemes', schemeItems), ], @@ -130,7 +133,7 @@ export async function listSchemesLogic( ? errorMessage.slice('Failed to list schemes: '.length) : errorMessage; - return toolResponse([header('List Schemes', headerParams), statusLine('error', rawError)]); + return toolResponse([headerEvent, statusLine('error', rawError)]); } } diff --git a/src/mcp/tools/project-discovery/show_build_settings.ts b/src/mcp/tools/project-discovery/show_build_settings.ts index 6f731f64..895ed353 100644 --- a/src/mcp/tools/project-discovery/show_build_settings.ts +++ b/src/mcp/tools/project-discovery/show_build_settings.ts @@ -60,12 +60,12 @@ export async function showBuildSettingsLogic( const hasProjectPath = typeof params.projectPath === 'string'; const pathValue = hasProjectPath ? params.projectPath : params.workspacePath; - const headerParams = [ + const headerEvent = header('Show Build Settings', [ { label: 'Scheme', value: params.scheme }, ...(hasProjectPath ? [{ label: 'Project', value: params.projectPath! }] : [{ label: 'Workspace', value: params.workspacePath! }]), - ]; + ]); try { const command = ['xcodebuild', '-showBuildSettings']; @@ -81,10 +81,7 @@ export async function showBuildSettingsLogic( const result = await executor(command, 'Show Build Settings', false); if (!result.success) { - return toolResponse([ - header('Show Build Settings', headerParams), - statusLine('error', result.error || 'Unknown error'), - ]); + return toolResponse([headerEvent, statusLine('error', result.error || 'Unknown error')]); } const settingsOutput = stripXcodebuildPreamble( @@ -106,7 +103,7 @@ export async function showBuildSettingsLogic( return toolResponse( [ - header('Show Build Settings', headerParams), + headerEvent, statusLine('success', 'Build settings retrieved.'), section('Settings', settingsLines), ], @@ -115,10 +112,7 @@ export async function showBuildSettingsLogic( } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); log('error', `Error showing build settings: ${errorMessage}`); - return toolResponse([ - header('Show Build Settings', headerParams), - statusLine('error', errorMessage), - ]); + return toolResponse([headerEvent, statusLine('error', errorMessage)]); } } diff --git a/src/mcp/tools/project-scaffolding/__tests__/scaffold_ios_project.test.ts b/src/mcp/tools/project-scaffolding/__tests__/scaffold_ios_project.test.ts index d247b2c5..ca52325f 100644 --- a/src/mcp/tools/project-scaffolding/__tests__/scaffold_ios_project.test.ts +++ b/src/mcp/tools/project-scaffolding/__tests__/scaffold_ios_project.test.ts @@ -1,12 +1,3 @@ -/** - * Vitest test for scaffold_ios_project plugin - * - * Tests the plugin structure and iOS scaffold tool functionality - * including parameter validation, file operations, template processing, and response formatting. - * - * Plugin location: plugins/utilities/scaffold_ios_project.js - */ - import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import * as z from 'zod'; import { schema, handler, scaffold_ios_projectLogic } from '../scaffold_ios_project.ts'; @@ -19,6 +10,7 @@ import { initConfigStore, type RuntimeConfigOverrides, } from '../../../../utils/config-store.ts'; +import { allText } from '../../../../test-utils/test-helpers.ts'; const cwd = '/repo'; @@ -32,7 +24,6 @@ describe('scaffold_ios_project plugin', () => { let mockFileSystemExecutor: any; beforeEach(async () => { - // Create mock executor using approved utility mockCommandExecutor = createMockExecutor({ success: true, output: 'Command executed successfully', @@ -40,7 +31,6 @@ describe('scaffold_ios_project plugin', () => { mockFileSystemExecutor = createMockFileSystemExecutor({ existsSync: (path) => { - // Mock template directories exist but project files don't return ( path.includes('xcodebuild-mcp-template') || path.includes('XcodeBuildMCP-iOS-Template') || @@ -73,7 +63,6 @@ describe('scaffold_ios_project plugin', () => { it('should have valid schema with required fields', () => { const schemaObj = z.object(schema); - // Test valid input expect( schemaObj.safeParse({ projectName: 'MyTestApp', @@ -90,7 +79,6 @@ describe('scaffold_ios_project plugin', () => { }).success, ).toBe(true); - // Test minimal valid input expect( schemaObj.safeParse({ projectName: 'MyTestApp', @@ -98,21 +86,18 @@ describe('scaffold_ios_project plugin', () => { }).success, ).toBe(true); - // Test invalid input - missing projectName expect( schemaObj.safeParse({ outputPath: '/path/to/output', }).success, ).toBe(false); - // Test invalid input - missing outputPath expect( schemaObj.safeParse({ projectName: 'MyTestApp', }).success, ).toBe(false); - // Test invalid input - wrong type for customizeNames expect( schemaObj.safeParse({ projectName: 'MyTestApp', @@ -121,7 +106,6 @@ describe('scaffold_ios_project plugin', () => { }).success, ).toBe(false); - // Test invalid input - wrong enum value for targetedDeviceFamily expect( schemaObj.safeParse({ projectName: 'MyTestApp', @@ -130,7 +114,6 @@ describe('scaffold_ios_project plugin', () => { }).success, ).toBe(false); - // Test invalid input - wrong enum value for supportedOrientations expect( schemaObj.safeParse({ projectName: 'MyTestApp', @@ -145,13 +128,11 @@ describe('scaffold_ios_project plugin', () => { it('should generate correct curl command for iOS template download', async () => { await initConfigStoreForTest({ iosTemplatePath: '' }); - // Track commands executed let capturedCommands: string[][] = []; const trackingCommandExecutor = createMockExecutor({ success: true, output: 'Command executed successfully', }); - // Wrap to capture commands const capturingExecutor = async (command: string[], ...args: any[]) => { capturedCommands.push(command); return trackingCommandExecutor(command, ...args); @@ -167,7 +148,6 @@ describe('scaffold_ios_project plugin', () => { mockFileSystemExecutor, ); - // Verify curl command was executed const curlCommand = capturedCommands.find((cmd) => cmd.includes('curl')); expect(curlCommand).toBeDefined(); expect(curlCommand).toEqual([ @@ -187,10 +167,8 @@ describe('scaffold_ios_project plugin', () => { it.skip('should generate correct unzip command for iOS template extraction', async () => { await initConfigStoreForTest({ iosTemplatePath: '' }); - // Create a mock that returns false for local template paths to force download const downloadMockFileSystemExecutor = createMockFileSystemExecutor({ existsSync: (path) => { - // Only return true for extracted template directories, false for local template paths return ( path.includes('xcodebuild-mcp-template') || path.includes('XcodeBuildMCP-iOS-Template') || @@ -209,13 +187,11 @@ describe('scaffold_ios_project plugin', () => { stat: async () => ({ isDirectory: () => true, mtimeMs: 0 }), }); - // Track commands executed let capturedCommands: string[][] = []; const trackingCommandExecutor = createMockExecutor({ success: true, output: 'Command executed successfully', }); - // Wrap to capture commands const capturingExecutor = async (command: string[], ...args: any[]) => { capturedCommands.push(command); return trackingCommandExecutor(command, ...args); @@ -231,7 +207,6 @@ describe('scaffold_ios_project plugin', () => { downloadMockFileSystemExecutor, ); - // Verify unzip command was executed const unzipCommand = capturedCommands.find((cmd) => cmd.includes('unzip')); expect(unzipCommand).toBeDefined(); expect(unzipCommand).toEqual(['unzip', '-q', expect.stringMatching(/template\.zip$/)]); @@ -242,13 +217,11 @@ describe('scaffold_ios_project plugin', () => { it('should generate correct commands when using custom template version', async () => { await initConfigStoreForTest({ iosTemplatePath: '', iosTemplateVersion: 'v2.0.0' }); - // Track commands executed let capturedCommands: string[][] = []; const trackingCommandExecutor = createMockExecutor({ success: true, output: 'Command executed successfully', }); - // Wrap to capture commands const capturingExecutor = async (command: string[], ...args: any[]) => { capturedCommands.push(command); return trackingCommandExecutor(command, ...args); @@ -264,7 +237,6 @@ describe('scaffold_ios_project plugin', () => { mockFileSystemExecutor, ); - // Verify curl command uses custom version const curlCommand = capturedCommands.find((cmd) => cmd.includes('curl')); expect(curlCommand).toBeDefined(); expect(curlCommand).toEqual([ @@ -282,10 +254,8 @@ describe('scaffold_ios_project plugin', () => { it.skip('should generate correct commands with no command executor passed', async () => { await initConfigStoreForTest({ iosTemplatePath: '' }); - // Create a mock that returns false for local template paths to force download const downloadMockFileSystemExecutor = createMockFileSystemExecutor({ existsSync: (path) => { - // Only return true for extracted template directories, false for local template paths return ( path.includes('xcodebuild-mcp-template') || path.includes('XcodeBuildMCP-iOS-Template') || @@ -304,13 +274,11 @@ describe('scaffold_ios_project plugin', () => { stat: async () => ({ isDirectory: () => true, mtimeMs: 0 }), }); - // Track commands executed - using default executor path let capturedCommands: string[][] = []; const trackingCommandExecutor = createMockExecutor({ success: true, output: 'Command executed successfully', }); - // Wrap to capture commands const capturingExecutor = async (command: string[], ...args: any[]) => { capturedCommands.push(command); return trackingCommandExecutor(command, ...args); @@ -326,7 +294,6 @@ describe('scaffold_ios_project plugin', () => { downloadMockFileSystemExecutor, ); - // Verify both curl and unzip commands were executed in sequence expect(capturedCommands.length).toBeGreaterThanOrEqual(2); const curlCommand = capturedCommands.find((cmd) => cmd.includes('curl')); @@ -345,13 +312,6 @@ describe('scaffold_ios_project plugin', () => { }); describe('Handler Behavior (Complete Literal Returns)', () => { - function textOf(result: { content: Array<{ type: string; text: string }> }): string { - return result.content - .filter((i) => i.type === 'text') - .map((i) => i.text) - .join('\n'); - } - it('should return success response for valid scaffold iOS project request', async () => { const result = await scaffold_ios_projectLogic( { @@ -365,7 +325,7 @@ describe('scaffold_ios_project plugin', () => { ); expect(result.isError).toBeFalsy(); - const text = textOf(result); + const text = allText(result); expect(text).toContain('Scaffold iOS Project'); expect(text).toContain('TestIOSApp'); expect(text).toContain('/tmp/test-projects'); @@ -404,7 +364,7 @@ describe('scaffold_ios_project plugin', () => { ); expect(result.isError).toBeFalsy(); - const text = textOf(result); + const text = allText(result); expect(text).toContain('Project scaffolded successfully'); expect(result.nextStepParams).toEqual({ build_sim: { @@ -432,7 +392,7 @@ describe('scaffold_ios_project plugin', () => { ); expect(result.isError).toBeFalsy(); - const text = textOf(result); + const text = allText(result); expect(text).toContain('Project scaffolded successfully'); expect(result.nextStepParams).toEqual({ build_sim: { @@ -460,12 +420,11 @@ describe('scaffold_ios_project plugin', () => { ); expect(result.isError).toBe(true); - const text = textOf(result); + const text = allText(result); expect(text).toContain('Project name must start with a letter'); }); it('should return error response for existing project files', async () => { - // Update mock to return true for existing files mockFileSystemExecutor = createMockFileSystemExecutor({ existsSync: () => true, readFile: async () => 'template content with MyProject placeholder', @@ -486,14 +445,13 @@ describe('scaffold_ios_project plugin', () => { ); expect(result.isError).toBe(true); - const text = textOf(result); + const text = allText(result); expect(text).toContain('Xcode project files already exist in /tmp/test-projects'); }); it('should return error response for template download failure', async () => { await initConfigStoreForTest({ iosTemplatePath: '' }); - // Mock command executor to fail for curl commands const failingMockCommandExecutor = createMockExecutor({ success: false, output: '', @@ -511,7 +469,7 @@ describe('scaffold_ios_project plugin', () => { ); expect(result.isError).toBe(true); - const text = textOf(result); + const text = allText(result); expect(text).toContain('Failed to get template for iOS'); expect(text).toContain('Template download failed'); @@ -521,10 +479,8 @@ describe('scaffold_ios_project plugin', () => { it.skip('should return error response for template extraction failure', async () => { await initConfigStoreForTest({ iosTemplatePath: '' }); - // Create a mock that returns false for local template paths to force download const downloadMockFileSystemExecutor = createMockFileSystemExecutor({ existsSync: (path) => { - // Only return true for extracted template directories, false for local template paths return ( path.includes('xcodebuild-mcp-template') || path.includes('XcodeBuildMCP-iOS-Template') || @@ -543,7 +499,6 @@ describe('scaffold_ios_project plugin', () => { stat: async () => ({ isDirectory: () => true, mtimeMs: 0 }), }); - // Mock command executor to fail for unzip commands const failingMockCommandExecutor = createMockExecutor({ success: false, output: '', @@ -561,7 +516,7 @@ describe('scaffold_ios_project plugin', () => { ); expect(result.isError).toBe(true); - const text = textOf(result); + const text = allText(result); expect(text).toContain('Failed to get template for iOS'); expect(text).toContain('Extraction failed'); diff --git a/src/mcp/tools/project-scaffolding/__tests__/scaffold_macos_project.test.ts b/src/mcp/tools/project-scaffolding/__tests__/scaffold_macos_project.test.ts index 5890b0da..6787a715 100644 --- a/src/mcp/tools/project-scaffolding/__tests__/scaffold_macos_project.test.ts +++ b/src/mcp/tools/project-scaffolding/__tests__/scaffold_macos_project.test.ts @@ -1,15 +1,4 @@ -/** - * Test for scaffold_macos_project plugin - Dependency Injection Architecture - * - * Tests the plugin structure and exported components for scaffold_macos_project tool. - * Uses pure dependency injection with createMockFileSystemExecutor. - * NO VITEST MOCKING ALLOWED - Only createMockExecutor/createMockFileSystemExecutor - * - * Plugin location: plugins/utilities/scaffold_macos_project.js - */ - import { describe, it, expect, beforeEach } from 'vitest'; -import * as z from 'zod'; import { createMockFileSystemExecutor, createNoopExecutor, @@ -23,6 +12,7 @@ import { initConfigStore, type RuntimeConfigOverrides, } from '../../../../utils/config-store.ts'; +import { allText } from '../../../../test-utils/test-helpers.ts'; const cwd = '/repo'; @@ -31,8 +21,6 @@ async function initConfigStoreForTest(overrides?: RuntimeConfigOverrides): Promi await initConfigStore({ cwd, fs: createMockFileSystemExecutor(), overrides }); } -// ONLY ALLOWED MOCKING: createMockFileSystemExecutor - describe('scaffold_macos_project plugin', () => { let mockFileSystemExecutor: ReturnType; let templateManagerStub: { @@ -48,7 +36,6 @@ describe('scaffold_macos_project plugin', () => { }; beforeEach(async () => { - // Create template manager stub using pure JavaScript approach let templateManagerCall = ''; let templateManagerError: Error | string | null = null; @@ -68,7 +55,6 @@ describe('scaffold_macos_project plugin', () => { templateManagerCall += `,cleanup(${path})`; return undefined; }, - // Test helpers setError: (error: Error | string | null) => { templateManagerError = error; }, @@ -78,7 +64,6 @@ describe('scaffold_macos_project plugin', () => { }, }; - // Create fresh mock file system executor for each test mockFileSystemExecutor = createMockFileSystemExecutor({ existsSync: () => false, mkdir: async () => {}, @@ -91,7 +76,6 @@ describe('scaffold_macos_project plugin', () => { ], }); - // Replace the real TemplateManager with our stub for most tests (TemplateManager as any).getTemplatePath = templateManagerStub.getTemplatePath; (TemplateManager as any).cleanup = templateManagerStub.cleanup; @@ -104,7 +88,6 @@ describe('scaffold_macos_project plugin', () => { }); it('should have valid schema with required fields', () => { - // Test the schema object exists expect(schema).toBeDefined(); expect(schema.projectName).toBeDefined(); expect(schema.outputPath).toBeDefined(); @@ -116,57 +99,39 @@ describe('scaffold_macos_project plugin', () => { describe('Command Generation', () => { it('should generate correct curl command for macOS template download', async () => { - // This test validates that the curl command would be generated correctly - // by verifying the URL construction logic const expectedUrl = 'https://github.com/getsentry/XcodeBuildMCP-macOS-Template/releases/download/'; - // The curl command should be structured correctly for macOS template expect(expectedUrl).toContain('XcodeBuildMCP-macOS-Template'); expect(expectedUrl).toContain('releases/download'); - // The template zip file should follow the expected pattern const expectedFilename = 'template.zip'; expect(expectedFilename).toMatch(/template\.zip$/); - // The curl command flags should be correct const expectedCurlFlags = ['-L', '-f', '-o']; - expect(expectedCurlFlags).toContain('-L'); // Follow redirects - expect(expectedCurlFlags).toContain('-f'); // Fail on HTTP errors - expect(expectedCurlFlags).toContain('-o'); // Output to file + expect(expectedCurlFlags).toContain('-L'); + expect(expectedCurlFlags).toContain('-f'); + expect(expectedCurlFlags).toContain('-o'); }); it('should generate correct unzip command for template extraction', async () => { - // This test validates that the unzip command would be generated correctly - // by verifying the command structure const expectedUnzipCommand = ['unzip', '-q', 'template.zip']; - // The unzip command should use the quiet flag expect(expectedUnzipCommand).toContain('-q'); - - // The unzip command should target the template zip file expect(expectedUnzipCommand).toContain('template.zip'); - - // The unzip command should be structured correctly expect(expectedUnzipCommand[0]).toBe('unzip'); expect(expectedUnzipCommand[1]).toBe('-q'); expect(expectedUnzipCommand[2]).toMatch(/template\.zip$/); }); it('should generate correct commands for template with version', async () => { - // This test validates that the curl command would be generated correctly with version const testVersion = 'v1.0.0'; const expectedUrlWithVersion = `https://github.com/getsentry/XcodeBuildMCP-macOS-Template/releases/download/${testVersion}/`; - // The URL should contain the specific version expect(expectedUrlWithVersion).toContain(testVersion); expect(expectedUrlWithVersion).toContain('XcodeBuildMCP-macOS-Template'); expect(expectedUrlWithVersion).toContain('releases/download'); - - // The version should be in the correct format expect(testVersion).toMatch(/^v\d+\.\d+\.\d+$/); - - // The full URL should be correctly constructed expect(expectedUrlWithVersion).toBe( `https://github.com/getsentry/XcodeBuildMCP-macOS-Template/releases/download/${testVersion}/`, ); @@ -182,14 +147,12 @@ describe('scaffold_macos_project plugin', () => { }); }; - // Mock local template path exists mockFileSystemExecutor.existsSync = (path: string) => { return path === '/local/template/path' || path === '/local/template/path/template'; }; await initConfigStoreForTest({ macosTemplatePath: '/local/template/path' }); - // Restore original TemplateManager for command generation tests const { TemplateManager: OriginalTemplateManager } = await import( '../../../../utils/template/index.ts' ); @@ -206,7 +169,6 @@ describe('scaffold_macos_project plugin', () => { mockFileSystemExecutor, ); - // Should not generate any curl or unzip commands when using local template expect(capturedCommands).not.toContainEqual( expect.arrayContaining(['curl', expect.anything(), expect.anything()]), ); @@ -214,20 +176,12 @@ describe('scaffold_macos_project plugin', () => { expect.arrayContaining(['unzip', expect.anything(), expect.anything()]), ); - // Restore stub after test (TemplateManager as any).getTemplatePath = templateManagerStub.getTemplatePath; (TemplateManager as any).cleanup = templateManagerStub.cleanup; }); }); describe('Handler Behavior (Complete Literal Returns)', () => { - function textOf(result: { content: Array<{ type: string; text: string }> }): string { - return result.content - .filter((i) => i.type === 'text') - .map((i) => i.text) - .join('\n'); - } - it('should return success response for valid scaffold macOS project request', async () => { const result = await scaffold_macos_projectLogic( { @@ -241,7 +195,7 @@ describe('scaffold_macos_project plugin', () => { ); expect(result.isError).toBeFalsy(); - const text = textOf(result); + const text = allText(result); expect(text).toContain('Scaffold macOS Project'); expect(text).toContain('TestMacApp'); expect(text).toContain('/tmp/test-projects'); @@ -257,7 +211,6 @@ describe('scaffold_macos_project plugin', () => { }, }); - // Verify template manager calls using manual tracking expect(templateManagerStub.getCalls()).toBe( 'getTemplatePath(macOS),cleanup(/tmp/test-templates/macos)', ); @@ -275,7 +228,7 @@ describe('scaffold_macos_project plugin', () => { ); expect(result.isError).toBeFalsy(); - const text = textOf(result); + const text = allText(result); expect(text).toContain('Project scaffolded successfully'); expect(result.nextStepParams).toEqual({ build_macos: { @@ -301,12 +254,11 @@ describe('scaffold_macos_project plugin', () => { ); expect(result.isError).toBe(true); - const text = textOf(result); + const text = allText(result); expect(text).toContain('Project name must start with a letter'); }); it('should return error response for existing project files', async () => { - // Override existsSync to return true for workspace file mockFileSystemExecutor.existsSync = () => true; const result = await scaffold_macos_projectLogic( @@ -320,7 +272,7 @@ describe('scaffold_macos_project plugin', () => { ); expect(result.isError).toBe(true); - const text = textOf(result); + const text = allText(result); expect(text).toContain('Xcode project files already exist in /tmp/test-projects'); }); @@ -338,7 +290,7 @@ describe('scaffold_macos_project plugin', () => { ); expect(result.isError).toBe(true); - const text = textOf(result); + const text = allText(result); expect(text).toContain('Failed to get template for macOS: Template not found'); }); }); @@ -355,14 +307,9 @@ describe('scaffold_macos_project plugin', () => { mockFileSystemExecutor, ); - // Verify template manager calls using manual tracking expect(templateManagerStub.getCalls()).toBe( 'getTemplatePath(macOS),cleanup(/tmp/test-templates/macos)', ); - - // File system operations are called by the mock implementation - // but we can't verify them without vitest mocking patterns - // This test validates the integration works correctly }); }); }); diff --git a/src/mcp/tools/project-scaffolding/scaffold_ios_project.ts b/src/mcp/tools/project-scaffolding/scaffold_ios_project.ts index e2d55341..2cfc0641 100644 --- a/src/mcp/tools/project-scaffolding/scaffold_ios_project.ts +++ b/src/mcp/tools/project-scaffolding/scaffold_ios_project.ts @@ -7,7 +7,7 @@ import * as z from 'zod'; import { join, dirname, basename } from 'path'; import { log } from '../../../utils/logging/index.ts'; -import { ValidationError } from '../../../utils/responses/index.ts'; +import { ValidationError } from '../../../utils/errors.ts'; import { TemplateManager } from '../../../utils/template/index.ts'; import type { CommandExecutor, FileSystemExecutor } from '../../../utils/execution/index.ts'; import { diff --git a/src/mcp/tools/project-scaffolding/scaffold_macos_project.ts b/src/mcp/tools/project-scaffolding/scaffold_macos_project.ts index 5ca61882..52edaf84 100644 --- a/src/mcp/tools/project-scaffolding/scaffold_macos_project.ts +++ b/src/mcp/tools/project-scaffolding/scaffold_macos_project.ts @@ -7,7 +7,7 @@ import * as z from 'zod'; import { join, dirname, basename } from 'path'; import { log } from '../../../utils/logging/index.ts'; -import { ValidationError } from '../../../utils/responses/index.ts'; +import { ValidationError } from '../../../utils/errors.ts'; import { TemplateManager } from '../../../utils/template/index.ts'; import type { ToolResponse } from '../../../types/common.ts'; import type { CommandExecutor } from '../../../utils/command.ts'; diff --git a/src/mcp/tools/session-management/__tests__/session_clear_defaults.test.ts b/src/mcp/tools/session-management/__tests__/session_clear_defaults.test.ts index ad60ba26..d16e79fd 100644 --- a/src/mcp/tools/session-management/__tests__/session_clear_defaults.test.ts +++ b/src/mcp/tools/session-management/__tests__/session_clear_defaults.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { sessionStore } from '../../../../utils/session-store.ts'; import { schema, handler, sessionClearDefaultsLogic } from '../session_clear_defaults.ts'; +import { allText } from '../../../../test-utils/test-helpers.ts'; describe('session-clear-defaults tool', () => { beforeEach(() => { @@ -32,19 +33,12 @@ describe('session-clear-defaults tool', () => { }); describe('Handler Behavior', () => { - function textOf(result: { content: Array<{ type: string; text: string }> }): string { - return result.content - .filter((i) => i.type === 'text') - .map((i) => i.text) - .join('\n'); - } - it('should clear specific keys when provided', async () => { const result = await sessionClearDefaultsLogic({ keys: ['scheme', 'deviceId', 'derivedDataPath'], }); expect(result.isError).toBeFalsy(); - expect(textOf(result)).toContain('Session defaults cleared'); + expect(allText(result)).toContain('Session defaults cleared'); const current = sessionStore.getAll(); expect(current.scheme).toBeUndefined(); @@ -62,7 +56,7 @@ describe('session-clear-defaults tool', () => { const result = await sessionClearDefaultsLogic({ keys: ['env'] }); expect(result.isError).toBeFalsy(); - expect(textOf(result)).toContain('Session defaults cleared'); + expect(allText(result)).toContain('Session defaults cleared'); const current = sessionStore.getAll(); expect(current.env).toBeUndefined(); @@ -75,7 +69,7 @@ describe('session-clear-defaults tool', () => { sessionStore.setActiveProfile(null); const result = await sessionClearDefaultsLogic({ all: true }); expect(result.isError).toBeFalsy(); - expect(textOf(result)).toContain('All session defaults cleared'); + expect(allText(result)).toContain('All session defaults cleared'); const current = sessionStore.getAll(); expect(Object.keys(current).length).toBe(0); @@ -109,7 +103,7 @@ describe('session-clear-defaults tool', () => { const result = await sessionClearDefaultsLogic({ profile: 'ios' }); expect(result.isError).toBeFalsy(); - expect(textOf(result)).toContain('profile "ios"'); + expect(allText(result)).toContain('profile "ios"'); expect(sessionStore.listProfiles()).toEqual(['watch']); expect(sessionStore.getAll().scheme).toBe('Watch'); @@ -118,20 +112,20 @@ describe('session-clear-defaults tool', () => { it('should error when the specified profile does not exist', async () => { const result = await sessionClearDefaultsLogic({ profile: 'missing' }); expect(result.isError).toBe(true); - expect(textOf(result)).toContain('does not exist'); + expect(allText(result)).toContain('does not exist'); }); it('should reject all=true when combined with scoped arguments', async () => { const result = await sessionClearDefaultsLogic({ all: true, profile: 'ios' }); expect(result.isError).toBe(true); - expect(textOf(result)).toContain('cannot be combined'); + expect(allText(result)).toContain('cannot be combined'); }); it('should validate keys enum', async () => { const result = (await handler({ keys: ['invalid' as any] })) as any; expect(result.isError).toBe(true); - expect(textOf(result)).toContain('Parameter validation failed'); - expect(textOf(result)).toContain('keys'); + expect(allText(result)).toContain('Parameter validation failed'); + expect(allText(result)).toContain('keys'); }); }); }); diff --git a/src/mcp/tools/session-management/__tests__/session_set_defaults.test.ts b/src/mcp/tools/session-management/__tests__/session_set_defaults.test.ts index c2e6fb9a..8a8443d8 100644 --- a/src/mcp/tools/session-management/__tests__/session_set_defaults.test.ts +++ b/src/mcp/tools/session-management/__tests__/session_set_defaults.test.ts @@ -6,6 +6,7 @@ import { sessionStore } from '../../../../utils/session-store.ts'; import { createMockFileSystemExecutor } from '../../../../test-utils/mock-executors.ts'; import { schema, handler, sessionSetDefaultsLogic } from '../session_set_defaults.ts'; import type { CommandExecutor } from '../../../../utils/execution/index.ts'; +import { allText } from '../../../../test-utils/test-helpers.ts'; describe('session-set-defaults tool', () => { beforeEach(() => { @@ -54,13 +55,6 @@ describe('session-set-defaults tool', () => { }); describe('Handler Behavior', () => { - function textOf(result: { content: Array<{ type: string; text: string }> }): string { - return result.content - .filter((i) => i.type === 'text') - .map((i) => i.text) - .join('\n'); - } - it('should set provided defaults and return updated state', async () => { const result = await sessionSetDefaultsLogic( { @@ -73,7 +67,7 @@ describe('session-set-defaults tool', () => { ); expect(result.isError).toBeFalsy(); - expect(textOf(result)).toContain('Session defaults updated.'); + expect(allText(result)).toContain('Session defaults updated.'); const current = sessionStore.getAll(); expect(current.scheme).toBe('MyScheme'); @@ -90,8 +84,8 @@ describe('session-set-defaults tool', () => { }); expect(result.isError).toBe(true); - expect(textOf(result)).toContain('Parameter validation failed'); - expect(textOf(result)).toContain('useLatestOS'); + expect(allText(result)).toContain('Parameter validation failed'); + expect(allText(result)).toContain('useLatestOS'); }); it('should reject env values that are not strings', async () => { @@ -102,8 +96,8 @@ describe('session-set-defaults tool', () => { }); expect(result.isError).toBe(true); - expect(textOf(result)).toContain('Parameter validation failed'); - expect(textOf(result)).toContain('env'); + expect(allText(result)).toContain('Parameter validation failed'); + expect(allText(result)).toContain('env'); }); it('should reject empty string defaults for required string fields', async () => { @@ -112,8 +106,8 @@ describe('session-set-defaults tool', () => { }); expect(result.isError).toBe(true); - expect(textOf(result)).toContain('Parameter validation failed'); - expect(textOf(result)).toContain('scheme'); + expect(allText(result)).toContain('Parameter validation failed'); + expect(allText(result)).toContain('scheme'); }); it('should clear workspacePath when projectPath is set', async () => { @@ -125,7 +119,7 @@ describe('session-set-defaults tool', () => { const current = sessionStore.getAll(); expect(current.projectPath).toBe('/new/App.xcodeproj'); expect(current.workspacePath).toBeUndefined(); - expect(textOf(result)).toContain('Cleared workspacePath because projectPath was set.'); + expect(allText(result)).toContain('Cleared workspacePath because projectPath was set.'); }); it('should clear projectPath when workspacePath is set', async () => { @@ -137,7 +131,7 @@ describe('session-set-defaults tool', () => { const current = sessionStore.getAll(); expect(current.workspacePath).toBe('/new/App.xcworkspace'); expect(current.projectPath).toBeUndefined(); - expect(textOf(result)).toContain('Cleared projectPath because workspacePath was set.'); + expect(allText(result)).toContain('Cleared projectPath because workspacePath was set.'); }); it('should clear stale simulatorName when simulatorId is explicitly set', async () => { @@ -149,7 +143,7 @@ describe('session-set-defaults tool', () => { const current = sessionStore.getAll(); expect(current.simulatorId).toBe('RESOLVED-SIM-UUID'); expect(current.simulatorName).toBeUndefined(); - expect(textOf(result)).toContain( + expect(allText(result)).toContain( 'Cleared simulatorName because simulatorId was set; background resolution will repopulate it.', ); }); @@ -161,7 +155,7 @@ describe('session-set-defaults tool', () => { // simulatorId resolution happens in background; stale id is cleared immediately expect(current.simulatorName).toBe('iPhone 17'); expect(current.simulatorId).toBeUndefined(); - expect(textOf(result)).toContain( + expect(allText(result)).toContain( 'Cleared simulatorId because simulatorName was set; background resolution will repopulate it.', ); }); @@ -173,7 +167,7 @@ describe('session-set-defaults tool', () => { createContext(), ); - expect(textOf(result)).not.toContain('Cleared simulatorName'); + expect(allText(result)).not.toContain('Cleared simulatorName'); }); it('should not fail when simulatorName cannot be resolved immediately', async () => { @@ -214,7 +208,7 @@ describe('session-set-defaults tool', () => { const current = sessionStore.getAll(); expect(current.workspacePath).toBe('/app/App.xcworkspace'); expect(current.projectPath).toBeUndefined(); - expect(textOf(res)).toContain( + expect(allText(res)).toContain( 'Both projectPath and workspacePath were provided; keeping workspacePath and ignoring projectPath.', ); }); @@ -231,7 +225,7 @@ describe('session-set-defaults tool', () => { // Both are kept, simulatorId takes precedence for tools expect(current.simulatorId).toBe('SIM-1'); expect(current.simulatorName).toBe('iPhone 17'); - expect(textOf(res)).toContain( + expect(allText(res)).toContain( 'Both simulatorId and simulatorName were provided; simulatorId will be used by tools.', ); }); @@ -270,7 +264,7 @@ describe('session-set-defaults tool', () => { createContext(), ); - expect(textOf(result)).toContain('Persisted defaults to'); + expect(allText(result)).toContain('Persisted defaults to'); expect(writes.length).toBe(1); expect(writes[0].path).toBe(configPath); @@ -298,7 +292,7 @@ describe('session-set-defaults tool', () => { ); expect(result.isError).toBeFalsy(); - expect(textOf(result)).toContain('Activated profile "ios".'); + expect(allText(result)).toContain('Activated profile "ios".'); expect(sessionStore.getActiveProfile()).toBe('ios'); expect(sessionStore.getAll().scheme).toBe('NewIOS'); expect(sessionStore.getAll().simulatorName).toBe('iPhone 17'); @@ -314,8 +308,8 @@ describe('session-set-defaults tool', () => { ); expect(result.isError).toBe(true); - expect(textOf(result)).toContain('Profile "missing" does not exist'); - expect(textOf(result)).toContain('createIfNotExists=true'); + expect(allText(result)).toContain('Profile "missing" does not exist'); + expect(allText(result)).toContain('createIfNotExists=true'); }); it('creates profile when createIfNotExists is true and activates it', async () => { @@ -329,7 +323,7 @@ describe('session-set-defaults tool', () => { ); expect(result.isError).toBeFalsy(); - expect(textOf(result)).toContain('Created and activated profile "ios".'); + expect(allText(result)).toContain('Created and activated profile "ios".'); expect(sessionStore.getActiveProfile()).toBe('ios'); expect(sessionStore.getAll().scheme).toBe('NewIOS'); }); @@ -416,7 +410,7 @@ describe('session-set-defaults tool', () => { createContext(), ); - expect(textOf(result)).toContain('Persisted defaults to'); + expect(allText(result)).toContain('Persisted defaults to'); expect(writes.length).toBe(1); const parsed = parseYaml(writes[0].content) as { @@ -428,7 +422,7 @@ describe('session-set-defaults tool', () => { it('should not persist when persist is true but no defaults were provided', async () => { const result = await sessionSetDefaultsLogic({ persist: true }, createContext()); - expect(textOf(result)).toContain('No defaults provided to persist'); + expect(allText(result)).toContain('No defaults provided to persist'); }); }); }); diff --git a/src/mcp/tools/session-management/__tests__/session_show_defaults.test.ts b/src/mcp/tools/session-management/__tests__/session_show_defaults.test.ts index e1139a08..a77be6df 100644 --- a/src/mcp/tools/session-management/__tests__/session_show_defaults.test.ts +++ b/src/mcp/tools/session-management/__tests__/session_show_defaults.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { sessionStore } from '../../../../utils/session-store.ts'; import { schema, handler } from '../session_show_defaults.ts'; +import { allText } from '../../../../test-utils/test-helpers.ts'; describe('session-show-defaults tool', () => { beforeEach(() => { @@ -22,17 +23,10 @@ describe('session-show-defaults tool', () => { }); describe('Handler Behavior', () => { - function textOf(result: { content: Array<{ type: string; text: string }> }): string { - return result.content - .filter((i) => i.type === 'text') - .map((i) => i.text) - .join('\n'); - } - it('should return empty defaults when none set', async () => { const result = await handler(); expect(result.isError).toBeFalsy(); - const text = textOf(result); + const text = allText(result); expect(text).toContain('Show Defaults'); expect(text).toContain('No session defaults are set'); }); @@ -41,7 +35,7 @@ describe('session-show-defaults tool', () => { sessionStore.setDefaults({ scheme: 'MyScheme', simulatorId: 'SIM-123' }); const result = await handler(); expect(result.isError).toBeFalsy(); - const text = textOf(result); + const text = allText(result); expect(text).toContain('scheme: MyScheme'); expect(text).toContain('simulatorId: SIM-123'); }); @@ -52,7 +46,7 @@ describe('session-show-defaults tool', () => { sessionStore.setDefaults({ scheme: 'IOSScheme' }); const result = await handler(); - const text = textOf(result); + const text = allText(result); expect(text).toContain('scheme: IOSScheme'); }); }); diff --git a/src/mcp/tools/session-management/__tests__/session_use_defaults_profile.test.ts b/src/mcp/tools/session-management/__tests__/session_use_defaults_profile.test.ts index 7f3bb56f..74317818 100644 --- a/src/mcp/tools/session-management/__tests__/session_use_defaults_profile.test.ts +++ b/src/mcp/tools/session-management/__tests__/session_use_defaults_profile.test.ts @@ -9,6 +9,7 @@ import { schema, sessionUseDefaultsProfileLogic, } from '../session_use_defaults_profile.ts'; +import { allText } from '../../../../test-utils/test-helpers.ts'; describe('session-use-defaults-profile tool', () => { beforeEach(() => { @@ -25,13 +26,6 @@ describe('session-use-defaults-profile tool', () => { expect(typeof schema).toBe('object'); }); - function textOf(result: { content: Array<{ type: string; text: string }> }): string { - return result.content - .filter((i) => i.type === 'text') - .map((i) => i.text) - .join('\n'); - } - it('activates an existing named profile', async () => { sessionStore.setActiveProfile('ios'); sessionStore.setActiveProfile(null); @@ -52,25 +46,25 @@ describe('session-use-defaults-profile tool', () => { it('returns error when both global and profile are provided', async () => { const result = await sessionUseDefaultsProfileLogic({ global: true, profile: 'ios' }); expect(result.isError).toBe(true); - expect(textOf(result)).toContain('either global=true or profile'); + expect(allText(result)).toContain('either global=true or profile'); }); it('returns error when profile does not exist', async () => { const result = await sessionUseDefaultsProfileLogic({ profile: 'macos' }); expect(result.isError).toBe(true); - expect(textOf(result)).toContain('does not exist'); + expect(allText(result)).toContain('does not exist'); }); it('returns error when profile name is blank after trimming', async () => { const result = await sessionUseDefaultsProfileLogic({ profile: ' ' }); expect(result.isError).toBe(true); - expect(textOf(result)).toContain('Profile name cannot be empty'); + expect(allText(result)).toContain('Profile name cannot be empty'); }); it('returns status for empty args', async () => { const result = await sessionUseDefaultsProfileLogic({}); expect(result.isError).toBeFalsy(); - expect(textOf(result)).toContain('Active profile: global'); + expect(allText(result)).toContain('Active profile: global'); }); it('persists active profile when persist=true', async () => { @@ -89,7 +83,7 @@ describe('session-use-defaults-profile tool', () => { const result = await sessionUseDefaultsProfileLogic({ profile: 'ios', persist: true }); expect(result.isError).toBeFalsy(); - expect(textOf(result)).toContain('Persisted active profile selection'); + expect(allText(result)).toContain('Persisted active profile selection'); expect(writes).toHaveLength(1); const parsed = parseYaml(writes[0].content) as { activeSessionDefaultsProfile?: string }; expect(parsed.activeSessionDefaultsProfile).toBe('ios'); diff --git a/src/mcp/tools/session-management/session_set_defaults.ts b/src/mcp/tools/session-management/session_set_defaults.ts index d60a8a98..448b7116 100644 --- a/src/mcp/tools/session-management/session_set_defaults.ts +++ b/src/mcp/tools/session-management/session_set_defaults.ts @@ -42,6 +42,7 @@ export async function sessionSetDefaultsLogic( params: Params, context: SessionSetDefaultsContext, ): Promise { + const headerEvent = header('Set Defaults'); const notices: string[] = []; let activeProfile = sessionStore.getActiveProfile(); const { @@ -55,16 +56,13 @@ export async function sessionSetDefaultsLogic( if (rawProfile !== undefined) { const profile = rawProfile.trim(); if (profile.length === 0) { - return toolResponse([ - header('Set Defaults'), - statusLine('error', 'Profile name cannot be empty.'), - ]); + return toolResponse([headerEvent, statusLine('error', 'Profile name cannot be empty.')]); } const profileExists = sessionStore.listProfiles().includes(profile); if (!profileExists && !createIfNotExists) { return toolResponse([ - header('Set Defaults'), + headerEvent, statusLine( 'error', `Profile "${profile}" does not exist. Pass createIfNotExists=true to create it.`, @@ -211,7 +209,7 @@ export async function sessionSetDefaultsLogic( } const updated = sessionStore.getAll(); - const events: PipelineEvent[] = [header('Set Defaults')]; + const events: PipelineEvent[] = [headerEvent]; const items = Object.entries(updated) .filter(([, v]) => v !== undefined) diff --git a/src/mcp/tools/session-management/session_use_defaults_profile.ts b/src/mcp/tools/session-management/session_use_defaults_profile.ts index 597ff617..3276fb4f 100644 --- a/src/mcp/tools/session-management/session_use_defaults_profile.ts +++ b/src/mcp/tools/session-management/session_use_defaults_profile.ts @@ -31,10 +31,11 @@ function resolveProfileToActivate(params: Params): string | null | undefined { export async function sessionUseDefaultsProfileLogic(params: Params): Promise { const notices: string[] = []; + const errorHeader = header('Use Defaults Profile'); if (params.global === true && params.profile !== undefined) { return toolResponse([ - header('Use Defaults Profile'), + errorHeader, statusLine('error', 'Provide either global=true or profile, not both.'), ]); } @@ -43,14 +44,11 @@ export async function sessionUseDefaultsProfileLogic(params: Params): Promise c.type === 'text') - .map((c) => c.text) - .join('\n'); -} +import { allText } from '../../../../test-utils/test-helpers.ts'; describe('erase_sims tool (single simulator)', () => { describe('Schema Validation', () => { diff --git a/src/mcp/tools/simulator-management/__tests__/reset_sim_location.test.ts b/src/mcp/tools/simulator-management/__tests__/reset_sim_location.test.ts index 2f7dd258..d3be8751 100644 --- a/src/mcp/tools/simulator-management/__tests__/reset_sim_location.test.ts +++ b/src/mcp/tools/simulator-management/__tests__/reset_sim_location.test.ts @@ -2,14 +2,7 @@ import { describe, it, expect } from 'vitest'; import * as z from 'zod'; import { schema, reset_sim_locationLogic } from '../reset_sim_location.ts'; import { createMockExecutor } from '../../../../test-utils/mock-executors.ts'; -import type { ToolResponse } from '../../../../types/common.ts'; - -function allText(result: ToolResponse): string { - return result.content - .filter((c) => c.type === 'text') - .map((c) => c.text) - .join('\n'); -} +import { allText } from '../../../../test-utils/test-helpers.ts'; describe('reset_sim_location plugin', () => { describe('Schema Validation', () => { diff --git a/src/mcp/tools/simulator-management/__tests__/set_sim_appearance.test.ts b/src/mcp/tools/simulator-management/__tests__/set_sim_appearance.test.ts index 2bc281c4..49316e6a 100644 --- a/src/mcp/tools/simulator-management/__tests__/set_sim_appearance.test.ts +++ b/src/mcp/tools/simulator-management/__tests__/set_sim_appearance.test.ts @@ -5,14 +5,7 @@ import { createMockCommandResponse, createMockExecutor, } from '../../../../test-utils/mock-executors.ts'; -import type { ToolResponse } from '../../../../types/common.ts'; - -function allText(result: ToolResponse): string { - return result.content - .filter((c) => c.type === 'text') - .map((c) => c.text) - .join('\n'); -} +import { allText } from '../../../../test-utils/test-helpers.ts'; describe('set_sim_appearance plugin', () => { describe('Export Field Validation (Literal)', () => { diff --git a/src/mcp/tools/simulator-management/__tests__/set_sim_location.test.ts b/src/mcp/tools/simulator-management/__tests__/set_sim_location.test.ts index 2d00869a..57daed8f 100644 --- a/src/mcp/tools/simulator-management/__tests__/set_sim_location.test.ts +++ b/src/mcp/tools/simulator-management/__tests__/set_sim_location.test.ts @@ -6,14 +6,7 @@ import { createNoopExecutor, } from '../../../../test-utils/mock-executors.ts'; import { schema, handler, set_sim_locationLogic } from '../set_sim_location.ts'; -import type { ToolResponse } from '../../../../types/common.ts'; - -function allText(result: ToolResponse): string { - return result.content - .filter((c) => c.type === 'text') - .map((c) => c.text) - .join('\n'); -} +import { allText } from '../../../../test-utils/test-helpers.ts'; describe('set_sim_location tool', () => { describe('Export Field Validation (Literal)', () => { diff --git a/src/mcp/tools/simulator-management/__tests__/sim_statusbar.test.ts b/src/mcp/tools/simulator-management/__tests__/sim_statusbar.test.ts index 7144991d..45122d0c 100644 --- a/src/mcp/tools/simulator-management/__tests__/sim_statusbar.test.ts +++ b/src/mcp/tools/simulator-management/__tests__/sim_statusbar.test.ts @@ -6,14 +6,7 @@ import { type CommandExecutor, } from '../../../../test-utils/mock-executors.ts'; import { schema, sim_statusbarLogic } from '../sim_statusbar.ts'; -import type { ToolResponse } from '../../../../types/common.ts'; - -function allText(result: ToolResponse): string { - return result.content - .filter((c) => c.type === 'text') - .map((c) => c.text) - .join('\n'); -} +import { allText } from '../../../../test-utils/test-helpers.ts'; describe('sim_statusbar tool', () => { describe('Schema Validation', () => { diff --git a/src/mcp/tools/simulator-management/erase_sims.ts b/src/mcp/tools/simulator-management/erase_sims.ts index c21ecd6f..3ce61d41 100644 --- a/src/mcp/tools/simulator-management/erase_sims.ts +++ b/src/mcp/tools/simulator-management/erase_sims.ts @@ -10,15 +10,13 @@ import { import { toolResponse } from '../../../utils/tool-response.ts'; import { header, section, statusLine } from '../../../utils/tool-event-builders.ts'; -const eraseSimsBaseSchema = z +const eraseSimsSchema = z .object({ simulatorId: z.uuid().describe('UDID of the simulator to erase.'), shutdownFirst: z.boolean().optional(), }) .passthrough(); -const eraseSimsSchema = eraseSimsBaseSchema; - type EraseSimsParams = z.infer; export async function erase_simsLogic( diff --git a/src/mcp/tools/simulator/__tests__/boot_sim.test.ts b/src/mcp/tools/simulator/__tests__/boot_sim.test.ts index 60a3d43b..eb0d1c53 100644 --- a/src/mcp/tools/simulator/__tests__/boot_sim.test.ts +++ b/src/mcp/tools/simulator/__tests__/boot_sim.test.ts @@ -6,14 +6,7 @@ import { } from '../../../../test-utils/mock-executors.ts'; import { sessionStore } from '../../../../utils/session-store.ts'; import { schema, handler, boot_simLogic } from '../boot_sim.ts'; -import type { ToolResponse } from '../../../../types/common.ts'; - -function allText(result: ToolResponse): string { - return result.content - .filter((c) => c.type === 'text') - .map((c) => c.text) - .join('\n'); -} +import { allText } from '../../../../test-utils/test-helpers.ts'; describe('boot_sim tool', () => { beforeEach(() => { diff --git a/src/mcp/tools/simulator/__tests__/build_run_sim.test.ts b/src/mcp/tools/simulator/__tests__/build_run_sim.test.ts index 10793d06..20f3067d 100644 --- a/src/mcp/tools/simulator/__tests__/build_run_sim.test.ts +++ b/src/mcp/tools/simulator/__tests__/build_run_sim.test.ts @@ -1,8 +1,3 @@ -/** - * Tests for build_run_sim plugin (unified) - * Following the canonical pending pipeline pattern from build_run_macos. - */ - import { describe, it, expect, beforeEach } from 'vitest'; import * as z from 'zod'; import { diff --git a/src/mcp/tools/simulator/__tests__/build_sim.test.ts b/src/mcp/tools/simulator/__tests__/build_sim.test.ts index 0396dec6..2e97212d 100644 --- a/src/mcp/tools/simulator/__tests__/build_sim.test.ts +++ b/src/mcp/tools/simulator/__tests__/build_sim.test.ts @@ -7,7 +7,6 @@ import { import type { CommandExecutor } from '../../../../utils/execution/index.ts'; import { sessionStore } from '../../../../utils/session-store.ts'; -// Import the named exports and logic function import { schema, handler, build_simLogic } from '../build_sim.ts'; function expectPendingBuildResponse( @@ -47,17 +46,14 @@ describe('build_sim tool', () => { it('should have correct public schema (only non-session fields)', () => { const schemaObj = z.strictObject(schema); - // Public schema should allow empty input expect(schemaObj.safeParse({}).success).toBe(true); - // Valid public inputs expect( schemaObj.safeParse({ extraArgs: ['--verbose'], }).success, ).toBe(true); - // Invalid types or unknown fields on public inputs expect(schemaObj.safeParse({ derivedDataPath: '/path/to/derived' }).success).toBe(false); expect(schemaObj.safeParse({ extraArgs: [123] }).success).toBe(false); expect(schemaObj.safeParse({ preferXcodebuild: false }).success).toBe(false); @@ -146,7 +142,6 @@ describe('build_sim tool', () => { it('should handle both simulatorId and simulatorName provided', async () => { const mockExecutor = createMockExecutor({ success: true, output: 'Build succeeded' }); - // Should fail with XOR validation const result = await handler({ workspacePath: '/path/to/workspace', scheme: 'MyScheme', diff --git a/src/mcp/tools/simulator/__tests__/get_sim_app_path.test.ts b/src/mcp/tools/simulator/__tests__/get_sim_app_path.test.ts index 84cb742f..0d94b529 100644 --- a/src/mcp/tools/simulator/__tests__/get_sim_app_path.test.ts +++ b/src/mcp/tools/simulator/__tests__/get_sim_app_path.test.ts @@ -1,8 +1,3 @@ -/** - * Tests for get_sim_app_path plugin (session-aware version) - * Mirrors patterns from other simulator session-aware migrations. - */ - import { describe, it, expect, beforeEach } from 'vitest'; import { ChildProcess } from 'child_process'; import * as z from 'zod'; @@ -158,7 +153,7 @@ describe('get_sim_app_path tool', () => { 'platform=iOS Simulator,name=iPhone 17,OS=latest', ]); - expect(result.isError).not.toBe(true); + expect(result.isError).toBeFalsy(); const text = result.content.map((c) => c.text).join('\n'); expect(text).toContain('Get App Path'); expect(text).toContain('MyScheme'); diff --git a/src/mcp/tools/simulator/__tests__/launch_app_logs_sim.test.ts b/src/mcp/tools/simulator/__tests__/launch_app_logs_sim.test.ts index e1818d49..42b4772d 100644 --- a/src/mcp/tools/simulator/__tests__/launch_app_logs_sim.test.ts +++ b/src/mcp/tools/simulator/__tests__/launch_app_logs_sim.test.ts @@ -1,8 +1,3 @@ -/** - * Tests for launch_app_logs_sim plugin (session-aware version) - * Follows CLAUDE.md guidance with literal validation and DI. - */ - import { describe, it, expect, beforeEach } from 'vitest'; import * as z from 'zod'; import { @@ -91,7 +86,7 @@ describe('launch_app_logs_sim tool', () => { expect(result.nextStepParams).toEqual({ stop_sim_log_cap: { logSessionId: 'test-session-123' }, }); - expect(result.isError).not.toBe(true); + expect(result.isError).toBeFalsy(); expect(capturedParams).toEqual({ simulatorUuid: 'test-uuid-123', diff --git a/src/mcp/tools/simulator/__tests__/list_sims.test.ts b/src/mcp/tools/simulator/__tests__/list_sims.test.ts index ae5da58e..62461ac2 100644 --- a/src/mcp/tools/simulator/__tests__/list_sims.test.ts +++ b/src/mcp/tools/simulator/__tests__/list_sims.test.ts @@ -5,7 +5,6 @@ import { createMockExecutor, } from '../../../../test-utils/mock-executors.ts'; -// Import the named exports and logic function import { schema, handler, list_simsLogic, listSimulators } from '../list_sims.ts'; describe('list_sims tool', () => { @@ -26,13 +25,11 @@ describe('list_sims tool', () => { it('should have correct schema with enabled boolean field', () => { const schemaObj = z.object(schema); - // Valid inputs expect(schemaObj.safeParse({ enabled: true }).success).toBe(true); expect(schemaObj.safeParse({ enabled: false }).success).toBe(true); expect(schemaObj.safeParse({ enabled: undefined }).success).toBe(true); expect(schemaObj.safeParse({}).success).toBe(true); - // Invalid inputs expect(schemaObj.safeParse({ enabled: 'yes' }).success).toBe(false); expect(schemaObj.safeParse({ enabled: 1 }).success).toBe(false); expect(schemaObj.safeParse({ enabled: null }).success).toBe(false); @@ -97,7 +94,6 @@ describe('list_sims tool', () => { -- iOS 17.0 -- iPhone 15 (test-uuid-123) (Shutdown)`; - // Create a mock executor that returns different outputs based on command const mockExecutor = async ( command: string[], logPrefix?: string, @@ -108,7 +104,6 @@ describe('list_sims tool', () => { callHistory.push({ command, logPrefix, useShell, env: opts?.env }); void detached; - // Return JSON output for JSON command if (command.includes('--json')) { return createMockCommandResponse({ success: true, @@ -117,7 +112,6 @@ describe('list_sims tool', () => { }); } - // Return text output for text command return createMockCommandResponse({ success: true, output: mockTextOutput, @@ -127,7 +121,6 @@ describe('list_sims tool', () => { const result = await list_simsLogic({ enabled: true }, mockExecutor); - // Verify both commands were called expect(callHistory).toHaveLength(2); expect(callHistory[0]).toEqual({ command: ['xcrun', 'simctl', 'list', 'devices', '--json'], @@ -290,7 +283,6 @@ describe('list_sims tool', () => { iPhone 15 (test-uuid-456) (Shutdown)`; const mockExecutor = async (command: string[]) => { - // JSON command returns invalid JSON if (command.includes('--json')) { return createMockCommandResponse({ success: true, @@ -299,7 +291,6 @@ describe('list_sims tool', () => { }); } - // Text command returns valid text output return createMockCommandResponse({ success: true, output: mockTextOutput, diff --git a/src/mcp/tools/simulator/__tests__/open_sim.test.ts b/src/mcp/tools/simulator/__tests__/open_sim.test.ts index dc3789ef..b4c730cd 100644 --- a/src/mcp/tools/simulator/__tests__/open_sim.test.ts +++ b/src/mcp/tools/simulator/__tests__/open_sim.test.ts @@ -6,14 +6,7 @@ import { type CommandExecutor, } from '../../../../test-utils/mock-executors.ts'; import { schema, handler, open_simLogic } from '../open_sim.ts'; -import type { ToolResponse } from '../../../../types/common.ts'; - -function allText(result: ToolResponse): string { - return result.content - .filter((c) => c.type === 'text') - .map((c) => c.text) - .join('\n'); -} +import { allText } from '../../../../test-utils/test-helpers.ts'; describe('open_sim tool', () => { describe('Export Field Validation (Literal)', () => { diff --git a/src/mcp/tools/simulator/__tests__/record_sim_video.test.ts b/src/mcp/tools/simulator/__tests__/record_sim_video.test.ts index 7efec3dc..20aec19a 100644 --- a/src/mcp/tools/simulator/__tests__/record_sim_video.test.ts +++ b/src/mcp/tools/simulator/__tests__/record_sim_video.test.ts @@ -1,6 +1,5 @@ import { describe, it, expect, vi, afterEach } from 'vitest'; -// Import the tool and logic import { schema, handler, record_sim_videoLogic } from '../record_sim_video.ts'; import { createMockFileSystemExecutor } from '../../../../test-utils/mock-executors.ts'; @@ -48,7 +47,6 @@ describe('record_sim_video logic - start behavior', () => { }), }; - // DI for AXe helpers: available and version OK const axe = { areAxeToolsAvailable: () => true, isAxeAtLeastVersion: async () => true, @@ -60,8 +58,7 @@ describe('record_sim_video logic - start behavior', () => { { simulatorId: VALID_SIM_ID, start: true, - // fps omitted to hit default 30 - outputFile: '/tmp/ignored.mp4', // should be ignored with a note + outputFile: '/tmp/ignored.mp4', } as any, DUMMY_EXECUTOR, axe, @@ -69,7 +66,7 @@ describe('record_sim_video logic - start behavior', () => { fs, ); - expect(res.isError).not.toBe(true); + expect(res.isError).toBeFalsy(); const texts = (res.content ?? []).map((c: any) => c.text).join('\n'); expect(texts).toContain('30'); @@ -103,7 +100,6 @@ describe('record_sim_video logic - end-to-end stop with rename', () => { isAxeAtLeastVersion: async () => true, }; - // Start (not strictly required for stop path, but included to mimic flow) const startRes = await record_sim_videoLogic( { simulatorId: VALID_SIM_ID, @@ -114,9 +110,8 @@ describe('record_sim_video logic - end-to-end stop with rename', () => { video, fs, ); - expect(startRes.isError).not.toBe(true); + expect(startRes.isError).toBeFalsy(); - // Stop and rename const outputFile = '/var/videos/final.mp4'; const stopRes = await record_sim_videoLogic( { @@ -130,7 +125,7 @@ describe('record_sim_video logic - end-to-end stop with rename', () => { fs, ); - expect(stopRes.isError).not.toBe(true); + expect(stopRes.isError).toBeFalsy(); const texts = (stopRes.content ?? []).map((c: any) => c.text).join('\n'); expect(texts).toContain('Original file: /tmp/recorded.mp4'); expect(texts).toContain(`Saved to: ${outputFile}`); diff --git a/src/mcp/tools/simulator/__tests__/screenshot.test.ts b/src/mcp/tools/simulator/__tests__/screenshot.test.ts index a54a5c96..6e17a953 100644 --- a/src/mcp/tools/simulator/__tests__/screenshot.test.ts +++ b/src/mcp/tools/simulator/__tests__/screenshot.test.ts @@ -1,9 +1,3 @@ -/** - * Tests for screenshot plugin - * Following CLAUDE.md testing standards with literal validation - * Using pure dependency injection for deterministic testing - */ - import { describe, it, expect, beforeEach } from 'vitest'; import * as z from 'zod'; import { @@ -12,18 +6,11 @@ import { createCommandMatchingMockExecutor, mockProcess, } from '../../../../test-utils/mock-executors.ts'; -import type { ToolResponse } from '../../../../types/common.ts'; import type { CommandExecutor } from '../../../../utils/execution/index.ts'; -import { SystemError } from '../../../../utils/responses/index.ts'; +import { SystemError } from '../../../../utils/errors.ts'; import { sessionStore } from '../../../../utils/session-store.ts'; import { schema, handler, screenshotLogic } from '../../ui-automation/screenshot.ts'; - -function allText(result: ToolResponse): string { - return result.content - .filter((c): c is { type: 'text'; text: string } => c.type === 'text') - .map((c) => c.text) - .join('\n'); -} +import { allText } from '../../../../test-utils/test-helpers.ts'; describe('screenshot plugin', () => { beforeEach(() => { @@ -49,7 +36,6 @@ describe('screenshot plugin', () => { }); describe('Command Generation', () => { - // Mock device list JSON for proper device name lookup const mockDeviceListJson = JSON.stringify({ devices: { 'com.apple.CoreSimulator.SimRuntime.iOS-17-2': [ @@ -62,11 +48,9 @@ describe('screenshot plugin', () => { it('should generate correct simctl and sips commands', async () => { const capturedCommands: string[][] = []; - // Wrap to capture commands and return appropriate mock responses const capturingExecutor = async (command: string[], ...args: any[]) => { capturedCommands.push(command); const cmdStr = command.join(' '); - // Return device list JSON for list command if (cmdStr.includes('simctl list devices')) { return { success: true, @@ -75,7 +59,6 @@ describe('screenshot plugin', () => { process: mockProcess, }; } - // Return success for all other commands return { success: true, output: '', error: undefined, process: mockProcess }; }; @@ -102,10 +85,8 @@ describe('screenshot plugin', () => { mockUuidDeps, ); - // Should execute all commands in sequence: screenshot, list devices, orientation detection, optimization expect(capturedCommands).toHaveLength(4); - // First command: xcrun simctl screenshot expect(capturedCommands[0]).toEqual([ 'xcrun', 'simctl', @@ -115,16 +96,13 @@ describe('screenshot plugin', () => { '/tmp/screenshot_mock-uuid-123.png', ]); - // Second command: xcrun simctl list devices (to get device name) expect(capturedCommands[1][0]).toBe('xcrun'); expect(capturedCommands[1][1]).toBe('simctl'); expect(capturedCommands[1][2]).toBe('list'); - // Third command: swift orientation detection expect(capturedCommands[2][0]).toBe('swift'); expect(capturedCommands[2][1]).toBe('-e'); - // Fourth command: sips optimization expect(capturedCommands[3]).toEqual([ 'sips', '-Z', @@ -144,11 +122,9 @@ describe('screenshot plugin', () => { it('should generate correct path with different uuid', async () => { const capturedCommands: string[][] = []; - // Wrap to capture commands and return appropriate mock responses const capturingExecutor = async (command: string[], ...args: any[]) => { capturedCommands.push(command); const cmdStr = command.join(' '); - // Return device list JSON for list command if (cmdStr.includes('simctl list devices')) { return { success: true, @@ -157,7 +133,6 @@ describe('screenshot plugin', () => { process: mockProcess, }; } - // Return success for all other commands return { success: true, output: '', error: undefined, process: mockProcess }; }; @@ -184,10 +159,8 @@ describe('screenshot plugin', () => { mockUuidDeps, ); - // Should execute all commands in sequence: screenshot, list devices, orientation detection, optimization expect(capturedCommands).toHaveLength(4); - // First command: xcrun simctl screenshot expect(capturedCommands[0]).toEqual([ 'xcrun', 'simctl', @@ -197,16 +170,13 @@ describe('screenshot plugin', () => { '/tmp/screenshot_different-uuid-456.png', ]); - // Second command: xcrun simctl list devices (to get device name) expect(capturedCommands[1][0]).toBe('xcrun'); expect(capturedCommands[1][1]).toBe('simctl'); expect(capturedCommands[1][2]).toBe('list'); - // Third command: swift orientation detection expect(capturedCommands[2][0]).toBe('swift'); expect(capturedCommands[2][1]).toBe('-e'); - // Fourth command: sips optimization expect(capturedCommands[3]).toEqual([ 'sips', '-Z', @@ -226,11 +196,9 @@ describe('screenshot plugin', () => { it('should use default dependencies when not provided', async () => { const capturedCommands: string[][] = []; - // Wrap to capture commands and return appropriate mock responses const capturingExecutor = async (command: string[], ...args: any[]) => { capturedCommands.push(command); const cmdStr = command.join(' '); - // Return device list JSON for list command if (cmdStr.includes('simctl list devices')) { return { success: true, @@ -239,7 +207,6 @@ describe('screenshot plugin', () => { process: mockProcess, }; } - // Return success for all other commands return { success: true, output: '', error: undefined, process: mockProcess }; }; @@ -258,7 +225,6 @@ describe('screenshot plugin', () => { // Should execute all commands in sequence: screenshot, list devices, orientation detection, optimization expect(capturedCommands).toHaveLength(4); - // First command should be generated with real os.tmpdir, path.join, and uuidv4 const firstCommand = capturedCommands[0]; expect(firstCommand).toHaveLength(6); expect(firstCommand[0]).toBe('xcrun'); @@ -268,21 +234,17 @@ describe('screenshot plugin', () => { expect(firstCommand[4]).toBe('screenshot'); expect(firstCommand[5]).toMatch(/\/.*\/screenshot_.*\.png/); - // Second command should be xcrun simctl list devices expect(capturedCommands[1][0]).toBe('xcrun'); expect(capturedCommands[1][1]).toBe('simctl'); expect(capturedCommands[1][2]).toBe('list'); - // Third command should be swift orientation detection expect(capturedCommands[2][0]).toBe('swift'); expect(capturedCommands[2][1]).toBe('-e'); - // Fourth command should be sips optimization const thirdCommand = capturedCommands[3]; expect(thirdCommand[0]).toBe('sips'); expect(thirdCommand[1]).toBe('-Z'); expect(thirdCommand[2]).toBe('800'); - // Should have proper PNG input and JPG output paths expect(thirdCommand[thirdCommand.length - 3]).toMatch(/\/.*\/screenshot_.*\.png/); expect(thirdCommand[thirdCommand.length - 1]).toMatch(/\/.*\/screenshot_optimized_.*\.jpg/); }); @@ -292,7 +254,6 @@ describe('screenshot plugin', () => { it('should capture screenshot successfully', async () => { const mockImageBuffer = Buffer.from('fake-image-data'); - // Mock both commands: screenshot + optimization const mockExecutor = createCommandMatchingMockExecutor({ 'xcrun simctl': { success: true, output: 'Screenshot saved' }, sips: { success: true, output: 'Image optimized' }, @@ -418,7 +379,6 @@ describe('screenshot plugin', () => { it('should call correct command with direct execution', async () => { const capturedArgs: any[][] = []; - // Mock device list JSON for proper device name lookup const mockDeviceListJson = JSON.stringify({ devices: { 'com.apple.CoreSimulator.SimRuntime.iOS-17-2': [ @@ -427,12 +387,10 @@ describe('screenshot plugin', () => { }, }); - // Capture all command executions and return appropriate mock responses const capturingExecutor: CommandExecutor = async (...args) => { capturedArgs.push(args); const command = args[0] as string[]; const cmdStr = command.join(' '); - // Return device list JSON for list command if (cmdStr.includes('simctl list devices')) { return { success: true, @@ -441,7 +399,6 @@ describe('screenshot plugin', () => { process: mockProcess, }; } - // Return success for all other commands return { success: true, output: '', error: undefined, process: mockProcess }; }; @@ -468,30 +425,25 @@ describe('screenshot plugin', () => { mockUuidDeps, ); - // Should capture all command executions: screenshot, list devices, orientation detection, optimization expect(capturedArgs).toHaveLength(4); - // First call: xcrun simctl screenshot (3 args: command, logPrefix, useShell) expect(capturedArgs[0]).toEqual([ ['xcrun', 'simctl', 'io', 'test-uuid', 'screenshot', '/tmp/screenshot_mock-uuid-123.png'], '[Screenshot]: screenshot', false, ]); - // Second call: xcrun simctl list devices (to get device name) expect(capturedArgs[1][0][0]).toBe('xcrun'); expect(capturedArgs[1][0][1]).toBe('simctl'); expect(capturedArgs[1][0][2]).toBe('list'); expect(capturedArgs[1][1]).toBe('[Screenshot]: list devices'); expect(capturedArgs[1][2]).toBe(false); - // Third call: swift orientation detection expect(capturedArgs[2][0][0]).toBe('swift'); expect(capturedArgs[2][0][1]).toBe('-e'); expect(capturedArgs[2][1]).toBe('[Screenshot]: detect orientation'); expect(capturedArgs[2][2]).toBe(false); - // Fourth call: sips optimization (3 args: command, logPrefix, useShell) expect(capturedArgs[3]).toEqual([ [ 'sips', diff --git a/src/mcp/tools/simulator/__tests__/test_sim.test.ts b/src/mcp/tools/simulator/__tests__/test_sim.test.ts index 449849db..8636c92b 100644 --- a/src/mcp/tools/simulator/__tests__/test_sim.test.ts +++ b/src/mcp/tools/simulator/__tests__/test_sim.test.ts @@ -1,8 +1,3 @@ -/** - * Tests for test_sim plugin (session-aware version) - * Follows CLAUDE.md guidance: dependency injection, no vi-mocks, literal validation. - */ - import { describe, it, expect, beforeEach, vi } from 'vitest'; import * as z from 'zod'; import { sessionStore } from '../../../../utils/session-store.ts'; diff --git a/src/mcp/tools/simulator/install_app_sim.ts b/src/mcp/tools/simulator/install_app_sim.ts index ad396c9d..5d580cf9 100644 --- a/src/mcp/tools/simulator/install_app_sim.ts +++ b/src/mcp/tools/simulator/install_app_sim.ts @@ -1,7 +1,7 @@ import * as z from 'zod'; import type { ToolResponse } from '../../../types/common.ts'; import { log } from '../../../utils/logging/index.ts'; -import { validateFileExists } from '../../../utils/validation/index.ts'; +import { validateFileExists } from '../../../utils/validation.ts'; import type { CommandExecutor, FileSystemExecutor } from '../../../utils/execution/index.ts'; import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; import { diff --git a/src/mcp/tools/simulator/open_sim.ts b/src/mcp/tools/simulator/open_sim.ts index 08f475d0..6b0f5944 100644 --- a/src/mcp/tools/simulator/open_sim.ts +++ b/src/mcp/tools/simulator/open_sim.ts @@ -12,7 +12,7 @@ const openSimSchema = z.object({}); type OpenSimParams = z.infer; export async function open_simLogic( - params: OpenSimParams, + _params: OpenSimParams, executor: CommandExecutor, ): Promise { log('info', 'Starting open simulator request'); diff --git a/src/mcp/tools/swift-package/__tests__/active-processes.test.ts b/src/mcp/tools/swift-package/__tests__/active-processes.test.ts index 86244a11..fcc5b22b 100644 --- a/src/mcp/tools/swift-package/__tests__/active-processes.test.ts +++ b/src/mcp/tools/swift-package/__tests__/active-processes.test.ts @@ -1,8 +1,3 @@ -/** - * Tests for active-processes module - * Following CLAUDE.md testing standards with literal validation - */ - import { describe, it, expect, beforeEach } from 'vitest'; import { activeProcesses, diff --git a/src/mcp/tools/swift-package/__tests__/swift_package_build.test.ts b/src/mcp/tools/swift-package/__tests__/swift_package_build.test.ts index 47dd4b15..73cfcc76 100644 --- a/src/mcp/tools/swift-package/__tests__/swift_package_build.test.ts +++ b/src/mcp/tools/swift-package/__tests__/swift_package_build.test.ts @@ -1,9 +1,3 @@ -/** - * Tests for swift_package_build plugin - * Following CLAUDE.md testing standards with literal validation - * Using dependency injection for deterministic testing - */ - import { describe, it, expect, beforeEach } from 'vitest'; import * as z from 'zod'; import { diff --git a/src/mcp/tools/swift-package/__tests__/swift_package_clean.test.ts b/src/mcp/tools/swift-package/__tests__/swift_package_clean.test.ts index 56fc682a..706cd43f 100644 --- a/src/mcp/tools/swift-package/__tests__/swift_package_clean.test.ts +++ b/src/mcp/tools/swift-package/__tests__/swift_package_clean.test.ts @@ -1,9 +1,3 @@ -/** - * Tests for swift_package_clean plugin - * Following CLAUDE.md testing standards with literal validation - * Using dependency injection for deterministic testing - */ - import { describe, it, expect } from 'vitest'; import { createMockExecutor, diff --git a/src/mcp/tools/swift-package/__tests__/swift_package_list.test.ts b/src/mcp/tools/swift-package/__tests__/swift_package_list.test.ts index 23df3d5f..4002afb2 100644 --- a/src/mcp/tools/swift-package/__tests__/swift_package_list.test.ts +++ b/src/mcp/tools/swift-package/__tests__/swift_package_list.test.ts @@ -1,9 +1,3 @@ -/** - * Tests for swift_package_list plugin - * Following CLAUDE.md testing standards with literal validation - * Using pure dependency injection for deterministic testing - */ - import { describe, it, expect, beforeEach } from 'vitest'; import { schema, handler, swift_package_listLogic } from '../swift_package_list.ts'; diff --git a/src/mcp/tools/swift-package/__tests__/swift_package_run.test.ts b/src/mcp/tools/swift-package/__tests__/swift_package_run.test.ts index 1ccc3279..a4576eb6 100644 --- a/src/mcp/tools/swift-package/__tests__/swift_package_run.test.ts +++ b/src/mcp/tools/swift-package/__tests__/swift_package_run.test.ts @@ -1,9 +1,3 @@ -/** - * Tests for swift_package_run plugin - * Following CLAUDE.md testing standards with literal validation - * Integration tests using dependency injection for deterministic testing - */ - import { describe, it, expect, beforeEach } from 'vitest'; import * as z from 'zod'; import { diff --git a/src/mcp/tools/swift-package/__tests__/swift_package_test.test.ts b/src/mcp/tools/swift-package/__tests__/swift_package_test.test.ts index ef491108..29f5af11 100644 --- a/src/mcp/tools/swift-package/__tests__/swift_package_test.test.ts +++ b/src/mcp/tools/swift-package/__tests__/swift_package_test.test.ts @@ -1,9 +1,3 @@ -/** - * Tests for swift_package_test plugin - * Following CLAUDE.md testing standards with literal validation - * Using dependency injection for deterministic testing - */ - import { describe, it, expect } from 'vitest'; import * as z from 'zod'; import { diff --git a/src/mcp/tools/swift-package/swift_package_list.ts b/src/mcp/tools/swift-package/swift_package_list.ts index 932c1962..09e9b47e 100644 --- a/src/mcp/tools/swift-package/swift_package_list.ts +++ b/src/mcp/tools/swift-package/swift_package_list.ts @@ -89,8 +89,6 @@ export const schema = swiftPackageListSchema.shape; export const handler = createTypedTool( swiftPackageListSchema, - (params: SwiftPackageListParams) => { - return swift_package_listLogic(params); - }, + (params: SwiftPackageListParams) => swift_package_listLogic(params), getDefaultCommandExecutor, ); diff --git a/src/mcp/tools/ui-automation/__tests__/button.test.ts b/src/mcp/tools/ui-automation/__tests__/button.test.ts index 8bf8effa..5cf1a193 100644 --- a/src/mcp/tools/ui-automation/__tests__/button.test.ts +++ b/src/mcp/tools/ui-automation/__tests__/button.test.ts @@ -1,7 +1,3 @@ -/** - * Tests for button tool plugin - */ - import { describe, it, expect } from 'vitest'; import * as z from 'zod'; import { @@ -12,13 +8,7 @@ import { import { schema, handler, buttonLogic } from '../button.ts'; import type { CommandExecutor } from '../../../../utils/execution/index.ts'; import { AXE_NOT_AVAILABLE_MESSAGE } from '../../../../utils/axe-helpers.ts'; - -function allText(result: { content: Array<{ type: string; text?: string }> }): string { - return result.content - .filter((c): c is { type: 'text'; text: string } => c.type === 'text') - .map((c) => c.text) - .join('\n'); -} +import { allText } from '../../../../test-utils/test-helpers.ts'; describe('Button Plugin', () => { describe('Export Field Validation (Literal)', () => { diff --git a/src/mcp/tools/ui-automation/__tests__/gesture.test.ts b/src/mcp/tools/ui-automation/__tests__/gesture.test.ts index b71a741a..5744a29e 100644 --- a/src/mcp/tools/ui-automation/__tests__/gesture.test.ts +++ b/src/mcp/tools/ui-automation/__tests__/gesture.test.ts @@ -1,7 +1,3 @@ -/** - * Tests for gesture tool plugin - */ - import { describe, it, expect, beforeEach } from 'vitest'; import * as z from 'zod'; import { @@ -12,13 +8,7 @@ import { import { sessionStore } from '../../../../utils/session-store.ts'; import { schema, handler, gestureLogic } from '../gesture.ts'; import { AXE_NOT_AVAILABLE_MESSAGE } from '../../../../utils/axe-helpers.ts'; - -function allText(result: { content: Array<{ type: string; text?: string }> }): string { - return result.content - .filter((c): c is { type: 'text'; text: string } => c.type === 'text') - .map((c) => c.text) - .join('\n'); -} +import { allText } from '../../../../test-utils/test-helpers.ts'; describe('Gesture Plugin', () => { beforeEach(() => { diff --git a/src/mcp/tools/ui-automation/__tests__/key_press.test.ts b/src/mcp/tools/ui-automation/__tests__/key_press.test.ts index 536bb036..1123a3e6 100644 --- a/src/mcp/tools/ui-automation/__tests__/key_press.test.ts +++ b/src/mcp/tools/ui-automation/__tests__/key_press.test.ts @@ -1,7 +1,3 @@ -/** - * Tests for key_press tool - */ - import { describe, it, expect, beforeEach } from 'vitest'; import * as z from 'zod'; import { @@ -13,6 +9,7 @@ import { import { sessionStore } from '../../../../utils/session-store.ts'; import { schema, handler, key_pressLogic } from '../key_press.ts'; import { AXE_NOT_AVAILABLE_MESSAGE } from '../../../../utils/axe-helpers.ts'; +import { allText } from '../../../../test-utils/test-helpers.ts'; function createDefaultMockAxeHelpers() { return { @@ -21,13 +18,6 @@ function createDefaultMockAxeHelpers() { }; } -function allText(result: { content: Array<{ type: string; text?: string }> }): string { - return result.content - .filter((c): c is { type: 'text'; text: string } => c.type === 'text') - .map((c) => c.text) - .join('\n'); -} - describe('Key Press Tool', () => { beforeEach(() => { sessionStore.clear(); diff --git a/src/mcp/tools/ui-automation/__tests__/key_sequence.test.ts b/src/mcp/tools/ui-automation/__tests__/key_sequence.test.ts index cdd1a57b..8c6dd5e2 100644 --- a/src/mcp/tools/ui-automation/__tests__/key_sequence.test.ts +++ b/src/mcp/tools/ui-automation/__tests__/key_sequence.test.ts @@ -1,7 +1,3 @@ -/** - * Tests for key_sequence tool - */ - import { describe, it, expect, beforeEach } from 'vitest'; import * as z from 'zod'; import { @@ -12,13 +8,7 @@ import { import { sessionStore } from '../../../../utils/session-store.ts'; import { schema, handler, key_sequenceLogic } from '../key_sequence.ts'; import { AXE_NOT_AVAILABLE_MESSAGE } from '../../../../utils/axe-helpers.ts'; - -function allText(result: { content: Array<{ type: string; text?: string }> }): string { - return result.content - .filter((c): c is { type: 'text'; text: string } => c.type === 'text') - .map((c) => c.text) - .join('\n'); -} +import { allText } from '../../../../test-utils/test-helpers.ts'; describe('Key Sequence Tool', () => { beforeEach(() => { diff --git a/src/mcp/tools/ui-automation/__tests__/long_press.test.ts b/src/mcp/tools/ui-automation/__tests__/long_press.test.ts index 408b11d6..fb71f147 100644 --- a/src/mcp/tools/ui-automation/__tests__/long_press.test.ts +++ b/src/mcp/tools/ui-automation/__tests__/long_press.test.ts @@ -1,20 +1,10 @@ -/** - * Tests for long_press tool plugin - */ - import { describe, it, expect, beforeEach } from 'vitest'; import * as z from 'zod'; import { createMockExecutor, mockProcess } from '../../../../test-utils/mock-executors.ts'; import { sessionStore } from '../../../../utils/session-store.ts'; import { schema, handler, long_pressLogic } from '../long_press.ts'; import { AXE_NOT_AVAILABLE_MESSAGE } from '../../../../utils/axe-helpers.ts'; - -function allText(result: { content: Array<{ type: string; text?: string }> }): string { - return result.content - .filter((c): c is { type: 'text'; text: string } => c.type === 'text') - .map((c) => c.text) - .join('\n'); -} +import { allText } from '../../../../test-utils/test-helpers.ts'; describe('Long Press Plugin', () => { beforeEach(() => { diff --git a/src/mcp/tools/ui-automation/__tests__/screenshot.test.ts b/src/mcp/tools/ui-automation/__tests__/screenshot.test.ts index 747f138e..816307b7 100644 --- a/src/mcp/tools/ui-automation/__tests__/screenshot.test.ts +++ b/src/mcp/tools/ui-automation/__tests__/screenshot.test.ts @@ -1,7 +1,3 @@ -/** - * Tests for screenshot tool plugin - */ - import { describe, it, expect, beforeEach } from 'vitest'; import * as z from 'zod'; import { @@ -9,7 +5,7 @@ import { createMockFileSystemExecutor, mockProcess, } from '../../../../test-utils/mock-executors.ts'; -import { SystemError } from '../../../../utils/responses/index.ts'; +import { SystemError } from '../../../../utils/errors.ts'; import { sessionStore } from '../../../../utils/session-store.ts'; import { schema, @@ -18,13 +14,7 @@ import { detectLandscapeMode, rotateImage, } from '../screenshot.ts'; - -function allText(result: { content: Array<{ type: string; text?: string }> }): string { - return result.content - .filter((c): c is { type: 'text'; text: string } => c.type === 'text') - .map((c) => c.text) - .join('\n'); -} +import { allText } from '../../../../test-utils/test-helpers.ts'; describe('Screenshot Plugin', () => { beforeEach(() => { diff --git a/src/mcp/tools/ui-automation/__tests__/snapshot_ui.test.ts b/src/mcp/tools/ui-automation/__tests__/snapshot_ui.test.ts index 66d073fe..1934ac27 100644 --- a/src/mcp/tools/ui-automation/__tests__/snapshot_ui.test.ts +++ b/src/mcp/tools/ui-automation/__tests__/snapshot_ui.test.ts @@ -1,20 +1,10 @@ -/** - * Tests for snapshot_ui tool plugin - */ - import { describe, it, expect } from 'vitest'; import * as z from 'zod'; import { createMockExecutor, createNoopExecutor } from '../../../../test-utils/mock-executors.ts'; import type { CommandExecutor } from '../../../../utils/execution/index.ts'; import { schema, handler, snapshot_uiLogic } from '../snapshot_ui.ts'; import { AXE_NOT_AVAILABLE_MESSAGE } from '../../../../utils/axe-helpers.ts'; - -function allText(result: { content: Array<{ type: string; text?: string }> }): string { - return result.content - .filter((c): c is { type: 'text'; text: string } => c.type === 'text') - .map((c) => c.text) - .join('\n'); -} +import { allText } from '../../../../test-utils/test-helpers.ts'; describe('Snapshot UI Plugin', () => { describe('Export Field Validation (Literal)', () => { diff --git a/src/mcp/tools/ui-automation/__tests__/swipe.test.ts b/src/mcp/tools/ui-automation/__tests__/swipe.test.ts index fb8d3338..376429b8 100644 --- a/src/mcp/tools/ui-automation/__tests__/swipe.test.ts +++ b/src/mcp/tools/ui-automation/__tests__/swipe.test.ts @@ -1,17 +1,13 @@ -/** - * Tests for swipe tool - */ - import { describe, it, expect, beforeEach } from 'vitest'; import * as z from 'zod'; import { createMockExecutor, mockProcess } from '../../../../test-utils/mock-executors.ts'; -import { SystemError } from '../../../../utils/responses/index.ts'; +import { SystemError } from '../../../../utils/errors.ts'; import { sessionStore } from '../../../../utils/session-store.ts'; import { schema, handler, type AxeHelpers, swipeLogic, type SwipeParams } from '../swipe.ts'; import { AXE_NOT_AVAILABLE_MESSAGE } from '../../../../utils/axe-helpers.ts'; +import { allText } from '../../../../test-utils/test-helpers.ts'; -// Helper function to create mock axe helpers function createMockAxeHelpers(): AxeHelpers { return { getAxePath: () => '/mocked/axe/path', @@ -19,7 +15,6 @@ function createMockAxeHelpers(): AxeHelpers { }; } -// Helper function to create mock axe helpers with null path (for dependency error tests) function createMockAxeHelpersWithNullPath(): AxeHelpers { return { getAxePath: () => null, @@ -27,13 +22,6 @@ function createMockAxeHelpersWithNullPath(): AxeHelpers { }; } -function allText(result: { content: Array<{ type: string; text?: string }> }): string { - return result.content - .filter((c): c is { type: 'text'; text: string } => c.type === 'text') - .map((c) => c.text) - .join('\n'); -} - describe('Swipe Tool', () => { beforeEach(() => { sessionStore.clear(); diff --git a/src/mcp/tools/ui-automation/__tests__/tap.test.ts b/src/mcp/tools/ui-automation/__tests__/tap.test.ts index 37764bb5..90eb6dbe 100644 --- a/src/mcp/tools/ui-automation/__tests__/tap.test.ts +++ b/src/mcp/tools/ui-automation/__tests__/tap.test.ts @@ -1,7 +1,3 @@ -/** - * Tests for tap plugin - */ - import { describe, it, expect, beforeEach } from 'vitest'; import * as z from 'zod'; import { createMockExecutor } from '../../../../test-utils/mock-executors.ts'; @@ -9,8 +5,8 @@ import { sessionStore } from '../../../../utils/session-store.ts'; import { schema, handler, type AxeHelpers, tapLogic } from '../tap.ts'; import { AXE_NOT_AVAILABLE_MESSAGE } from '../../../../utils/axe-helpers.ts'; +import { allText } from '../../../../test-utils/test-helpers.ts'; -// Helper function to create mock axe helpers function createMockAxeHelpers(): AxeHelpers { return { getAxePath: () => '/mocked/axe/path', @@ -18,7 +14,6 @@ function createMockAxeHelpers(): AxeHelpers { }; } -// Helper function to create mock axe helpers with null path (for dependency error tests) function createMockAxeHelpersWithNullPath(): AxeHelpers { return { getAxePath: () => null, @@ -26,13 +21,6 @@ function createMockAxeHelpersWithNullPath(): AxeHelpers { }; } -function allText(result: { content: Array<{ type: string; text?: string }> }): string { - return result.content - .filter((c): c is { type: 'text'; text: string } => c.type === 'text') - .map((c) => c.text) - .join('\n'); -} - describe('Tap Plugin', () => { beforeEach(() => { sessionStore.clear(); diff --git a/src/mcp/tools/ui-automation/__tests__/touch.test.ts b/src/mcp/tools/ui-automation/__tests__/touch.test.ts index 4600435c..51008810 100644 --- a/src/mcp/tools/ui-automation/__tests__/touch.test.ts +++ b/src/mcp/tools/ui-automation/__tests__/touch.test.ts @@ -1,21 +1,10 @@ -/** - * Tests for touch tool plugin - * Following CLAUDE.md testing standards with dependency injection - */ - import { describe, it, expect, beforeEach } from 'vitest'; import * as z from 'zod'; import { createMockExecutor, mockProcess } from '../../../../test-utils/mock-executors.ts'; import { sessionStore } from '../../../../utils/session-store.ts'; import { schema, handler, touchLogic } from '../touch.ts'; import { AXE_NOT_AVAILABLE_MESSAGE } from '../../../../utils/axe-helpers.ts'; - -function allText(result: { content: Array<{ type: string; text?: string }> }): string { - return result.content - .filter((c): c is { type: 'text'; text: string } => c.type === 'text') - .map((c) => c.text) - .join('\n'); -} +import { allText } from '../../../../test-utils/test-helpers.ts'; describe('Touch Plugin', () => { beforeEach(() => { diff --git a/src/mcp/tools/ui-automation/__tests__/type_text.test.ts b/src/mcp/tools/ui-automation/__tests__/type_text.test.ts index 027ce23c..07de4c4a 100644 --- a/src/mcp/tools/ui-automation/__tests__/type_text.test.ts +++ b/src/mcp/tools/ui-automation/__tests__/type_text.test.ts @@ -1,7 +1,3 @@ -/** - * Tests for type_text tool - */ - import { describe, it, expect, beforeEach } from 'vitest'; import * as z from 'zod'; import { @@ -12,6 +8,7 @@ import { import { sessionStore } from '../../../../utils/session-store.ts'; import { schema, handler, type_textLogic } from '../type_text.ts'; import { AXE_NOT_AVAILABLE_MESSAGE } from '../../../../utils/axe-helpers.ts'; +import { allText } from '../../../../test-utils/test-helpers.ts'; // Mock axe helpers for dependency injection function createMockAxeHelpers( @@ -34,13 +31,6 @@ function createRejectingExecutor(error: any) { }; } -function allText(result: { content: Array<{ type: string; text?: string }> }): string { - return result.content - .filter((c): c is { type: 'text'; text: string } => c.type === 'text') - .map((c) => c.text) - .join('\n'); -} - describe('Type Text Tool', () => { beforeEach(() => { sessionStore.clear(); diff --git a/src/mcp/tools/ui-automation/button.ts b/src/mcp/tools/ui-automation/button.ts index 71a4cdbf..959e31ea 100644 --- a/src/mcp/tools/ui-automation/button.ts +++ b/src/mcp/tools/ui-automation/button.ts @@ -6,16 +6,14 @@ import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; import { getDefaultDebuggerManager } from '../../../utils/debugger/index.ts'; import type { DebuggerManager } from '../../../utils/debugger/debugger-manager.ts'; import { guardUiAutomationAgainstStoppedDebugger } from '../../../utils/debugger/ui-automation-guard.ts'; -import { - getAxePath, - getBundledAxeEnvironment, - AXE_NOT_AVAILABLE_MESSAGE, -} from '../../../utils/axe-helpers.ts'; +import { AXE_NOT_AVAILABLE_MESSAGE } from '../../../utils/axe-helpers.ts'; import { DependencyError, AxeError, SystemError } from '../../../utils/errors.ts'; import { createSessionAwareTool, getSessionAwareToolSchemaShape, } from '../../../utils/typed-tool-factory.ts'; +import { executeAxeCommand, defaultAxeHelpers } from './shared/axe-command.ts'; +import type { AxeHelpers } from './shared/axe-command.ts'; import { toolResponse } from '../../../utils/tool-response.ts'; import { header, statusLine, section } from '../../../utils/tool-event-builders.ts'; @@ -33,20 +31,12 @@ const buttonSchema = z.object({ type ButtonParams = z.infer; -export interface AxeHelpers { - getAxePath: () => string | null; - getBundledAxeEnvironment: () => Record; -} - const LOG_PREFIX = '[AXe]'; export async function buttonLogic( params: ButtonParams, executor: CommandExecutor, - axeHelpers: AxeHelpers = { - getAxePath, - getBundledAxeEnvironment, - }, + axeHelpers: AxeHelpers = defaultAxeHelpers, debuggerManager: DebuggerManager = getDefaultDebuggerManager(), ): Promise { const toolName = 'button'; @@ -72,12 +62,11 @@ export async function buttonLogic( try { await executeAxeCommand(commandArgs, simulatorId, 'button', executor, axeHelpers); log('info', `${LOG_PREFIX}/${toolName}: Success for ${simulatorId}`); - const events = [ + return toolResponse([ headerEvent, statusLine('success', `Hardware button '${buttonType}' pressed successfully.`), ...(guard.warningText ? [statusLine('warning' as const, guard.warningText)] : []), - ]; - return toolResponse(events); + ]); } catch (error) { log('error', `${LOG_PREFIX}/${toolName}: Failed - ${error}`); if (error instanceof DependencyError) { @@ -117,74 +106,7 @@ export const schema = getSessionAwareToolSchemaShape({ export const handler = createSessionAwareTool({ internalSchema: buttonSchema as unknown as z.ZodType, logicFunction: (params: ButtonParams, executor: CommandExecutor) => - buttonLogic(params, executor, { - getAxePath, - getBundledAxeEnvironment, - }), + buttonLogic(params, executor, defaultAxeHelpers), getExecutor: getDefaultCommandExecutor, requirements: [{ allOf: ['simulatorId'], message: 'simulatorId is required' }], }); - -// Helper function for executing axe commands (inlined from src/tools/axe/index.ts) -async function executeAxeCommand( - commandArgs: string[], - simulatorId: string, - commandName: string, - executor: CommandExecutor = getDefaultCommandExecutor(), - axeHelpers: AxeHelpers = { getAxePath, getBundledAxeEnvironment }, -): Promise { - // Get the appropriate axe binary path - const axeBinary = axeHelpers.getAxePath(); - if (!axeBinary) { - throw new DependencyError('AXe binary not found'); - } - - // Add --udid parameter to all commands - const fullArgs = [...commandArgs, '--udid', simulatorId]; - - // Construct the full command array with the axe binary as the first element - const fullCommand = [axeBinary, ...fullArgs]; - - try { - // Determine environment variables for bundled AXe - const axeEnv = axeBinary !== 'axe' ? axeHelpers.getBundledAxeEnvironment() : undefined; - - const result = await executor( - fullCommand, - `${LOG_PREFIX}: ${commandName}`, - false, - axeEnv ? { env: axeEnv } : undefined, - ); - - if (!result.success) { - throw new AxeError( - `axe command '${commandName}' failed.`, - commandName, - result.error ?? result.output, - simulatorId, - ); - } - - // Check for stderr output in successful commands - if (result.error) { - log( - 'warn', - `${LOG_PREFIX}: Command '${commandName}' produced stderr output but exited successfully. Output: ${result.error}`, - ); - } - - // Function now returns void - the calling code creates its own response - } catch (error) { - if (error instanceof Error) { - if (error instanceof AxeError) { - throw error; - } - - // Otherwise wrap it in a SystemError - throw new SystemError(`Failed to execute axe command: ${error.message}`, error); - } - - // For any other type of error - throw new SystemError(`Failed to execute axe command: ${String(error)}`); - } -} diff --git a/src/mcp/tools/ui-automation/gesture.ts b/src/mcp/tools/ui-automation/gesture.ts index 1bbcbd9b..d2e6c5fc 100644 --- a/src/mcp/tools/ui-automation/gesture.ts +++ b/src/mcp/tools/ui-automation/gesture.ts @@ -14,15 +14,13 @@ import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; import { getDefaultDebuggerManager } from '../../../utils/debugger/index.ts'; import type { DebuggerManager } from '../../../utils/debugger/debugger-manager.ts'; import { guardUiAutomationAgainstStoppedDebugger } from '../../../utils/debugger/ui-automation-guard.ts'; -import { - getAxePath, - getBundledAxeEnvironment, - AXE_NOT_AVAILABLE_MESSAGE, -} from '../../../utils/axe-helpers.ts'; +import { AXE_NOT_AVAILABLE_MESSAGE } from '../../../utils/axe-helpers.ts'; import { createSessionAwareTool, getSessionAwareToolSchemaShape, } from '../../../utils/typed-tool-factory.ts'; +import { executeAxeCommand, defaultAxeHelpers } from './shared/axe-command.ts'; +import type { AxeHelpers } from './shared/axe-command.ts'; import { toolResponse } from '../../../utils/tool-response.ts'; import { header, statusLine, section } from '../../../utils/tool-event-builders.ts'; @@ -82,20 +80,12 @@ const gestureSchema = z.object({ type GestureParams = z.infer; -export interface AxeHelpers { - getAxePath: () => string | null; - getBundledAxeEnvironment: () => Record; -} - const LOG_PREFIX = '[AXe]'; export async function gestureLogic( params: GestureParams, executor: CommandExecutor, - axeHelpers: AxeHelpers = { - getAxePath, - getBundledAxeEnvironment, - }, + axeHelpers: AxeHelpers = defaultAxeHelpers, debuggerManager: DebuggerManager = getDefaultDebuggerManager(), ): Promise { const toolName = 'gesture'; @@ -137,12 +127,11 @@ export async function gestureLogic( try { await executeAxeCommand(commandArgs, simulatorId, 'gesture', executor, axeHelpers); log('info', `${LOG_PREFIX}/${toolName}: Success for ${simulatorId}`); - const events = [ + return toolResponse([ headerEvent, statusLine('success', `Gesture '${preset}' executed successfully.`), ...(guard.warningText ? [statusLine('warning' as const, guard.warningText)] : []), - ]; - return toolResponse(events); + ]); } catch (error) { log('error', `${LOG_PREFIX}/${toolName}: Failed - ${error}`); if (error instanceof DependencyError) { @@ -182,74 +171,7 @@ export const schema = getSessionAwareToolSchemaShape({ export const handler = createSessionAwareTool({ internalSchema: gestureSchema as unknown as z.ZodType, logicFunction: (params: GestureParams, executor: CommandExecutor) => - gestureLogic(params, executor, { - getAxePath, - getBundledAxeEnvironment, - }), + gestureLogic(params, executor, defaultAxeHelpers), getExecutor: getDefaultCommandExecutor, requirements: [{ allOf: ['simulatorId'], message: 'simulatorId is required' }], }); - -// Helper function for executing axe commands (inlined from src/tools/axe/index.ts) -async function executeAxeCommand( - commandArgs: string[], - simulatorId: string, - commandName: string, - executor: CommandExecutor = getDefaultCommandExecutor(), - axeHelpers: AxeHelpers = { getAxePath, getBundledAxeEnvironment }, -): Promise { - // Get the appropriate axe binary path - const axeBinary = axeHelpers.getAxePath(); - if (!axeBinary) { - throw new DependencyError('AXe binary not found'); - } - - // Add --udid parameter to all commands - const fullArgs = [...commandArgs, '--udid', simulatorId]; - - // Construct the full command array with the axe binary as the first element - const fullCommand = [axeBinary, ...fullArgs]; - - try { - // Determine environment variables for bundled AXe - const axeEnv = axeBinary !== 'axe' ? axeHelpers.getBundledAxeEnvironment() : undefined; - - const result = await executor( - fullCommand, - `${LOG_PREFIX}: ${commandName}`, - false, - axeEnv ? { env: axeEnv } : undefined, - ); - - if (!result.success) { - throw new AxeError( - `axe command '${commandName}' failed.`, - commandName, - result.error ?? result.output, - simulatorId, - ); - } - - // Check for stderr output in successful commands - if (result.error) { - log( - 'warn', - `${LOG_PREFIX}: Command '${commandName}' produced stderr output but exited successfully. Output: ${result.error}`, - ); - } - - // Function now returns void - the calling code creates its own response - } catch (error) { - if (error instanceof Error) { - if (error instanceof AxeError) { - throw error; - } - - // Otherwise wrap it in a SystemError - throw new SystemError(`Failed to execute axe command: ${error.message}`, error); - } - - // For any other type of error - throw new SystemError(`Failed to execute axe command: ${String(error)}`); - } -} diff --git a/src/mcp/tools/ui-automation/key_press.ts b/src/mcp/tools/ui-automation/key_press.ts index 742af49e..f8ad9c40 100644 --- a/src/mcp/tools/ui-automation/key_press.ts +++ b/src/mcp/tools/ui-automation/key_press.ts @@ -7,15 +7,13 @@ import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; import { getDefaultDebuggerManager } from '../../../utils/debugger/index.ts'; import type { DebuggerManager } from '../../../utils/debugger/debugger-manager.ts'; import { guardUiAutomationAgainstStoppedDebugger } from '../../../utils/debugger/ui-automation-guard.ts'; -import { - getAxePath, - getBundledAxeEnvironment, - AXE_NOT_AVAILABLE_MESSAGE, -} from '../../../utils/axe-helpers.ts'; +import { AXE_NOT_AVAILABLE_MESSAGE } from '../../../utils/axe-helpers.ts'; import { createSessionAwareTool, getSessionAwareToolSchemaShape, } from '../../../utils/typed-tool-factory.ts'; +import { executeAxeCommand, defaultAxeHelpers } from './shared/axe-command.ts'; +import type { AxeHelpers } from './shared/axe-command.ts'; import { toolResponse } from '../../../utils/tool-response.ts'; import { header, statusLine, section } from '../../../utils/tool-event-builders.ts'; @@ -36,20 +34,12 @@ const keyPressSchema = z.object({ type KeyPressParams = z.infer; -export interface AxeHelpers { - getAxePath: () => string | null; - getBundledAxeEnvironment: () => Record; -} - const LOG_PREFIX = '[AXe]'; export async function key_pressLogic( params: KeyPressParams, executor: CommandExecutor, - axeHelpers: AxeHelpers = { - getAxePath, - getBundledAxeEnvironment, - }, + axeHelpers: AxeHelpers = defaultAxeHelpers, debuggerManager: DebuggerManager = getDefaultDebuggerManager(), ): Promise { const toolName = 'key_press'; @@ -75,12 +65,11 @@ export async function key_pressLogic( try { await executeAxeCommand(commandArgs, simulatorId, 'key', executor, axeHelpers); log('info', `${LOG_PREFIX}/${toolName}: Success for ${simulatorId}`); - const events = [ + return toolResponse([ headerEvent, statusLine('success', `Key press (code: ${keyCode}) simulated successfully.`), ...(guard.warningText ? [statusLine('warning' as const, guard.warningText)] : []), - ]; - return toolResponse(events); + ]); } catch (error) { log('error', `${LOG_PREFIX}/${toolName}: Failed - ${error}`); if (error instanceof DependencyError) { @@ -122,74 +111,7 @@ export const schema = getSessionAwareToolSchemaShape({ export const handler = createSessionAwareTool({ internalSchema: keyPressSchema as unknown as z.ZodType, logicFunction: (params: KeyPressParams, executor: CommandExecutor) => - key_pressLogic(params, executor, { - getAxePath, - getBundledAxeEnvironment, - }), + key_pressLogic(params, executor, defaultAxeHelpers), getExecutor: getDefaultCommandExecutor, requirements: [{ allOf: ['simulatorId'], message: 'simulatorId is required' }], }); - -// Helper function for executing axe commands (inlined from src/tools/axe/index.ts) -async function executeAxeCommand( - commandArgs: string[], - simulatorId: string, - commandName: string, - executor: CommandExecutor = getDefaultCommandExecutor(), - axeHelpers: AxeHelpers = { getAxePath, getBundledAxeEnvironment }, -): Promise { - // Get the appropriate axe binary path - const axeBinary = axeHelpers.getAxePath(); - if (!axeBinary) { - throw new DependencyError('AXe binary not found'); - } - - // Add --udid parameter to all commands - const fullArgs = [...commandArgs, '--udid', simulatorId]; - - // Construct the full command array with the axe binary as the first element - const fullCommand = [axeBinary, ...fullArgs]; - - try { - // Determine environment variables for bundled AXe - const axeEnv = axeBinary !== 'axe' ? axeHelpers.getBundledAxeEnvironment() : undefined; - - const result = await executor( - fullCommand, - `${LOG_PREFIX}: ${commandName}`, - false, - axeEnv ? { env: axeEnv } : undefined, - ); - - if (!result.success) { - throw new AxeError( - `axe command '${commandName}' failed.`, - commandName, - result.error ?? result.output, - simulatorId, - ); - } - - // Check for stderr output in successful commands - if (result.error) { - log( - 'warn', - `${LOG_PREFIX}: Command '${commandName}' produced stderr output but exited successfully. Output: ${result.error}`, - ); - } - - // Function now returns void - the calling code creates its own response - } catch (error) { - if (error instanceof Error) { - if (error instanceof AxeError) { - throw error; - } - - // Otherwise wrap it in a SystemError - throw new SystemError(`Failed to execute axe command: ${error.message}`, error); - } - - // For any other type of error - throw new SystemError(`Failed to execute axe command: ${String(error)}`); - } -} diff --git a/src/mcp/tools/ui-automation/key_sequence.ts b/src/mcp/tools/ui-automation/key_sequence.ts index 15fda832..9c856201 100644 --- a/src/mcp/tools/ui-automation/key_sequence.ts +++ b/src/mcp/tools/ui-automation/key_sequence.ts @@ -13,15 +13,13 @@ import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; import { getDefaultDebuggerManager } from '../../../utils/debugger/index.ts'; import type { DebuggerManager } from '../../../utils/debugger/debugger-manager.ts'; import { guardUiAutomationAgainstStoppedDebugger } from '../../../utils/debugger/ui-automation-guard.ts'; -import { - getAxePath, - getBundledAxeEnvironment, - AXE_NOT_AVAILABLE_MESSAGE, -} from '../../../utils/axe-helpers.ts'; +import { AXE_NOT_AVAILABLE_MESSAGE } from '../../../utils/axe-helpers.ts'; import { createSessionAwareTool, getSessionAwareToolSchemaShape, } from '../../../utils/typed-tool-factory.ts'; +import { executeAxeCommand, defaultAxeHelpers } from './shared/axe-command.ts'; +import type { AxeHelpers } from './shared/axe-command.ts'; import { toolResponse } from '../../../utils/tool-response.ts'; import { header, statusLine, section } from '../../../utils/tool-event-builders.ts'; @@ -36,20 +34,12 @@ const keySequenceSchema = z.object({ type KeySequenceParams = z.infer; -export interface AxeHelpers { - getAxePath: () => string | null; - getBundledAxeEnvironment: () => Record; -} - const LOG_PREFIX = '[AXe]'; export async function key_sequenceLogic( params: KeySequenceParams, executor: CommandExecutor, - axeHelpers: AxeHelpers = { - getAxePath, - getBundledAxeEnvironment, - }, + axeHelpers: AxeHelpers = defaultAxeHelpers, debuggerManager: DebuggerManager = getDefaultDebuggerManager(), ): Promise { const toolName = 'key_sequence'; @@ -78,12 +68,11 @@ export async function key_sequenceLogic( try { await executeAxeCommand(commandArgs, simulatorId, 'key-sequence', executor, axeHelpers); log('info', `${LOG_PREFIX}/${toolName}: Success for ${simulatorId}`); - const events = [ + return toolResponse([ headerEvent, statusLine('success', `Key sequence [${keyCodes.join(',')}] executed successfully.`), ...(guard.warningText ? [statusLine('warning' as const, guard.warningText)] : []), - ]; - return toolResponse(events); + ]); } catch (error) { log('error', `${LOG_PREFIX}/${toolName}: Failed - ${error}`); if (error instanceof DependencyError) { @@ -125,74 +114,7 @@ export const schema = getSessionAwareToolSchemaShape({ export const handler = createSessionAwareTool({ internalSchema: keySequenceSchema as unknown as z.ZodType, logicFunction: (params: KeySequenceParams, executor: CommandExecutor) => - key_sequenceLogic(params, executor, { - getAxePath, - getBundledAxeEnvironment, - }), + key_sequenceLogic(params, executor, defaultAxeHelpers), getExecutor: getDefaultCommandExecutor, requirements: [{ allOf: ['simulatorId'], message: 'simulatorId is required' }], }); - -// Helper function for executing axe commands (inlined from src/tools/axe/index.ts) -async function executeAxeCommand( - commandArgs: string[], - simulatorId: string, - commandName: string, - executor: CommandExecutor = getDefaultCommandExecutor(), - axeHelpers: AxeHelpers = { getAxePath, getBundledAxeEnvironment }, -): Promise { - // Get the appropriate axe binary path - const axeBinary = axeHelpers.getAxePath(); - if (!axeBinary) { - throw new DependencyError('AXe binary not found'); - } - - // Add --udid parameter to all commands - const fullArgs = [...commandArgs, '--udid', simulatorId]; - - // Construct the full command array with the axe binary as the first element - const fullCommand = [axeBinary, ...fullArgs]; - - try { - // Determine environment variables for bundled AXe - const axeEnv = axeBinary !== 'axe' ? axeHelpers.getBundledAxeEnvironment() : undefined; - - const result = await executor( - fullCommand, - `${LOG_PREFIX}: ${commandName}`, - false, - axeEnv ? { env: axeEnv } : undefined, - ); - - if (!result.success) { - throw new AxeError( - `axe command '${commandName}' failed.`, - commandName, - result.error ?? result.output, - simulatorId, - ); - } - - // Check for stderr output in successful commands - if (result.error) { - log( - 'warn', - `${LOG_PREFIX}: Command '${commandName}' produced stderr output but exited successfully. Output: ${result.error}`, - ); - } - - // Function now returns void - the calling code creates its own response - } catch (error) { - if (error instanceof Error) { - if (error instanceof AxeError) { - throw error; - } - - // Otherwise wrap it in a SystemError - throw new SystemError(`Failed to execute axe command: ${error.message}`, error); - } - - // For any other type of error - throw new SystemError(`Failed to execute axe command: ${String(error)}`); - } -} diff --git a/src/mcp/tools/ui-automation/long_press.ts b/src/mcp/tools/ui-automation/long_press.ts index 487ff837..e1513592 100644 --- a/src/mcp/tools/ui-automation/long_press.ts +++ b/src/mcp/tools/ui-automation/long_press.ts @@ -14,16 +14,14 @@ import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; import { getDefaultDebuggerManager } from '../../../utils/debugger/index.ts'; import type { DebuggerManager } from '../../../utils/debugger/debugger-manager.ts'; import { guardUiAutomationAgainstStoppedDebugger } from '../../../utils/debugger/ui-automation-guard.ts'; -import { - getAxePath, - getBundledAxeEnvironment, - AXE_NOT_AVAILABLE_MESSAGE, -} from '../../../utils/axe-helpers.ts'; +import { AXE_NOT_AVAILABLE_MESSAGE } from '../../../utils/axe-helpers.ts'; import { createSessionAwareTool, getSessionAwareToolSchemaShape, } from '../../../utils/typed-tool-factory.ts'; import { getSnapshotUiWarning } from './shared/snapshot-ui-state.ts'; +import { executeAxeCommand, defaultAxeHelpers } from './shared/axe-command.ts'; +import type { AxeHelpers } from './shared/axe-command.ts'; import { toolResponse } from '../../../utils/tool-response.ts'; import { header, statusLine, section } from '../../../utils/tool-event-builders.ts'; @@ -43,20 +41,12 @@ const publicSchemaObject = z.strictObject( longPressSchema.omit({ simulatorId: true } as const).shape, ); -export interface AxeHelpers { - getAxePath: () => string | null; - getBundledAxeEnvironment: () => Record; -} - const LOG_PREFIX = '[AXe]'; export async function long_pressLogic( params: LongPressParams, executor: CommandExecutor, - axeHelpers: AxeHelpers = { - getAxePath, - getBundledAxeEnvironment, - }, + axeHelpers: AxeHelpers = defaultAxeHelpers, debuggerManager: DebuggerManager = getDefaultDebuggerManager(), ): Promise { const toolName = 'long_press'; @@ -96,13 +86,11 @@ export async function long_pressLogic( const coordinateWarning = getSnapshotUiWarning(simulatorId); const warnings = [guard.warningText, coordinateWarning].filter(Boolean); - const events = [ + return toolResponse([ headerEvent, statusLine('success', `Long press at (${x}, ${y}) for ${duration}ms simulated successfully.`), ...warnings.map((w) => statusLine('warning' as const, w)), - ]; - - return toolResponse(events); + ]); } catch (error) { log('error', `${LOG_PREFIX}/${toolName}: Failed - ${error}`); if (error instanceof DependencyError) { @@ -140,74 +128,7 @@ export const schema = getSessionAwareToolSchemaShape({ export const handler = createSessionAwareTool({ internalSchema: longPressSchema as unknown as z.ZodType, logicFunction: (params: LongPressParams, executor: CommandExecutor) => - long_pressLogic(params, executor, { - getAxePath, - getBundledAxeEnvironment, - }), + long_pressLogic(params, executor, defaultAxeHelpers), getExecutor: getDefaultCommandExecutor, requirements: [{ allOf: ['simulatorId'], message: 'simulatorId is required' }], }); - -// Helper function for executing axe commands (inlined from src/tools/axe/index.ts) -async function executeAxeCommand( - commandArgs: string[], - simulatorId: string, - commandName: string, - executor: CommandExecutor = getDefaultCommandExecutor(), - axeHelpers: AxeHelpers = { getAxePath, getBundledAxeEnvironment }, -): Promise { - // Get the appropriate axe binary path - const axeBinary = axeHelpers.getAxePath(); - if (!axeBinary) { - throw new DependencyError('AXe binary not found'); - } - - // Add --udid parameter to all commands - const fullArgs = [...commandArgs, '--udid', simulatorId]; - - // Construct the full command array with the axe binary as the first element - const fullCommand = [axeBinary, ...fullArgs]; - - try { - // Determine environment variables for bundled AXe - const axeEnv = axeBinary !== 'axe' ? axeHelpers.getBundledAxeEnvironment() : undefined; - - const result = await executor( - fullCommand, - `${LOG_PREFIX}: ${commandName}`, - false, - axeEnv ? { env: axeEnv } : undefined, - ); - - if (!result.success) { - throw new AxeError( - `axe command '${commandName}' failed.`, - commandName, - result.error ?? result.output, - simulatorId, - ); - } - - // Check for stderr output in successful commands - if (result.error) { - log( - 'warn', - `${LOG_PREFIX}: Command '${commandName}' produced stderr output but exited successfully. Output: ${result.error}`, - ); - } - - // Function now returns void - the calling code creates its own response - } catch (error) { - if (error instanceof Error) { - if (error instanceof AxeError) { - throw error; - } - - // Otherwise wrap it in a SystemError - throw new SystemError(`Failed to execute axe command: ${error.message}`, error); - } - - // For any other type of error - throw new SystemError(`Failed to execute axe command: ${String(error)}`); - } -} diff --git a/src/mcp/tools/ui-automation/screenshot.ts b/src/mcp/tools/ui-automation/screenshot.ts index 779b9d8c..ad1b6ee4 100644 --- a/src/mcp/tools/ui-automation/screenshot.ts +++ b/src/mcp/tools/ui-automation/screenshot.ts @@ -269,14 +269,13 @@ export async function screenshotLogic( }; } - const textResponse = toolResponse([ + return toolResponse([ headerEvent, statusLine( 'success', `Screenshot captured: ${screenshotPath} (image/png, optimization failed)`, ), ]); - return textResponse; } log('info', `${LOG_PREFIX}/screenshot: Image optimized successfully`); @@ -346,9 +345,8 @@ export const schema = getSessionAwareToolSchemaShape({ export const handler = createSessionAwareTool({ internalSchema: screenshotSchema as unknown as z.ZodType, - logicFunction: (params: ScreenshotParams, executor: CommandExecutor) => { - return screenshotLogic(params, executor); - }, + logicFunction: (params: ScreenshotParams, executor: CommandExecutor) => + screenshotLogic(params, executor), getExecutor: getDefaultCommandExecutor, requirements: [{ allOf: ['simulatorId'], message: 'simulatorId is required' }], }); diff --git a/src/mcp/tools/ui-automation/shared/axe-command.ts b/src/mcp/tools/ui-automation/shared/axe-command.ts new file mode 100644 index 00000000..eddcd481 --- /dev/null +++ b/src/mcp/tools/ui-automation/shared/axe-command.ts @@ -0,0 +1,70 @@ +import { log } from '../../../../utils/logging/index.ts'; +import type { CommandExecutor } from '../../../../utils/execution/index.ts'; +import { getDefaultCommandExecutor } from '../../../../utils/execution/index.ts'; +import { getAxePath, getBundledAxeEnvironment } from '../../../../utils/axe-helpers.ts'; +import { DependencyError, AxeError, SystemError } from '../../../../utils/errors.ts'; + +export interface AxeHelpers { + getAxePath: () => string | null; + getBundledAxeEnvironment: () => Record; +} + +export const defaultAxeHelpers: AxeHelpers = { + getAxePath, + getBundledAxeEnvironment, +}; + +const LOG_PREFIX = '[AXe]'; + +export async function executeAxeCommand( + commandArgs: string[], + simulatorId: string, + commandName: string, + executor: CommandExecutor = getDefaultCommandExecutor(), + axeHelpers: AxeHelpers = defaultAxeHelpers, +): Promise { + const axeBinary = axeHelpers.getAxePath(); + if (!axeBinary) { + throw new DependencyError('AXe binary not found'); + } + + const fullArgs = [...commandArgs, '--udid', simulatorId]; + const fullCommand = [axeBinary, ...fullArgs]; + + try { + const axeEnv = axeBinary !== 'axe' ? axeHelpers.getBundledAxeEnvironment() : undefined; + + const result = await executor( + fullCommand, + `${LOG_PREFIX}: ${commandName}`, + false, + axeEnv ? { env: axeEnv } : undefined, + ); + + if (!result.success) { + throw new AxeError( + `axe command '${commandName}' failed.`, + commandName, + result.error ?? result.output, + simulatorId, + ); + } + + if (result.error) { + log( + 'warn', + `${LOG_PREFIX}: Command '${commandName}' produced stderr output but exited successfully. Output: ${result.error}`, + ); + } + + return result.output.trim(); + } catch (error) { + if (error instanceof Error) { + if (error instanceof AxeError) { + throw error; + } + throw new SystemError(`Failed to execute axe command: ${error.message}`, error); + } + throw new SystemError(`Failed to execute axe command: ${String(error)}`); + } +} diff --git a/src/mcp/tools/ui-automation/snapshot_ui.ts b/src/mcp/tools/ui-automation/snapshot_ui.ts index 2d4ed545..5513ebeb 100644 --- a/src/mcp/tools/ui-automation/snapshot_ui.ts +++ b/src/mcp/tools/ui-automation/snapshot_ui.ts @@ -7,16 +7,14 @@ import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; import { getDefaultDebuggerManager } from '../../../utils/debugger/index.ts'; import type { DebuggerManager } from '../../../utils/debugger/debugger-manager.ts'; import { guardUiAutomationAgainstStoppedDebugger } from '../../../utils/debugger/ui-automation-guard.ts'; -import { - getAxePath, - getBundledAxeEnvironment, - AXE_NOT_AVAILABLE_MESSAGE, -} from '../../../utils/axe-helpers.ts'; +import { AXE_NOT_AVAILABLE_MESSAGE } from '../../../utils/axe-helpers.ts'; import { createSessionAwareTool, getSessionAwareToolSchemaShape, } from '../../../utils/typed-tool-factory.ts'; import { recordSnapshotUiCall } from './shared/snapshot-ui-state.ts'; +import { executeAxeCommand, defaultAxeHelpers } from './shared/axe-command.ts'; +import type { AxeHelpers } from './shared/axe-command.ts'; import { toolResponse } from '../../../utils/tool-response.ts'; import { header, statusLine, section } from '../../../utils/tool-event-builders.ts'; @@ -26,23 +24,12 @@ const snapshotUiSchema = z.object({ type SnapshotUiParams = z.infer; -export interface AxeHelpers { - getAxePath: () => string | null; - getBundledAxeEnvironment: () => Record; -} - const LOG_PREFIX = '[AXe]'; -/** - * Core business logic for snapshot_ui functionality - */ export async function snapshot_uiLogic( params: SnapshotUiParams, executor: CommandExecutor, - axeHelpers: AxeHelpers = { - getAxePath, - getBundledAxeEnvironment, - }, + axeHelpers: AxeHelpers = defaultAxeHelpers, debuggerManager: DebuggerManager = getDefaultDebuggerManager(), ): Promise { const toolName = 'snapshot_ui'; @@ -73,24 +60,26 @@ export async function snapshot_uiLogic( recordSnapshotUiCall(simulatorId); log('info', `${LOG_PREFIX}/${toolName}: Success for ${simulatorId}`); - const events = [ - headerEvent, - statusLine('success', 'Accessibility hierarchy retrieved successfully.'), - section('Accessibility Hierarchy', ['```json', responseText, '```']), - section('Tips', [ - '- Use frame coordinates for tap/swipe (center: x+width/2, y+height/2)', - '- If a debugger is attached, ensure the app is running (not stopped on breakpoints)', - '- Screenshots are for visual verification only', - ]), - ...(guard.warningText ? [statusLine('warning' as const, guard.warningText)] : []), - ]; - return toolResponse(events, { - nextStepParams: { - snapshot_ui: { simulatorId }, - tap: { simulatorId, x: 0, y: 0 }, - screenshot: { simulatorId }, + return toolResponse( + [ + headerEvent, + statusLine('success', 'Accessibility hierarchy retrieved successfully.'), + section('Accessibility Hierarchy', ['```json', responseText, '```']), + section('Tips', [ + '- Use frame coordinates for tap/swipe (center: x+width/2, y+height/2)', + '- If a debugger is attached, ensure the app is running (not stopped on breakpoints)', + '- Screenshots are for visual verification only', + ]), + ...(guard.warningText ? [statusLine('warning' as const, guard.warningText)] : []), + ], + { + nextStepParams: { + snapshot_ui: { simulatorId }, + tap: { simulatorId, x: 0, y: 0 }, + screenshot: { simulatorId }, + }, }, - }); + ); } catch (error) { log('error', `${LOG_PREFIX}/${toolName}: Failed - ${error}`); if (error instanceof DependencyError) { @@ -132,69 +121,7 @@ export const schema = getSessionAwareToolSchemaShape({ export const handler = createSessionAwareTool({ internalSchema: snapshotUiSchema as unknown as z.ZodType, logicFunction: (params: SnapshotUiParams, executor: CommandExecutor) => - snapshot_uiLogic(params, executor, { - getAxePath, - getBundledAxeEnvironment, - }), + snapshot_uiLogic(params, executor, defaultAxeHelpers), getExecutor: getDefaultCommandExecutor, requirements: [{ allOf: ['simulatorId'], message: 'simulatorId is required' }], }); - -// Helper function for executing axe commands (inlined from src/tools/axe/index.ts) -async function executeAxeCommand( - commandArgs: string[], - simulatorId: string, - commandName: string, - executor: CommandExecutor = getDefaultCommandExecutor(), - axeHelpers: AxeHelpers = { getAxePath, getBundledAxeEnvironment }, -): Promise { - // Get the appropriate axe binary path - const axeBinary = axeHelpers.getAxePath(); - if (!axeBinary) { - throw new DependencyError('AXe binary not found'); - } - - // Add --udid parameter to all commands - const fullArgs = [...commandArgs, '--udid', simulatorId]; - - // Construct the full command array with the axe binary as the first element - const fullCommand = [axeBinary, ...fullArgs]; - - try { - // Determine environment variables for bundled AXe - const axeEnv = axeBinary !== 'axe' ? axeHelpers.getBundledAxeEnvironment() : undefined; - - const result = await executor( - fullCommand, - `${LOG_PREFIX}: ${commandName}`, - false, - axeEnv ? { env: axeEnv } : undefined, - ); - - if (!result.success) { - throw new AxeError( - `axe command '${commandName}' failed.`, - commandName, - result.error ?? result.output, - simulatorId, - ); - } - - // Check for stderr output in successful commands - if (result.error) { - log( - 'warn', - `${LOG_PREFIX}: Command '${commandName}' produced stderr output but exited successfully. Output: ${result.error}`, - ); - } - - return result.output.trim(); - } catch (error) { - if (error instanceof AxeError) { - throw error; - } - const message = error instanceof Error ? error.message : String(error); - const cause = error instanceof Error ? error : undefined; - throw new SystemError(`Failed to execute axe command: ${message}`, cause); - } -} diff --git a/src/mcp/tools/ui-automation/swipe.ts b/src/mcp/tools/ui-automation/swipe.ts index bea02eb7..d3dd59cb 100644 --- a/src/mcp/tools/ui-automation/swipe.ts +++ b/src/mcp/tools/ui-automation/swipe.ts @@ -13,16 +13,15 @@ import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; import { getDefaultDebuggerManager } from '../../../utils/debugger/index.ts'; import type { DebuggerManager } from '../../../utils/debugger/debugger-manager.ts'; import { guardUiAutomationAgainstStoppedDebugger } from '../../../utils/debugger/ui-automation-guard.ts'; -import { - getAxePath, - getBundledAxeEnvironment, - AXE_NOT_AVAILABLE_MESSAGE, -} from '../../../utils/axe-helpers.ts'; +import { AXE_NOT_AVAILABLE_MESSAGE } from '../../../utils/axe-helpers.ts'; import { createSessionAwareTool, getSessionAwareToolSchemaShape, } from '../../../utils/typed-tool-factory.ts'; import { getSnapshotUiWarning } from './shared/snapshot-ui-state.ts'; +import { executeAxeCommand, defaultAxeHelpers } from './shared/axe-command.ts'; +import type { AxeHelpers } from './shared/axe-command.ts'; +export type { AxeHelpers } from './shared/axe-command.ts'; import { toolResponse } from '../../../utils/tool-response.ts'; import { header, statusLine, section } from '../../../utils/tool-event-builders.ts'; @@ -54,23 +53,12 @@ export type SwipeParams = z.infer; const publicSchemaObject = z.strictObject(swipeSchema.omit({ simulatorId: true } as const).shape); -export interface AxeHelpers { - getAxePath: () => string | null; - getBundledAxeEnvironment: () => Record; -} - const LOG_PREFIX = '[AXe]'; -/** - * Core swipe logic implementation - */ export async function swipeLogic( params: SwipeParams, executor: CommandExecutor, - axeHelpers: AxeHelpers = { - getAxePath, - getBundledAxeEnvironment, - }, + axeHelpers: AxeHelpers = defaultAxeHelpers, debuggerManager: DebuggerManager = getDefaultDebuggerManager(), ): Promise { const toolName = 'swipe'; @@ -122,16 +110,14 @@ export async function swipeLogic( const coordinateWarning = getSnapshotUiWarning(simulatorId); const warnings = [guard.warningText, coordinateWarning].filter(Boolean); - const events = [ + return toolResponse([ headerEvent, statusLine( 'success', `Swipe from (${x1}, ${y1}) to (${x2}, ${y2})${optionsText} simulated successfully.`, ), ...warnings.map((w) => statusLine('warning' as const, w)), - ]; - - return toolResponse(events); + ]); } catch (error) { log('error', `${LOG_PREFIX}/${toolName}: Failed - ${error}`); if (error instanceof DependencyError) { @@ -169,74 +155,7 @@ export const schema = getSessionAwareToolSchemaShape({ export const handler = createSessionAwareTool({ internalSchema: swipeSchema as unknown as z.ZodType, logicFunction: (params: SwipeParams, executor: CommandExecutor) => - swipeLogic(params, executor, { - getAxePath, - getBundledAxeEnvironment, - }), + swipeLogic(params, executor, defaultAxeHelpers), getExecutor: getDefaultCommandExecutor, requirements: [{ allOf: ['simulatorId'], message: 'simulatorId is required' }], }); - -// Helper function for executing axe commands (inlined from src/tools/axe/index.ts) -async function executeAxeCommand( - commandArgs: string[], - simulatorId: string, - commandName: string, - executor: CommandExecutor = getDefaultCommandExecutor(), - axeHelpers: AxeHelpers = { getAxePath, getBundledAxeEnvironment }, -): Promise { - // Get the appropriate axe binary path - const axeBinary = axeHelpers.getAxePath(); - if (!axeBinary) { - throw new DependencyError('AXe binary not found'); - } - - // Add --udid parameter to all commands - const fullArgs = [...commandArgs, '--udid', simulatorId]; - - // Construct the full command array with the axe binary as the first element - const fullCommand = [axeBinary, ...fullArgs]; - - try { - // Determine environment variables for bundled AXe - const axeEnv = axeBinary !== 'axe' ? axeHelpers.getBundledAxeEnvironment() : undefined; - - const result = await executor( - fullCommand, - `${LOG_PREFIX}: ${commandName}`, - false, - axeEnv ? { env: axeEnv } : undefined, - ); - - if (!result.success) { - throw new AxeError( - `axe command '${commandName}' failed.`, - commandName, - result.error ?? result.output, - simulatorId, - ); - } - - // Check for stderr output in successful commands - if (result.error) { - log( - 'warn', - `${LOG_PREFIX}: Command '${commandName}' produced stderr output but exited successfully. Output: ${result.error}`, - ); - } - - // Function now returns void - the calling code creates its own response - } catch (error) { - if (error instanceof Error) { - if (error instanceof AxeError) { - throw error; - } - - // Otherwise wrap it in a SystemError - throw new SystemError(`Failed to execute axe command: ${error.message}`, error); - } - - // For any other type of error - throw new SystemError(`Failed to execute axe command: ${String(error)}`); - } -} diff --git a/src/mcp/tools/ui-automation/tap.ts b/src/mcp/tools/ui-automation/tap.ts index 6c469223..0499bb22 100644 --- a/src/mcp/tools/ui-automation/tap.ts +++ b/src/mcp/tools/ui-automation/tap.ts @@ -6,25 +6,19 @@ import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; import { getDefaultDebuggerManager } from '../../../utils/debugger/index.ts'; import type { DebuggerManager } from '../../../utils/debugger/debugger-manager.ts'; import { guardUiAutomationAgainstStoppedDebugger } from '../../../utils/debugger/ui-automation-guard.ts'; -import { - getAxePath, - getBundledAxeEnvironment, - AXE_NOT_AVAILABLE_MESSAGE, -} from '../../../utils/axe-helpers.ts'; +import { AXE_NOT_AVAILABLE_MESSAGE } from '../../../utils/axe-helpers.ts'; import { DependencyError, AxeError, SystemError } from '../../../utils/errors.ts'; import { createSessionAwareTool, getSessionAwareToolSchemaShape, } from '../../../utils/typed-tool-factory.ts'; import { getSnapshotUiWarning } from './shared/snapshot-ui-state.ts'; +import { executeAxeCommand, defaultAxeHelpers } from './shared/axe-command.ts'; +import type { AxeHelpers } from './shared/axe-command.ts'; +export type { AxeHelpers } from './shared/axe-command.ts'; import { toolResponse } from '../../../utils/tool-response.ts'; import { header, statusLine, section } from '../../../utils/tool-event-builders.ts'; -export interface AxeHelpers { - getAxePath: () => string | null; - getBundledAxeEnvironment: () => Record; -} - const baseTapSchema = z.object({ simulatorId: z.uuid({ message: 'Invalid Simulator UUID format' }), x: z @@ -112,10 +106,7 @@ const LOG_PREFIX = '[AXe]'; export async function tapLogic( params: TapParams, executor: CommandExecutor, - axeHelpers: AxeHelpers = { - getAxePath, - getBundledAxeEnvironment, - }, + axeHelpers: AxeHelpers = defaultAxeHelpers, debuggerManager: DebuggerManager = getDefaultDebuggerManager(), ): Promise { const toolName = 'tap'; @@ -171,13 +162,11 @@ export async function tapLogic( const coordinateWarning = usesCoordinates ? getSnapshotUiWarning(simulatorId) : null; const warnings = [guard.warningText, coordinateWarning].filter(Boolean); - const events = [ + return toolResponse([ headerEvent, statusLine('success', `${actionDescription} simulated successfully.`), ...warnings.map((w) => statusLine('warning' as const, w)), - ]; - - return toolResponse(events); + ]); } catch (error: unknown) { const errorMessage = error instanceof Error ? error.message : String(error); log('error', `${LOG_PREFIX}/${toolName}: Failed - ${errorMessage}`); @@ -216,74 +205,7 @@ export const schema = getSessionAwareToolSchemaShape({ export const handler = createSessionAwareTool({ internalSchema: tapSchema as unknown as z.ZodType, logicFunction: (params: TapParams, executor: CommandExecutor) => - tapLogic(params, executor, { - getAxePath, - getBundledAxeEnvironment, - }), + tapLogic(params, executor, defaultAxeHelpers), getExecutor: getDefaultCommandExecutor, requirements: [{ allOf: ['simulatorId'], message: 'simulatorId is required' }], }); - -// Helper function for executing axe commands (inlined from src/tools/axe/index.ts) -async function executeAxeCommand( - commandArgs: string[], - simulatorId: string, - commandName: string, - executor: CommandExecutor = getDefaultCommandExecutor(), - axeHelpers: AxeHelpers = { getAxePath, getBundledAxeEnvironment }, -): Promise { - // Get the appropriate axe binary path - const axeBinary = axeHelpers.getAxePath(); - if (!axeBinary) { - throw new DependencyError('AXe binary not found'); - } - - // Add --udid parameter to all commands - const fullArgs = [...commandArgs, '--udid', simulatorId]; - - // Construct the full command array with the axe binary as the first element - const fullCommand = [axeBinary, ...fullArgs]; - - try { - // Determine environment variables for bundled AXe - const axeEnv = axeBinary !== 'axe' ? axeHelpers.getBundledAxeEnvironment() : undefined; - - const result = await executor( - fullCommand, - `${LOG_PREFIX}: ${commandName}`, - false, - axeEnv ? { env: axeEnv } : undefined, - ); - - if (!result.success) { - throw new AxeError( - `axe command '${commandName}' failed.`, - commandName, - result.error ?? result.output, - simulatorId, - ); - } - - // Check for stderr output in successful commands - if (result.error) { - log( - 'warn', - `${LOG_PREFIX}: Command '${commandName}' produced stderr output but exited successfully. Output: ${result.error}`, - ); - } - - // Function now returns void - the calling code creates its own response - } catch (error: unknown) { - if (error instanceof Error) { - if (error instanceof AxeError) { - throw error; - } - - // Otherwise wrap it in a SystemError - throw new SystemError(`Failed to execute axe command: ${error.message}`, error); - } - - // For any other type of error - throw new SystemError(`Failed to execute axe command: ${String(error)}`); - } -} diff --git a/src/mcp/tools/ui-automation/touch.ts b/src/mcp/tools/ui-automation/touch.ts index e5925eec..060f7fb7 100644 --- a/src/mcp/tools/ui-automation/touch.ts +++ b/src/mcp/tools/ui-automation/touch.ts @@ -13,17 +13,15 @@ import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; import { getDefaultDebuggerManager } from '../../../utils/debugger/index.ts'; import type { DebuggerManager } from '../../../utils/debugger/debugger-manager.ts'; import { guardUiAutomationAgainstStoppedDebugger } from '../../../utils/debugger/ui-automation-guard.ts'; -import { - getAxePath, - getBundledAxeEnvironment, - AXE_NOT_AVAILABLE_MESSAGE, -} from '../../../utils/axe-helpers.ts'; +import { AXE_NOT_AVAILABLE_MESSAGE } from '../../../utils/axe-helpers.ts'; import type { ToolResponse } from '../../../types/common.ts'; import { createSessionAwareTool, getSessionAwareToolSchemaShape, } from '../../../utils/typed-tool-factory.ts'; import { getSnapshotUiWarning } from './shared/snapshot-ui-state.ts'; +import { executeAxeCommand, defaultAxeHelpers } from './shared/axe-command.ts'; +import type { AxeHelpers } from './shared/axe-command.ts'; import { toolResponse } from '../../../utils/tool-response.ts'; import { header, statusLine, section } from '../../../utils/tool-event-builders.ts'; @@ -44,17 +42,12 @@ type TouchParams = z.infer; const publicSchemaObject = z.strictObject(touchSchema.omit({ simulatorId: true } as const).shape); -interface AxeHelpers { - getAxePath: () => string | null; - getBundledAxeEnvironment: () => Record; -} - const LOG_PREFIX = '[AXe]'; export async function touchLogic( params: TouchParams, executor: CommandExecutor, - axeHelpers?: AxeHelpers, + axeHelpers: AxeHelpers = defaultAxeHelpers, debuggerManager: DebuggerManager = getDefaultDebuggerManager(), ): Promise { const toolName = 'touch'; @@ -100,13 +93,11 @@ export async function touchLogic( const coordinateWarning = getSnapshotUiWarning(simulatorId); const warnings = [guard.warningText, coordinateWarning].filter(Boolean); - const events = [ + return toolResponse([ headerEvent, statusLine('success', `Touch event (${actionText}) at (${x}, ${y}) executed successfully.`), ...warnings.map((w) => statusLine('warning' as const, w)), - ]; - - return toolResponse(events); + ]); } catch (error) { log( 'error', @@ -150,70 +141,3 @@ export const handler = createSessionAwareTool({ getExecutor: getDefaultCommandExecutor, requirements: [{ allOf: ['simulatorId'], message: 'simulatorId is required' }], }); - -// Helper function for executing axe commands (inlined from src/tools/axe/index.ts) -async function executeAxeCommand( - commandArgs: string[], - simulatorId: string, - commandName: string, - executor: CommandExecutor = getDefaultCommandExecutor(), - axeHelpers?: AxeHelpers, -): Promise { - // Use injected helpers or default to imported functions - const helpers = axeHelpers ?? { getAxePath, getBundledAxeEnvironment }; - - // Get the appropriate axe binary path - const axeBinary = helpers.getAxePath(); - if (!axeBinary) { - throw new DependencyError('AXe binary not found'); - } - - // Add --udid parameter to all commands - const fullArgs = [...commandArgs, '--udid', simulatorId]; - - // Construct the full command array with the axe binary as the first element - const fullCommand = [axeBinary, ...fullArgs]; - - try { - // Determine environment variables for bundled AXe - const axeEnv = axeBinary !== 'axe' ? helpers.getBundledAxeEnvironment() : undefined; - - const result = await executor( - fullCommand, - `${LOG_PREFIX}: ${commandName}`, - false, - axeEnv ? { env: axeEnv } : undefined, - ); - - if (!result.success) { - throw new AxeError( - `axe command '${commandName}' failed.`, - commandName, - result.error ?? result.output, - simulatorId, - ); - } - - // Check for stderr output in successful commands - if (result.error) { - log( - 'warn', - `${LOG_PREFIX}: Command '${commandName}' produced stderr output but exited successfully. Output: ${result.error}`, - ); - } - - // Function now returns void - the calling code creates its own response - } catch (error) { - if (error instanceof Error) { - if (error instanceof AxeError) { - throw error; - } - - // Otherwise wrap it in a SystemError - throw new SystemError(`Failed to execute axe command: ${error.message}`, error); - } - - // For any other type of error - throw new SystemError(`Failed to execute axe command: ${String(error)}`); - } -} diff --git a/src/mcp/tools/ui-automation/type_text.ts b/src/mcp/tools/ui-automation/type_text.ts index ea18c0e6..7548b658 100644 --- a/src/mcp/tools/ui-automation/type_text.ts +++ b/src/mcp/tools/ui-automation/type_text.ts @@ -14,15 +14,13 @@ import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; import { getDefaultDebuggerManager } from '../../../utils/debugger/index.ts'; import type { DebuggerManager } from '../../../utils/debugger/debugger-manager.ts'; import { guardUiAutomationAgainstStoppedDebugger } from '../../../utils/debugger/ui-automation-guard.ts'; -import { - getAxePath, - getBundledAxeEnvironment, - AXE_NOT_AVAILABLE_MESSAGE, -} from '../../../utils/axe-helpers.ts'; +import { AXE_NOT_AVAILABLE_MESSAGE } from '../../../utils/axe-helpers.ts'; import { createSessionAwareTool, getSessionAwareToolSchemaShape, } from '../../../utils/typed-tool-factory.ts'; +import { executeAxeCommand, defaultAxeHelpers } from './shared/axe-command.ts'; +import type { AxeHelpers } from './shared/axe-command.ts'; import { toolResponse } from '../../../utils/tool-response.ts'; import { header, statusLine, section } from '../../../utils/tool-event-builders.ts'; @@ -39,15 +37,10 @@ const publicSchemaObject = z.strictObject( typeTextSchema.omit({ simulatorId: true } as const).shape, ); -interface AxeHelpers { - getAxePath: () => string | null; - getBundledAxeEnvironment: () => Record; -} - export async function type_textLogic( params: TypeTextParams, executor: CommandExecutor, - axeHelpers?: AxeHelpers, + axeHelpers: AxeHelpers = defaultAxeHelpers, debuggerManager: DebuggerManager = getDefaultDebuggerManager(), ): Promise { const toolName = 'type_text'; @@ -73,12 +66,11 @@ export async function type_textLogic( try { await executeAxeCommand(commandArgs, simulatorId, 'type', executor, axeHelpers); log('info', `${LOG_PREFIX}/${toolName}: Success for ${simulatorId}`); - const events = [ + return toolResponse([ headerEvent, statusLine('success', 'Text typing simulated successfully.'), ...(guard.warningText ? [statusLine('warning' as const, guard.warningText)] : []), - ]; - return toolResponse(events); + ]); } catch (error) { log( 'error', @@ -123,70 +115,3 @@ export const handler = createSessionAwareTool({ getExecutor: getDefaultCommandExecutor, requirements: [{ allOf: ['simulatorId'], message: 'simulatorId is required' }], }); - -// Helper function for executing axe commands (inlined from src/tools/axe/index.ts) -async function executeAxeCommand( - commandArgs: string[], - simulatorId: string, - commandName: string, - executor: CommandExecutor = getDefaultCommandExecutor(), - axeHelpers?: AxeHelpers, -): Promise { - // Use provided helpers or defaults - const helpers = axeHelpers ?? { getAxePath, getBundledAxeEnvironment }; - - // Get the appropriate axe binary path - const axeBinary = helpers.getAxePath(); - if (!axeBinary) { - throw new DependencyError('AXe binary not found'); - } - - // Add --udid parameter to all commands - const fullArgs = [...commandArgs, '--udid', simulatorId]; - - // Construct the full command array with the axe binary as the first element - const fullCommand = [axeBinary, ...fullArgs]; - - try { - // Determine environment variables for bundled AXe - const axeEnv = axeBinary !== 'axe' ? helpers.getBundledAxeEnvironment() : undefined; - - const result = await executor( - fullCommand, - `${LOG_PREFIX}: ${commandName}`, - false, - axeEnv ? { env: axeEnv } : undefined, - ); - - if (!result.success) { - throw new AxeError( - `axe command '${commandName}' failed.`, - commandName, - result.error ?? result.output, - simulatorId, - ); - } - - // Check for stderr output in successful commands - if (result.error) { - log( - 'warn', - `${LOG_PREFIX}: Command '${commandName}' produced stderr output but exited successfully. Output: ${result.error}`, - ); - } - - // Function now returns void - the calling code creates its own response - } catch (error) { - if (error instanceof Error) { - if (error instanceof AxeError) { - throw error; - } - - // Otherwise wrap it in a SystemError - throw new SystemError(`Failed to execute axe command: ${error.message}`, error); - } - - // For any other type of error - throw new SystemError(`Failed to execute axe command: ${String(error)}`); - } -} diff --git a/src/mcp/tools/utilities/__tests__/clean.test.ts b/src/mcp/tools/utilities/__tests__/clean.test.ts index 707a67cc..af82f67d 100644 --- a/src/mcp/tools/utilities/__tests__/clean.test.ts +++ b/src/mcp/tools/utilities/__tests__/clean.test.ts @@ -52,7 +52,7 @@ describe('clean (unified) tool', () => { it('runs project-path flow via logic', async () => { const mock = createMockExecutor({ success: true, output: 'ok' }); const result = await cleanLogic({ projectPath: '/p.xcodeproj', scheme: 'App' } as any, mock); - expect(result.isError).not.toBe(true); + expect(result.isError).toBeFalsy(); }); it('runs workspace-path flow via logic', async () => { @@ -61,7 +61,7 @@ describe('clean (unified) tool', () => { { workspacePath: '/w.xcworkspace', scheme: 'App' } as any, mock, ); - expect(result.isError).not.toBe(true); + expect(result.isError).toBeFalsy(); }); it('handler validation: requires scheme when workspacePath is provided', async () => { @@ -83,9 +83,8 @@ describe('clean (unified) tool', () => { { projectPath: '/p.xcodeproj', scheme: 'App' } as any, mockExecutor, ); - expect(result.isError).not.toBe(true); + expect(result.isError).toBeFalsy(); - // Check that the command contains iOS platform destination const commandStr = capturedCommand.join(' '); expect(commandStr).toContain('-destination'); expect(commandStr).toContain('platform=iOS'); @@ -106,9 +105,8 @@ describe('clean (unified) tool', () => { } as any, mockExecutor, ); - expect(result.isError).not.toBe(true); + expect(result.isError).toBeFalsy(); - // Check that the command contains macOS platform destination const commandStr = capturedCommand.join(' '); expect(commandStr).toContain('-destination'); expect(commandStr).toContain('platform=macOS'); @@ -129,9 +127,8 @@ describe('clean (unified) tool', () => { } as any, mockExecutor, ); - expect(result.isError).not.toBe(true); + expect(result.isError).toBeFalsy(); - // For clean operations, iOS Simulator should be mapped to iOS platform const commandStr = capturedCommand.join(' '); expect(commandStr).toContain('-destination'); expect(commandStr).toContain('platform=iOS'); diff --git a/src/mcp/tools/utilities/clean.ts b/src/mcp/tools/utilities/clean.ts index 553db55b..2edd25ec 100644 --- a/src/mcp/tools/utilities/clean.ts +++ b/src/mcp/tools/utilities/clean.ts @@ -66,9 +66,11 @@ export async function cleanLogic( params: CleanParams, executor: CommandExecutor, ): Promise { + const headerEvent = header('Clean'); + if (params.workspacePath && !params.scheme) { return toolResponse([ - header('Clean'), + headerEvent, statusLine('error', 'scheme is required when workspacePath is provided.'), ]); } @@ -90,7 +92,7 @@ export async function cleanLogic( const platformEnum = platformMap[targetPlatform]; if (!platformEnum) { return toolResponse([ - header('Clean'), + headerEvent, statusLine('error', `Unsupported platform: "${targetPlatform}".`), ]); } diff --git a/src/mcp/tools/workflow-discovery/__tests__/manage_workflows.test.ts b/src/mcp/tools/workflow-discovery/__tests__/manage_workflows.test.ts index 60c75e0c..730fe3aa 100644 --- a/src/mcp/tools/workflow-discovery/__tests__/manage_workflows.test.ts +++ b/src/mcp/tools/workflow-discovery/__tests__/manage_workflows.test.ts @@ -25,6 +25,7 @@ import { applyWorkflowSelectionFromManifest, getRegisteredWorkflows, } from '../../../../utils/tool-registry.ts'; +import { allText } from '../../../../test-utils/test-helpers.ts'; describe('manage_workflows tool', () => { beforeEach(() => { @@ -32,13 +33,6 @@ describe('manage_workflows tool', () => { vi.mocked(getRegisteredWorkflows).mockReset(); }); - function allText(result: { content: Array<{ type: string; text: string }> }): string { - return result.content - .filter((c) => c.type === 'text') - .map((c) => c.text) - .join('\n'); - } - it('merges new workflows with current set when enable is true', async () => { vi.mocked(getRegisteredWorkflows).mockReturnValue(['simulator']); vi.mocked(applyWorkflowSelectionFromManifest).mockResolvedValue({ diff --git a/src/mcp/tools/xcode-ide/__tests__/bridge_tools.test.ts b/src/mcp/tools/xcode-ide/__tests__/bridge_tools.test.ts index 8590c76a..f9f80cbe 100644 --- a/src/mcp/tools/xcode-ide/__tests__/bridge_tools.test.ts +++ b/src/mcp/tools/xcode-ide/__tests__/bridge_tools.test.ts @@ -34,15 +34,9 @@ import { buildXcodeToolsBridgeStatus, getMcpBridgeAvailability, } from '../../../../integrations/xcode-tools-bridge/core.ts'; +import { allText } from '../../../../test-utils/test-helpers.ts'; describe('xcode-ide bridge tools (standalone fallback)', () => { - function textOf(result: { content: Array<{ type: string; text: string }> }): string { - return result.content - .filter((i) => i.type === 'text') - .map((i) => i.text) - .join('\n'); - } - beforeEach(async () => { await shutdownXcodeToolsBridge(); @@ -88,7 +82,7 @@ describe('xcode-ide bridge tools (standalone fallback)', () => { it('status handler returns bridge status without MCP server instance', async () => { const result = await statusHandler(); - const text = textOf(result); + const text = allText(result); expect(text).toContain('Bridge Status'); expect(text).toContain('"bridgeAvailable": true'); expect(buildXcodeToolsBridgeStatus).toHaveBeenCalledOnce(); @@ -96,7 +90,7 @@ describe('xcode-ide bridge tools (standalone fallback)', () => { it('sync handler uses direct bridge client when MCP server is not initialized', async () => { const result = await syncHandler(); - const text = textOf(result); + const text = allText(result); expect(text).toContain('Bridge Sync'); expect(text).toContain('"total": 2'); expect(clientMocks.connectOnce).toHaveBeenCalledOnce(); @@ -106,7 +100,7 @@ describe('xcode-ide bridge tools (standalone fallback)', () => { it('disconnect handler succeeds without MCP server instance', async () => { const result = await disconnectHandler(); - const text = textOf(result); + const text = allText(result); expect(text).toContain('Bridge Disconnect'); expect(text).toContain('"connected": false'); expect(clientMocks.disconnect).toHaveBeenCalledOnce(); @@ -114,7 +108,7 @@ describe('xcode-ide bridge tools (standalone fallback)', () => { it('list handler returns bridge tools without MCP server instance', async () => { const result = await listHandler({ refresh: true }); - const text = textOf(result); + const text = allText(result); expect(text).toContain('Xcode IDE List Tools'); expect(text).toContain('"toolCount": 2'); expect(clientMocks.listTools).toHaveBeenCalledOnce(); diff --git a/src/mcp/tools/xcode-ide/__tests__/sync_xcode_defaults.test.ts b/src/mcp/tools/xcode-ide/__tests__/sync_xcode_defaults.test.ts index a1067eba..6b23f73b 100644 --- a/src/mcp/tools/xcode-ide/__tests__/sync_xcode_defaults.test.ts +++ b/src/mcp/tools/xcode-ide/__tests__/sync_xcode_defaults.test.ts @@ -4,8 +4,8 @@ import { join } from 'path'; import { sessionStore } from '../../../../utils/session-store.ts'; import { createCommandMatchingMockExecutor } from '../../../../test-utils/mock-executors.ts'; import { schema, syncXcodeDefaultsLogic } from '../sync_xcode_defaults.ts'; +import { allText } from '../../../../test-utils/test-helpers.ts'; -// Path to the example project (used as test fixture) const EXAMPLE_PROJECT_PATH = join(process.cwd(), 'example_projects/iOS/MCPTest.xcodeproj'); const EXAMPLE_XCUSERSTATE = join( EXAMPLE_PROJECT_PATH, @@ -25,13 +25,6 @@ describe('sync_xcode_defaults tool', () => { }); describe('syncXcodeDefaultsLogic', () => { - function textOf(result: { content: Array<{ type: string; text: string }> }): string { - return result.content - .filter((i) => i.type === 'text') - .map((i) => i.text) - .join('\n'); - } - it('returns error when no project found', async () => { const executor = createCommandMatchingMockExecutor({ whoami: { output: 'testuser\n' }, @@ -41,7 +34,7 @@ describe('sync_xcode_defaults tool', () => { const result = await syncXcodeDefaultsLogic({}, { executor, cwd: '/test/project' }); expect(result.isError).toBe(true); - expect(textOf(result)).toContain('Failed to read Xcode IDE state'); + expect(allText(result)).toContain('Failed to read Xcode IDE state'); }); it('returns error when xcuserstate file not found', async () => { @@ -54,13 +47,11 @@ describe('sync_xcode_defaults tool', () => { const result = await syncXcodeDefaultsLogic({}, { executor, cwd: '/test/project' }); expect(result.isError).toBe(true); - expect(textOf(result)).toContain('Failed to read Xcode IDE state'); + expect(allText(result)).toContain('Failed to read Xcode IDE state'); }); }); describe('syncXcodeDefaultsLogic integration', () => { - // These tests use the actual example project fixture - it.skipIf(!existsSync(EXAMPLE_XCUSERSTATE))( 'syncs scheme and simulator from example project', async () => { @@ -143,7 +134,6 @@ describe('sync_xcode_defaults tool', () => { }); it.skipIf(!existsSync(EXAMPLE_XCUSERSTATE))('updates existing session defaults', async () => { - // Set some existing defaults sessionStore.setDefaults({ scheme: 'OldScheme', simulatorId: 'OLD-SIM-UUID', @@ -178,7 +168,6 @@ describe('sync_xcode_defaults tool', () => { expect(defaults.simulatorId).toBe('B38FE93D-578B-454B-BE9A-C6FA0CE5F096'); expect(defaults.simulatorName).toBe('Apple Vision Pro'); expect(defaults.bundleId).toBe('io.sentry.MCPTest'); - // Original projectPath should be preserved expect(defaults.projectPath).toBe('/some/project.xcodeproj'); }); }); diff --git a/src/mcp/tools/xcode-ide/sync_xcode_defaults.ts b/src/mcp/tools/xcode-ide/sync_xcode_defaults.ts index a4a9e60a..333a0cdf 100644 --- a/src/mcp/tools/xcode-ide/sync_xcode_defaults.ts +++ b/src/mcp/tools/xcode-ide/sync_xcode_defaults.ts @@ -24,6 +24,8 @@ export async function syncXcodeDefaultsLogic( _params: Params, ctx: SyncXcodeDefaultsContext, ): Promise { + const headerEvent = header('Sync Xcode Defaults'); + const xcodeState = await readXcodeIdeState({ executor: ctx.executor, cwd: ctx.cwd, @@ -33,7 +35,7 @@ export async function syncXcodeDefaultsLogic( if (xcodeState.error) { return toolResponse([ - header('Sync Xcode Defaults'), + headerEvent, statusLine('error', `Failed to read Xcode IDE state: ${xcodeState.error}`), ]); } @@ -66,7 +68,7 @@ export async function syncXcodeDefaultsLogic( if (Object.keys(synced).length === 0) { return toolResponse([ - header('Sync Xcode Defaults'), + headerEvent, statusLine('info', 'No scheme or simulator selection detected in Xcode IDE state.'), ]); } @@ -76,7 +78,7 @@ export async function syncXcodeDefaultsLogic( const items = Object.entries(synced).map(([k, v]) => ({ label: k, value: v })); return toolResponse([ - header('Sync Xcode Defaults'), + headerEvent, detailTree(items), statusLine('success', 'Synced session defaults from Xcode IDE.'), ]); diff --git a/src/test-utils/test-helpers.ts b/src/test-utils/test-helpers.ts new file mode 100644 index 00000000..a350ba19 --- /dev/null +++ b/src/test-utils/test-helpers.ts @@ -0,0 +1,17 @@ +/** + * Shared test helpers for extracting text content from tool responses. + */ + +import type { ToolResponse } from '../types/common.ts'; + +/** + * Extract and join all text content items from a tool response. + */ +export function allText( + result: ToolResponse | { content: Array<{ type: string; text?: string }> }, +): string { + return result.content + .filter((c): c is { type: 'text'; text: string } => c.type === 'text') + .map((c) => c.text) + .join('\n'); +} diff --git a/src/types/pipeline-events.ts b/src/types/pipeline-events.ts index 744abccd..60eee547 100644 --- a/src/types/pipeline-events.ts +++ b/src/types/pipeline-events.ts @@ -154,10 +154,6 @@ export type PipelineEvent = | TestProgressEvent | TestFailureEvent; -// --- Backward compatibility alias --- - -export type XcodebuildEvent = PipelineEvent; - // --- Build-run notice types (used by xcodebuild pipeline internals) --- export type NoticeLevel = 'info' | 'success' | 'warning'; diff --git a/src/utils/capabilities.ts b/src/utils/capabilities.ts deleted file mode 100644 index e69de29b..00000000 diff --git a/src/utils/renderers/event-formatting.ts b/src/utils/renderers/event-formatting.ts index 8ac45cbe..bb135d21 100644 --- a/src/utils/renderers/event-formatting.ts +++ b/src/utils/renderers/event-formatting.ts @@ -358,42 +358,27 @@ export function formatGroupedCompilerErrors( return lines.join('\n'); } +const BUILD_STAGE_LABEL: Record, string> = { + RESOLVING_PACKAGES: 'Resolving packages', + COMPILING: 'Compiling', + LINKING: 'Linking', + PREPARING_TESTS: 'Preparing tests', + RUN_TESTS: 'Running tests', + ARCHIVING: 'Archiving', +}; + export function formatBuildStageEvent(event: BuildStageEvent): string { - switch (event.stage) { - case 'RESOLVING_PACKAGES': - return '\u203A Resolving packages'; - case 'COMPILING': - return '\u203A Compiling'; - case 'LINKING': - return '\u203A Linking'; - case 'PREPARING_TESTS': - return '\u203A Preparing tests'; - case 'RUN_TESTS': - return '\u203A Running tests'; - case 'ARCHIVING': - return '\u203A Archiving'; - case 'COMPLETED': - return event.message; + if (event.stage === 'COMPLETED') { + return event.message; } + return `\u203A ${BUILD_STAGE_LABEL[event.stage]}`; } export function formatTransientBuildStageEvent(event: BuildStageEvent): string { - switch (event.stage) { - case 'RESOLVING_PACKAGES': - return 'Resolving packages...'; - case 'COMPILING': - return 'Compiling...'; - case 'LINKING': - return 'Linking...'; - case 'PREPARING_TESTS': - return 'Preparing tests...'; - case 'RUN_TESTS': - return 'Running tests...'; - case 'ARCHIVING': - return 'Archiving...'; - case 'COMPLETED': - return event.message; + if (event.stage === 'COMPLETED') { + return event.message; } + return `${BUILD_STAGE_LABEL[event.stage]}...`; } export function formatHumanCompilerWarningEvent( @@ -416,11 +401,7 @@ export function formatGroupedWarnings( const lines = [heading, '']; for (const event of events) { - const diagnostic = parseHumanDiagnostic(event, 'warning', options); - lines.push(` \u{26A0} ${event.message}`); - if (diagnostic.location) { - lines.push(` ${diagnostic.location}`); - } + lines.push(formatHumanCompilerWarningEvent(event, options)); lines.push(''); } diff --git a/src/utils/responses/index.ts b/src/utils/responses/index.ts deleted file mode 100644 index 276aadbd..00000000 --- a/src/utils/responses/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export { DependencyError, AxeError, SystemError, ValidationError } from '../errors.ts'; - -export type { ToolResponse } from '../../types/common.ts'; diff --git a/src/utils/tool-event-builders.ts b/src/utils/tool-event-builders.ts index be90e452..f8309829 100644 --- a/src/utils/tool-event-builders.ts +++ b/src/utils/tool-event-builders.ts @@ -5,7 +5,6 @@ import type { FileRefEvent, TableEvent, DetailTreeEvent, - SummaryEvent, } from '../types/pipeline-events.ts'; function now(): string { @@ -77,27 +76,3 @@ export function detailTree(items: Array<{ label: string; value: string }>): Deta items, }; } - -export function summary( - status: 'SUCCEEDED' | 'FAILED', - opts?: { - operation?: string; - durationMs?: number; - totalTests?: number; - passedTests?: number; - failedTests?: number; - skippedTests?: number; - }, -): SummaryEvent { - return { - type: 'summary', - timestamp: now(), - status, - operation: opts?.operation, - durationMs: opts?.durationMs, - totalTests: opts?.totalTests, - passedTests: opts?.passedTests, - failedTests: opts?.failedTests, - skippedTests: opts?.skippedTests, - }; -} diff --git a/src/utils/typed-tool-factory.ts b/src/utils/typed-tool-factory.ts index 294e887f..044a5e01 100644 --- a/src/utils/typed-tool-factory.ts +++ b/src/utils/typed-tool-factory.ts @@ -16,9 +16,7 @@ function createValidatedHandler( return async (args: Record): Promise => { try { const validatedParams = schema.parse(args); - - const response = await logicFunction(validatedParams, getContext()); - return response; + return logicFunction(validatedParams, getContext()); } catch (error) { if (error instanceof z.ZodError) { const details = `Invalid parameters:\n${formatZodIssues(error)}`; @@ -181,8 +179,7 @@ function createSessionAwareHandler(opts: { } const validated = internalSchema.parse(merged); - const response = await logicFunction(validated, getContext()); - return response; + return logicFunction(validated, getContext()); } catch (error) { if (error instanceof z.ZodError) { const details = `Invalid parameters:\n${formatZodIssues(error)}`; diff --git a/src/utils/validation/index.ts b/src/utils/validation/index.ts deleted file mode 100644 index a5f99827..00000000 --- a/src/utils/validation/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from '../validation.ts'; diff --git a/src/utils/xcodebuild-output.ts b/src/utils/xcodebuild-output.ts index c675c3b6..4355fd42 100644 --- a/src/utils/xcodebuild-output.ts +++ b/src/utils/xcodebuild-output.ts @@ -72,14 +72,6 @@ function formatBuildRunStepLabel(step: string): string { } } -function mapNoticeLevelToStatusLineLevel( - level: NoticeLevel, -): 'success' | 'error' | 'info' | 'warning' { - if (level === 'success') return 'success'; - if (level === 'warning') return 'warning'; - return 'info'; -} - export function createNoticeEvent( operation: XcodebuildOperation, message: string, @@ -103,10 +95,12 @@ export function createNoticeEvent( }; } + const statusLevel = level === 'success' || level === 'warning' ? level : 'info'; + return { type: 'status-line', timestamp: new Date().toISOString(), - level: mapNoticeLevelToStatusLineLevel(level), + level: statusLevel, message, }; } @@ -158,13 +152,7 @@ function createNextStepsEvent(steps: NextStep[]): PipelineEvent | null { return { type: 'next-steps', timestamp: new Date().toISOString(), - steps: steps.map(({ label, tool, workflow, cliTool, params }) => ({ - label, - tool, - workflow, - cliTool, - params, - })), + steps, }; } From 77dac205710c95b055548177238e7aec3f3b2e94 Mon Sep 17 00:00:00 2001 From: Cameron Cooke Date: Thu, 26 Mar 2026 12:35:01 +0000 Subject: [PATCH 06/50] fix: Improve pipeline output consistency and snapshot coverage - Fix 3-space indent for header params and icon-prefixed section lines to align with emoji grid - Fix duplicate headers in xcodebuild error paths: build-utils no longer emits its own header when a pipeline is active - Add deviceId to pipeline header params for device build-and-run and test tools - Add -allowProvisioningUpdates to xcodebuild commands to prevent interactive keychain prompts corrupting CLI output - Preserve test failure details and discovery in snapshot normalizer (removed over-aggressive TEST_FAILURE_BLOCK_REGEX and TEST_DISCOVERY_REGEX stripping) - Fix section line splitting in debugging tools so multi-line output (stack, variables, lldb-command) is properly indented per line - Add debugging happy-path snapshot tests (attach, stack, add-breakpoint, lldb-command, remove-breakpoint, detach) using live simulator debugger - Add device error-path fixtures (stop, install, launch) and fix test--failure fixture naming - Add @main to Calculator example app for Xcode 26.4 compatibility - Regenerate all snapshot fixtures --- .../CalculatorApp/CalculatorApp.swift | 1 + package.json | 4 +- .../__tests__/debugging-tools.test.ts | 6 +- .../tools/debugging/debug_breakpoint_add.ts | 2 +- .../debugging/debug_breakpoint_remove.ts | 2 +- src/mcp/tools/debugging/debug_lldb_command.ts | 2 +- src/mcp/tools/debugging/debug_stack.ts | 2 +- src/mcp/tools/debugging/debug_variables.ts | 2 +- .../device/__tests__/build_device.test.ts | 3 + src/mcp/tools/device/build_run_device.ts | 1 + .../tools/macos/__tests__/build_macos.test.ts | 6 + .../macos/__tests__/build_run_macos.test.ts | 3 + .../simulator/__tests__/build_run_sim.test.ts | 5 + .../simulator/__tests__/build_sim.test.ts | 6 + ...-coverage-report--error-invalid-bundle.txt | 4 +- .../coverage/get-coverage-report--success.txt | 4 +- ...et-file-coverage--error-invalid-bundle.txt | 6 +- .../coverage/get-file-coverage--success.txt | 26 +-- .../debugging/add-breakpoint--success.txt | 7 + .../debugging/attach--success.txt | 12 ++ .../debugging/continue--success.txt | 4 + .../debugging/detach--success.txt | 4 + .../lldb-command--error-no-session.txt | 2 +- .../debugging/lldb-command--success.txt | 14 ++ .../debugging/remove-breakpoint--success.txt | 7 + .../__fixtures__/debugging/stack--success.txt | 24 +++ .../debugging/variables--success.txt | 18 ++ .../device/build--error-wrong-scheme.txt | 12 ++ .../__fixtures__/device/build--success.txt | 6 +- .../device/build-and-run--success.txt | 23 +++ .../get-app-path--error-wrong-scheme.txt | 10 + .../device/get-app-path--success.txt | 8 +- .../device/install--error-invalid-app.txt | 10 + .../device/launch--error-invalid-bundle.txt | 10 + .../__fixtures__/device/list--success.txt | 4 +- .../device/stop--error-no-app.txt | 10 + .../__fixtures__/device/test--failure.txt | 12 ++ .../logging/start-sim-log--error.txt | 4 +- .../logging/stop-sim-log--error.txt | 2 +- .../__fixtures__/macos/build--success.txt | 6 +- .../macos/build-and-run--success.txt | 6 +- .../macos/get-app-path--success.txt | 8 +- .../macos/get-macos-bundle-id--success.txt | 2 +- .../macos/launch--error-invalid-app.txt | 2 +- .../__fixtures__/macos/stop--error-no-app.txt | 2 +- .../__fixtures__/macos/test--success.txt | 6 +- .../get-app-bundle-id--success.txt | 2 +- .../get-macos-bundle-id--success.txt | 2 +- .../list-schemes--success.txt | 2 +- .../show-build-settings--success.txt | 4 +- .../scaffold-ios--error-existing.txt | 6 +- .../scaffold-ios--success.txt | 6 +- .../scaffold-macos--success.txt | 6 +- .../session-show-defaults--success.txt | 2 +- .../session-use-defaults-profile--success.txt | 4 +- .../boot--error-invalid-id.txt | 2 +- .../reset-location--success.txt | 2 +- .../set-appearance--success.txt | 4 +- .../set-location--success.txt | 4 +- .../statusbar--success.txt | 4 +- .../simulator/build--error-wrong-scheme.txt | 16 +- .../__fixtures__/simulator/build--success.txt | 6 +- .../simulator/build-and-run--success.txt | 6 +- .../simulator/get-app-path--success.txt | 10 +- .../simulator/install--error-invalid-app.txt | 4 +- .../simulator/launch-app--success.txt | 4 +- .../launch-app-with-logs--success.txt | 6 +- .../simulator/screenshot--success.txt | 2 +- .../simulator/stop--error-no-app.txt | 4 +- .../__fixtures__/simulator/test--success.txt | 16 +- .../swift-package/build--error-bad-path.txt | 2 +- .../swift-package/build--success.txt | 2 +- .../swift-package/clean--success.txt | 2 +- .../swift-package/run--success.txt | 4 +- .../swift-package/stop--error-no-process.txt | 2 +- .../swift-package/test--success.txt | 2 +- .../ui-automation/button--success.txt | 2 +- .../ui-automation/gesture--success.txt | 2 +- .../ui-automation/key-press--success.txt | 2 +- .../ui-automation/key-sequence--success.txt | 2 +- .../ui-automation/long-press--success.txt | 2 +- .../ui-automation/snapshot-ui--success.txt | 2 +- .../ui-automation/swipe--success.txt | 2 +- .../ui-automation/tap--error-no-simulator.txt | 2 +- .../ui-automation/tap--success.txt | 2 +- .../ui-automation/touch--success.txt | 2 +- .../ui-automation/type-text--success.txt | 2 +- .../__fixtures__/utilities/clean--success.txt | 6 +- .../__tests__/debugging.snapshot.test.ts | 145 +++++++++++--- .../__tests__/device.snapshot.test.ts | 177 ++++++++++-------- src/snapshot-tests/normalize.ts | 2 - src/utils/build-utils.ts | 57 ++++-- .../__tests__/event-formatting.test.ts | 2 +- src/utils/renderers/event-formatting.ts | 5 +- src/utils/test-common.ts | 3 + 95 files changed, 600 insertions(+), 277 deletions(-) create mode 100644 src/snapshot-tests/__fixtures__/debugging/add-breakpoint--success.txt create mode 100644 src/snapshot-tests/__fixtures__/debugging/attach--success.txt create mode 100644 src/snapshot-tests/__fixtures__/debugging/continue--success.txt create mode 100644 src/snapshot-tests/__fixtures__/debugging/detach--success.txt create mode 100644 src/snapshot-tests/__fixtures__/debugging/lldb-command--success.txt create mode 100644 src/snapshot-tests/__fixtures__/debugging/remove-breakpoint--success.txt create mode 100644 src/snapshot-tests/__fixtures__/debugging/stack--success.txt create mode 100644 src/snapshot-tests/__fixtures__/debugging/variables--success.txt create mode 100644 src/snapshot-tests/__fixtures__/device/build--error-wrong-scheme.txt create mode 100644 src/snapshot-tests/__fixtures__/device/build-and-run--success.txt create mode 100644 src/snapshot-tests/__fixtures__/device/get-app-path--error-wrong-scheme.txt create mode 100644 src/snapshot-tests/__fixtures__/device/install--error-invalid-app.txt create mode 100644 src/snapshot-tests/__fixtures__/device/launch--error-invalid-bundle.txt create mode 100644 src/snapshot-tests/__fixtures__/device/stop--error-no-app.txt create mode 100644 src/snapshot-tests/__fixtures__/device/test--failure.txt diff --git a/example_projects/iOS_Calculator/CalculatorApp/CalculatorApp.swift b/example_projects/iOS_Calculator/CalculatorApp/CalculatorApp.swift index b8cd4d0b..d03531b4 100644 --- a/example_projects/iOS_Calculator/CalculatorApp/CalculatorApp.swift +++ b/example_projects/iOS_Calculator/CalculatorApp/CalculatorApp.swift @@ -1,6 +1,7 @@ import SwiftUI import CalculatorAppFeature +@main struct CalculatorApp: App { var body: some Scene { WindowGroup { diff --git a/package.json b/package.json index 716b85ad..1d15baa1 100644 --- a/package.json +++ b/package.json @@ -45,8 +45,8 @@ "license:check": "npx -y license-checker --production --onlyAllow 'MIT;ISC;BSD-2-Clause;BSD-3-Clause;Apache-2.0;Unlicense;FSL-1.1-MIT'", "knip": "knip", "test": "vitest run", - "test:snapshot": "npm run build && vitest run --config vitest.snapshot.config.ts", - "test:snapshot:update": "npm run build && UPDATE_SNAPSHOTS=1 vitest run --config vitest.snapshot.config.ts", + "test:snapshot": "npm run build && node build/cli.js daemon stop 2>/dev/null; vitest run --config vitest.snapshot.config.ts", + "test:snapshot:update": "npm run build && node build/cli.js daemon stop 2>/dev/null; UPDATE_SNAPSHOTS=1 vitest run --config vitest.snapshot.config.ts", "test:smoke": "npm run build && vitest run --config vitest.smoke.config.ts", "test:watch": "vitest", "test:ui": "vitest --ui", diff --git a/src/mcp/tools/debugging/__tests__/debugging-tools.test.ts b/src/mcp/tools/debugging/__tests__/debugging-tools.test.ts index c646988f..e49ce8bc 100644 --- a/src/mcp/tools/debugging/__tests__/debugging-tools.test.ts +++ b/src/mcp/tools/debugging/__tests__/debugging-tools.test.ts @@ -761,7 +761,8 @@ describe('debug_stack', () => { expect(result.isError).toBeFalsy(); const text = allText(result); - expect(text).toContain(stackOutput.trim()); + expect(text).toContain('frame #0: 0x0000 main at main.swift:10'); + expect(text).toContain('frame #1: 0x0001 start'); }); it('should pass threadIndex and maxFrames through', async () => { @@ -851,7 +852,8 @@ describe('debug_variables', () => { expect(result.isError).toBeFalsy(); const text = allText(result); - expect(text).toContain(variablesOutput.trim()); + expect(text).toContain('(Int) x = 42'); + expect(text).toContain('(String) name = "hello"'); }); it('should pass frameIndex through', async () => { diff --git a/src/mcp/tools/debugging/debug_breakpoint_add.ts b/src/mcp/tools/debugging/debug_breakpoint_add.ts index ed77f8a5..962d1bee 100644 --- a/src/mcp/tools/debugging/debug_breakpoint_add.ts +++ b/src/mcp/tools/debugging/debug_breakpoint_add.ts @@ -53,7 +53,7 @@ export async function debug_breakpoint_addLogic( const events = [ headerEvent, statusLine('success', `Breakpoint ${result.id} set`), - ...(rawOutput ? [section('Output', [rawOutput])] : []), + ...(rawOutput ? [section('Output', rawOutput.split('\n'))] : []), ]; return toolResponse(events); diff --git a/src/mcp/tools/debugging/debug_breakpoint_remove.ts b/src/mcp/tools/debugging/debug_breakpoint_remove.ts index c2e367d5..5cdefa66 100644 --- a/src/mcp/tools/debugging/debug_breakpoint_remove.ts +++ b/src/mcp/tools/debugging/debug_breakpoint_remove.ts @@ -27,7 +27,7 @@ export async function debug_breakpoint_removeLogic( const events = [ headerEvent, statusLine('success', `Breakpoint ${params.breakpointId} removed`), - ...(rawOutput ? [section('Output', [rawOutput])] : []), + ...(rawOutput ? [section('Output', rawOutput.split('\n'))] : []), ]; return toolResponse(events); diff --git a/src/mcp/tools/debugging/debug_lldb_command.ts b/src/mcp/tools/debugging/debug_lldb_command.ts index aa7828ef..68b316e2 100644 --- a/src/mcp/tools/debugging/debug_lldb_command.ts +++ b/src/mcp/tools/debugging/debug_lldb_command.ts @@ -34,7 +34,7 @@ export async function debug_lldb_commandLogic( return toolResponse([ headerEvent, statusLine('success', 'Command executed'), - ...(trimmed ? [section('Output', [trimmed])] : []), + ...(trimmed ? [section('Output', trimmed.split('\n'))] : []), ]); } catch (error) { const message = error instanceof Error ? error.message : String(error); diff --git a/src/mcp/tools/debugging/debug_stack.ts b/src/mcp/tools/debugging/debug_stack.ts index ab96118a..493e42a1 100644 --- a/src/mcp/tools/debugging/debug_stack.ts +++ b/src/mcp/tools/debugging/debug_stack.ts @@ -32,7 +32,7 @@ export async function debug_stackLogic( return toolResponse([ headerEvent, statusLine('success', 'Stack trace retrieved'), - ...(trimmed ? [section('Frames', [trimmed])] : []), + ...(trimmed ? [section('Frames', trimmed.split('\n'))] : []), ]); } catch (error) { const message = error instanceof Error ? error.message : String(error); diff --git a/src/mcp/tools/debugging/debug_variables.ts b/src/mcp/tools/debugging/debug_variables.ts index caae533c..5e6f80be 100644 --- a/src/mcp/tools/debugging/debug_variables.ts +++ b/src/mcp/tools/debugging/debug_variables.ts @@ -30,7 +30,7 @@ export async function debug_variablesLogic( return toolResponse([ headerEvent, statusLine('success', 'Variables retrieved'), - ...(trimmed ? [section('Values', [trimmed])] : []), + ...(trimmed ? [section('Values', trimmed.split('\n'))] : []), ]); } catch (error) { const message = error instanceof Error ? error.message : String(error); diff --git a/src/mcp/tools/device/__tests__/build_device.test.ts b/src/mcp/tools/device/__tests__/build_device.test.ts index c45b874c..991d8766 100644 --- a/src/mcp/tools/device/__tests__/build_device.test.ts +++ b/src/mcp/tools/device/__tests__/build_device.test.ts @@ -181,6 +181,7 @@ describe('build_device plugin', () => { '-configuration', 'Debug', '-skipMacroValidation', + '-allowProvisioningUpdates', '-destination', 'generic/platform=iOS', 'build', @@ -228,6 +229,7 @@ describe('build_device plugin', () => { '-configuration', 'Debug', '-skipMacroValidation', + '-allowProvisioningUpdates', '-destination', 'generic/platform=iOS', 'build', @@ -314,6 +316,7 @@ describe('build_device plugin', () => { '-configuration', 'Release', '-skipMacroValidation', + '-allowProvisioningUpdates', '-destination', 'generic/platform=iOS', '-derivedDataPath', diff --git a/src/mcp/tools/device/build_run_device.ts b/src/mcp/tools/device/build_run_device.ts index 3cf18e15..c03a4ee3 100644 --- a/src/mcp/tools/device/build_run_device.ts +++ b/src/mcp/tools/device/build_run_device.ts @@ -103,6 +103,7 @@ export async function build_run_deviceLogic( scheme: params.scheme, configuration, platform: String(platform), + deviceId: params.deviceId, preflight: preflightText, }, message: preflightText, diff --git a/src/mcp/tools/macos/__tests__/build_macos.test.ts b/src/mcp/tools/macos/__tests__/build_macos.test.ts index a92ceb9e..49676a4f 100644 --- a/src/mcp/tools/macos/__tests__/build_macos.test.ts +++ b/src/mcp/tools/macos/__tests__/build_macos.test.ts @@ -209,6 +209,7 @@ describe('build_macos plugin', () => { '-configuration', 'Debug', '-skipMacroValidation', + '-allowProvisioningUpdates', '-destination', 'platform=macOS', 'build', @@ -247,6 +248,7 @@ describe('build_macos plugin', () => { '-configuration', 'Release', '-skipMacroValidation', + '-allowProvisioningUpdates', '-destination', 'platform=macOS,arch=x86_64', '-derivedDataPath', @@ -284,6 +286,7 @@ describe('build_macos plugin', () => { '-configuration', 'Debug', '-skipMacroValidation', + '-allowProvisioningUpdates', '-destination', 'platform=macOS', '-derivedDataPath', @@ -320,6 +323,7 @@ describe('build_macos plugin', () => { '-configuration', 'Debug', '-skipMacroValidation', + '-allowProvisioningUpdates', '-destination', 'platform=macOS,arch=arm64', 'build', @@ -353,6 +357,7 @@ describe('build_macos plugin', () => { '-configuration', 'Debug', '-skipMacroValidation', + '-allowProvisioningUpdates', '-destination', 'platform=macOS', 'build', @@ -386,6 +391,7 @@ describe('build_macos plugin', () => { '-configuration', 'Debug', '-skipMacroValidation', + '-allowProvisioningUpdates', '-destination', 'platform=macOS', 'build', diff --git a/src/mcp/tools/macos/__tests__/build_run_macos.test.ts b/src/mcp/tools/macos/__tests__/build_run_macos.test.ts index 726c682f..ea5561e2 100644 --- a/src/mcp/tools/macos/__tests__/build_run_macos.test.ts +++ b/src/mcp/tools/macos/__tests__/build_run_macos.test.ts @@ -125,6 +125,7 @@ describe('build_run_macos', () => { '-configuration', 'Debug', '-skipMacroValidation', + '-allowProvisioningUpdates', '-destination', 'platform=macOS', 'build', @@ -203,6 +204,7 @@ describe('build_run_macos', () => { '-configuration', 'Debug', '-skipMacroValidation', + '-allowProvisioningUpdates', '-destination', 'platform=macOS', 'build', @@ -426,6 +428,7 @@ describe('build_run_macos', () => { '-configuration', 'Debug', '-skipMacroValidation', + '-allowProvisioningUpdates', '-destination', 'platform=macOS', 'build', diff --git a/src/mcp/tools/simulator/__tests__/build_run_sim.test.ts b/src/mcp/tools/simulator/__tests__/build_run_sim.test.ts index 20f3067d..3709fc19 100644 --- a/src/mcp/tools/simulator/__tests__/build_run_sim.test.ts +++ b/src/mcp/tools/simulator/__tests__/build_run_sim.test.ts @@ -387,6 +387,7 @@ describe('build_run_sim tool', () => { '-configuration', 'Debug', '-skipMacroValidation', + '-allowProvisioningUpdates', '-destination', 'platform=iOS Simulator,name=iPhone 17,OS=latest', 'build', @@ -442,6 +443,7 @@ describe('build_run_sim tool', () => { '-configuration', 'Debug', '-skipMacroValidation', + '-allowProvisioningUpdates', '-destination', 'platform=iOS Simulator,name=iPhone 17,OS=latest', 'build', @@ -505,6 +507,7 @@ describe('build_run_sim tool', () => { '-configuration', 'Release', '-skipMacroValidation', + '-allowProvisioningUpdates', '-destination', 'platform=iOS Simulator,name=iPhone 17', 'build', @@ -548,6 +551,7 @@ describe('build_run_sim tool', () => { '-configuration', 'Debug', '-skipMacroValidation', + '-allowProvisioningUpdates', '-destination', 'platform=iOS Simulator,name=iPhone 17 Pro,OS=latest', 'build', @@ -578,6 +582,7 @@ describe('build_run_sim tool', () => { '-configuration', 'Debug', '-skipMacroValidation', + '-allowProvisioningUpdates', '-destination', 'platform=tvOS Simulator,name=Apple TV 4K,OS=latest', 'build', diff --git a/src/mcp/tools/simulator/__tests__/build_sim.test.ts b/src/mcp/tools/simulator/__tests__/build_sim.test.ts index 2e97212d..f39da987 100644 --- a/src/mcp/tools/simulator/__tests__/build_sim.test.ts +++ b/src/mcp/tools/simulator/__tests__/build_sim.test.ts @@ -225,6 +225,7 @@ describe('build_sim tool', () => { '-configuration', 'Debug', '-skipMacroValidation', + '-allowProvisioningUpdates', '-destination', 'platform=iOS Simulator,name=iPhone 17,OS=latest', 'build', @@ -256,6 +257,7 @@ describe('build_sim tool', () => { '-configuration', 'Debug', '-skipMacroValidation', + '-allowProvisioningUpdates', '-destination', 'platform=iOS Simulator,name=iPhone 17,OS=latest', 'build', @@ -291,6 +293,7 @@ describe('build_sim tool', () => { '-configuration', 'Release', '-skipMacroValidation', + '-allowProvisioningUpdates', '-destination', 'platform=iOS Simulator,name=iPhone 17', '-derivedDataPath', @@ -325,6 +328,7 @@ describe('build_sim tool', () => { '-configuration', 'Debug', '-skipMacroValidation', + '-allowProvisioningUpdates', '-destination', 'platform=iOS Simulator,name=iPhone 17 Pro,OS=latest', 'build', @@ -357,6 +361,7 @@ describe('build_sim tool', () => { '-configuration', 'Debug', '-skipMacroValidation', + '-allowProvisioningUpdates', '-destination', 'platform=iOS Simulator,name=iPhone 17,OS=latest', 'build', @@ -388,6 +393,7 @@ describe('build_sim tool', () => { '-configuration', 'Debug', '-skipMacroValidation', + '-allowProvisioningUpdates', '-destination', 'platform=watchOS Simulator,name=Apple Watch Ultra 2,OS=latest', 'build', diff --git a/src/snapshot-tests/__fixtures__/coverage/get-coverage-report--error-invalid-bundle.txt b/src/snapshot-tests/__fixtures__/coverage/get-coverage-report--error-invalid-bundle.txt index 16262d73..9da13665 100644 --- a/src/snapshot-tests/__fixtures__/coverage/get-coverage-report--error-invalid-bundle.txt +++ b/src/snapshot-tests/__fixtures__/coverage/get-coverage-report--error-invalid-bundle.txt @@ -1,6 +1,6 @@ ๐Ÿ“Š Coverage Report - xcresult: /invalid.xcresult + xcresult: /invalid.xcresult -โŒ Failed to get coverage report: Error: Error Domain=XCCovErrorDomain Code=0 "Failed to load result bundle" UserInfo={NSLocalizedDescription=Failed to load result bundle, NSUnderlyingError=0x7b3d88a00 {Error Domain=XCResultStorage.ResultBundleFactory.Error Code=0 "Failed to create a new result bundle reader, underlying error: Info.plist at /invalid.xcresult/Info.plist does not exist, the result bundle might be corrupted or the provided path is not a result bundle"}} +โŒ Failed to get coverage report: Error: Error Domain=XCCovErrorDomain Code=0 "Failed to load result bundle" UserInfo={NSLocalizedDescription=Failed to load result bundle, NSUnderlyingError=0x84fcd98b0 {Error Domain=XCResultStorage.ResultBundleFactory.Error Code=0 "Failed to create a new result bundle reader, underlying error: Info.plist at /invalid.xcresult/Info.plist does not exist, the result bundle might be corrupted or the provided path is not a result bundle"}} diff --git a/src/snapshot-tests/__fixtures__/coverage/get-coverage-report--success.txt b/src/snapshot-tests/__fixtures__/coverage/get-coverage-report--success.txt index 034cde37..710beac5 100644 --- a/src/snapshot-tests/__fixtures__/coverage/get-coverage-report--success.txt +++ b/src/snapshot-tests/__fixtures__/coverage/get-coverage-report--success.txt @@ -1,8 +1,8 @@ ๐Ÿ“Š Coverage Report - xcresult: /TestResults.xcresult - Target Filter: CalculatorAppTests + xcresult: /TestResults.xcresult + Target Filter: CalculatorAppTests โ„น๏ธ Overall: 94.9% (354/373 lines) diff --git a/src/snapshot-tests/__fixtures__/coverage/get-file-coverage--error-invalid-bundle.txt b/src/snapshot-tests/__fixtures__/coverage/get-file-coverage--error-invalid-bundle.txt index b4f7f41f..1073d467 100644 --- a/src/snapshot-tests/__fixtures__/coverage/get-file-coverage--error-invalid-bundle.txt +++ b/src/snapshot-tests/__fixtures__/coverage/get-file-coverage--error-invalid-bundle.txt @@ -1,7 +1,7 @@ ๐Ÿ“Š File Coverage - xcresult: /invalid.xcresult - File: SomeFile.swift + xcresult: /invalid.xcresult + File: SomeFile.swift -โŒ Failed to get file coverage: Error: Error Domain=XCCovErrorDomain Code=0 "Failed to load result bundle" UserInfo={NSLocalizedDescription=Failed to load result bundle, NSUnderlyingError=0xb1a1a1ea0 {Error Domain=XCResultStorage.ResultBundleFactory.Error Code=0 "Failed to create a new result bundle reader, underlying error: Info.plist at /invalid.xcresult/Info.plist does not exist, the result bundle might be corrupted or the provided path is not a result bundle"}} +โŒ Failed to get file coverage: Error: Error Domain=XCCovErrorDomain Code=0 "Failed to load result bundle" UserInfo={NSLocalizedDescription=Failed to load result bundle, NSUnderlyingError=0x9d9bbbe80 {Error Domain=XCResultStorage.ResultBundleFactory.Error Code=0 "Failed to create a new result bundle reader, underlying error: Info.plist at /invalid.xcresult/Info.plist does not exist, the result bundle might be corrupted or the provided path is not a result bundle"}} diff --git a/src/snapshot-tests/__fixtures__/coverage/get-file-coverage--success.txt b/src/snapshot-tests/__fixtures__/coverage/get-file-coverage--success.txt index f0705117..8b67a8c1 100644 --- a/src/snapshot-tests/__fixtures__/coverage/get-file-coverage--success.txt +++ b/src/snapshot-tests/__fixtures__/coverage/get-file-coverage--success.txt @@ -1,25 +1,25 @@ ๐Ÿ“Š File Coverage - xcresult: /TestResults.xcresult - File: CalculatorService.swift + xcresult: /TestResults.xcresult + File: CalculatorService.swift File: example_projects/iOS_Calculator/CalculatorAppPackage/Sources/CalculatorAppFeature/CalculatorService.swift โ„น๏ธ Coverage: 83.1% (157/189 lines) ๐Ÿ”ด Not Covered (7 functions, 22 lines) - L159 CalculatorService.deleteLastDigit() -- 0/16 lines - L58 implicit closure #2 in CalculatorService.inputNumber(_:) -- 0/1 lines - L98 implicit closure #3 in CalculatorService.calculate() -- 0/1 lines - L99 implicit closure #4 in CalculatorService.calculate() -- 0/1 lines - L162 implicit closure #1 in CalculatorService.deleteLastDigit() -- 0/1 lines - L172 implicit closure #2 in CalculatorService.deleteLastDigit() -- 0/1 lines - L214 implicit closure #4 in CalculatorService.formatNumber(_:) -- 0/1 lines + L159 CalculatorService.deleteLastDigit() -- 0/16 lines + L58 implicit closure #2 in CalculatorService.inputNumber(_:) -- 0/1 lines + L98 implicit closure #3 in CalculatorService.calculate() -- 0/1 lines + L99 implicit closure #4 in CalculatorService.calculate() -- 0/1 lines + L162 implicit closure #1 in CalculatorService.deleteLastDigit() -- 0/1 lines + L172 implicit closure #2 in CalculatorService.deleteLastDigit() -- 0/1 lines + L214 implicit closure #4 in CalculatorService.formatNumber(_:) -- 0/1 lines ๐ŸŸก Partial Coverage (4 functions) - L184 CalculatorService.updateExpressionDisplay() -- 80.0% (8/10 lines) - L195 CalculatorService.formatNumber(_:) -- 85.7% (18/21 lines) - L93 CalculatorService.calculate() -- 89.5% (34/38 lines) - L63 CalculatorService.inputDecimal() -- 92.9% (13/14 lines) + L184 CalculatorService.updateExpressionDisplay() -- 80.0% (8/10 lines) + L195 CalculatorService.formatNumber(_:) -- 85.7% (18/21 lines) + L93 CalculatorService.calculate() -- 89.5% (34/38 lines) + L63 CalculatorService.inputDecimal() -- 92.9% (13/14 lines) ๐ŸŸข Full Coverage (28 functions) -- all at 100% diff --git a/src/snapshot-tests/__fixtures__/debugging/add-breakpoint--success.txt b/src/snapshot-tests/__fixtures__/debugging/add-breakpoint--success.txt new file mode 100644 index 00000000..ae35b98b --- /dev/null +++ b/src/snapshot-tests/__fixtures__/debugging/add-breakpoint--success.txt @@ -0,0 +1,7 @@ + +๐Ÿ› Add Breakpoint + +โœ… Breakpoint 1 set + +Output + Set breakpoint 1 at ContentView.swift:42 diff --git a/src/snapshot-tests/__fixtures__/debugging/attach--success.txt b/src/snapshot-tests/__fixtures__/debugging/attach--success.txt new file mode 100644 index 00000000..43b9b43e --- /dev/null +++ b/src/snapshot-tests/__fixtures__/debugging/attach--success.txt @@ -0,0 +1,12 @@ + +๐Ÿ› Attach Debugger + +โœ… Attached DAP debugger to simulator process 2643 () + โ”œ Debug session ID: + โ”œ Status: This session is now the current debug session. + โ”” Execution: Execution is paused. Use debug_continue to resume before UI automation. + +Next steps: +1. Add a breakpoint: debug_breakpoint_add({ debugSessionId: "", file: "...", line: 123 }) +2. Continue execution: debug_continue({ debugSessionId: "" }) +3. Show call stack: debug_stack({ debugSessionId: "" }) diff --git a/src/snapshot-tests/__fixtures__/debugging/continue--success.txt b/src/snapshot-tests/__fixtures__/debugging/continue--success.txt new file mode 100644 index 00000000..bb44582d --- /dev/null +++ b/src/snapshot-tests/__fixtures__/debugging/continue--success.txt @@ -0,0 +1,4 @@ + +๐Ÿ› Continue + +โœ… Resumed debugger session diff --git a/src/snapshot-tests/__fixtures__/debugging/detach--success.txt b/src/snapshot-tests/__fixtures__/debugging/detach--success.txt new file mode 100644 index 00000000..89a010f5 --- /dev/null +++ b/src/snapshot-tests/__fixtures__/debugging/detach--success.txt @@ -0,0 +1,4 @@ + +๐Ÿ› Detach + +โœ… Detached debugger session diff --git a/src/snapshot-tests/__fixtures__/debugging/lldb-command--error-no-session.txt b/src/snapshot-tests/__fixtures__/debugging/lldb-command--error-no-session.txt index 6daf1551..74ba88dc 100644 --- a/src/snapshot-tests/__fixtures__/debugging/lldb-command--error-no-session.txt +++ b/src/snapshot-tests/__fixtures__/debugging/lldb-command--error-no-session.txt @@ -1,6 +1,6 @@ ๐Ÿ› LLDB Command - Command: bt + Command: bt โŒ Failed to run LLDB command: No active debug session. Provide debugSessionId or attach first. diff --git a/src/snapshot-tests/__fixtures__/debugging/lldb-command--success.txt b/src/snapshot-tests/__fixtures__/debugging/lldb-command--success.txt new file mode 100644 index 00000000..0e0408f5 --- /dev/null +++ b/src/snapshot-tests/__fixtures__/debugging/lldb-command--success.txt @@ -0,0 +1,14 @@ + +๐Ÿ› LLDB Command + + Command: breakpoint list + +โœ… Command executed + +Output + Current breakpoints: + 1: file = 'ContentView.swift', line = 42, exact_match = 0, locations = 1, resolved = 1, hit count = 0 + Names: + dap + + 1.1: where = CalculatorApp.debug.dylib`closure #1 in closure #1 in closure #1 in ContentView.body.getter + 1428 at ContentView.swift:42:31, address = 0x0000000102d3bc28, resolved, hit count = 0 diff --git a/src/snapshot-tests/__fixtures__/debugging/remove-breakpoint--success.txt b/src/snapshot-tests/__fixtures__/debugging/remove-breakpoint--success.txt new file mode 100644 index 00000000..07196bce --- /dev/null +++ b/src/snapshot-tests/__fixtures__/debugging/remove-breakpoint--success.txt @@ -0,0 +1,7 @@ + +๐Ÿ› Remove Breakpoint + +โœ… Breakpoint 1 removed + +Output + Removed breakpoint 1. diff --git a/src/snapshot-tests/__fixtures__/debugging/stack--success.txt b/src/snapshot-tests/__fixtures__/debugging/stack--success.txt new file mode 100644 index 00000000..241c8a47 --- /dev/null +++ b/src/snapshot-tests/__fixtures__/debugging/stack--success.txt @@ -0,0 +1,24 @@ + +๐Ÿ› Stack Trace + +โœ… Stack trace retrieved + +Frames + Thread 15494840 (Thread 1 Queue: com.apple.main-thread (serial)) + frame #0: mach_msg2_trap at /usr/lib/system/libsystem_kernel.dylib`mach_msg2_trap:3 + frame #1: mach_msg2_internal at /usr/lib/system/libsystem_kernel.dylib`mach_msg2_internal:56 + frame #2: mach_msg_overwrite at /usr/lib/system/libsystem_kernel.dylib`mach_msg_overwrite:121 + frame #3: mach_msg at /usr/lib/system/libsystem_kernel.dylib`mach_msg:6 + frame #4: __CFRunLoopServiceMachPort at /Library/Developer/CoreSimulator/Volumes/iOS_23E244/Library/Developer/CoreSimulator/Profiles/Runtimes/iOS 26.4.simruntime/Contents/Resources/RuntimeRoot/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation`__CFRunLoopServiceMachPort:40 + frame #5: __CFRunLoopRun at /Library/Developer/CoreSimulator/Volumes/iOS_23E244/Library/Developer/CoreSimulator/Profiles/Runtimes/iOS 26.4.simruntime/Contents/Resources/RuntimeRoot/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation`__CFRunLoopRun:283 + frame #6: _CFRunLoopRunSpecificWithOptions at /Library/Developer/CoreSimulator/Volumes/iOS_23E244/Library/Developer/CoreSimulator/Profiles/Runtimes/iOS 26.4.simruntime/Contents/Resources/RuntimeRoot/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation`_CFRunLoopRunSpecificWithOptions:125 + frame #7: GSEventRunModal at /Library/Developer/CoreSimulator/Volumes/iOS_23E244/Library/Developer/CoreSimulator/Profiles/Runtimes/iOS 26.4.simruntime/Contents/Resources/RuntimeRoot/System/Library/PrivateFrameworks/GraphicsServices.framework/GraphicsServices`GSEventRunModal:30 + frame #8: -[UIApplication _run] at /Library/Developer/CoreSimulator/Volumes/iOS_23E244/Library/Developer/CoreSimulator/Profiles/Runtimes/iOS 26.4.simruntime/Contents/Resources/RuntimeRoot/System/Library/PrivateFrameworks/UIKitCore.framework/UIKitCore`-[UIApplication _run]:195 + frame #9: UIApplicationMain at /Library/Developer/CoreSimulator/Volumes/iOS_23E244/Library/Developer/CoreSimulator/Profiles/Runtimes/iOS 26.4.simruntime/Contents/Resources/RuntimeRoot/System/Library/PrivateFrameworks/UIKitCore.framework/UIKitCore`UIApplicationMain:31 + frame #10: closure #1 in KitRendererCommon(_:) at /Library/Developer/CoreSimulator/Volumes/iOS_23E244/Library/Developer/CoreSimulator/Profiles/Runtimes/iOS 26.4.simruntime/Contents/Resources/RuntimeRoot/System/Library/Frameworks/SwiftUI.framework/SwiftUI`closure #1 (Swift.UnsafeMutablePointer>>) -> Swift.Never in SwiftUI.KitRendererCommon(Swift.AnyObject.Type) -> Swift.Never:42 + frame #11: runApp<ฯ„_0_0>(_:) at /Library/Developer/CoreSimulator/Volumes/iOS_23E244/Library/Developer/CoreSimulator/Profiles/Runtimes/iOS 26.4.simruntime/Contents/Resources/RuntimeRoot/System/Library/Frameworks/SwiftUI.framework/SwiftUI`SwiftUI.runApp<ฯ„_0_0 where ฯ„_0_0: SwiftUI.App>(ฯ„_0_0) -> Swift.Never:46 + frame #12: static App.main() at /Library/Developer/CoreSimulator/Volumes/iOS_23E244/Library/Developer/CoreSimulator/Profiles/Runtimes/iOS 26.4.simruntime/Contents/Resources/RuntimeRoot/System/Library/Frameworks/SwiftUI.framework/SwiftUI`static SwiftUI.App.main() -> ():38 + frame #13: static CalculatorApp.$main() at /Library/Developer/CoreSimulator/Devices//data/Containers/Bundle/Application//CalculatorApp.app/CalculatorApp.debug.dylib`static CalculatorApp.CalculatorApp.$main() -> ():11 + frame #14: main at /Library/Developer/CoreSimulator/Devices//data/Containers/Bundle/Application//CalculatorApp.app/CalculatorApp.debug.dylib`main:4 + frame #15: start_sim at /Library/Developer/CoreSimulator/Volumes/iOS_23E244/Library/Developer/CoreSimulator/Profiles/Runtimes/iOS 26.4.simruntime/Contents/Resources/RuntimeRoot/usr/lib/dyld_sim`start_sim:6 + frame #16: start at /usr/lib/dyld`start:1797 diff --git a/src/snapshot-tests/__fixtures__/debugging/variables--success.txt b/src/snapshot-tests/__fixtures__/debugging/variables--success.txt new file mode 100644 index 00000000..2e842bba --- /dev/null +++ b/src/snapshot-tests/__fixtures__/debugging/variables--success.txt @@ -0,0 +1,18 @@ + +๐Ÿ› Variables + +โœ… Variables retrieved + +Values + Locals: + (no variables) + + Globals: + (no variables) + + Registers: + General Purpose Registers () = + Floating Point Registers () = + Exception State Registers () = + Scalable Vector Extension Registers () = + Scalable Matrix Extension Registers () = diff --git a/src/snapshot-tests/__fixtures__/device/build--error-wrong-scheme.txt b/src/snapshot-tests/__fixtures__/device/build--error-wrong-scheme.txt new file mode 100644 index 00000000..95b55718 --- /dev/null +++ b/src/snapshot-tests/__fixtures__/device/build--error-wrong-scheme.txt @@ -0,0 +1,12 @@ + +๐Ÿ”จ Build + + Scheme: NONEXISTENT + Configuration: Debug + Platform: iOS + +Errors (1): + + โœ— The workspace named "CalculatorApp" does not contain a scheme named "NONEXISTENT". The "-list" option can be used to find the names of the schemes in the workspace. + +โŒ Build failed. (โฑ๏ธ ) diff --git a/src/snapshot-tests/__fixtures__/device/build--success.txt b/src/snapshot-tests/__fixtures__/device/build--success.txt index e54c5093..7f5b568e 100644 --- a/src/snapshot-tests/__fixtures__/device/build--success.txt +++ b/src/snapshot-tests/__fixtures__/device/build--success.txt @@ -1,9 +1,9 @@ ๐Ÿ”จ Build - Scheme: CalculatorApp - Configuration: Debug - Platform: iOS + Scheme: CalculatorApp + Configuration: Debug + Platform: iOS โœ… Build succeeded. (โฑ๏ธ ) diff --git a/src/snapshot-tests/__fixtures__/device/build-and-run--success.txt b/src/snapshot-tests/__fixtures__/device/build-and-run--success.txt new file mode 100644 index 00000000..4eced7c4 --- /dev/null +++ b/src/snapshot-tests/__fixtures__/device/build-and-run--success.txt @@ -0,0 +1,23 @@ + +๐Ÿš€ Build & Run + + Scheme: CalculatorApp + Configuration: Debug + Platform: iOS + Device: + +โ„น๏ธ Resolving app path +โœ… Resolving app path +โ„น๏ธ Installing app +โœ… Installing app +โ„น๏ธ Launching app + +โœ… Build succeeded. (โฑ๏ธ ) +โœ… Build & Run complete + โ”œ App Path: /Library/Developer/Xcode/DerivedData/CalculatorApp-/Build/Products/Debug-iphoneos/CalculatorApp.app + โ”œ Bundle ID: io.sentry.calculatorapp + โ”” Process ID: + +Next steps: +1. Capture device logs: xcodebuildmcp device start-device-log-capture --device-id "" --bundle-id "io.sentry.calculatorapp" +2. Stop app on device: xcodebuildmcp device stop --device-id "" --process-id "18692" diff --git a/src/snapshot-tests/__fixtures__/device/get-app-path--error-wrong-scheme.txt b/src/snapshot-tests/__fixtures__/device/get-app-path--error-wrong-scheme.txt new file mode 100644 index 00000000..c0272359 --- /dev/null +++ b/src/snapshot-tests/__fixtures__/device/get-app-path--error-wrong-scheme.txt @@ -0,0 +1,10 @@ + +๐Ÿ” Get App Path + + Scheme: NONEXISTENT + Workspace: example_projects/iOS_Calculator/CalculatorApp.xcworkspace + Configuration: Debug + Platform: iOS + +โŒ xcodebuild[11262:15615870] Writing error result bundle to /var/folders/_t/2njffz894t57qpp76v1sw__h0000gn/T/ResultBundle_2026-26-03_14-24-0027.xcresult +xcodebuild: error: The workspace named "CalculatorApp" does not contain a scheme named "NONEXISTENT". The "-list" option can be used to find the names of the schemes in the workspace. diff --git a/src/snapshot-tests/__fixtures__/device/get-app-path--success.txt b/src/snapshot-tests/__fixtures__/device/get-app-path--success.txt index 4c1f6d89..019cc1c2 100644 --- a/src/snapshot-tests/__fixtures__/device/get-app-path--success.txt +++ b/src/snapshot-tests/__fixtures__/device/get-app-path--success.txt @@ -1,10 +1,10 @@ ๐Ÿ” Get App Path - Scheme: CalculatorApp - Workspace: example_projects/iOS_Calculator/CalculatorApp.xcworkspace - Configuration: Debug - Platform: iOS + Scheme: CalculatorApp + Workspace: example_projects/iOS_Calculator/CalculatorApp.xcworkspace + Configuration: Debug + Platform: iOS โ”” App Path: /Library/Developer/Xcode/DerivedData/CalculatorApp-/Build/Products/Debug-iphoneos/CalculatorApp.app โœ… App path resolved. diff --git a/src/snapshot-tests/__fixtures__/device/install--error-invalid-app.txt b/src/snapshot-tests/__fixtures__/device/install--error-invalid-app.txt new file mode 100644 index 00000000..53e5b6d8 --- /dev/null +++ b/src/snapshot-tests/__fixtures__/device/install--error-invalid-app.txt @@ -0,0 +1,10 @@ + +๐Ÿ“ฆ Install App + + Device: + App: /tmp/nonexistent.app + +โŒ Failed to install app: Failed to load provisioning paramter list due to error: Error Domain=com.apple.dt.CoreDeviceError Code=1002 "No provider was found." UserInfo={NSLocalizedDescription=No provider was found.}. +`devicectl manage create` may support a reduced set of arguments. +ERROR: The specified device was not found. (Name: ) (com.apple.dt.CoreDeviceError error 1000 (0x3E8)) + DeviceName = diff --git a/src/snapshot-tests/__fixtures__/device/launch--error-invalid-bundle.txt b/src/snapshot-tests/__fixtures__/device/launch--error-invalid-bundle.txt new file mode 100644 index 00000000..9ffa4ce5 --- /dev/null +++ b/src/snapshot-tests/__fixtures__/device/launch--error-invalid-bundle.txt @@ -0,0 +1,10 @@ + +๐Ÿš€ Launch App + + Device: + Bundle ID: com.nonexistent.app + +โŒ Failed to launch app: Failed to load provisioning paramter list due to error: Error Domain=com.apple.dt.CoreDeviceError Code=1002 "No provider was found." UserInfo={NSLocalizedDescription=No provider was found.}. +`devicectl manage create` may support a reduced set of arguments. +ERROR: The specified device was not found. (Name: ) (com.apple.dt.CoreDeviceError error 1000 (0x3E8)) + DeviceName = diff --git a/src/snapshot-tests/__fixtures__/device/list--success.txt b/src/snapshot-tests/__fixtures__/device/list--success.txt index 3a9848ef..7c768cde 100644 --- a/src/snapshot-tests/__fixtures__/device/list--success.txt +++ b/src/snapshot-tests/__fixtures__/device/list--success.txt @@ -14,7 +14,7 @@ โ”œ UDID: โ”œ Model: Watch7,20 โ”œ Product Type: Watch7,20 - โ”œ Platform: Unknown 26.1 + โ”œ Platform: Unknown 26.3 โ”œ CPU Architecture: arm64e โ”œ Connection: localNetwork โ”” Developer Mode: disabled @@ -25,7 +25,7 @@ โ”œ Product Type: iPhone17,2 โ”œ Platform: Unknown 26.3.1 (a) โ”œ CPU Architecture: arm64e - โ”œ Connection: localNetwork + โ”œ Connection: wired โ”” Developer Mode: enabled ๐ŸŸข iPhone diff --git a/src/snapshot-tests/__fixtures__/device/stop--error-no-app.txt b/src/snapshot-tests/__fixtures__/device/stop--error-no-app.txt new file mode 100644 index 00000000..9f1c229d --- /dev/null +++ b/src/snapshot-tests/__fixtures__/device/stop--error-no-app.txt @@ -0,0 +1,10 @@ + +๐Ÿ›‘ Stop App + + Device: + PID: 99999 + +โŒ Failed to stop app: Failed to load provisioning paramter list due to error: Error Domain=com.apple.dt.CoreDeviceError Code=1002 "No provider was found." UserInfo={NSLocalizedDescription=No provider was found.}. +`devicectl manage create` may support a reduced set of arguments. +ERROR: The specified device was not found. (Name: ) (com.apple.dt.CoreDeviceError error 1000 (0x3E8)) + DeviceName = diff --git a/src/snapshot-tests/__fixtures__/device/test--failure.txt b/src/snapshot-tests/__fixtures__/device/test--failure.txt new file mode 100644 index 00000000..64a251a0 --- /dev/null +++ b/src/snapshot-tests/__fixtures__/device/test--failure.txt @@ -0,0 +1,12 @@ + +๐Ÿงช Test + + Scheme: CalculatorApp + Configuration: Debug + Platform: iOS + Device: + + โœ— CalculatorAppTests.CalculatorAppTests/testCalculatorServiceFailure: XCTAssertEqual failed: ("0") is not equal to ("999") - This test should fail - display should be 0, not 999 + example_projects/iOS_Calculator/CalculatorAppTests/CalculatorAppTests.swift:52 + +โŒ Test failed. (, โฑ๏ธ ) diff --git a/src/snapshot-tests/__fixtures__/logging/start-sim-log--error.txt b/src/snapshot-tests/__fixtures__/logging/start-sim-log--error.txt index bd807dc8..df4106f0 100644 --- a/src/snapshot-tests/__fixtures__/logging/start-sim-log--error.txt +++ b/src/snapshot-tests/__fixtures__/logging/start-sim-log--error.txt @@ -1,8 +1,8 @@ ๐Ÿ“ Start Log Capture - Simulator: - Bundle ID: com.nonexistent.app + Simulator: + Bundle ID: com.nonexistent.app Details Session ID: diff --git a/src/snapshot-tests/__fixtures__/logging/stop-sim-log--error.txt b/src/snapshot-tests/__fixtures__/logging/stop-sim-log--error.txt index e4ef5f62..057d83a4 100644 --- a/src/snapshot-tests/__fixtures__/logging/stop-sim-log--error.txt +++ b/src/snapshot-tests/__fixtures__/logging/stop-sim-log--error.txt @@ -1,6 +1,6 @@ ๐Ÿ“ Stop Log Capture - Session ID: nonexistent-session-id + Session ID: nonexistent-session-id โŒ Error stopping log capture session nonexistent-session-id: Log capture session not found: nonexistent-session-id diff --git a/src/snapshot-tests/__fixtures__/macos/build--success.txt b/src/snapshot-tests/__fixtures__/macos/build--success.txt index 4696b2cd..03c6406b 100644 --- a/src/snapshot-tests/__fixtures__/macos/build--success.txt +++ b/src/snapshot-tests/__fixtures__/macos/build--success.txt @@ -1,9 +1,9 @@ ๐Ÿ”จ Build - Scheme: MCPTest - Configuration: Debug - Platform: macOS + Scheme: MCPTest + Configuration: Debug + Platform: macOS โœ… Build succeeded. (โฑ๏ธ ) diff --git a/src/snapshot-tests/__fixtures__/macos/build-and-run--success.txt b/src/snapshot-tests/__fixtures__/macos/build-and-run--success.txt index a2045afe..6222904d 100644 --- a/src/snapshot-tests/__fixtures__/macos/build-and-run--success.txt +++ b/src/snapshot-tests/__fixtures__/macos/build-and-run--success.txt @@ -1,9 +1,9 @@ ๐Ÿš€ Build & Run - Scheme: MCPTest - Configuration: Debug - Platform: macOS + Scheme: MCPTest + Configuration: Debug + Platform: macOS โ„น๏ธ Resolving app path โœ… Resolving app path diff --git a/src/snapshot-tests/__fixtures__/macos/get-app-path--success.txt b/src/snapshot-tests/__fixtures__/macos/get-app-path--success.txt index 1a2d031e..1bc26b88 100644 --- a/src/snapshot-tests/__fixtures__/macos/get-app-path--success.txt +++ b/src/snapshot-tests/__fixtures__/macos/get-app-path--success.txt @@ -1,10 +1,10 @@ ๐Ÿ” Get App Path - Scheme: MCPTest - Project: example_projects/macOS/MCPTest.xcodeproj - Configuration: Debug - Platform: macOS + Scheme: MCPTest + Project: example_projects/macOS/MCPTest.xcodeproj + Configuration: Debug + Platform: macOS โ”” App Path: /Library/Developer/Xcode/DerivedData/MCPTest-/Build/Products/Debug/MCPTest.app โœ… App path resolved. diff --git a/src/snapshot-tests/__fixtures__/macos/get-macos-bundle-id--success.txt b/src/snapshot-tests/__fixtures__/macos/get-macos-bundle-id--success.txt index da91a431..3c7cc843 100644 --- a/src/snapshot-tests/__fixtures__/macos/get-macos-bundle-id--success.txt +++ b/src/snapshot-tests/__fixtures__/macos/get-macos-bundle-id--success.txt @@ -1,7 +1,7 @@ ๐Ÿ” Get macOS Bundle ID - App: /BundleTest.app + App: /BundleTest.app โŒ Could not extract bundle ID from Info.plist: Print: Entry, ":CFBundleIdentifier", Does Not Exist diff --git a/src/snapshot-tests/__fixtures__/macos/launch--error-invalid-app.txt b/src/snapshot-tests/__fixtures__/macos/launch--error-invalid-app.txt index 3007e214..f103dd51 100644 --- a/src/snapshot-tests/__fixtures__/macos/launch--error-invalid-app.txt +++ b/src/snapshot-tests/__fixtures__/macos/launch--error-invalid-app.txt @@ -1,6 +1,6 @@ ๐Ÿš€ Launch macOS App - App: /Fake.app + App: /Fake.app โœ… App launched successfully. diff --git a/src/snapshot-tests/__fixtures__/macos/stop--error-no-app.txt b/src/snapshot-tests/__fixtures__/macos/stop--error-no-app.txt index 537667a5..b04a24c6 100644 --- a/src/snapshot-tests/__fixtures__/macos/stop--error-no-app.txt +++ b/src/snapshot-tests/__fixtures__/macos/stop--error-no-app.txt @@ -1,6 +1,6 @@ ๐Ÿ›‘ Stop macOS App - Target: NonExistentXBMTestApp + Target: NonExistentXBMTestApp โœ… App stopped successfully. diff --git a/src/snapshot-tests/__fixtures__/macos/test--success.txt b/src/snapshot-tests/__fixtures__/macos/test--success.txt index 1d0bc50c..d5e3c7fc 100644 --- a/src/snapshot-tests/__fixtures__/macos/test--success.txt +++ b/src/snapshot-tests/__fixtures__/macos/test--success.txt @@ -1,8 +1,8 @@ ๐Ÿงช Test - Scheme: MCPTest - Configuration: Debug - Platform: macOS + Scheme: MCPTest + Configuration: Debug + Platform: macOS โœ… Test succeeded. (, โฑ๏ธ ) diff --git a/src/snapshot-tests/__fixtures__/project-discovery/get-app-bundle-id--success.txt b/src/snapshot-tests/__fixtures__/project-discovery/get-app-bundle-id--success.txt index 5582635c..9698f3d9 100644 --- a/src/snapshot-tests/__fixtures__/project-discovery/get-app-bundle-id--success.txt +++ b/src/snapshot-tests/__fixtures__/project-discovery/get-app-bundle-id--success.txt @@ -1,6 +1,6 @@ ๐Ÿ” Get Bundle ID - App: /BundleTest.app + App: /BundleTest.app โœ… Bundle ID: com.test.snapshot diff --git a/src/snapshot-tests/__fixtures__/project-discovery/get-macos-bundle-id--success.txt b/src/snapshot-tests/__fixtures__/project-discovery/get-macos-bundle-id--success.txt index da91a431..3c7cc843 100644 --- a/src/snapshot-tests/__fixtures__/project-discovery/get-macos-bundle-id--success.txt +++ b/src/snapshot-tests/__fixtures__/project-discovery/get-macos-bundle-id--success.txt @@ -1,7 +1,7 @@ ๐Ÿ” Get macOS Bundle ID - App: /BundleTest.app + App: /BundleTest.app โŒ Could not extract bundle ID from Info.plist: Print: Entry, ":CFBundleIdentifier", Does Not Exist diff --git a/src/snapshot-tests/__fixtures__/project-discovery/list-schemes--success.txt b/src/snapshot-tests/__fixtures__/project-discovery/list-schemes--success.txt index 73bf9ee8..f09f7e52 100644 --- a/src/snapshot-tests/__fixtures__/project-discovery/list-schemes--success.txt +++ b/src/snapshot-tests/__fixtures__/project-discovery/list-schemes--success.txt @@ -1,7 +1,7 @@ ๐Ÿ” List Schemes - Workspace: example_projects/iOS_Calculator/CalculatorApp.xcworkspace + Workspace: example_projects/iOS_Calculator/CalculatorApp.xcworkspace โœ… Found 2 scheme(s). diff --git a/src/snapshot-tests/__fixtures__/project-discovery/show-build-settings--success.txt b/src/snapshot-tests/__fixtures__/project-discovery/show-build-settings--success.txt index b36b472f..f44b3bd1 100644 --- a/src/snapshot-tests/__fixtures__/project-discovery/show-build-settings--success.txt +++ b/src/snapshot-tests/__fixtures__/project-discovery/show-build-settings--success.txt @@ -1,8 +1,8 @@ ๐Ÿ” Show Build Settings - Scheme: CalculatorApp - Workspace: example_projects/iOS_Calculator/CalculatorApp.xcworkspace + Scheme: CalculatorApp + Workspace: example_projects/iOS_Calculator/CalculatorApp.xcworkspace โœ… Build settings retrieved. diff --git a/src/snapshot-tests/__fixtures__/project-scaffolding/scaffold-ios--error-existing.txt b/src/snapshot-tests/__fixtures__/project-scaffolding/scaffold-ios--error-existing.txt index 4b265ca2..c0f01f43 100644 --- a/src/snapshot-tests/__fixtures__/project-scaffolding/scaffold-ios--error-existing.txt +++ b/src/snapshot-tests/__fixtures__/project-scaffolding/scaffold-ios--error-existing.txt @@ -1,8 +1,8 @@ ๐Ÿ“ Scaffold iOS Project - Name: SnapshotTestApp - Path: /ios-existing - Platform: iOS + Name: SnapshotTestApp + Path: /ios-existing + Platform: iOS โŒ Xcode project files already exist in /ios-existing diff --git a/src/snapshot-tests/__fixtures__/project-scaffolding/scaffold-ios--success.txt b/src/snapshot-tests/__fixtures__/project-scaffolding/scaffold-ios--success.txt index 7ed76a2b..ac1ab1d6 100644 --- a/src/snapshot-tests/__fixtures__/project-scaffolding/scaffold-ios--success.txt +++ b/src/snapshot-tests/__fixtures__/project-scaffolding/scaffold-ios--success.txt @@ -1,8 +1,8 @@ ๐Ÿ“ Scaffold iOS Project - Name: SnapshotTestApp - Path: /ios - Platform: iOS + Name: SnapshotTestApp + Path: /ios + Platform: iOS โœ… Project scaffolded successfully at /ios. diff --git a/src/snapshot-tests/__fixtures__/project-scaffolding/scaffold-macos--success.txt b/src/snapshot-tests/__fixtures__/project-scaffolding/scaffold-macos--success.txt index 6f2941b4..7ff4229a 100644 --- a/src/snapshot-tests/__fixtures__/project-scaffolding/scaffold-macos--success.txt +++ b/src/snapshot-tests/__fixtures__/project-scaffolding/scaffold-macos--success.txt @@ -1,8 +1,8 @@ ๐Ÿ“ Scaffold macOS Project - Name: SnapshotTestMacApp - Path: /macos - Platform: macOS + Name: SnapshotTestMacApp + Path: /macos + Platform: macOS โœ… Project scaffolded successfully at /macos. diff --git a/src/snapshot-tests/__fixtures__/session-management/session-show-defaults--success.txt b/src/snapshot-tests/__fixtures__/session-management/session-show-defaults--success.txt index 7ad2fbd5..4ebc3645 100644 --- a/src/snapshot-tests/__fixtures__/session-management/session-show-defaults--success.txt +++ b/src/snapshot-tests/__fixtures__/session-management/session-show-defaults--success.txt @@ -1,7 +1,7 @@ โš™๏ธ Show Defaults - Active Profile: global + Active Profile: global โ”œ workspacePath: example_projects/iOS_Calculator/CalculatorApp.xcworkspace โ”” scheme: CalculatorApp diff --git a/src/snapshot-tests/__fixtures__/session-management/session-use-defaults-profile--success.txt b/src/snapshot-tests/__fixtures__/session-management/session-use-defaults-profile--success.txt index 9618a34a..1932565f 100644 --- a/src/snapshot-tests/__fixtures__/session-management/session-use-defaults-profile--success.txt +++ b/src/snapshot-tests/__fixtures__/session-management/session-use-defaults-profile--success.txt @@ -1,7 +1,7 @@ โš™๏ธ Use Defaults Profile - Active Profile: global - Known Profiles: (none) + Active Profile: global + Known Profiles: (none) โœ… Active profile: global diff --git a/src/snapshot-tests/__fixtures__/simulator-management/boot--error-invalid-id.txt b/src/snapshot-tests/__fixtures__/simulator-management/boot--error-invalid-id.txt index 578d8123..a732897c 100644 --- a/src/snapshot-tests/__fixtures__/simulator-management/boot--error-invalid-id.txt +++ b/src/snapshot-tests/__fixtures__/simulator-management/boot--error-invalid-id.txt @@ -1,6 +1,6 @@ ๐Ÿ“ฑ Boot Simulator - Simulator: + Simulator: โŒ Boot simulator operation failed: Invalid device or device pair: diff --git a/src/snapshot-tests/__fixtures__/simulator-management/reset-location--success.txt b/src/snapshot-tests/__fixtures__/simulator-management/reset-location--success.txt index 2271d799..9ff28aa0 100644 --- a/src/snapshot-tests/__fixtures__/simulator-management/reset-location--success.txt +++ b/src/snapshot-tests/__fixtures__/simulator-management/reset-location--success.txt @@ -1,6 +1,6 @@ ๐Ÿ“ Reset Location - Simulator: + Simulator: โœ… Location reset to default diff --git a/src/snapshot-tests/__fixtures__/simulator-management/set-appearance--success.txt b/src/snapshot-tests/__fixtures__/simulator-management/set-appearance--success.txt index 826960cb..20eac34b 100644 --- a/src/snapshot-tests/__fixtures__/simulator-management/set-appearance--success.txt +++ b/src/snapshot-tests/__fixtures__/simulator-management/set-appearance--success.txt @@ -1,7 +1,7 @@ ๐ŸŽจ Set Appearance - Simulator: - Mode: dark + Simulator: + Mode: dark โœ… Appearance set to dark mode diff --git a/src/snapshot-tests/__fixtures__/simulator-management/set-location--success.txt b/src/snapshot-tests/__fixtures__/simulator-management/set-location--success.txt index 136e8186..63a67b00 100644 --- a/src/snapshot-tests/__fixtures__/simulator-management/set-location--success.txt +++ b/src/snapshot-tests/__fixtures__/simulator-management/set-location--success.txt @@ -1,7 +1,7 @@ ๐Ÿ“ Set Location - Simulator: - Coordinates: 37.7749,-122.4194 + Simulator: + Coordinates: 37.7749,-122.4194 โœ… Location set to 37.7749,-122.4194 diff --git a/src/snapshot-tests/__fixtures__/simulator-management/statusbar--success.txt b/src/snapshot-tests/__fixtures__/simulator-management/statusbar--success.txt index 20c323ed..7395c366 100644 --- a/src/snapshot-tests/__fixtures__/simulator-management/statusbar--success.txt +++ b/src/snapshot-tests/__fixtures__/simulator-management/statusbar--success.txt @@ -1,7 +1,7 @@ ๐Ÿ“ฑ Statusbar - Simulator: - Data Network: wifi + Simulator: + Data Network: wifi โœ… Status bar data network set to wifi diff --git a/src/snapshot-tests/__fixtures__/simulator/build--error-wrong-scheme.txt b/src/snapshot-tests/__fixtures__/simulator/build--error-wrong-scheme.txt index efb30273..2f6107de 100644 --- a/src/snapshot-tests/__fixtures__/simulator/build--error-wrong-scheme.txt +++ b/src/snapshot-tests/__fixtures__/simulator/build--error-wrong-scheme.txt @@ -1,18 +1,12 @@ ๐Ÿ”จ Build - Scheme: NONEXISTENT - Configuration: Debug - Platform: iOS Simulator - -โš™๏ธ iOS Simulator Build build - - Scheme: NONEXISTENT - Platform: iOS Simulator - Configuration: Debug - -โŒ iOS Simulator Build build failed for scheme NONEXISTENT. + Scheme: NONEXISTENT + Configuration: Debug + Platform: iOS Simulator Errors (1): + โœ— The workspace named "CalculatorApp" does not contain a scheme named "NONEXISTENT". The "-list" option can be used to find the names of the schemes in the workspace. + โŒ Build failed. (โฑ๏ธ ) diff --git a/src/snapshot-tests/__fixtures__/simulator/build--success.txt b/src/snapshot-tests/__fixtures__/simulator/build--success.txt index d1edf812..273f4bdf 100644 --- a/src/snapshot-tests/__fixtures__/simulator/build--success.txt +++ b/src/snapshot-tests/__fixtures__/simulator/build--success.txt @@ -1,9 +1,9 @@ ๐Ÿ”จ Build - Scheme: CalculatorApp - Configuration: Debug - Platform: iOS Simulator + Scheme: CalculatorApp + Configuration: Debug + Platform: iOS Simulator โœ… Build succeeded. (โฑ๏ธ ) diff --git a/src/snapshot-tests/__fixtures__/simulator/build-and-run--success.txt b/src/snapshot-tests/__fixtures__/simulator/build-and-run--success.txt index 3b949498..0fe65ab0 100644 --- a/src/snapshot-tests/__fixtures__/simulator/build-and-run--success.txt +++ b/src/snapshot-tests/__fixtures__/simulator/build-and-run--success.txt @@ -1,9 +1,9 @@ ๐Ÿš€ Build & Run - Scheme: CalculatorApp - Configuration: Debug - Platform: iOS Simulator + Scheme: CalculatorApp + Configuration: Debug + Platform: iOS Simulator โ„น๏ธ Resolving app path โœ… Resolving app path diff --git a/src/snapshot-tests/__fixtures__/simulator/get-app-path--success.txt b/src/snapshot-tests/__fixtures__/simulator/get-app-path--success.txt index 3e638872..faaeee17 100644 --- a/src/snapshot-tests/__fixtures__/simulator/get-app-path--success.txt +++ b/src/snapshot-tests/__fixtures__/simulator/get-app-path--success.txt @@ -1,11 +1,11 @@ ๐Ÿ” Get App Path - Scheme: CalculatorApp - Workspace: example_projects/iOS_Calculator/CalculatorApp.xcworkspace - Configuration: Debug - Platform: iOS Simulator - Simulator: iPhone 17 + Scheme: CalculatorApp + Workspace: example_projects/iOS_Calculator/CalculatorApp.xcworkspace + Configuration: Debug + Platform: iOS Simulator + Simulator: iPhone 17 โ”” App Path: /Library/Developer/Xcode/DerivedData/CalculatorApp-/Build/Products/Debug-iphonesimulator/CalculatorApp.app โœ… App path resolved diff --git a/src/snapshot-tests/__fixtures__/simulator/install--error-invalid-app.txt b/src/snapshot-tests/__fixtures__/simulator/install--error-invalid-app.txt index 03eddd8e..ed46d6a9 100644 --- a/src/snapshot-tests/__fixtures__/simulator/install--error-invalid-app.txt +++ b/src/snapshot-tests/__fixtures__/simulator/install--error-invalid-app.txt @@ -1,8 +1,8 @@ ๐Ÿ“ฆ Install App - Simulator: - App Path: /NotAnApp.app + Simulator: + App Path: /NotAnApp.app โŒ Install app in simulator operation failed: An error was encountered processing the command (domain=IXErrorDomain, code=13): Simulator device failed to install the application. diff --git a/src/snapshot-tests/__fixtures__/simulator/launch-app--success.txt b/src/snapshot-tests/__fixtures__/simulator/launch-app--success.txt index e8e660f2..603b2668 100644 --- a/src/snapshot-tests/__fixtures__/simulator/launch-app--success.txt +++ b/src/snapshot-tests/__fixtures__/simulator/launch-app--success.txt @@ -1,7 +1,7 @@ ๐Ÿš€ Launch App - Simulator: - Bundle ID: io.sentry.calculatorapp + Simulator: + Bundle ID: io.sentry.calculatorapp โœ… App launched successfully in simulator diff --git a/src/snapshot-tests/__fixtures__/simulator/launch-app-with-logs--success.txt b/src/snapshot-tests/__fixtures__/simulator/launch-app-with-logs--success.txt index acc18e0c..d168f403 100644 --- a/src/snapshot-tests/__fixtures__/simulator/launch-app-with-logs--success.txt +++ b/src/snapshot-tests/__fixtures__/simulator/launch-app-with-logs--success.txt @@ -1,9 +1,9 @@ ๐Ÿš€ Launch App - Simulator: - Bundle ID: io.sentry.calculatorapp - Log Capture: enabled + Simulator: + Bundle ID: io.sentry.calculatorapp + Log Capture: enabled โ”” Log Session ID: โœ… App launched successfully in simulator with log capture enabled diff --git a/src/snapshot-tests/__fixtures__/simulator/screenshot--success.txt b/src/snapshot-tests/__fixtures__/simulator/screenshot--success.txt index f62d0ee6..3569c560 100644 --- a/src/snapshot-tests/__fixtures__/simulator/screenshot--success.txt +++ b/src/snapshot-tests/__fixtures__/simulator/screenshot--success.txt @@ -1,6 +1,6 @@ ๐Ÿ“ท Screenshot - Simulator: + Simulator: โœ… Screenshot captured: /var/folders/_t/2njffz894t57qpp76v1sw__h0000gn/T/screenshot_optimized_.jpg (image/jpeg) diff --git a/src/snapshot-tests/__fixtures__/simulator/stop--error-no-app.txt b/src/snapshot-tests/__fixtures__/simulator/stop--error-no-app.txt index f31b56f1..a6c6ba51 100644 --- a/src/snapshot-tests/__fixtures__/simulator/stop--error-no-app.txt +++ b/src/snapshot-tests/__fixtures__/simulator/stop--error-no-app.txt @@ -1,8 +1,8 @@ ๐Ÿ›‘ Stop App - Simulator: - Bundle ID: com.nonexistent.app + Simulator: + Bundle ID: com.nonexistent.app โŒ Stop app in simulator operation failed: An error was encountered processing the command (domain=NSPOSIXErrorDomain, code=3): Simulator device failed to terminate com.nonexistent.app. diff --git a/src/snapshot-tests/__fixtures__/simulator/test--success.txt b/src/snapshot-tests/__fixtures__/simulator/test--success.txt index eab86fbd..2e1dc881 100644 --- a/src/snapshot-tests/__fixtures__/simulator/test--success.txt +++ b/src/snapshot-tests/__fixtures__/simulator/test--success.txt @@ -1,16 +1,12 @@ ๐Ÿงช Test - Scheme: CalculatorApp - Configuration: Debug - Platform: iOS Simulator + Scheme: CalculatorApp + Configuration: Debug + Platform: iOS Simulator + Simulator: iPhone 17 -โš™๏ธ Test Run test-without-building - - Scheme: CalculatorApp - Platform: iOS Simulator - Configuration: Debug - -โŒ Test Run test-without-building failed for scheme CalculatorApp. + โœ— CalculatorAppTests.CalculatorAppTests/testCalculatorServiceFailure: XCTAssertEqual failed: ("0") is not equal to ("999") - This test should fail - display should be 0, not 999 + example_projects/iOS_Calculator/CalculatorAppTests/CalculatorAppTests.swift:52 โŒ Test failed. (, โฑ๏ธ ) diff --git a/src/snapshot-tests/__fixtures__/swift-package/build--error-bad-path.txt b/src/snapshot-tests/__fixtures__/swift-package/build--error-bad-path.txt index b4c04985..92b2c65d 100644 --- a/src/snapshot-tests/__fixtures__/swift-package/build--error-bad-path.txt +++ b/src/snapshot-tests/__fixtures__/swift-package/build--error-bad-path.txt @@ -1,6 +1,6 @@ ๐Ÿ“ฆ Swift Package Build - Package: /example_projects/NONEXISTENT + Package: /example_projects/NONEXISTENT โŒ Swift package build failed: error: chdir error: No such file or directory (2): /example_projects/NONEXISTENT diff --git a/src/snapshot-tests/__fixtures__/swift-package/build--success.txt b/src/snapshot-tests/__fixtures__/swift-package/build--success.txt index e081a6cc..e043632e 100644 --- a/src/snapshot-tests/__fixtures__/swift-package/build--success.txt +++ b/src/snapshot-tests/__fixtures__/swift-package/build--success.txt @@ -1,7 +1,7 @@ ๐Ÿ“ฆ Swift Package Build - Package: /example_projects/spm + Package: /example_projects/spm Output [0/1] Planning build diff --git a/src/snapshot-tests/__fixtures__/swift-package/clean--success.txt b/src/snapshot-tests/__fixtures__/swift-package/clean--success.txt index 43c95a7a..a0e316a0 100644 --- a/src/snapshot-tests/__fixtures__/swift-package/clean--success.txt +++ b/src/snapshot-tests/__fixtures__/swift-package/clean--success.txt @@ -1,6 +1,6 @@ ๐Ÿงน Swift Package Clean - Package: /example_projects/spm + Package: /example_projects/spm โœ… Swift package cleaned successfully diff --git a/src/snapshot-tests/__fixtures__/swift-package/run--success.txt b/src/snapshot-tests/__fixtures__/swift-package/run--success.txt index 11526a4c..c848ef6e 100644 --- a/src/snapshot-tests/__fixtures__/swift-package/run--success.txt +++ b/src/snapshot-tests/__fixtures__/swift-package/run--success.txt @@ -1,8 +1,8 @@ ๐Ÿš€ Swift Package Run - Package: /example_projects/spm - Executable: spm + Package: /example_projects/spm + Executable: spm Output Hello, world! diff --git a/src/snapshot-tests/__fixtures__/swift-package/stop--error-no-process.txt b/src/snapshot-tests/__fixtures__/swift-package/stop--error-no-process.txt index 631d63f8..12294168 100644 --- a/src/snapshot-tests/__fixtures__/swift-package/stop--error-no-process.txt +++ b/src/snapshot-tests/__fixtures__/swift-package/stop--error-no-process.txt @@ -1,6 +1,6 @@ ๐Ÿ›‘ Swift Package Stop - PID: 999999 + PID: 999999 โŒ No running process found with PID 999999. Use swift_package_list to check active processes. diff --git a/src/snapshot-tests/__fixtures__/swift-package/test--success.txt b/src/snapshot-tests/__fixtures__/swift-package/test--success.txt index 7ebcba4e..8d4106af 100644 --- a/src/snapshot-tests/__fixtures__/swift-package/test--success.txt +++ b/src/snapshot-tests/__fixtures__/swift-package/test--success.txt @@ -1,7 +1,7 @@ ๐Ÿงช Swift Package Test - Package: /example_projects/spm + Package: /example_projects/spm Output Test Suite 'All tests' started at . diff --git a/src/snapshot-tests/__fixtures__/ui-automation/button--success.txt b/src/snapshot-tests/__fixtures__/ui-automation/button--success.txt index eeaa4102..58a78296 100644 --- a/src/snapshot-tests/__fixtures__/ui-automation/button--success.txt +++ b/src/snapshot-tests/__fixtures__/ui-automation/button--success.txt @@ -1,6 +1,6 @@ ๐Ÿ‘† Button - Simulator: + Simulator: โœ… Hardware button 'home' pressed successfully. diff --git a/src/snapshot-tests/__fixtures__/ui-automation/gesture--success.txt b/src/snapshot-tests/__fixtures__/ui-automation/gesture--success.txt index 1b1916d8..f7cbf673 100644 --- a/src/snapshot-tests/__fixtures__/ui-automation/gesture--success.txt +++ b/src/snapshot-tests/__fixtures__/ui-automation/gesture--success.txt @@ -1,6 +1,6 @@ ๐Ÿ‘† Gesture - Simulator: + Simulator: โœ… Gesture 'scroll-down' executed successfully. diff --git a/src/snapshot-tests/__fixtures__/ui-automation/key-press--success.txt b/src/snapshot-tests/__fixtures__/ui-automation/key-press--success.txt index 4f27c677..c687f6b6 100644 --- a/src/snapshot-tests/__fixtures__/ui-automation/key-press--success.txt +++ b/src/snapshot-tests/__fixtures__/ui-automation/key-press--success.txt @@ -1,6 +1,6 @@ โŒจ๏ธ Key Press - Simulator: + Simulator: โœ… Key press (code: 4) simulated successfully. diff --git a/src/snapshot-tests/__fixtures__/ui-automation/key-sequence--success.txt b/src/snapshot-tests/__fixtures__/ui-automation/key-sequence--success.txt index aff32c81..6950454c 100644 --- a/src/snapshot-tests/__fixtures__/ui-automation/key-sequence--success.txt +++ b/src/snapshot-tests/__fixtures__/ui-automation/key-sequence--success.txt @@ -1,6 +1,6 @@ โŒจ๏ธ Key Sequence - Simulator: + Simulator: โœ… Key sequence [4,5,6] executed successfully. diff --git a/src/snapshot-tests/__fixtures__/ui-automation/long-press--success.txt b/src/snapshot-tests/__fixtures__/ui-automation/long-press--success.txt index 21d45ff1..09930c37 100644 --- a/src/snapshot-tests/__fixtures__/ui-automation/long-press--success.txt +++ b/src/snapshot-tests/__fixtures__/ui-automation/long-press--success.txt @@ -1,7 +1,7 @@ ๐Ÿ‘† Long Press - Simulator: + Simulator: โœ… Long press at (100, 400) for 500ms simulated successfully. โš ๏ธ Warning: snapshot_ui has not been called yet. Consider using snapshot_ui for precise coordinates instead of guessing from screenshots. diff --git a/src/snapshot-tests/__fixtures__/ui-automation/snapshot-ui--success.txt b/src/snapshot-tests/__fixtures__/ui-automation/snapshot-ui--success.txt index fa3f708e..a00d1168 100644 --- a/src/snapshot-tests/__fixtures__/ui-automation/snapshot-ui--success.txt +++ b/src/snapshot-tests/__fixtures__/ui-automation/snapshot-ui--success.txt @@ -1,7 +1,7 @@ ๐Ÿ“ท Snapshot UI - Simulator: + Simulator: โœ… Accessibility hierarchy retrieved successfully. diff --git a/src/snapshot-tests/__fixtures__/ui-automation/swipe--success.txt b/src/snapshot-tests/__fixtures__/ui-automation/swipe--success.txt index fb08f794..842459c3 100644 --- a/src/snapshot-tests/__fixtures__/ui-automation/swipe--success.txt +++ b/src/snapshot-tests/__fixtures__/ui-automation/swipe--success.txt @@ -1,7 +1,7 @@ ๐Ÿ‘† Swipe - Simulator: + Simulator: โœ… Swipe from (200, 400) to (200, 200) simulated successfully. โš ๏ธ Warning: snapshot_ui has not been called yet. Consider using snapshot_ui for precise coordinates instead of guessing from screenshots. diff --git a/src/snapshot-tests/__fixtures__/ui-automation/tap--error-no-simulator.txt b/src/snapshot-tests/__fixtures__/ui-automation/tap--error-no-simulator.txt index ca372fe6..62a48049 100644 --- a/src/snapshot-tests/__fixtures__/ui-automation/tap--error-no-simulator.txt +++ b/src/snapshot-tests/__fixtures__/ui-automation/tap--error-no-simulator.txt @@ -1,7 +1,7 @@ ๐Ÿ‘† Tap - Simulator: + Simulator: โŒ Failed to simulate tap at (100, 100): axe command 'tap' failed. diff --git a/src/snapshot-tests/__fixtures__/ui-automation/tap--success.txt b/src/snapshot-tests/__fixtures__/ui-automation/tap--success.txt index ec35a6a8..54988f19 100644 --- a/src/snapshot-tests/__fixtures__/ui-automation/tap--success.txt +++ b/src/snapshot-tests/__fixtures__/ui-automation/tap--success.txt @@ -1,7 +1,7 @@ ๐Ÿ‘† Tap - Simulator: + Simulator: โœ… Tap at (100, 400) simulated successfully. โš ๏ธ Warning: snapshot_ui has not been called yet. Consider using snapshot_ui for precise coordinates instead of guessing from screenshots. diff --git a/src/snapshot-tests/__fixtures__/ui-automation/touch--success.txt b/src/snapshot-tests/__fixtures__/ui-automation/touch--success.txt index f7da4a0a..ccfc1ca1 100644 --- a/src/snapshot-tests/__fixtures__/ui-automation/touch--success.txt +++ b/src/snapshot-tests/__fixtures__/ui-automation/touch--success.txt @@ -1,7 +1,7 @@ ๐Ÿ‘† Touch - Simulator: + Simulator: โœ… Touch event (touch down+up) at (100, 400) executed successfully. โš ๏ธ Warning: snapshot_ui has not been called yet. Consider using snapshot_ui for precise coordinates instead of guessing from screenshots. diff --git a/src/snapshot-tests/__fixtures__/ui-automation/type-text--success.txt b/src/snapshot-tests/__fixtures__/ui-automation/type-text--success.txt index 212f95c7..72a6ac50 100644 --- a/src/snapshot-tests/__fixtures__/ui-automation/type-text--success.txt +++ b/src/snapshot-tests/__fixtures__/ui-automation/type-text--success.txt @@ -1,6 +1,6 @@ โŒจ๏ธ Type Text - Simulator: + Simulator: โœ… Text typing simulated successfully. diff --git a/src/snapshot-tests/__fixtures__/utilities/clean--success.txt b/src/snapshot-tests/__fixtures__/utilities/clean--success.txt index c9a5e007..e796414c 100644 --- a/src/snapshot-tests/__fixtures__/utilities/clean--success.txt +++ b/src/snapshot-tests/__fixtures__/utilities/clean--success.txt @@ -1,8 +1,8 @@ ๐Ÿงน Clean - Scheme: CalculatorApp - Configuration: Debug - Platform: iOS + Scheme: CalculatorApp + Configuration: Debug + Platform: iOS โœ… Build succeeded. (โฑ๏ธ ) diff --git a/src/snapshot-tests/__tests__/debugging.snapshot.test.ts b/src/snapshot-tests/__tests__/debugging.snapshot.test.ts index efb24eeb..bc98a65a 100644 --- a/src/snapshot-tests/__tests__/debugging.snapshot.test.ts +++ b/src/snapshot-tests/__tests__/debugging.snapshot.test.ts @@ -1,8 +1,12 @@ -import { describe, it, expect, beforeAll, afterAll } from 'vitest'; -import { createSnapshotHarness } from '../harness.ts'; +import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; +import { execSync } from 'node:child_process'; +import { createSnapshotHarness, ensureSimulatorBooted } from '../harness.ts'; import { expectMatchesFixture } from '../fixture-io.ts'; import type { SnapshotHarness } from '../harness.ts'; +const WORKSPACE = 'example_projects/iOS_Calculator/CalculatorApp.xcworkspace'; +const BUNDLE_ID = 'io.sentry.calculatorapp'; + describe('debugging workflow', () => { let harness: SnapshotHarness; @@ -14,40 +18,32 @@ describe('debugging workflow', () => { harness.cleanup(); }); - describe('continue', () => { - it('error - no session', async () => { + describe('error paths (no session)', () => { + it('continue - error no session', async () => { const { text, isError } = await harness.invoke('debugging', 'continue', {}); expect(isError).toBe(true); expectMatchesFixture(text, __filename, 'continue--error-no-session'); }, 30_000); - }); - describe('detach', () => { - it('error - no session', async () => { + it('detach - error no session', async () => { const { text, isError } = await harness.invoke('debugging', 'detach', {}); expect(isError).toBe(true); expectMatchesFixture(text, __filename, 'detach--error-no-session'); }, 30_000); - }); - describe('stack', () => { - it('error - no session', async () => { + it('stack - error no session', async () => { const { text, isError } = await harness.invoke('debugging', 'stack', {}); expect(isError).toBe(true); expectMatchesFixture(text, __filename, 'stack--error-no-session'); }, 30_000); - }); - describe('variables', () => { - it('error - no session', async () => { + it('variables - error no session', async () => { const { text, isError } = await harness.invoke('debugging', 'variables', {}); expect(isError).toBe(true); expectMatchesFixture(text, __filename, 'variables--error-no-session'); }, 30_000); - }); - describe('add-breakpoint', () => { - it('error - no session', async () => { + it('add-breakpoint - error no session', async () => { const { text, isError } = await harness.invoke('debugging', 'add-breakpoint', { file: 'test.swift', line: 1, @@ -55,30 +51,24 @@ describe('debugging workflow', () => { expect(isError).toBe(true); expectMatchesFixture(text, __filename, 'add-breakpoint--error-no-session'); }, 30_000); - }); - describe('remove-breakpoint', () => { - it('error - no session', async () => { + it('remove-breakpoint - error no session', async () => { const { text, isError } = await harness.invoke('debugging', 'remove-breakpoint', { breakpointId: 1, }); expect(isError).toBe(true); expectMatchesFixture(text, __filename, 'remove-breakpoint--error-no-session'); }, 30_000); - }); - describe('lldb-command', () => { - it('error - no session', async () => { + it('lldb-command - error no session', async () => { const { text, isError } = await harness.invoke('debugging', 'lldb-command', { command: 'bt', }); expect(isError).toBe(true); expectMatchesFixture(text, __filename, 'lldb-command--error-no-session'); }, 30_000); - }); - describe('attach', () => { - it('error - no process', async () => { + it('attach - error no process', async () => { const { text, isError } = await harness.invoke('debugging', 'attach', { simulatorId: '00000000-0000-0000-0000-000000000000', bundleId: 'com.nonexistent.app', @@ -87,4 +77,109 @@ describe('debugging workflow', () => { expectMatchesFixture(text, __filename, 'attach--error-no-process'); }, 30_000); }); + + describe('happy path (live debugger session)', () => { + let simulatorUdid: string; + + beforeAll(async () => { + vi.setConfig({ testTimeout: 120_000 }); + simulatorUdid = await ensureSimulatorBooted('iPhone 17'); + + execSync( + [ + 'xcodebuild build', + `-workspace ${WORKSPACE}`, + '-scheme CalculatorApp', + `-destination 'platform=iOS Simulator,id=${simulatorUdid}'`, + '-quiet', + ].join(' '), + { encoding: 'utf8', timeout: 120_000, stdio: 'pipe' }, + ); + + execSync(`xcrun simctl launch --terminate-running-process ${simulatorUdid} ${BUNDLE_ID}`, { + encoding: 'utf8', + stdio: 'pipe', + }); + + await new Promise((resolve) => setTimeout(resolve, 2000)); + }, 120_000); + + afterAll(async () => { + try { + await harness.invoke('debugging', 'detach', {}); + } catch { + // best-effort cleanup + } + }); + + it('attach - success', async () => { + const { text, isError } = await harness.invoke('debugging', 'attach', { + simulatorId: simulatorUdid, + bundleId: BUNDLE_ID, + continueOnAttach: false, + }); + expect(isError).toBe(false); + expect(text.length).toBeGreaterThan(10); + expectMatchesFixture(text, __filename, 'attach--success'); + }, 30_000); + + it('pause via lldb', async () => { + // DAP attach is non-intrusive; pause so subsequent commands work on a stopped process + await harness.invoke('debugging', 'lldb-command', { command: 'process interrupt' }); + // Let the process settle after interrupt + await new Promise((resolve) => setTimeout(resolve, 1000)); + }, 30_000); + + it('stack - success', async () => { + const { text, isError } = await harness.invoke('debugging', 'stack', {}); + expect(isError).toBe(false); + expect(text.length).toBeGreaterThan(10); + expectMatchesFixture(text, __filename, 'stack--success'); + }, 30_000); + + it('variables - success', async () => { + const { text, isError } = await harness.invoke('debugging', 'variables', {}); + expect(isError).toBe(false); + expect(text.length).toBeGreaterThan(10); + expectMatchesFixture(text, __filename, 'variables--success'); + }, 30_000); + + it('add-breakpoint - success', async () => { + const { text, isError } = await harness.invoke('debugging', 'add-breakpoint', { + file: 'ContentView.swift', + line: 42, + }); + expect(isError).toBe(false); + expectMatchesFixture(text, __filename, 'add-breakpoint--success'); + }, 30_000); + + it('continue - success', async () => { + const { text, isError } = await harness.invoke('debugging', 'continue', {}); + expect(isError).toBe(false); + expectMatchesFixture(text, __filename, 'continue--success'); + }, 30_000); + + it('lldb-command - success', async () => { + const { text, isError } = await harness.invoke('debugging', 'lldb-command', { + command: 'breakpoint list', + }); + expect(isError).toBe(false); + expect(text.length).toBeGreaterThan(0); + expectMatchesFixture(text, __filename, 'lldb-command--success'); + }, 30_000); + + it('remove-breakpoint - success', async () => { + const { text, isError } = await harness.invoke('debugging', 'remove-breakpoint', { + breakpointId: 1, + }); + expect(isError).toBe(false); + expectMatchesFixture(text, __filename, 'remove-breakpoint--success'); + }, 30_000); + + it('detach - success', async () => { + const { text, isError } = await harness.invoke('debugging', 'detach', {}); + expect(isError).toBe(false); + expectMatchesFixture(text, __filename, 'detach--success'); + }, 30_000); + }); }); diff --git a/src/snapshot-tests/__tests__/device.snapshot.test.ts b/src/snapshot-tests/__tests__/device.snapshot.test.ts index b22566fb..a2cbaa1e 100644 --- a/src/snapshot-tests/__tests__/device.snapshot.test.ts +++ b/src/snapshot-tests/__tests__/device.snapshot.test.ts @@ -1,115 +1,130 @@ -import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; import { createSnapshotHarness } from '../harness.ts'; import { expectMatchesFixture } from '../fixture-io.ts'; import type { SnapshotHarness } from '../harness.ts'; const WORKSPACE = 'example_projects/iOS_Calculator/CalculatorApp.xcworkspace'; +const DEVICE_ID = process.env.DEVICE_ID; describe('device workflow', () => { let harness: SnapshotHarness; beforeAll(async () => { + vi.setConfig({ testTimeout: 120_000 }); harness = await createSnapshotHarness(); - }); + }, 120_000); afterAll(() => { harness.cleanup(); }); - describe('build', () => { - it( - 'success', - async () => { - const { text, isError } = await harness.invoke('device', 'build', { - workspacePath: WORKSPACE, - scheme: 'CalculatorApp', - }); - expect(isError).toBe(false); - expect(text.length).toBeGreaterThan(10); - expectMatchesFixture(text, __filename, 'build--success'); - }, - { timeout: 120000 }, - ); + describe('list', () => { + it('success', async () => { + const { text, isError } = await harness.invoke('device', 'list', {}); + expect(isError).toBe(false); + expectMatchesFixture(text, __filename, 'list--success'); + }); }); - describe('get-app-path', () => { - it( - 'success', - async () => { - const { text, isError } = await harness.invoke('device', 'get-app-path', { - workspacePath: WORKSPACE, - scheme: 'CalculatorApp', - }); - expect(isError).toBe(false); - expect(text.length).toBeGreaterThan(10); - expectMatchesFixture(text, __filename, 'get-app-path--success'); - }, - { timeout: 120000 }, - ); - }); + describe('build', () => { + it('success', async () => { + const { text, isError } = await harness.invoke('device', 'build', { + workspacePath: WORKSPACE, + scheme: 'CalculatorApp', + }); + expect(isError).toBe(false); + expect(text.length).toBeGreaterThan(10); + expectMatchesFixture(text, __filename, 'build--success'); + }); - describe('list', () => { - it( - 'success', - async () => { - const { text, isError } = await harness.invoke('device', 'list', {}); - expect(isError).toBe(false); - expectMatchesFixture(text, __filename, 'list--success'); - }, - { timeout: 120000 }, - ); + it('error - wrong scheme', async () => { + const { text, isError } = await harness.invoke('device', 'build', { + workspacePath: WORKSPACE, + scheme: 'NONEXISTENT', + }); + expect(isError).toBe(true); + expectMatchesFixture(text, __filename, 'build--error-wrong-scheme'); + }); }); - describe.runIf(process.env.DEVICE_ID)('build-and-run (requires device)', () => { - it( - 'success', - async () => { - const { text, isError } = await harness.invoke('device', 'build-and-run', { - workspacePath: WORKSPACE, - scheme: 'CalculatorApp', - deviceId: process.env.DEVICE_ID, - }); - expect(isError).toBe(false); - expect(text.length).toBeGreaterThan(10); - expectMatchesFixture(text, __filename, 'build-and-run--success'); - }, - { timeout: 120000 }, - ); - }); + describe('get-app-path', () => { + it('success', async () => { + const { text, isError } = await harness.invoke('device', 'get-app-path', { + workspacePath: WORKSPACE, + scheme: 'CalculatorApp', + }); + expect(isError).toBe(false); + expect(text.length).toBeGreaterThan(10); + expectMatchesFixture(text, __filename, 'get-app-path--success'); + }); - describe.runIf(process.env.DEVICE_ID)('test (requires device)', () => { - it( - 'success', - async () => { - const { text, isError } = await harness.invoke('device', 'test', { - workspacePath: WORKSPACE, - scheme: 'CalculatorApp', - deviceId: process.env.DEVICE_ID, - }); - expect(text.length).toBeGreaterThan(10); - expectMatchesFixture(text, __filename, 'test--success'); - }, - { timeout: 120000 }, - ); + it('error - wrong scheme', async () => { + const { text, isError } = await harness.invoke('device', 'get-app-path', { + workspacePath: WORKSPACE, + scheme: 'NONEXISTENT', + }); + expect(isError).toBe(true); + expectMatchesFixture(text, __filename, 'get-app-path--error-wrong-scheme'); + }); }); - describe.runIf(process.env.DEVICE_ID)('install (requires device)', () => { - it.skip('success - requires dynamic built app path', async () => {}); + describe('install', () => { + it('error - invalid app path', async () => { + const { text, isError } = await harness.invoke('device', 'install', { + deviceId: '00000000-0000-0000-0000-000000000000', + appPath: '/tmp/nonexistent.app', + }); + expect(isError).toBe(true); + expectMatchesFixture(text, __filename, 'install--error-invalid-app'); + }); }); - describe.runIf(process.env.DEVICE_ID)('launch (requires device)', () => { - it.skip('success - requires installed app', async () => {}); + describe('launch', () => { + it('error - invalid bundle', async () => { + const { text, isError } = await harness.invoke('device', 'launch', { + deviceId: '00000000-0000-0000-0000-000000000000', + bundleId: 'com.nonexistent.app', + }); + expect(isError).toBe(true); + expectMatchesFixture(text, __filename, 'launch--error-invalid-bundle'); + }); }); - describe.runIf(process.env.DEVICE_ID)('stop (requires device)', () => { - it.skip('success - requires running app', async () => {}); + describe('stop', () => { + it('error - no app', async () => { + const { text, isError } = await harness.invoke('device', 'stop', { + deviceId: '00000000-0000-0000-0000-000000000000', + processId: 99999, + bundleId: 'com.nonexistent.app', + }); + expect(isError).toBe(true); + expectMatchesFixture(text, __filename, 'stop--error-no-app'); + }); }); - describe.runIf(process.env.DEVICE_ID)('start-device-log-capture (requires device)', () => { - it.skip('success - requires running app', async () => {}); + describe.runIf(DEVICE_ID)('build-and-run (requires device)', () => { + it('success', async () => { + const { text, isError } = await harness.invoke('device', 'build-and-run', { + workspacePath: WORKSPACE, + scheme: 'CalculatorApp', + deviceId: DEVICE_ID, + }); + expect(isError).toBe(false); + expect(text.length).toBeGreaterThan(10); + expectMatchesFixture(text, __filename, 'build-and-run--success'); + }); }); - describe.runIf(process.env.DEVICE_ID)('stop-device-log-capture (requires device)', () => { - it.skip('success - requires active log session', async () => {}); + describe.runIf(DEVICE_ID)('test (requires device)', () => { + it('failure - intentional test failure', async () => { + const { text, isError } = await harness.invoke('device', 'test', { + workspacePath: WORKSPACE, + scheme: 'CalculatorApp', + deviceId: DEVICE_ID, + }); + expect(isError).toBe(true); + expect(text.length).toBeGreaterThan(10); + expectMatchesFixture(text, __filename, 'test--failure'); + }, 300_000); }); }); diff --git a/src/snapshot-tests/normalize.ts b/src/snapshot-tests/normalize.ts index f1366a10..27608117 100644 --- a/src/snapshot-tests/normalize.ts +++ b/src/snapshot-tests/normalize.ts @@ -77,9 +77,7 @@ export function normalizeSnapshotOutput(text: string): string { normalized = normalized.replace(PROCESS_ID_REGEX, 'Process ID: '); normalized = normalized.replace(PROGRESS_LINE_REGEX, ''); normalized = normalized.replace(WARNINGS_BLOCK_REGEX, ''); - normalized = normalized.replace(TEST_DISCOVERY_REGEX, 'Resolved to test(s)\n'); normalized = normalized.replace(XCODE_INFRA_ERRORS_REGEX, ''); - normalized = normalized.replace(TEST_FAILURE_BLOCK_REGEX, ''); normalized = normalized.replace(SPM_STEP_LINE_REGEX, ''); normalized = normalized.replace(SPM_PLANNING_LINE_REGEX, ''); diff --git a/src/utils/build-utils.ts b/src/utils/build-utils.ts index f592fbcb..4a26833e 100644 --- a/src/utils/build-utils.ts +++ b/src/utils/build-utils.ts @@ -118,6 +118,7 @@ export async function executeXcodeBuildCommand( command.push('-scheme', params.scheme); command.push('-configuration', params.configuration); command.push('-skipMacroValidation'); + command.push('-allowProvisioningUpdates'); let destinationString: string; const isSimulatorPlatform = [ @@ -142,15 +143,16 @@ export async function executeXcodeBuildCommand( platformOptions.useLatestOS, ); } else { + const errorMsg = `For ${platformOptions.platform} platform, either simulatorId or simulatorName must be provided`; + if (pipeline) { + return { content: [{ type: 'text', text: errorMsg }], isError: true }; + } return toolResponse([ header(`${platformOptions.logPrefix} ${buildAction}`, [ { label: 'Scheme', value: params.scheme }, { label: 'Platform', value: String(platformOptions.platform) }, ]), - statusLine( - 'error', - `For ${platformOptions.platform} platform, either simulatorId or simulatorName must be provided`, - ), + statusLine('error', errorMsg), ]); } } else if (platformOptions.platform === XcodePlatform.macOS) { @@ -176,12 +178,16 @@ export async function executeXcodeBuildCommand( destinationString = `generic/platform=${platformName}`; } } else { + const errorMsg = `Unsupported platform: ${platformOptions.platform}`; + if (pipeline) { + return { content: [{ type: 'text', text: errorMsg }], isError: true }; + } return toolResponse([ header(`${platformOptions.logPrefix} ${buildAction}`, [ { label: 'Scheme', value: params.scheme }, { label: 'Platform', value: String(platformOptions.platform) }, ]), - statusLine('error', `Unsupported platform: ${platformOptions.platform}`), + statusLine('error', errorMsg), ]); } @@ -274,16 +280,28 @@ export async function executeXcodeBuildCommand( `${platformOptions.logPrefix} ${buildAction} failed: ${result.error}`, { sentry: isMcpError }, ); + const failureMsg = `${platformOptions.logPrefix} ${buildAction} failed for scheme ${params.scheme}.`; + + if (pipeline) { + const content: { type: 'text'; text: string }[] = [{ type: 'text', text: failureMsg }]; + + if (warningOrErrorLines.length === 0 && useXcodemake) { + content.push({ + type: 'text', + text: 'Incremental build using xcodemake failed, suggest using preferXcodebuild option to try build again using slower xcodebuild command.', + }); + } + + return { content, isError: true }; + } + const errorResponse = toolResponse([ header(`${platformOptions.logPrefix} ${buildAction}`, [ { label: 'Scheme', value: params.scheme }, { label: 'Platform', value: String(platformOptions.platform) }, { label: 'Configuration', value: params.configuration }, ]), - statusLine( - 'error', - `${platformOptions.logPrefix} ${buildAction} failed for scheme ${params.scheme}.`, - ), + statusLine('error', failureMsg), ]); if (buildMessages.length > 0 && errorResponse.content) { @@ -334,14 +352,12 @@ Future builds will use the generated Makefile for improved performance. } } + const successText = pipeline + ? `${platformOptions.logPrefix} ${buildAction} succeeded for scheme ${params.scheme}.` + : `โœ… ${platformOptions.logPrefix} ${buildAction} succeeded for scheme ${params.scheme}.`; + const successResponse: ToolResponse = { - content: [ - ...buildMessages, - { - type: 'text', - text: `โœ… ${platformOptions.logPrefix} ${buildAction} succeeded for scheme ${params.scheme}.`, - }, - ], + content: [...buildMessages, { type: 'text', text: successText }], }; if (additionalInfo) { @@ -364,15 +380,16 @@ Future builds will use the generated Makefile for improved performance. sentry: !isSpawnError, }); + const errorMsg = `Error during ${platformOptions.logPrefix} ${buildAction}: ${errorMessage}`; + if (pipeline) { + return { content: [{ type: 'text', text: errorMsg }], isError: true }; + } return toolResponse([ header(`${platformOptions.logPrefix} ${buildAction}`, [ { label: 'Scheme', value: params.scheme }, { label: 'Platform', value: String(platformOptions.platform) }, ]), - statusLine( - 'error', - `Error during ${platformOptions.logPrefix} ${buildAction}: ${errorMessage}`, - ), + statusLine('error', errorMsg), ]); } } diff --git a/src/utils/renderers/__tests__/event-formatting.test.ts b/src/utils/renderers/__tests__/event-formatting.test.ts index a4387504..6064005a 100644 --- a/src/utils/renderers/__tests__/event-formatting.test.ts +++ b/src/utils/renderers/__tests__/event-formatting.test.ts @@ -22,7 +22,7 @@ describe('event formatting', () => { operation: 'Build & Run', params: [{ label: 'Scheme', value: 'MyApp' }], }), - ).toBe('\u{1F680} Build & Run\n\n Scheme: MyApp\n'); + ).toBe('\u{1F680} Build & Run\n\n Scheme: MyApp\n'); }); it('formats build-stage events as durable phase lines', () => { diff --git a/src/utils/renderers/event-formatting.ts b/src/utils/renderers/event-formatting.ts index bb135d21..6734497e 100644 --- a/src/utils/renderers/event-formatting.ts +++ b/src/utils/renderers/event-formatting.ts @@ -213,7 +213,7 @@ export function formatHeaderEvent(event: HeaderEvent): string { const lines: string[] = [`${emoji} ${event.operation}`, '']; for (const param of event.params) { - lines.push(` ${param.label}: ${param.value}`); + lines.push(` ${param.label}: ${param.value}`); } lines.push(''); @@ -248,7 +248,8 @@ export function formatSectionEvent(event: SectionEvent): string { if (event.lines.length === 0) { return header; } - const indented = event.lines.map((line) => ` ${line}`); + const indent = event.icon ? ' ' : ' '; + const indented = event.lines.map((line) => `${indent}${line}`); return [header, ...indented].join('\n'); } diff --git a/src/utils/test-common.ts b/src/utils/test-common.ts index 55026671..57f273a3 100644 --- a/src/utils/test-common.ts +++ b/src/utils/test-common.ts @@ -101,6 +101,9 @@ export async function handleTestLogic( scheme: params.scheme, configuration: params.configuration, platform: String(params.platform), + ...(params.simulatorName ? { simulatorName: params.simulatorName } : {}), + ...(params.simulatorId ? { simulatorId: params.simulatorId } : {}), + ...(params.deviceId ? { deviceId: params.deviceId } : {}), preflight: preflightText, }, message: preflightText, From da019716d4d7098c0a4db35686efc4be88154e98 Mon Sep 17 00:00:00 2001 From: Cameron Cooke Date: Thu, 26 Mar 2026 22:15:48 +0000 Subject: [PATCH 07/50] WIP --- src/cli/output.ts | 4 +- src/cli/register-tool-commands.ts | 18 ++- .../device/__tests__/build_device.test.ts | 3 - src/mcp/tools/device/get_device_app_path.ts | 2 +- src/mcp/tools/device/install_app_device.ts | 3 +- src/mcp/tools/device/launch_app_device.ts | 6 +- src/mcp/tools/device/stop_app_device.ts | 3 +- .../__tests__/start_device_log_cap.test.ts | 3 +- .../__tests__/start_sim_log_cap.test.ts | 3 +- src/mcp/tools/logging/start_device_log_cap.ts | 12 +- src/mcp/tools/logging/start_sim_log_cap.ts | 18 +-- src/mcp/tools/logging/stop_device_log_cap.ts | 2 +- src/mcp/tools/logging/stop_sim_log_cap.ts | 2 +- .../tools/macos/__tests__/build_macos.test.ts | 6 - .../macos/__tests__/build_run_macos.test.ts | 3 - src/mcp/tools/macos/get_mac_app_path.ts | 2 +- .../__tests__/discover_projs.test.ts | 1 - .../tools/project-discovery/discover_projs.ts | 9 -- .../simulator/__tests__/build_run_sim.test.ts | 5 - .../simulator/__tests__/build_sim.test.ts | 6 - src/mcp/tools/simulator/get_sim_app_path.ts | 2 +- ...-coverage-report--error-invalid-bundle.txt | 2 +- ...et-file-coverage--error-invalid-bundle.txt | 2 +- .../debugging/attach--success.txt | 3 +- .../debugging/lldb-command--success.txt | 4 +- .../__fixtures__/debugging/stack--success.txt | 2 +- .../device/build-and-run--success.txt | 5 +- .../get-app-path--error-wrong-scheme.txt | 2 +- .../device/get-app-path--success.txt | 3 +- .../__fixtures__/device/install--success.txt | 7 ++ .../__fixtures__/device/launch--success.txt | 9 ++ .../__fixtures__/device/list--success.txt | 6 +- .../device/stop--error-no-app.txt | 2 +- .../__fixtures__/device/stop--success.txt | 7 ++ .../__fixtures__/device/test--failure.txt | 2 +- .../__fixtures__/device/test--success.txt | 9 ++ .../logging/start-device-log--error.txt | 7 ++ .../logging/start-device-log--success.txt | 13 ++ .../logging/start-sim-log--error.txt | 14 --- .../logging/start-sim-log--success.txt | 13 ++ .../logging/stop-device-log--error.txt | 6 + .../logging/stop-device-log--success.txt | 16 +++ .../logging/stop-sim-log--success.txt | 12 ++ .../macos/build-and-run--success.txt | 1 + .../macos/get-app-path--success.txt | 3 +- .../discover-projs--success.txt | 1 - .../simulator/build-and-run--success.txt | 1 + .../simulator/get-app-path--success.txt | 3 +- .../__fixtures__/simulator/test--failure.txt | 12 ++ .../__fixtures__/simulator/test--success.txt | 5 +- .../swift-package/stop--error-no-process.txt | 2 +- .../__tests__/device.snapshot.test.ts | 77 ++++++++++++ .../__tests__/logging.snapshot.test.ts | 114 ++++++++++++++++-- .../__tests__/simulator.snapshot.test.ts | 14 ++- src/snapshot-tests/normalize.ts | 7 +- src/utils/build-utils.ts | 1 - src/utils/command.ts | 12 ++ src/utils/device-name-resolver.ts | 54 +++++++++ src/utils/renderers/cli-text-renderer.ts | 2 +- src/utils/renderers/index.ts | 2 +- src/utils/renderers/mcp-renderer.ts | 2 +- src/utils/xcodebuild-pipeline.ts | 10 +- 62 files changed, 465 insertions(+), 117 deletions(-) create mode 100644 src/snapshot-tests/__fixtures__/device/install--success.txt create mode 100644 src/snapshot-tests/__fixtures__/device/launch--success.txt create mode 100644 src/snapshot-tests/__fixtures__/device/stop--success.txt create mode 100644 src/snapshot-tests/__fixtures__/device/test--success.txt create mode 100644 src/snapshot-tests/__fixtures__/logging/start-device-log--error.txt create mode 100644 src/snapshot-tests/__fixtures__/logging/start-device-log--success.txt delete mode 100644 src/snapshot-tests/__fixtures__/logging/start-sim-log--error.txt create mode 100644 src/snapshot-tests/__fixtures__/logging/start-sim-log--success.txt create mode 100644 src/snapshot-tests/__fixtures__/logging/stop-device-log--error.txt create mode 100644 src/snapshot-tests/__fixtures__/logging/stop-device-log--success.txt create mode 100644 src/snapshot-tests/__fixtures__/logging/stop-sim-log--success.txt create mode 100644 src/snapshot-tests/__fixtures__/simulator/test--failure.txt create mode 100644 src/utils/device-name-resolver.ts diff --git a/src/cli/output.ts b/src/cli/output.ts index beb622b4..6400fcf7 100644 --- a/src/cli/output.ts +++ b/src/cli/output.ts @@ -1,7 +1,7 @@ import type { ToolResponse, OutputStyle } from '../types/common.ts'; import { formatCliTextLine } from '../utils/terminal-output.ts'; -export type OutputFormat = 'text' | 'json'; +export type OutputFormat = 'text' | 'json' | 'raw'; export interface PrintToolResponseOptions { format?: OutputFormat; @@ -49,7 +49,7 @@ export function printToolResponse( ): void { const { format = 'text', style = 'normal' } = options; - if (isCompletePipelineStream(response)) { + if (isCompletePipelineStream(response) || process.env.XCODEBUILDMCP_VERBOSE === '1') { if (response.isError) { process.exitCode = 1; } diff --git a/src/cli/register-tool-commands.ts b/src/cli/register-tool-commands.ts index 2bd98dd4..42ccea96 100644 --- a/src/cli/register-tool-commands.ts +++ b/src/cli/register-tool-commands.ts @@ -141,7 +141,9 @@ function registerToolSubcommand( tool.description ?? `Run the ${tool.mcpName} tool`, (subYargs) => { // Hide root-level options from tool help - subYargs.option('log-level', { hidden: true }).option('style', { hidden: true }); + subYargs + .option('log-level', { hidden: true }) + .option('style', { hidden: true }); // Parse option-like values as arguments (e.g. --extra-args "-only-testing:...") subYargs.parserConfiguration({ @@ -170,7 +172,7 @@ function registerToolSubcommand( // Add --output option for format control subYargs.option('output', { type: 'string', - choices: ['text', 'json'] as const, + choices: ['text', 'json', 'raw'] as const, default: 'text', describe: 'Output format', }); @@ -212,6 +214,7 @@ function registerToolSubcommand( const socketPath = argv.socket as string; const logLevel = argv['log-level'] as string | undefined; + if ( profileOverride && !isKnownCliSessionDefaultsProfile(opts.runtimeConfig, profileOverride) @@ -281,6 +284,11 @@ function registerToolSubcommand( const previousCliOutputFormat = process.env.XCODEBUILDMCP_CLI_OUTPUT_FORMAT; process.env.XCODEBUILDMCP_CLI_OUTPUT_FORMAT = outputFormat; + const previousVerbose = process.env.XCODEBUILDMCP_VERBOSE; + if (outputFormat === 'raw') { + process.env.XCODEBUILDMCP_VERBOSE = '1'; + } + try { // Invoke the tool const response = await invoker.invokeDirect(tool, args, { @@ -298,6 +306,12 @@ function registerToolSubcommand( } else { process.env.XCODEBUILDMCP_CLI_OUTPUT_FORMAT = previousCliOutputFormat; } + + if (previousVerbose === undefined) { + delete process.env.XCODEBUILDMCP_VERBOSE; + } else { + process.env.XCODEBUILDMCP_VERBOSE = previousVerbose; + } } }, ); diff --git a/src/mcp/tools/device/__tests__/build_device.test.ts b/src/mcp/tools/device/__tests__/build_device.test.ts index 991d8766..c45b874c 100644 --- a/src/mcp/tools/device/__tests__/build_device.test.ts +++ b/src/mcp/tools/device/__tests__/build_device.test.ts @@ -181,7 +181,6 @@ describe('build_device plugin', () => { '-configuration', 'Debug', '-skipMacroValidation', - '-allowProvisioningUpdates', '-destination', 'generic/platform=iOS', 'build', @@ -229,7 +228,6 @@ describe('build_device plugin', () => { '-configuration', 'Debug', '-skipMacroValidation', - '-allowProvisioningUpdates', '-destination', 'generic/platform=iOS', 'build', @@ -316,7 +314,6 @@ describe('build_device plugin', () => { '-configuration', 'Release', '-skipMacroValidation', - '-allowProvisioningUpdates', '-destination', 'generic/platform=iOS', '-derivedDataPath', diff --git a/src/mcp/tools/device/get_device_app_path.ts b/src/mcp/tools/device/get_device_app_path.ts index c5a2ee7f..bb776bcf 100644 --- a/src/mcp/tools/device/get_device_app_path.ts +++ b/src/mcp/tools/device/get_device_app_path.ts @@ -96,8 +96,8 @@ export async function get_device_app_pathLogic( return toolResponse( [ headerEvent, - detailTree([{ label: 'App Path', value: appPath }]), statusLine('success', 'App path resolved.'), + detailTree([{ label: 'App Path', value: appPath }]), ], { nextStepParams: { diff --git a/src/mcp/tools/device/install_app_device.ts b/src/mcp/tools/device/install_app_device.ts index 0fc09018..9a1f6ff7 100644 --- a/src/mcp/tools/device/install_app_device.ts +++ b/src/mcp/tools/device/install_app_device.ts @@ -16,6 +16,7 @@ import { } from '../../../utils/typed-tool-factory.ts'; import { toolResponse } from '../../../utils/tool-response.ts'; import { header, statusLine } from '../../../utils/tool-event-builders.ts'; +import { formatDeviceId } from '../../../utils/device-name-resolver.ts'; const installAppDeviceSchema = z.object({ deviceId: z @@ -35,7 +36,7 @@ export async function install_app_deviceLogic( ): Promise { const { deviceId, appPath } = params; const headerEvent = header('Install App', [ - { label: 'Device', value: deviceId }, + { label: 'Device', value: formatDeviceId(deviceId) }, { label: 'App', value: appPath }, ]); diff --git a/src/mcp/tools/device/launch_app_device.ts b/src/mcp/tools/device/launch_app_device.ts index f84bd205..329ceca0 100644 --- a/src/mcp/tools/device/launch_app_device.ts +++ b/src/mcp/tools/device/launch_app_device.ts @@ -20,6 +20,7 @@ import { import { join } from 'path'; import { toolResponse } from '../../../utils/tool-response.ts'; import { header, statusLine, detailTree } from '../../../utils/tool-event-builders.ts'; +import { formatDeviceId } from '../../../utils/device-name-resolver.ts'; type LaunchDataResponse = { result?: { @@ -55,7 +56,7 @@ export async function launch_app_deviceLogic( log('info', `Launching app ${bundleId} on device ${deviceId}`); const headerEvent = header('Launch App', [ - { label: 'Device', value: deviceId }, + { label: 'Device', value: formatDeviceId(deviceId) }, { label: 'Bundle ID', value: bundleId }, ]); @@ -105,13 +106,12 @@ export async function launch_app_deviceLogic( } const events = [headerEvent]; + events.push(statusLine('success', 'App launched successfully.')); if (processId) { events.push(detailTree([{ label: 'Process ID', value: processId.toString() }])); } - events.push(statusLine('success', 'App launched successfully.')); - return toolResponse( events, processId ? { nextStepParams: { stop_app_device: { deviceId, processId } } } : undefined, diff --git a/src/mcp/tools/device/stop_app_device.ts b/src/mcp/tools/device/stop_app_device.ts index 052bc269..44e68df5 100644 --- a/src/mcp/tools/device/stop_app_device.ts +++ b/src/mcp/tools/device/stop_app_device.ts @@ -16,6 +16,7 @@ import { } from '../../../utils/typed-tool-factory.ts'; import { toolResponse } from '../../../utils/tool-response.ts'; import { header, statusLine } from '../../../utils/tool-event-builders.ts'; +import { formatDeviceId } from '../../../utils/device-name-resolver.ts'; const stopAppDeviceSchema = z.object({ deviceId: z.string().describe('UDID of the device (obtained from list_devices)'), @@ -32,7 +33,7 @@ export async function stop_app_deviceLogic( ): Promise { const { deviceId, processId } = params; const headerEvent = header('Stop App', [ - { label: 'Device', value: deviceId }, + { label: 'Device', value: formatDeviceId(deviceId) }, { label: 'PID', value: processId.toString() }, ]); diff --git a/src/mcp/tools/logging/__tests__/start_device_log_cap.test.ts b/src/mcp/tools/logging/__tests__/start_device_log_cap.test.ts index 5e889b22..2cbca494 100644 --- a/src/mcp/tools/logging/__tests__/start_device_log_cap.test.ts +++ b/src/mcp/tools/logging/__tests__/start_device_log_cap.test.ts @@ -136,8 +136,7 @@ describe('start_device_log_cap plugin', () => { ); const text = allText(result); - expect(text).toContain('Do not call launch_app_device during this capture session'); - expect(text).toContain('Interact with your app'); + expect(text).toContain('Do not call launch_app_device during this session'); const sessionIdMatch = text.match(/Session ID: ([a-f0-9-]{36})/); expect(sessionIdMatch).not.toBeNull(); const sessionId = sessionIdMatch?.[1]; diff --git a/src/mcp/tools/logging/__tests__/start_sim_log_cap.test.ts b/src/mcp/tools/logging/__tests__/start_sim_log_cap.test.ts index 1f81887a..eba20546 100644 --- a/src/mcp/tools/logging/__tests__/start_sim_log_cap.test.ts +++ b/src/mcp/tools/logging/__tests__/start_sim_log_cap.test.ts @@ -100,7 +100,6 @@ describe('start_sim_log_cap plugin', () => { const text = allText(result); expect(text).toContain('test-uuid-123'); expect(text).toContain('app subsystem'); - expect(text).toContain('stop capture to retrieve logs'); expect(result.nextStepParams?.stop_sim_log_cap).toBeDefined(); expect(result.nextStepParams?.stop_sim_log_cap).toMatchObject({ logSessionId: 'test-uuid-123', @@ -210,7 +209,7 @@ describe('start_sim_log_cap plugin', () => { ); const text = allText(result); - expect(text).toContain('app was relaunched to capture console output'); + expect(text).toContain('App relaunched to capture console output'); expect(text).toContain('test-uuid-123'); }); diff --git a/src/mcp/tools/logging/start_device_log_cap.ts b/src/mcp/tools/logging/start_device_log_cap.ts index 16176498..4500f2cf 100644 --- a/src/mcp/tools/logging/start_device_log_cap.ts +++ b/src/mcp/tools/logging/start_device_log_cap.ts @@ -20,7 +20,7 @@ import { getSessionAwareToolSchemaShape, } from '../../../utils/typed-tool-factory.ts'; import { toolResponse } from '../../../utils/tool-response.ts'; -import { header, section, statusLine } from '../../../utils/tool-event-builders.ts'; +import { header, detailTree, statusLine } from '../../../utils/tool-event-builders.ts'; import { activeDeviceLogSessions, type DeviceLogSession, @@ -647,13 +647,11 @@ export async function start_device_log_capLogic( return toolResponse( [ headerEvent, - section('Details', [ - `Session ID: ${sessionId}`, - 'The app has been launched on the device with console output capture enabled.', - 'Do not call launch_app_device during this capture session; relaunching can interrupt captured output.', - 'Interact with your app on the device, then stop capture to retrieve logs.', - ]), statusLine('success', 'Log capture started.'), + detailTree([ + { label: 'Session ID', value: sessionId }, + { label: 'Note', value: 'Do not call launch_app_device during this session' }, + ]), ], { nextStepParams: { diff --git a/src/mcp/tools/logging/start_sim_log_cap.ts b/src/mcp/tools/logging/start_sim_log_cap.ts index e5c4c730..25037ab4 100644 --- a/src/mcp/tools/logging/start_sim_log_cap.ts +++ b/src/mcp/tools/logging/start_sim_log_cap.ts @@ -15,7 +15,7 @@ import { getSessionAwareToolSchemaShape, } from '../../../utils/typed-tool-factory.ts'; import { toolResponse } from '../../../utils/tool-response.ts'; -import { header, section, statusLine } from '../../../utils/tool-event-builders.ts'; +import { header, statusLine, detailTree } from '../../../utils/tool-event-builders.ts'; const startSimLogCapSchema = z.object({ simulatorId: z @@ -73,16 +73,20 @@ export async function start_sim_log_capLogic( const filterDescription = buildSubsystemFilterDescription(subsystemFilter); - const lines: string[] = []; - lines.push(`Session ID: ${sessionId}`); + const items: Array<{ label: string; value: string }> = [ + { label: 'Session ID', value: sessionId }, + { label: 'Filter', value: filterDescription }, + ]; if (captureConsole) { - lines.push('Note: Your app was relaunched to capture console output.'); + items.push({ label: 'Console', value: 'App relaunched to capture console output' }); } - lines.push(filterDescription); - lines.push('Interact with your simulator and app, then stop capture to retrieve logs.'); return toolResponse( - [headerEvent, section('Details', lines), statusLine('success', 'Log capture started.')], + [ + headerEvent, + statusLine('success', 'Log capture started.'), + detailTree(items), + ], { nextStepParams: { stop_sim_log_cap: { logSessionId: sessionId }, diff --git a/src/mcp/tools/logging/stop_device_log_cap.ts b/src/mcp/tools/logging/stop_device_log_cap.ts index 272025e7..1980648d 100644 --- a/src/mcp/tools/logging/stop_device_log_cap.ts +++ b/src/mcp/tools/logging/stop_device_log_cap.ts @@ -51,8 +51,8 @@ export async function stop_device_log_capLogic( return toolResponse([ headerEvent, - section('Captured Logs', [result.logContent]), statusLine('success', 'Log capture stopped.'), + section('Captured Logs', result.logContent.split('\n')), ]); } catch (error) { const message = error instanceof Error ? error.message : String(error); diff --git a/src/mcp/tools/logging/stop_sim_log_cap.ts b/src/mcp/tools/logging/stop_sim_log_cap.ts index 727ad2b1..9153ebfc 100644 --- a/src/mcp/tools/logging/stop_sim_log_cap.ts +++ b/src/mcp/tools/logging/stop_sim_log_cap.ts @@ -46,8 +46,8 @@ export async function stop_sim_log_capLogic( } return toolResponse([ headerEvent, - section('Captured Logs', [logContent]), statusLine('success', 'Log capture stopped.'), + section('Captured Logs', logContent.split('\n')), ]); } diff --git a/src/mcp/tools/macos/__tests__/build_macos.test.ts b/src/mcp/tools/macos/__tests__/build_macos.test.ts index 49676a4f..a92ceb9e 100644 --- a/src/mcp/tools/macos/__tests__/build_macos.test.ts +++ b/src/mcp/tools/macos/__tests__/build_macos.test.ts @@ -209,7 +209,6 @@ describe('build_macos plugin', () => { '-configuration', 'Debug', '-skipMacroValidation', - '-allowProvisioningUpdates', '-destination', 'platform=macOS', 'build', @@ -248,7 +247,6 @@ describe('build_macos plugin', () => { '-configuration', 'Release', '-skipMacroValidation', - '-allowProvisioningUpdates', '-destination', 'platform=macOS,arch=x86_64', '-derivedDataPath', @@ -286,7 +284,6 @@ describe('build_macos plugin', () => { '-configuration', 'Debug', '-skipMacroValidation', - '-allowProvisioningUpdates', '-destination', 'platform=macOS', '-derivedDataPath', @@ -323,7 +320,6 @@ describe('build_macos plugin', () => { '-configuration', 'Debug', '-skipMacroValidation', - '-allowProvisioningUpdates', '-destination', 'platform=macOS,arch=arm64', 'build', @@ -357,7 +353,6 @@ describe('build_macos plugin', () => { '-configuration', 'Debug', '-skipMacroValidation', - '-allowProvisioningUpdates', '-destination', 'platform=macOS', 'build', @@ -391,7 +386,6 @@ describe('build_macos plugin', () => { '-configuration', 'Debug', '-skipMacroValidation', - '-allowProvisioningUpdates', '-destination', 'platform=macOS', 'build', diff --git a/src/mcp/tools/macos/__tests__/build_run_macos.test.ts b/src/mcp/tools/macos/__tests__/build_run_macos.test.ts index ea5561e2..726c682f 100644 --- a/src/mcp/tools/macos/__tests__/build_run_macos.test.ts +++ b/src/mcp/tools/macos/__tests__/build_run_macos.test.ts @@ -125,7 +125,6 @@ describe('build_run_macos', () => { '-configuration', 'Debug', '-skipMacroValidation', - '-allowProvisioningUpdates', '-destination', 'platform=macOS', 'build', @@ -204,7 +203,6 @@ describe('build_run_macos', () => { '-configuration', 'Debug', '-skipMacroValidation', - '-allowProvisioningUpdates', '-destination', 'platform=macOS', 'build', @@ -428,7 +426,6 @@ describe('build_run_macos', () => { '-configuration', 'Debug', '-skipMacroValidation', - '-allowProvisioningUpdates', '-destination', 'platform=macOS', 'build', diff --git a/src/mcp/tools/macos/get_mac_app_path.ts b/src/mcp/tools/macos/get_mac_app_path.ts index 4d8f274d..2028e498 100644 --- a/src/mcp/tools/macos/get_mac_app_path.ts +++ b/src/mcp/tools/macos/get_mac_app_path.ts @@ -137,8 +137,8 @@ export async function get_mac_app_pathLogic( return toolResponse( [ headerEvent, - detailTree([{ label: 'App Path', value: appPath }]), statusLine('success', 'App path resolved.'), + detailTree([{ label: 'App Path', value: appPath }]), ], { nextStepParams: { diff --git a/src/mcp/tools/project-discovery/__tests__/discover_projs.test.ts b/src/mcp/tools/project-discovery/__tests__/discover_projs.test.ts index d2ebf0dd..063b96a4 100644 --- a/src/mcp/tools/project-discovery/__tests__/discover_projs.test.ts +++ b/src/mcp/tools/project-discovery/__tests__/discover_projs.test.ts @@ -158,7 +158,6 @@ describe('discover_projs plugin', () => { expect(text).toContain('Found 1 project(s) and 1 workspace(s).'); expect(text).toContain('/workspace/MyApp.xcodeproj'); expect(text).toContain('/workspace/MyWorkspace.xcworkspace'); - expect(text).toContain('session-set-defaults'); }); it('should handle fs error with code', async () => { diff --git a/src/mcp/tools/project-discovery/discover_projs.ts b/src/mcp/tools/project-discovery/discover_projs.ts index 44acb933..528ced97 100644 --- a/src/mcp/tools/project-discovery/discover_projs.ts +++ b/src/mcp/tools/project-discovery/discover_projs.ts @@ -255,15 +255,6 @@ export async function discover_projsLogic( events.push(section('Workspaces', results.workspaces)); } - if (results.projects.length > 0 || results.workspaces.length > 0) { - events.push( - statusLine( - 'info', - "Save a default with session-set-defaults { projectPath: '...' } or { workspacePath: '...' }.", - ), - ); - } - return toolResponse(events); } diff --git a/src/mcp/tools/simulator/__tests__/build_run_sim.test.ts b/src/mcp/tools/simulator/__tests__/build_run_sim.test.ts index 3709fc19..20f3067d 100644 --- a/src/mcp/tools/simulator/__tests__/build_run_sim.test.ts +++ b/src/mcp/tools/simulator/__tests__/build_run_sim.test.ts @@ -387,7 +387,6 @@ describe('build_run_sim tool', () => { '-configuration', 'Debug', '-skipMacroValidation', - '-allowProvisioningUpdates', '-destination', 'platform=iOS Simulator,name=iPhone 17,OS=latest', 'build', @@ -443,7 +442,6 @@ describe('build_run_sim tool', () => { '-configuration', 'Debug', '-skipMacroValidation', - '-allowProvisioningUpdates', '-destination', 'platform=iOS Simulator,name=iPhone 17,OS=latest', 'build', @@ -507,7 +505,6 @@ describe('build_run_sim tool', () => { '-configuration', 'Release', '-skipMacroValidation', - '-allowProvisioningUpdates', '-destination', 'platform=iOS Simulator,name=iPhone 17', 'build', @@ -551,7 +548,6 @@ describe('build_run_sim tool', () => { '-configuration', 'Debug', '-skipMacroValidation', - '-allowProvisioningUpdates', '-destination', 'platform=iOS Simulator,name=iPhone 17 Pro,OS=latest', 'build', @@ -582,7 +578,6 @@ describe('build_run_sim tool', () => { '-configuration', 'Debug', '-skipMacroValidation', - '-allowProvisioningUpdates', '-destination', 'platform=tvOS Simulator,name=Apple TV 4K,OS=latest', 'build', diff --git a/src/mcp/tools/simulator/__tests__/build_sim.test.ts b/src/mcp/tools/simulator/__tests__/build_sim.test.ts index f39da987..2e97212d 100644 --- a/src/mcp/tools/simulator/__tests__/build_sim.test.ts +++ b/src/mcp/tools/simulator/__tests__/build_sim.test.ts @@ -225,7 +225,6 @@ describe('build_sim tool', () => { '-configuration', 'Debug', '-skipMacroValidation', - '-allowProvisioningUpdates', '-destination', 'platform=iOS Simulator,name=iPhone 17,OS=latest', 'build', @@ -257,7 +256,6 @@ describe('build_sim tool', () => { '-configuration', 'Debug', '-skipMacroValidation', - '-allowProvisioningUpdates', '-destination', 'platform=iOS Simulator,name=iPhone 17,OS=latest', 'build', @@ -293,7 +291,6 @@ describe('build_sim tool', () => { '-configuration', 'Release', '-skipMacroValidation', - '-allowProvisioningUpdates', '-destination', 'platform=iOS Simulator,name=iPhone 17', '-derivedDataPath', @@ -328,7 +325,6 @@ describe('build_sim tool', () => { '-configuration', 'Debug', '-skipMacroValidation', - '-allowProvisioningUpdates', '-destination', 'platform=iOS Simulator,name=iPhone 17 Pro,OS=latest', 'build', @@ -361,7 +357,6 @@ describe('build_sim tool', () => { '-configuration', 'Debug', '-skipMacroValidation', - '-allowProvisioningUpdates', '-destination', 'platform=iOS Simulator,name=iPhone 17,OS=latest', 'build', @@ -393,7 +388,6 @@ describe('build_sim tool', () => { '-configuration', 'Debug', '-skipMacroValidation', - '-allowProvisioningUpdates', '-destination', 'platform=watchOS Simulator,name=Apple Watch Ultra 2,OS=latest', 'build', diff --git a/src/mcp/tools/simulator/get_sim_app_path.ts b/src/mcp/tools/simulator/get_sim_app_path.ts index 89c168e7..7ef989a5 100644 --- a/src/mcp/tools/simulator/get_sim_app_path.ts +++ b/src/mcp/tools/simulator/get_sim_app_path.ts @@ -171,8 +171,8 @@ export async function get_sim_app_pathLogic( return toolResponse( [ headerEvent, - detailTree([{ label: 'App Path', value: appPath }]), statusLine('success', 'App path resolved'), + detailTree([{ label: 'App Path', value: appPath }]), ], { nextStepParams: { diff --git a/src/snapshot-tests/__fixtures__/coverage/get-coverage-report--error-invalid-bundle.txt b/src/snapshot-tests/__fixtures__/coverage/get-coverage-report--error-invalid-bundle.txt index 9da13665..5c06b714 100644 --- a/src/snapshot-tests/__fixtures__/coverage/get-coverage-report--error-invalid-bundle.txt +++ b/src/snapshot-tests/__fixtures__/coverage/get-coverage-report--error-invalid-bundle.txt @@ -3,4 +3,4 @@ xcresult: /invalid.xcresult -โŒ Failed to get coverage report: Error: Error Domain=XCCovErrorDomain Code=0 "Failed to load result bundle" UserInfo={NSLocalizedDescription=Failed to load result bundle, NSUnderlyingError=0x84fcd98b0 {Error Domain=XCResultStorage.ResultBundleFactory.Error Code=0 "Failed to create a new result bundle reader, underlying error: Info.plist at /invalid.xcresult/Info.plist does not exist, the result bundle might be corrupted or the provided path is not a result bundle"}} +โŒ Failed to get coverage report: Error: Error Domain=XCCovErrorDomain Code=0 "Failed to load result bundle" UserInfo={NSLocalizedDescription=Failed to load result bundle, NSUnderlyingError=0xadc338280 {Error Domain=XCResultStorage.ResultBundleFactory.Error Code=0 "Failed to create a new result bundle reader, underlying error: Info.plist at /invalid.xcresult/Info.plist does not exist, the result bundle might be corrupted or the provided path is not a result bundle"}} diff --git a/src/snapshot-tests/__fixtures__/coverage/get-file-coverage--error-invalid-bundle.txt b/src/snapshot-tests/__fixtures__/coverage/get-file-coverage--error-invalid-bundle.txt index 1073d467..177eaa38 100644 --- a/src/snapshot-tests/__fixtures__/coverage/get-file-coverage--error-invalid-bundle.txt +++ b/src/snapshot-tests/__fixtures__/coverage/get-file-coverage--error-invalid-bundle.txt @@ -4,4 +4,4 @@ xcresult: /invalid.xcresult File: SomeFile.swift -โŒ Failed to get file coverage: Error: Error Domain=XCCovErrorDomain Code=0 "Failed to load result bundle" UserInfo={NSLocalizedDescription=Failed to load result bundle, NSUnderlyingError=0x9d9bbbe80 {Error Domain=XCResultStorage.ResultBundleFactory.Error Code=0 "Failed to create a new result bundle reader, underlying error: Info.plist at /invalid.xcresult/Info.plist does not exist, the result bundle might be corrupted or the provided path is not a result bundle"}} +โŒ Failed to get file coverage: Error: Error Domain=XCCovErrorDomain Code=0 "Failed to load result bundle" UserInfo={NSLocalizedDescription=Failed to load result bundle, NSUnderlyingError=0xc1ffa0dc0 {Error Domain=XCResultStorage.ResultBundleFactory.Error Code=0 "Failed to create a new result bundle reader, underlying error: Info.plist at /invalid.xcresult/Info.plist does not exist, the result bundle might be corrupted or the provided path is not a result bundle"}} diff --git a/src/snapshot-tests/__fixtures__/debugging/attach--success.txt b/src/snapshot-tests/__fixtures__/debugging/attach--success.txt index 43b9b43e..d4b1c104 100644 --- a/src/snapshot-tests/__fixtures__/debugging/attach--success.txt +++ b/src/snapshot-tests/__fixtures__/debugging/attach--success.txt @@ -1,7 +1,8 @@ ๐Ÿ› Attach Debugger -โœ… Attached DAP debugger to simulator process 2643 () +โœ… Attached DAP debugger to simulator process 76138 () + โ”œ Debug session ID: โ”œ Status: This session is now the current debug session. โ”” Execution: Execution is paused. Use debug_continue to resume before UI automation. diff --git a/src/snapshot-tests/__fixtures__/debugging/lldb-command--success.txt b/src/snapshot-tests/__fixtures__/debugging/lldb-command--success.txt index 0e0408f5..c88a62e2 100644 --- a/src/snapshot-tests/__fixtures__/debugging/lldb-command--success.txt +++ b/src/snapshot-tests/__fixtures__/debugging/lldb-command--success.txt @@ -7,8 +7,6 @@ Output Current breakpoints: - 1: file = 'ContentView.swift', line = 42, exact_match = 0, locations = 1, resolved = 1, hit count = 0 + 1: file = 'ContentView.swift', line = 42, exact_match = 0, locations = 0 (pending) Names: dap - - 1.1: where = CalculatorApp.debug.dylib`closure #1 in closure #1 in closure #1 in ContentView.body.getter + 1428 at ContentView.swift:42:31, address = 0x0000000102d3bc28, resolved, hit count = 0 diff --git a/src/snapshot-tests/__fixtures__/debugging/stack--success.txt b/src/snapshot-tests/__fixtures__/debugging/stack--success.txt index 241c8a47..85736400 100644 --- a/src/snapshot-tests/__fixtures__/debugging/stack--success.txt +++ b/src/snapshot-tests/__fixtures__/debugging/stack--success.txt @@ -4,7 +4,7 @@ โœ… Stack trace retrieved Frames - Thread 15494840 (Thread 1 Queue: com.apple.main-thread (serial)) + Thread 16147149 (Thread 1 Queue: com.apple.main-thread (serial)) frame #0: mach_msg2_trap at /usr/lib/system/libsystem_kernel.dylib`mach_msg2_trap:3 frame #1: mach_msg2_internal at /usr/lib/system/libsystem_kernel.dylib`mach_msg2_internal:56 frame #2: mach_msg_overwrite at /usr/lib/system/libsystem_kernel.dylib`mach_msg_overwrite:121 diff --git a/src/snapshot-tests/__fixtures__/device/build-and-run--success.txt b/src/snapshot-tests/__fixtures__/device/build-and-run--success.txt index 4eced7c4..4e9f9dc5 100644 --- a/src/snapshot-tests/__fixtures__/device/build-and-run--success.txt +++ b/src/snapshot-tests/__fixtures__/device/build-and-run--success.txt @@ -4,7 +4,7 @@ Scheme: CalculatorApp Configuration: Debug Platform: iOS - Device: + Device: () โ„น๏ธ Resolving app path โœ… Resolving app path @@ -14,10 +14,11 @@ โœ… Build succeeded. (โฑ๏ธ ) โœ… Build & Run complete + โ”œ App Path: /Library/Developer/Xcode/DerivedData/CalculatorApp-/Build/Products/Debug-iphoneos/CalculatorApp.app โ”œ Bundle ID: io.sentry.calculatorapp โ”” Process ID: Next steps: 1. Capture device logs: xcodebuildmcp device start-device-log-capture --device-id "" --bundle-id "io.sentry.calculatorapp" -2. Stop app on device: xcodebuildmcp device stop --device-id "" --process-id "18692" +2. Stop app on device: xcodebuildmcp device stop --device-id "" --process-id "21293" diff --git a/src/snapshot-tests/__fixtures__/device/get-app-path--error-wrong-scheme.txt b/src/snapshot-tests/__fixtures__/device/get-app-path--error-wrong-scheme.txt index c0272359..287b8d83 100644 --- a/src/snapshot-tests/__fixtures__/device/get-app-path--error-wrong-scheme.txt +++ b/src/snapshot-tests/__fixtures__/device/get-app-path--error-wrong-scheme.txt @@ -6,5 +6,5 @@ Configuration: Debug Platform: iOS -โŒ xcodebuild[11262:15615870] Writing error result bundle to /var/folders/_t/2njffz894t57qpp76v1sw__h0000gn/T/ResultBundle_2026-26-03_14-24-0027.xcresult +โŒ xcodebuild[76609:16148643] Writing error result bundle to /var/folders/_t/2njffz894t57qpp76v1sw__h0000gn/T/ResultBundle_2026-26-03_21-45-0013.xcresult xcodebuild: error: The workspace named "CalculatorApp" does not contain a scheme named "NONEXISTENT". The "-list" option can be used to find the names of the schemes in the workspace. diff --git a/src/snapshot-tests/__fixtures__/device/get-app-path--success.txt b/src/snapshot-tests/__fixtures__/device/get-app-path--success.txt index 019cc1c2..b656eda6 100644 --- a/src/snapshot-tests/__fixtures__/device/get-app-path--success.txt +++ b/src/snapshot-tests/__fixtures__/device/get-app-path--success.txt @@ -6,5 +6,6 @@ Configuration: Debug Platform: iOS - โ”” App Path: /Library/Developer/Xcode/DerivedData/CalculatorApp-/Build/Products/Debug-iphoneos/CalculatorApp.app โœ… App path resolved. + + โ”” App Path: /Library/Developer/Xcode/DerivedData/CalculatorApp-/Build/Products/Debug-iphoneos/CalculatorApp.app diff --git a/src/snapshot-tests/__fixtures__/device/install--success.txt b/src/snapshot-tests/__fixtures__/device/install--success.txt new file mode 100644 index 00000000..7accbd02 --- /dev/null +++ b/src/snapshot-tests/__fixtures__/device/install--success.txt @@ -0,0 +1,7 @@ + +๐Ÿ“ฆ Install App + + Device: () + App: /Library/Developer/Xcode/DerivedData/CalculatorApp-/Build/Products/Debug-iphoneos/CalculatorApp.app + +โœ… App installed successfully. diff --git a/src/snapshot-tests/__fixtures__/device/launch--success.txt b/src/snapshot-tests/__fixtures__/device/launch--success.txt new file mode 100644 index 00000000..32a9c081 --- /dev/null +++ b/src/snapshot-tests/__fixtures__/device/launch--success.txt @@ -0,0 +1,9 @@ + +๐Ÿš€ Launch App + + Device: () + Bundle ID: io.sentry.calculatorapp + +โœ… App launched successfully. + + โ”” Process ID: diff --git a/src/snapshot-tests/__fixtures__/device/list--success.txt b/src/snapshot-tests/__fixtures__/device/list--success.txt index 7c768cde..3a19e9b4 100644 --- a/src/snapshot-tests/__fixtures__/device/list--success.txt +++ b/src/snapshot-tests/__fixtures__/device/list--success.txt @@ -2,6 +2,7 @@ ๐Ÿ“ฑ List Devices ๐ŸŸข Cameronโ€™s Appleย Watch + โ”œ UDID: โ”œ Model: Watch4,2 โ”œ Product Type: Watch4,2 @@ -11,6 +12,7 @@ โ”” Developer Mode: disabled ๐ŸŸข Cameronโ€™s Appleย Watch + โ”œ UDID: โ”œ Model: Watch7,20 โ”œ Product Type: Watch7,20 @@ -20,15 +22,17 @@ โ”” Developer Mode: disabled ๐ŸŸข Cameronโ€™s iPhone 16 Pro Max + โ”œ UDID: โ”œ Model: iPhone17,2 โ”œ Product Type: iPhone17,2 โ”œ Platform: Unknown 26.3.1 (a) โ”œ CPU Architecture: arm64e - โ”œ Connection: wired + โ”œ Connection: localNetwork โ”” Developer Mode: enabled ๐ŸŸข iPhone + โ”œ UDID: โ”œ Model: iPhone99,11 โ”œ Product Type: iPhone99,11 diff --git a/src/snapshot-tests/__fixtures__/device/stop--error-no-app.txt b/src/snapshot-tests/__fixtures__/device/stop--error-no-app.txt index 9f1c229d..76a7f793 100644 --- a/src/snapshot-tests/__fixtures__/device/stop--error-no-app.txt +++ b/src/snapshot-tests/__fixtures__/device/stop--error-no-app.txt @@ -2,7 +2,7 @@ ๐Ÿ›‘ Stop App Device: - PID: 99999 + PID: โŒ Failed to stop app: Failed to load provisioning paramter list due to error: Error Domain=com.apple.dt.CoreDeviceError Code=1002 "No provider was found." UserInfo={NSLocalizedDescription=No provider was found.}. `devicectl manage create` may support a reduced set of arguments. diff --git a/src/snapshot-tests/__fixtures__/device/stop--success.txt b/src/snapshot-tests/__fixtures__/device/stop--success.txt new file mode 100644 index 00000000..64805405 --- /dev/null +++ b/src/snapshot-tests/__fixtures__/device/stop--success.txt @@ -0,0 +1,7 @@ + +๐Ÿ›‘ Stop App + + Device: () + PID: + +โœ… App stopped successfully. diff --git a/src/snapshot-tests/__fixtures__/device/test--failure.txt b/src/snapshot-tests/__fixtures__/device/test--failure.txt index 64a251a0..9fa50bab 100644 --- a/src/snapshot-tests/__fixtures__/device/test--failure.txt +++ b/src/snapshot-tests/__fixtures__/device/test--failure.txt @@ -4,7 +4,7 @@ Scheme: CalculatorApp Configuration: Debug Platform: iOS - Device: + Device: () โœ— CalculatorAppTests.CalculatorAppTests/testCalculatorServiceFailure: XCTAssertEqual failed: ("0") is not equal to ("999") - This test should fail - display should be 0, not 999 example_projects/iOS_Calculator/CalculatorAppTests/CalculatorAppTests.swift:52 diff --git a/src/snapshot-tests/__fixtures__/device/test--success.txt b/src/snapshot-tests/__fixtures__/device/test--success.txt new file mode 100644 index 00000000..f14ef880 --- /dev/null +++ b/src/snapshot-tests/__fixtures__/device/test--success.txt @@ -0,0 +1,9 @@ + +๐Ÿงช Test + + Scheme: CalculatorApp + Configuration: Debug + Platform: iOS + Device: () + +โœ… Test succeeded. (, โฑ๏ธ ) diff --git a/src/snapshot-tests/__fixtures__/logging/start-device-log--error.txt b/src/snapshot-tests/__fixtures__/logging/start-device-log--error.txt new file mode 100644 index 00000000..e9ba3b76 --- /dev/null +++ b/src/snapshot-tests/__fixtures__/logging/start-device-log--error.txt @@ -0,0 +1,7 @@ + +๐Ÿ“ Start Log Capture + + Device: + Bundle ID: com.nonexistent.app + +โŒ Failed to start device log capture: Device log capture process exited immediately (exit code: 1) diff --git a/src/snapshot-tests/__fixtures__/logging/start-device-log--success.txt b/src/snapshot-tests/__fixtures__/logging/start-device-log--success.txt new file mode 100644 index 00000000..26b22ee1 --- /dev/null +++ b/src/snapshot-tests/__fixtures__/logging/start-device-log--success.txt @@ -0,0 +1,13 @@ + +๐Ÿ“ Start Log Capture + + Device: + Bundle ID: io.sentry.calculatorapp + +โœ… Log capture started. + + โ”œ Session ID: + โ”” Note: Do not call launch_app_device during this session + +Next steps: +1. Stop capture and retrieve logs: stop_device_log_cap({ logSessionId: "" }) diff --git a/src/snapshot-tests/__fixtures__/logging/start-sim-log--error.txt b/src/snapshot-tests/__fixtures__/logging/start-sim-log--error.txt deleted file mode 100644 index df4106f0..00000000 --- a/src/snapshot-tests/__fixtures__/logging/start-sim-log--error.txt +++ /dev/null @@ -1,14 +0,0 @@ - -๐Ÿ“ Start Log Capture - - Simulator: - Bundle ID: com.nonexistent.app - -Details - Session ID: - Only structured logs from the app subsystem are being captured. - Interact with your simulator and app, then stop capture to retrieve logs. -โœ… Log capture started. - -Next steps: -1. Stop capture and retrieve logs: stop_sim_log_cap({ logSessionId: "" }) diff --git a/src/snapshot-tests/__fixtures__/logging/start-sim-log--success.txt b/src/snapshot-tests/__fixtures__/logging/start-sim-log--success.txt new file mode 100644 index 00000000..326c8024 --- /dev/null +++ b/src/snapshot-tests/__fixtures__/logging/start-sim-log--success.txt @@ -0,0 +1,13 @@ + +๐Ÿ“ Start Log Capture + + Simulator: + Bundle ID: io.sentry.calculatorapp + +โœ… Log capture started. + + โ”œ Session ID: + โ”” Filter: Only structured logs from the app subsystem are being captured. + +Next steps: +1. Stop capture and retrieve logs: stop_sim_log_cap({ logSessionId: "" }) diff --git a/src/snapshot-tests/__fixtures__/logging/stop-device-log--error.txt b/src/snapshot-tests/__fixtures__/logging/stop-device-log--error.txt new file mode 100644 index 00000000..cbdaea86 --- /dev/null +++ b/src/snapshot-tests/__fixtures__/logging/stop-device-log--error.txt @@ -0,0 +1,6 @@ + +๐Ÿ“ Stop Log Capture + + Session ID: nonexistent-session-id + +โŒ Failed to stop device log capture session nonexistent-session-id: Device log capture session not found: nonexistent-session-id diff --git a/src/snapshot-tests/__fixtures__/logging/stop-device-log--success.txt b/src/snapshot-tests/__fixtures__/logging/stop-device-log--success.txt new file mode 100644 index 00000000..f7b1ec5d --- /dev/null +++ b/src/snapshot-tests/__fixtures__/logging/stop-device-log--success.txt @@ -0,0 +1,16 @@ + +๐Ÿ“ Stop Log Capture + + Session ID: + +โœ… Log capture stopped. + +Captured Logs + + --- Device log capture for bundle ID: io.sentry.calculatorapp on device: --- + 21:59:31 Acquired usage assertion. + Launched application with io.sentry.calculatorapp bundle identifier. + Waiting for the application to terminateโ€ฆ + App terminated due to signal 15. + + --- Device log capture ended (exit code: 1) --- diff --git a/src/snapshot-tests/__fixtures__/logging/stop-sim-log--success.txt b/src/snapshot-tests/__fixtures__/logging/stop-sim-log--success.txt new file mode 100644 index 00000000..ed86327e --- /dev/null +++ b/src/snapshot-tests/__fixtures__/logging/stop-sim-log--success.txt @@ -0,0 +1,12 @@ + +๐Ÿ“ Stop Log Capture + + Session ID: + +โœ… Log capture stopped. + +Captured Logs + + --- Log capture for bundle ID: io.sentry.calculatorapp --- + getpwuid_r did not find a match for uid 501 + Filtering the log data using "subsystem == "io.sentry.calculatorapp"" diff --git a/src/snapshot-tests/__fixtures__/macos/build-and-run--success.txt b/src/snapshot-tests/__fixtures__/macos/build-and-run--success.txt index 6222904d..fa2bb9fe 100644 --- a/src/snapshot-tests/__fixtures__/macos/build-and-run--success.txt +++ b/src/snapshot-tests/__fixtures__/macos/build-and-run--success.txt @@ -12,6 +12,7 @@ โœ… Build succeeded. (โฑ๏ธ ) โœ… Build & Run complete + โ”” App Path: /Library/Developer/Xcode/DerivedData/MCPTest-/Build/Products/Debug/MCPTest.app Next steps: diff --git a/src/snapshot-tests/__fixtures__/macos/get-app-path--success.txt b/src/snapshot-tests/__fixtures__/macos/get-app-path--success.txt index 1bc26b88..97b7494c 100644 --- a/src/snapshot-tests/__fixtures__/macos/get-app-path--success.txt +++ b/src/snapshot-tests/__fixtures__/macos/get-app-path--success.txt @@ -6,5 +6,6 @@ Configuration: Debug Platform: macOS - โ”” App Path: /Library/Developer/Xcode/DerivedData/MCPTest-/Build/Products/Debug/MCPTest.app โœ… App path resolved. + + โ”” App Path: /Library/Developer/Xcode/DerivedData/MCPTest-/Build/Products/Debug/MCPTest.app diff --git a/src/snapshot-tests/__fixtures__/project-discovery/discover-projs--success.txt b/src/snapshot-tests/__fixtures__/project-discovery/discover-projs--success.txt index 39281470..0e55e9c8 100644 --- a/src/snapshot-tests/__fixtures__/project-discovery/discover-projs--success.txt +++ b/src/snapshot-tests/__fixtures__/project-discovery/discover-projs--success.txt @@ -8,4 +8,3 @@ Projects Workspaces example_projects/iOS_Calculator/CalculatorApp.xcworkspace -โ„น๏ธ Save a default with session-set-defaults { projectPath: '...' } or { workspacePath: '...' }. diff --git a/src/snapshot-tests/__fixtures__/simulator/build-and-run--success.txt b/src/snapshot-tests/__fixtures__/simulator/build-and-run--success.txt index 0fe65ab0..6728ffa4 100644 --- a/src/snapshot-tests/__fixtures__/simulator/build-and-run--success.txt +++ b/src/snapshot-tests/__fixtures__/simulator/build-and-run--success.txt @@ -15,6 +15,7 @@ โœ… Build succeeded. (โฑ๏ธ ) โœ… Build & Run complete + โ”œ App Path: /Library/Developer/Xcode/DerivedData/CalculatorApp-/Build/Products/Debug-iphonesimulator/CalculatorApp.app โ”” Bundle ID: io.sentry.calculatorapp diff --git a/src/snapshot-tests/__fixtures__/simulator/get-app-path--success.txt b/src/snapshot-tests/__fixtures__/simulator/get-app-path--success.txt index faaeee17..313d93b0 100644 --- a/src/snapshot-tests/__fixtures__/simulator/get-app-path--success.txt +++ b/src/snapshot-tests/__fixtures__/simulator/get-app-path--success.txt @@ -7,5 +7,6 @@ Platform: iOS Simulator Simulator: iPhone 17 - โ”” App Path: /Library/Developer/Xcode/DerivedData/CalculatorApp-/Build/Products/Debug-iphonesimulator/CalculatorApp.app โœ… App path resolved + + โ”” App Path: /Library/Developer/Xcode/DerivedData/CalculatorApp-/Build/Products/Debug-iphonesimulator/CalculatorApp.app diff --git a/src/snapshot-tests/__fixtures__/simulator/test--failure.txt b/src/snapshot-tests/__fixtures__/simulator/test--failure.txt new file mode 100644 index 00000000..2e1dc881 --- /dev/null +++ b/src/snapshot-tests/__fixtures__/simulator/test--failure.txt @@ -0,0 +1,12 @@ + +๐Ÿงช Test + + Scheme: CalculatorApp + Configuration: Debug + Platform: iOS Simulator + Simulator: iPhone 17 + + โœ— CalculatorAppTests.CalculatorAppTests/testCalculatorServiceFailure: XCTAssertEqual failed: ("0") is not equal to ("999") - This test should fail - display should be 0, not 999 + example_projects/iOS_Calculator/CalculatorAppTests/CalculatorAppTests.swift:52 + +โŒ Test failed. (, โฑ๏ธ ) diff --git a/src/snapshot-tests/__fixtures__/simulator/test--success.txt b/src/snapshot-tests/__fixtures__/simulator/test--success.txt index 2e1dc881..9b7c8b5e 100644 --- a/src/snapshot-tests/__fixtures__/simulator/test--success.txt +++ b/src/snapshot-tests/__fixtures__/simulator/test--success.txt @@ -6,7 +6,4 @@ Platform: iOS Simulator Simulator: iPhone 17 - โœ— CalculatorAppTests.CalculatorAppTests/testCalculatorServiceFailure: XCTAssertEqual failed: ("0") is not equal to ("999") - This test should fail - display should be 0, not 999 - example_projects/iOS_Calculator/CalculatorAppTests/CalculatorAppTests.swift:52 - -โŒ Test failed. (, โฑ๏ธ ) +โœ… Test succeeded. (, โฑ๏ธ ) diff --git a/src/snapshot-tests/__fixtures__/swift-package/stop--error-no-process.txt b/src/snapshot-tests/__fixtures__/swift-package/stop--error-no-process.txt index 12294168..4e59cb21 100644 --- a/src/snapshot-tests/__fixtures__/swift-package/stop--error-no-process.txt +++ b/src/snapshot-tests/__fixtures__/swift-package/stop--error-no-process.txt @@ -1,6 +1,6 @@ ๐Ÿ›‘ Swift Package Stop - PID: 999999 + PID: โŒ No running process found with PID 999999. Use swift_package_list to check active processes. diff --git a/src/snapshot-tests/__tests__/device.snapshot.test.ts b/src/snapshot-tests/__tests__/device.snapshot.test.ts index a2cbaa1e..b1dc67d4 100644 --- a/src/snapshot-tests/__tests__/device.snapshot.test.ts +++ b/src/snapshot-tests/__tests__/device.snapshot.test.ts @@ -1,9 +1,11 @@ import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; +import { execSync } from 'node:child_process'; import { createSnapshotHarness } from '../harness.ts'; import { expectMatchesFixture } from '../fixture-io.ts'; import type { SnapshotHarness } from '../harness.ts'; const WORKSPACE = 'example_projects/iOS_Calculator/CalculatorApp.xcworkspace'; +const BUNDLE_ID = 'io.sentry.calculatorapp'; const DEVICE_ID = process.env.DEVICE_ID; describe('device workflow', () => { @@ -115,7 +117,82 @@ describe('device workflow', () => { }); }); + describe.runIf(DEVICE_ID)('install (requires device)', () => { + it('success', async () => { + const appPathOutput = execSync( + [ + 'xcodebuild -workspace', WORKSPACE, + '-scheme CalculatorApp', + `-destination 'id=${DEVICE_ID}'`, + '-showBuildSettings', + ].join(' '), + { encoding: 'utf8', timeout: 30_000, stdio: 'pipe' }, + ); + const builtProductsDir = appPathOutput + .split('\n') + .find((l) => l.includes('BUILT_PRODUCTS_DIR')) + ?.split('=')[1] + ?.trim(); + const appPath = `${builtProductsDir}/CalculatorApp.app`; + + const { text, isError } = await harness.invoke('device', 'install', { + deviceId: DEVICE_ID, + appPath, + }); + expect(isError).toBe(false); + expectMatchesFixture(text, __filename, 'install--success'); + }, 60_000); + }); + + describe.runIf(DEVICE_ID)('launch (requires device)', () => { + it('success', async () => { + const { text, isError } = await harness.invoke('device', 'launch', { + deviceId: DEVICE_ID, + bundleId: BUNDLE_ID, + }); + expect(isError).toBe(false); + expectMatchesFixture(text, __filename, 'launch--success'); + }, 60_000); + }); + + describe.runIf(DEVICE_ID)('stop (requires device)', () => { + it('success', async () => { + const tmpJson = `/tmp/devicectl-launch-${Date.now()}.json`; + execSync( + `xcrun devicectl device process launch --device ${DEVICE_ID} ${BUNDLE_ID} --json-output ${tmpJson}`, + { encoding: 'utf8', timeout: 30_000, stdio: 'pipe' }, + ); + const launchData = JSON.parse( + require('fs').readFileSync(tmpJson, 'utf8'), + ); + require('fs').unlinkSync(tmpJson); + const pid = launchData?.result?.process?.processIdentifier; + expect(pid).toBeGreaterThan(0); + + await new Promise((resolve) => setTimeout(resolve, 2000)); + + const { text, isError } = await harness.invoke('device', 'stop', { + deviceId: DEVICE_ID, + processId: pid, + }); + expect(isError).toBe(false); + expectMatchesFixture(text, __filename, 'stop--success'); + }, 60_000); + }); + describe.runIf(DEVICE_ID)('test (requires device)', () => { + it('success - targeted passing test', async () => { + const { text, isError } = await harness.invoke('device', 'test', { + workspacePath: WORKSPACE, + scheme: 'CalculatorApp', + deviceId: DEVICE_ID, + extraArgs: ['-only-testing:CalculatorAppTests/CalculatorAppTests/testAddition'], + }); + expect(isError).toBe(false); + expect(text.length).toBeGreaterThan(10); + expectMatchesFixture(text, __filename, 'test--success'); + }, 300_000); + it('failure - intentional test failure', async () => { const { text, isError } = await harness.invoke('device', 'test', { workspacePath: WORKSPACE, diff --git a/src/snapshot-tests/__tests__/logging.snapshot.test.ts b/src/snapshot-tests/__tests__/logging.snapshot.test.ts index 6603b332..5b5193f2 100644 --- a/src/snapshot-tests/__tests__/logging.snapshot.test.ts +++ b/src/snapshot-tests/__tests__/logging.snapshot.test.ts @@ -1,27 +1,43 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; -import { createSnapshotHarness } from '../harness.ts'; +import { spawnSync } from 'node:child_process'; +import path from 'node:path'; +import { createSnapshotHarness, ensureSimulatorBooted } from '../harness.ts'; import { expectMatchesFixture } from '../fixture-io.ts'; import type { SnapshotHarness } from '../harness.ts'; +const CLI_PATH = path.resolve(process.cwd(), 'build/cli.js'); +const UUID_PATTERN = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i; +const DEVICE_ID = process.env.DEVICE_ID; +const BUNDLE_ID = 'io.sentry.calculatorapp'; + +function extractSessionId(stdout: string): string { + const line = stdout.split('\n').find((l) => l.includes('Session ID:')); + const match = line?.match(UUID_PATTERN); + return match![0]; +} + describe('logging workflow', () => { let harness: SnapshotHarness; + let simulatorUdid: string; beforeAll(async () => { + simulatorUdid = await ensureSimulatorBooted('iPhone 17'); harness = await createSnapshotHarness(); - }); + }, 30_000); afterAll(() => { harness.cleanup(); }); describe('start-simulator-log-capture', () => { - it('error - invalid session params', async () => { - const { text } = await harness.invoke('logging', 'start-simulator-log-capture', { - simulatorId: '00000000-0000-0000-0000-000000000000', - bundleId: 'com.nonexistent.app', + it('success', async () => { + const { text, isError } = await harness.invoke('logging', 'start-simulator-log-capture', { + simulatorId: simulatorUdid, + bundleId: BUNDLE_ID, }); + expect(isError).toBe(false); expect(text.length).toBeGreaterThan(0); - expectMatchesFixture(text, __filename, 'start-sim-log--error'); + expectMatchesFixture(text, __filename, 'start-sim-log--success'); }, 30_000); }); @@ -33,5 +49,89 @@ describe('logging workflow', () => { expect(isError).toBe(true); expectMatchesFixture(text, __filename, 'stop-sim-log--error'); }, 30_000); + + it('success', async () => { + const { VITEST, NODE_ENV, ...cleanEnv } = process.env; + + const startArgs = JSON.stringify({ + simulatorId: simulatorUdid, + bundleId: BUNDLE_ID, + }); + const rawStart = spawnSync( + 'node', + [CLI_PATH, 'logging', 'start-simulator-log-capture', '--json', startArgs], + { encoding: 'utf8', timeout: 30_000, cwd: process.cwd(), env: cleanEnv }, + ); + expect(rawStart.status).toBe(0); + + const sessionId = extractSessionId(rawStart.stdout); + expect(sessionId).toBeTruthy(); + + await new Promise((resolve) => setTimeout(resolve, 2000)); + + const { text, isError } = await harness.invoke('logging', 'stop-simulator-log-capture', { + logSessionId: sessionId, + }); + expect(isError).toBe(false); + expectMatchesFixture(text, __filename, 'stop-sim-log--success'); + }, 30_000); + }); + + describe('start-device-log-capture', () => { + it('error - invalid device', async () => { + const { text, isError } = await harness.invoke('logging', 'start-device-log-capture', { + deviceId: '00000000-0000-0000-0000-000000000000', + bundleId: 'com.nonexistent.app', + }); + expect(isError).toBe(true); + expectMatchesFixture(text, __filename, 'start-device-log--error'); + }, 30_000); + }); + + describe('stop-device-log-capture', () => { + it('error - no session', async () => { + const { text, isError } = await harness.invoke('logging', 'stop-device-log-capture', { + logSessionId: 'nonexistent-session-id', + }); + expect(isError).toBe(true); + expectMatchesFixture(text, __filename, 'stop-device-log--error'); + }, 30_000); + }); + + describe.runIf(DEVICE_ID)('start-device-log-capture (requires device)', () => { + it('success', async () => { + const { text, isError } = await harness.invoke('logging', 'start-device-log-capture', { + deviceId: DEVICE_ID, + bundleId: BUNDLE_ID, + }); + expect(isError).toBe(false); + expect(text.length).toBeGreaterThan(0); + expectMatchesFixture(text, __filename, 'start-device-log--success'); + }, 60_000); + }); + + describe.runIf(DEVICE_ID)('stop-device-log-capture (requires device)', () => { + it('success', async () => { + const { VITEST, NODE_ENV, ...cleanEnv } = process.env; + + const startArgs = JSON.stringify({ deviceId: DEVICE_ID, bundleId: BUNDLE_ID }); + const rawStart = spawnSync( + 'node', + [CLI_PATH, 'logging', 'start-device-log-capture', '--json', startArgs], + { encoding: 'utf8', timeout: 60_000, cwd: process.cwd(), env: cleanEnv }, + ); + expect(rawStart.status).toBe(0); + + const sessionId = extractSessionId(rawStart.stdout); + expect(sessionId).toBeTruthy(); + + await new Promise((resolve) => setTimeout(resolve, 3000)); + + const { text, isError } = await harness.invoke('logging', 'stop-device-log-capture', { + logSessionId: sessionId, + }); + expect(isError).toBe(false); + expectMatchesFixture(text, __filename, 'stop-device-log--success'); + }, 60_000); }); }); diff --git a/src/snapshot-tests/__tests__/simulator.snapshot.test.ts b/src/snapshot-tests/__tests__/simulator.snapshot.test.ts index 58a0f2eb..02c347d8 100644 --- a/src/snapshot-tests/__tests__/simulator.snapshot.test.ts +++ b/src/snapshot-tests/__tests__/simulator.snapshot.test.ts @@ -61,11 +61,23 @@ describe('simulator workflow', () => { workspacePath: WORKSPACE, scheme: 'CalculatorApp', simulatorName: 'iPhone 17', + extraArgs: ['-only-testing:CalculatorAppTests/CalculatorAppTests/testAddition'], }); - expect(isError).toBe(true); + expect(isError).toBe(false); expect(text.length).toBeGreaterThan(10); expectMatchesFixture(text, __filename, 'test--success'); }, 120_000); + + it('failure - intentional test failure', async () => { + const { text, isError } = await harness.invoke('simulator', 'test', { + workspacePath: WORKSPACE, + scheme: 'CalculatorApp', + simulatorName: 'iPhone 17', + }); + expect(isError).toBe(true); + expect(text.length).toBeGreaterThan(10); + expectMatchesFixture(text, __filename, 'test--failure'); + }, 120_000); }); describe('get-app-path', () => { diff --git a/src/snapshot-tests/normalize.ts b/src/snapshot-tests/normalize.ts index 27608117..5f389bc7 100644 --- a/src/snapshot-tests/normalize.ts +++ b/src/snapshot-tests/normalize.ts @@ -5,11 +5,11 @@ const ANSI_REGEX = /\x1B\[[0-9;]*[mK]/g; const ISO_TIMESTAMP_REGEX = /\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?Z/g; const UUID_REGEX = /[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}/g; const DURATION_REGEX = /\d+\.\d+s\b/g; -const PID_NUMBER_REGEX = /pid:\s*\d+/g; +const PID_NUMBER_REGEX = /pid:\s*\d+/gi; const PID_JSON_REGEX = /"pid"\s*:\s*\d+/g; const PROCESS_ID_REGEX = /Process ID: \d+/g; const DERIVED_DATA_HASH_REGEX = /(DerivedData\/[A-Za-z0-9_]+)-[a-z]{28}\b/g; -const PROGRESS_LINE_REGEX = /^โ€บ.*\n?/gm; +const PROGRESS_LINE_REGEX = /^โ€บ.*\n*/gm; const WARNINGS_BLOCK_REGEX = /Warnings \(\d+\):\n(?:\n? *โš [^\n]*\n?)*/g; const TEST_DISCOVERY_REGEX = /Resolved to \d+ test\(s\):\n(?:\s*-\s+[^\n]+\n)*(?:\s*\.\.\. and \d+ more\n)?/g; @@ -71,8 +71,9 @@ export function normalizeSnapshotOutput(text: string): string { normalized = normalized.replace(DERIVED_DATA_HASH_REGEX, '$1-'); normalized = normalized.replace(ISO_TIMESTAMP_REGEX, ''); normalized = normalized.replace(UUID_REGEX, ''); + normalized = normalized.replace(/Device: .+ \(\)/g, 'Device: ()'); normalized = normalized.replace(DURATION_REGEX, ''); - normalized = normalized.replace(PID_NUMBER_REGEX, 'pid: '); + normalized = normalized.replace(PID_NUMBER_REGEX, (match) => match.replace(/\d+/, '')); normalized = normalized.replace(PID_JSON_REGEX, '"pid" : '); normalized = normalized.replace(PROCESS_ID_REGEX, 'Process ID: '); normalized = normalized.replace(PROGRESS_LINE_REGEX, ''); diff --git a/src/utils/build-utils.ts b/src/utils/build-utils.ts index 4a26833e..0601d04e 100644 --- a/src/utils/build-utils.ts +++ b/src/utils/build-utils.ts @@ -118,7 +118,6 @@ export async function executeXcodeBuildCommand( command.push('-scheme', params.scheme); command.push('-configuration', params.configuration); command.push('-skipMacroValidation'); - command.push('-allowProvisioningUpdates'); let destinationString: string; const isSimulatorPlatform = [ diff --git a/src/utils/command.ts b/src/utils/command.ts index a02a5cb2..9d307e65 100644 --- a/src/utils/command.ts +++ b/src/utils/command.ts @@ -46,6 +46,13 @@ async function defaultExecutor( useShell && escapedCommand.length === 3 ? escapedCommand[2] : [executable, ...args].join(' '); log('debug', `Executing ${logPrefix ?? ''} command: ${displayCommand}`); + const verbose = process.env.XCODEBUILDMCP_VERBOSE === '1'; + if (verbose) { + const dim = process.stderr.isTTY ? '\x1B[2m' : ''; + const reset = process.stderr.isTTY ? '\x1B[0m' : ''; + process.stderr.write(`${dim}$ ${displayCommand}${reset}\n`); + } + const spawnOpts: Parameters[2] = { stdio: ['ignore', 'pipe', 'pipe'], env: { ...process.env, ...(opts?.env ?? {}) }, @@ -69,6 +76,11 @@ async function defaultExecutor( const childProcess = spawn(executable, args, spawnOpts); + if (verbose) { + childProcess.stdout?.pipe(process.stderr, { end: false }); + childProcess.stderr?.pipe(process.stderr, { end: false }); + } + let stdout = ''; let stderr = ''; diff --git a/src/utils/device-name-resolver.ts b/src/utils/device-name-resolver.ts new file mode 100644 index 00000000..dc3fef33 --- /dev/null +++ b/src/utils/device-name-resolver.ts @@ -0,0 +1,54 @@ +import { execSync } from 'node:child_process'; +import { readFileSync, unlinkSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +interface DeviceInfo { + identifier: string; + name: string; +} + +let cachedDevices: Map | null = null; + +function loadDeviceNames(): Map { + if (cachedDevices) 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?: Array<{ identifier: string; deviceProperties: { name: string } }> }; + }; + + for (const device of data.result?.devices ?? []) { + map.set(device.identifier, device.deviceProperties.name); + } + } catch { + // Device list unavailable โ€” return empty map, will fall back to UUID only + } finally { + try { + unlinkSync(tmpFile); + } catch { + // ignore + } + } + + cachedDevices = map; + return map; +} + +export function formatDeviceId(deviceId: string): string { + const names = loadDeviceNames(); + const name = names.get(deviceId); + if (name) { + return `${name} (${deviceId})`; + } + return deviceId; +} diff --git a/src/utils/renderers/cli-text-renderer.ts b/src/utils/renderers/cli-text-renderer.ts index 2d73131f..79be9485 100644 --- a/src/utils/renderers/cli-text-renderer.ts +++ b/src/utils/renderers/cli-text-renderer.ts @@ -97,7 +97,7 @@ export function createCliTextRenderer(options: { interactive: boolean }): Xcodeb } case 'detail-tree': { - writeDurable(formatDetailTreeEvent(event)); + writeSection(formatDetailTreeEvent(event)); break; } diff --git a/src/utils/renderers/index.ts b/src/utils/renderers/index.ts index 678bf7f2..14d5b836 100644 --- a/src/utils/renderers/index.ts +++ b/src/utils/renderers/index.ts @@ -28,7 +28,7 @@ export function resolveRenderers(): { const runtime = process.env.XCODEBUILDMCP_RUNTIME; const outputFormat = process.env.XCODEBUILDMCP_CLI_OUTPUT_FORMAT; - if (runtime === 'cli') { + if (runtime === 'cli' && process.env.XCODEBUILDMCP_VERBOSE !== '1') { if (outputFormat === 'json') { renderers.push(createCliJsonlRenderer()); } else { diff --git a/src/utils/renderers/mcp-renderer.ts b/src/utils/renderers/mcp-renderer.ts index f2b8aed3..b9dcba54 100644 --- a/src/utils/renderers/mcp-renderer.ts +++ b/src/utils/renderers/mcp-renderer.ts @@ -65,7 +65,7 @@ export function createMcpRenderer(): XcodebuildRenderer & { } case 'detail-tree': { - pushText(formatDetailTreeEvent(event)); + pushSection(formatDetailTreeEvent(event)); break; } diff --git a/src/utils/xcodebuild-pipeline.ts b/src/utils/xcodebuild-pipeline.ts index 31f1cea6..65863d8c 100644 --- a/src/utils/xcodebuild-pipeline.ts +++ b/src/utils/xcodebuild-pipeline.ts @@ -10,6 +10,7 @@ import type { XcodebuildRunState } from './xcodebuild-run-state.ts'; import { resolveRenderers } from './renderers/index.ts'; import type { XcodebuildRenderer } from './renderers/index.ts'; import { displayPath } from './build-preflight.ts'; +import { formatDeviceId } from './device-name-resolver.ts'; export interface PipelineOptions { operation: XcodebuildOperation; @@ -76,7 +77,14 @@ function buildHeaderParams( if (key === 'simulatorId' && typeof params.simulatorName === 'string') { continue; } - const displayValue = pathKeys.has(key) ? displayPath(value) : value; + 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 }); } } From 5178dc301ec15691cd3a6e71e82bc873c2d8d73f Mon Sep 17 00:00:00 2001 From: Cameron Cooke Date: Fri, 27 Mar 2026 18:30:39 +0000 Subject: [PATCH 08/50] WIP --- .../macOS/MCPTestTests/MCPTestTests.swift | 15 +- .../spm/Sources/quick-task/main.swift | 2 +- .../spm/Tests/TestLibTests/SimpleTests.swift | 5 + src/mcp/tools/debugging/debug_attach_sim.ts | 31 +-- src/mcp/tools/device/launch_app_device.ts | 13 +- src/mcp/tools/doctor/doctor.ts | 2 +- .../macos/__tests__/stop_mac_app.test.ts | 6 +- src/mcp/tools/macos/stop_mac_app.ts | 17 +- .../tools/project-discovery/discover_projs.ts | 3 +- .../__tests__/launch_app_logs_sim.test.ts | 4 +- .../__tests__/launch_app_sim.test.ts | 10 +- src/mcp/tools/simulator/record_sim_video.ts | 9 +- .../__tests__/swift_package_test.test.ts | 153 ++++--------- .../tools/swift-package/swift_package_test.ts | 43 ++-- src/mcp/tools/ui-automation/long_press.ts | 6 +- src/mcp/tools/ui-automation/swipe.ts | 6 +- src/mcp/tools/ui-automation/tap.ts | 6 +- src/mcp/tools/ui-automation/touch.ts | 6 +- ...-coverage-report--error-invalid-bundle.txt | 2 +- ...et-file-coverage--error-invalid-bundle.txt | 2 +- .../debugging/attach--success-continue.txt | 13 ++ .../debugging/attach--success.txt | 2 +- .../debugging/lldb-command--success.txt | 4 +- .../__fixtures__/debugging/stack--success.txt | 36 ++-- .../device/build-and-run--success.txt | 2 +- .../get-app-path--error-wrong-scheme.txt | 2 +- .../__fixtures__/device/test--failure.txt | 2 - .../logging/stop-device-log--success.txt | 2 +- .../macos/build--error-wrong-scheme.txt | 12 ++ .../build-and-run--error-wrong-scheme.txt | 12 ++ .../get-app-path--error-wrong-scheme.txt | 10 + ...get-macos-bundle-id--error-missing-app.txt | 6 + .../macos/get-macos-bundle-id--success.txt | 4 +- .../macos/launch--error-invalid-app.txt | 4 +- .../__fixtures__/macos/launch--success.txt | 6 + .../__fixtures__/macos/stop--error-no-app.txt | 4 +- .../__fixtures__/macos/stop--success.txt | 6 + .../macos/test--error-wrong-scheme.txt | 12 ++ .../__fixtures__/macos/test--failure.txt | 11 + .../discover-projs--error-invalid-root.txt | 4 + .../get-app-bundle-id--error-missing-app.txt | 6 + ...get-macos-bundle-id--error-missing-app.txt | 6 + .../get-macos-bundle-id--success.txt | 4 +- .../list-schemes--error-invalid-workspace.txt | 7 + ...how-build-settings--error-wrong-scheme.txt | 8 + .../scaffold-macos--error-existing.txt | 8 + .../erase--error-invalid-id.txt | 6 + ...eset-location--error-invalid-simulator.txt | 6 + ...et-appearance--error-invalid-simulator.txt | 7 + .../set-location--error-invalid-simulator.txt | 7 + .../statusbar--error-invalid-simulator.txt | 7 + .../build-and-run--error-wrong-scheme.txt | 12 ++ .../get-app-path--error-wrong-scheme.txt | 19 ++ .../simulator/install--success.txt | 7 + .../launch-app--error-not-installed.txt | 7 + .../screenshot--error-invalid-simulator.txt | 6 + .../__fixtures__/simulator/stop--success.txt | 7 + .../simulator/test--error-wrong-scheme.txt | 13 ++ .../swift-package/clean--error-bad-path.txt | 6 + .../run--error-bad-executable.txt | 12 ++ .../swift-package/test--error-bad-path.txt | 12 ++ .../swift-package/test--failure.txt | 12 ++ .../swift-package/test--success.txt | 25 +-- .../button--error-no-simulator.txt | 9 + .../gesture--error-no-simulator.txt | 9 + .../key-press--error-no-simulator.txt | 9 + .../key-sequence--error-no-simulator.txt | 9 + .../long-press--error-no-simulator.txt | 9 + .../snapshot-ui--error-no-simulator.txt | 9 + .../swipe--error-no-simulator.txt | 9 + .../touch--error-no-simulator.txt | 9 + .../type-text--error-no-simulator.txt | 9 + .../utilities/clean--error-wrong-scheme.txt | 12 ++ .../__tests__/debugging.snapshot.test.ts | 22 ++ .../__tests__/logging.snapshot.test.ts | 2 + .../__tests__/macos.snapshot.test.ts | 106 ++++++++- .../project-discovery.snapshot.test.ts | 57 ++++- .../project-scaffolding.snapshot.test.ts | 21 ++ .../simulator-management.snapshot.test.ts | 46 +++- .../__tests__/simulator.snapshot.test.ts | 82 ++++++- .../__tests__/swift-package.snapshot.test.ts | 35 +++ .../__tests__/ui-automation.snapshot.test.ts | 88 ++++++++ .../__tests__/utilities.snapshot.test.ts | 9 + src/snapshot-tests/normalize.ts | 12 ++ src/utils/__tests__/build-utils.test.ts | 16 +- .../swift-testing-line-parsers.test.ts | 157 ++++++++++++++ src/utils/swift-testing-event-parser.ts | 201 ++++++++++++++++++ src/utils/swift-testing-line-parsers.ts | 149 +++++++++++++ src/utils/test-common.ts | 18 +- src/utils/video-capture/index.ts | 2 + src/utils/video_capture.ts | 28 ++- src/utils/xcodebuild-event-parser.ts | 130 ++++++++++- src/utils/xcodebuild-pipeline.ts | 5 + src/utils/xcresult-test-failures.ts | 80 +++++++ 94 files changed, 1784 insertions(+), 280 deletions(-) create mode 100644 src/snapshot-tests/__fixtures__/debugging/attach--success-continue.txt create mode 100644 src/snapshot-tests/__fixtures__/macos/build--error-wrong-scheme.txt create mode 100644 src/snapshot-tests/__fixtures__/macos/build-and-run--error-wrong-scheme.txt create mode 100644 src/snapshot-tests/__fixtures__/macos/get-app-path--error-wrong-scheme.txt create mode 100644 src/snapshot-tests/__fixtures__/macos/get-macos-bundle-id--error-missing-app.txt create mode 100644 src/snapshot-tests/__fixtures__/macos/launch--success.txt create mode 100644 src/snapshot-tests/__fixtures__/macos/stop--success.txt create mode 100644 src/snapshot-tests/__fixtures__/macos/test--error-wrong-scheme.txt create mode 100644 src/snapshot-tests/__fixtures__/macos/test--failure.txt create mode 100644 src/snapshot-tests/__fixtures__/project-discovery/discover-projs--error-invalid-root.txt create mode 100644 src/snapshot-tests/__fixtures__/project-discovery/get-app-bundle-id--error-missing-app.txt create mode 100644 src/snapshot-tests/__fixtures__/project-discovery/get-macos-bundle-id--error-missing-app.txt create mode 100644 src/snapshot-tests/__fixtures__/project-discovery/list-schemes--error-invalid-workspace.txt create mode 100644 src/snapshot-tests/__fixtures__/project-discovery/show-build-settings--error-wrong-scheme.txt create mode 100644 src/snapshot-tests/__fixtures__/project-scaffolding/scaffold-macos--error-existing.txt create mode 100644 src/snapshot-tests/__fixtures__/simulator-management/erase--error-invalid-id.txt create mode 100644 src/snapshot-tests/__fixtures__/simulator-management/reset-location--error-invalid-simulator.txt create mode 100644 src/snapshot-tests/__fixtures__/simulator-management/set-appearance--error-invalid-simulator.txt create mode 100644 src/snapshot-tests/__fixtures__/simulator-management/set-location--error-invalid-simulator.txt create mode 100644 src/snapshot-tests/__fixtures__/simulator-management/statusbar--error-invalid-simulator.txt create mode 100644 src/snapshot-tests/__fixtures__/simulator/build-and-run--error-wrong-scheme.txt create mode 100644 src/snapshot-tests/__fixtures__/simulator/get-app-path--error-wrong-scheme.txt create mode 100644 src/snapshot-tests/__fixtures__/simulator/install--success.txt create mode 100644 src/snapshot-tests/__fixtures__/simulator/launch-app--error-not-installed.txt create mode 100644 src/snapshot-tests/__fixtures__/simulator/screenshot--error-invalid-simulator.txt create mode 100644 src/snapshot-tests/__fixtures__/simulator/stop--success.txt create mode 100644 src/snapshot-tests/__fixtures__/simulator/test--error-wrong-scheme.txt create mode 100644 src/snapshot-tests/__fixtures__/swift-package/clean--error-bad-path.txt create mode 100644 src/snapshot-tests/__fixtures__/swift-package/run--error-bad-executable.txt create mode 100644 src/snapshot-tests/__fixtures__/swift-package/test--error-bad-path.txt create mode 100644 src/snapshot-tests/__fixtures__/swift-package/test--failure.txt create mode 100644 src/snapshot-tests/__fixtures__/ui-automation/button--error-no-simulator.txt create mode 100644 src/snapshot-tests/__fixtures__/ui-automation/gesture--error-no-simulator.txt create mode 100644 src/snapshot-tests/__fixtures__/ui-automation/key-press--error-no-simulator.txt create mode 100644 src/snapshot-tests/__fixtures__/ui-automation/key-sequence--error-no-simulator.txt create mode 100644 src/snapshot-tests/__fixtures__/ui-automation/long-press--error-no-simulator.txt create mode 100644 src/snapshot-tests/__fixtures__/ui-automation/snapshot-ui--error-no-simulator.txt create mode 100644 src/snapshot-tests/__fixtures__/ui-automation/swipe--error-no-simulator.txt create mode 100644 src/snapshot-tests/__fixtures__/ui-automation/touch--error-no-simulator.txt create mode 100644 src/snapshot-tests/__fixtures__/ui-automation/type-text--error-no-simulator.txt create mode 100644 src/snapshot-tests/__fixtures__/utilities/clean--error-wrong-scheme.txt create mode 100644 src/utils/__tests__/swift-testing-line-parsers.test.ts create mode 100644 src/utils/swift-testing-event-parser.ts create mode 100644 src/utils/swift-testing-line-parsers.ts create mode 100644 src/utils/xcresult-test-failures.ts diff --git a/example_projects/macOS/MCPTestTests/MCPTestTests.swift b/example_projects/macOS/MCPTestTests/MCPTestTests.swift index afce860a..be41aec1 100644 --- a/example_projects/macOS/MCPTestTests/MCPTestTests.swift +++ b/example_projects/macOS/MCPTestTests/MCPTestTests.swift @@ -1,16 +1,13 @@ -// -// MCPTestTests.swift -// MCPTestTests -// -// Created by Cameron on 15/12/2025. -// - import Testing struct MCPTestTests { - @Test func example() async throws { - // Write your test here and use APIs like `#expect(...)` to check expected conditions. + @Test func appNameIsCorrect() async throws { + let expected = "MCPTest" + #expect(expected == "MCPTest") } + @Test func deliberateFailure() async throws { + #expect(1 == 2, "This test is designed to fail for snapshot testing") + } } diff --git a/example_projects/spm/Sources/quick-task/main.swift b/example_projects/spm/Sources/quick-task/main.swift index 1a22bb9e..76bb8dbd 100644 --- a/example_projects/spm/Sources/quick-task/main.swift +++ b/example_projects/spm/Sources/quick-task/main.swift @@ -33,4 +33,4 @@ struct QuickTask: AsyncParsableCommand { print("โœ… Quick task completed successfully!") } } -} \ No newline at end of file +} diff --git a/example_projects/spm/Tests/TestLibTests/SimpleTests.swift b/example_projects/spm/Tests/TestLibTests/SimpleTests.swift index 27bf893f..c16c3fd1 100644 --- a/example_projects/spm/Tests/TestLibTests/SimpleTests.swift +++ b/example_projects/spm/Tests/TestLibTests/SimpleTests.swift @@ -42,3 +42,8 @@ func optionalTest() { #expect(nilValue == nil) #expect(someValue! == 42) } + +@Test("Expected failure") +func testFail() { + #expect(true == false, "This test should fail, and is for simulating a test failure") +} diff --git a/src/mcp/tools/debugging/debug_attach_sim.ts b/src/mcp/tools/debugging/debug_attach_sim.ts index 019cf218..b092b081 100644 --- a/src/mcp/tools/debugging/debug_attach_sim.ts +++ b/src/mcp/tools/debugging/debug_attach_sim.ts @@ -124,17 +124,21 @@ export async function debug_attach_simLogic( await debuggerManager.resumeSession(session.id); } catch (error) { const message = error instanceof Error ? error.message : String(error); - try { - await debuggerManager.detachSession(session.id); - } catch (detachError) { - const detachMessage = - detachError instanceof Error ? detachError.message : String(detachError); - log('warn', `Failed to detach debugger session after resume failure: ${detachMessage}`); + if (/not\s*stopped/i.test(message)) { + log('debug', 'Process already running after attach, no resume needed'); + } else { + try { + await debuggerManager.detachSession(session.id); + } catch (detachError) { + const detachMessage = + detachError instanceof Error ? detachError.message : String(detachError); + log('warn', `Failed to detach debugger session after resume failure: ${detachMessage}`); + } + return toolResponse([ + headerEvent, + statusLine('error', `Failed to resume debugger after attach: ${message}`), + ]); } - return toolResponse([ - headerEvent, - statusLine('error', `Failed to resume debugger after attach: ${message}`), - ]); } } @@ -142,8 +146,11 @@ export async function debug_attach_simLogic( const currentText = isCurrent ? 'This session is now the current debug session.' : 'This session is not set as the current session.'; - const resumeText = shouldContinue - ? 'Execution resumed after attach.' + + const execState = await debuggerManager.getExecutionState(session.id); + const isRunning = execState.status === 'running' || execState.status === 'unknown'; + const resumeText = isRunning + ? 'Execution is running. App is responsive to UI interaction.' : 'Execution is paused. Use debug_continue to resume before UI automation.'; const events = [ diff --git a/src/mcp/tools/device/launch_app_device.ts b/src/mcp/tools/device/launch_app_device.ts index 329ceca0..920d3937 100644 --- a/src/mcp/tools/device/launch_app_device.ts +++ b/src/mcp/tools/device/launch_app_device.ts @@ -20,6 +20,7 @@ import { import { join } from 'path'; import { toolResponse } from '../../../utils/tool-response.ts'; import { header, statusLine, detailTree } from '../../../utils/tool-event-builders.ts'; +import type { PipelineEvent } from '../../../types/pipeline-events.ts'; import { formatDeviceId } from '../../../utils/device-name-resolver.ts'; type LaunchDataResponse = { @@ -105,16 +106,20 @@ export async function launch_app_deviceLogic( await fileSystem.rm(tempJsonPath, { force: true }).catch(() => {}); } - const events = [headerEvent]; - events.push(statusLine('success', 'App launched successfully.')); + const events: PipelineEvent[] = [ + headerEvent, + statusLine('success', 'App launched successfully.'), + ]; - if (processId) { + if (processId !== undefined) { events.push(detailTree([{ label: 'Process ID', value: processId.toString() }])); } return toolResponse( events, - processId ? { nextStepParams: { stop_app_device: { deviceId, processId } } } : undefined, + processId !== undefined + ? { nextStepParams: { stop_app_device: { deviceId, processId } } } + : undefined, ); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); diff --git a/src/mcp/tools/doctor/doctor.ts b/src/mcp/tools/doctor/doctor.ts index 02865db8..d85f1f42 100644 --- a/src/mcp/tools/doctor/doctor.ts +++ b/src/mcp/tools/doctor/doctor.ts @@ -263,7 +263,7 @@ export async function runDoctor( : (sanitizeValue(doctorInfoRaw, '', projectNames, piiTerms) as typeof doctorInfoRaw); const events: PipelineEvent[] = [ - header('Doctor', [ + header('XcodeBuildMCP Doctor', [ { label: 'Generated', value: doctorInfo.timestamp }, { label: 'Server Version', value: doctorInfo.serverVersion }, { diff --git a/src/mcp/tools/macos/__tests__/stop_mac_app.test.ts b/src/mcp/tools/macos/__tests__/stop_mac_app.test.ts index f52f32f6..362fdb5b 100644 --- a/src/mcp/tools/macos/__tests__/stop_mac_app.test.ts +++ b/src/mcp/tools/macos/__tests__/stop_mac_app.test.ts @@ -66,11 +66,7 @@ describe('stop_mac_app plugin', () => { ); expect(calls).toHaveLength(1); - expect(calls[0].command).toEqual([ - 'sh', - '-c', - 'pkill -f "Calculator" || osascript -e \'tell application "Calculator" to quit\'', - ]); + expect(calls[0].command).toEqual(['pkill', '-f', 'Calculator']); }); it('should prioritize processId over appName', async () => { diff --git a/src/mcp/tools/macos/stop_mac_app.ts b/src/mcp/tools/macos/stop_mac_app.ts index 733f9d3d..a5c0918e 100644 --- a/src/mcp/tools/macos/stop_mac_app.ts +++ b/src/mcp/tools/macos/stop_mac_app.ts @@ -26,7 +26,7 @@ export async function stop_mac_appLogic( } const target = params.processId ? `PID ${params.processId}` : params.appName!; - const headerEvent = header('Stop macOS App', [{ label: 'Target', value: target }]); + const headerEvent = header('Stop macOS App', [{ label: 'App', value: target }]); log('info', `Stopping macOS app: ${target}`); @@ -36,14 +36,17 @@ export async function stop_mac_appLogic( if (params.processId) { command = ['kill', String(params.processId)]; } else { - command = [ - 'sh', - '-c', - `pkill -f "${params.appName}" || osascript -e 'tell application "${params.appName}" to quit'`, - ]; + command = ['pkill', '-f', params.appName!]; } - await executor(command, 'Stop macOS App'); + const result = await executor(command, 'Stop macOS App'); + + if (!result.success) { + return toolResponse([ + headerEvent, + statusLine('error', `Stop macOS app operation failed: ${result.error ?? 'Unknown error'}`), + ]); + } return toolResponse([headerEvent, statusLine('success', 'App stopped successfully.')]); } catch (error) { diff --git a/src/mcp/tools/project-discovery/discover_projs.ts b/src/mcp/tools/project-discovery/discover_projs.ts index 528ced97..bcf1ec4a 100644 --- a/src/mcp/tools/project-discovery/discover_projs.ts +++ b/src/mcp/tools/project-discovery/discover_projs.ts @@ -14,6 +14,7 @@ import type { FileSystemExecutor } from '../../../utils/FileSystemExecutor.ts'; import { createTypedTool } from '../../../utils/typed-tool-factory.ts'; import { toolResponse } from '../../../utils/tool-response.ts'; import { header, statusLine, section } from '../../../utils/tool-event-builders.ts'; +import type { PipelineEvent } from '../../../types/pipeline-events.ts'; // Constants const DEFAULT_MAX_DEPTH = 5; @@ -239,7 +240,7 @@ export async function discover_projsLogic( `Discovery finished. Found ${results.projects.length} projects and ${results.workspaces.length} workspaces.`, ); - const events = [ + const events: PipelineEvent[] = [ headerEvent, statusLine( 'success', diff --git a/src/mcp/tools/simulator/__tests__/launch_app_logs_sim.test.ts b/src/mcp/tools/simulator/__tests__/launch_app_logs_sim.test.ts index 42b4772d..f4b7ea39 100644 --- a/src/mcp/tools/simulator/__tests__/launch_app_logs_sim.test.ts +++ b/src/mcp/tools/simulator/__tests__/launch_app_logs_sim.test.ts @@ -77,7 +77,7 @@ describe('launch_app_logs_sim tool', () => { logCaptureStub, ); - const text = result.content.map((c: { text: string }) => c.text).join('\n'); + const text = result.content.map((c) => (c.type === 'text' ? c.text : '')).join('\n'); expect(text).toContain('Launch App'); expect(text).toContain('App launched successfully'); expect(text).toContain('test-uuid-123'); @@ -208,7 +208,7 @@ describe('launch_app_logs_sim tool', () => { logCaptureStub, ); - const text = result.content.map((c: { text: string }) => c.text).join('\n'); + const text = result.content.map((c) => (c.type === 'text' ? c.text : '')).join('\n'); expect(text).toContain('Failed to launch app with log capture'); expect(text).toContain('Failed to start log capture'); expect(result.isError).toBe(true); diff --git a/src/mcp/tools/simulator/__tests__/launch_app_sim.test.ts b/src/mcp/tools/simulator/__tests__/launch_app_sim.test.ts index 06b72132..83d2d22a 100644 --- a/src/mcp/tools/simulator/__tests__/launch_app_sim.test.ts +++ b/src/mcp/tools/simulator/__tests__/launch_app_sim.test.ts @@ -97,7 +97,7 @@ describe('launch_app_sim tool', () => { sequencedExecutor, ); - const text = result.content.map((c: { text: string }) => c.text).join('\n'); + const text = result.content.map((c) => (c.type === 'text' ? c.text : '')).join('\n'); expect(text).toContain('Launch App'); expect(text).toContain('App launched successfully'); expect(text).toContain('test-uuid-123'); @@ -181,7 +181,7 @@ describe('launch_app_sim tool', () => { sequencedExecutor, ); - const text = result.content.map((c: { text: string }) => c.text).join('\n'); + const text = result.content.map((c) => (c.type === 'text' ? c.text : '')).join('\n'); expect(text).toContain('Launch App'); expect(text).toContain('App launched successfully'); expect(text).toContain('"iPhone 17" (resolved-uuid)'); @@ -224,7 +224,7 @@ describe('launch_app_sim tool', () => { mockExecutor, ); - const text = result.content.map((c: { text: string }) => c.text).join('\n'); + const text = result.content.map((c) => (c.type === 'text' ? c.text : '')).join('\n'); expect(text).toContain('App is not installed on the simulator'); expect(text).toContain('install_app_sim'); expect(result.isError).toBe(true); @@ -251,7 +251,7 @@ describe('launch_app_sim tool', () => { mockExecutor, ); - const text = result.content.map((c: { text: string }) => c.text).join('\n'); + const text = result.content.map((c) => (c.type === 'text' ? c.text : '')).join('\n'); expect(text).toContain('App is not installed on the simulator (check failed)'); expect(text).toContain('install_app_sim'); expect(result.isError).toBe(true); @@ -285,7 +285,7 @@ describe('launch_app_sim tool', () => { mockExecutor, ); - const text = result.content.map((c: { text: string }) => c.text).join('\n'); + const text = result.content.map((c) => (c.type === 'text' ? c.text : '')).join('\n'); expect(text).toContain('Launch app in simulator operation failed'); expect(text).toContain('Launch failed'); expect(result.isError).toBe(true); diff --git a/src/mcp/tools/simulator/record_sim_video.ts b/src/mcp/tools/simulator/record_sim_video.ts index 1e3ddd0e..de2807f9 100644 --- a/src/mcp/tools/simulator/record_sim_video.ts +++ b/src/mcp/tools/simulator/record_sim_video.ts @@ -99,10 +99,7 @@ export async function record_sim_videoLogic( if (!startRes.started) { return toolResponse([ headerEvent, - statusLine( - 'error', - `Failed to start video recording: ${startRes.error ?? 'Unknown error'}`, - ), + statusLine('error', `Failed to start video recording: ${startRes.error}`), ]); } @@ -149,7 +146,7 @@ export async function record_sim_videoLogic( if (!stopRes.stopped) { return toolResponse([ headerEvent, - statusLine('error', `Failed to stop video recording: ${stopRes.error ?? 'Unknown error'}`), + statusLine('error', `Failed to stop video recording: ${stopRes.error}`), ]); } @@ -201,7 +198,7 @@ export async function record_sim_videoLogic( const response = toolResponse(stopEvents); if (finalSavedPath) { - (response as Record)._meta = { outputFile: finalSavedPath }; + return { ...response, _meta: { outputFile: finalSavedPath } }; } return response; } diff --git a/src/mcp/tools/swift-package/__tests__/swift_package_test.test.ts b/src/mcp/tools/swift-package/__tests__/swift_package_test.test.ts index 29f5af11..df802f3c 100644 --- a/src/mcp/tools/swift-package/__tests__/swift_package_test.test.ts +++ b/src/mcp/tools/swift-package/__tests__/swift_package_test.test.ts @@ -2,11 +2,10 @@ import { describe, it, expect } from 'vitest'; import * as z from 'zod'; import { createMockExecutor, - createMockFileSystemExecutor, - createNoopExecutor, createMockCommandResponse, } from '../../../../test-utils/mock-executors.ts'; import { schema, handler, swift_package_testLogic } from '../swift_package_test.ts'; +import { allText } from '../../../../test-utils/test-helpers.ts'; import type { CommandExecutor } from '../../../../utils/execution/index.ts'; describe('swift_package_test plugin', () => { @@ -62,14 +61,9 @@ describe('swift_package_test plugin', () => { describe('Command Generation Testing', () => { it('should build correct command for basic test', async () => { - const calls: Array<{ - args: string[]; - name?: string; - hideOutput?: boolean; - opts?: { env?: Record; cwd?: string }; - }> = []; - const mockExecutor: CommandExecutor = async (args, name, hideOutput, opts) => { - calls.push({ args, name, hideOutput, opts }); + const calls: Array<{ args: string[] }> = []; + const mockExecutor: CommandExecutor = async (args, _name, _hideOutput, _opts) => { + calls.push({ args }); return createMockCommandResponse({ success: true, output: 'Test Passed', @@ -85,23 +79,13 @@ describe('swift_package_test plugin', () => { ); expect(calls).toHaveLength(1); - expect(calls[0]).toEqual({ - args: ['swift', 'test', '--package-path', '/test/package'], - name: 'Swift Package Test', - hideOutput: false, - opts: undefined, - }); + expect(calls[0].args).toEqual(['swift', 'test', '--package-path', '/test/package']); }); it('should build correct command with all parameters', async () => { - const calls: Array<{ - args: string[]; - name?: string; - hideOutput?: boolean; - opts?: { env?: Record; cwd?: string }; - }> = []; - const mockExecutor: CommandExecutor = async (args, name, hideOutput, opts) => { - calls.push({ args, name, hideOutput, opts }); + const calls: Array<{ args: string[] }> = []; + const mockExecutor: CommandExecutor = async (args, _name, _hideOutput, _opts) => { + calls.push({ args }); return createMockCommandResponse({ success: true, output: 'Tests completed', @@ -123,62 +107,38 @@ describe('swift_package_test plugin', () => { ); expect(calls).toHaveLength(1); - expect(calls[0]).toEqual({ - args: [ - 'swift', - 'test', - '--package-path', - '/test/package', - '-c', - 'release', - '--test-product', - 'MyTests', - '--filter', - 'Test.*', - '--no-parallel', - '--show-code-coverage', - '-Xswiftc', - '-parse-as-library', - ], - name: 'Swift Package Test', - hideOutput: false, - opts: undefined, - }); + expect(calls[0].args).toEqual([ + 'swift', + 'test', + '--package-path', + '/test/package', + '-c', + 'release', + '--test-product', + 'MyTests', + '--filter', + 'Test.*', + '--no-parallel', + '--show-code-coverage', + '-Xswiftc', + '-parse-as-library', + ]); }); }); describe('Response Logic Testing', () => { - it('should handle empty packagePath parameter', async () => { - const mockExecutor = createMockExecutor({ - success: true, - output: 'Tests completed with empty path', - }); - - const result = await swift_package_testLogic({ packagePath: '' }, mockExecutor); - - expect(result.isError).toBeUndefined(); - const text = result.content.map((c) => c.text).join('\n'); - expect(text).toContain('Swift package tests completed'); - }); - - it('should return successful test response', async () => { + it('should return non-error for successful tests', async () => { const mockExecutor = createMockExecutor({ success: true, output: 'All tests passed.', }); const result = await swift_package_testLogic( - { - packagePath: '/test/package', - }, + { packagePath: '/test/package' }, mockExecutor, ); - expect(result.isError).toBeUndefined(); - const text = result.content.map((c) => c.text).join('\n'); - expect(text).toContain('Swift Package Test'); - expect(text).toContain('Swift package tests completed'); - expect(text).toContain('All tests passed.'); + expect(result.isError).toBeFalsy(); }); it('should return error response for test failure', async () => { @@ -188,37 +148,11 @@ describe('swift_package_test plugin', () => { }); const result = await swift_package_testLogic( - { - packagePath: '/test/package', - }, + { packagePath: '/test/package' }, mockExecutor, ); expect(result.isError).toBe(true); - const text = result.content.map((c) => c.text).join('\n'); - expect(text).toContain('Swift package tests failed'); - expect(text).toContain('2 tests failed'); - }); - - it('should include stdout diagnostics when stderr is empty on test failure', async () => { - const mockExecutor = createMockExecutor({ - success: false, - error: '', - output: - "main.swift:10:25: error: cannot find type 'DOESNOTEXIST' in scope\nlet broken: DOESNOTEXIST = 42", - }); - - const result = await swift_package_testLogic( - { - packagePath: '/test/package', - }, - mockExecutor, - ); - - expect(result.isError).toBe(true); - const text = result.content.map((c) => c.text).join('\n'); - expect(text).toContain('Swift package tests failed'); - expect(text).toContain("cannot find type 'DOESNOTEXIST' in scope"); }); it('should handle spawn error', async () => { @@ -227,42 +161,27 @@ describe('swift_package_test plugin', () => { }; const result = await swift_package_testLogic( - { - packagePath: '/test/package', - }, + { packagePath: '/test/package' }, mockExecutor, ); expect(result.isError).toBe(true); - const text = result.content.map((c) => c.text).join('\n'); + const text = allText(result); expect(text).toContain('Failed to execute swift test'); expect(text).toContain('spawn ENOENT'); }); - it('should handle successful test with parameters', async () => { - const mockExecutor = createMockExecutor({ - success: true, - output: 'Tests completed.', - }); + it('should return error for invalid configuration', async () => { + const mockExecutor = createMockExecutor({ success: true, output: '' }); const result = await swift_package_testLogic( - { - packagePath: '/test/package', - testProduct: 'MyTests', - filter: 'Test.*', - configuration: 'release', - parallel: false, - showCodecov: true, - parseAsLibrary: true, - }, + { packagePath: '/test/package', configuration: 'invalid' as 'debug' }, mockExecutor, ); - expect(result.isError).toBeUndefined(); - const text = result.content.map((c) => c.text).join('\n'); - expect(text).toContain('Swift Package Test'); - expect(text).toContain('Swift package tests completed'); - expect(text).toContain('Tests completed.'); + expect(result.isError).toBe(true); + const text = allText(result); + expect(text).toContain('Invalid configuration'); }); }); }); diff --git a/src/mcp/tools/swift-package/swift_package_test.ts b/src/mcp/tools/swift-package/swift_package_test.ts index fcee1ffc..cca925dd 100644 --- a/src/mcp/tools/swift-package/swift_package_test.ts +++ b/src/mcp/tools/swift-package/swift_package_test.ts @@ -9,7 +9,10 @@ import { getSessionAwareToolSchemaShape, } from '../../../utils/typed-tool-factory.ts'; import { toolResponse } from '../../../utils/tool-response.ts'; -import { header, statusLine, section } from '../../../utils/tool-event-builders.ts'; +import { header, statusLine } from '../../../utils/tool-event-builders.ts'; +import { startBuildPipeline } from '../../../utils/xcodebuild-pipeline.ts'; +import { createPendingXcodebuildResponse } from '../../../utils/xcodebuild-output.ts'; +import { displayPath } from '../../../utils/build-preflight.ts'; const baseSchemaObject = z.object({ packagePath: z.string(), @@ -72,21 +75,33 @@ export async function swift_package_testLogic( } log('info', `Running swift ${swiftArgs.join(' ')}`); + + const configText = `Swift Package Test\n Package: ${displayPath(resolvedPath)}`; + const started = startBuildPipeline({ + operation: 'TEST', + toolName: 'swift_package_test', + params: { + scheme: params.testProduct ?? path.basename(resolvedPath), + configuration: params.configuration ?? 'debug', + platform: 'Swift Package', + preflight: configText, + }, + message: configText, + }); + + const { pipeline } = started; + try { - const result = await executor(['swift', ...swiftArgs], 'Swift Package Test', false); - if (!result.success) { - const errorMessage = result.error || result.output || 'Unknown error'; - return toolResponse([ - headerEvent, - statusLine('error', `Swift package tests failed: ${errorMessage}`), - ]); - } + const result = await executor(['swift', ...swiftArgs], 'Swift Package Test', false, { + onStdout: (chunk: string) => pipeline.onStdout(chunk), + onStderr: (chunk: string) => pipeline.onStderr(chunk), + }); - return toolResponse([ - headerEvent, - ...(result.output ? [section('Output', [result.output])] : []), - statusLine('success', 'Swift package tests completed'), - ]); + const response: ToolResponse = result.success + ? { content: [], isError: false } + : { content: [{ type: 'text', text: result.error || result.output || 'Unknown error' }], isError: true }; + + return createPendingXcodebuildResponse(started, response); } catch (error) { const message = error instanceof Error ? error.message : String(error); log('error', `Swift package test failed: ${message}`); diff --git a/src/mcp/tools/ui-automation/long_press.ts b/src/mcp/tools/ui-automation/long_press.ts index e1513592..a0a5346f 100644 --- a/src/mcp/tools/ui-automation/long_press.ts +++ b/src/mcp/tools/ui-automation/long_press.ts @@ -85,11 +85,13 @@ export async function long_pressLogic( log('info', `${LOG_PREFIX}/${toolName}: Success for ${simulatorId}`); const coordinateWarning = getSnapshotUiWarning(simulatorId); - const warnings = [guard.warningText, coordinateWarning].filter(Boolean); + const warnings = [guard.warningText, coordinateWarning].filter( + (w): w is string => typeof w === 'string' && w.length > 0, + ); return toolResponse([ headerEvent, statusLine('success', `Long press at (${x}, ${y}) for ${duration}ms simulated successfully.`), - ...warnings.map((w) => statusLine('warning' as const, w)), + ...warnings.map((w) => statusLine('warning', w)), ]); } catch (error) { log('error', `${LOG_PREFIX}/${toolName}: Failed - ${error}`); diff --git a/src/mcp/tools/ui-automation/swipe.ts b/src/mcp/tools/ui-automation/swipe.ts index d3dd59cb..cad97787 100644 --- a/src/mcp/tools/ui-automation/swipe.ts +++ b/src/mcp/tools/ui-automation/swipe.ts @@ -109,14 +109,16 @@ export async function swipeLogic( log('info', `${LOG_PREFIX}/${toolName}: Success for ${simulatorId}`); const coordinateWarning = getSnapshotUiWarning(simulatorId); - const warnings = [guard.warningText, coordinateWarning].filter(Boolean); + const warnings = [guard.warningText, coordinateWarning].filter( + (w): w is string => typeof w === 'string' && w.length > 0, + ); return toolResponse([ headerEvent, statusLine( 'success', `Swipe from (${x1}, ${y1}) to (${x2}, ${y2})${optionsText} simulated successfully.`, ), - ...warnings.map((w) => statusLine('warning' as const, w)), + ...warnings.map((w) => statusLine('warning', w)), ]); } catch (error) { log('error', `${LOG_PREFIX}/${toolName}: Failed - ${error}`); diff --git a/src/mcp/tools/ui-automation/tap.ts b/src/mcp/tools/ui-automation/tap.ts index 0499bb22..e5b751b4 100644 --- a/src/mcp/tools/ui-automation/tap.ts +++ b/src/mcp/tools/ui-automation/tap.ts @@ -161,11 +161,13 @@ export async function tapLogic( log('info', `${LOG_PREFIX}/${toolName}: Success for ${simulatorId}`); const coordinateWarning = usesCoordinates ? getSnapshotUiWarning(simulatorId) : null; - const warnings = [guard.warningText, coordinateWarning].filter(Boolean); + const warnings = [guard.warningText, coordinateWarning].filter( + (w): w is string => typeof w === 'string' && w.length > 0, + ); return toolResponse([ headerEvent, statusLine('success', `${actionDescription} simulated successfully.`), - ...warnings.map((w) => statusLine('warning' as const, w)), + ...warnings.map((w) => statusLine('warning', w)), ]); } catch (error: unknown) { const errorMessage = error instanceof Error ? error.message : String(error); diff --git a/src/mcp/tools/ui-automation/touch.ts b/src/mcp/tools/ui-automation/touch.ts index 060f7fb7..f9905167 100644 --- a/src/mcp/tools/ui-automation/touch.ts +++ b/src/mcp/tools/ui-automation/touch.ts @@ -92,11 +92,13 @@ export async function touchLogic( log('info', `${LOG_PREFIX}/${toolName}: Success for ${simulatorId}`); const coordinateWarning = getSnapshotUiWarning(simulatorId); - const warnings = [guard.warningText, coordinateWarning].filter(Boolean); + const warnings = [guard.warningText, coordinateWarning].filter( + (w): w is string => typeof w === 'string' && w.length > 0, + ); return toolResponse([ headerEvent, statusLine('success', `Touch event (${actionText}) at (${x}, ${y}) executed successfully.`), - ...warnings.map((w) => statusLine('warning' as const, w)), + ...warnings.map((w) => statusLine('warning', w)), ]); } catch (error) { log( diff --git a/src/snapshot-tests/__fixtures__/coverage/get-coverage-report--error-invalid-bundle.txt b/src/snapshot-tests/__fixtures__/coverage/get-coverage-report--error-invalid-bundle.txt index 5c06b714..8464b03a 100644 --- a/src/snapshot-tests/__fixtures__/coverage/get-coverage-report--error-invalid-bundle.txt +++ b/src/snapshot-tests/__fixtures__/coverage/get-coverage-report--error-invalid-bundle.txt @@ -3,4 +3,4 @@ xcresult: /invalid.xcresult -โŒ Failed to get coverage report: Error: Error Domain=XCCovErrorDomain Code=0 "Failed to load result bundle" UserInfo={NSLocalizedDescription=Failed to load result bundle, NSUnderlyingError=0xadc338280 {Error Domain=XCResultStorage.ResultBundleFactory.Error Code=0 "Failed to create a new result bundle reader, underlying error: Info.plist at /invalid.xcresult/Info.plist does not exist, the result bundle might be corrupted or the provided path is not a result bundle"}} +โŒ Failed to get coverage report: Error: Error Domain=XCCovErrorDomain Code=0 "Failed to load result bundle" UserInfo={NSLocalizedDescription=Failed to load result bundle, NSUnderlyingError=0x8240b0d20 {Error Domain=XCResultStorage.ResultBundleFactory.Error Code=0 "Failed to create a new result bundle reader, underlying error: Info.plist at /invalid.xcresult/Info.plist does not exist, the result bundle might be corrupted or the provided path is not a result bundle"}} diff --git a/src/snapshot-tests/__fixtures__/coverage/get-file-coverage--error-invalid-bundle.txt b/src/snapshot-tests/__fixtures__/coverage/get-file-coverage--error-invalid-bundle.txt index 177eaa38..1e2c462b 100644 --- a/src/snapshot-tests/__fixtures__/coverage/get-file-coverage--error-invalid-bundle.txt +++ b/src/snapshot-tests/__fixtures__/coverage/get-file-coverage--error-invalid-bundle.txt @@ -4,4 +4,4 @@ xcresult: /invalid.xcresult File: SomeFile.swift -โŒ Failed to get file coverage: Error: Error Domain=XCCovErrorDomain Code=0 "Failed to load result bundle" UserInfo={NSLocalizedDescription=Failed to load result bundle, NSUnderlyingError=0xc1ffa0dc0 {Error Domain=XCResultStorage.ResultBundleFactory.Error Code=0 "Failed to create a new result bundle reader, underlying error: Info.plist at /invalid.xcresult/Info.plist does not exist, the result bundle might be corrupted or the provided path is not a result bundle"}} +โŒ Failed to get file coverage: Error: Error Domain=XCCovErrorDomain Code=0 "Failed to load result bundle" UserInfo={NSLocalizedDescription=Failed to load result bundle, NSUnderlyingError=0xcabcd98b0 {Error Domain=XCResultStorage.ResultBundleFactory.Error Code=0 "Failed to create a new result bundle reader, underlying error: Info.plist at /invalid.xcresult/Info.plist does not exist, the result bundle might be corrupted or the provided path is not a result bundle"}} diff --git a/src/snapshot-tests/__fixtures__/debugging/attach--success-continue.txt b/src/snapshot-tests/__fixtures__/debugging/attach--success-continue.txt new file mode 100644 index 00000000..2d6f7cb0 --- /dev/null +++ b/src/snapshot-tests/__fixtures__/debugging/attach--success-continue.txt @@ -0,0 +1,13 @@ + +๐Ÿ› Attach Debugger + +โœ… Attached DAP debugger to simulator process () + + โ”œ Debug session ID: + โ”œ Status: This session is now the current debug session. + โ”” Execution: Execution is running. App is responsive to UI interaction. + +Next steps: +1. Add a breakpoint: debug_breakpoint_add({ debugSessionId: "", file: "...", line: 123 }) +2. Continue execution: debug_continue({ debugSessionId: "" }) +3. Show call stack: debug_stack({ debugSessionId: "" }) diff --git a/src/snapshot-tests/__fixtures__/debugging/attach--success.txt b/src/snapshot-tests/__fixtures__/debugging/attach--success.txt index d4b1c104..b77f1bd1 100644 --- a/src/snapshot-tests/__fixtures__/debugging/attach--success.txt +++ b/src/snapshot-tests/__fixtures__/debugging/attach--success.txt @@ -1,7 +1,7 @@ ๐Ÿ› Attach Debugger -โœ… Attached DAP debugger to simulator process 76138 () +โœ… Attached DAP debugger to simulator process () โ”œ Debug session ID: โ”œ Status: This session is now the current debug session. diff --git a/src/snapshot-tests/__fixtures__/debugging/lldb-command--success.txt b/src/snapshot-tests/__fixtures__/debugging/lldb-command--success.txt index c88a62e2..f7c24be5 100644 --- a/src/snapshot-tests/__fixtures__/debugging/lldb-command--success.txt +++ b/src/snapshot-tests/__fixtures__/debugging/lldb-command--success.txt @@ -7,6 +7,8 @@ Output Current breakpoints: - 1: file = 'ContentView.swift', line = 42, exact_match = 0, locations = 0 (pending) + 1: file = 'ContentView.swift', line = 42, exact_match = 0, locations = 1, resolved = 1, hit count = 0 Names: dap + + 1.1: where = `closure #1 in closure #1 in closure #1 in ContentView.body.getter + 1428 at ContentView.swift:42:31, address = , resolved, hit count = 0 diff --git a/src/snapshot-tests/__fixtures__/debugging/stack--success.txt b/src/snapshot-tests/__fixtures__/debugging/stack--success.txt index 85736400..096a1f96 100644 --- a/src/snapshot-tests/__fixtures__/debugging/stack--success.txt +++ b/src/snapshot-tests/__fixtures__/debugging/stack--success.txt @@ -4,21 +4,21 @@ โœ… Stack trace retrieved Frames - Thread 16147149 (Thread 1 Queue: com.apple.main-thread (serial)) - frame #0: mach_msg2_trap at /usr/lib/system/libsystem_kernel.dylib`mach_msg2_trap:3 - frame #1: mach_msg2_internal at /usr/lib/system/libsystem_kernel.dylib`mach_msg2_internal:56 - frame #2: mach_msg_overwrite at /usr/lib/system/libsystem_kernel.dylib`mach_msg_overwrite:121 - frame #3: mach_msg at /usr/lib/system/libsystem_kernel.dylib`mach_msg:6 - frame #4: __CFRunLoopServiceMachPort at /Library/Developer/CoreSimulator/Volumes/iOS_23E244/Library/Developer/CoreSimulator/Profiles/Runtimes/iOS 26.4.simruntime/Contents/Resources/RuntimeRoot/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation`__CFRunLoopServiceMachPort:40 - frame #5: __CFRunLoopRun at /Library/Developer/CoreSimulator/Volumes/iOS_23E244/Library/Developer/CoreSimulator/Profiles/Runtimes/iOS 26.4.simruntime/Contents/Resources/RuntimeRoot/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation`__CFRunLoopRun:283 - frame #6: _CFRunLoopRunSpecificWithOptions at /Library/Developer/CoreSimulator/Volumes/iOS_23E244/Library/Developer/CoreSimulator/Profiles/Runtimes/iOS 26.4.simruntime/Contents/Resources/RuntimeRoot/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation`_CFRunLoopRunSpecificWithOptions:125 - frame #7: GSEventRunModal at /Library/Developer/CoreSimulator/Volumes/iOS_23E244/Library/Developer/CoreSimulator/Profiles/Runtimes/iOS 26.4.simruntime/Contents/Resources/RuntimeRoot/System/Library/PrivateFrameworks/GraphicsServices.framework/GraphicsServices`GSEventRunModal:30 - frame #8: -[UIApplication _run] at /Library/Developer/CoreSimulator/Volumes/iOS_23E244/Library/Developer/CoreSimulator/Profiles/Runtimes/iOS 26.4.simruntime/Contents/Resources/RuntimeRoot/System/Library/PrivateFrameworks/UIKitCore.framework/UIKitCore`-[UIApplication _run]:195 - frame #9: UIApplicationMain at /Library/Developer/CoreSimulator/Volumes/iOS_23E244/Library/Developer/CoreSimulator/Profiles/Runtimes/iOS 26.4.simruntime/Contents/Resources/RuntimeRoot/System/Library/PrivateFrameworks/UIKitCore.framework/UIKitCore`UIApplicationMain:31 - frame #10: closure #1 in KitRendererCommon(_:) at /Library/Developer/CoreSimulator/Volumes/iOS_23E244/Library/Developer/CoreSimulator/Profiles/Runtimes/iOS 26.4.simruntime/Contents/Resources/RuntimeRoot/System/Library/Frameworks/SwiftUI.framework/SwiftUI`closure #1 (Swift.UnsafeMutablePointer>>) -> Swift.Never in SwiftUI.KitRendererCommon(Swift.AnyObject.Type) -> Swift.Never:42 - frame #11: runApp<ฯ„_0_0>(_:) at /Library/Developer/CoreSimulator/Volumes/iOS_23E244/Library/Developer/CoreSimulator/Profiles/Runtimes/iOS 26.4.simruntime/Contents/Resources/RuntimeRoot/System/Library/Frameworks/SwiftUI.framework/SwiftUI`SwiftUI.runApp<ฯ„_0_0 where ฯ„_0_0: SwiftUI.App>(ฯ„_0_0) -> Swift.Never:46 - frame #12: static App.main() at /Library/Developer/CoreSimulator/Volumes/iOS_23E244/Library/Developer/CoreSimulator/Profiles/Runtimes/iOS 26.4.simruntime/Contents/Resources/RuntimeRoot/System/Library/Frameworks/SwiftUI.framework/SwiftUI`static SwiftUI.App.main() -> ():38 - frame #13: static CalculatorApp.$main() at /Library/Developer/CoreSimulator/Devices//data/Containers/Bundle/Application//CalculatorApp.app/CalculatorApp.debug.dylib`static CalculatorApp.CalculatorApp.$main() -> ():11 - frame #14: main at /Library/Developer/CoreSimulator/Devices//data/Containers/Bundle/Application//CalculatorApp.app/CalculatorApp.debug.dylib`main:4 - frame #15: start_sim at /Library/Developer/CoreSimulator/Volumes/iOS_23E244/Library/Developer/CoreSimulator/Profiles/Runtimes/iOS 26.4.simruntime/Contents/Resources/RuntimeRoot/usr/lib/dyld_sim`start_sim:6 - frame #16: start at /usr/lib/dyld`start:1797 + Thread (Thread 1 Queue: com.apple.main-thread (serial)) + frame #0: mach_msg2_trap at /usr/lib/system/libsystem_kernel.dylib`mach_msg2_trap: + frame #1: mach_msg2_internal at /usr/lib/system/libsystem_kernel.dylib`mach_msg2_internal: + frame #2: mach_msg_overwrite at /usr/lib/system/libsystem_kernel.dylib`mach_msg_overwrite: + frame #3: mach_msg at /usr/lib/system/libsystem_kernel.dylib`mach_msg: + frame #4: __CFRunLoopServiceMachPort at /Library/Developer/CoreSimulator/Volumes/iOS_23E244/Library/Developer/CoreSimulator/Profiles/Runtimes/iOS 26.4.simruntime/Contents/Resources/RuntimeRoot/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation`__CFRunLoopServiceMachPort: + frame #5: __CFRunLoopRun at /Library/Developer/CoreSimulator/Volumes/iOS_23E244/Library/Developer/CoreSimulator/Profiles/Runtimes/iOS 26.4.simruntime/Contents/Resources/RuntimeRoot/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation`__CFRunLoopRun: + frame #6: _CFRunLoopRunSpecificWithOptions at /Library/Developer/CoreSimulator/Volumes/iOS_23E244/Library/Developer/CoreSimulator/Profiles/Runtimes/iOS 26.4.simruntime/Contents/Resources/RuntimeRoot/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation`_CFRunLoopRunSpecificWithOptions: + frame #7: GSEventRunModal at /Library/Developer/CoreSimulator/Volumes/iOS_23E244/Library/Developer/CoreSimulator/Profiles/Runtimes/iOS 26.4.simruntime/Contents/Resources/RuntimeRoot/System/Library/PrivateFrameworks/GraphicsServices.framework/GraphicsServices`GSEventRunModal: + frame #8: -[UIApplication _run] at /Library/Developer/CoreSimulator/Volumes/iOS_23E244/Library/Developer/CoreSimulator/Profiles/Runtimes/iOS 26.4.simruntime/Contents/Resources/RuntimeRoot/System/Library/PrivateFrameworks/UIKitCore.framework/UIKitCore`-[UIApplication _run]: + frame #9: UIApplicationMain at /Library/Developer/CoreSimulator/Volumes/iOS_23E244/Library/Developer/CoreSimulator/Profiles/Runtimes/iOS 26.4.simruntime/Contents/Resources/RuntimeRoot/System/Library/PrivateFrameworks/UIKitCore.framework/UIKitCore`UIApplicationMain: + frame #10: closure #1 in KitRendererCommon(_:) at /Library/Developer/CoreSimulator/Volumes/iOS_23E244/Library/Developer/CoreSimulator/Profiles/Runtimes/iOS 26.4.simruntime/Contents/Resources/RuntimeRoot/System/Library/Frameworks/SwiftUI.framework/SwiftUI`closure #1 (Swift.UnsafeMutablePointer>>) -> Swift.Never in SwiftUI.KitRendererCommon(Swift.AnyObject.Type) -> Swift.Never: + frame #11: runApp<ฯ„_0_0>(_:) at /Library/Developer/CoreSimulator/Volumes/iOS_23E244/Library/Developer/CoreSimulator/Profiles/Runtimes/iOS 26.4.simruntime/Contents/Resources/RuntimeRoot/System/Library/Frameworks/SwiftUI.framework/SwiftUI`SwiftUI.runApp<ฯ„_0_0 where ฯ„_0_0: SwiftUI.App>(ฯ„_0_0) -> Swift.Never: + frame #12: static App.main() at /Library/Developer/CoreSimulator/Volumes/iOS_23E244/Library/Developer/CoreSimulator/Profiles/Runtimes/iOS 26.4.simruntime/Contents/Resources/RuntimeRoot/System/Library/Frameworks/SwiftUI.framework/SwiftUI`static SwiftUI.App.main() -> (): + frame #13: static at /Library/Developer/CoreSimulator/Devices//data/Containers/Bundle/Application//`static -> (): + frame #14: main at /Library/Developer/CoreSimulator/Devices//data/Containers/Bundle/Application//`main: + frame #15: start_sim at /Library/Developer/CoreSimulator/Volumes/iOS_23E244/Library/Developer/CoreSimulator/Profiles/Runtimes/iOS 26.4.simruntime/Contents/Resources/RuntimeRoot/usr/lib/dyld_sim`start_sim: + frame #16: start at /usr/lib/dyld`start: diff --git a/src/snapshot-tests/__fixtures__/device/build-and-run--success.txt b/src/snapshot-tests/__fixtures__/device/build-and-run--success.txt index 4e9f9dc5..e12b576c 100644 --- a/src/snapshot-tests/__fixtures__/device/build-and-run--success.txt +++ b/src/snapshot-tests/__fixtures__/device/build-and-run--success.txt @@ -21,4 +21,4 @@ Next steps: 1. Capture device logs: xcodebuildmcp device start-device-log-capture --device-id "" --bundle-id "io.sentry.calculatorapp" -2. Stop app on device: xcodebuildmcp device stop --device-id "" --process-id "21293" +2. Stop app on device: xcodebuildmcp device stop --device-id "" --process-id "22761" diff --git a/src/snapshot-tests/__fixtures__/device/get-app-path--error-wrong-scheme.txt b/src/snapshot-tests/__fixtures__/device/get-app-path--error-wrong-scheme.txt index 287b8d83..6d5fb405 100644 --- a/src/snapshot-tests/__fixtures__/device/get-app-path--error-wrong-scheme.txt +++ b/src/snapshot-tests/__fixtures__/device/get-app-path--error-wrong-scheme.txt @@ -6,5 +6,5 @@ Configuration: Debug Platform: iOS -โŒ xcodebuild[76609:16148643] Writing error result bundle to /var/folders/_t/2njffz894t57qpp76v1sw__h0000gn/T/ResultBundle_2026-26-03_21-45-0013.xcresult +โŒ xcodebuild[43591:16955984] Writing error result bundle to /var/folders/_t/2njffz894t57qpp76v1sw__h0000gn/T/ResultBundle_2026-27-03_09-46-0041.xcresult xcodebuild: error: The workspace named "CalculatorApp" does not contain a scheme named "NONEXISTENT". The "-list" option can be used to find the names of the schemes in the workspace. diff --git a/src/snapshot-tests/__fixtures__/device/test--failure.txt b/src/snapshot-tests/__fixtures__/device/test--failure.txt index 9fa50bab..2b905783 100644 --- a/src/snapshot-tests/__fixtures__/device/test--failure.txt +++ b/src/snapshot-tests/__fixtures__/device/test--failure.txt @@ -8,5 +8,3 @@ โœ— CalculatorAppTests.CalculatorAppTests/testCalculatorServiceFailure: XCTAssertEqual failed: ("0") is not equal to ("999") - This test should fail - display should be 0, not 999 example_projects/iOS_Calculator/CalculatorAppTests/CalculatorAppTests.swift:52 - -โŒ Test failed. (, โฑ๏ธ ) diff --git a/src/snapshot-tests/__fixtures__/logging/stop-device-log--success.txt b/src/snapshot-tests/__fixtures__/logging/stop-device-log--success.txt index f7b1ec5d..d6b54e54 100644 --- a/src/snapshot-tests/__fixtures__/logging/stop-device-log--success.txt +++ b/src/snapshot-tests/__fixtures__/logging/stop-device-log--success.txt @@ -8,7 +8,7 @@ Captured Logs --- Device log capture for bundle ID: io.sentry.calculatorapp on device: --- - 21:59:31 Acquired usage assertion. + 09:50:59 Acquired usage assertion. Launched application with io.sentry.calculatorapp bundle identifier. Waiting for the application to terminateโ€ฆ App terminated due to signal 15. diff --git a/src/snapshot-tests/__fixtures__/macos/build--error-wrong-scheme.txt b/src/snapshot-tests/__fixtures__/macos/build--error-wrong-scheme.txt new file mode 100644 index 00000000..962a375c --- /dev/null +++ b/src/snapshot-tests/__fixtures__/macos/build--error-wrong-scheme.txt @@ -0,0 +1,12 @@ + +๐Ÿ”จ Build + + Scheme: NONEXISTENT + Configuration: Debug + Platform: macOS + +Errors (1): + + โœ— The project named "MCPTest" does not contain a scheme named "NONEXISTENT". The "-list" option can be used to find the names of the schemes in the project. + +โŒ Build failed. (โฑ๏ธ ) diff --git a/src/snapshot-tests/__fixtures__/macos/build-and-run--error-wrong-scheme.txt b/src/snapshot-tests/__fixtures__/macos/build-and-run--error-wrong-scheme.txt new file mode 100644 index 00000000..8187d6f2 --- /dev/null +++ b/src/snapshot-tests/__fixtures__/macos/build-and-run--error-wrong-scheme.txt @@ -0,0 +1,12 @@ + +๐Ÿš€ Build & Run + + Scheme: NONEXISTENT + Configuration: Debug + Platform: macOS + +Errors (1): + + โœ— The project named "MCPTest" does not contain a scheme named "NONEXISTENT". The "-list" option can be used to find the names of the schemes in the project. + +โŒ Build failed. (โฑ๏ธ ) diff --git a/src/snapshot-tests/__fixtures__/macos/get-app-path--error-wrong-scheme.txt b/src/snapshot-tests/__fixtures__/macos/get-app-path--error-wrong-scheme.txt new file mode 100644 index 00000000..b10bef09 --- /dev/null +++ b/src/snapshot-tests/__fixtures__/macos/get-app-path--error-wrong-scheme.txt @@ -0,0 +1,10 @@ + +๐Ÿ” Get App Path + + Scheme: NONEXISTENT + Project: example_projects/macOS/MCPTest.xcodeproj + Configuration: Debug + Platform: macOS + +โŒ +xcodebuild: error: The project named "MCPTest" does not contain a scheme named "NONEXISTENT". The "-list" option can be used to find the names of the schemes in the project. diff --git a/src/snapshot-tests/__fixtures__/macos/get-macos-bundle-id--error-missing-app.txt b/src/snapshot-tests/__fixtures__/macos/get-macos-bundle-id--error-missing-app.txt new file mode 100644 index 00000000..8448129e --- /dev/null +++ b/src/snapshot-tests/__fixtures__/macos/get-macos-bundle-id--error-missing-app.txt @@ -0,0 +1,6 @@ + +๐Ÿ” Get macOS Bundle ID + + App: /nonexistent/path/Fake.app + +โŒ File not found: '/nonexistent/path/Fake.app'. Please check the path and try again. diff --git a/src/snapshot-tests/__fixtures__/macos/get-macos-bundle-id--success.txt b/src/snapshot-tests/__fixtures__/macos/get-macos-bundle-id--success.txt index 3c7cc843..0aace01b 100644 --- a/src/snapshot-tests/__fixtures__/macos/get-macos-bundle-id--success.txt +++ b/src/snapshot-tests/__fixtures__/macos/get-macos-bundle-id--success.txt @@ -3,6 +3,4 @@ App: /BundleTest.app -โŒ Could not extract bundle ID from Info.plist: Print: Entry, ":CFBundleIdentifier", Does Not Exist - -โ„น๏ธ Make sure the path points to a valid macOS app bundle (.app directory). +โœ… Bundle ID: com.test.snapshot-macos diff --git a/src/snapshot-tests/__fixtures__/macos/launch--error-invalid-app.txt b/src/snapshot-tests/__fixtures__/macos/launch--error-invalid-app.txt index f103dd51..352508e4 100644 --- a/src/snapshot-tests/__fixtures__/macos/launch--error-invalid-app.txt +++ b/src/snapshot-tests/__fixtures__/macos/launch--error-invalid-app.txt @@ -1,6 +1,6 @@ ๐Ÿš€ Launch macOS App - App: /Fake.app + App: /NonExistent.app -โœ… App launched successfully. +โŒ File not found: '/NonExistent.app'. Please check the path and try again. diff --git a/src/snapshot-tests/__fixtures__/macos/launch--success.txt b/src/snapshot-tests/__fixtures__/macos/launch--success.txt new file mode 100644 index 00000000..c01630eb --- /dev/null +++ b/src/snapshot-tests/__fixtures__/macos/launch--success.txt @@ -0,0 +1,6 @@ + +๐Ÿš€ Launch macOS App + + App: /Library/Developer/Xcode/DerivedData/MCPTest-/Build/Products/Debug/MCPTest.app + +โœ… App launched successfully. diff --git a/src/snapshot-tests/__fixtures__/macos/stop--error-no-app.txt b/src/snapshot-tests/__fixtures__/macos/stop--error-no-app.txt index b04a24c6..f5380129 100644 --- a/src/snapshot-tests/__fixtures__/macos/stop--error-no-app.txt +++ b/src/snapshot-tests/__fixtures__/macos/stop--error-no-app.txt @@ -1,6 +1,6 @@ ๐Ÿ›‘ Stop macOS App - Target: NonExistentXBMTestApp + App: PID 999999 -โœ… App stopped successfully. +โŒ Stop macOS app operation failed: kill: 999999: No such process diff --git a/src/snapshot-tests/__fixtures__/macos/stop--success.txt b/src/snapshot-tests/__fixtures__/macos/stop--success.txt new file mode 100644 index 00000000..077d6a1d --- /dev/null +++ b/src/snapshot-tests/__fixtures__/macos/stop--success.txt @@ -0,0 +1,6 @@ + +๐Ÿ›‘ Stop macOS App + + App: MCPTest + +โœ… App stopped successfully. diff --git a/src/snapshot-tests/__fixtures__/macos/test--error-wrong-scheme.txt b/src/snapshot-tests/__fixtures__/macos/test--error-wrong-scheme.txt new file mode 100644 index 00000000..ebf72cd4 --- /dev/null +++ b/src/snapshot-tests/__fixtures__/macos/test--error-wrong-scheme.txt @@ -0,0 +1,12 @@ + +๐Ÿงช Test + + Scheme: NONEXISTENT + Configuration: Debug + Platform: macOS + +Errors (1): + + โœ— The project named "MCPTest" does not contain a scheme named "NONEXISTENT". The "-list" option can be used to find the names of the schemes in the project. + +โŒ Test failed. (, โฑ๏ธ ) diff --git a/src/snapshot-tests/__fixtures__/macos/test--failure.txt b/src/snapshot-tests/__fixtures__/macos/test--failure.txt new file mode 100644 index 00000000..82683920 --- /dev/null +++ b/src/snapshot-tests/__fixtures__/macos/test--failure.txt @@ -0,0 +1,11 @@ + +๐Ÿงช Test + + Scheme: MCPTest + Configuration: Debug + Platform: macOS + + โœ— deliberateFailure(): Expectation failed: 1 == 2: This test is designed to fail for snapshot testing + MCPTestTests.swift:11 + +โŒ Test failed. (, โฑ๏ธ ) diff --git a/src/snapshot-tests/__fixtures__/project-discovery/discover-projs--error-invalid-root.txt b/src/snapshot-tests/__fixtures__/project-discovery/discover-projs--error-invalid-root.txt new file mode 100644 index 00000000..07a40363 --- /dev/null +++ b/src/snapshot-tests/__fixtures__/project-discovery/discover-projs--error-invalid-root.txt @@ -0,0 +1,4 @@ + +๐Ÿ” Discover Projects + +โŒ Failed to access scan path: /nonexistent/path. Error: ENOENT: no such file or directory, stat '/nonexistent/path' diff --git a/src/snapshot-tests/__fixtures__/project-discovery/get-app-bundle-id--error-missing-app.txt b/src/snapshot-tests/__fixtures__/project-discovery/get-app-bundle-id--error-missing-app.txt new file mode 100644 index 00000000..8eb9031a --- /dev/null +++ b/src/snapshot-tests/__fixtures__/project-discovery/get-app-bundle-id--error-missing-app.txt @@ -0,0 +1,6 @@ + +๐Ÿ” Get Bundle ID + + App: /nonexistent/path/Fake.app + +โŒ File not found: '/nonexistent/path/Fake.app'. Please check the path and try again. diff --git a/src/snapshot-tests/__fixtures__/project-discovery/get-macos-bundle-id--error-missing-app.txt b/src/snapshot-tests/__fixtures__/project-discovery/get-macos-bundle-id--error-missing-app.txt new file mode 100644 index 00000000..8448129e --- /dev/null +++ b/src/snapshot-tests/__fixtures__/project-discovery/get-macos-bundle-id--error-missing-app.txt @@ -0,0 +1,6 @@ + +๐Ÿ” Get macOS Bundle ID + + App: /nonexistent/path/Fake.app + +โŒ File not found: '/nonexistent/path/Fake.app'. Please check the path and try again. diff --git a/src/snapshot-tests/__fixtures__/project-discovery/get-macos-bundle-id--success.txt b/src/snapshot-tests/__fixtures__/project-discovery/get-macos-bundle-id--success.txt index 3c7cc843..5acbdd97 100644 --- a/src/snapshot-tests/__fixtures__/project-discovery/get-macos-bundle-id--success.txt +++ b/src/snapshot-tests/__fixtures__/project-discovery/get-macos-bundle-id--success.txt @@ -3,6 +3,4 @@ App: /BundleTest.app -โŒ Could not extract bundle ID from Info.plist: Print: Entry, ":CFBundleIdentifier", Does Not Exist - -โ„น๏ธ Make sure the path points to a valid macOS app bundle (.app directory). +โœ… Bundle ID: com.test.snapshot diff --git a/src/snapshot-tests/__fixtures__/project-discovery/list-schemes--error-invalid-workspace.txt b/src/snapshot-tests/__fixtures__/project-discovery/list-schemes--error-invalid-workspace.txt new file mode 100644 index 00000000..8740e247 --- /dev/null +++ b/src/snapshot-tests/__fixtures__/project-discovery/list-schemes--error-invalid-workspace.txt @@ -0,0 +1,7 @@ + +๐Ÿ” List Schemes + + Workspace: /nonexistent/path/Fake.xcworkspace + +โŒ xcodebuild[44362:16961447] Writing error result bundle to /var/folders/_t/2njffz894t57qpp76v1sw__h0000gn/T/ResultBundle_2026-27-03_09-49-0011.xcresult +xcodebuild: error: '/nonexistent/path/Fake.xcworkspace' does not exist. diff --git a/src/snapshot-tests/__fixtures__/project-discovery/show-build-settings--error-wrong-scheme.txt b/src/snapshot-tests/__fixtures__/project-discovery/show-build-settings--error-wrong-scheme.txt new file mode 100644 index 00000000..bedf72a7 --- /dev/null +++ b/src/snapshot-tests/__fixtures__/project-discovery/show-build-settings--error-wrong-scheme.txt @@ -0,0 +1,8 @@ + +๐Ÿ” Show Build Settings + + Scheme: NONEXISTENT + Workspace: example_projects/iOS_Calculator/CalculatorApp.xcworkspace + +โŒ xcodebuild[44409:16961675] Writing error result bundle to /var/folders/_t/2njffz894t57qpp76v1sw__h0000gn/T/ResultBundle_2026-27-03_09-49-0013.xcresult +xcodebuild: error: The workspace named "CalculatorApp" does not contain a scheme named "NONEXISTENT". The "-list" option can be used to find the names of the schemes in the workspace. diff --git a/src/snapshot-tests/__fixtures__/project-scaffolding/scaffold-macos--error-existing.txt b/src/snapshot-tests/__fixtures__/project-scaffolding/scaffold-macos--error-existing.txt new file mode 100644 index 00000000..4f1820af --- /dev/null +++ b/src/snapshot-tests/__fixtures__/project-scaffolding/scaffold-macos--error-existing.txt @@ -0,0 +1,8 @@ + +๐Ÿ“ Scaffold macOS Project + + Name: SnapshotTestMacApp + Path: /macos-existing + Platform: macOS + +โŒ Xcode project files already exist in /macos-existing diff --git a/src/snapshot-tests/__fixtures__/simulator-management/erase--error-invalid-id.txt b/src/snapshot-tests/__fixtures__/simulator-management/erase--error-invalid-id.txt new file mode 100644 index 00000000..561f2bb2 --- /dev/null +++ b/src/snapshot-tests/__fixtures__/simulator-management/erase--error-invalid-id.txt @@ -0,0 +1,6 @@ + +๐Ÿ—‘ Erase Simulator + + Simulator: + +โŒ Failed to erase simulator: Invalid device: diff --git a/src/snapshot-tests/__fixtures__/simulator-management/reset-location--error-invalid-simulator.txt b/src/snapshot-tests/__fixtures__/simulator-management/reset-location--error-invalid-simulator.txt new file mode 100644 index 00000000..429bddde --- /dev/null +++ b/src/snapshot-tests/__fixtures__/simulator-management/reset-location--error-invalid-simulator.txt @@ -0,0 +1,6 @@ + +๐Ÿ“ Reset Location + + Simulator: + +โŒ Failed to reset simulator location: Invalid device: diff --git a/src/snapshot-tests/__fixtures__/simulator-management/set-appearance--error-invalid-simulator.txt b/src/snapshot-tests/__fixtures__/simulator-management/set-appearance--error-invalid-simulator.txt new file mode 100644 index 00000000..7dd258fc --- /dev/null +++ b/src/snapshot-tests/__fixtures__/simulator-management/set-appearance--error-invalid-simulator.txt @@ -0,0 +1,7 @@ + +๐ŸŽจ Set Appearance + + Simulator: + Mode: dark + +โŒ Failed to set simulator appearance: Invalid device: diff --git a/src/snapshot-tests/__fixtures__/simulator-management/set-location--error-invalid-simulator.txt b/src/snapshot-tests/__fixtures__/simulator-management/set-location--error-invalid-simulator.txt new file mode 100644 index 00000000..a1d6aace --- /dev/null +++ b/src/snapshot-tests/__fixtures__/simulator-management/set-location--error-invalid-simulator.txt @@ -0,0 +1,7 @@ + +๐Ÿ“ Set Location + + Simulator: + Coordinates: 37.7749,-122.4194 + +โŒ Failed to set simulator location: Invalid device: diff --git a/src/snapshot-tests/__fixtures__/simulator-management/statusbar--error-invalid-simulator.txt b/src/snapshot-tests/__fixtures__/simulator-management/statusbar--error-invalid-simulator.txt new file mode 100644 index 00000000..0c00f0cd --- /dev/null +++ b/src/snapshot-tests/__fixtures__/simulator-management/statusbar--error-invalid-simulator.txt @@ -0,0 +1,7 @@ + +๐Ÿ“ฑ Statusbar + + Simulator: + Data Network: wifi + +โŒ Failed to set status bar: Invalid device: diff --git a/src/snapshot-tests/__fixtures__/simulator/build-and-run--error-wrong-scheme.txt b/src/snapshot-tests/__fixtures__/simulator/build-and-run--error-wrong-scheme.txt new file mode 100644 index 00000000..6c03ffca --- /dev/null +++ b/src/snapshot-tests/__fixtures__/simulator/build-and-run--error-wrong-scheme.txt @@ -0,0 +1,12 @@ + +๐Ÿš€ Build & Run + + Scheme: NONEXISTENT + Configuration: Debug + Platform: iOS Simulator + +Errors (1): + + โœ— The workspace named "CalculatorApp" does not contain a scheme named "NONEXISTENT". The "-list" option can be used to find the names of the schemes in the workspace. + +โŒ Build failed. (โฑ๏ธ ) diff --git a/src/snapshot-tests/__fixtures__/simulator/get-app-path--error-wrong-scheme.txt b/src/snapshot-tests/__fixtures__/simulator/get-app-path--error-wrong-scheme.txt new file mode 100644 index 00000000..f58b0288 --- /dev/null +++ b/src/snapshot-tests/__fixtures__/simulator/get-app-path--error-wrong-scheme.txt @@ -0,0 +1,19 @@ + +๐Ÿ” Get App Path + + Scheme: NONEXISTENT + Workspace: example_projects/iOS_Calculator/CalculatorApp.xcworkspace + Configuration: Debug + Platform: iOS Simulator + Simulator: iPhone 17 + +โŒ Failed to get build settings: xcodebuild[46420:16976772] Writing error result bundle to /var/folders/_t/2njffz894t57qpp76v1sw__h0000gn/T/ResultBundle_2026-27-03_09-50-0028.xcresult +xcodebuild: error: The workspace named "CalculatorApp" does not contain a scheme named "NONEXISTENT". The "-list" option can be used to find the names of the schemes in the workspace. + +Command line invocation: + /Applications/Xcode-26.4.0.app/Contents/Developer/usr/bin/xcodebuild -showBuildSettings -workspace example_projects/iOS_Calculator/CalculatorApp.xcworkspace -scheme NONEXISTENT -configuration Debug -destination "platform=iOS Simulator,name=iPhone 17,OS=latest" + +Resolve Package Graph + +Resolved source packages: + CalculatorAppFeature: /example_projects/iOS_Calculator/CalculatorAppPackage diff --git a/src/snapshot-tests/__fixtures__/simulator/install--success.txt b/src/snapshot-tests/__fixtures__/simulator/install--success.txt new file mode 100644 index 00000000..3235bfb4 --- /dev/null +++ b/src/snapshot-tests/__fixtures__/simulator/install--success.txt @@ -0,0 +1,7 @@ + +๐Ÿ“ฆ Install App + + Simulator: + App Path: /Library/Developer/Xcode/DerivedData/CalculatorApp-/Build/Products/Debug-iphonesimulator/CalculatorApp.app + +โœ… App installed successfully in simulator diff --git a/src/snapshot-tests/__fixtures__/simulator/launch-app--error-not-installed.txt b/src/snapshot-tests/__fixtures__/simulator/launch-app--error-not-installed.txt new file mode 100644 index 00000000..ebdb9854 --- /dev/null +++ b/src/snapshot-tests/__fixtures__/simulator/launch-app--error-not-installed.txt @@ -0,0 +1,7 @@ + +๐Ÿš€ Launch App + + Simulator: + Bundle ID: com.nonexistent.app + +โŒ App is not installed on the simulator. Please use install_app_sim before launching. Workflow: build -> install -> launch. diff --git a/src/snapshot-tests/__fixtures__/simulator/screenshot--error-invalid-simulator.txt b/src/snapshot-tests/__fixtures__/simulator/screenshot--error-invalid-simulator.txt new file mode 100644 index 00000000..66ce3224 --- /dev/null +++ b/src/snapshot-tests/__fixtures__/simulator/screenshot--error-invalid-simulator.txt @@ -0,0 +1,6 @@ + +๐Ÿ“ท Screenshot + + Simulator: + +โŒ System error executing screenshot: Failed to capture screenshot: Invalid device: diff --git a/src/snapshot-tests/__fixtures__/simulator/stop--success.txt b/src/snapshot-tests/__fixtures__/simulator/stop--success.txt new file mode 100644 index 00000000..dc15fa7a --- /dev/null +++ b/src/snapshot-tests/__fixtures__/simulator/stop--success.txt @@ -0,0 +1,7 @@ + +๐Ÿ›‘ Stop App + + Simulator: + Bundle ID: io.sentry.calculatorapp + +โœ… App io.sentry.calculatorapp stopped successfully in simulator diff --git a/src/snapshot-tests/__fixtures__/simulator/test--error-wrong-scheme.txt b/src/snapshot-tests/__fixtures__/simulator/test--error-wrong-scheme.txt new file mode 100644 index 00000000..d787369e --- /dev/null +++ b/src/snapshot-tests/__fixtures__/simulator/test--error-wrong-scheme.txt @@ -0,0 +1,13 @@ + +๐Ÿงช Test + + Scheme: NONEXISTENT + Configuration: Debug + Platform: iOS Simulator + Simulator: iPhone 17 + +Errors (1): + + โœ— The workspace named "CalculatorApp" does not contain a scheme named "NONEXISTENT". The "-list" option can be used to find the names of the schemes in the workspace. + +โŒ Test failed. (, โฑ๏ธ ) diff --git a/src/snapshot-tests/__fixtures__/swift-package/clean--error-bad-path.txt b/src/snapshot-tests/__fixtures__/swift-package/clean--error-bad-path.txt new file mode 100644 index 00000000..ee0fa6bf --- /dev/null +++ b/src/snapshot-tests/__fixtures__/swift-package/clean--error-bad-path.txt @@ -0,0 +1,6 @@ + +๐Ÿงน Swift Package Clean + + Package: /example_projects/NONEXISTENT + +โŒ Swift package clean failed: error: chdir error: No such file or directory (2): /example_projects/NONEXISTENT diff --git a/src/snapshot-tests/__fixtures__/swift-package/run--error-bad-executable.txt b/src/snapshot-tests/__fixtures__/swift-package/run--error-bad-executable.txt new file mode 100644 index 00000000..12edb6bc --- /dev/null +++ b/src/snapshot-tests/__fixtures__/swift-package/run--error-bad-executable.txt @@ -0,0 +1,12 @@ + +๐Ÿš€ Swift Package Run + + Package: /example_projects/spm + Executable: nonexistent-executable + +Output + (no output) +Errors: +error: no executable product named 'nonexistent-executable' + +โŒ Swift executable failed diff --git a/src/snapshot-tests/__fixtures__/swift-package/test--error-bad-path.txt b/src/snapshot-tests/__fixtures__/swift-package/test--error-bad-path.txt new file mode 100644 index 00000000..4d9ab900 --- /dev/null +++ b/src/snapshot-tests/__fixtures__/swift-package/test--error-bad-path.txt @@ -0,0 +1,12 @@ + +๐Ÿงช Swift Package Test + + Scheme: NONEXISTENT + Configuration: debug + Platform: Swift Package + +Errors (1): + + โœ— chdir error: No such file or directory (2): /example_projects/NONEXISTENT + +โŒ Test failed. (, โฑ๏ธ ) diff --git a/src/snapshot-tests/__fixtures__/swift-package/test--failure.txt b/src/snapshot-tests/__fixtures__/swift-package/test--failure.txt new file mode 100644 index 00000000..71560876 --- /dev/null +++ b/src/snapshot-tests/__fixtures__/swift-package/test--failure.txt @@ -0,0 +1,12 @@ + +๐Ÿงช Swift Package Test + + Scheme: spm + Configuration: debug + Platform: Swift Package + + โœ— Expected failure: Expectation failed: true == false +This test should fail, and is for simulating a test failure + SimpleTests.swift:48 + +โŒ Test failed. (, โฑ๏ธ ) diff --git a/src/snapshot-tests/__fixtures__/swift-package/test--success.txt b/src/snapshot-tests/__fixtures__/swift-package/test--success.txt index 8d4106af..024235aa 100644 --- a/src/snapshot-tests/__fixtures__/swift-package/test--success.txt +++ b/src/snapshot-tests/__fixtures__/swift-package/test--success.txt @@ -1,25 +1,8 @@ ๐Ÿงช Swift Package Test - Package: /example_projects/spm + Scheme: spm + Configuration: debug + Platform: Swift Package -Output - Test Suite 'All tests' started at . -Test Suite 'All tests' passed at . - Executed 0 tests, with 0 failures (0 unexpected) in 0.000 () seconds -โ—‡ Test run started. -โ†ณ Testing Library Version: 1743 -โ†ณ Target Platform: arm64e-apple-macos14.0 -โ—‡ Test "Array operations" started. -โ—‡ Test "Basic math operations" started. -โ—‡ Test "Basic truth assertions" started. -โ—‡ Test "Optional handling" started. -โ—‡ Test "String operations" started. -โœ” Test "Array operations" passed after seconds. -โœ” Test "Basic math operations" passed after seconds. -โœ” Test "Basic truth assertions" passed after seconds. -โœ” Test "Optional handling" passed after seconds. -โœ” Test "String operations" passed after seconds. -โœ” Test run with 5 tests in 0 suites passed after seconds. - -โœ… Swift package tests completed +โœ… Test succeeded. (, โฑ๏ธ ) diff --git a/src/snapshot-tests/__fixtures__/ui-automation/button--error-no-simulator.txt b/src/snapshot-tests/__fixtures__/ui-automation/button--error-no-simulator.txt new file mode 100644 index 00000000..79190ab0 --- /dev/null +++ b/src/snapshot-tests/__fixtures__/ui-automation/button--error-no-simulator.txt @@ -0,0 +1,9 @@ + +๐Ÿ‘† Button + + Simulator: + +โŒ Failed to press button 'home': axe command 'button' failed. + +Details + Error: CLIError(errorDescription: "Simulator with UDID not found in set.") diff --git a/src/snapshot-tests/__fixtures__/ui-automation/gesture--error-no-simulator.txt b/src/snapshot-tests/__fixtures__/ui-automation/gesture--error-no-simulator.txt new file mode 100644 index 00000000..3e391377 --- /dev/null +++ b/src/snapshot-tests/__fixtures__/ui-automation/gesture--error-no-simulator.txt @@ -0,0 +1,9 @@ + +๐Ÿ‘† Gesture + + Simulator: + +โŒ Failed to execute gesture 'scroll-down': axe command 'gesture' failed. + +Details + Error: CLIError(errorDescription: "Simulator with UDID not found in set.") diff --git a/src/snapshot-tests/__fixtures__/ui-automation/key-press--error-no-simulator.txt b/src/snapshot-tests/__fixtures__/ui-automation/key-press--error-no-simulator.txt new file mode 100644 index 00000000..3be3e3c9 --- /dev/null +++ b/src/snapshot-tests/__fixtures__/ui-automation/key-press--error-no-simulator.txt @@ -0,0 +1,9 @@ + +โŒจ๏ธ Key Press + + Simulator: + +โŒ Failed to simulate key press (code: 4): axe command 'key' failed. + +Details + Error: CLIError(errorDescription: "Simulator with UDID not found in set.") diff --git a/src/snapshot-tests/__fixtures__/ui-automation/key-sequence--error-no-simulator.txt b/src/snapshot-tests/__fixtures__/ui-automation/key-sequence--error-no-simulator.txt new file mode 100644 index 00000000..3b886b57 --- /dev/null +++ b/src/snapshot-tests/__fixtures__/ui-automation/key-sequence--error-no-simulator.txt @@ -0,0 +1,9 @@ + +โŒจ๏ธ Key Sequence + + Simulator: + +โŒ Failed to execute key sequence: axe command 'key-sequence' failed. + +Details + Error: CLIError(errorDescription: "Simulator with UDID not found in set.") diff --git a/src/snapshot-tests/__fixtures__/ui-automation/long-press--error-no-simulator.txt b/src/snapshot-tests/__fixtures__/ui-automation/long-press--error-no-simulator.txt new file mode 100644 index 00000000..006987ff --- /dev/null +++ b/src/snapshot-tests/__fixtures__/ui-automation/long-press--error-no-simulator.txt @@ -0,0 +1,9 @@ + +๐Ÿ‘† Long Press + + Simulator: + +โŒ Failed to simulate long press at (100, 400): axe command 'touch' failed. + +Details + Error: CLIError(errorDescription: "Simulator with UDID not found in set.") diff --git a/src/snapshot-tests/__fixtures__/ui-automation/snapshot-ui--error-no-simulator.txt b/src/snapshot-tests/__fixtures__/ui-automation/snapshot-ui--error-no-simulator.txt new file mode 100644 index 00000000..f55fe099 --- /dev/null +++ b/src/snapshot-tests/__fixtures__/ui-automation/snapshot-ui--error-no-simulator.txt @@ -0,0 +1,9 @@ + +๐Ÿ“ท Snapshot UI + + Simulator: + +โŒ Failed to get accessibility hierarchy: axe command 'describe-ui' failed. + +Details + Error: CLIError(errorDescription: "Simulator with UDID not found in set.") diff --git a/src/snapshot-tests/__fixtures__/ui-automation/swipe--error-no-simulator.txt b/src/snapshot-tests/__fixtures__/ui-automation/swipe--error-no-simulator.txt new file mode 100644 index 00000000..c7d6bf08 --- /dev/null +++ b/src/snapshot-tests/__fixtures__/ui-automation/swipe--error-no-simulator.txt @@ -0,0 +1,9 @@ + +๐Ÿ‘† Swipe + + Simulator: + +โŒ Failed to simulate swipe: axe command 'swipe' failed. + +Details + Error: CLIError(errorDescription: "Simulator with UDID not found in set.") diff --git a/src/snapshot-tests/__fixtures__/ui-automation/touch--error-no-simulator.txt b/src/snapshot-tests/__fixtures__/ui-automation/touch--error-no-simulator.txt new file mode 100644 index 00000000..b9607fd0 --- /dev/null +++ b/src/snapshot-tests/__fixtures__/ui-automation/touch--error-no-simulator.txt @@ -0,0 +1,9 @@ + +๐Ÿ‘† Touch + + Simulator: + +โŒ Failed to execute touch event: axe command 'touch' failed. + +Details + Error: CLIError(errorDescription: "Simulator with UDID not found in set.") diff --git a/src/snapshot-tests/__fixtures__/ui-automation/type-text--error-no-simulator.txt b/src/snapshot-tests/__fixtures__/ui-automation/type-text--error-no-simulator.txt new file mode 100644 index 00000000..7d305290 --- /dev/null +++ b/src/snapshot-tests/__fixtures__/ui-automation/type-text--error-no-simulator.txt @@ -0,0 +1,9 @@ + +โŒจ๏ธ Type Text + + Simulator: + +โŒ Failed to simulate text typing: axe command 'type' failed. + +Details + Error: CLIError(errorDescription: "Simulator with UDID not found in set.") diff --git a/src/snapshot-tests/__fixtures__/utilities/clean--error-wrong-scheme.txt b/src/snapshot-tests/__fixtures__/utilities/clean--error-wrong-scheme.txt new file mode 100644 index 00000000..16de0148 --- /dev/null +++ b/src/snapshot-tests/__fixtures__/utilities/clean--error-wrong-scheme.txt @@ -0,0 +1,12 @@ + +๐Ÿงน Clean + + Scheme: NONEXISTENT + Configuration: Debug + Platform: iOS + +Errors (1): + + โœ— The workspace named "CalculatorApp" does not contain a scheme named "NONEXISTENT". The "-list" option can be used to find the names of the schemes in the workspace. + +โŒ Build failed. (โฑ๏ธ ) diff --git a/src/snapshot-tests/__tests__/debugging.snapshot.test.ts b/src/snapshot-tests/__tests__/debugging.snapshot.test.ts index bc98a65a..84c7fe8a 100644 --- a/src/snapshot-tests/__tests__/debugging.snapshot.test.ts +++ b/src/snapshot-tests/__tests__/debugging.snapshot.test.ts @@ -181,5 +181,27 @@ describe('debugging workflow', () => { expect(isError).toBe(false); expectMatchesFixture(text, __filename, 'detach--success'); }, 30_000); + + it('attach - success (continue on attach)', async () => { + execSync( + `xcrun simctl launch --terminate-running-process ${simulatorUdid} ${BUNDLE_ID}`, + { encoding: 'utf8', stdio: 'pipe' }, + ); + await new Promise((resolve) => setTimeout(resolve, 2000)); + + const { text, isError } = await harness.invoke('debugging', 'attach', { + simulatorId: simulatorUdid, + bundleId: BUNDLE_ID, + continueOnAttach: true, + }); + expect(isError).toBe(false); + expect(text.length).toBeGreaterThan(10); + expectMatchesFixture(text, __filename, 'attach--success-continue'); + }, 30_000); + + it('detach after continue-on-attach', async () => { + const { isError } = await harness.invoke('debugging', 'detach', {}); + expect(isError).toBe(false); + }, 30_000); }); }); diff --git a/src/snapshot-tests/__tests__/logging.snapshot.test.ts b/src/snapshot-tests/__tests__/logging.snapshot.test.ts index 5b5193f2..fae20913 100644 --- a/src/snapshot-tests/__tests__/logging.snapshot.test.ts +++ b/src/snapshot-tests/__tests__/logging.snapshot.test.ts @@ -39,6 +39,8 @@ describe('logging workflow', () => { expect(text.length).toBeGreaterThan(0); expectMatchesFixture(text, __filename, 'start-sim-log--success'); }, 30_000); + + }); describe('stop-simulator-log-capture', () => { diff --git a/src/snapshot-tests/__tests__/macos.snapshot.test.ts b/src/snapshot-tests/__tests__/macos.snapshot.test.ts index 115b1c13..97dca326 100644 --- a/src/snapshot-tests/__tests__/macos.snapshot.test.ts +++ b/src/snapshot-tests/__tests__/macos.snapshot.test.ts @@ -1,4 +1,5 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { execSync } from 'node:child_process'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; @@ -24,8 +25,10 @@ describe('macos workflow', () => { bundleIdAppPath = path.join(tmpDir, 'BundleTest.app'); fs.mkdirSync(bundleIdAppPath); + const contentsDir = path.join(bundleIdAppPath, 'Contents'); + fs.mkdirSync(contentsDir); fs.writeFileSync( - path.join(bundleIdAppPath, 'Info.plist'), + path.join(contentsDir, 'Info.plist'), ` @@ -54,6 +57,15 @@ describe('macos workflow', () => { expect(text.length).toBeGreaterThan(10); expectMatchesFixture(text, __filename, 'build--success'); }); + + it('error - wrong scheme', { timeout: 120000 }, async () => { + const { text, isError } = await harness.invoke('macos', 'build', { + projectPath: PROJECT, + scheme: 'NONEXISTENT', + }); + expect(isError).toBe(true); + expectMatchesFixture(text, __filename, 'build--error-wrong-scheme'); + }); }); describe('build-and-run', () => { @@ -66,6 +78,15 @@ describe('macos workflow', () => { expect(text.length).toBeGreaterThan(10); expectMatchesFixture(text, __filename, 'build-and-run--success'); }); + + it('error - wrong scheme', { timeout: 120000 }, async () => { + const { text, isError } = await harness.invoke('macos', 'build-and-run', { + projectPath: PROJECT, + scheme: 'NONEXISTENT', + }); + expect(isError).toBe(true); + expectMatchesFixture(text, __filename, 'build-and-run--error-wrong-scheme'); + }); }); describe('test', () => { @@ -73,11 +94,31 @@ describe('macos workflow', () => { const { text, isError } = await harness.invoke('macos', 'test', { projectPath: PROJECT, scheme: 'MCPTest', + extraArgs: ['-only-testing:MCPTestTests/MCPTestTests/appNameIsCorrect'], }); expect(isError).toBe(false); expect(text.length).toBeGreaterThan(10); expectMatchesFixture(text, __filename, 'test--success'); }); + + it('failure - intentional test failure', { timeout: 120000 }, async () => { + const { text, isError } = await harness.invoke('macos', 'test', { + projectPath: PROJECT, + scheme: 'MCPTest', + }); + expect(isError).toBe(true); + expect(text.length).toBeGreaterThan(10); + expectMatchesFixture(text, __filename, 'test--failure'); + }); + + it('error - wrong scheme', { timeout: 120000 }, async () => { + const { text, isError } = await harness.invoke('macos', 'test', { + projectPath: PROJECT, + scheme: 'NONEXISTENT', + }); + expect(isError).toBe(true); + expectMatchesFixture(text, __filename, 'test--error-wrong-scheme'); + }); }); describe('get-app-path', () => { @@ -90,23 +131,67 @@ describe('macos workflow', () => { expect(text.length).toBeGreaterThan(10); expectMatchesFixture(text, __filename, 'get-app-path--success'); }); + + it('error - wrong scheme', { timeout: 120000 }, async () => { + const { text, isError } = await harness.invoke('macos', 'get-app-path', { + projectPath: PROJECT, + scheme: 'NONEXISTENT', + }); + expect(isError).toBe(true); + expectMatchesFixture(text, __filename, 'get-app-path--error-wrong-scheme'); + }); }); describe('launch', () => { + it('success', { timeout: 120000 }, async () => { + const settingsOutput = execSync( + `xcodebuild -project ${PROJECT} -scheme MCPTest -showBuildSettings 2>/dev/null`, + { encoding: 'utf8' }, + ); + const match = settingsOutput.match(/BUILT_PRODUCTS_DIR = (.+)/); + const appPath = `${match![1]!.trim()}/MCPTest.app`; + + const { text, isError } = await harness.invoke('macos', 'launch', { + appPath, + }); + expect(isError).toBe(false); + expectMatchesFixture(text, __filename, 'launch--success'); + }); + it('error - invalid app', { timeout: 120000 }, async () => { - const { text } = await harness.invoke('macos', 'launch', { - appPath: fakeAppPath, + const nonExistentApp = path.join(tmpDir, 'NonExistent.app'); + const { text, isError } = await harness.invoke('macos', 'launch', { + appPath: nonExistentApp, }); + expect(isError).toBe(true); expect(text.length).toBeGreaterThan(0); expectMatchesFixture(text, __filename, 'launch--error-invalid-app'); }); }); describe('stop', () => { + it('success', { timeout: 120000 }, async () => { + const settingsOutput = execSync( + `xcodebuild -project ${PROJECT} -scheme MCPTest -showBuildSettings 2>/dev/null`, + { encoding: 'utf8' }, + ); + const match = settingsOutput.match(/BUILT_PRODUCTS_DIR = (.+)/); + const appPath = `${match![1]!.trim()}/MCPTest.app`; + + await harness.invoke('macos', 'launch', { appPath }); + + const { text, isError } = await harness.invoke('macos', 'stop', { + appName: 'MCPTest', + }); + expect(isError).toBe(false); + expectMatchesFixture(text, __filename, 'stop--success'); + }); + it('error - no app', { timeout: 120000 }, async () => { - const { text } = await harness.invoke('macos', 'stop', { - appName: 'NonExistentXBMTestApp', + const { text, isError } = await harness.invoke('macos', 'stop', { + processId: 999999, }); + expect(isError).toBe(true); expect(text.length).toBeGreaterThan(0); expectMatchesFixture(text, __filename, 'stop--error-no-app'); }); @@ -114,11 +199,20 @@ describe('macos workflow', () => { describe('get-macos-bundle-id', () => { it('success', { timeout: 120000 }, async () => { - const { text } = await harness.invoke('macos', 'get-macos-bundle-id', { + const { text, isError } = await harness.invoke('macos', 'get-macos-bundle-id', { appPath: bundleIdAppPath, }); + expect(isError).toBe(false); expect(text.length).toBeGreaterThan(0); expectMatchesFixture(text, __filename, 'get-macos-bundle-id--success'); }); + + it('error - missing app', { timeout: 120000 }, async () => { + const { text, isError } = await harness.invoke('macos', 'get-macos-bundle-id', { + appPath: '/nonexistent/path/Fake.app', + }); + expect(isError).toBe(true); + expectMatchesFixture(text, __filename, 'get-macos-bundle-id--error-missing-app'); + }); }); }); diff --git a/src/snapshot-tests/__tests__/project-discovery.snapshot.test.ts b/src/snapshot-tests/__tests__/project-discovery.snapshot.test.ts index 1fac39ad..a244f362 100644 --- a/src/snapshot-tests/__tests__/project-discovery.snapshot.test.ts +++ b/src/snapshot-tests/__tests__/project-discovery.snapshot.test.ts @@ -28,6 +28,19 @@ describe('project-discovery workflow', () => { CFBundleIdentifier com.test.snapshot +`, + ); + const contentsDir = path.join(bundleIdAppPath, 'Contents'); + fs.mkdirSync(contentsDir); + fs.writeFileSync( + path.join(contentsDir, 'Info.plist'), + ` + + + + CFBundleIdentifier + com.test.snapshot + `, ); }); @@ -48,6 +61,14 @@ describe('project-discovery workflow', () => { expect(text.length).toBeGreaterThan(10); expectMatchesFixture(text, __filename, 'list-schemes--success'); }); + + it('error - invalid workspace', async () => { + const { text, isError } = await harness.invoke('project-discovery', 'list-schemes', { + workspacePath: '/nonexistent/path/Fake.xcworkspace', + }); + expect(isError).toBe(true); + expectMatchesFixture(text, __filename, 'list-schemes--error-invalid-workspace'); + }); }); describe('show-build-settings', () => { @@ -60,6 +81,15 @@ describe('project-discovery workflow', () => { expect(text.length).toBeGreaterThan(10); expectMatchesFixture(text, __filename, 'show-build-settings--success'); }); + + it('error - wrong scheme', async () => { + const { text, isError } = await harness.invoke('project-discovery', 'show-build-settings', { + workspacePath: WORKSPACE, + scheme: 'NONEXISTENT', + }); + expect(isError).toBe(true); + expectMatchesFixture(text, __filename, 'show-build-settings--error-wrong-scheme'); + }); }); describe('discover-projs', () => { @@ -70,6 +100,14 @@ describe('project-discovery workflow', () => { expect(isError).toBe(false); expectMatchesFixture(text, __filename, 'discover-projs--success'); }); + + it('error - invalid root', async () => { + const { text, isError } = await harness.invoke('project-discovery', 'discover-projects', { + workspaceRoot: '/nonexistent/path', + }); + expect(isError).toBe(true); + expectMatchesFixture(text, __filename, 'discover-projs--error-invalid-root'); + }); }); describe('get-app-bundle-id', () => { @@ -81,15 +119,32 @@ describe('project-discovery workflow', () => { expect(text.length).toBeGreaterThan(0); expectMatchesFixture(text, __filename, 'get-app-bundle-id--success'); }); + + it('error - missing app', async () => { + const { text, isError } = await harness.invoke('project-discovery', 'get-app-bundle-id', { + appPath: '/nonexistent/path/Fake.app', + }); + expect(isError).toBe(true); + expectMatchesFixture(text, __filename, 'get-app-bundle-id--error-missing-app'); + }); }); describe('get-macos-bundle-id', () => { it('success', async () => { - const { text } = await harness.invoke('project-discovery', 'get-macos-bundle-id', { + const { text, isError } = await harness.invoke('project-discovery', 'get-macos-bundle-id', { appPath: bundleIdAppPath, }); + expect(isError).toBe(false); expect(text.length).toBeGreaterThan(0); expectMatchesFixture(text, __filename, 'get-macos-bundle-id--success'); }); + + it('error - missing app', async () => { + const { text, isError } = await harness.invoke('project-discovery', 'get-macos-bundle-id', { + appPath: '/nonexistent/path/Fake.app', + }); + expect(isError).toBe(true); + expectMatchesFixture(text, __filename, 'get-macos-bundle-id--error-missing-app'); + }); }); }); diff --git a/src/snapshot-tests/__tests__/project-scaffolding.snapshot.test.ts b/src/snapshot-tests/__tests__/project-scaffolding.snapshot.test.ts index 83e3c31e..235574fb 100644 --- a/src/snapshot-tests/__tests__/project-scaffolding.snapshot.test.ts +++ b/src/snapshot-tests/__tests__/project-scaffolding.snapshot.test.ts @@ -72,5 +72,26 @@ describe('project-scaffolding workflow', () => { expect(text.length).toBeGreaterThan(10); expectMatchesFixture(normalizeTmpDir(text, tmpDir), __filename, 'scaffold-macos--success'); }, 120000); + + it('error - existing project', async () => { + const outputPath = join(tmpDir, 'macos-existing'); + mkdirSync(outputPath, { recursive: true }); + + await harness.invoke('project-scaffolding', 'scaffold-macos', { + projectName: 'SnapshotTestMacApp', + outputPath, + }); + + const { text, isError } = await harness.invoke('project-scaffolding', 'scaffold-macos', { + projectName: 'SnapshotTestMacApp', + outputPath, + }); + expect(isError).toBe(true); + expectMatchesFixture( + normalizeTmpDir(text, tmpDir), + __filename, + 'scaffold-macos--error-existing', + ); + }, 120000); }); }); diff --git a/src/snapshot-tests/__tests__/simulator-management.snapshot.test.ts b/src/snapshot-tests/__tests__/simulator-management.snapshot.test.ts index ad5c6f15..4d62dfae 100644 --- a/src/snapshot-tests/__tests__/simulator-management.snapshot.test.ts +++ b/src/snapshot-tests/__tests__/simulator-management.snapshot.test.ts @@ -51,6 +51,15 @@ describe('simulator-management workflow', () => { expect(isError).toBe(false); expectMatchesFixture(text, __filename, 'set-appearance--success'); }); + + it('error - invalid simulator', async () => { + const { text, isError } = await harness.invoke('simulator-management', 'set-appearance', { + simulatorId: '00000000-0000-0000-0000-000000000000', + mode: 'dark', + }); + expect(isError).toBe(true); + expectMatchesFixture(text, __filename, 'set-appearance--error-invalid-simulator'); + }); }); describe('set-location', () => { @@ -63,6 +72,16 @@ describe('simulator-management workflow', () => { expect(isError).toBe(false); expectMatchesFixture(text, __filename, 'set-location--success'); }); + + it('error - invalid simulator', async () => { + const { text, isError } = await harness.invoke('simulator-management', 'set-location', { + simulatorId: '00000000-0000-0000-0000-000000000000', + latitude: 37.7749, + longitude: -122.4194, + }); + expect(isError).toBe(true); + expectMatchesFixture(text, __filename, 'set-location--error-invalid-simulator'); + }); }); describe('reset-location', () => { @@ -73,6 +92,14 @@ describe('simulator-management workflow', () => { expect(isError).toBe(false); expectMatchesFixture(text, __filename, 'reset-location--success'); }); + + it('error - invalid simulator', async () => { + const { text, isError } = await harness.invoke('simulator-management', 'reset-location', { + simulatorId: '00000000-0000-0000-0000-000000000000', + }); + expect(isError).toBe(true); + expectMatchesFixture(text, __filename, 'reset-location--error-invalid-simulator'); + }); }); describe('statusbar', () => { @@ -84,9 +111,26 @@ describe('simulator-management workflow', () => { expect(isError).toBe(false); expectMatchesFixture(text, __filename, 'statusbar--success'); }); + + it('error - invalid simulator', async () => { + const { text, isError } = await harness.invoke('simulator-management', 'statusbar', { + simulatorId: '00000000-0000-0000-0000-000000000000', + dataNetwork: 'wifi', + }); + expect(isError).toBe(true); + expectMatchesFixture(text, __filename, 'statusbar--error-invalid-simulator'); + }); }); describe('erase', () => { - it.skip('destructive - erases simulator content', () => {}); + it('error - invalid id', async () => { + const { text, isError } = await harness.invoke('simulator-management', 'erase', { + simulatorId: '00000000-0000-0000-0000-000000000000', + }); + expect(isError).toBe(true); + expectMatchesFixture(text, __filename, 'erase--error-invalid-id'); + }); + + it.skip('success - destructive, requires throwaway simulator', () => {}); }); }); diff --git a/src/snapshot-tests/__tests__/simulator.snapshot.test.ts b/src/snapshot-tests/__tests__/simulator.snapshot.test.ts index 02c347d8..97a43f18 100644 --- a/src/snapshot-tests/__tests__/simulator.snapshot.test.ts +++ b/src/snapshot-tests/__tests__/simulator.snapshot.test.ts @@ -1,4 +1,5 @@ import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; +import { execSync } from 'node:child_process'; import { createSnapshotHarness, ensureSimulatorBooted } from '../harness.ts'; import { expectMatchesFixture } from '../fixture-io.ts'; import type { SnapshotHarness } from '../harness.ts'; @@ -53,6 +54,16 @@ describe('simulator workflow', () => { expect(text.length).toBeGreaterThan(10); expectMatchesFixture(text, __filename, 'build-and-run--success'); }, 120_000); + + it('error - wrong scheme', async () => { + const { text, isError } = await harness.invoke('simulator', 'build-and-run', { + workspacePath: WORKSPACE, + scheme: 'NONEXISTENT', + simulatorName: 'iPhone 17', + }); + expect(isError).toBe(true); + expectMatchesFixture(text, __filename, 'build-and-run--error-wrong-scheme'); + }, 120_000); }); describe('test', () => { @@ -78,6 +89,16 @@ describe('simulator workflow', () => { expect(text.length).toBeGreaterThan(10); expectMatchesFixture(text, __filename, 'test--failure'); }, 120_000); + + it('error - wrong scheme', async () => { + const { text, isError } = await harness.invoke('simulator', 'test', { + workspacePath: WORKSPACE, + scheme: 'NONEXISTENT', + simulatorName: 'iPhone 17', + }); + expect(isError).toBe(true); + expectMatchesFixture(text, __filename, 'test--error-wrong-scheme'); + }, 120_000); }); describe('get-app-path', () => { @@ -92,6 +113,17 @@ describe('simulator workflow', () => { expect(text.length).toBeGreaterThan(10); expectMatchesFixture(text, __filename, 'get-app-path--success'); }, 120_000); + + it('error - wrong scheme', async () => { + const { text, isError } = await harness.invoke('simulator', 'get-app-path', { + workspacePath: WORKSPACE, + scheme: 'NONEXISTENT', + platform: 'iOS Simulator', + simulatorName: 'iPhone 17', + }); + expect(isError).toBe(true); + expectMatchesFixture(text, __filename, 'get-app-path--error-wrong-scheme'); + }, 120_000); }); describe('list', () => { @@ -104,7 +136,21 @@ describe('simulator workflow', () => { }); describe('install', () => { - it.skip('success - requires dynamic built app path', async () => {}); + it('success', async () => { + const settingsOutput = execSync( + `xcodebuild -workspace ${WORKSPACE} -scheme CalculatorApp -showBuildSettings -destination 'platform=iOS Simulator,name=iPhone 17' 2>/dev/null`, + { encoding: 'utf8' }, + ); + const match = settingsOutput.match(/BUILT_PRODUCTS_DIR = (.+)/); + const appPath = `${match![1]!.trim()}/CalculatorApp.app`; + + const { text, isError } = await harness.invoke('simulator', 'install', { + simulatorId: simulatorUdid, + appPath, + }); + expect(isError).toBe(false); + expectMatchesFixture(text, __filename, 'install--success'); + }, 120_000); it('error - invalid app', async () => { const fs = await import('node:fs'); @@ -135,6 +181,15 @@ describe('simulator workflow', () => { expect(isError).toBe(false); expectMatchesFixture(text, __filename, 'launch-app--success'); }, 120_000); + + it('error - not installed', async () => { + const { text, isError } = await harness.invoke('simulator', 'launch-app', { + simulatorId: simulatorUdid, + bundleId: 'com.nonexistent.app', + }); + expect(isError).toBe(true); + expectMatchesFixture(text, __filename, 'launch-app--error-not-installed'); + }, 120_000); }); describe('launch-app-with-logs', () => { @@ -146,6 +201,8 @@ describe('simulator workflow', () => { expect(isError).toBe(false); expectMatchesFixture(text, __filename, 'launch-app-with-logs--success'); }, 120_000); + + }); describe('screenshot', () => { @@ -157,6 +214,15 @@ describe('simulator workflow', () => { expect(isError).toBe(false); expectMatchesFixture(text, __filename, 'screenshot--success'); }, 120_000); + + it('error - invalid simulator', async () => { + const { text, isError } = await harness.invoke('simulator', 'screenshot', { + simulatorId: '00000000-0000-0000-0000-000000000000', + returnFormat: 'path', + }); + expect(isError).toBe(true); + expectMatchesFixture(text, __filename, 'screenshot--error-invalid-simulator'); + }, 120_000); }); describe('record-video', () => { @@ -164,6 +230,20 @@ describe('simulator workflow', () => { }); describe('stop', () => { + it('success', async () => { + await harness.invoke('simulator', 'launch-app', { + simulatorId: simulatorUdid, + bundleId: 'io.sentry.calculatorapp', + }); + + const { text, isError } = await harness.invoke('simulator', 'stop', { + simulatorId: simulatorUdid, + bundleId: 'io.sentry.calculatorapp', + }); + expect(isError).toBe(false); + expectMatchesFixture(text, __filename, 'stop--success'); + }, 120_000); + it('error - no app', async () => { const { text, isError } = await harness.invoke('simulator', 'stop', { simulatorId: simulatorUdid, diff --git a/src/snapshot-tests/__tests__/swift-package.snapshot.test.ts b/src/snapshot-tests/__tests__/swift-package.snapshot.test.ts index 3ccf372e..88356fb5 100644 --- a/src/snapshot-tests/__tests__/swift-package.snapshot.test.ts +++ b/src/snapshot-tests/__tests__/swift-package.snapshot.test.ts @@ -40,11 +40,29 @@ describe('swift-package workflow', () => { it('success', async () => { const { text, isError } = await harness.invoke('swift-package', 'test', { packagePath: PACKAGE_PATH, + filter: 'basicTruthTest', }); expect(isError).toBe(false); expect(text.length).toBeGreaterThan(10); expectMatchesFixture(text, __filename, 'test--success'); }, 120_000); + + it('failure - intentional test failure', async () => { + const { text, isError } = await harness.invoke('swift-package', 'test', { + packagePath: PACKAGE_PATH, + }); + expect(isError).toBe(true); + expect(text.length).toBeGreaterThan(10); + expectMatchesFixture(text, __filename, 'test--failure'); + }, 120_000); + + it('error - bad path', async () => { + const { text, isError } = await harness.invoke('swift-package', 'test', { + packagePath: 'example_projects/NONEXISTENT', + }); + expect(isError).toBe(true); + expectMatchesFixture(text, __filename, 'test--error-bad-path'); + }); }); describe('clean', () => { @@ -55,6 +73,14 @@ describe('swift-package workflow', () => { expect(isError).toBe(false); expectMatchesFixture(text, __filename, 'clean--success'); }); + + it('error - bad path', async () => { + const { text, isError } = await harness.invoke('swift-package', 'clean', { + packagePath: 'example_projects/NONEXISTENT', + }); + expect(isError).toBe(true); + expectMatchesFixture(text, __filename, 'clean--error-bad-path'); + }); }); describe('run', () => { @@ -67,6 +93,15 @@ describe('swift-package workflow', () => { expect(text.length).toBeGreaterThan(0); expectMatchesFixture(text, __filename, 'run--success'); }, 120_000); + + it('error - bad executable', async () => { + const { text, isError } = await harness.invoke('swift-package', 'run', { + packagePath: PACKAGE_PATH, + executableName: 'nonexistent-executable', + }); + expect(isError).toBe(true); + expectMatchesFixture(text, __filename, 'run--error-bad-executable'); + }, 120_000); }); describe('list', () => { diff --git a/src/snapshot-tests/__tests__/ui-automation.snapshot.test.ts b/src/snapshot-tests/__tests__/ui-automation.snapshot.test.ts index 25ea2b32..97f3a8fe 100644 --- a/src/snapshot-tests/__tests__/ui-automation.snapshot.test.ts +++ b/src/snapshot-tests/__tests__/ui-automation.snapshot.test.ts @@ -44,6 +44,14 @@ describe('ui-automation workflow', () => { expect(text.length).toBeGreaterThan(100); expectMatchesFixture(text, __filename, 'snapshot-ui--success'); }); + + it('error - invalid simulator', async () => { + const { text, isError } = await harness.invoke('ui-automation', 'snapshot-ui', { + simulatorId: INVALID_SIMULATOR_ID, + }); + expect(isError).toBe(true); + expectMatchesFixture(text, __filename, 'snapshot-ui--error-no-simulator'); + }); }); describe('tap', () => { @@ -80,6 +88,18 @@ describe('ui-automation workflow', () => { expect(isError).toBe(false); expectMatchesFixture(text, __filename, 'touch--success'); }); + + it('error - invalid simulator', async () => { + const { text, isError } = await harness.invoke('ui-automation', 'touch', { + simulatorId: INVALID_SIMULATOR_ID, + x: 100, + y: 400, + down: true, + up: true, + }); + expect(isError).toBe(true); + expectMatchesFixture(text, __filename, 'touch--error-no-simulator'); + }); }); describe('long-press', () => { @@ -93,6 +113,17 @@ describe('ui-automation workflow', () => { expect(isError).toBe(false); expectMatchesFixture(text, __filename, 'long-press--success'); }); + + it('error - invalid simulator', async () => { + const { text, isError } = await harness.invoke('ui-automation', 'long-press', { + simulatorId: INVALID_SIMULATOR_ID, + x: 100, + y: 400, + duration: 500, + }); + expect(isError).toBe(true); + expectMatchesFixture(text, __filename, 'long-press--error-no-simulator'); + }); }); describe('swipe', () => { @@ -107,6 +138,18 @@ describe('ui-automation workflow', () => { expect(isError).toBe(false); expectMatchesFixture(text, __filename, 'swipe--success'); }); + + it('error - invalid simulator', async () => { + const { text, isError } = await harness.invoke('ui-automation', 'swipe', { + simulatorId: INVALID_SIMULATOR_ID, + x1: 200, + y1: 400, + x2: 200, + y2: 200, + }); + expect(isError).toBe(true); + expectMatchesFixture(text, __filename, 'swipe--error-no-simulator'); + }); }); describe('gesture', () => { @@ -118,6 +161,15 @@ describe('ui-automation workflow', () => { expect(isError).toBe(false); expectMatchesFixture(text, __filename, 'gesture--success'); }); + + it('error - invalid simulator', async () => { + const { text, isError } = await harness.invoke('ui-automation', 'gesture', { + simulatorId: INVALID_SIMULATOR_ID, + preset: 'scroll-down', + }); + expect(isError).toBe(true); + expectMatchesFixture(text, __filename, 'gesture--error-no-simulator'); + }); }); describe('button', () => { @@ -129,6 +181,15 @@ describe('ui-automation workflow', () => { expect(isError).toBe(false); expectMatchesFixture(text, __filename, 'button--success'); }); + + it('error - invalid simulator', async () => { + const { text, isError } = await harness.invoke('ui-automation', 'button', { + simulatorId: INVALID_SIMULATOR_ID, + buttonType: 'home', + }); + expect(isError).toBe(true); + expectMatchesFixture(text, __filename, 'button--error-no-simulator'); + }); }); describe('key-press', () => { @@ -140,6 +201,15 @@ describe('ui-automation workflow', () => { expect(isError).toBe(false); expectMatchesFixture(text, __filename, 'key-press--success'); }); + + it('error - invalid simulator', async () => { + const { text, isError } = await harness.invoke('ui-automation', 'key-press', { + simulatorId: INVALID_SIMULATOR_ID, + keyCode: 4, + }); + expect(isError).toBe(true); + expectMatchesFixture(text, __filename, 'key-press--error-no-simulator'); + }); }); describe('key-sequence', () => { @@ -151,6 +221,15 @@ describe('ui-automation workflow', () => { expect(isError).toBe(false); expectMatchesFixture(text, __filename, 'key-sequence--success'); }); + + it('error - invalid simulator', async () => { + const { text, isError } = await harness.invoke('ui-automation', 'key-sequence', { + simulatorId: INVALID_SIMULATOR_ID, + keyCodes: [4, 5, 6], + }); + expect(isError).toBe(true); + expectMatchesFixture(text, __filename, 'key-sequence--error-no-simulator'); + }); }); describe('type-text', () => { @@ -162,5 +241,14 @@ describe('ui-automation workflow', () => { expect(isError).toBe(false); expectMatchesFixture(text, __filename, 'type-text--success'); }); + + it('error - invalid simulator', async () => { + const { text, isError } = await harness.invoke('ui-automation', 'type-text', { + simulatorId: INVALID_SIMULATOR_ID, + text: 'hello', + }); + expect(isError).toBe(true); + expectMatchesFixture(text, __filename, 'type-text--error-no-simulator'); + }); }); }); diff --git a/src/snapshot-tests/__tests__/utilities.snapshot.test.ts b/src/snapshot-tests/__tests__/utilities.snapshot.test.ts index 6906a9d5..e05b5e21 100644 --- a/src/snapshot-tests/__tests__/utilities.snapshot.test.ts +++ b/src/snapshot-tests/__tests__/utilities.snapshot.test.ts @@ -26,5 +26,14 @@ describe('utilities workflow', () => { expect(text.length).toBeGreaterThan(10); expectMatchesFixture(text, __filename, 'clean--success'); }, 120000); + + it('error - wrong scheme', async () => { + const { text, isError } = await harness.invoke('utilities', 'clean', { + workspacePath: WORKSPACE, + scheme: 'NONEXISTENT', + }); + expect(isError).toBe(true); + expectMatchesFixture(text, __filename, 'clean--error-wrong-scheme'); + }, 120000); }); }); diff --git a/src/snapshot-tests/normalize.ts b/src/snapshot-tests/normalize.ts index 5f389bc7..a381c19c 100644 --- a/src/snapshot-tests/normalize.ts +++ b/src/snapshot-tests/normalize.ts @@ -8,6 +8,11 @@ const DURATION_REGEX = /\d+\.\d+s\b/g; const PID_NUMBER_REGEX = /pid:\s*\d+/gi; const PID_JSON_REGEX = /"pid"\s*:\s*\d+/g; const PROCESS_ID_REGEX = /Process ID: \d+/g; +const PROCESS_INLINE_PID_REGEX = /process \d+/g; +const THREAD_ID_REGEX = /Thread \d{5,}/g; +const HEX_ADDRESS_REGEX = /0x[0-9a-fA-F]{8,}/g; +const LLDB_MODULE_DYLIB_REGEX = /CalculatorApp[^\s`]*/g; +const LLDB_FRAME_OFFSET_REGEX = /(`[^`]+):(\d+)$/gm; const DERIVED_DATA_HASH_REGEX = /(DerivedData\/[A-Za-z0-9_]+)-[a-z]{28}\b/g; const PROGRESS_LINE_REGEX = /^โ€บ.*\n*/gm; const WARNINGS_BLOCK_REGEX = /Warnings \(\d+\):\n(?:\n? *โš [^\n]*\n?)*/g; @@ -24,6 +29,7 @@ const SWIFT_TESTING_DURATION_REGEX = /after \d+\.\d+ seconds/g; const TEST_SUMMARY_COUNTS_REGEX = /\(Total: \d+(?:, Passed: \d+)?(?:, Failed: \d+)?(?:, Skipped: \d+)?, /g; const COVERAGE_CALL_COUNT_REGEX = /called \d+x\)/g; +const RESULT_BUNDLE_LINE_REGEX = /\S+\[\d+:\d+\] Writing error result bundle to \S+/g; const TRAILING_WHITESPACE_REGEX = /[ \t]+$/gm; function sortLinesInBlock(text: string, marker: RegExp): string { @@ -76,6 +82,12 @@ export function normalizeSnapshotOutput(text: string): string { normalized = normalized.replace(PID_NUMBER_REGEX, (match) => match.replace(/\d+/, '')); normalized = normalized.replace(PID_JSON_REGEX, '"pid" : '); normalized = normalized.replace(PROCESS_ID_REGEX, 'Process ID: '); + normalized = normalized.replace(PROCESS_INLINE_PID_REGEX, 'process '); + normalized = normalized.replace(THREAD_ID_REGEX, 'Thread '); + normalized = normalized.replace(HEX_ADDRESS_REGEX, ''); + normalized = normalized.replace(LLDB_MODULE_DYLIB_REGEX, ''); + normalized = normalized.replace(LLDB_FRAME_OFFSET_REGEX, '$1:'); + normalized = normalized.replace(RESULT_BUNDLE_LINE_REGEX, ''); normalized = normalized.replace(PROGRESS_LINE_REGEX, ''); normalized = normalized.replace(WARNINGS_BLOCK_REGEX, ''); normalized = normalized.replace(XCODE_INFRA_ERRORS_REGEX, ''); diff --git a/src/utils/__tests__/build-utils.test.ts b/src/utils/__tests__/build-utils.test.ts index fa885e97..a5db5615 100644 --- a/src/utils/__tests__/build-utils.test.ts +++ b/src/utils/__tests__/build-utils.test.ts @@ -83,7 +83,7 @@ describe('build-utils Sentry Classification', () => { ); expect(result.isError).toBe(true); - const stderrItem = result.content.find((c) => c.text.includes('[stderr]')); + const stderrItem = result.content.find((c) => c.type === 'text' && c.text.includes('[stderr]')); expect(stderrItem?.text).toContain('โŒ [stderr] project.xcodeproj cannot be opened'); }); @@ -103,7 +103,7 @@ describe('build-utils Sentry Classification', () => { ); expect(result.isError).toBe(true); - const stderrItem = result.content.find((c) => c.text.includes('[stderr]')); + const stderrItem = result.content.find((c) => c.type === 'text' && c.text.includes('[stderr]')); expect(stderrItem?.text).toContain('โŒ [stderr] Unable to find a destination matching'); }); @@ -123,7 +123,7 @@ describe('build-utils Sentry Classification', () => { ); expect(result.isError).toBe(true); - const stderrItem = result.content.find((c) => c.text.includes('[stderr]')); + const stderrItem = result.content.find((c) => c.type === 'text' && c.text.includes('[stderr]')); expect(stderrItem?.text).toContain('โŒ [stderr] Build failed with errors'); }); }); @@ -148,7 +148,7 @@ describe('build-utils Sentry Classification', () => { ); expect(result.isError).toBe(true); - const errorItem = result.content.find((c) => c.text.includes('Error during')); + const errorItem = result.content.find((c) => c.type === 'text' && c.text.includes('Error during')); expect(errorItem?.text).toContain('Error during Test Build build: spawn xcodebuild ENOENT'); }); @@ -171,7 +171,7 @@ describe('build-utils Sentry Classification', () => { ); expect(result.isError).toBe(true); - const errorItem = result.content.find((c) => c.text.includes('Error during')); + const errorItem = result.content.find((c) => c.type === 'text' && c.text.includes('Error during')); expect(errorItem?.text).toContain('Error during Test Build build: spawn xcodebuild EACCES'); }); @@ -194,7 +194,7 @@ describe('build-utils Sentry Classification', () => { ); expect(result.isError).toBe(true); - const errorItem = result.content.find((c) => c.text.includes('Error during')); + const errorItem = result.content.find((c) => c.type === 'text' && c.text.includes('Error during')); expect(errorItem?.text).toContain('Error during Test Build build: spawn xcodebuild EPERM'); }); @@ -216,7 +216,7 @@ describe('build-utils Sentry Classification', () => { ); expect(result.isError).toBe(true); - const errorItem = result.content.find((c) => c.text.includes('Error during')); + const errorItem = result.content.find((c) => c.type === 'text' && c.text.includes('Error during')); expect(errorItem?.text).toContain('Error during Test Build build: Unexpected internal error'); }); }); @@ -261,7 +261,7 @@ describe('build-utils Sentry Classification', () => { ); expect(result.isError).toBe(true); - const stderrItem = result.content.find((c) => c.text.includes('[stderr]')); + const stderrItem = result.content.find((c) => c.type === 'text' && c.text.includes('[stderr]')); expect(stderrItem?.text).toContain('โŒ [stderr] Some error without exit code'); }); }); diff --git a/src/utils/__tests__/swift-testing-line-parsers.test.ts b/src/utils/__tests__/swift-testing-line-parsers.test.ts new file mode 100644 index 00000000..1bcf9d0e --- /dev/null +++ b/src/utils/__tests__/swift-testing-line-parsers.test.ts @@ -0,0 +1,157 @@ +import { describe, it, expect } from 'vitest'; +import { + parseSwiftTestingResultLine, + parseSwiftTestingIssueLine, + parseSwiftTestingRunSummary, + parseSwiftTestingContinuationLine, + parseXcodebuildSwiftTestingLine, +} from '../swift-testing-line-parsers.ts'; + +describe('Swift Testing line parsers', () => { + describe('parseSwiftTestingResultLine', () => { + it('should parse a passed test', () => { + const result = parseSwiftTestingResultLine( + 'โœ” Test "Basic math operations" passed after 0.001 seconds.', + ); + expect(result).toEqual({ + status: 'passed', + rawName: 'Basic math operations', + testName: 'Basic math operations', + durationText: '0.001s', + }); + }); + + it('should parse a failed test', () => { + const result = parseSwiftTestingResultLine( + 'โœ˜ Test "Expected failure" failed after 0.001 seconds with 1 issue.', + ); + expect(result).toEqual({ + status: 'failed', + rawName: 'Expected failure', + testName: 'Expected failure', + durationText: '0.001s', + }); + }); + + it('should parse a skipped test', () => { + const result = parseSwiftTestingResultLine('โ—‡ Test "Disabled test" skipped.'); + expect(result).toEqual({ + status: 'skipped', + rawName: 'Disabled test', + testName: 'Disabled test', + }); + }); + + it('should return null for non-matching lines', () => { + expect(parseSwiftTestingResultLine('โ—‡ Test "Foo" started.')).toBeNull(); + expect(parseSwiftTestingResultLine('random text')).toBeNull(); + }); + }); + + describe('parseSwiftTestingIssueLine', () => { + it('should parse an issue with location', () => { + const result = parseSwiftTestingIssueLine( + 'โœ˜ Test "Expected failure" recorded an issue at SimpleTests.swift:48:5: Expectation failed: true == false', + ); + expect(result).toEqual({ + rawTestName: 'Expected failure', + testName: 'Expected failure', + location: 'SimpleTests.swift:48', + message: 'Expectation failed: true == false', + }); + }); + + it('should parse an issue without location', () => { + const result = parseSwiftTestingIssueLine( + 'โœ˜ Test "Some test" recorded an issue: Something went wrong', + ); + expect(result).toEqual({ + rawTestName: 'Some test', + testName: 'Some test', + message: 'Something went wrong', + }); + }); + + it('should return null for non-matching lines', () => { + expect(parseSwiftTestingIssueLine('โœ˜ Test "Foo" failed after 0.001 seconds')).toBeNull(); + }); + }); + + describe('parseSwiftTestingRunSummary', () => { + it('should parse a failed run summary', () => { + const result = parseSwiftTestingRunSummary( + 'โœ˜ Test run with 6 tests in 0 suites failed after 0.001 seconds with 1 issue.', + ); + expect(result).toEqual({ + executed: 6, + failed: 1, + durationText: '0.001s', + }); + }); + + it('should parse a passed run summary', () => { + const result = parseSwiftTestingRunSummary( + 'โœ” Test run with 5 tests in 2 suites passed after 0.003 seconds.', + ); + expect(result).toEqual({ + executed: 5, + failed: 0, + durationText: '0.003s', + }); + }); + + it('should return null for non-matching lines', () => { + expect(parseSwiftTestingRunSummary('random text')).toBeNull(); + }); + }); + + describe('parseSwiftTestingContinuationLine', () => { + it('should parse a continuation line', () => { + expect( + parseSwiftTestingContinuationLine('โ†ณ This test should fail'), + ).toBe('This test should fail'); + }); + + it('should return null for non-continuation lines', () => { + expect(parseSwiftTestingContinuationLine('regular line')).toBeNull(); + }); + }); + + describe('parseXcodebuildSwiftTestingLine', () => { + it('should parse a passed test case', () => { + const result = parseXcodebuildSwiftTestingLine( + "Test case 'MCPTestTests/appNameIsCorrect()' passed on 'My Mac - MCPTest (78757)' (0.000 seconds)", + ); + expect(result).toEqual({ + status: 'passed', + rawName: 'MCPTestTests/appNameIsCorrect()', + suiteName: 'MCPTestTests', + testName: 'appNameIsCorrect()', + durationText: '0.000s', + }); + }); + + it('should parse a failed test case', () => { + const result = parseXcodebuildSwiftTestingLine( + "Test case 'MCPTestTests/deliberateFailure()' failed on 'My Mac - MCPTest (78757)' (0.000 seconds)", + ); + expect(result).toEqual({ + status: 'failed', + rawName: 'MCPTestTests/deliberateFailure()', + suiteName: 'MCPTestTests', + testName: 'deliberateFailure()', + durationText: '0.000s', + }); + }); + + it('should return null for XCTest format lines', () => { + expect( + parseXcodebuildSwiftTestingLine("Test Case '-[Suite test]' passed (0.001 seconds)."), + ).toBeNull(); + }); + + it('should return null for non-matching lines', () => { + expect(parseXcodebuildSwiftTestingLine('random text')).toBeNull(); + }); + }); +}); diff --git a/src/utils/swift-testing-event-parser.ts b/src/utils/swift-testing-event-parser.ts new file mode 100644 index 00000000..9159d1b1 --- /dev/null +++ b/src/utils/swift-testing-event-parser.ts @@ -0,0 +1,201 @@ +import type { PipelineEvent } from '../types/pipeline-events.ts'; +import { + parseSwiftTestingResultLine, + parseSwiftTestingIssueLine, + parseSwiftTestingRunSummary, + parseSwiftTestingContinuationLine, +} from './swift-testing-line-parsers.ts'; +import { + parseTestCaseLine, + parseTotalsLine, + parseFailureDiagnostic, +} from './xcodebuild-line-parsers.ts'; + +export interface SwiftTestingEventParser { + onStdout(chunk: string): void; + onStderr(chunk: string): void; + flush(): void; +} + +export interface SwiftTestingEventParserOptions { + onEvent: (event: PipelineEvent) => void; +} + +function now(): string { + return new Date().toISOString(); +} + +export function createSwiftTestingEventParser( + options: SwiftTestingEventParserOptions, +): SwiftTestingEventParser { + const { onEvent } = options; + + let stdoutBuffer = ''; + let stderrBuffer = ''; + let completedCount = 0; + let failedCount = 0; + let skippedCount = 0; + + let lastIssueDiagnostic: { + testName?: string; + message: string; + location?: string; + } | null = null; + + function flushPendingIssue(): void { + if (!lastIssueDiagnostic) { + return; + } + onEvent({ + type: 'test-failure', + timestamp: now(), + operation: 'TEST', + test: lastIssueDiagnostic.testName, + message: lastIssueDiagnostic.message, + location: lastIssueDiagnostic.location, + }); + lastIssueDiagnostic = null; + } + + function emitTestProgress(): void { + onEvent({ + type: 'test-progress', + timestamp: now(), + operation: 'TEST', + completed: completedCount, + failed: failedCount, + skipped: skippedCount, + }); + } + + function processLine(rawLine: string): void { + const line = rawLine.trim(); + if (!line) { + flushPendingIssue(); + return; + } + + // Swift Testing continuation line (โ†ณ) appends context to the pending issue + const continuation = parseSwiftTestingContinuationLine(line); + if (continuation && lastIssueDiagnostic) { + lastIssueDiagnostic.message += `\n${continuation}`; + return; + } + + flushPendingIssue(); + + // Swift Testing issue line: โœ˜ Test "Name" recorded an issue at file:line:col: message + const issue = parseSwiftTestingIssueLine(line); + if (issue) { + lastIssueDiagnostic = { + testName: issue.testName, + message: issue.message, + location: issue.location, + }; + return; + } + + // Swift Testing result line: โœ”/โœ˜/โ—‡ Test "Name" passed/failed/skipped + const stResult = parseSwiftTestingResultLine(line); + if (stResult) { + completedCount += 1; + if (stResult.status === 'failed') { + failedCount += 1; + } + if (stResult.status === 'skipped') { + skippedCount += 1; + } + emitTestProgress(); + return; + } + + // Swift Testing run summary + const stSummary = parseSwiftTestingRunSummary(line); + if (stSummary) { + completedCount = stSummary.executed; + failedCount = stSummary.failed; + emitTestProgress(); + return; + } + + // XCTest: Test Case '...' passed/failed (for mixed output from `swift test`) + const xcTestCase = parseTestCaseLine(line); + if (xcTestCase) { + completedCount += 1; + if (xcTestCase.status === 'failed') { + failedCount += 1; + } + if (xcTestCase.status === 'skipped') { + skippedCount += 1; + } + emitTestProgress(); + return; + } + + // XCTest totals: Executed N tests, with N failures + const xcTotals = parseTotalsLine(line); + if (xcTotals) { + completedCount = xcTotals.executed; + failedCount = xcTotals.failed; + emitTestProgress(); + return; + } + + // XCTest failure diagnostic: file:line: error: -[Suite test] : message + const xcFailure = parseFailureDiagnostic(line); + if (xcFailure) { + onEvent({ + type: 'test-failure', + timestamp: now(), + operation: 'TEST', + suite: xcFailure.suiteName, + test: xcFailure.testName, + message: xcFailure.message, + location: xcFailure.location, + }); + return; + } + + // Detect test run start + if (/^[โ—‡] Test run started/u.test(line) || /^Testing started$/u.test(line)) { + onEvent({ + type: 'build-stage', + timestamp: now(), + operation: 'TEST', + stage: 'RUN_TESTS', + message: 'Running tests', + }); + return; + } + } + + function drainLines(buffer: string, chunk: string): string { + const combined = buffer + chunk; + const lines = combined.split(/\r?\n/u); + const remainder = lines.pop() ?? ''; + for (const line of lines) { + processLine(line); + } + return remainder; + } + + return { + onStdout(chunk: string): void { + stdoutBuffer = drainLines(stdoutBuffer, chunk); + }, + onStderr(chunk: string): void { + stderrBuffer = drainLines(stderrBuffer, chunk); + }, + flush(): void { + if (stdoutBuffer.trim()) { + processLine(stdoutBuffer); + } + if (stderrBuffer.trim()) { + processLine(stderrBuffer); + } + flushPendingIssue(); + stdoutBuffer = ''; + stderrBuffer = ''; + }, + }; +} diff --git a/src/utils/swift-testing-line-parsers.ts b/src/utils/swift-testing-line-parsers.ts new file mode 100644 index 00000000..883e25c3 --- /dev/null +++ b/src/utils/swift-testing-line-parsers.ts @@ -0,0 +1,149 @@ +import type { ParsedTestCase, ParsedFailureDiagnostic, ParsedTotals } from './xcodebuild-line-parsers.ts'; + +/** + * Parse a Swift Testing result line (passed/failed/skipped). + * + * Matches: + * โœ” Test "Name" passed after 0.001 seconds. + * โœ˜ Test "Name" failed after 0.001 seconds with 1 issue. + * โœ˜ Test "Name" failed after 0.001 seconds with 3 issues. + * โ—‡ Test "Name" skipped. + */ +export function parseSwiftTestingResultLine(line: string): ParsedTestCase | null { + const passedMatch = line.match( + /^[โœ”] Test "(.+)" passed after ([\d.]+) seconds\.?$/u, + ); + if (passedMatch) { + const [, name, duration] = passedMatch; + return { + status: 'passed', + rawName: name, + testName: name, + durationText: `${duration}s`, + }; + } + + const failedMatch = line.match( + /^[โœ˜] Test "(.+)" failed after ([\d.]+) seconds/u, + ); + if (failedMatch) { + const [, name, duration] = failedMatch; + return { + status: 'failed', + rawName: name, + testName: name, + durationText: `${duration}s`, + }; + } + + const skippedMatch = line.match(/^[โ—‡] Test "(.+)" skipped/u); + if (skippedMatch) { + return { + status: 'skipped', + rawName: skippedMatch[1], + testName: skippedMatch[1], + }; + } + + return null; +} + +/** + * Parse a Swift Testing issue line. + * + * Matches: + * โœ˜ Test "Name" recorded an issue at File.swift:48:5: Expectation failed: ... + * โœ˜ Test "Name" recorded an issue: message + */ +export function parseSwiftTestingIssueLine(line: string): ParsedFailureDiagnostic | null { + const locationMatch = line.match( + /^[โœ˜] Test "(.+)" recorded an issue at (.+?):(\d+):\d+: (.+)$/u, + ); + if (locationMatch) { + const [, testName, filePath, lineNumber, message] = locationMatch; + return { + rawTestName: testName, + testName, + location: `${filePath}:${lineNumber}`, + message, + }; + } + + const simpleMatch = line.match( + /^[โœ˜] Test "(.+)" recorded an issue: (.+)$/u, + ); + if (simpleMatch) { + const [, testName, message] = simpleMatch; + return { + rawTestName: testName, + testName, + message, + }; + } + + return null; +} + +/** + * Parse a Swift Testing run summary line. + * + * Matches: + * โœ” Test run with 6 tests in 2 suites passed after 0.001 seconds. + * โœ˜ Test run with 6 tests in 0 suites failed after 0.001 seconds with 1 issue. + */ +export function parseSwiftTestingRunSummary(line: string): ParsedTotals | null { + const match = line.match( + /^[โœ”โœ˜] Test run with (\d+) tests? in \d+ suites? (?:passed|failed) after ([\d.]+) seconds/u, + ); + if (!match) { + return null; + } + + const total = Number(match[1]); + const durationText = `${match[2]}s`; + + const issueMatch = line.match(/with (\d+) issues?/u); + const failed = issueMatch ? Number(issueMatch[1]) : 0; + + return { executed: total, failed, durationText }; +} + +/** + * Parse a Swift Testing continuation line (additional context for an issue). + * + * Matches: + * โ†ณ This test should fail... + */ +export function parseSwiftTestingContinuationLine(line: string): string | null { + const match = line.match(/^โ†ณ (.+)$/u); + return match ? match[1] : null; +} + +/** + * Parse xcodebuild's Swift Testing format. + * + * Matches: + * Test case 'Suite/testName()' passed on 'My Mac - App (12345)' (0.001 seconds) + * Test case 'Suite/testName()' failed on 'My Mac - App (12345)' (0.001 seconds) + */ +export function parseXcodebuildSwiftTestingLine(line: string): ParsedTestCase | null { + const match = line.match( + /^Test case '(.+)' (passed|failed|skipped) on '.+' \(([^)]+) seconds?\)$/u, + ); + if (!match) { + return null; + } + const [, rawName, status, duration] = match; + + const slashIndex = rawName.lastIndexOf('/'); + const suiteName = slashIndex > 0 ? rawName.slice(0, slashIndex) : undefined; + const testName = slashIndex > 0 ? rawName.slice(slashIndex + 1) : rawName; + + return { + status: status as 'passed' | 'failed' | 'skipped', + rawName, + suiteName, + testName, + durationText: `${duration}s`, + }; +} diff --git a/src/utils/test-common.ts b/src/utils/test-common.ts index 57f273a3..1f5fae0f 100644 --- a/src/utils/test-common.ts +++ b/src/utils/test-common.ts @@ -13,6 +13,7 @@ import { log } from './logger.ts'; import type { XcodePlatform } from './xcode.ts'; import { executeXcodeBuildCommand } from './build/index.ts'; import { toolResponse } from './tool-response.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'; @@ -26,8 +27,19 @@ import { import { formatToolPreflight } from './build-preflight.ts'; import { createSimulatorTwoPhaseExecutionPlan } from './simulator-test-execution.ts'; import { startBuildPipeline } from './xcodebuild-pipeline.ts'; +import type { XcodebuildPipeline } from './xcodebuild-pipeline.ts'; import { createPendingXcodebuildResponse } from './xcodebuild-output.ts'; +function emitXcresultFailures(pipeline: XcodebuildPipeline): void { + const xcresultPath = pipeline.xcresultPath; + if (xcresultPath) { + const failures = extractTestFailuresFromXcresult(xcresultPath); + for (const event of failures) { + pipeline.emitEvent(event); + } + } +} + export function resolveTestProgressEnabled(progress: boolean | undefined): boolean { if (typeof progress === 'boolean') { return progress; @@ -161,7 +173,7 @@ export async function handleTestLogic( } pipeline.emitEvent({ - type: 'status', + type: 'build-stage', timestamp: new Date().toISOString(), operation: 'TEST', stage: 'PREPARING_TESTS', @@ -178,6 +190,8 @@ export async function handleTestLogic( pipeline, ); + emitXcresultFailures(pipeline); + return createPendingXcodebuildResponse(started, testWithoutBuildingResult, { extras: preflightExtras, }); @@ -193,6 +207,8 @@ export async function handleTestLogic( pipeline, ); + emitXcresultFailures(pipeline); + return createPendingXcodebuildResponse(started, singlePhaseResult, { extras: preflightExtras, }); diff --git a/src/utils/video-capture/index.ts b/src/utils/video-capture/index.ts index 1d3f5f2f..3880d260 100644 --- a/src/utils/video-capture/index.ts +++ b/src/utils/video-capture/index.ts @@ -2,4 +2,6 @@ export { startSimulatorVideoCapture, stopSimulatorVideoCapture, type AxeHelpers, + type StartVideoCaptureResult, + type StopVideoCaptureResult, } from '../video_capture.ts'; diff --git a/src/utils/video_capture.ts b/src/utils/video_capture.ts index 1cd5c5a4..30208c07 100644 --- a/src/utils/video_capture.ts +++ b/src/utils/video_capture.ts @@ -28,6 +28,14 @@ export interface AxeHelpers { getBundledAxeEnvironment: () => Record; } +export type StartVideoCaptureResult = + | { started: true; sessionId: string; warning?: string } + | { started: false; error: string }; + +export type StopVideoCaptureResult = + | { stopped: true; sessionId: string; stdout?: string; parsedPath?: string } + | { stopped: false; error: string }; + function createTimeoutPromise(timeoutMs: number): Promise<'timed_out'> { return new Promise((resolve) => { const timer = setTimeout(() => resolve('timed_out'), timeoutMs); @@ -72,10 +80,14 @@ async function waitForChildToStop(session: Session, timeoutMs: number): Promise< } } +type StopSessionSuccess = { sessionId: string; stdout: string; parsedPath?: string }; +type StopSessionError = { error: string }; +type StopSessionResult = StopSessionSuccess | StopSessionError; + async function stopSession( simulatorUuid: string, options: { timeoutMs?: number } = {}, -): Promise<{ sessionId?: string; stdout?: string; parsedPath?: string; error?: string }> { +): Promise { const session = sessions.get(simulatorUuid); if (!session) { return { error: 'No active video recording session for this simulator' }; @@ -147,7 +159,7 @@ export async function startSimulatorVideoCapture( params: { simulatorUuid: string; fps?: number }, executor: CommandExecutor, axeHelpers?: AxeHelpers, -): Promise<{ started: boolean; sessionId?: string; warning?: string; error?: string }> { +): Promise { const simulatorUuid = params.simulatorUuid; if (!simulatorUuid) { return { started: false, error: 'simulatorUuid is required' }; @@ -230,13 +242,7 @@ export async function startSimulatorVideoCapture( export async function stopSimulatorVideoCapture( params: { simulatorUuid: string }, executor: CommandExecutor, -): Promise<{ - stopped: boolean; - sessionId?: string; - stdout?: string; - parsedPath?: string; - error?: string; -}> { +): Promise { void executor; const simulatorUuid = params.simulatorUuid; @@ -245,7 +251,7 @@ export async function stopSimulatorVideoCapture( } const result = await stopSession(simulatorUuid, { timeoutMs: 5000 }); - if (result.error) { + if ('error' in result) { return { stopped: false, error: result.error }; } @@ -272,7 +278,7 @@ export async function stopAllVideoCaptureSessions(timeoutMs = 1000): Promise<{ for (const simulatorUuid of simulatorIds) { const result = await stopSession(simulatorUuid, { timeoutMs }); - if (result.error) { + if ('error' in result) { errors.push(`${simulatorUuid}: ${result.error}`); } } diff --git a/src/utils/xcodebuild-event-parser.ts b/src/utils/xcodebuild-event-parser.ts index 1194128d..3b731f2d 100644 --- a/src/utils/xcodebuild-event-parser.ts +++ b/src/utils/xcodebuild-event-parser.ts @@ -12,6 +12,13 @@ import { parseFailureDiagnostic, parseBuildErrorDiagnostic, } from './xcodebuild-line-parsers.ts'; +import { + parseXcodebuildSwiftTestingLine, + parseSwiftTestingIssueLine, + parseSwiftTestingResultLine, + parseSwiftTestingRunSummary, + parseSwiftTestingContinuationLine, +} from './swift-testing-line-parsers.ts'; function resolveStageFromLine(line: string): XcodebuildStage | null { if (packageResolutionPatterns.some((pattern) => pattern.test(line))) { @@ -23,7 +30,7 @@ function resolveStageFromLine(line: string): XcodebuildStage | null { if (linkPatterns.some((pattern) => pattern.test(line))) { return 'LINKING'; } - if (/^Testing started$/u.test(line) || /^Test Suite .+ started/u.test(line)) { + if (/^Testing started$/u.test(line) || /^Test Suite .+ started/u.test(line) || /^[โ—‡] Test run started/u.test(line)) { return 'RUN_TESTS'; } return null; @@ -69,6 +76,7 @@ export interface XcodebuildEventParser { onStdout(chunk: string): void; onStderr(chunk: string): void; flush(): void; + xcresultPath: string | null; } export function createXcodebuildEventParser(options: EventParserOptions): XcodebuildEventParser { @@ -79,6 +87,7 @@ export function createXcodebuildEventParser(options: EventParserOptions): Xcodeb let completedCount = 0; let failedCount = 0; let skippedCount = 0; + let detectedXcresultPath: string | null = null; let pendingError: { message: string; @@ -87,6 +96,29 @@ export function createXcodebuildEventParser(options: EventParserOptions): Xcodeb timestamp: string; } | null = null; + let pendingSwiftTestingIssue: { + testName?: string; + message: string; + location?: string; + } | null = null; + + function flushPendingSwiftTestingIssue(): void { + if (!pendingSwiftTestingIssue) { + return; + } + if (operation === 'TEST') { + onEvent({ + type: 'test-failure', + timestamp: now(), + operation: 'TEST', + test: pendingSwiftTestingIssue.testName, + message: pendingSwiftTestingIssue.message, + location: pendingSwiftTestingIssue.location, + }); + } + pendingSwiftTestingIssue = null; + } + function flushPendingError(): void { if (!pendingError) { return; @@ -105,10 +137,20 @@ export function createXcodebuildEventParser(options: EventParserOptions): Xcodeb function processLine(rawLine: string): void { const line = rawLine.trim(); if (!line) { + flushPendingSwiftTestingIssue(); flushPendingError(); return; } + // Swift Testing continuation line (โ†ณ) appends context to pending issue + const stContinuation = parseSwiftTestingContinuationLine(line); + if (stContinuation && pendingSwiftTestingIssue) { + pendingSwiftTestingIssue.message += `\n${stContinuation}`; + return; + } + + flushPendingSwiftTestingIssue(); + if (pendingError && /^\s/u.test(rawLine)) { pendingError.message += `\n${line}`; pendingError.rawLines.push(rawLine); @@ -174,6 +216,81 @@ export function createXcodebuildEventParser(options: EventParserOptions): Xcodeb return; } + // xcodebuild Swift Testing: Test case 'Suite/test()' passed on 'device' (0.000 seconds) + const xcodebuildST = parseXcodebuildSwiftTestingLine(line); + if (xcodebuildST) { + completedCount += 1; + if (xcodebuildST.status === 'failed') { + failedCount += 1; + } + if (xcodebuildST.status === 'skipped') { + skippedCount += 1; + } + if (operation === 'TEST') { + onEvent({ + type: 'test-progress', + timestamp: now(), + operation: 'TEST', + completed: completedCount, + failed: failedCount, + skipped: skippedCount, + }); + } + return; + } + + // Swift Testing issue: โœ˜ Test "Name" recorded an issue at file:line:col: message + const stIssue = parseSwiftTestingIssueLine(line); + if (stIssue) { + pendingSwiftTestingIssue = { + testName: stIssue.testName, + message: stIssue.message, + location: stIssue.location, + }; + return; + } + + // Swift Testing result: โœ”/โœ˜ Test "Name" passed/failed after X seconds + const stResult = parseSwiftTestingResultLine(line); + if (stResult) { + completedCount += 1; + if (stResult.status === 'failed') { + failedCount += 1; + } + if (stResult.status === 'skipped') { + skippedCount += 1; + } + if (operation === 'TEST') { + onEvent({ + type: 'test-progress', + timestamp: now(), + operation: 'TEST', + completed: completedCount, + failed: failedCount, + skipped: skippedCount, + }); + } + return; + } + + // Swift Testing run summary: โœ”/โœ˜ Test run with N tests... + const stSummary = parseSwiftTestingRunSummary(line); + if (stSummary) { + completedCount = stSummary.executed; + failedCount = stSummary.failed; + if (operation === 'TEST') { + onEvent({ + type: 'test-progress', + timestamp: now(), + operation: 'TEST', + completed: completedCount, + failed: failedCount, + skipped: skippedCount, + }); + } + return; + } + const stage = resolveStageFromLine(line); if (stage) { onEvent({ @@ -213,6 +330,13 @@ export function createXcodebuildEventParser(options: EventParserOptions): Xcodeb if (/^Test Suite /u.test(line)) { return; } + + // Capture xcresult path from xcodebuild output + const xcresultMatch = line.match(/^\s*(\S+\.xcresult)\s*$/u); + if (xcresultMatch) { + detectedXcresultPath = xcresultMatch[1]; + return; + } } function drainLines(buffer: string, chunk: string): string { @@ -239,9 +363,13 @@ export function createXcodebuildEventParser(options: EventParserOptions): Xcodeb if (stderrBuffer.trim()) { processLine(stderrBuffer); } + flushPendingSwiftTestingIssue(); flushPendingError(); stdoutBuffer = ''; stderrBuffer = ''; }, + get xcresultPath(): string | null { + return detectedXcresultPath; + }, }; } diff --git a/src/utils/xcodebuild-pipeline.ts b/src/utils/xcodebuild-pipeline.ts index 65863d8c..3e3da31a 100644 --- a/src/utils/xcodebuild-pipeline.ts +++ b/src/utils/xcodebuild-pipeline.ts @@ -40,6 +40,7 @@ export interface XcodebuildPipeline { options?: PipelineFinalizeOptions, ): PipelineResult; highestStageRank(): number; + xcresultPath: string | null; } export interface StartedPipeline { @@ -173,5 +174,9 @@ export function createXcodebuildPipeline(options: PipelineOptions): XcodebuildPi highestStageRank(): number { return runState.highestStageRank(); }, + + get xcresultPath(): string | null { + return parser.xcresultPath; + }, }; } diff --git a/src/utils/xcresult-test-failures.ts b/src/utils/xcresult-test-failures.ts new file mode 100644 index 00000000..b1074c43 --- /dev/null +++ b/src/utils/xcresult-test-failures.ts @@ -0,0 +1,80 @@ +import { execFileSync } from 'node:child_process'; +import { log } from './logger.ts'; +import type { PipelineEvent } from '../types/pipeline-events.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): void { + 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); + events.push({ + type: 'test-failure', + timestamp: new Date().toISOString(), + operation: 'TEST', + test: node.name, + message: parsed.message, + location: parsed.location, + }); + } + } + } + if (node.children) { + for (const child of node.children) { + walk(child); + } + } + } + + 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[1]}:${match[2]}`, + message: match[3], + }; + } + return { message: raw }; +} From abfd1b190460657eb1209cf4b222e29d9bd8d7ee Mon Sep 17 00:00:00 2001 From: Cameron Cooke Date: Sat, 28 Mar 2026 11:17:13 +0000 Subject: [PATCH 09/50] WIP --- .../macOS/MCPTestTests/MCPTestsXCTests.swift | 13 ++ .../spm/.xcodebuildmcp/config.yaml | 9 + .../device/__tests__/build_run_device.test.ts | 2 + .../__tests__/get_device_app_path.test.ts | 4 +- src/mcp/tools/device/build_device.ts | 2 + src/mcp/tools/device/build_run_device.ts | 4 + src/mcp/tools/device/get_device_app_path.ts | 121 +++++++---- src/mcp/tools/device/list_devices.ts | 78 +++---- .../macos/__tests__/build_run_macos.test.ts | 1 + .../macos/__tests__/get_mac_app_path.test.ts | 4 +- src/mcp/tools/macos/build_macos.ts | 2 + src/mcp/tools/macos/build_run_macos.ts | 4 + src/mcp/tools/macos/get_mac_app_path.ts | 112 +++++----- .../session_use_defaults_profile.test.ts | 2 +- .../session_show_defaults.ts | 9 +- .../session_use_defaults_profile.ts | 6 +- .../simulator/__tests__/build_run_sim.test.ts | 1 + .../__tests__/get_sim_app_path.test.ts | 4 +- .../__tests__/launch_app_logs_sim.test.ts | 3 + .../simulator/__tests__/screenshot.test.ts | 21 +- src/mcp/tools/simulator/build_run_sim.ts | 13 ++ src/mcp/tools/simulator/build_sim.ts | 4 + src/mcp/tools/simulator/get_sim_app_path.ts | 124 ++++++----- .../tools/simulator/launch_app_logs_sim.ts | 2 +- src/mcp/tools/simulator/list_sims.ts | 10 +- .../__tests__/screenshot.test.ts | 11 +- src/mcp/tools/ui-automation/screenshot.ts | 50 ++++- src/mcp/tools/utilities/clean.ts | 2 + ...-coverage-report--error-invalid-bundle.txt | 2 +- ...et-file-coverage--error-invalid-bundle.txt | 2 +- .../coverage/get-file-coverage--success.txt | 1 + .../debugging/add-breakpoint--success.txt | 2 +- .../debugging/attach--success-continue.txt | 1 - .../debugging/attach--success.txt | 1 - .../debugging/lldb-command--success.txt | 6 +- .../debugging/remove-breakpoint--success.txt | 2 +- .../__fixtures__/debugging/stack--success.txt | 28 +-- .../debugging/variables--success.txt | 2 +- .../device/build--error-wrong-scheme.txt | 2 + .../__fixtures__/device/build--success.txt | 2 + .../device/build-and-run--success.txt | 1 - .../get-app-path--error-wrong-scheme.txt | 7 +- .../device/get-app-path--success.txt | 8 +- .../__fixtures__/device/launch--success.txt | 1 - .../__fixtures__/device/list--success.txt | 60 ++--- .../__fixtures__/device/stop--success.txt | 2 +- .../__fixtures__/device/test--failure.txt | 13 +- .../__fixtures__/device/test--success.txt | 3 +- .../logging/start-device-log--success.txt | 4 +- .../logging/start-sim-log--success.txt | 5 +- .../logging/stop-device-log--success.txt | 3 +- .../logging/stop-sim-log--success.txt | 3 +- .../macos/build--error-wrong-scheme.txt | 2 + .../__fixtures__/macos/build--success.txt | 3 + .../build-and-run--error-wrong-scheme.txt | 2 + .../macos/build-and-run--success.txt | 7 +- .../get-app-path--error-wrong-scheme.txt | 7 +- .../macos/get-app-path--success.txt | 7 +- .../macos/get-macos-bundle-id--success.txt | 3 +- .../__fixtures__/macos/launch--success.txt | 4 +- .../__fixtures__/macos/stop--success.txt | 2 +- .../macos/test--error-wrong-scheme.txt | 1 + .../__fixtures__/macos/test--failure.txt | 14 +- .../__fixtures__/macos/test--success.txt | 3 +- .../discover-projs--error-invalid-root.txt | 4 + .../discover-projs--success.txt | 10 +- .../get-app-bundle-id--success.txt | 3 +- .../get-macos-bundle-id--success.txt | 3 +- .../list-schemes--error-invalid-workspace.txt | 2 +- .../list-schemes--success.txt | 4 +- ...how-build-settings--error-wrong-scheme.txt | 2 +- .../show-build-settings--success.txt | 4 +- .../scaffold-ios--success.txt | 3 +- .../scaffold-macos--success.txt | 3 +- .../session-clear-defaults--success.txt | 4 +- .../session-set-defaults--success.txt | 22 +- .../session-show-defaults--success.txt | 35 ++- .../session-sync-xcode-defaults--success.txt | 4 +- .../session-use-defaults-profile--success.txt | 5 +- .../simulator-management/boot--success.txt | 6 + .../simulator-management/erase--success.txt | 6 + .../simulator-management/list--success.txt | 173 ++++++++++----- .../simulator-management/open--success.txt | 2 +- .../reset-location--success.txt | 2 +- .../set-appearance--success.txt | 2 +- .../set-location--success.txt | 2 +- .../statusbar--success.txt | 2 +- .../simulator/build--error-wrong-scheme.txt | 3 + .../__fixtures__/simulator/build--success.txt | 3 + .../build-and-run--error-wrong-scheme.txt | 3 + .../simulator/build-and-run--success.txt | 7 +- .../get-app-path--error-wrong-scheme.txt | 11 +- .../simulator/get-app-path--success.txt | 9 +- .../simulator/install--success.txt | 2 +- .../simulator/launch-app--success.txt | 3 +- .../launch-app-with-logs--success.txt | 2 +- .../__fixtures__/simulator/list--success.txt | 173 ++++++++++----- .../simulator/screenshot--success.txt | 5 +- .../__fixtures__/simulator/stop--success.txt | 2 +- .../simulator/test--error-wrong-scheme.txt | 3 +- .../__fixtures__/simulator/test--failure.txt | 13 +- .../__fixtures__/simulator/test--success.txt | 3 +- .../swift-package/build--success.txt | 7 +- .../swift-package/list--no-processes.txt | 4 + .../swift-package/list--success.txt | 12 +- .../run--error-bad-executable.txt | 10 +- .../swift-package/run--success.txt | 11 +- .../swift-package/test--error-bad-path.txt | 1 + .../swift-package/test--failure.txt | 14 +- .../swift-package/test--success.txt | 1 + .../ui-automation/long-press--success.txt | 1 + .../ui-automation/swipe--success.txt | 1 + .../ui-automation/tap--success.txt | 1 + .../ui-automation/touch--success.txt | 1 + .../utilities/clean--error-wrong-scheme.txt | 7 +- .../__fixtures__/utilities/clean--success.txt | 3 +- .../device/build--error-wrong-scheme.txt | 29 +++ .../device/build--success.txt | 20 ++ .../device/build-and-run--success.txt | 20 ++ .../device/install--error-invalid-app.txt | 6 + .../device/install--success-attempt.txt | 6 + .../device/launch--error-invalid-bundle.txt | 6 + .../device/launch--success.txt | 6 + .../device/list--success.txt | 31 +++ .../device/stop--error-no-app.txt | 2 + .../device/stop--success.txt | 2 + .../device/test--failure.txt | 47 ++++ .../device/test--success.txt | 47 ++++ .../logging/logs--success.txt | 7 + .../macos/build--error-wrong-scheme.txt | 27 +++ .../macos/build--success.txt | 12 + .../build-and-run--error-wrong-scheme.txt | 29 +++ .../macos/build-and-run--success.txt | 30 +++ .../macos/clean--success.txt | 10 + .../macos/stop--error-no-app.txt | 2 + .../macos/stop--success.txt | 3 + .../macos/test--error-wrong-scheme.txt | 16 ++ .../macos/test--failure.txt | 26 +++ .../macos/test--success.txt | 19 ++ .../list-schemes--error-invalid-workspace.txt | 1 + .../list-schemes--success.txt | 10 + .../show-build-settings--success.txt | 6 + .../scaffold-ios--error-existing.txt | 4 + .../scaffold-ios--success.txt | 2 + .../scaffold-macos--error-existing.txt | 4 + .../scaffold-macos--success.txt | 2 + .../session-clear-defaults--success.txt | 3 + .../session-set-defaults--success.txt | 6 + .../session-show-defaults--success.txt | 6 + .../session-sync-xcode-defaults--success.txt | 27 +++ .../boot--error-invalid-id.txt | 3 + .../erase--error-invalid-id.txt | 4 + .../simulator-management/list--success.txt | 74 +++++++ .../simulator-management/open--success.txt | 2 + ...et-appearance--error-invalid-simulator.txt | 1 + .../set-appearance--success.txt | 1 + .../set-location--error-invalid-simulator.txt | 2 + .../set-location--success.txt | 1 + .../simulator/build--error-wrong-scheme.txt | 29 +++ .../simulator/build--success.txt | 14 ++ .../build-and-run--error-wrong-scheme.txt | 29 +++ .../simulator/build-and-run--success.txt | 38 ++++ .../simulator/install--error-invalid-app.txt | 13 ++ .../simulator/install--success.txt | 27 +++ .../simulator/launch-app--success.txt | 27 +++ .../simulator/list--success.txt | 74 +++++++ .../screenshot--error-invalid-simulator.txt | 1 + .../simulator/screenshot--success.txt | 4 + .../simulator/stop--error-no-app.txt | 2 + .../simulator/stop--success.txt | 3 + .../simulator/test--error-wrong-scheme.txt | 17 ++ .../simulator/test--failure.txt | 34 +++ .../simulator/test--success.txt | 23 ++ .../swift-package/list--success.txt | 7 + .../swift-package/stop--error-no-process.txt | 2 + .../button--error-no-simulator.txt | 1 + .../ui-automation/button--success.txt | 1 + .../gesture--error-no-simulator.txt | 1 + .../ui-automation/gesture--success.txt | 1 + .../key-press--error-no-simulator.txt | 1 + .../ui-automation/key-press--success.txt | 1 + .../key-sequence--error-no-simulator.txt | 1 + .../ui-automation/key-sequence--success.txt | 1 + .../long-press--error-no-simulator.txt | 2 + .../ui-automation/long-press--success.txt | 1 + .../snapshot-ui--error-no-simulator.txt | 1 + .../ui-automation/snapshot-ui--success.txt | 4 + .../swipe--error-no-simulator.txt | 2 + .../ui-automation/swipe--success.txt | 1 + .../ui-automation/tap--error-no-simulator.txt | 2 + .../ui-automation/tap--success.txt | 1 + .../touch--error-no-simulator.txt | 2 + .../ui-automation/touch--success.txt | 2 + .../type-text--error-no-simulator.txt | 1 + .../ui-automation/type-text--success.txt | 1 + .../utilities/clean--error-wrong-scheme.txt | 8 + .../utilities/clean--success.txt | 10 + .../__tests__/coverage.flowdeck.test.ts | 8 + .../__tests__/debugging.flowdeck.test.ts | 6 + .../__tests__/device.flowdeck.test.ts | 140 ++++++++++++ .../__tests__/doctor.flowdeck.test.ts | 6 + .../__tests__/logging.flowdeck.test.ts | 52 +++++ .../__tests__/macos.flowdeck.test.ts | 110 ++++++++++ .../project-discovery.flowdeck.test.ts | 61 ++++++ .../project-scaffolding.flowdeck.test.ts | 78 +++++++ .../session-management.flowdeck.test.ts | 59 +++++ .../simulator-management.flowdeck.test.ts | 95 ++++++++ .../__tests__/simulator.flowdeck.test.ts | 168 ++++++++++++++ .../__tests__/swift-package.flowdeck.test.ts | 49 +++++ .../__tests__/ui-automation.flowdeck.test.ts | 205 ++++++++++++++++++ .../__tests__/utilities.flowdeck.test.ts | 34 +++ .../workflow-discovery.flowdeck.test.ts | 6 + .../__tests__/xcode-ide.flowdeck.test.ts | 6 + src/snapshot-tests/flowdeck-fixture-io.ts | 12 + src/snapshot-tests/flowdeck-harness.ts | 36 +++ src/snapshot-tests/flowdeck-pty.py | 36 +++ src/snapshot-tests/normalize.ts | 3 +- src/types/pipeline-events.ts | 1 + src/utils/__tests__/test-common.test.ts | 4 +- src/utils/__tests__/xcodebuild-output.test.ts | 95 +++++++- src/utils/build-preflight.ts | 24 +- .../__tests__/event-formatting.test.ts | 30 +++ src/utils/renderers/cli-text-renderer.ts | 16 +- src/utils/renderers/event-formatting.ts | 46 ++++ src/utils/renderers/mcp-renderer.ts | 24 +- src/utils/xcodebuild-event-parser.ts | 40 +++- src/utils/xcodebuild-log-capture.ts | 72 ++++++ src/utils/xcodebuild-output.ts | 12 + src/utils/xcodebuild-pipeline.ts | 95 +++++++- vitest.flowdeck.config.ts | 23 ++ vitest.snapshot.config.ts | 1 + 231 files changed, 3530 insertions(+), 528 deletions(-) create mode 100644 example_projects/macOS/MCPTestTests/MCPTestsXCTests.swift create mode 100644 example_projects/spm/.xcodebuildmcp/config.yaml create mode 100644 src/snapshot-tests/__fixtures__/simulator-management/boot--success.txt create mode 100644 src/snapshot-tests/__fixtures__/simulator-management/erase--success.txt create mode 100644 src/snapshot-tests/__fixtures__/swift-package/list--no-processes.txt create mode 100644 src/snapshot-tests/__flowdeck_fixtures__/device/build--error-wrong-scheme.txt create mode 100644 src/snapshot-tests/__flowdeck_fixtures__/device/build--success.txt create mode 100644 src/snapshot-tests/__flowdeck_fixtures__/device/build-and-run--success.txt create mode 100644 src/snapshot-tests/__flowdeck_fixtures__/device/install--error-invalid-app.txt create mode 100644 src/snapshot-tests/__flowdeck_fixtures__/device/install--success-attempt.txt create mode 100644 src/snapshot-tests/__flowdeck_fixtures__/device/launch--error-invalid-bundle.txt create mode 100644 src/snapshot-tests/__flowdeck_fixtures__/device/launch--success.txt create mode 100644 src/snapshot-tests/__flowdeck_fixtures__/device/list--success.txt create mode 100644 src/snapshot-tests/__flowdeck_fixtures__/device/stop--error-no-app.txt create mode 100644 src/snapshot-tests/__flowdeck_fixtures__/device/stop--success.txt create mode 100644 src/snapshot-tests/__flowdeck_fixtures__/device/test--failure.txt create mode 100644 src/snapshot-tests/__flowdeck_fixtures__/device/test--success.txt create mode 100644 src/snapshot-tests/__flowdeck_fixtures__/logging/logs--success.txt create mode 100644 src/snapshot-tests/__flowdeck_fixtures__/macos/build--error-wrong-scheme.txt create mode 100644 src/snapshot-tests/__flowdeck_fixtures__/macos/build--success.txt create mode 100644 src/snapshot-tests/__flowdeck_fixtures__/macos/build-and-run--error-wrong-scheme.txt create mode 100644 src/snapshot-tests/__flowdeck_fixtures__/macos/build-and-run--success.txt create mode 100644 src/snapshot-tests/__flowdeck_fixtures__/macos/clean--success.txt create mode 100644 src/snapshot-tests/__flowdeck_fixtures__/macos/stop--error-no-app.txt create mode 100644 src/snapshot-tests/__flowdeck_fixtures__/macos/stop--success.txt create mode 100644 src/snapshot-tests/__flowdeck_fixtures__/macos/test--error-wrong-scheme.txt create mode 100644 src/snapshot-tests/__flowdeck_fixtures__/macos/test--failure.txt create mode 100644 src/snapshot-tests/__flowdeck_fixtures__/macos/test--success.txt create mode 100644 src/snapshot-tests/__flowdeck_fixtures__/project-discovery/list-schemes--error-invalid-workspace.txt create mode 100644 src/snapshot-tests/__flowdeck_fixtures__/project-discovery/list-schemes--success.txt create mode 100644 src/snapshot-tests/__flowdeck_fixtures__/project-discovery/show-build-settings--success.txt create mode 100644 src/snapshot-tests/__flowdeck_fixtures__/project-scaffolding/scaffold-ios--error-existing.txt create mode 100644 src/snapshot-tests/__flowdeck_fixtures__/project-scaffolding/scaffold-ios--success.txt create mode 100644 src/snapshot-tests/__flowdeck_fixtures__/project-scaffolding/scaffold-macos--error-existing.txt create mode 100644 src/snapshot-tests/__flowdeck_fixtures__/project-scaffolding/scaffold-macos--success.txt create mode 100644 src/snapshot-tests/__flowdeck_fixtures__/session-management/session-clear-defaults--success.txt create mode 100644 src/snapshot-tests/__flowdeck_fixtures__/session-management/session-set-defaults--success.txt create mode 100644 src/snapshot-tests/__flowdeck_fixtures__/session-management/session-show-defaults--success.txt create mode 100644 src/snapshot-tests/__flowdeck_fixtures__/session-management/session-sync-xcode-defaults--success.txt create mode 100644 src/snapshot-tests/__flowdeck_fixtures__/simulator-management/boot--error-invalid-id.txt create mode 100644 src/snapshot-tests/__flowdeck_fixtures__/simulator-management/erase--error-invalid-id.txt create mode 100644 src/snapshot-tests/__flowdeck_fixtures__/simulator-management/list--success.txt create mode 100644 src/snapshot-tests/__flowdeck_fixtures__/simulator-management/open--success.txt create mode 100644 src/snapshot-tests/__flowdeck_fixtures__/simulator-management/set-appearance--error-invalid-simulator.txt create mode 100644 src/snapshot-tests/__flowdeck_fixtures__/simulator-management/set-appearance--success.txt create mode 100644 src/snapshot-tests/__flowdeck_fixtures__/simulator-management/set-location--error-invalid-simulator.txt create mode 100644 src/snapshot-tests/__flowdeck_fixtures__/simulator-management/set-location--success.txt create mode 100644 src/snapshot-tests/__flowdeck_fixtures__/simulator/build--error-wrong-scheme.txt create mode 100644 src/snapshot-tests/__flowdeck_fixtures__/simulator/build--success.txt create mode 100644 src/snapshot-tests/__flowdeck_fixtures__/simulator/build-and-run--error-wrong-scheme.txt create mode 100644 src/snapshot-tests/__flowdeck_fixtures__/simulator/build-and-run--success.txt create mode 100644 src/snapshot-tests/__flowdeck_fixtures__/simulator/install--error-invalid-app.txt create mode 100644 src/snapshot-tests/__flowdeck_fixtures__/simulator/install--success.txt create mode 100644 src/snapshot-tests/__flowdeck_fixtures__/simulator/launch-app--success.txt create mode 100644 src/snapshot-tests/__flowdeck_fixtures__/simulator/list--success.txt create mode 100644 src/snapshot-tests/__flowdeck_fixtures__/simulator/screenshot--error-invalid-simulator.txt create mode 100644 src/snapshot-tests/__flowdeck_fixtures__/simulator/screenshot--success.txt create mode 100644 src/snapshot-tests/__flowdeck_fixtures__/simulator/stop--error-no-app.txt create mode 100644 src/snapshot-tests/__flowdeck_fixtures__/simulator/stop--success.txt create mode 100644 src/snapshot-tests/__flowdeck_fixtures__/simulator/test--error-wrong-scheme.txt create mode 100644 src/snapshot-tests/__flowdeck_fixtures__/simulator/test--failure.txt create mode 100644 src/snapshot-tests/__flowdeck_fixtures__/simulator/test--success.txt create mode 100644 src/snapshot-tests/__flowdeck_fixtures__/swift-package/list--success.txt create mode 100644 src/snapshot-tests/__flowdeck_fixtures__/swift-package/stop--error-no-process.txt create mode 100644 src/snapshot-tests/__flowdeck_fixtures__/ui-automation/button--error-no-simulator.txt create mode 100644 src/snapshot-tests/__flowdeck_fixtures__/ui-automation/button--success.txt create mode 100644 src/snapshot-tests/__flowdeck_fixtures__/ui-automation/gesture--error-no-simulator.txt create mode 100644 src/snapshot-tests/__flowdeck_fixtures__/ui-automation/gesture--success.txt create mode 100644 src/snapshot-tests/__flowdeck_fixtures__/ui-automation/key-press--error-no-simulator.txt create mode 100644 src/snapshot-tests/__flowdeck_fixtures__/ui-automation/key-press--success.txt create mode 100644 src/snapshot-tests/__flowdeck_fixtures__/ui-automation/key-sequence--error-no-simulator.txt create mode 100644 src/snapshot-tests/__flowdeck_fixtures__/ui-automation/key-sequence--success.txt create mode 100644 src/snapshot-tests/__flowdeck_fixtures__/ui-automation/long-press--error-no-simulator.txt create mode 100644 src/snapshot-tests/__flowdeck_fixtures__/ui-automation/long-press--success.txt create mode 100644 src/snapshot-tests/__flowdeck_fixtures__/ui-automation/snapshot-ui--error-no-simulator.txt create mode 100644 src/snapshot-tests/__flowdeck_fixtures__/ui-automation/snapshot-ui--success.txt create mode 100644 src/snapshot-tests/__flowdeck_fixtures__/ui-automation/swipe--error-no-simulator.txt create mode 100644 src/snapshot-tests/__flowdeck_fixtures__/ui-automation/swipe--success.txt create mode 100644 src/snapshot-tests/__flowdeck_fixtures__/ui-automation/tap--error-no-simulator.txt create mode 100644 src/snapshot-tests/__flowdeck_fixtures__/ui-automation/tap--success.txt create mode 100644 src/snapshot-tests/__flowdeck_fixtures__/ui-automation/touch--error-no-simulator.txt create mode 100644 src/snapshot-tests/__flowdeck_fixtures__/ui-automation/touch--success.txt create mode 100644 src/snapshot-tests/__flowdeck_fixtures__/ui-automation/type-text--error-no-simulator.txt create mode 100644 src/snapshot-tests/__flowdeck_fixtures__/ui-automation/type-text--success.txt create mode 100644 src/snapshot-tests/__flowdeck_fixtures__/utilities/clean--error-wrong-scheme.txt create mode 100644 src/snapshot-tests/__flowdeck_fixtures__/utilities/clean--success.txt create mode 100644 src/snapshot-tests/__tests__/coverage.flowdeck.test.ts create mode 100644 src/snapshot-tests/__tests__/debugging.flowdeck.test.ts create mode 100644 src/snapshot-tests/__tests__/device.flowdeck.test.ts create mode 100644 src/snapshot-tests/__tests__/doctor.flowdeck.test.ts create mode 100644 src/snapshot-tests/__tests__/logging.flowdeck.test.ts create mode 100644 src/snapshot-tests/__tests__/macos.flowdeck.test.ts create mode 100644 src/snapshot-tests/__tests__/project-discovery.flowdeck.test.ts create mode 100644 src/snapshot-tests/__tests__/project-scaffolding.flowdeck.test.ts create mode 100644 src/snapshot-tests/__tests__/session-management.flowdeck.test.ts create mode 100644 src/snapshot-tests/__tests__/simulator-management.flowdeck.test.ts create mode 100644 src/snapshot-tests/__tests__/simulator.flowdeck.test.ts create mode 100644 src/snapshot-tests/__tests__/swift-package.flowdeck.test.ts create mode 100644 src/snapshot-tests/__tests__/ui-automation.flowdeck.test.ts create mode 100644 src/snapshot-tests/__tests__/utilities.flowdeck.test.ts create mode 100644 src/snapshot-tests/__tests__/workflow-discovery.flowdeck.test.ts create mode 100644 src/snapshot-tests/__tests__/xcode-ide.flowdeck.test.ts create mode 100644 src/snapshot-tests/flowdeck-fixture-io.ts create mode 100644 src/snapshot-tests/flowdeck-harness.ts create mode 100644 src/snapshot-tests/flowdeck-pty.py create mode 100644 src/utils/xcodebuild-log-capture.ts create mode 100644 vitest.flowdeck.config.ts diff --git a/example_projects/macOS/MCPTestTests/MCPTestsXCTests.swift b/example_projects/macOS/MCPTestTests/MCPTestsXCTests.swift new file mode 100644 index 00000000..9262029c --- /dev/null +++ b/example_projects/macOS/MCPTestTests/MCPTestsXCTests.swift @@ -0,0 +1,13 @@ +import XCTest + +final class MCPTestsXCTests: XCTestCase { + + func testAppNameIsCorrect() async throws { + let expected = "MCPTest" + XCTAssertTrue(expected == "MCPTest") + } + + func testDeliberateFailure() async throws { + XCTAssertTrue(1 == 2, "This test is designed to fail for snapshot testing") + } +} diff --git a/example_projects/spm/.xcodebuildmcp/config.yaml b/example_projects/spm/.xcodebuildmcp/config.yaml new file mode 100644 index 00000000..b28ad2ad --- /dev/null +++ b/example_projects/spm/.xcodebuildmcp/config.yaml @@ -0,0 +1,9 @@ +schemaVersion: 1 +enabledWorkflows: + - project-discovery + - swift-package +debug: false +sentryDisabled: true +sessionDefaults: + workspacePath: .swiftpm/xcode/package.xcworkspace + scheme: long-server diff --git a/src/mcp/tools/device/__tests__/build_run_device.test.ts b/src/mcp/tools/device/__tests__/build_run_device.test.ts index 3cd755a9..1f027a4e 100644 --- a/src/mcp/tools/device/__tests__/build_run_device.test.ts +++ b/src/mcp/tools/device/__tests__/build_run_device.test.ts @@ -228,6 +228,7 @@ describe('build_run_device tool', () => { expect.objectContaining({ label: 'App Path', value: '/tmp/build/MyApp.app' }), expect.objectContaining({ label: 'Bundle ID', value: 'io.sentry.MyApp' }), expect.objectContaining({ label: 'Process ID', value: '1234' }), + expect.objectContaining({ label: 'Build Logs', value: expect.stringContaining('build_run_device_') }), ]), }), ], @@ -280,6 +281,7 @@ describe('build_run_device tool', () => { const detailTree = tailEvents[1]; expect(detailTree.type).toBe('detail-tree'); expect(detailTree.items?.some((item) => item.label === 'Process ID')).toBe(false); + expect(detailTree.items?.some((item) => item.label === 'Build Logs')).toBe(true); }); it('uses generic destination for build-settings lookup', async () => { diff --git a/src/mcp/tools/device/__tests__/get_device_app_path.test.ts b/src/mcp/tools/device/__tests__/get_device_app_path.test.ts index 30029910..6abf16bd 100644 --- a/src/mcp/tools/device/__tests__/get_device_app_path.test.ts +++ b/src/mcp/tools/device/__tests__/get_device_app_path.test.ts @@ -296,7 +296,9 @@ describe('get_device_app_path plugin', () => { expect(text).toContain('Get App Path'); expect(text).toContain('Scheme: MyScheme'); expect(text).toContain('Project: /path/to/nonexistent.xcodeproj'); - expect(text).toContain('The project does not exist.'); + expect(text).toContain('Errors (1):'); + expect(text).toContain('โœ— The project does not exist.'); + expect(text).toContain('Query failed.'); expect(result.nextStepParams).toBeUndefined(); }); diff --git a/src/mcp/tools/device/build_device.ts b/src/mcp/tools/device/build_device.ts index 4fbc63bf..0b02e680 100644 --- a/src/mcp/tools/device/build_device.ts +++ b/src/mcp/tools/device/build_device.ts @@ -82,6 +82,8 @@ export async function buildDeviceLogic( const pipelineParams = { scheme: params.scheme, + workspacePath: params.workspacePath, + projectPath: params.projectPath, configuration: processedParams.configuration, platform: 'iOS', preflight: preflightText, diff --git a/src/mcp/tools/device/build_run_device.ts b/src/mcp/tools/device/build_run_device.ts index c03a4ee3..64ed3998 100644 --- a/src/mcp/tools/device/build_run_device.ts +++ b/src/mcp/tools/device/build_run_device.ts @@ -101,6 +101,8 @@ export async function build_run_deviceLogic( toolName: 'build_run_device', params: { scheme: params.scheme, + workspacePath: params.workspacePath, + projectPath: params.projectPath, configuration, platform: String(platform), deviceId: params.deviceId, @@ -307,7 +309,9 @@ export async function build_run_deviceLogic( bundleId, launchState: 'requested', ...(processId !== undefined ? { processId } : {}), + buildLogPath: started.pipeline.logPath, }), + includeBuildLogFileRef: false, }, ); } catch (error) { diff --git a/src/mcp/tools/device/get_device_app_path.ts b/src/mcp/tools/device/get_device_app_path.ts index bb776bcf..c80b9b8b 100644 --- a/src/mcp/tools/device/get_device_app_path.ts +++ b/src/mcp/tools/device/get_device_app_path.ts @@ -5,6 +5,7 @@ * Accepts mutually exclusive `projectPath` or `workspacePath`. */ +import path from 'node:path'; import * as z from 'zod'; import type { ToolResponse } from '../../../types/common.ts'; import { log } from '../../../utils/logging/index.ts'; @@ -15,9 +16,13 @@ import { getSessionAwareToolSchemaShape, } from '../../../utils/typed-tool-factory.ts'; import { nullifyEmptyStrings } from '../../../utils/schema-helpers.ts'; -import { mapDevicePlatform, resolveAppPathFromBuildSettings } from './build-settings.ts'; -import { toolResponse } from '../../../utils/tool-response.ts'; -import { header, statusLine, detailTree } from '../../../utils/tool-event-builders.ts'; +import { mapDevicePlatform } from './build-settings.ts'; +import { formatToolPreflight } from '../../../utils/build-preflight.ts'; +import { formatQueryError, formatQueryFailureSummary } from '../../../utils/xcodebuild-error-utils.ts'; +import { + extractAppPathFromBuildSettingsOutput, + getBuildSettingsDestination, +} from '../../../utils/app-path-resolver.ts'; // Unified schema: XOR between projectPath and workspacePath, sharing common options const baseOptions = { @@ -53,65 +58,93 @@ const publicSchemaObject = baseSchemaObject.omit({ platform: true, } as const); -function buildHeaderParams( - params: GetDeviceAppPathParams, - configuration: string, - platform: string, -) { - const headerParams: Array<{ label: string; value: string }> = [ - { label: 'Scheme', value: params.scheme }, - ]; - if (params.workspacePath) { - headerParams.push({ label: 'Workspace', value: params.workspacePath }); - } else if (params.projectPath) { - headerParams.push({ label: 'Project', value: params.projectPath }); - } - headerParams.push({ label: 'Configuration', value: configuration }); - headerParams.push({ label: 'Platform', value: platform }); - return headerParams; -} - export async function get_device_app_pathLogic( params: GetDeviceAppPathParams, executor: CommandExecutor, ): Promise { const platform = mapDevicePlatform(params.platform); const configuration = params.configuration ?? 'Debug'; - const headerEvent = header('Get App Path', buildHeaderParams(params, configuration, platform)); + const preflightText = formatToolPreflight({ + operation: 'Get App Path', + scheme: params.scheme, + workspacePath: params.workspacePath, + projectPath: params.projectPath, + configuration, + platform, + }); log('info', `Getting app path for scheme ${params.scheme} on platform ${platform}`); try { - const appPath = await resolveAppPathFromBuildSettings( - { - projectPath: params.projectPath, - workspacePath: params.workspacePath, - scheme: params.scheme, - configuration, - platform, - }, - executor, + const command = ['xcodebuild', '-showBuildSettings']; + + const projectPath = params.projectPath ? path.resolve(process.cwd(), params.projectPath) : undefined; + const workspacePath = params.workspacePath ? path.resolve(process.cwd(), params.workspacePath) : undefined; + + if (projectPath) { + command.push('-project', projectPath); + } else if (workspacePath) { + command.push('-workspace', workspacePath); + } + + command.push('-scheme', params.scheme); + command.push('-configuration', configuration); + command.push('-destination', getBuildSettingsDestination(platform)); + + const workingDirectory = projectPath + ? path.dirname(projectPath) + : workspacePath + ? path.dirname(workspacePath) + : undefined; + + const result = await executor( + command, + 'Get App Path', + false, + workingDirectory ? { cwd: workingDirectory } : undefined, ); - return toolResponse( - [ - headerEvent, - statusLine('success', 'App path resolved.'), - detailTree([{ label: 'App Path', value: appPath }]), - ], - { - nextStepParams: { - get_app_bundle_id: { appPath }, - install_app_device: { deviceId: 'DEVICE_UDID', appPath }, - launch_app_device: { deviceId: 'DEVICE_UDID', bundleId: 'BUNDLE_ID' }, + if (!result.success) { + const rawOutput = [result.error, result.output].filter(Boolean).join('\n'); + return { + content: [ + { + type: 'text', + text: `\n${preflightText}${formatQueryError(rawOutput)}\n\n${formatQueryFailureSummary()}`, + }, + ], + isError: true, + }; + } + + const appPath = extractAppPathFromBuildSettingsOutput(result.output); + + return { + content: [ + { + type: 'text', + text: `\n${preflightText} โ”” App Path: ${appPath}`, }, + ], + nextStepParams: { + get_app_bundle_id: { appPath }, + install_app_device: { deviceId: 'DEVICE_UDID', appPath }, + launch_app_device: { deviceId: 'DEVICE_UDID', bundleId: 'BUNDLE_ID' }, }, - ); + }; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); log('error', `Error retrieving app path: ${errorMessage}`); - return toolResponse([headerEvent, statusLine('error', errorMessage)]); + return { + content: [ + { + type: 'text', + text: `\n${preflightText}${formatQueryError(errorMessage)}\n\n${formatQueryFailureSummary()}`, + }, + ], + isError: true, + }; } } diff --git a/src/mcp/tools/device/list_devices.ts b/src/mcp/tools/device/list_devices.ts index e8697911..5654f5e0 100644 --- a/src/mcp/tools/device/list_devices.ts +++ b/src/mcp/tools/device/list_devices.ts @@ -16,7 +16,7 @@ import { tmpdir } from 'os'; import { join } from 'path'; import type { PipelineEvent } from '../../../types/pipeline-events.ts'; import { toolResponse } from '../../../utils/tool-response.ts'; -import { header, statusLine, section, detailTree } from '../../../utils/tool-event-builders.ts'; +import { header, statusLine, section, table } from '../../../utils/tool-event-builders.ts'; const listDevicesSchema = z.object({}); @@ -29,7 +29,7 @@ function isAvailableState(state: string): boolean { function getPlatformLabel(platformIdentifier?: string): string { const platformId = platformIdentifier?.toLowerCase() ?? ''; - if (platformId.includes('ios') || platformId.includes('iphone')) { + if (platformId.includes('iphone') || platformId.includes('ios')) { return 'iOS'; } if (platformId.includes('ipad')) { @@ -38,12 +38,15 @@ function getPlatformLabel(platformIdentifier?: string): string { if (platformId.includes('watch')) { return 'watchOS'; } - if (platformId.includes('tv') || platformId.includes('apple tv')) { + if (platformId.includes('appletv') || platformId.includes('tvos') || platformId.includes('apple tv')) { return 'tvOS'; } - if (platformId.includes('vision')) { + if (platformId.includes('xros') || platformId.includes('vision')) { return 'visionOS'; } + if (platformId.includes('mac')) { + return 'macOS'; + } return 'Unknown'; } @@ -228,48 +231,47 @@ export async function list_devicesLogic( const unpairedDevices = uniqueDevices.filter((d) => d.state === 'Unpaired'); if (availableDevices.length > 0) { - for (const device of availableDevices) { - const items: Array<{ label: string; value: string }> = [ - { label: 'UDID', value: device.identifier }, - { label: 'Model', value: device.model ?? 'Unknown' }, - ]; - if (device.productType) { - items.push({ label: 'Product Type', value: device.productType }); - } - items.push({ - label: 'Platform', - value: `${device.platform} ${device.osVersion ?? ''}`.trim(), - }); - if (device.cpuArchitecture) { - items.push({ label: 'CPU Architecture', value: device.cpuArchitecture }); - } - items.push({ label: 'Connection', value: device.connectionType ?? 'Unknown' }); - if (device.developerModeStatus) { - items.push({ label: 'Developer Mode', value: device.developerModeStatus }); - } - events.push(section(device.name, [], { icon: 'green-circle' }), detailTree(items)); - } + events.push( + table( + ['Name', 'Identifier', 'Platform', 'Model', 'Connection', 'Developer Mode'], + availableDevices.map((device) => ({ + Name: device.name, + Identifier: device.identifier, + Platform: `${device.platform} ${device.osVersion ?? ''}`.trim(), + Model: device.model ?? device.productType ?? 'Unknown', + Connection: device.connectionType || 'Unknown', + 'Developer Mode': device.developerModeStatus ?? 'Unknown', + })), + 'Available Devices', + ), + ); } if (pairedDevices.length > 0) { - for (const device of pairedDevices) { - events.push( - section(device.name, [], { icon: 'yellow-circle' }), - detailTree([ - { label: 'UDID', value: device.identifier }, - { label: 'Model', value: device.model ?? 'Unknown' }, - { label: 'Platform', value: `${device.platform} ${device.osVersion ?? ''}`.trim() }, - ]), - ); - } + events.push( + table( + ['Name', 'Identifier', 'Platform', 'Model'], + pairedDevices.map((device) => ({ + Name: device.name, + Identifier: device.identifier, + Platform: `${device.platform} ${device.osVersion ?? ''}`.trim(), + Model: device.model ?? device.productType ?? 'Unknown', + })), + 'Paired Devices', + ), + ); } if (unpairedDevices.length > 0) { events.push( - section( + table( + ['Name', 'Identifier', 'Platform'], + unpairedDevices.map((device) => ({ + Name: device.name, + Identifier: device.identifier, + Platform: `${device.platform} ${device.osVersion ?? ''}`.trim(), + })), 'Unpaired Devices', - unpairedDevices.map((d) => `${d.name} (${d.identifier})`), - { icon: 'red-circle' }, ), ); } diff --git a/src/mcp/tools/macos/__tests__/build_run_macos.test.ts b/src/mcp/tools/macos/__tests__/build_run_macos.test.ts index 726c682f..a3cb547a 100644 --- a/src/mcp/tools/macos/__tests__/build_run_macos.test.ts +++ b/src/mcp/tools/macos/__tests__/build_run_macos.test.ts @@ -146,6 +146,7 @@ describe('build_run_macos', () => { type: 'detail-tree', items: expect.arrayContaining([ expect.objectContaining({ label: 'App Path', value: '/path/to/build/MyApp.app' }), + expect.objectContaining({ label: 'Build Logs', value: expect.stringContaining('build_run_macos_') }), ]), }), ], diff --git a/src/mcp/tools/macos/__tests__/get_mac_app_path.test.ts b/src/mcp/tools/macos/__tests__/get_mac_app_path.test.ts index c58568b3..c9989b06 100644 --- a/src/mcp/tools/macos/__tests__/get_mac_app_path.test.ts +++ b/src/mcp/tools/macos/__tests__/get_mac_app_path.test.ts @@ -429,7 +429,9 @@ FULL_PRODUCT_NAME = MyApp.app const text = allText(result); expect(text).toContain('Get App Path'); expect(text).toContain('Scheme: MyScheme'); - expect(text).toContain('No such scheme'); + expect(text).toContain('Errors (1):'); + expect(text).toContain('โœ— No such scheme'); + expect(text).toContain('Query failed.'); expect(result.nextStepParams).toBeUndefined(); }); diff --git a/src/mcp/tools/macos/build_macos.ts b/src/mcp/tools/macos/build_macos.ts index a3f0a287..c58fd7c5 100644 --- a/src/mcp/tools/macos/build_macos.ts +++ b/src/mcp/tools/macos/build_macos.ts @@ -93,6 +93,8 @@ export async function buildMacOSLogic( const pipelineParams = { scheme: params.scheme, + workspacePath: params.workspacePath, + projectPath: params.projectPath, configuration: processedParams.configuration, platform: 'macOS', preflight: preflightText, diff --git a/src/mcp/tools/macos/build_run_macos.ts b/src/mcp/tools/macos/build_run_macos.ts index 8911eb30..64e2ed70 100644 --- a/src/mcp/tools/macos/build_run_macos.ts +++ b/src/mcp/tools/macos/build_run_macos.ts @@ -94,6 +94,8 @@ export async function buildRunMacOSLogic( toolName: 'build_run_macos', params: { scheme: params.scheme, + workspacePath: params.workspacePath, + projectPath: params.projectPath, configuration, platform: 'macOS', preflight: preflightText, @@ -186,7 +188,9 @@ export async function buildRunMacOSLogic( target: 'macOS', appPath, launchState: 'requested', + buildLogPath: started.pipeline.logPath, }), + includeBuildLogFileRef: false, }, ); } catch (error) { diff --git a/src/mcp/tools/macos/get_mac_app_path.ts b/src/mcp/tools/macos/get_mac_app_path.ts index 2028e498..4a8d68bc 100644 --- a/src/mcp/tools/macos/get_mac_app_path.ts +++ b/src/mcp/tools/macos/get_mac_app_path.ts @@ -15,8 +15,9 @@ import { getSessionAwareToolSchemaShape, } from '../../../utils/typed-tool-factory.ts'; import { nullifyEmptyStrings } from '../../../utils/schema-helpers.ts'; -import { toolResponse } from '../../../utils/tool-response.ts'; -import { header, detailTree, statusLine } from '../../../utils/tool-event-builders.ts'; +import { formatToolPreflight } from '../../../utils/build-preflight.ts'; +import { formatQueryError, formatQueryFailureSummary } from '../../../utils/xcodebuild-error-utils.ts'; +import { extractAppPathFromBuildSettingsOutput } from '../../../utils/app-path-resolver.ts'; const baseOptions = { scheme: z.string().describe('The scheme to use'), @@ -56,29 +57,20 @@ const getMacosAppPathSchema = z.preprocess( type GetMacosAppPathParams = z.infer; -function buildHeaderParams(params: GetMacosAppPathParams, configuration: string) { - const headerParams: Array<{ label: string; value: string }> = [ - { label: 'Scheme', value: params.scheme }, - ]; - if (params.workspacePath) { - headerParams.push({ label: 'Workspace', value: params.workspacePath }); - } else if (params.projectPath) { - headerParams.push({ label: 'Project', value: params.projectPath }); - } - headerParams.push({ label: 'Configuration', value: configuration }); - headerParams.push({ label: 'Platform', value: 'macOS' }); - if (params.arch) { - headerParams.push({ label: 'Architecture', value: params.arch }); - } - return headerParams; -} - export async function get_mac_app_pathLogic( params: GetMacosAppPathParams, executor: CommandExecutor, ): Promise { const configuration = params.configuration ?? 'Debug'; - const headerEvent = header('Get App Path', buildHeaderParams(params, configuration)); + const preflightText = formatToolPreflight({ + operation: 'Get App Path', + scheme: params.scheme, + workspacePath: params.workspacePath, + projectPath: params.projectPath, + configuration, + platform: 'macOS', + arch: params.arch, + }); log('info', `Getting app path for scheme ${params.scheme} on platform macOS`); @@ -110,47 +102,69 @@ export async function get_mac_app_pathLogic( const result = await executor(command, 'Get App Path', false); if (!result.success) { - return toolResponse([headerEvent, statusLine('error', result.error ?? 'Unknown error')]); + const rawOutput = [result.error, result.output].filter(Boolean).join('\n'); + return { + content: [ + { + type: 'text', + text: `\n${preflightText}${formatQueryError(rawOutput)}\n\n${formatQueryFailureSummary()}`, + }, + ], + isError: true, + }; } if (!result.output) { - return toolResponse([ - headerEvent, - statusLine('error', 'Failed to extract build settings output from the result'), - ]); + return { + content: [ + { + type: 'text', + text: `\n${preflightText}${formatQueryError('Failed to extract build settings output from the result.')}\n\n${formatQueryFailureSummary()}`, + }, + ], + isError: true, + }; } - const builtProductsDirMatch = result.output.match(/^\s*BUILT_PRODUCTS_DIR\s*=\s*(.+)$/m); - const fullProductNameMatch = result.output.match(/^\s*FULL_PRODUCT_NAME\s*=\s*(.+)$/m); - - if (!builtProductsDirMatch || !fullProductNameMatch) { - return toolResponse([ - headerEvent, - statusLine('error', 'Could not extract app path from build settings'), - ]); + let appPath: string; + try { + appPath = extractAppPathFromBuildSettingsOutput(result.output); + } catch { + return { + content: [ + { + type: 'text', + text: `\n${preflightText}${formatQueryError('Could not extract app path from build settings.')}\n\n${formatQueryFailureSummary()}`, + }, + ], + isError: true, + }; } - const builtProductsDir = builtProductsDirMatch[1].trim(); - const fullProductName = fullProductNameMatch[1].trim(); - const appPath = `${builtProductsDir}/${fullProductName}`; - - return toolResponse( - [ - headerEvent, - statusLine('success', 'App path resolved.'), - detailTree([{ label: 'App Path', value: appPath }]), - ], - { - nextStepParams: { - get_mac_bundle_id: { appPath }, - launch_mac_app: { appPath }, + return { + content: [ + { + type: 'text', + text: `\n${preflightText} โ”” App Path: ${appPath}`, }, + ], + nextStepParams: { + get_mac_bundle_id: { appPath }, + launch_mac_app: { appPath }, }, - ); + }; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); log('error', `Error retrieving app path: ${errorMessage}`); - return toolResponse([headerEvent, statusLine('error', errorMessage)]); + return { + content: [ + { + type: 'text', + text: `\n${preflightText}${formatQueryError(errorMessage)}\n\n${formatQueryFailureSummary()}`, + }, + ], + isError: true, + }; } } diff --git a/src/mcp/tools/session-management/__tests__/session_use_defaults_profile.test.ts b/src/mcp/tools/session-management/__tests__/session_use_defaults_profile.test.ts index 74317818..03b333ce 100644 --- a/src/mcp/tools/session-management/__tests__/session_use_defaults_profile.test.ts +++ b/src/mcp/tools/session-management/__tests__/session_use_defaults_profile.test.ts @@ -64,7 +64,7 @@ describe('session-use-defaults-profile tool', () => { it('returns status for empty args', async () => { const result = await sessionUseDefaultsProfileLogic({}); expect(result.isError).toBeFalsy(); - expect(allText(result)).toContain('Active profile: global'); + expect(allText(result)).toContain('Active profile: global defaults'); }); it('persists active profile when persist=true', async () => { diff --git a/src/mcp/tools/session-management/session_show_defaults.ts b/src/mcp/tools/session-management/session_show_defaults.ts index f6e4f9d2..bd6ea254 100644 --- a/src/mcp/tools/session-management/session_show_defaults.ts +++ b/src/mcp/tools/session-management/session_show_defaults.ts @@ -5,9 +5,14 @@ import { header, detailTree, statusLine } from '../../../utils/tool-event-builde export const schema = {}; +function formatActiveProfileLabel(activeProfile: string | null): string { + return activeProfile ?? 'global defaults'; +} + export const handler = async (): Promise => { const current = sessionStore.getAll(); const activeProfile = sessionStore.getActiveProfile(); + const activeProfileLabel = formatActiveProfileLabel(activeProfile); const items = Object.entries(current) .filter(([, v]) => v !== undefined) @@ -18,13 +23,13 @@ export const handler = async (): Promise => { header('Show Defaults'), statusLine( 'info', - `No session defaults are set. Active profile: ${activeProfile ?? 'global'}`, + `No session defaults are set. Active profile: ${activeProfileLabel}`, ), ]); } return toolResponse([ - header('Show Defaults', [{ label: 'Active Profile', value: activeProfile ?? 'global' }]), + header('Show Defaults', [{ label: 'Active Profile', value: activeProfileLabel }]), detailTree(items), ]); }; diff --git a/src/mcp/tools/session-management/session_use_defaults_profile.ts b/src/mcp/tools/session-management/session_use_defaults_profile.ts index 3276fb4f..e6cf64fe 100644 --- a/src/mcp/tools/session-management/session_use_defaults_profile.ts +++ b/src/mcp/tools/session-management/session_use_defaults_profile.ts @@ -23,6 +23,10 @@ const schemaObj = z.object({ type Params = z.input; +function formatActiveProfileLabel(activeProfile: string | null): string { + return activeProfile ?? 'global defaults'; +} + function resolveProfileToActivate(params: Params): string | null | undefined { if (params.global === true) return null; if (params.profile === undefined) return undefined; @@ -64,7 +68,7 @@ export async function sessionUseDefaultsProfileLogic(params: Params): Promise { items: expect.arrayContaining([ expect.objectContaining({ label: 'App Path', value: '/path/to/build/MyApp.app' }), expect.objectContaining({ label: 'Bundle ID', value: 'io.sentry.MyApp' }), + expect.objectContaining({ label: 'Build Logs', value: expect.stringContaining('build_run_sim_') }), ]), }), ], diff --git a/src/mcp/tools/simulator/__tests__/get_sim_app_path.test.ts b/src/mcp/tools/simulator/__tests__/get_sim_app_path.test.ts index 0d94b529..2fb4d44d 100644 --- a/src/mcp/tools/simulator/__tests__/get_sim_app_path.test.ts +++ b/src/mcp/tools/simulator/__tests__/get_sim_app_path.test.ts @@ -185,7 +185,9 @@ describe('get_sim_app_path tool', () => { const text = result.content.map((c) => c.text).join('\n'); expect(text).toContain('Get App Path'); expect(text).toContain('MyScheme'); - expect(text).toContain('Failed to get build settings'); + expect(text).toContain('Errors (1):'); + expect(text).toContain('โœ— Failed to run xcodebuild'); + expect(text).toContain('Query failed.'); expect(result.nextStepParams).toBeUndefined(); }); }); diff --git a/src/mcp/tools/simulator/__tests__/launch_app_logs_sim.test.ts b/src/mcp/tools/simulator/__tests__/launch_app_logs_sim.test.ts index f4b7ea39..2bc90fa7 100644 --- a/src/mcp/tools/simulator/__tests__/launch_app_logs_sim.test.ts +++ b/src/mcp/tools/simulator/__tests__/launch_app_logs_sim.test.ts @@ -83,6 +83,9 @@ describe('launch_app_logs_sim tool', () => { expect(text).toContain('test-uuid-123'); expect(text).toContain('log capture enabled'); expect(text).toContain('test-session-123'); + expect(text.indexOf('App launched successfully')).toBeLessThan( + text.indexOf('Log Session ID: test-session-123'), + ); expect(result.nextStepParams).toEqual({ stop_sim_log_cap: { logSessionId: 'test-session-123' }, }); diff --git a/src/mcp/tools/simulator/__tests__/screenshot.test.ts b/src/mcp/tools/simulator/__tests__/screenshot.test.ts index 6e17a953..c16ba583 100644 --- a/src/mcp/tools/simulator/__tests__/screenshot.test.ts +++ b/src/mcp/tools/simulator/__tests__/screenshot.test.ts @@ -85,7 +85,7 @@ describe('screenshot plugin', () => { mockUuidDeps, ); - expect(capturedCommands).toHaveLength(4); + expect(capturedCommands).toHaveLength(5); expect(capturedCommands[0]).toEqual([ 'xcrun', @@ -117,6 +117,9 @@ describe('screenshot plugin', () => { '--out', '/tmp/screenshot_optimized_mock-uuid-123.jpg', ]); + + expect(capturedCommands[4][0]).toBe('sips'); + expect(capturedCommands[4][1]).toBe('-g'); }); it('should generate correct path with different uuid', async () => { @@ -159,7 +162,7 @@ describe('screenshot plugin', () => { mockUuidDeps, ); - expect(capturedCommands).toHaveLength(4); + expect(capturedCommands).toHaveLength(5); expect(capturedCommands[0]).toEqual([ 'xcrun', @@ -191,6 +194,9 @@ describe('screenshot plugin', () => { '--out', '/tmp/screenshot_optimized_different-uuid-456.jpg', ]); + + expect(capturedCommands[4][0]).toBe('sips'); + expect(capturedCommands[4][1]).toBe('-g'); }); it('should use default dependencies when not provided', async () => { @@ -222,8 +228,8 @@ describe('screenshot plugin', () => { mockFileSystemExecutor, ); - // Should execute all commands in sequence: screenshot, list devices, orientation detection, optimization - expect(capturedCommands).toHaveLength(4); + // Should execute all commands in sequence: screenshot, list devices, orientation detection, optimization, dimensions + expect(capturedCommands).toHaveLength(5); const firstCommand = capturedCommands[0]; expect(firstCommand).toHaveLength(6); @@ -286,6 +292,7 @@ describe('screenshot plugin', () => { const text = allText(result); expect(text).toContain('Screenshot'); expect(text).toContain('Screenshot captured.'); + expect(text).toContain('Format: image/jpeg'); const imageContent = result.content.find((c) => c.type === 'image'); expect(imageContent).toEqual({ type: 'image', @@ -425,7 +432,7 @@ describe('screenshot plugin', () => { mockUuidDeps, ); - expect(capturedArgs).toHaveLength(4); + expect(capturedArgs).toHaveLength(5); expect(capturedArgs[0]).toEqual([ ['xcrun', 'simctl', 'io', 'test-uuid', 'screenshot', '/tmp/screenshot_mock-uuid-123.png'], @@ -462,6 +469,10 @@ describe('screenshot plugin', () => { '[Screenshot]: optimize image', false, ]); + + expect(capturedArgs[4][0][0]).toBe('sips'); + expect(capturedArgs[4][0][1]).toBe('-g'); + expect(capturedArgs[4][1]).toBe('[Screenshot]: get dimensions'); }); it('should handle SystemError exceptions', async () => { diff --git a/src/mcp/tools/simulator/build_run_sim.ts b/src/mcp/tools/simulator/build_run_sim.ts index 36574198..c474cc12 100644 --- a/src/mcp/tools/simulator/build_run_sim.ts +++ b/src/mcp/tools/simulator/build_run_sim.ts @@ -151,8 +151,12 @@ export async function build_run_simLogic( toolName: 'build_run_sim', params: { scheme: params.scheme, + workspacePath: params.workspacePath, + projectPath: params.projectPath, configuration, platform: displayPlatform, + simulatorName: params.simulatorName, + simulatorId: params.simulatorId, preflight: preflightText, }, message: preflightText, @@ -439,6 +443,7 @@ export async function build_run_simLogic( data: { step: 'launch-app', status: 'started', appPath: appBundlePath }, }); + let processId: number | undefined; try { log('info', `Launching app with bundle ID: ${bundleId} on simulator: ${simulatorId}`); const launchResult = await executor( @@ -448,6 +453,11 @@ export async function build_run_simLogic( if (!launchResult.success) { throw new Error(launchResult.error ?? 'Failed to launch app'); } + const pidMatch = launchResult.output?.match(/:\s*(\d+)\s*$/); + if (pidMatch) { + processId = parseInt(pidMatch[1], 10); + log('info', `Launched with PID: ${processId}`); + } } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); log('error', `Failed to launch app: ${errorMessage}`); @@ -482,7 +492,10 @@ export async function build_run_simLogic( appPath: appBundlePath, bundleId, launchState: 'requested', + processId, + buildLogPath: started.pipeline.logPath, }), + includeBuildLogFileRef: false, }, ); } catch (error) { diff --git a/src/mcp/tools/simulator/build_sim.ts b/src/mcp/tools/simulator/build_sim.ts index f1474900..fcf54bfe 100644 --- a/src/mcp/tools/simulator/build_sim.ts +++ b/src/mcp/tools/simulator/build_sim.ts @@ -136,8 +136,12 @@ async function _handleSimulatorBuildLogic( const pipelineParams = { scheme: params.scheme, + workspacePath: params.workspacePath, + projectPath: params.projectPath, configuration: sharedBuildParams.configuration, platform: String(detectedPlatform), + simulatorName: params.simulatorName, + simulatorId: params.simulatorId, preflight: preflightText, }; diff --git a/src/mcp/tools/simulator/get_sim_app_path.ts b/src/mcp/tools/simulator/get_sim_app_path.ts index 7ef989a5..7bc79e1d 100644 --- a/src/mcp/tools/simulator/get_sim_app_path.ts +++ b/src/mcp/tools/simulator/get_sim_app_path.ts @@ -18,8 +18,9 @@ import { getSessionAwareToolSchemaShape, } from '../../../utils/typed-tool-factory.ts'; import { nullifyEmptyStrings } from '../../../utils/schema-helpers.ts'; -import { toolResponse } from '../../../utils/tool-response.ts'; -import { header, statusLine, detailTree } from '../../../utils/tool-event-builders.ts'; +import { formatToolPreflight } from '../../../utils/build-preflight.ts'; +import { formatQueryError, formatQueryFailureSummary } from '../../../utils/xcodebuild-error-utils.ts'; +import { extractAppPathFromBuildSettingsOutput } from '../../../utils/app-path-resolver.ts'; const SIMULATOR_PLATFORMS = [ XcodePlatform.iOSSimulator, @@ -98,23 +99,16 @@ export async function get_sim_app_pathLogic( log('info', `Getting app path for scheme ${params.scheme} on platform ${params.platform}`); - const headerParams: Array<{ label: string; value: string }> = [ - { label: 'Scheme', value: params.scheme }, - ]; - if (params.workspacePath) { - headerParams.push({ label: 'Workspace', value: params.workspacePath }); - } else if (params.projectPath) { - headerParams.push({ label: 'Project', value: params.projectPath }); - } - headerParams.push({ label: 'Configuration', value: configuration }); - headerParams.push({ label: 'Platform', value: params.platform }); - if (params.simulatorName) { - headerParams.push({ label: 'Simulator', value: params.simulatorName }); - } else if (params.simulatorId) { - headerParams.push({ label: 'Simulator', value: params.simulatorId }); - } - - const headerEvent = header('Get App Path', headerParams); + const preflightText = formatToolPreflight({ + operation: 'Get App Path', + scheme: params.scheme, + workspacePath: params.workspacePath, + projectPath: params.projectPath, + configuration, + platform: params.platform, + simulatorName: params.simulatorName, + simulatorId: params.simulatorId, + }); try { const command = ['xcodebuild', '-showBuildSettings']; @@ -138,58 +132,70 @@ export async function get_sim_app_pathLogic( if (!result.success) { const rawOutput = [result.error, result.output].filter(Boolean).join('\n'); - return toolResponse([ - headerEvent, - statusLine('error', `Failed to get build settings: ${rawOutput}`), - ]); + return { + content: [ + { + type: 'text', + text: `\n${preflightText}${formatQueryError(rawOutput)}\n\n${formatQueryFailureSummary()}`, + }, + ], + isError: true, + }; } if (!result.output) { - return toolResponse([ - headerEvent, - statusLine('error', 'Failed to extract build settings output from the result.'), - ]); + return { + content: [ + { + type: 'text', + text: `\n${preflightText}${formatQueryError('Failed to extract build settings output from the result.')}\n\n${formatQueryFailureSummary()}`, + }, + ], + isError: true, + }; } - const builtProductsDirMatch = result.output.match(/^\s*BUILT_PRODUCTS_DIR\s*=\s*(.+)$/m); - const fullProductNameMatch = result.output.match(/^\s*FULL_PRODUCT_NAME\s*=\s*(.+)$/m); - - if (!builtProductsDirMatch || !fullProductNameMatch) { - return toolResponse([ - headerEvent, - statusLine( - 'error', - 'Failed to extract app path from build settings. Make sure the app has been built first.', - ), - ]); + let appPath: string; + try { + appPath = extractAppPathFromBuildSettingsOutput(result.output); + } catch { + return { + content: [ + { + type: 'text', + text: `\n${preflightText}${formatQueryError('Failed to extract app path from build settings. Make sure the app has been built first.')}\n\n${formatQueryFailureSummary()}`, + }, + ], + isError: true, + }; } - const builtProductsDir = builtProductsDirMatch[1].trim(); - const fullProductName = fullProductNameMatch[1].trim(); - const appPath = `${builtProductsDir}/${fullProductName}`; - - return toolResponse( - [ - headerEvent, - statusLine('success', 'App path resolved'), - detailTree([{ label: 'App Path', value: appPath }]), - ], - { - nextStepParams: { - get_app_bundle_id: { appPath }, - boot_sim: { simulatorId: 'SIMULATOR_UUID' }, - install_app_sim: { simulatorId: 'SIMULATOR_UUID', appPath }, - launch_app_sim: { simulatorId: 'SIMULATOR_UUID', bundleId: 'BUNDLE_ID' }, + return { + content: [ + { + type: 'text', + text: `\n${preflightText} โ”” App Path: ${appPath}`, }, + ], + nextStepParams: { + get_app_bundle_id: { appPath }, + boot_sim: { simulatorId: 'SIMULATOR_UUID' }, + install_app_sim: { simulatorId: 'SIMULATOR_UUID', appPath }, + launch_app_sim: { simulatorId: 'SIMULATOR_UUID', bundleId: 'BUNDLE_ID' }, }, - ); + }; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); log('error', `Error retrieving app path: ${errorMessage}`); - return toolResponse([ - headerEvent, - statusLine('error', `Error retrieving app path: ${errorMessage}`), - ]); + return { + content: [ + { + type: 'text', + text: `\n${preflightText}${formatQueryError(errorMessage)}\n\n${formatQueryFailureSummary()}`, + }, + ], + isError: true, + }; } } diff --git a/src/mcp/tools/simulator/launch_app_logs_sim.ts b/src/mcp/tools/simulator/launch_app_logs_sim.ts index 5e76e4ec..7abce3b3 100644 --- a/src/mcp/tools/simulator/launch_app_logs_sim.ts +++ b/src/mcp/tools/simulator/launch_app_logs_sim.ts @@ -100,11 +100,11 @@ export async function launch_app_logs_simLogic( return toolResponse( [ headerEvent, - detailTree([{ label: 'Log Session ID', value: sessionId }]), statusLine( 'success', `App launched successfully in simulator ${params.simulatorId} with log capture enabled`, ), + detailTree([{ label: 'Log Session ID', value: sessionId }]), ], { nextStepParams: { diff --git a/src/mcp/tools/simulator/list_sims.ts b/src/mcp/tools/simulator/list_sims.ts index 3d328e66..7367c45a 100644 --- a/src/mcp/tools/simulator/list_sims.ts +++ b/src/mcp/tools/simulator/list_sims.ts @@ -169,6 +169,14 @@ export async function listSimulators(executor: CommandExecutor): Promise { { v4: () => 'test-uuid' }, ); - // Should have: screenshot, list devices, orientation detection, optimization (no rotation) - expect(capturedCommands.length).toBe(4); + // Should have: screenshot, list devices, orientation detection, optimization, dimensions (no rotation) + expect(capturedCommands.length).toBe(5); // Fourth command should be optimization, not rotation expect(capturedCommands[3][0]).toBe('sips'); expect(capturedCommands[3]).toContain('-Z'); + // Fifth command should be dimensions + expect(capturedCommands[4][0]).toBe('sips'); + expect(capturedCommands[4][1]).toBe('-g'); }); it('should continue without rotation if orientation detection fails', async () => { @@ -764,8 +767,8 @@ describe('Screenshot Plugin', () => { // Should still succeed expect(result.isError).toBeFalsy(); - // Should have: screenshot, list devices, failed orientation detection, optimization - expect(capturedCommands.length).toBe(4); + // Should have: screenshot, list devices, failed orientation detection, optimization, dimensions + expect(capturedCommands.length).toBe(5); }); it('should continue if rotation fails but still return image', async () => { diff --git a/src/mcp/tools/ui-automation/screenshot.ts b/src/mcp/tools/ui-automation/screenshot.ts index ad1b6ee4..bcac4529 100644 --- a/src/mcp/tools/ui-automation/screenshot.ts +++ b/src/mcp/tools/ui-automation/screenshot.ts @@ -24,10 +24,32 @@ import { getSessionAwareToolSchemaShape, } from '../../../utils/typed-tool-factory.ts'; import { toolResponse } from '../../../utils/tool-response.ts'; -import { header, statusLine } from '../../../utils/tool-event-builders.ts'; +import { header, statusLine, detailTree } from '../../../utils/tool-event-builders.ts'; const LOG_PREFIX = '[Screenshot]'; +async function getImageDimensions( + imagePath: string, + executor: CommandExecutor, +): Promise { + try { + const result = await executor( + ['sips', '-g', 'pixelWidth', '-g', 'pixelHeight', imagePath], + `${LOG_PREFIX}: get dimensions`, + false, + ); + if (!result.success || !result.output) return null; + const widthMatch = result.output.match(/pixelWidth:\s*(\d+)/); + const heightMatch = result.output.match(/pixelHeight:\s*(\d+)/); + if (widthMatch && heightMatch) { + return `${widthMatch[1]}x${heightMatch[1]}`; + } + return null; + } catch { + return null; + } +} + /** * Type for simctl device list response */ @@ -271,10 +293,11 @@ export async function screenshotLogic( return toolResponse([ headerEvent, - statusLine( - 'success', - `Screenshot captured: ${screenshotPath} (image/png, optimization failed)`, - ), + statusLine('success', 'Screenshot captured.'), + detailTree([ + { label: 'Screenshot', value: screenshotPath }, + { label: 'Format', value: 'image/png (optimization failed)' }, + ]), ]); } @@ -282,6 +305,7 @@ export async function screenshotLogic( if (returnFormat === 'base64') { const base64Image = await fileSystemExecutor.readFile(optimizedPath, 'base64'); + const base64Dims = await getImageDimensions(optimizedPath, executor); log('info', `${LOG_PREFIX}/screenshot: Successfully encoded image as Base64`); @@ -295,6 +319,12 @@ export async function screenshotLogic( const textResponse = toolResponse([ headerEvent, statusLine('success', 'Screenshot captured.'), + detailTree( + [ + { label: 'Format', value: 'image/jpeg' }, + ...(base64Dims ? [{ label: 'Size', value: base64Dims }] : []), + ] as Array<{ label: string; value: string }>, + ), ]); textResponse.content.push(createImageContent(base64Image, 'image/jpeg')); return textResponse; @@ -306,9 +336,17 @@ export async function screenshotLogic( log('warn', `${LOG_PREFIX}/screenshot: Failed to delete temp file: ${err}`); } + const dims = await getImageDimensions(optimizedPath, executor); return toolResponse([ headerEvent, - statusLine('success', `Screenshot captured: ${optimizedPath} (image/jpeg)`), + statusLine('success', 'Screenshot captured.'), + detailTree( + [ + { label: 'Screenshot', value: optimizedPath }, + { label: 'Format', value: 'image/jpeg' }, + ...(dims ? [{ label: 'Size', value: dims }] : []), + ] as Array<{ label: string; value: string }>, + ), ]); } catch (fileError) { log('error', `${LOG_PREFIX}/screenshot: Failed to process image file: ${fileError}`); diff --git a/src/mcp/tools/utilities/clean.ts b/src/mcp/tools/utilities/clean.ts index 2edd25ec..c450fe01 100644 --- a/src/mcp/tools/utilities/clean.ts +++ b/src/mcp/tools/utilities/clean.ts @@ -128,6 +128,8 @@ export async function cleanLogic( const pipelineParams = { scheme: typedParams.scheme, + workspacePath: params.workspacePath as string | undefined, + projectPath: params.projectPath as string | undefined, configuration: typedParams.configuration, platform: String(cleanPlatform), preflight: preflightText, diff --git a/src/snapshot-tests/__fixtures__/coverage/get-coverage-report--error-invalid-bundle.txt b/src/snapshot-tests/__fixtures__/coverage/get-coverage-report--error-invalid-bundle.txt index 8464b03a..f1b738e6 100644 --- a/src/snapshot-tests/__fixtures__/coverage/get-coverage-report--error-invalid-bundle.txt +++ b/src/snapshot-tests/__fixtures__/coverage/get-coverage-report--error-invalid-bundle.txt @@ -3,4 +3,4 @@ xcresult: /invalid.xcresult -โŒ Failed to get coverage report: Error: Error Domain=XCCovErrorDomain Code=0 "Failed to load result bundle" UserInfo={NSLocalizedDescription=Failed to load result bundle, NSUnderlyingError=0x8240b0d20 {Error Domain=XCResultStorage.ResultBundleFactory.Error Code=0 "Failed to create a new result bundle reader, underlying error: Info.plist at /invalid.xcresult/Info.plist does not exist, the result bundle might be corrupted or the provided path is not a result bundle"}} +โŒ Failed to get coverage report: Error: Error Domain=XCCovErrorDomain Code=0 "Failed to load result bundle" UserInfo={NSLocalizedDescription=Failed to load result bundle, NSUnderlyingError= {Error Domain=XCResultStorage.ResultBundleFactory.Error Code=0 "Failed to create a new result bundle reader, underlying error: Info.plist at /invalid.xcresult/Info.plist does not exist, the result bundle might be corrupted or the provided path is not a result bundle"}} diff --git a/src/snapshot-tests/__fixtures__/coverage/get-file-coverage--error-invalid-bundle.txt b/src/snapshot-tests/__fixtures__/coverage/get-file-coverage--error-invalid-bundle.txt index 1e2c462b..2d5def30 100644 --- a/src/snapshot-tests/__fixtures__/coverage/get-file-coverage--error-invalid-bundle.txt +++ b/src/snapshot-tests/__fixtures__/coverage/get-file-coverage--error-invalid-bundle.txt @@ -4,4 +4,4 @@ xcresult: /invalid.xcresult File: SomeFile.swift -โŒ Failed to get file coverage: Error: Error Domain=XCCovErrorDomain Code=0 "Failed to load result bundle" UserInfo={NSLocalizedDescription=Failed to load result bundle, NSUnderlyingError=0xcabcd98b0 {Error Domain=XCResultStorage.ResultBundleFactory.Error Code=0 "Failed to create a new result bundle reader, underlying error: Info.plist at /invalid.xcresult/Info.plist does not exist, the result bundle might be corrupted or the provided path is not a result bundle"}} +โŒ Failed to get file coverage: Error: Error Domain=XCCovErrorDomain Code=0 "Failed to load result bundle" UserInfo={NSLocalizedDescription=Failed to load result bundle, NSUnderlyingError= {Error Domain=XCResultStorage.ResultBundleFactory.Error Code=0 "Failed to create a new result bundle reader, underlying error: Info.plist at /invalid.xcresult/Info.plist does not exist, the result bundle might be corrupted or the provided path is not a result bundle"}} diff --git a/src/snapshot-tests/__fixtures__/coverage/get-file-coverage--success.txt b/src/snapshot-tests/__fixtures__/coverage/get-file-coverage--success.txt index 8b67a8c1..56320a0f 100644 --- a/src/snapshot-tests/__fixtures__/coverage/get-file-coverage--success.txt +++ b/src/snapshot-tests/__fixtures__/coverage/get-file-coverage--success.txt @@ -5,6 +5,7 @@ File: CalculatorService.swift File: example_projects/iOS_Calculator/CalculatorAppPackage/Sources/CalculatorAppFeature/CalculatorService.swift + โ„น๏ธ Coverage: 83.1% (157/189 lines) ๐Ÿ”ด Not Covered (7 functions, 22 lines) diff --git a/src/snapshot-tests/__fixtures__/debugging/add-breakpoint--success.txt b/src/snapshot-tests/__fixtures__/debugging/add-breakpoint--success.txt index ae35b98b..771bfb62 100644 --- a/src/snapshot-tests/__fixtures__/debugging/add-breakpoint--success.txt +++ b/src/snapshot-tests/__fixtures__/debugging/add-breakpoint--success.txt @@ -3,5 +3,5 @@ โœ… Breakpoint 1 set -Output +Output: Set breakpoint 1 at ContentView.swift:42 diff --git a/src/snapshot-tests/__fixtures__/debugging/attach--success-continue.txt b/src/snapshot-tests/__fixtures__/debugging/attach--success-continue.txt index 2d6f7cb0..6b1304e2 100644 --- a/src/snapshot-tests/__fixtures__/debugging/attach--success-continue.txt +++ b/src/snapshot-tests/__fixtures__/debugging/attach--success-continue.txt @@ -2,7 +2,6 @@ ๐Ÿ› Attach Debugger โœ… Attached DAP debugger to simulator process () - โ”œ Debug session ID: โ”œ Status: This session is now the current debug session. โ”” Execution: Execution is running. App is responsive to UI interaction. diff --git a/src/snapshot-tests/__fixtures__/debugging/attach--success.txt b/src/snapshot-tests/__fixtures__/debugging/attach--success.txt index b77f1bd1..4ddf7a58 100644 --- a/src/snapshot-tests/__fixtures__/debugging/attach--success.txt +++ b/src/snapshot-tests/__fixtures__/debugging/attach--success.txt @@ -2,7 +2,6 @@ ๐Ÿ› Attach Debugger โœ… Attached DAP debugger to simulator process () - โ”œ Debug session ID: โ”œ Status: This session is now the current debug session. โ”” Execution: Execution is paused. Use debug_continue to resume before UI automation. diff --git a/src/snapshot-tests/__fixtures__/debugging/lldb-command--success.txt b/src/snapshot-tests/__fixtures__/debugging/lldb-command--success.txt index f7c24be5..c8c9afca 100644 --- a/src/snapshot-tests/__fixtures__/debugging/lldb-command--success.txt +++ b/src/snapshot-tests/__fixtures__/debugging/lldb-command--success.txt @@ -5,10 +5,8 @@ โœ… Command executed -Output +Output: Current breakpoints: - 1: file = 'ContentView.swift', line = 42, exact_match = 0, locations = 1, resolved = 1, hit count = 0 + 1: file = 'ContentView.swift', line = 42, exact_match = 0, locations = 0 (pending) Names: dap - - 1.1: where = `closure #1 in closure #1 in closure #1 in ContentView.body.getter + 1428 at ContentView.swift:42:31, address = , resolved, hit count = 0 diff --git a/src/snapshot-tests/__fixtures__/debugging/remove-breakpoint--success.txt b/src/snapshot-tests/__fixtures__/debugging/remove-breakpoint--success.txt index 07196bce..4714ab85 100644 --- a/src/snapshot-tests/__fixtures__/debugging/remove-breakpoint--success.txt +++ b/src/snapshot-tests/__fixtures__/debugging/remove-breakpoint--success.txt @@ -3,5 +3,5 @@ โœ… Breakpoint 1 removed -Output +Output: Removed breakpoint 1. diff --git a/src/snapshot-tests/__fixtures__/debugging/stack--success.txt b/src/snapshot-tests/__fixtures__/debugging/stack--success.txt index 096a1f96..013eda6d 100644 --- a/src/snapshot-tests/__fixtures__/debugging/stack--success.txt +++ b/src/snapshot-tests/__fixtures__/debugging/stack--success.txt @@ -3,22 +3,22 @@ โœ… Stack trace retrieved -Frames - Thread (Thread 1 Queue: com.apple.main-thread (serial)) +Frames: + Thread (Thread 1) frame #0: mach_msg2_trap at /usr/lib/system/libsystem_kernel.dylib`mach_msg2_trap: frame #1: mach_msg2_internal at /usr/lib/system/libsystem_kernel.dylib`mach_msg2_internal: frame #2: mach_msg_overwrite at /usr/lib/system/libsystem_kernel.dylib`mach_msg_overwrite: - frame #3: mach_msg at /usr/lib/system/libsystem_kernel.dylib`mach_msg: - frame #4: __CFRunLoopServiceMachPort at /Library/Developer/CoreSimulator/Volumes/iOS_23E244/Library/Developer/CoreSimulator/Profiles/Runtimes/iOS 26.4.simruntime/Contents/Resources/RuntimeRoot/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation`__CFRunLoopServiceMachPort: - frame #5: __CFRunLoopRun at /Library/Developer/CoreSimulator/Volumes/iOS_23E244/Library/Developer/CoreSimulator/Profiles/Runtimes/iOS 26.4.simruntime/Contents/Resources/RuntimeRoot/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation`__CFRunLoopRun: - frame #6: _CFRunLoopRunSpecificWithOptions at /Library/Developer/CoreSimulator/Volumes/iOS_23E244/Library/Developer/CoreSimulator/Profiles/Runtimes/iOS 26.4.simruntime/Contents/Resources/RuntimeRoot/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation`_CFRunLoopRunSpecificWithOptions: - frame #7: GSEventRunModal at /Library/Developer/CoreSimulator/Volumes/iOS_23E244/Library/Developer/CoreSimulator/Profiles/Runtimes/iOS 26.4.simruntime/Contents/Resources/RuntimeRoot/System/Library/PrivateFrameworks/GraphicsServices.framework/GraphicsServices`GSEventRunModal: - frame #8: -[UIApplication _run] at /Library/Developer/CoreSimulator/Volumes/iOS_23E244/Library/Developer/CoreSimulator/Profiles/Runtimes/iOS 26.4.simruntime/Contents/Resources/RuntimeRoot/System/Library/PrivateFrameworks/UIKitCore.framework/UIKitCore`-[UIApplication _run]: - frame #9: UIApplicationMain at /Library/Developer/CoreSimulator/Volumes/iOS_23E244/Library/Developer/CoreSimulator/Profiles/Runtimes/iOS 26.4.simruntime/Contents/Resources/RuntimeRoot/System/Library/PrivateFrameworks/UIKitCore.framework/UIKitCore`UIApplicationMain: - frame #10: closure #1 in KitRendererCommon(_:) at /Library/Developer/CoreSimulator/Volumes/iOS_23E244/Library/Developer/CoreSimulator/Profiles/Runtimes/iOS 26.4.simruntime/Contents/Resources/RuntimeRoot/System/Library/Frameworks/SwiftUI.framework/SwiftUI`closure #1 (Swift.UnsafeMutablePointer>>) -> Swift.Never in SwiftUI.KitRendererCommon(Swift.AnyObject.Type) -> Swift.Never: - frame #11: runApp<ฯ„_0_0>(_:) at /Library/Developer/CoreSimulator/Volumes/iOS_23E244/Library/Developer/CoreSimulator/Profiles/Runtimes/iOS 26.4.simruntime/Contents/Resources/RuntimeRoot/System/Library/Frameworks/SwiftUI.framework/SwiftUI`SwiftUI.runApp<ฯ„_0_0 where ฯ„_0_0: SwiftUI.App>(ฯ„_0_0) -> Swift.Never: - frame #12: static App.main() at /Library/Developer/CoreSimulator/Volumes/iOS_23E244/Library/Developer/CoreSimulator/Profiles/Runtimes/iOS 26.4.simruntime/Contents/Resources/RuntimeRoot/System/Library/Frameworks/SwiftUI.framework/SwiftUI`static SwiftUI.App.main() -> (): - frame #13: static at /Library/Developer/CoreSimulator/Devices//data/Containers/Bundle/Application//`static -> (): - frame #14: main at /Library/Developer/CoreSimulator/Devices//data/Containers/Bundle/Application//`main: + frame #3: mach_msg at /usr/lib/system/libsystem_kernel.dylib`mach_msg:6 + frame #4: at :1 + frame #5: at :1 + frame #6: at :1 + frame #7: at :1 + frame #8: at :1 + frame #9: at :1 + frame #10: at :1 + frame #11: at :1 + frame #12: at : + frame #13: static CalculatorApp.$main() at /Library/Developer/CoreSimulator/Devices//data/Containers/Bundle/Application//CalculatorApp.app/CalculatorApp.debug.dylib`static CalculatorApp.CalculatorApp.$main() -> (): + frame #14: main at /Library/Developer/CoreSimulator/Devices//data/Containers/Bundle/Application//CalculatorApp.app/CalculatorApp.debug.dylib`main: frame #15: start_sim at /Library/Developer/CoreSimulator/Volumes/iOS_23E244/Library/Developer/CoreSimulator/Profiles/Runtimes/iOS 26.4.simruntime/Contents/Resources/RuntimeRoot/usr/lib/dyld_sim`start_sim: frame #16: start at /usr/lib/dyld`start: diff --git a/src/snapshot-tests/__fixtures__/debugging/variables--success.txt b/src/snapshot-tests/__fixtures__/debugging/variables--success.txt index 2e842bba..bf96d0fd 100644 --- a/src/snapshot-tests/__fixtures__/debugging/variables--success.txt +++ b/src/snapshot-tests/__fixtures__/debugging/variables--success.txt @@ -3,7 +3,7 @@ โœ… Variables retrieved -Values +Values: Locals: (no variables) diff --git a/src/snapshot-tests/__fixtures__/device/build--error-wrong-scheme.txt b/src/snapshot-tests/__fixtures__/device/build--error-wrong-scheme.txt index 95b55718..89508ab0 100644 --- a/src/snapshot-tests/__fixtures__/device/build--error-wrong-scheme.txt +++ b/src/snapshot-tests/__fixtures__/device/build--error-wrong-scheme.txt @@ -2,6 +2,7 @@ ๐Ÿ”จ Build Scheme: NONEXISTENT + Workspace: example_projects/iOS_Calculator/CalculatorApp.xcworkspace Configuration: Debug Platform: iOS @@ -10,3 +11,4 @@ Errors (1): โœ— The workspace named "CalculatorApp" does not contain a scheme named "NONEXISTENT". The "-list" option can be used to find the names of the schemes in the workspace. โŒ Build failed. (โฑ๏ธ ) + โ”” Build Logs: /logs/build_device_2026-03-28T08-19-57-988Z.log diff --git a/src/snapshot-tests/__fixtures__/device/build--success.txt b/src/snapshot-tests/__fixtures__/device/build--success.txt index 7f5b568e..25b7188c 100644 --- a/src/snapshot-tests/__fixtures__/device/build--success.txt +++ b/src/snapshot-tests/__fixtures__/device/build--success.txt @@ -2,10 +2,12 @@ ๐Ÿ”จ Build Scheme: CalculatorApp + Workspace: example_projects/iOS_Calculator/CalculatorApp.xcworkspace Configuration: Debug Platform: iOS โœ… Build succeeded. (โฑ๏ธ ) + โ”” Build Logs: /logs/build_device_2026-03-28T08-19-54-221Z.log Next steps: 1. Get built device app path: xcodebuildmcp device get-app-path --scheme "CalculatorApp" diff --git a/src/snapshot-tests/__fixtures__/device/build-and-run--success.txt b/src/snapshot-tests/__fixtures__/device/build-and-run--success.txt index e12b576c..38df91db 100644 --- a/src/snapshot-tests/__fixtures__/device/build-and-run--success.txt +++ b/src/snapshot-tests/__fixtures__/device/build-and-run--success.txt @@ -14,7 +14,6 @@ โœ… Build succeeded. (โฑ๏ธ ) โœ… Build & Run complete - โ”œ App Path: /Library/Developer/Xcode/DerivedData/CalculatorApp-/Build/Products/Debug-iphoneos/CalculatorApp.app โ”œ Bundle ID: io.sentry.calculatorapp โ”” Process ID: diff --git a/src/snapshot-tests/__fixtures__/device/get-app-path--error-wrong-scheme.txt b/src/snapshot-tests/__fixtures__/device/get-app-path--error-wrong-scheme.txt index 6d5fb405..6817789b 100644 --- a/src/snapshot-tests/__fixtures__/device/get-app-path--error-wrong-scheme.txt +++ b/src/snapshot-tests/__fixtures__/device/get-app-path--error-wrong-scheme.txt @@ -6,5 +6,8 @@ Configuration: Debug Platform: iOS -โŒ xcodebuild[43591:16955984] Writing error result bundle to /var/folders/_t/2njffz894t57qpp76v1sw__h0000gn/T/ResultBundle_2026-27-03_09-46-0041.xcresult -xcodebuild: error: The workspace named "CalculatorApp" does not contain a scheme named "NONEXISTENT". The "-list" option can be used to find the names of the schemes in the workspace. +Errors (1): + + โœ— The workspace named "CalculatorApp" does not contain a scheme named "NONEXISTENT". The "-list" option can be used to find the names of the schemes in the workspace. + +โŒ Query failed. diff --git a/src/snapshot-tests/__fixtures__/device/get-app-path--success.txt b/src/snapshot-tests/__fixtures__/device/get-app-path--success.txt index b656eda6..17762c6d 100644 --- a/src/snapshot-tests/__fixtures__/device/get-app-path--success.txt +++ b/src/snapshot-tests/__fixtures__/device/get-app-path--success.txt @@ -6,6 +6,10 @@ Configuration: Debug Platform: iOS -โœ… App path resolved. - +โœ… Success โ”” App Path: /Library/Developer/Xcode/DerivedData/CalculatorApp-/Build/Products/Debug-iphoneos/CalculatorApp.app + +Next steps: +1. Get bundle ID: xcodebuildmcp device get-app-bundle-id --app-path "/Library/Developer/Xcode/DerivedData/CalculatorApp-/Build/Products/Debug-iphoneos/CalculatorApp.app" +2. Install app on device: xcodebuildmcp device install --device-id "DEVICE_UDID" --app-path "/Library/Developer/Xcode/DerivedData/CalculatorApp-/Build/Products/Debug-iphoneos/CalculatorApp.app" +3. Launch app on device: xcodebuildmcp device launch --device-id "DEVICE_UDID" --bundle-id "BUNDLE_ID" diff --git a/src/snapshot-tests/__fixtures__/device/launch--success.txt b/src/snapshot-tests/__fixtures__/device/launch--success.txt index 32a9c081..74164ce9 100644 --- a/src/snapshot-tests/__fixtures__/device/launch--success.txt +++ b/src/snapshot-tests/__fixtures__/device/launch--success.txt @@ -5,5 +5,4 @@ Bundle ID: io.sentry.calculatorapp โœ… App launched successfully. - โ”” Process ID: diff --git a/src/snapshot-tests/__fixtures__/device/list--success.txt b/src/snapshot-tests/__fixtures__/device/list--success.txt index 3a19e9b4..998b5370 100644 --- a/src/snapshot-tests/__fixtures__/device/list--success.txt +++ b/src/snapshot-tests/__fixtures__/device/list--success.txt @@ -1,45 +1,27 @@ ๐Ÿ“ฑ List Devices -๐ŸŸข Cameronโ€™s Appleย Watch - - โ”œ UDID: - โ”œ Model: Watch4,2 - โ”œ Product Type: Watch4,2 - โ”œ Platform: Unknown 10.6.1 - โ”œ CPU Architecture: arm64_32 - โ”œ Connection: - โ”” Developer Mode: disabled - -๐ŸŸข Cameronโ€™s Appleย Watch - - โ”œ UDID: - โ”œ Model: Watch7,20 - โ”œ Product Type: Watch7,20 - โ”œ Platform: Unknown 26.3 - โ”œ CPU Architecture: arm64e - โ”œ Connection: localNetwork - โ”” Developer Mode: disabled - -๐ŸŸข Cameronโ€™s iPhone 16 Pro Max - - โ”œ UDID: - โ”œ Model: iPhone17,2 - โ”œ Product Type: iPhone17,2 - โ”œ Platform: Unknown 26.3.1 (a) - โ”œ CPU Architecture: arm64e - โ”œ Connection: localNetwork - โ”” Developer Mode: enabled - -๐ŸŸข iPhone - - โ”œ UDID: - โ”œ Model: iPhone99,11 - โ”œ Product Type: iPhone99,11 - โ”œ Platform: Unknown 26.1 - โ”œ CPU Architecture: arm64e - โ”” Connection: -โœ… Devices discovered. +iOS Devices: + + ๐Ÿ“ฑ [โœ“] Cameronโ€™s iPhone 16 Pro Max + OS: 26.3.1 (a) + UDID: + + ๐Ÿ“ฑ [โœ—] iPhone + OS: 26.1 + UDID: + +watchOS Devices: + + โŒš๏ธ [โœ—] Cameronโ€™s Appleย Watch + OS: 10.6.1 + UDID: + + โŒš๏ธ [โœ“] Cameronโ€™s Appleย Watch + OS: 26.3 + UDID: + +โœ… 4 physical devices discovered (2 iOS, 2 watchOS). Hints Use the device ID/UDID from above when required by other tools. diff --git a/src/snapshot-tests/__fixtures__/device/stop--success.txt b/src/snapshot-tests/__fixtures__/device/stop--success.txt index 64805405..2fa57755 100644 --- a/src/snapshot-tests/__fixtures__/device/stop--success.txt +++ b/src/snapshot-tests/__fixtures__/device/stop--success.txt @@ -4,4 +4,4 @@ Device: () PID: -โœ… App stopped successfully. +โœ… App stopped successfully diff --git a/src/snapshot-tests/__fixtures__/device/test--failure.txt b/src/snapshot-tests/__fixtures__/device/test--failure.txt index 2b905783..1bc9cd60 100644 --- a/src/snapshot-tests/__fixtures__/device/test--failure.txt +++ b/src/snapshot-tests/__fixtures__/device/test--failure.txt @@ -6,5 +6,14 @@ Platform: iOS Device: () - โœ— CalculatorAppTests.CalculatorAppTests/testCalculatorServiceFailure: XCTAssertEqual failed: ("0") is not equal to ("999") - This test should fail - display should be 0, not 999 - example_projects/iOS_Calculator/CalculatorAppTests/CalculatorAppTests.swift:52 +CalculatorAppTests + โœ— testCalculatorServiceFailure (โฑ๏ธ ): + - XCTAssertEqual failed: ("0") is not equal to ("999") - This test should fail - display should be 0, not 999 + example_projects/iOS_Calculator/CalculatorAppTests/CalculatorAppTests.swift:52 + +This test should fail to verify error reporting + โœ— test (โฑ๏ธ ): + - Test failed + +โŒ 2 tests failed, 53 passed, 0 skipped (โฑ๏ธ ) + โ”” Build Logs: /logs/test_sim_2026-03-28T08-18-25-423Z.log \ No newline at end of file diff --git a/src/snapshot-tests/__fixtures__/device/test--success.txt b/src/snapshot-tests/__fixtures__/device/test--success.txt index f14ef880..3469553d 100644 --- a/src/snapshot-tests/__fixtures__/device/test--success.txt +++ b/src/snapshot-tests/__fixtures__/device/test--success.txt @@ -6,4 +6,5 @@ Platform: iOS Device: () -โœ… Test succeeded. (, โฑ๏ธ ) +โœ… 53 tests passed, 0 skipped (โฑ๏ธ ) + โ”” Build Logs: /logs/test_device_2026-03-28T08-18-25-423Z.log \ No newline at end of file diff --git a/src/snapshot-tests/__fixtures__/logging/start-device-log--success.txt b/src/snapshot-tests/__fixtures__/logging/start-device-log--success.txt index 26b22ee1..8f2a0dfe 100644 --- a/src/snapshot-tests/__fixtures__/logging/start-device-log--success.txt +++ b/src/snapshot-tests/__fixtures__/logging/start-device-log--success.txt @@ -5,9 +5,7 @@ Bundle ID: io.sentry.calculatorapp โœ… Log capture started. - - โ”œ Session ID: - โ”” Note: Do not call launch_app_device during this session + โ”” Session ID: Next steps: 1. Stop capture and retrieve logs: stop_device_log_cap({ logSessionId: "" }) diff --git a/src/snapshot-tests/__fixtures__/logging/start-sim-log--success.txt b/src/snapshot-tests/__fixtures__/logging/start-sim-log--success.txt index 326c8024..e0c8c94c 100644 --- a/src/snapshot-tests/__fixtures__/logging/start-sim-log--success.txt +++ b/src/snapshot-tests/__fixtures__/logging/start-sim-log--success.txt @@ -3,11 +3,10 @@ Simulator: Bundle ID: io.sentry.calculatorapp + Filter: Structured logs only โœ… Log capture started. - - โ”œ Session ID: - โ”” Filter: Only structured logs from the app subsystem are being captured. + โ”” Session ID: Next steps: 1. Stop capture and retrieve logs: stop_sim_log_cap({ logSessionId: "" }) diff --git a/src/snapshot-tests/__fixtures__/logging/stop-device-log--success.txt b/src/snapshot-tests/__fixtures__/logging/stop-device-log--success.txt index d6b54e54..df356b7b 100644 --- a/src/snapshot-tests/__fixtures__/logging/stop-device-log--success.txt +++ b/src/snapshot-tests/__fixtures__/logging/stop-device-log--success.txt @@ -4,8 +4,9 @@ Session ID: โœ… Log capture stopped. + โ”” Logs: /logs/io.sentry.calculatorapp_2026-03-28T08-19-54-221Z.log -Captured Logs +Captured Logs: --- Device log capture for bundle ID: io.sentry.calculatorapp on device: --- 09:50:59 Acquired usage assertion. diff --git a/src/snapshot-tests/__fixtures__/logging/stop-sim-log--success.txt b/src/snapshot-tests/__fixtures__/logging/stop-sim-log--success.txt index ed86327e..67ef5a71 100644 --- a/src/snapshot-tests/__fixtures__/logging/stop-sim-log--success.txt +++ b/src/snapshot-tests/__fixtures__/logging/stop-sim-log--success.txt @@ -4,8 +4,9 @@ Session ID: โœ… Log capture stopped. + โ”” Logs: /logs/io.sentry.calculatorapp_2026-03-28T08-19-54-221Z.log -Captured Logs +Captured Logs: --- Log capture for bundle ID: io.sentry.calculatorapp --- getpwuid_r did not find a match for uid 501 diff --git a/src/snapshot-tests/__fixtures__/macos/build--error-wrong-scheme.txt b/src/snapshot-tests/__fixtures__/macos/build--error-wrong-scheme.txt index 962a375c..f66b868d 100644 --- a/src/snapshot-tests/__fixtures__/macos/build--error-wrong-scheme.txt +++ b/src/snapshot-tests/__fixtures__/macos/build--error-wrong-scheme.txt @@ -2,6 +2,7 @@ ๐Ÿ”จ Build Scheme: NONEXISTENT + Project: example_projects/macOS/MCPTest.xcodeproj Configuration: Debug Platform: macOS @@ -10,3 +11,4 @@ Errors (1): โœ— The project named "MCPTest" does not contain a scheme named "NONEXISTENT". The "-list" option can be used to find the names of the schemes in the project. โŒ Build failed. (โฑ๏ธ ) + โ”” Build Logs: /logs/build_macos_2026-03-28T08-19-05-102Z.log diff --git a/src/snapshot-tests/__fixtures__/macos/build--success.txt b/src/snapshot-tests/__fixtures__/macos/build--success.txt index 03c6406b..928e0d39 100644 --- a/src/snapshot-tests/__fixtures__/macos/build--success.txt +++ b/src/snapshot-tests/__fixtures__/macos/build--success.txt @@ -2,10 +2,13 @@ ๐Ÿ”จ Build Scheme: MCPTest + Project: example_projects/macOS/MCPTest.xcodeproj Configuration: Debug Platform: macOS โœ… Build succeeded. (โฑ๏ธ ) + โ”œ Bundle ID: io.sentry.calculatorapp + โ”” Build Logs: /logs/build_macos_2026-03-28T08-19-03-365Z.log Next steps: 1. Get built macOS app path: xcodebuildmcp macos get-app-path --scheme "MCPTest" diff --git a/src/snapshot-tests/__fixtures__/macos/build-and-run--error-wrong-scheme.txt b/src/snapshot-tests/__fixtures__/macos/build-and-run--error-wrong-scheme.txt index 8187d6f2..97be484b 100644 --- a/src/snapshot-tests/__fixtures__/macos/build-and-run--error-wrong-scheme.txt +++ b/src/snapshot-tests/__fixtures__/macos/build-and-run--error-wrong-scheme.txt @@ -2,6 +2,7 @@ ๐Ÿš€ Build & Run Scheme: NONEXISTENT + Project: example_projects/macOS/MCPTest.xcodeproj Configuration: Debug Platform: macOS @@ -10,3 +11,4 @@ Errors (1): โœ— The project named "MCPTest" does not contain a scheme named "NONEXISTENT". The "-list" option can be used to find the names of the schemes in the project. โŒ Build failed. (โฑ๏ธ ) + โ”” Build Logs: /logs/build_run_macos_2026-03-28T08-19-08-750Z.log diff --git a/src/snapshot-tests/__fixtures__/macos/build-and-run--success.txt b/src/snapshot-tests/__fixtures__/macos/build-and-run--success.txt index fa2bb9fe..227a1db6 100644 --- a/src/snapshot-tests/__fixtures__/macos/build-and-run--success.txt +++ b/src/snapshot-tests/__fixtures__/macos/build-and-run--success.txt @@ -2,6 +2,7 @@ ๐Ÿš€ Build & Run Scheme: MCPTest + Project: example_projects/macOS/MCPTest.xcodeproj Configuration: Debug Platform: macOS @@ -12,8 +13,10 @@ โœ… Build succeeded. (โฑ๏ธ ) โœ… Build & Run complete - - โ”” App Path: /Library/Developer/Xcode/DerivedData/MCPTest-/Build/Products/Debug/MCPTest.app + โ”œ App Path: /Library/Developer/Xcode/DerivedData/MCPTest-/Build/Products/Debug/MCPTest.app + โ”œ Bundle ID: io.sentry.calculatorapp + โ”œ Process ID: + โ”” Build Logs: /logs/build_run_macos_2026-03-28T08-19-06-319Z.log Next steps: 1. Interact with the launched app in the foreground diff --git a/src/snapshot-tests/__fixtures__/macos/get-app-path--error-wrong-scheme.txt b/src/snapshot-tests/__fixtures__/macos/get-app-path--error-wrong-scheme.txt index b10bef09..0661e112 100644 --- a/src/snapshot-tests/__fixtures__/macos/get-app-path--error-wrong-scheme.txt +++ b/src/snapshot-tests/__fixtures__/macos/get-app-path--error-wrong-scheme.txt @@ -6,5 +6,8 @@ Configuration: Debug Platform: macOS -โŒ -xcodebuild: error: The project named "MCPTest" does not contain a scheme named "NONEXISTENT". The "-list" option can be used to find the names of the schemes in the project. +Errors (1): + + โœ— The project named "MCPTest" does not contain a scheme named "NONEXISTENT". The "-list" option can be used to find the names of the schemes in the project. + +โŒ Query failed. diff --git a/src/snapshot-tests/__fixtures__/macos/get-app-path--success.txt b/src/snapshot-tests/__fixtures__/macos/get-app-path--success.txt index 97b7494c..86afdde3 100644 --- a/src/snapshot-tests/__fixtures__/macos/get-app-path--success.txt +++ b/src/snapshot-tests/__fixtures__/macos/get-app-path--success.txt @@ -6,6 +6,9 @@ Configuration: Debug Platform: macOS -โœ… App path resolved. - +โœ… Success โ”” App Path: /Library/Developer/Xcode/DerivedData/MCPTest-/Build/Products/Debug/MCPTest.app + +Next steps: +1. Get bundle ID: xcodebuildmcp macos get-macos-bundle-id --app-path "/Library/Developer/Xcode/DerivedData/MCPTest-/Build/Products/Debug/MCPTest.app" +2. Launch app: xcodebuildmcp macos launch --app-path "/Library/Developer/Xcode/DerivedData/MCPTest-/Build/Products/Debug/MCPTest.app" diff --git a/src/snapshot-tests/__fixtures__/macos/get-macos-bundle-id--success.txt b/src/snapshot-tests/__fixtures__/macos/get-macos-bundle-id--success.txt index 0aace01b..db3b5bee 100644 --- a/src/snapshot-tests/__fixtures__/macos/get-macos-bundle-id--success.txt +++ b/src/snapshot-tests/__fixtures__/macos/get-macos-bundle-id--success.txt @@ -3,4 +3,5 @@ App: /BundleTest.app -โœ… Bundle ID: com.test.snapshot-macos +โœ… Success + โ”” Bundle ID: com.test.snapshot-macos diff --git a/src/snapshot-tests/__fixtures__/macos/launch--success.txt b/src/snapshot-tests/__fixtures__/macos/launch--success.txt index c01630eb..c24af7f7 100644 --- a/src/snapshot-tests/__fixtures__/macos/launch--success.txt +++ b/src/snapshot-tests/__fixtures__/macos/launch--success.txt @@ -3,4 +3,6 @@ App: /Library/Developer/Xcode/DerivedData/MCPTest-/Build/Products/Debug/MCPTest.app -โœ… App launched successfully. +โœ… App launched successfully + โ”œ Bundle ID: io.sentry.calculatorapp + โ”” Process ID: diff --git a/src/snapshot-tests/__fixtures__/macos/stop--success.txt b/src/snapshot-tests/__fixtures__/macos/stop--success.txt index 077d6a1d..9d9f34c0 100644 --- a/src/snapshot-tests/__fixtures__/macos/stop--success.txt +++ b/src/snapshot-tests/__fixtures__/macos/stop--success.txt @@ -3,4 +3,4 @@ App: MCPTest -โœ… App stopped successfully. +โœ… App stoped successfully diff --git a/src/snapshot-tests/__fixtures__/macos/test--error-wrong-scheme.txt b/src/snapshot-tests/__fixtures__/macos/test--error-wrong-scheme.txt index ebf72cd4..b16df221 100644 --- a/src/snapshot-tests/__fixtures__/macos/test--error-wrong-scheme.txt +++ b/src/snapshot-tests/__fixtures__/macos/test--error-wrong-scheme.txt @@ -10,3 +10,4 @@ Errors (1): โœ— The project named "MCPTest" does not contain a scheme named "NONEXISTENT". The "-list" option can be used to find the names of the schemes in the project. โŒ Test failed. (, โฑ๏ธ ) + โ”” Build Logs: /logs/test_macos_2026-03-28T08-19-28-397Z.log diff --git a/src/snapshot-tests/__fixtures__/macos/test--failure.txt b/src/snapshot-tests/__fixtures__/macos/test--failure.txt index 82683920..25409e90 100644 --- a/src/snapshot-tests/__fixtures__/macos/test--failure.txt +++ b/src/snapshot-tests/__fixtures__/macos/test--failure.txt @@ -5,7 +5,15 @@ Configuration: Debug Platform: macOS - โœ— deliberateFailure(): Expectation failed: 1 == 2: This test is designed to fail for snapshot testing - MCPTestTests.swift:11 +MCPTestsXCTests + โœ— testCalculatorServiceFailure (โฑ๏ธ ): + - XCTAssertTrue failed - This test is designed to fail for snapshot testing + example_projects/macOS/MCPTestTests/MCPTestTests.swift:11 -โŒ Test failed. (, โฑ๏ธ ) +MCPTestTests + โœ— appNameIsCorrect() (โฑ๏ธ ): + - Expectation failed: 1 == 2: This test is designed to fail for snapshot testing + example_projects/macOS/MCPTestTests/MCPTestsXCTests.swift:11 + +โŒ 1 test failed, 1 passed, 0 skipped (โฑ๏ธ ) + โ”” Build Logs: /logs/test_macos_2026-03-28T08-18-25-423Z.log diff --git a/src/snapshot-tests/__fixtures__/macos/test--success.txt b/src/snapshot-tests/__fixtures__/macos/test--success.txt index d5e3c7fc..18a1fb41 100644 --- a/src/snapshot-tests/__fixtures__/macos/test--success.txt +++ b/src/snapshot-tests/__fixtures__/macos/test--success.txt @@ -5,4 +5,5 @@ Configuration: Debug Platform: macOS -โœ… Test succeeded. (, โฑ๏ธ ) +โœ… 2 tests passed, 0 skipped (โฑ๏ธ ) + โ”” Build Logs: /logs/test_macos_2026-03-28T08-19-10-003Z.log diff --git a/src/snapshot-tests/__fixtures__/project-discovery/discover-projs--error-invalid-root.txt b/src/snapshot-tests/__fixtures__/project-discovery/discover-projs--error-invalid-root.txt index 07a40363..b8c28324 100644 --- a/src/snapshot-tests/__fixtures__/project-discovery/discover-projs--error-invalid-root.txt +++ b/src/snapshot-tests/__fixtures__/project-discovery/discover-projs--error-invalid-root.txt @@ -1,4 +1,8 @@ ๐Ÿ” Discover Projects + Workspace root: /nonexistent/path/Fake.app + Scan path: /nonexistent/path + Max depth: 3 + โŒ Failed to access scan path: /nonexistent/path. Error: ENOENT: no such file or directory, stat '/nonexistent/path' diff --git a/src/snapshot-tests/__fixtures__/project-discovery/discover-projs--success.txt b/src/snapshot-tests/__fixtures__/project-discovery/discover-projs--success.txt index 0e55e9c8..b48021bf 100644 --- a/src/snapshot-tests/__fixtures__/project-discovery/discover-projs--success.txt +++ b/src/snapshot-tests/__fixtures__/project-discovery/discover-projs--success.txt @@ -1,10 +1,14 @@ ๐Ÿ” Discover Projects -โœ… Found 1 project(s) and 1 workspace(s). + Workspace root: + Scan path: + Max depth: 3 -Projects +โœ… Found 1 project and 1 workspace + +Projects: example_projects/iOS_Calculator/CalculatorApp.xcodeproj -Workspaces +Workspaces: example_projects/iOS_Calculator/CalculatorApp.xcworkspace diff --git a/src/snapshot-tests/__fixtures__/project-discovery/get-app-bundle-id--success.txt b/src/snapshot-tests/__fixtures__/project-discovery/get-app-bundle-id--success.txt index 9698f3d9..2fc3ecf3 100644 --- a/src/snapshot-tests/__fixtures__/project-discovery/get-app-bundle-id--success.txt +++ b/src/snapshot-tests/__fixtures__/project-discovery/get-app-bundle-id--success.txt @@ -3,4 +3,5 @@ App: /BundleTest.app -โœ… Bundle ID: com.test.snapshot +โœ… Bundle ID + โ”” com.test.snapshot \ No newline at end of file diff --git a/src/snapshot-tests/__fixtures__/project-discovery/get-macos-bundle-id--success.txt b/src/snapshot-tests/__fixtures__/project-discovery/get-macos-bundle-id--success.txt index 5acbdd97..868dbb3e 100644 --- a/src/snapshot-tests/__fixtures__/project-discovery/get-macos-bundle-id--success.txt +++ b/src/snapshot-tests/__fixtures__/project-discovery/get-macos-bundle-id--success.txt @@ -3,4 +3,5 @@ App: /BundleTest.app -โœ… Bundle ID: com.test.snapshot +โœ… Bundle ID + โ”” com.test.snapshot diff --git a/src/snapshot-tests/__fixtures__/project-discovery/list-schemes--error-invalid-workspace.txt b/src/snapshot-tests/__fixtures__/project-discovery/list-schemes--error-invalid-workspace.txt index 8740e247..d9f29e7f 100644 --- a/src/snapshot-tests/__fixtures__/project-discovery/list-schemes--error-invalid-workspace.txt +++ b/src/snapshot-tests/__fixtures__/project-discovery/list-schemes--error-invalid-workspace.txt @@ -3,5 +3,5 @@ Workspace: /nonexistent/path/Fake.xcworkspace -โŒ xcodebuild[44362:16961447] Writing error result bundle to /var/folders/_t/2njffz894t57qpp76v1sw__h0000gn/T/ResultBundle_2026-27-03_09-49-0011.xcresult +โŒ xcodebuild: error: '/nonexistent/path/Fake.xcworkspace' does not exist. diff --git a/src/snapshot-tests/__fixtures__/project-discovery/list-schemes--success.txt b/src/snapshot-tests/__fixtures__/project-discovery/list-schemes--success.txt index f09f7e52..3b86a906 100644 --- a/src/snapshot-tests/__fixtures__/project-discovery/list-schemes--success.txt +++ b/src/snapshot-tests/__fixtures__/project-discovery/list-schemes--success.txt @@ -3,8 +3,8 @@ Workspace: example_projects/iOS_Calculator/CalculatorApp.xcworkspace -โœ… Found 2 scheme(s). +โœ… Found 2 schemes -Schemes +Schemes: CalculatorApp CalculatorAppFeature diff --git a/src/snapshot-tests/__fixtures__/project-discovery/show-build-settings--error-wrong-scheme.txt b/src/snapshot-tests/__fixtures__/project-discovery/show-build-settings--error-wrong-scheme.txt index bedf72a7..76f8c183 100644 --- a/src/snapshot-tests/__fixtures__/project-discovery/show-build-settings--error-wrong-scheme.txt +++ b/src/snapshot-tests/__fixtures__/project-discovery/show-build-settings--error-wrong-scheme.txt @@ -4,5 +4,5 @@ Scheme: NONEXISTENT Workspace: example_projects/iOS_Calculator/CalculatorApp.xcworkspace -โŒ xcodebuild[44409:16961675] Writing error result bundle to /var/folders/_t/2njffz894t57qpp76v1sw__h0000gn/T/ResultBundle_2026-27-03_09-49-0013.xcresult +โŒ xcodebuild: error: The workspace named "CalculatorApp" does not contain a scheme named "NONEXISTENT". The "-list" option can be used to find the names of the schemes in the workspace. diff --git a/src/snapshot-tests/__fixtures__/project-discovery/show-build-settings--success.txt b/src/snapshot-tests/__fixtures__/project-discovery/show-build-settings--success.txt index f44b3bd1..dfaf435d 100644 --- a/src/snapshot-tests/__fixtures__/project-discovery/show-build-settings--success.txt +++ b/src/snapshot-tests/__fixtures__/project-discovery/show-build-settings--success.txt @@ -4,7 +4,7 @@ Scheme: CalculatorApp Workspace: example_projects/iOS_Calculator/CalculatorApp.xcworkspace -โœ… Build settings retrieved. +โœ… Build settings retrieved Settings Build settings for action build and target CalculatorApp: @@ -382,7 +382,7 @@ Settings OSAC = /usr/bin/osacompile PACKAGE_TYPE = com.apple.package-type.wrapper.application PASCAL_STRINGS = YES - PATH = /Applications/Xcode-26.4.0.app/Contents/SharedFrameworks/SwiftBuild.framework/Versions/A/PlugIns/SWBBuildService.bundle/Contents/PlugIns/SWBUniversalPlatformPlugin.bundle/Contents/Frameworks/SWBUniversalPlatform.framework/Resources:/Applications/Xcode-26.4.0.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin:/Applications/Xcode-26.4.0.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/local/bin:/Applications/Xcode-26.4.0.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/libexec:/Applications/Xcode-26.4.0.app/Contents/Developer/Platforms/iPhoneOS.platform/usr/bin:/Applications/Xcode-26.4.0.app/Contents/Developer/Platforms/iPhoneOS.platform/usr/local/bin:/Applications/Xcode-26.4.0.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/usr/bin:/Applications/Xcode-26.4.0.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/usr/local/bin:/Applications/Xcode-26.4.0.app/Contents/Developer/usr/bin:/Applications/Xcode-26.4.0.app/Contents/Developer/usr/local/bin:/node_modules/.bin:/.codex/worktrees/43f4/node_modules/.bin:/.codex/worktrees/node_modules/.bin:/.codex/node_modules/.bin:/node_modules/.bin:/Users/node_modules/.bin:/node_modules/.bin:/opt/homebrew/lib/node_modules/npm/node_modules/@npmcli/run-script/lib/node-gyp-bin:/bin:/.local/share/sentry-devenv/bin:/.antigravity/antigravity/bin:/.local/bin:/.opencode/bin:/.codeium/windsurf/bin:/opt/homebrew/bin:/perl5/bin:/.nvm/versions/node/v22.21.1/bin:/Developer/xcodemake:/.npm-global/bin:/.rbenv/shims:/.dotfiles/bin:/usr/local/bin:/usr/local/sbin:/.oh-my-zsh/bin:/opt/homebrew/sbin:/System/Cryptexes/App/usr/bin:/usr/bin:/bin:/usr/sbin:/sbin:/var/run/com.apple.security.cryptexd/codex.system/bootstrap/usr/local/bin:/var/run/com.apple.security.cryptexd/codex.system/bootstrap/usr/bin:/var/run/com.apple.security.cryptexd/codex.system/bootstrap/usr/appleinternal/bin:/opt/pmk/env/global/bin:/Library/Apple/usr/bin:/usr/local/MacGPG2/bin:/.cargo/bin:/Applications/iTerm.app/Contents/Resources/utilities:/.lmstudio/bin + PATH = /Applications/Xcode-26.4.0.app/Contents/SharedFrameworks/SwiftBuild.framework/Versions/A/PlugIns/SWBBuildService.bundle/Contents/PlugIns/SWBUniversalPlatformPlugin.bundle/Contents/Frameworks/SWBUniversalPlatform.framework/Resources:/Applications/Xcode-26.4.0.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin:/Applications/Xcode-26.4.0.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/local/bin:/Applications/Xcode-26.4.0.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/libexec:/Applications/Xcode-26.4.0.app/Contents/Developer/Platforms/iPhoneOS.platform/usr/bin:/Applications/Xcode-26.4.0.app/Contents/Developer/Platforms/iPhoneOS.platform/usr/local/bin:/Applications/Xcode-26.4.0.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/usr/bin:/Applications/Xcode-26.4.0.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/usr/local/bin:/Applications/Xcode-26.4.0.app/Contents/Developer/usr/bin:/Applications/Xcode-26.4.0.app/Contents/Developer/usr/local/bin:/node_modules/.bin:/.codex/worktrees/43f4/node_modules/.bin:/.codex/worktrees/node_modules/.bin:/.codex/node_modules/.bin:/node_modules/.bin:/Users/node_modules/.bin:/node_modules/.bin:/opt/homebrew/lib/node_modules/npm/node_modules/@npmcli/run-script/lib/node-gyp-bin:/.codex/tmp/arg0/codex-arg0JFBF9V:/opt/homebrew/lib/node_modules/@openai/codex/node_modules/@openai/codex-darwin-arm64/vendor/aarch64-apple-darwin/path:/bin:/.local/share/sentry-devenv/bin:/.antigravity/antigravity/bin:/.local/bin:/.opencode/bin:/.codeium/windsurf/bin:/opt/homebrew/bin:/perl5/bin:/.nvm/versions/node/v22.21.1/bin:/Developer/xcodemake:/.npm-global/bin:/.rbenv/shims:/.dotfiles/bin:/usr/local/bin:/usr/local/sbin:/.oh-my-zsh/bin:/opt/homebrew/sbin:/System/Cryptexes/App/usr/bin:/usr/bin:/bin:/usr/sbin:/sbin:/var/run/com.apple.security.cryptexd/codex.system/bootstrap/usr/local/bin:/var/run/com.apple.security.cryptexd/codex.system/bootstrap/usr/bin:/var/run/com.apple.security.cryptexd/codex.system/bootstrap/usr/appleinternal/bin:/opt/pmk/env/global/bin:/Library/Apple/usr/bin:/usr/local/MacGPG2/bin:/.cargo/bin:/.lmstudio/bin:/go/bin PATH_PREFIXES_EXCLUDED_FROM_HEADER_DEPENDENCIES = /usr/include /usr/local/include /System/Library/Frameworks /System/Library/PrivateFrameworks /Applications/Xcode-26.4.0.app/Contents/Developer/Headers /Applications/Xcode-26.4.0.app/Contents/Developer/SDKs /Applications/Xcode-26.4.0.app/Contents/Developer/Platforms PBDEVELOPMENTPLIST_PATH = CalculatorApp.app/pbdevelopment.plist PER_ARCH_MODULE_FILE_DIR = /Library/Developer/Xcode/DerivedData/CalculatorApp-/Build/Intermediates.noindex/CalculatorApp.build/Debug-iphoneos/CalculatorApp.build/Objects-normal/undefined_arch diff --git a/src/snapshot-tests/__fixtures__/project-scaffolding/scaffold-ios--success.txt b/src/snapshot-tests/__fixtures__/project-scaffolding/scaffold-ios--success.txt index ac1ab1d6..aa1a8d0e 100644 --- a/src/snapshot-tests/__fixtures__/project-scaffolding/scaffold-ios--success.txt +++ b/src/snapshot-tests/__fixtures__/project-scaffolding/scaffold-ios--success.txt @@ -5,4 +5,5 @@ Path: /ios Platform: iOS -โœ… Project scaffolded successfully at /ios. +โœ… Project scaffolded successfully + โ”” /ios diff --git a/src/snapshot-tests/__fixtures__/project-scaffolding/scaffold-macos--success.txt b/src/snapshot-tests/__fixtures__/project-scaffolding/scaffold-macos--success.txt index 7ff4229a..8358e9d1 100644 --- a/src/snapshot-tests/__fixtures__/project-scaffolding/scaffold-macos--success.txt +++ b/src/snapshot-tests/__fixtures__/project-scaffolding/scaffold-macos--success.txt @@ -5,4 +5,5 @@ Path: /macos Platform: macOS -โœ… Project scaffolded successfully at /macos. +โœ… Project scaffolded successfully + โ”” /ios \ No newline at end of file diff --git a/src/snapshot-tests/__fixtures__/session-management/session-clear-defaults--success.txt b/src/snapshot-tests/__fixtures__/session-management/session-clear-defaults--success.txt index e3386962..7e2331d6 100644 --- a/src/snapshot-tests/__fixtures__/session-management/session-clear-defaults--success.txt +++ b/src/snapshot-tests/__fixtures__/session-management/session-clear-defaults--success.txt @@ -1,4 +1,6 @@ โš™๏ธ Clear Defaults -โœ… Session defaults cleared. + Profile: (default) + +โœ… Session defaults cleared (default profile) diff --git a/src/snapshot-tests/__fixtures__/session-management/session-set-defaults--success.txt b/src/snapshot-tests/__fixtures__/session-management/session-set-defaults--success.txt index 03d702e1..773d40cd 100644 --- a/src/snapshot-tests/__fixtures__/session-management/session-set-defaults--success.txt +++ b/src/snapshot-tests/__fixtures__/session-management/session-set-defaults--success.txt @@ -1,6 +1,24 @@ โš™๏ธ Set Defaults + Workspace Path: example_projects/iOS_Calculator/CalculatorApp.xcworkspace + Scheme: CalculatorApp + Profile: (default) + +โœ… Session defaults updated (default profile) + โ”œ projectPath: (not set) โ”œ workspacePath: example_projects/iOS_Calculator/CalculatorApp.xcworkspace - โ”” scheme: CalculatorApp -โœ… Session defaults updated. + โ”œ scheme: CalculatorApp + โ”œ configuration: (not set) + โ”œ simulatorName: (not set) + โ”œ simulatorId: (not set) + โ”œ simulatorPlatform: (not set) + โ”œ deviceId: (not set) + โ”œ useLatestOS: (not set) + โ”œ arch: (not set) + โ”œ suppressWarnings: (not set) + โ”œ derivedDataPath: (not set) + โ”œ preferXcodebuild: (not set) + โ”œ platform: (not set) + โ”œ bundleId: (not set) + โ”” env: (not set) diff --git a/src/snapshot-tests/__fixtures__/session-management/session-show-defaults--success.txt b/src/snapshot-tests/__fixtures__/session-management/session-show-defaults--success.txt index 4ebc3645..15db89a5 100644 --- a/src/snapshot-tests/__fixtures__/session-management/session-show-defaults--success.txt +++ b/src/snapshot-tests/__fixtures__/session-management/session-show-defaults--success.txt @@ -1,7 +1,38 @@ โš™๏ธ Show Defaults - Active Profile: global +๐Ÿ“ (default) + โ”œ projectPath: (not set) + โ”œ workspacePath: example_projects/iOS_Calculator/CalculatorApp.xcworkspace + โ”œ scheme: CalculatorApp + โ”œ configuration: (not set) + โ”œ simulatorName: (not set) + โ”œ simulatorId: (not set) + โ”œ simulatorPlatform: (not set) + โ”œ deviceId: (not set) + โ”œ useLatestOS: (not set) + โ”œ arch: (not set) + โ”œ suppressWarnings: (not set) + โ”œ derivedDataPath: (not set) + โ”œ preferXcodebuild: (not set) + โ”œ platform: (not set) + โ”œ bundleId: (not set) + โ”” env: (not set) +๐Ÿ“ MyCustomProfile + โ”œ projectPath: (not set) โ”œ workspacePath: example_projects/iOS_Calculator/CalculatorApp.xcworkspace - โ”” scheme: CalculatorApp + โ”œ scheme: CalculatorApp + โ”œ configuration: (not set) + โ”œ simulatorName: (not set) + โ”œ simulatorId: (not set) + โ”œ simulatorPlatform: (not set) + โ”œ deviceId: (not set) + โ”œ useLatestOS: (not set) + โ”œ arch: (not set) + โ”œ suppressWarnings: (not set) + โ”œ derivedDataPath: (not set) + โ”œ preferXcodebuild: (not set) + โ”œ platform: (not set) + โ”œ bundleId: (not set) + โ”” env: (not set) \ No newline at end of file diff --git a/src/snapshot-tests/__fixtures__/session-management/session-sync-xcode-defaults--success.txt b/src/snapshot-tests/__fixtures__/session-management/session-sync-xcode-defaults--success.txt index 90ac1f09..ba596930 100644 --- a/src/snapshot-tests/__fixtures__/session-management/session-sync-xcode-defaults--success.txt +++ b/src/snapshot-tests/__fixtures__/session-management/session-sync-xcode-defaults--success.txt @@ -1,5 +1,5 @@ โš™๏ธ Sync Xcode Defaults - โ”” scheme: CalculatorApp -โœ… Synced session defaults from Xcode IDE. +โœ… Synced session defaults from Xcode IDE (default profile) + โ”” env: (not set) \ No newline at end of file diff --git a/src/snapshot-tests/__fixtures__/session-management/session-use-defaults-profile--success.txt b/src/snapshot-tests/__fixtures__/session-management/session-use-defaults-profile--success.txt index 1932565f..099bd99f 100644 --- a/src/snapshot-tests/__fixtures__/session-management/session-use-defaults-profile--success.txt +++ b/src/snapshot-tests/__fixtures__/session-management/session-use-defaults-profile--success.txt @@ -1,7 +1,6 @@ โš™๏ธ Use Defaults Profile - Active Profile: global - Known Profiles: (none) + Current profile: (default) -โœ… Active profile: global +โœ… Activated profile (MyCustomProfile profile) diff --git a/src/snapshot-tests/__fixtures__/simulator-management/boot--success.txt b/src/snapshot-tests/__fixtures__/simulator-management/boot--success.txt new file mode 100644 index 00000000..a0fd81d1 --- /dev/null +++ b/src/snapshot-tests/__fixtures__/simulator-management/boot--success.txt @@ -0,0 +1,6 @@ + +๐Ÿ“ฑ Boot Simulator + + Simulator: + +โœ… Simulator booted successfully diff --git a/src/snapshot-tests/__fixtures__/simulator-management/erase--success.txt b/src/snapshot-tests/__fixtures__/simulator-management/erase--success.txt new file mode 100644 index 00000000..f8055402 --- /dev/null +++ b/src/snapshot-tests/__fixtures__/simulator-management/erase--success.txt @@ -0,0 +1,6 @@ + +๐Ÿ—‘ Erase Simulator + + Simulator: + +โœ… Simulators were erased successfully diff --git a/src/snapshot-tests/__fixtures__/simulator-management/list--success.txt b/src/snapshot-tests/__fixtures__/simulator-management/list--success.txt index 69b81c81..34419dff 100644 --- a/src/snapshot-tests/__fixtures__/simulator-management/list--success.txt +++ b/src/snapshot-tests/__fixtures__/simulator-management/list--success.txt @@ -1,59 +1,120 @@ ๐Ÿ“ฑ List Simulators -com.apple.CoreSimulator.SimRuntime.iOS-26-4 - -Name UUID State ---------------------- ------------------------------------ -------- -iPhone 17 Pro Shutdown -iPhone 17 Pro Max Shutdown -iPhone 17e Shutdown -iPhone Air Shutdown -iPhone 17 Booted -iPad Pro 13-inch (M5) Shutdown -iPad Pro 11-inch (M5) Shutdown -iPad mini (A17 Pro) Shutdown -iPad Air 13-inch (M4) Shutdown -iPad Air 11-inch (M4) Shutdown -iPad (A16) Shutdown - -com.apple.CoreSimulator.SimRuntime.xrOS-26-2 - -Name UUID State ----------------- ------------------------------------ -------- -Apple Vision Pro Shutdown - -com.apple.CoreSimulator.SimRuntime.watchOS-26-2 - -Name UUID State ----------------------------- ------------------------------------ -------- -Apple Watch Series 11 (46mm) Shutdown -Apple Watch Series 11 (42mm) Shutdown -Apple Watch Ultra 3 (49mm) Shutdown -Apple Watch SE 3 (44mm) Shutdown -Apple Watch SE 3 (40mm) Shutdown - -com.apple.CoreSimulator.SimRuntime.tvOS-26-2 - -Name UUID State ---------------------------------------- ------------------------------------ -------- -Apple TV 4K (3rd generation) Shutdown -Apple TV 4K (3rd generation) (at 1080p) Shutdown -Apple TV Shutdown - -com.apple.CoreSimulator.SimRuntime.iOS-26-2 - -Name UUID State ---------------------- ------------------------------------ -------- -iPhone 17 Pro Shutdown -iPhone 17 Pro Max Shutdown -iPhone Air Shutdown -iPhone 17 Shutdown -iPhone 16e Shutdown -iPad Pro 13-inch (M5) Shutdown -iPad Pro 11-inch (M5) Shutdown -iPad mini (A17 Pro) Shutdown -iPad (A16) Shutdown -iPad Air 13-inch (M3) Shutdown -iPad Air 11-inch (M3) Shutdown -โœ… Listed available simulators +iOS Simulators: + + iOS 26.4: + + ๐Ÿ“ฑ [โœ—] iPhone 17 Pro (Shutdown) + UDID: + + ๐Ÿ“ฑ [โœ—] iPhone 17 Pro Max (Shutdown) + UDID: + + ๐Ÿ“ฑ [โœ—] iPhone 17e (Shutdown) + UDID: + + ๐Ÿ“ฑ [โœ—] iPhone Air (Shutdown) + UDID: + + ๐Ÿ“ฑ [โœ“] iPhone 17 (Booted) + UDID: + + ๐Ÿ“ฑ [โœ—] iPad Pro 13-inch (M5) (Shutdown) + UDID: + + ๐Ÿ“ฑ [โœ—] iPad Pro 11-inch (M5) (Shutdown) + UDID: + + ๐Ÿ“ฑ [โœ—] iPad mini (A17 Pro) (Shutdown) + UDID: + + ๐Ÿ“ฑ [โœ—] iPad Air 13-inch (M4) (Shutdown) + UDID: + + ๐Ÿ“ฑ [โœ—] iPad Air 11-inch (M4) (Shutdown) + UDID: + + ๐Ÿ“ฑ [โœ—] iPad (A16) (Shutdown) + UDID: + + iOS 26.2: + + ๐Ÿ“ฑ [โœ—] iPhone 17 Pro (Shutdown) + UDID: + + ๐Ÿ“ฑ [โœ—] iPhone 17 Pro Max (Shutdown) + UDID: + + ๐Ÿ“ฑ [โœ—] iPhone Air (Shutdown) + UDID: + + ๐Ÿ“ฑ [โœ—] iPhone 17 (Shutdown) + UDID: + + ๐Ÿ“ฑ [โœ—] iPhone 16e (Shutdown) + UDID: + + ๐Ÿ“ฑ [โœ—] iPad Pro 13-inch (M5) (Shutdown) + UDID: + + ๐Ÿ“ฑ [โœ—] iPad Pro 11-inch (M5) (Shutdown) + UDID: + + ๐Ÿ“ฑ [โœ—] iPad mini (A17 Pro) (Shutdown) + UDID: + + ๐Ÿ“ฑ [โœ—] iPad (A16) (Shutdown) + UDID: + + ๐Ÿ“ฑ [โœ—] iPad Air 13-inch (M3) (Shutdown) + UDID: + + ๐Ÿ“ฑ [โœ—] iPad Air 11-inch (M3) (Shutdown) + UDID: + +visionOS Simulators: + + xrOS 26.2: + + ๐Ÿฅฝ [โœ—] Apple Vision Pro (Shutdown) + UDID: + +watchOS Simulators: + + watchOS 26.2: + + โŒš๏ธ [โœ—] Apple Watch Series 11 (46mm) (Shutdown) + UDID: + + โŒš๏ธ [โœ—] Apple Watch Series 11 (42mm) (Shutdown) + UDID: + + โŒš๏ธ [โœ—] Apple Watch Ultra 3 (49mm) (Shutdown) + UDID: + + โŒš๏ธ [โœ—] Apple Watch SE 3 (44mm) (Shutdown) + UDID: + + โŒš๏ธ [โœ—] Apple Watch SE 3 (40mm) (Shutdown) + UDID: + +tvOS Simulators: + + tvOS 26.2: + + ๐Ÿ“บ [โœ—] Apple TV 4K (3rd generation) (Shutdown) + UDID: + + ๐Ÿ“บ [โœ—] Apple TV 4K (3rd generation) (at 1080p) (Shutdown) + UDID: + + ๐Ÿ“บ [โœ—] Apple TV (Shutdown) + UDID: + +โœ… 31 simulators available (22 iOS, 1 visionOS, 5 watchOS, 3 tvOS). + +Hints + Use the simulator ID/UDID from above when required by other tools. + Save a default simulator with session-set-defaults { simulatorId: 'SIMULATOR_UDID' }. + Before running boot/build/run tools, set the desired simulator identifier in session defaults. diff --git a/src/snapshot-tests/__fixtures__/simulator-management/open--success.txt b/src/snapshot-tests/__fixtures__/simulator-management/open--success.txt index d8114a00..f972624d 100644 --- a/src/snapshot-tests/__fixtures__/simulator-management/open--success.txt +++ b/src/snapshot-tests/__fixtures__/simulator-management/open--success.txt @@ -1,4 +1,4 @@ ๐Ÿ“ฑ Open Simulator -โœ… Simulator app opened +โœ… Simulator opened successfully diff --git a/src/snapshot-tests/__fixtures__/simulator-management/reset-location--success.txt b/src/snapshot-tests/__fixtures__/simulator-management/reset-location--success.txt index 9ff28aa0..0626956e 100644 --- a/src/snapshot-tests/__fixtures__/simulator-management/reset-location--success.txt +++ b/src/snapshot-tests/__fixtures__/simulator-management/reset-location--success.txt @@ -3,4 +3,4 @@ Simulator: -โœ… Location reset to default +โœ… Location successfully reset to default diff --git a/src/snapshot-tests/__fixtures__/simulator-management/set-appearance--success.txt b/src/snapshot-tests/__fixtures__/simulator-management/set-appearance--success.txt index 20eac34b..bd3a256c 100644 --- a/src/snapshot-tests/__fixtures__/simulator-management/set-appearance--success.txt +++ b/src/snapshot-tests/__fixtures__/simulator-management/set-appearance--success.txt @@ -4,4 +4,4 @@ Simulator: Mode: dark -โœ… Appearance set to dark mode +โœ… Appearance successfully set to dark mode diff --git a/src/snapshot-tests/__fixtures__/simulator-management/set-location--success.txt b/src/snapshot-tests/__fixtures__/simulator-management/set-location--success.txt index 63a67b00..783dc9f2 100644 --- a/src/snapshot-tests/__fixtures__/simulator-management/set-location--success.txt +++ b/src/snapshot-tests/__fixtures__/simulator-management/set-location--success.txt @@ -4,4 +4,4 @@ Simulator: Coordinates: 37.7749,-122.4194 -โœ… Location set to 37.7749,-122.4194 +โœ… Location set successfully diff --git a/src/snapshot-tests/__fixtures__/simulator-management/statusbar--success.txt b/src/snapshot-tests/__fixtures__/simulator-management/statusbar--success.txt index 7395c366..3d93eeaf 100644 --- a/src/snapshot-tests/__fixtures__/simulator-management/statusbar--success.txt +++ b/src/snapshot-tests/__fixtures__/simulator-management/statusbar--success.txt @@ -4,4 +4,4 @@ Simulator: Data Network: wifi -โœ… Status bar data network set to wifi +โœ… Status bar data network set successfully diff --git a/src/snapshot-tests/__fixtures__/simulator/build--error-wrong-scheme.txt b/src/snapshot-tests/__fixtures__/simulator/build--error-wrong-scheme.txt index 2f6107de..acb323f2 100644 --- a/src/snapshot-tests/__fixtures__/simulator/build--error-wrong-scheme.txt +++ b/src/snapshot-tests/__fixtures__/simulator/build--error-wrong-scheme.txt @@ -2,11 +2,14 @@ ๐Ÿ”จ Build Scheme: NONEXISTENT + Workspace: example_projects/iOS_Calculator/CalculatorApp.xcworkspace Configuration: Debug Platform: iOS Simulator + Simulator: iPhone 17 Errors (1): โœ— The workspace named "CalculatorApp" does not contain a scheme named "NONEXISTENT". The "-list" option can be used to find the names of the schemes in the workspace. โŒ Build failed. (โฑ๏ธ ) + โ”” Build Logs: /logs/build_sim_2026-03-28T08-18-09-055Z.log diff --git a/src/snapshot-tests/__fixtures__/simulator/build--success.txt b/src/snapshot-tests/__fixtures__/simulator/build--success.txt index 273f4bdf..598f5be6 100644 --- a/src/snapshot-tests/__fixtures__/simulator/build--success.txt +++ b/src/snapshot-tests/__fixtures__/simulator/build--success.txt @@ -2,10 +2,13 @@ ๐Ÿ”จ Build Scheme: CalculatorApp + Workspace: example_projects/iOS_Calculator/CalculatorApp.xcworkspace Configuration: Debug Platform: iOS Simulator + Simulator: iPhone 17 โœ… Build succeeded. (โฑ๏ธ ) + โ”” Build Logs: /logs/build_sim_2026-03-28T08-18-03-808Z.log Next steps: 1. Get built app path in simulator derived data: xcodebuildmcp simulator get-app-path --simulator-name "iPhone 17" --scheme "CalculatorApp" --platform "iOS Simulator" diff --git a/src/snapshot-tests/__fixtures__/simulator/build-and-run--error-wrong-scheme.txt b/src/snapshot-tests/__fixtures__/simulator/build-and-run--error-wrong-scheme.txt index 6c03ffca..8e9bad72 100644 --- a/src/snapshot-tests/__fixtures__/simulator/build-and-run--error-wrong-scheme.txt +++ b/src/snapshot-tests/__fixtures__/simulator/build-and-run--error-wrong-scheme.txt @@ -2,11 +2,14 @@ ๐Ÿš€ Build & Run Scheme: NONEXISTENT + Workspace: example_projects/iOS_Calculator/CalculatorApp.xcworkspace Configuration: Debug Platform: iOS Simulator + Simulator: iPhone 17 Errors (1): โœ— The workspace named "CalculatorApp" does not contain a scheme named "NONEXISTENT". The "-list" option can be used to find the names of the schemes in the workspace. โŒ Build failed. (โฑ๏ธ ) + โ”” Build Logs: /logs/build_run_sim_2026-03-28T08-18-14-321Z.log diff --git a/src/snapshot-tests/__fixtures__/simulator/build-and-run--success.txt b/src/snapshot-tests/__fixtures__/simulator/build-and-run--success.txt index 6728ffa4..2ff5b604 100644 --- a/src/snapshot-tests/__fixtures__/simulator/build-and-run--success.txt +++ b/src/snapshot-tests/__fixtures__/simulator/build-and-run--success.txt @@ -2,8 +2,10 @@ ๐Ÿš€ Build & Run Scheme: CalculatorApp + Workspace: example_projects/iOS_Calculator/CalculatorApp.xcworkspace Configuration: Debug Platform: iOS Simulator + Simulator: iPhone 17 โ„น๏ธ Resolving app path โœ… Resolving app path @@ -15,9 +17,10 @@ โœ… Build succeeded. (โฑ๏ธ ) โœ… Build & Run complete - โ”œ App Path: /Library/Developer/Xcode/DerivedData/CalculatorApp-/Build/Products/Debug-iphonesimulator/CalculatorApp.app - โ”” Bundle ID: io.sentry.calculatorapp + โ”œ Bundle ID: io.sentry.calculatorapp + โ”œ Process ID: + โ”” Build Logs: /logs/build_run_sim_2026-03-28T08-18-10-408Z.log Next steps: 1. Capture structured logs (app continues running): xcodebuildmcp logging start-simulator-log-capture --simulator-id "" --bundle-id "io.sentry.calculatorapp" diff --git a/src/snapshot-tests/__fixtures__/simulator/get-app-path--error-wrong-scheme.txt b/src/snapshot-tests/__fixtures__/simulator/get-app-path--error-wrong-scheme.txt index f58b0288..202d727e 100644 --- a/src/snapshot-tests/__fixtures__/simulator/get-app-path--error-wrong-scheme.txt +++ b/src/snapshot-tests/__fixtures__/simulator/get-app-path--error-wrong-scheme.txt @@ -7,13 +7,8 @@ Platform: iOS Simulator Simulator: iPhone 17 -โŒ Failed to get build settings: xcodebuild[46420:16976772] Writing error result bundle to /var/folders/_t/2njffz894t57qpp76v1sw__h0000gn/T/ResultBundle_2026-27-03_09-50-0028.xcresult -xcodebuild: error: The workspace named "CalculatorApp" does not contain a scheme named "NONEXISTENT". The "-list" option can be used to find the names of the schemes in the workspace. +Errors (1): -Command line invocation: - /Applications/Xcode-26.4.0.app/Contents/Developer/usr/bin/xcodebuild -showBuildSettings -workspace example_projects/iOS_Calculator/CalculatorApp.xcworkspace -scheme NONEXISTENT -configuration Debug -destination "platform=iOS Simulator,name=iPhone 17,OS=latest" + โœ— The workspace named "CalculatorApp" does not contain a scheme named "NONEXISTENT". The "-list" option can be used to find the names of the schemes in the workspace. -Resolve Package Graph - -Resolved source packages: - CalculatorAppFeature: /example_projects/iOS_Calculator/CalculatorAppPackage +โŒ Failed to get app path diff --git a/src/snapshot-tests/__fixtures__/simulator/get-app-path--success.txt b/src/snapshot-tests/__fixtures__/simulator/get-app-path--success.txt index 313d93b0..2a93f842 100644 --- a/src/snapshot-tests/__fixtures__/simulator/get-app-path--success.txt +++ b/src/snapshot-tests/__fixtures__/simulator/get-app-path--success.txt @@ -7,6 +7,11 @@ Platform: iOS Simulator Simulator: iPhone 17 -โœ… App path resolved - +โœ… Get app path successful (โฑ๏ธ ) โ”” App Path: /Library/Developer/Xcode/DerivedData/CalculatorApp-/Build/Products/Debug-iphonesimulator/CalculatorApp.app + +Next steps: +1. Get bundle ID: xcodebuildmcp device get-app-bundle-id --app-path "/Library/Developer/Xcode/DerivedData/CalculatorApp-/Build/Products/Debug-iphonesimulator/CalculatorApp.app" +2. Boot simulator: xcodebuildmcp simulator-management boot --simulator-id "SIMULATOR_UUID" +3. Install app: xcodebuildmcp simulator install --simulator-id "SIMULATOR_UUID" --app-path "/Library/Developer/Xcode/DerivedData/CalculatorApp-/Build/Products/Debug-iphonesimulator/CalculatorApp.app" +4. Launch app: xcodebuildmcp simulator launch-app --simulator-id "SIMULATOR_UUID" --bundle-id "BUNDLE_ID" diff --git a/src/snapshot-tests/__fixtures__/simulator/install--success.txt b/src/snapshot-tests/__fixtures__/simulator/install--success.txt index 3235bfb4..c91036cf 100644 --- a/src/snapshot-tests/__fixtures__/simulator/install--success.txt +++ b/src/snapshot-tests/__fixtures__/simulator/install--success.txt @@ -4,4 +4,4 @@ Simulator: App Path: /Library/Developer/Xcode/DerivedData/CalculatorApp-/Build/Products/Debug-iphonesimulator/CalculatorApp.app -โœ… App installed successfully in simulator +โœ… App installed successfully diff --git a/src/snapshot-tests/__fixtures__/simulator/launch-app--success.txt b/src/snapshot-tests/__fixtures__/simulator/launch-app--success.txt index 603b2668..f8807f69 100644 --- a/src/snapshot-tests/__fixtures__/simulator/launch-app--success.txt +++ b/src/snapshot-tests/__fixtures__/simulator/launch-app--success.txt @@ -4,4 +4,5 @@ Simulator: Bundle ID: io.sentry.calculatorapp -โœ… App launched successfully in simulator +โœ… App launched successfully + โ”” Process ID: \ No newline at end of file diff --git a/src/snapshot-tests/__fixtures__/simulator/launch-app-with-logs--success.txt b/src/snapshot-tests/__fixtures__/simulator/launch-app-with-logs--success.txt index d168f403..43c6f537 100644 --- a/src/snapshot-tests/__fixtures__/simulator/launch-app-with-logs--success.txt +++ b/src/snapshot-tests/__fixtures__/simulator/launch-app-with-logs--success.txt @@ -5,8 +5,8 @@ Bundle ID: io.sentry.calculatorapp Log Capture: enabled - โ”” Log Session ID: โœ… App launched successfully in simulator with log capture enabled + โ”” Log Session ID: Next steps: 1. Stop capture and retrieve logs: stop_sim_log_cap({ logSessionId: "" }) diff --git a/src/snapshot-tests/__fixtures__/simulator/list--success.txt b/src/snapshot-tests/__fixtures__/simulator/list--success.txt index 69b81c81..34419dff 100644 --- a/src/snapshot-tests/__fixtures__/simulator/list--success.txt +++ b/src/snapshot-tests/__fixtures__/simulator/list--success.txt @@ -1,59 +1,120 @@ ๐Ÿ“ฑ List Simulators -com.apple.CoreSimulator.SimRuntime.iOS-26-4 - -Name UUID State ---------------------- ------------------------------------ -------- -iPhone 17 Pro Shutdown -iPhone 17 Pro Max Shutdown -iPhone 17e Shutdown -iPhone Air Shutdown -iPhone 17 Booted -iPad Pro 13-inch (M5) Shutdown -iPad Pro 11-inch (M5) Shutdown -iPad mini (A17 Pro) Shutdown -iPad Air 13-inch (M4) Shutdown -iPad Air 11-inch (M4) Shutdown -iPad (A16) Shutdown - -com.apple.CoreSimulator.SimRuntime.xrOS-26-2 - -Name UUID State ----------------- ------------------------------------ -------- -Apple Vision Pro Shutdown - -com.apple.CoreSimulator.SimRuntime.watchOS-26-2 - -Name UUID State ----------------------------- ------------------------------------ -------- -Apple Watch Series 11 (46mm) Shutdown -Apple Watch Series 11 (42mm) Shutdown -Apple Watch Ultra 3 (49mm) Shutdown -Apple Watch SE 3 (44mm) Shutdown -Apple Watch SE 3 (40mm) Shutdown - -com.apple.CoreSimulator.SimRuntime.tvOS-26-2 - -Name UUID State ---------------------------------------- ------------------------------------ -------- -Apple TV 4K (3rd generation) Shutdown -Apple TV 4K (3rd generation) (at 1080p) Shutdown -Apple TV Shutdown - -com.apple.CoreSimulator.SimRuntime.iOS-26-2 - -Name UUID State ---------------------- ------------------------------------ -------- -iPhone 17 Pro Shutdown -iPhone 17 Pro Max Shutdown -iPhone Air Shutdown -iPhone 17 Shutdown -iPhone 16e Shutdown -iPad Pro 13-inch (M5) Shutdown -iPad Pro 11-inch (M5) Shutdown -iPad mini (A17 Pro) Shutdown -iPad (A16) Shutdown -iPad Air 13-inch (M3) Shutdown -iPad Air 11-inch (M3) Shutdown -โœ… Listed available simulators +iOS Simulators: + + iOS 26.4: + + ๐Ÿ“ฑ [โœ—] iPhone 17 Pro (Shutdown) + UDID: + + ๐Ÿ“ฑ [โœ—] iPhone 17 Pro Max (Shutdown) + UDID: + + ๐Ÿ“ฑ [โœ—] iPhone 17e (Shutdown) + UDID: + + ๐Ÿ“ฑ [โœ—] iPhone Air (Shutdown) + UDID: + + ๐Ÿ“ฑ [โœ“] iPhone 17 (Booted) + UDID: + + ๐Ÿ“ฑ [โœ—] iPad Pro 13-inch (M5) (Shutdown) + UDID: + + ๐Ÿ“ฑ [โœ—] iPad Pro 11-inch (M5) (Shutdown) + UDID: + + ๐Ÿ“ฑ [โœ—] iPad mini (A17 Pro) (Shutdown) + UDID: + + ๐Ÿ“ฑ [โœ—] iPad Air 13-inch (M4) (Shutdown) + UDID: + + ๐Ÿ“ฑ [โœ—] iPad Air 11-inch (M4) (Shutdown) + UDID: + + ๐Ÿ“ฑ [โœ—] iPad (A16) (Shutdown) + UDID: + + iOS 26.2: + + ๐Ÿ“ฑ [โœ—] iPhone 17 Pro (Shutdown) + UDID: + + ๐Ÿ“ฑ [โœ—] iPhone 17 Pro Max (Shutdown) + UDID: + + ๐Ÿ“ฑ [โœ—] iPhone Air (Shutdown) + UDID: + + ๐Ÿ“ฑ [โœ—] iPhone 17 (Shutdown) + UDID: + + ๐Ÿ“ฑ [โœ—] iPhone 16e (Shutdown) + UDID: + + ๐Ÿ“ฑ [โœ—] iPad Pro 13-inch (M5) (Shutdown) + UDID: + + ๐Ÿ“ฑ [โœ—] iPad Pro 11-inch (M5) (Shutdown) + UDID: + + ๐Ÿ“ฑ [โœ—] iPad mini (A17 Pro) (Shutdown) + UDID: + + ๐Ÿ“ฑ [โœ—] iPad (A16) (Shutdown) + UDID: + + ๐Ÿ“ฑ [โœ—] iPad Air 13-inch (M3) (Shutdown) + UDID: + + ๐Ÿ“ฑ [โœ—] iPad Air 11-inch (M3) (Shutdown) + UDID: + +visionOS Simulators: + + xrOS 26.2: + + ๐Ÿฅฝ [โœ—] Apple Vision Pro (Shutdown) + UDID: + +watchOS Simulators: + + watchOS 26.2: + + โŒš๏ธ [โœ—] Apple Watch Series 11 (46mm) (Shutdown) + UDID: + + โŒš๏ธ [โœ—] Apple Watch Series 11 (42mm) (Shutdown) + UDID: + + โŒš๏ธ [โœ—] Apple Watch Ultra 3 (49mm) (Shutdown) + UDID: + + โŒš๏ธ [โœ—] Apple Watch SE 3 (44mm) (Shutdown) + UDID: + + โŒš๏ธ [โœ—] Apple Watch SE 3 (40mm) (Shutdown) + UDID: + +tvOS Simulators: + + tvOS 26.2: + + ๐Ÿ“บ [โœ—] Apple TV 4K (3rd generation) (Shutdown) + UDID: + + ๐Ÿ“บ [โœ—] Apple TV 4K (3rd generation) (at 1080p) (Shutdown) + UDID: + + ๐Ÿ“บ [โœ—] Apple TV (Shutdown) + UDID: + +โœ… 31 simulators available (22 iOS, 1 visionOS, 5 watchOS, 3 tvOS). + +Hints + Use the simulator ID/UDID from above when required by other tools. + Save a default simulator with session-set-defaults { simulatorId: 'SIMULATOR_UDID' }. + Before running boot/build/run tools, set the desired simulator identifier in session defaults. diff --git a/src/snapshot-tests/__fixtures__/simulator/screenshot--success.txt b/src/snapshot-tests/__fixtures__/simulator/screenshot--success.txt index 3569c560..48c1e7b4 100644 --- a/src/snapshot-tests/__fixtures__/simulator/screenshot--success.txt +++ b/src/snapshot-tests/__fixtures__/simulator/screenshot--success.txt @@ -3,4 +3,7 @@ Simulator: -โœ… Screenshot captured: /var/folders/_t/2njffz894t57qpp76v1sw__h0000gn/T/screenshot_optimized_.jpg (image/jpeg) +โœ… Screenshot captured + โ”œ Screenshot: /var/folders/_t/2njffz894t57qpp76v1sw__h0000gn/T/screenshot_optimized_.jpg + โ”œ Format: image/jpeg + โ”” Size: 368x800px diff --git a/src/snapshot-tests/__fixtures__/simulator/stop--success.txt b/src/snapshot-tests/__fixtures__/simulator/stop--success.txt index dc15fa7a..855e0888 100644 --- a/src/snapshot-tests/__fixtures__/simulator/stop--success.txt +++ b/src/snapshot-tests/__fixtures__/simulator/stop--success.txt @@ -4,4 +4,4 @@ Simulator: Bundle ID: io.sentry.calculatorapp -โœ… App io.sentry.calculatorapp stopped successfully in simulator +โœ… App stopped successfully diff --git a/src/snapshot-tests/__fixtures__/simulator/test--error-wrong-scheme.txt b/src/snapshot-tests/__fixtures__/simulator/test--error-wrong-scheme.txt index d787369e..815e7925 100644 --- a/src/snapshot-tests/__fixtures__/simulator/test--error-wrong-scheme.txt +++ b/src/snapshot-tests/__fixtures__/simulator/test--error-wrong-scheme.txt @@ -10,4 +10,5 @@ Errors (1): โœ— The workspace named "CalculatorApp" does not contain a scheme named "NONEXISTENT". The "-list" option can be used to find the names of the schemes in the workspace. -โŒ Test failed. (, โฑ๏ธ ) +โŒ Test failed (, โฑ๏ธ ) + โ”” Build Logs: /logs/test_sim_2026-03-28T08-18-48-444Z.log diff --git a/src/snapshot-tests/__fixtures__/simulator/test--failure.txt b/src/snapshot-tests/__fixtures__/simulator/test--failure.txt index 2e1dc881..edcb658b 100644 --- a/src/snapshot-tests/__fixtures__/simulator/test--failure.txt +++ b/src/snapshot-tests/__fixtures__/simulator/test--failure.txt @@ -6,7 +6,14 @@ Platform: iOS Simulator Simulator: iPhone 17 - โœ— CalculatorAppTests.CalculatorAppTests/testCalculatorServiceFailure: XCTAssertEqual failed: ("0") is not equal to ("999") - This test should fail - display should be 0, not 999 - example_projects/iOS_Calculator/CalculatorAppTests/CalculatorAppTests.swift:52 +CalculatorAppTests + โœ— testCalculatorServiceFailure (โฑ๏ธ ): + - XCTAssertEqual failed: ("0") is not equal to ("999") - This test should fail - display should be 0, not 999 + example_projects/iOS_Calculator/CalculatorAppTests/CalculatorAppTests.swift:52 -โŒ Test failed. (, โฑ๏ธ ) +This test should fail to verify error reporting + โœ— test (โฑ๏ธ ): + - Test failed + +โŒ 2 tests failed, 53 passed, 0 skipped (โฑ๏ธ ) + โ”” Build Logs: /logs/test_sim_2026-03-28T08-18-25-423Z.log diff --git a/src/snapshot-tests/__fixtures__/simulator/test--success.txt b/src/snapshot-tests/__fixtures__/simulator/test--success.txt index 9b7c8b5e..73524b3b 100644 --- a/src/snapshot-tests/__fixtures__/simulator/test--success.txt +++ b/src/snapshot-tests/__fixtures__/simulator/test--success.txt @@ -6,4 +6,5 @@ Platform: iOS Simulator Simulator: iPhone 17 -โœ… Test succeeded. (, โฑ๏ธ ) +โœ… 53 tests passed, 0 skipped (โฑ๏ธ ) + โ”” Build Logs: /logs/test_sim_2026-03-28T08-18-25-423Z.log diff --git a/src/snapshot-tests/__fixtures__/swift-package/build--success.txt b/src/snapshot-tests/__fixtures__/swift-package/build--success.txt index e043632e..a65cacc4 100644 --- a/src/snapshot-tests/__fixtures__/swift-package/build--success.txt +++ b/src/snapshot-tests/__fixtures__/swift-package/build--success.txt @@ -3,8 +3,5 @@ Package: /example_projects/spm -Output - [0/1] Planning build -Build complete! () - -โœ… Swift package build succeeded +โœ… Build succeeded. (โฑ๏ธ ) + โ”” Build Logs: /logs/build_spm_2026-03-28T08-18-03-808Z.log diff --git a/src/snapshot-tests/__fixtures__/swift-package/list--no-processes.txt b/src/snapshot-tests/__fixtures__/swift-package/list--no-processes.txt new file mode 100644 index 00000000..3d744593 --- /dev/null +++ b/src/snapshot-tests/__fixtures__/swift-package/list--no-processes.txt @@ -0,0 +1,4 @@ + +๐Ÿ“ฆ Swift Package Processes + +โ„น๏ธ No Swift Package processes currently running. diff --git a/src/snapshot-tests/__fixtures__/swift-package/list--success.txt b/src/snapshot-tests/__fixtures__/swift-package/list--success.txt index 3b51e358..4b34adbf 100644 --- a/src/snapshot-tests/__fixtures__/swift-package/list--success.txt +++ b/src/snapshot-tests/__fixtures__/swift-package/list--success.txt @@ -1,4 +1,12 @@ -๐Ÿ“ฆ Swift Package List +๐Ÿ“ฆ Swift Package Processes -โ„น๏ธ No Swift Package processes currently running. +Running Processes (2): + + ๐ŸŸข long-server + PID: 12345 | Uptime: 10s + Package: /example_projects/spm + + ๐ŸŸข quick-task + PID: 12346 | Uptime: 3s + Package: /example_projects/spm diff --git a/src/snapshot-tests/__fixtures__/swift-package/run--error-bad-executable.txt b/src/snapshot-tests/__fixtures__/swift-package/run--error-bad-executable.txt index 12edb6bc..1784637c 100644 --- a/src/snapshot-tests/__fixtures__/swift-package/run--error-bad-executable.txt +++ b/src/snapshot-tests/__fixtures__/swift-package/run--error-bad-executable.txt @@ -4,9 +4,9 @@ Package: /example_projects/spm Executable: nonexistent-executable -Output - (no output) -Errors: -error: no executable product named 'nonexistent-executable' +Errors (1): -โŒ Swift executable failed + โœ— no executable product named 'nonexistent-executable' + +โŒ Run failed. (โฑ๏ธ ) + โ”” Build Logs: /logs/build_sim_2026-03-28T08-18-09-055Z.log diff --git a/src/snapshot-tests/__fixtures__/swift-package/run--success.txt b/src/snapshot-tests/__fixtures__/swift-package/run--success.txt index c848ef6e..9b897fc2 100644 --- a/src/snapshot-tests/__fixtures__/swift-package/run--success.txt +++ b/src/snapshot-tests/__fixtures__/swift-package/run--success.txt @@ -4,7 +4,12 @@ Package: /example_projects/spm Executable: spm -Output - Hello, world! +โœ… Build succeeded. (โฑ๏ธ ) +โœ… Build & Run complete + โ”œ App Path: /Library/Developer/Xcode/DerivedData/CalculatorApp-/Build/Products/Debug-iphonesimulator/CalculatorApp.app + โ”œ Bundle ID: io.sentry.calculatorapp + โ”œ Process ID: + โ”” Build Logs: /logs/build_run_spm_2026-03-28T08-18-10-408Z.log -โœ… Swift executable completed successfully +Output + Hello, world! \ No newline at end of file diff --git a/src/snapshot-tests/__fixtures__/swift-package/test--error-bad-path.txt b/src/snapshot-tests/__fixtures__/swift-package/test--error-bad-path.txt index 4d9ab900..3acd3dbd 100644 --- a/src/snapshot-tests/__fixtures__/swift-package/test--error-bad-path.txt +++ b/src/snapshot-tests/__fixtures__/swift-package/test--error-bad-path.txt @@ -10,3 +10,4 @@ Errors (1): โœ— chdir error: No such file or directory (2): /example_projects/NONEXISTENT โŒ Test failed. (, โฑ๏ธ ) + โ”” Build Logs: /logs/swift_package_test_2026-03-28T08-19-47-575Z.log diff --git a/src/snapshot-tests/__fixtures__/swift-package/test--failure.txt b/src/snapshot-tests/__fixtures__/swift-package/test--failure.txt index 71560876..8ede6c04 100644 --- a/src/snapshot-tests/__fixtures__/swift-package/test--failure.txt +++ b/src/snapshot-tests/__fixtures__/swift-package/test--failure.txt @@ -5,8 +5,14 @@ Configuration: debug Platform: Swift Package - โœ— Expected failure: Expectation failed: true == false -This test should fail, and is for simulating a test failure - SimpleTests.swift:48 +CalculatorAppTests + โœ— testCalculatorServiceFailure (โฑ๏ธ ): + - XCTAssertEqual failed: ("0") is not equal to ("999") - This test should fail - display should be 0, not 999 + example_projects/iOS_Calculator/CalculatorAppTests/CalculatorAppTests.swift:52 -โŒ Test failed. (, โฑ๏ธ ) +This test should fail to verify error reporting + โœ— test (โฑ๏ธ ): + - Test failed + +โŒ 2 tests failed, 53 passed, 0 skipped (โฑ๏ธ ) + โ”” Build Logs: /logs/test_sim_2026-03-28T08-18-25-423Z.log diff --git a/src/snapshot-tests/__fixtures__/swift-package/test--success.txt b/src/snapshot-tests/__fixtures__/swift-package/test--success.txt index 024235aa..5936f0fe 100644 --- a/src/snapshot-tests/__fixtures__/swift-package/test--success.txt +++ b/src/snapshot-tests/__fixtures__/swift-package/test--success.txt @@ -6,3 +6,4 @@ Platform: Swift Package โœ… Test succeeded. (, โฑ๏ธ ) + โ”” Build Logs: /logs/swift_package_test_2026-03-28T08-19-43-542Z.log diff --git a/src/snapshot-tests/__fixtures__/ui-automation/long-press--success.txt b/src/snapshot-tests/__fixtures__/ui-automation/long-press--success.txt index 09930c37..901c2463 100644 --- a/src/snapshot-tests/__fixtures__/ui-automation/long-press--success.txt +++ b/src/snapshot-tests/__fixtures__/ui-automation/long-press--success.txt @@ -4,4 +4,5 @@ Simulator: โœ… Long press at (100, 400) for 500ms simulated successfully. + โš ๏ธ Warning: snapshot_ui has not been called yet. Consider using snapshot_ui for precise coordinates instead of guessing from screenshots. diff --git a/src/snapshot-tests/__fixtures__/ui-automation/swipe--success.txt b/src/snapshot-tests/__fixtures__/ui-automation/swipe--success.txt index 842459c3..bae0cfea 100644 --- a/src/snapshot-tests/__fixtures__/ui-automation/swipe--success.txt +++ b/src/snapshot-tests/__fixtures__/ui-automation/swipe--success.txt @@ -4,4 +4,5 @@ Simulator: โœ… Swipe from (200, 400) to (200, 200) simulated successfully. + โš ๏ธ Warning: snapshot_ui has not been called yet. Consider using snapshot_ui for precise coordinates instead of guessing from screenshots. diff --git a/src/snapshot-tests/__fixtures__/ui-automation/tap--success.txt b/src/snapshot-tests/__fixtures__/ui-automation/tap--success.txt index 54988f19..a3a27d96 100644 --- a/src/snapshot-tests/__fixtures__/ui-automation/tap--success.txt +++ b/src/snapshot-tests/__fixtures__/ui-automation/tap--success.txt @@ -4,4 +4,5 @@ Simulator: โœ… Tap at (100, 400) simulated successfully. + โš ๏ธ Warning: snapshot_ui has not been called yet. Consider using snapshot_ui for precise coordinates instead of guessing from screenshots. diff --git a/src/snapshot-tests/__fixtures__/ui-automation/touch--success.txt b/src/snapshot-tests/__fixtures__/ui-automation/touch--success.txt index ccfc1ca1..d29ac812 100644 --- a/src/snapshot-tests/__fixtures__/ui-automation/touch--success.txt +++ b/src/snapshot-tests/__fixtures__/ui-automation/touch--success.txt @@ -4,4 +4,5 @@ Simulator: โœ… Touch event (touch down+up) at (100, 400) executed successfully. + โš ๏ธ Warning: snapshot_ui has not been called yet. Consider using snapshot_ui for precise coordinates instead of guessing from screenshots. diff --git a/src/snapshot-tests/__fixtures__/utilities/clean--error-wrong-scheme.txt b/src/snapshot-tests/__fixtures__/utilities/clean--error-wrong-scheme.txt index 16de0148..a452b652 100644 --- a/src/snapshot-tests/__fixtures__/utilities/clean--error-wrong-scheme.txt +++ b/src/snapshot-tests/__fixtures__/utilities/clean--error-wrong-scheme.txt @@ -2,11 +2,8 @@ ๐Ÿงน Clean Scheme: NONEXISTENT + Workspace: example_projects/iOS_Calculator/CalculatorApp.xcworkspace Configuration: Debug Platform: iOS -Errors (1): - - โœ— The workspace named "CalculatorApp" does not contain a scheme named "NONEXISTENT". The "-list" option can be used to find the names of the schemes in the workspace. - -โŒ Build failed. (โฑ๏ธ ) +โŒ Clean failed: error: chdir error: No such file or directory (2): /example_projects/NONEXISTENT diff --git a/src/snapshot-tests/__fixtures__/utilities/clean--success.txt b/src/snapshot-tests/__fixtures__/utilities/clean--success.txt index e796414c..3e0c7025 100644 --- a/src/snapshot-tests/__fixtures__/utilities/clean--success.txt +++ b/src/snapshot-tests/__fixtures__/utilities/clean--success.txt @@ -2,7 +2,8 @@ ๐Ÿงน Clean Scheme: CalculatorApp + Workspace: example_projects/iOS_Calculator/CalculatorApp.xcworkspace Configuration: Debug Platform: iOS -โœ… Build succeeded. (โฑ๏ธ ) +โœ… Clean successful diff --git a/src/snapshot-tests/__flowdeck_fixtures__/device/build--error-wrong-scheme.txt b/src/snapshot-tests/__flowdeck_fixtures__/device/build--error-wrong-scheme.txt new file mode 100644 index 00000000..3d15ba8a --- /dev/null +++ b/src/snapshot-tests/__flowdeck_fixtures__/device/build--error-wrong-scheme.txt @@ -0,0 +1,29 @@ +๐Ÿš€ Build Started + + Workspace: CalculatorApp.xcworkspace + Scheme: NONEXISTENT + Configuration: Debug + Platform: iOS Device + Device: 0000FE01-E7A450D374E02B18 + Derived Data: /Users/cameroncooke/Library/Developer/FlowDeck/DerivedData/XcodeBuildMCP-21505d5e586f + + โ†’ Resolving Package Graph... + +โœ— Build failed: +โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +Build failed (exit code: 65) +Command: /usr/bin/xcrun xcodebuild DEBUG_INFORMATION_FORMAT=dwarf COMPILER_INDEX_STORE_ENABLE=NO -workspace /Users/cameroncooke/.codex/worktrees/43f4/XcodeBuildMCP/example_projects/iOS_Calculator/CalculatorApp.xcworkspace -scheme NONEXISTENT -configuration Debug -destination id=0000FE01-E7A450D374E02B18 -derivedDataPath /Users/cameroncooke/Library/Developer/FlowDeck/DerivedData/XcodeBuildMCP-21505d5e586f -skipPackageUpdates -parallelizeTargets -skipMacroValidation -packageCachePath /Users/cameroncooke/Library/Caches/org.swift.swiftpm -resultBundlePath /Users/cameroncooke/.flowdeck/logs/21505d5e586f/build.xcresult build: + +Xcode Build Errors (1): + + โœ— The workspace named "CalculatorApp" does not contain a scheme named "NONEXISTENT". The "-list" option can be used to find the names of the schemes in the workspace. + + +Hint: Scheme not found in project + โ€ข List schemes: flowdeck project schemes -w + โ€ข Check scheme is shared: Manage Schemes > Shared checkbox + โ€ข Verify scheme name spelling (case-sensitive) + โ€ข Recreate scheme: Product > Scheme > New Scheme + +Full build log: /Users/cameroncooke/.flowdeck/logs/21505d5e586f/build.log +โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ diff --git a/src/snapshot-tests/__flowdeck_fixtures__/device/build--success.txt b/src/snapshot-tests/__flowdeck_fixtures__/device/build--success.txt new file mode 100644 index 00000000..54780428 --- /dev/null +++ b/src/snapshot-tests/__flowdeck_fixtures__/device/build--success.txt @@ -0,0 +1,20 @@ +๐Ÿš€ Build Started + + Workspace: CalculatorApp.xcworkspace + Scheme: CalculatorApp + Configuration: Debug + Platform: iOS Device + Device: 0000FE01-E7A450D374E02B18 + Derived Data: /Users/cameroncooke/Library/Developer/FlowDeck/DerivedData/XcodeBuildMCP-21505d5e586f + + โ†’ Resolving Package Graph... + +โœ— Build failed: +โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +Build failed (exit code: 70) +Command: /usr/bin/xcrun xcodebuild DEBUG_INFORMATION_FORMAT=dwarf COMPILER_INDEX_STORE_ENABLE=NO -workspace /Users/cameroncooke/.codex/worktrees/43f4/XcodeBuildMCP/example_projects/iOS_Calculator/CalculatorApp.xcworkspace -scheme CalculatorApp -configuration Debug -destination id=0000FE01-E7A450D374E02B18 -derivedDataPath /Users/cameroncooke/Library/Developer/FlowDeck/DerivedData/XcodeBuildMCP-21505d5e586f -skipPackageUpdates -parallelizeTargets -skipMacroValidation -packageCachePath /Users/cameroncooke/Library/Caches/org.swift.swiftpm -resultBundlePath /Users/cameroncooke/.flowdeck/logs/21505d5e586f/build.xcresult build: +Unable to find a matching destination for the selected simulator. + +The simulator may have been deleted or is unavailable. +Try selecting a different simulator with 'flowdeck config set' or press 'D' in interactive mode. +โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ diff --git a/src/snapshot-tests/__flowdeck_fixtures__/device/build-and-run--success.txt b/src/snapshot-tests/__flowdeck_fixtures__/device/build-and-run--success.txt new file mode 100644 index 00000000..f9fd8b6d --- /dev/null +++ b/src/snapshot-tests/__flowdeck_fixtures__/device/build-and-run--success.txt @@ -0,0 +1,20 @@ +๐Ÿš€ Build Started + + Workspace: CalculatorApp.xcworkspace + Scheme: CalculatorApp + Configuration: Debug + Platform: iOS Device + Device: 0000FE01-E7A450D374E02B18 + Derived Data: /Users/cameroncooke/Library/Developer/FlowDeck/DerivedData/XcodeBuildMCP-21505d5e586f + + โ†’ Resolving Package Graph... + +โœ— Run failed: +โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +Build failed (exit code: 70) +Command: /usr/bin/xcrun xcodebuild DEBUG_INFORMATION_FORMAT=dwarf COMPILER_INDEX_STORE_ENABLE=NO -workspace /Users/cameroncooke/.codex/worktrees/43f4/XcodeBuildMCP/example_projects/iOS_Calculator/CalculatorApp.xcworkspace -scheme CalculatorApp -configuration Debug -destination id=0000FE01-E7A450D374E02B18 -derivedDataPath /Users/cameroncooke/Library/Developer/FlowDeck/DerivedData/XcodeBuildMCP-21505d5e586f -skipPackageUpdates -parallelizeTargets -skipMacroValidation -packageCachePath /Users/cameroncooke/Library/Caches/org.swift.swiftpm -resultBundlePath /Users/cameroncooke/.flowdeck/logs/21505d5e586f/build.xcresult build: +Unable to find a matching destination for the selected simulator. + +The simulator may have been deleted or is unavailable. +Try selecting a different simulator with 'flowdeck config set' or press 'D' in interactive mode. +โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ diff --git a/src/snapshot-tests/__flowdeck_fixtures__/device/install--error-invalid-app.txt b/src/snapshot-tests/__flowdeck_fixtures__/device/install--error-invalid-app.txt new file mode 100644 index 00000000..d3211dbe --- /dev/null +++ b/src/snapshot-tests/__flowdeck_fixtures__/device/install--error-invalid-app.txt @@ -0,0 +1,6 @@ +Installing app on device 00000000-0000-0000-0000-000000000000... +Failed to install app on device 00000000-0000-0000-0000-000000000000: Failed to load provisioning paramter list due to error: Error Domain=com.apple.dt.CoreDeviceError Code=1002 "No provider was found." UserInfo={NSLocalizedDescription=No provider was found.}. +`devicectl manage create` may support a reduced set of arguments. +ERROR: The specified device was not found. (Name: 00000000-0000-0000-0000-000000000000) (com.apple.dt.CoreDeviceError error 1000 (0x3E8)) + DeviceName = 00000000-0000-0000-0000-000000000000 + diff --git a/src/snapshot-tests/__flowdeck_fixtures__/device/install--success-attempt.txt b/src/snapshot-tests/__flowdeck_fixtures__/device/install--success-attempt.txt new file mode 100644 index 00000000..47b0ad28 --- /dev/null +++ b/src/snapshot-tests/__flowdeck_fixtures__/device/install--success-attempt.txt @@ -0,0 +1,6 @@ +Installing app on device 0000FE01-E7A450D374E02B18... +Failed to install app on device 0000FE01-E7A450D374E02B18: Failed to load provisioning paramter list due to error: Error Domain=com.apple.dt.CoreDeviceError Code=1002 "No provider was found." UserInfo={NSLocalizedDescription=No provider was found.}. +`devicectl manage create` may support a reduced set of arguments. +ERROR: CoreDeviceService was unable to locate a device matching the requested device identifier. (DeviceIdentifier: ecid_16691554988071070488) (com.apple.dt.CoreDeviceError error 1011 (0x3F3)) + DeviceIdentifier = ecid_16691554988071070488 + diff --git a/src/snapshot-tests/__flowdeck_fixtures__/device/launch--error-invalid-bundle.txt b/src/snapshot-tests/__flowdeck_fixtures__/device/launch--error-invalid-bundle.txt new file mode 100644 index 00000000..9f0f4d6a --- /dev/null +++ b/src/snapshot-tests/__flowdeck_fixtures__/device/launch--error-invalid-bundle.txt @@ -0,0 +1,6 @@ +Launching app on device 00000000-0000-0000-0000-000000000000... +Failed to launch app on device 00000000-0000-0000-0000-000000000000: Failed to load provisioning paramter list due to error: Error Domain=com.apple.dt.CoreDeviceError Code=1002 "No provider was found." UserInfo={NSLocalizedDescription=No provider was found.}. +`devicectl manage create` may support a reduced set of arguments. +ERROR: The specified device was not found. (Name: 00000000-0000-0000-0000-000000000000) (com.apple.dt.CoreDeviceError error 1000 (0x3E8)) + DeviceName = 00000000-0000-0000-0000-000000000000 + diff --git a/src/snapshot-tests/__flowdeck_fixtures__/device/launch--success.txt b/src/snapshot-tests/__flowdeck_fixtures__/device/launch--success.txt new file mode 100644 index 00000000..fab2ed3d --- /dev/null +++ b/src/snapshot-tests/__flowdeck_fixtures__/device/launch--success.txt @@ -0,0 +1,6 @@ +Launching app on device 0000FE01-E7A450D374E02B18... +Failed to launch app on device 0000FE01-E7A450D374E02B18: Failed to load provisioning paramter list due to error: Error Domain=com.apple.dt.CoreDeviceError Code=1002 "No provider was found." UserInfo={NSLocalizedDescription=No provider was found.}. +`devicectl manage create` may support a reduced set of arguments. +ERROR: CoreDeviceService was unable to locate a device matching the requested device identifier. (DeviceIdentifier: ecid_16691554988071070488) (com.apple.dt.CoreDeviceError error 1011 (0x3F3)) + DeviceIdentifier = ecid_16691554988071070488 + diff --git a/src/snapshot-tests/__flowdeck_fixtures__/device/list--success.txt b/src/snapshot-tests/__flowdeck_fixtures__/device/list--success.txt new file mode 100644 index 00000000..a4d55d1a --- /dev/null +++ b/src/snapshot-tests/__flowdeck_fixtures__/device/list--success.txt @@ -0,0 +1,31 @@ + +macOS +-------------------------------------------------- + ๐Ÿ’ป [โœ“] My Mac + Build and run as native macOS app + ๐Ÿ’ป [โœ“] My Mac Catalyst + Build and run iOS app via Mac Catalyst + +iOS Devices: +-------------------------------------------------- + ๐Ÿ”Œ [โœ“] Cameronโ€™s iPhone 16 Pro Max + OS: 26.3.1 (a) | Type: iPhone + UDID: 00008140-000278A438E3C01C + Connection: USB + ๐Ÿ”Œ [โœ“] iPhone + OS: 26.1 | Type: iPhone + UDID: 0000FE01-E7A450D374E02B18 + Connection: USB + +watchOS Devices: +-------------------------------------------------- + ๐Ÿ”Œ [โœ“] Cameronโ€™s Appleย Watch + OS: 10.6.1 | Type: appleWatch + UDID: 00008006-001202302EA2002E + Connection: USB + ๐Ÿ“ก [โœ“] Cameronโ€™s Appleย Watch + OS: 26.3 | Type: appleWatch + UDID: 00008310-001268591A00E01E + Connection: Network + +Found 4 device(s) physical devices diff --git a/src/snapshot-tests/__flowdeck_fixtures__/device/stop--error-no-app.txt b/src/snapshot-tests/__flowdeck_fixtures__/device/stop--error-no-app.txt new file mode 100644 index 00000000..62109a87 --- /dev/null +++ b/src/snapshot-tests/__flowdeck_fixtures__/device/stop--error-no-app.txt @@ -0,0 +1,2 @@ +โœ— App Not Found: com.nonexistent.app +โœ— The operation couldnโ€™t be completed. (ArgumentParser.ExitCode error 1.) diff --git a/src/snapshot-tests/__flowdeck_fixtures__/device/stop--success.txt b/src/snapshot-tests/__flowdeck_fixtures__/device/stop--success.txt new file mode 100644 index 00000000..e4fd4e58 --- /dev/null +++ b/src/snapshot-tests/__flowdeck_fixtures__/device/stop--success.txt @@ -0,0 +1,2 @@ +โœ— App Not Found: io.sentry.calculatorapp +โœ— The operation couldnโ€™t be completed. (ArgumentParser.ExitCode error 1.) diff --git a/src/snapshot-tests/__flowdeck_fixtures__/device/test--failure.txt b/src/snapshot-tests/__flowdeck_fixtures__/device/test--failure.txt new file mode 100644 index 00000000..d9a4dea6 --- /dev/null +++ b/src/snapshot-tests/__flowdeck_fixtures__/device/test--failure.txt @@ -0,0 +1,47 @@ +Using scheme test configuration: Debug +๐Ÿงช Test: CalculatorApp + Workspace: /Users/cameroncooke/.codex/worktrees/43f4/XcodeBuildMCP/example_projects/iOS_Calculator/CalculatorApp.xcworkspace + Configuration: Debug + Target: Unknown + + โ†’ ๐Ÿ”— Resolving Package Graph + +Error + + error: Unable to find a destination matching the provided destination specifier: +  { id:0000FE01-E7A450D374E02B18 } +  Available destinations for the "CalculatorApp" scheme: +  { platform:macOS, arch:arm64, variant:Designed for [iPad,iPhone], id:00006040-001849590220801C, name:My Mac } +  { platform:iOS, arch:arm64, id:00008140-000278A438E3C01C, name:Cameronโ€™s iPhone 16 Pro Max } +  { platform:iOS, id:dvtdevice-DVTiPhonePlaceholder-iphoneos:placeholder, name:Any iOS Device } +  { platform:iOS Simulator, id:dvtdevice-DVTiOSDeviceSimulatorPlaceholder-iphonesimulator:placeholder, name:Any iOS Simulator Device } +  { platform:visionOS Simulator, arch:arm64, variant:Designed for [iPad,iPhone], id:CE3C0D03-8F60-497A-A3B9-6A80BA997FC2, OS:26.2, name:Apple Vision Pro } +  { platform:iOS Simulator, arch:arm64, id:F57BAB36-B6CD-48D2-859C-80A800F74A0F, OS:26.2, name:iPad (A16) } +  { platform:iOS Simulator, arch:arm64, id:A29F4D19-7093-4C7F-AB11-6B35B79FB628, OS:26.4, name:iPad (A16) } +  { platform:iOS Simulator, arch:arm64, id:F8A5DC33-877D-4822-B2FB-575FA6E5D498, OS:26.2, name:iPad Air 11-inch (M3) } +  { platform:iOS Simulator, arch:arm64, id:AA14371A-F88F-4E79-BC61-29DEEC777D04, OS:26.4, name:iPad Air 11-inch (M4) } +  { platform:iOS Simulator, arch:arm64, id:491E8F59-046E-4086-9CDF-00199C278129, OS:26.2, name:iPad Air 13-inch (M3) } +  { platform:iOS Simulator, arch:arm64, id:5104B6B7-C0EC-464E-B7A2-E4DFCADC8C44, OS:26.4, name:iPad Air 13-inch (M4) } +  { platform:iOS Simulator, arch:arm64, id:FB22CCA5-651D-48B5-BA1E-B36C7EE5D76D, OS:26.2, name:iPad Pro 11-inch (M5) } +  { platform:iOS Simulator, arch:arm64, id:6A7E4E66-A156-41C0-9EFA-F23BFC0A20E9, OS:26.4, name:iPad Pro 11-inch (M5) } +  { platform:iOS Simulator, arch:arm64, id:D1B07075-6396-4D6E-B94F-8BD5182AC5F5, OS:26.2, name:iPad Pro 13-inch (M5) } +  { platform:iOS Simulator, arch:arm64, id:DFA87889-6433-4F86-9CE9-5BC24B835631, OS:26.4, name:iPad Pro 13-inch (M5) } +  { platform:iOS Simulator, arch:arm64, id:17218729-C154-4094-8FBF-B61CE7B47017, OS:26.2, name:iPad mini (A17 Pro) } +  { platform:iOS Simulator, arch:arm64, id:EA43D962-29D3-4997-814F-B167BEA2AE50, OS:26.4, name:iPad mini (A17 Pro) } +  { platform:iOS Simulator, arch:arm64, id:5A6209BA-E989-46EE-BC3A-2E65C53D5E79, OS:26.2, name:iPhone 16e } +  { platform:iOS Simulator, arch:arm64, id:E5D1716B-28D3-45FC-A1DD-41FB13CD58C9, OS:26.2, name:iPhone 17 } +  { platform:iOS Simulator, arch:arm64, id:01DA97D9-3856-46C5-A75E-DDD48100B2DB, OS:26.4, name:iPhone 17 } +  { platform:iOS Simulator, arch:arm64, id:B38FE93D-578B-454B-BE9A-C6FA0CE5F096, OS:26.2, name:iPhone 17 Pro } +  { platform:iOS Simulator, arch:arm64, id:A2C64636-37E9-4B68-B872-E7F0A82A5670, OS:26.4, name:iPhone 17 Pro } +  { platform:iOS Simulator, arch:arm64, id:023BBD4A-62FC-4E96-8B95-845F53F6F0B4, OS:26.2, name:iPhone 17 Pro Max } +  { platform:iOS Simulator, arch:arm64, id:5213C8D8-61D0-4CD7-B468-B463C6206C7D, OS:26.4, name:iPhone 17 Pro Max } +  { platform:iOS Simulator, arch:arm64, id:576E3635-AAB6-481B-9C33-7EAA20B1083F, OS:26.4, name:iPhone 17e } +  { platform:iOS Simulator, arch:arm64, id:BCB58C03-288E-4DFD-A2E8-4E852E91426D, OS:26.2, name:iPhone Air } +  { platform:iOS Simulator, arch:arm64, id:3C76234F-F3E7-4991-85C1-FB302127D84E, OS:26.4, name:iPhone Air } + Hint: No matching destination found +  - Check available simulators: flowdeck simulator list +  - Create a simulator: flowdeck simulator create --name 'iPhone 16' --device-type 'iPhone 16' --runtime 'iOS 18.0' +  - Install missing runtime: flowdeck simulator runtimes install iOS +  - Use --simulator 'iPhone 16' or --simulator none for macOS +  + diff --git a/src/snapshot-tests/__flowdeck_fixtures__/device/test--success.txt b/src/snapshot-tests/__flowdeck_fixtures__/device/test--success.txt new file mode 100644 index 00000000..d9a4dea6 --- /dev/null +++ b/src/snapshot-tests/__flowdeck_fixtures__/device/test--success.txt @@ -0,0 +1,47 @@ +Using scheme test configuration: Debug +๐Ÿงช Test: CalculatorApp + Workspace: /Users/cameroncooke/.codex/worktrees/43f4/XcodeBuildMCP/example_projects/iOS_Calculator/CalculatorApp.xcworkspace + Configuration: Debug + Target: Unknown + + โ†’ ๐Ÿ”— Resolving Package Graph + +Error + + error: Unable to find a destination matching the provided destination specifier: +  { id:0000FE01-E7A450D374E02B18 } +  Available destinations for the "CalculatorApp" scheme: +  { platform:macOS, arch:arm64, variant:Designed for [iPad,iPhone], id:00006040-001849590220801C, name:My Mac } +  { platform:iOS, arch:arm64, id:00008140-000278A438E3C01C, name:Cameronโ€™s iPhone 16 Pro Max } +  { platform:iOS, id:dvtdevice-DVTiPhonePlaceholder-iphoneos:placeholder, name:Any iOS Device } +  { platform:iOS Simulator, id:dvtdevice-DVTiOSDeviceSimulatorPlaceholder-iphonesimulator:placeholder, name:Any iOS Simulator Device } +  { platform:visionOS Simulator, arch:arm64, variant:Designed for [iPad,iPhone], id:CE3C0D03-8F60-497A-A3B9-6A80BA997FC2, OS:26.2, name:Apple Vision Pro } +  { platform:iOS Simulator, arch:arm64, id:F57BAB36-B6CD-48D2-859C-80A800F74A0F, OS:26.2, name:iPad (A16) } +  { platform:iOS Simulator, arch:arm64, id:A29F4D19-7093-4C7F-AB11-6B35B79FB628, OS:26.4, name:iPad (A16) } +  { platform:iOS Simulator, arch:arm64, id:F8A5DC33-877D-4822-B2FB-575FA6E5D498, OS:26.2, name:iPad Air 11-inch (M3) } +  { platform:iOS Simulator, arch:arm64, id:AA14371A-F88F-4E79-BC61-29DEEC777D04, OS:26.4, name:iPad Air 11-inch (M4) } +  { platform:iOS Simulator, arch:arm64, id:491E8F59-046E-4086-9CDF-00199C278129, OS:26.2, name:iPad Air 13-inch (M3) } +  { platform:iOS Simulator, arch:arm64, id:5104B6B7-C0EC-464E-B7A2-E4DFCADC8C44, OS:26.4, name:iPad Air 13-inch (M4) } +  { platform:iOS Simulator, arch:arm64, id:FB22CCA5-651D-48B5-BA1E-B36C7EE5D76D, OS:26.2, name:iPad Pro 11-inch (M5) } +  { platform:iOS Simulator, arch:arm64, id:6A7E4E66-A156-41C0-9EFA-F23BFC0A20E9, OS:26.4, name:iPad Pro 11-inch (M5) } +  { platform:iOS Simulator, arch:arm64, id:D1B07075-6396-4D6E-B94F-8BD5182AC5F5, OS:26.2, name:iPad Pro 13-inch (M5) } +  { platform:iOS Simulator, arch:arm64, id:DFA87889-6433-4F86-9CE9-5BC24B835631, OS:26.4, name:iPad Pro 13-inch (M5) } +  { platform:iOS Simulator, arch:arm64, id:17218729-C154-4094-8FBF-B61CE7B47017, OS:26.2, name:iPad mini (A17 Pro) } +  { platform:iOS Simulator, arch:arm64, id:EA43D962-29D3-4997-814F-B167BEA2AE50, OS:26.4, name:iPad mini (A17 Pro) } +  { platform:iOS Simulator, arch:arm64, id:5A6209BA-E989-46EE-BC3A-2E65C53D5E79, OS:26.2, name:iPhone 16e } +  { platform:iOS Simulator, arch:arm64, id:E5D1716B-28D3-45FC-A1DD-41FB13CD58C9, OS:26.2, name:iPhone 17 } +  { platform:iOS Simulator, arch:arm64, id:01DA97D9-3856-46C5-A75E-DDD48100B2DB, OS:26.4, name:iPhone 17 } +  { platform:iOS Simulator, arch:arm64, id:B38FE93D-578B-454B-BE9A-C6FA0CE5F096, OS:26.2, name:iPhone 17 Pro } +  { platform:iOS Simulator, arch:arm64, id:A2C64636-37E9-4B68-B872-E7F0A82A5670, OS:26.4, name:iPhone 17 Pro } +  { platform:iOS Simulator, arch:arm64, id:023BBD4A-62FC-4E96-8B95-845F53F6F0B4, OS:26.2, name:iPhone 17 Pro Max } +  { platform:iOS Simulator, arch:arm64, id:5213C8D8-61D0-4CD7-B468-B463C6206C7D, OS:26.4, name:iPhone 17 Pro Max } +  { platform:iOS Simulator, arch:arm64, id:576E3635-AAB6-481B-9C33-7EAA20B1083F, OS:26.4, name:iPhone 17e } +  { platform:iOS Simulator, arch:arm64, id:BCB58C03-288E-4DFD-A2E8-4E852E91426D, OS:26.2, name:iPhone Air } +  { platform:iOS Simulator, arch:arm64, id:3C76234F-F3E7-4991-85C1-FB302127D84E, OS:26.4, name:iPhone Air } + Hint: No matching destination found +  - Check available simulators: flowdeck simulator list +  - Create a simulator: flowdeck simulator create --name 'iPhone 16' --device-type 'iPhone 16' --runtime 'iOS 18.0' +  - Install missing runtime: flowdeck simulator runtimes install iOS +  - Use --simulator 'iPhone 16' or --simulator none for macOS +  + diff --git a/src/snapshot-tests/__flowdeck_fixtures__/logging/logs--success.txt b/src/snapshot-tests/__flowdeck_fixtures__/logging/logs--success.txt new file mode 100644 index 00000000..f128067e --- /dev/null +++ b/src/snapshot-tests/__flowdeck_fixtures__/logging/logs--success.txt @@ -0,0 +1,7 @@ +โœ— Multiple apps (2) found matching 'io.sentry.calculatorapp'. + +Be more specific by using the short ID (e.g., abc123) +instead of the bundle ID. + +To see app IDs: + flowdeck apps diff --git a/src/snapshot-tests/__flowdeck_fixtures__/macos/build--error-wrong-scheme.txt b/src/snapshot-tests/__flowdeck_fixtures__/macos/build--error-wrong-scheme.txt new file mode 100644 index 00000000..b3773758 --- /dev/null +++ b/src/snapshot-tests/__flowdeck_fixtures__/macos/build--error-wrong-scheme.txt @@ -0,0 +1,27 @@ +๐Ÿš€ Build Started + + Workspace: MCPTest.xcodeproj + Scheme: NONEXISTENT + Configuration: Debug + Platform: macOS + Derived Data: /Users/cameroncooke/Library/Developer/FlowDeck/DerivedData/XcodeBuildMCP-21505d5e586f + + +โœ— Build failed: +โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +Build failed (exit code: 65) +Command: /usr/bin/xcrun xcodebuild DEBUG_INFORMATION_FORMAT=dwarf COMPILER_INDEX_STORE_ENABLE=NO -project /Users/cameroncooke/.codex/worktrees/43f4/XcodeBuildMCP/example_projects/macOS/MCPTest.xcodeproj -scheme NONEXISTENT -configuration Debug -derivedDataPath /Users/cameroncooke/Library/Developer/FlowDeck/DerivedData/XcodeBuildMCP-21505d5e586f -skipPackageUpdates -parallelizeTargets -skipMacroValidation -packageCachePath /Users/cameroncooke/Library/Caches/org.swift.swiftpm -resultBundlePath /Users/cameroncooke/.flowdeck/logs/21505d5e586f/build.xcresult build: + +Xcode Build Errors (1): + + โœ— The project named "MCPTest" does not contain a scheme named "NONEXISTENT". The "-list" option can be used to find the names of the schemes in the project. + + +Hint: Scheme not found in project + โ€ข List schemes: flowdeck project schemes -w + โ€ข Check scheme is shared: Manage Schemes > Shared checkbox + โ€ข Verify scheme name spelling (case-sensitive) + โ€ข Recreate scheme: Product > Scheme > New Scheme + +Full build log: /Users/cameroncooke/.flowdeck/logs/21505d5e586f/build.log +โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ diff --git a/src/snapshot-tests/__flowdeck_fixtures__/macos/build--success.txt b/src/snapshot-tests/__flowdeck_fixtures__/macos/build--success.txt new file mode 100644 index 00000000..0783681b --- /dev/null +++ b/src/snapshot-tests/__flowdeck_fixtures__/macos/build--success.txt @@ -0,0 +1,12 @@ +๐Ÿš€ Build Started + + Workspace: MCPTest.xcodeproj + Scheme: MCPTest + Configuration: Debug + Platform: macOS + Derived Data: /Users/cameroncooke/Library/Developer/FlowDeck/DerivedData/XcodeBuildMCP-21505d5e586f + + โ†’ Compiling... + +โœ“ Build Completed + diff --git a/src/snapshot-tests/__flowdeck_fixtures__/macos/build-and-run--error-wrong-scheme.txt b/src/snapshot-tests/__flowdeck_fixtures__/macos/build-and-run--error-wrong-scheme.txt new file mode 100644 index 00000000..020189cd --- /dev/null +++ b/src/snapshot-tests/__flowdeck_fixtures__/macos/build-and-run--error-wrong-scheme.txt @@ -0,0 +1,29 @@ +๐Ÿ”จ Building NONEXISTENT for macOS... + +๐Ÿš€ Build Started + + Workspace: MCPTest.xcodeproj + Scheme: NONEXISTENT + Configuration: Debug + Platform: macOS + Derived Data: /Users/cameroncooke/Library/Developer/FlowDeck/DerivedData/XcodeBuildMCP-21505d5e586f + + +โœ— Run failed: +โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +Build failed (exit code: 65) +Command: /usr/bin/xcrun xcodebuild DEBUG_INFORMATION_FORMAT=dwarf COMPILER_INDEX_STORE_ENABLE=NO -project /Users/cameroncooke/.codex/worktrees/43f4/XcodeBuildMCP/example_projects/macOS/MCPTest.xcodeproj -scheme NONEXISTENT -configuration Debug -derivedDataPath /Users/cameroncooke/Library/Developer/FlowDeck/DerivedData/XcodeBuildMCP-21505d5e586f -skipPackageUpdates -parallelizeTargets -skipMacroValidation -packageCachePath /Users/cameroncooke/Library/Caches/org.swift.swiftpm -resultBundlePath /Users/cameroncooke/.flowdeck/logs/21505d5e586f/build.xcresult build: + +Xcode Build Errors (1): + + โœ— The project named "MCPTest" does not contain a scheme named "NONEXISTENT". The "-list" option can be used to find the names of the schemes in the project. + + +Hint: Scheme not found in project + โ€ข List schemes: flowdeck project schemes -w + โ€ข Check scheme is shared: Manage Schemes > Shared checkbox + โ€ข Verify scheme name spelling (case-sensitive) + โ€ข Recreate scheme: Product > Scheme > New Scheme + +Full build log: /Users/cameroncooke/.flowdeck/logs/21505d5e586f/build.log +โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ diff --git a/src/snapshot-tests/__flowdeck_fixtures__/macos/build-and-run--success.txt b/src/snapshot-tests/__flowdeck_fixtures__/macos/build-and-run--success.txt new file mode 100644 index 00000000..a7a8346b --- /dev/null +++ b/src/snapshot-tests/__flowdeck_fixtures__/macos/build-and-run--success.txt @@ -0,0 +1,30 @@ +๐Ÿ”จ Building MCPTest for macOS... + +๐Ÿš€ Build Started + + Workspace: MCPTest.xcodeproj + Scheme: MCPTest + Configuration: Debug + Platform: macOS + Derived Data: /Users/cameroncooke/Library/Developer/FlowDeck/DerivedData/XcodeBuildMCP-21505d5e586f + + +โœ“ Build Completed + +๐Ÿš€ Launching macOS app... + + +โœ“ App Launched Successfully + + App ID: A937FD39 + Process ID: 11536 + Bundle ID: io.sentry.MCPTest + Target: My Mac + Build Logs: /Users/cameroncooke/.flowdeck/logs/21505d5e586f/build.log + Runtime Logs: /Users/cameroncooke/Library/Developer/FlowDeck/DerivedData/XcodeBuildMCP-21505d5e586f/Build/Logs/io.sentry.MCPTest_A937FD39-4EAF-462C-870A-BF357040F58D.log + + + Use 'flowdeck apps' to list running apps + Use 'flowdeck stop A937FD39' to stop this app + Use 'flowdeck logs A937FD39' to stream logs + diff --git a/src/snapshot-tests/__flowdeck_fixtures__/macos/clean--success.txt b/src/snapshot-tests/__flowdeck_fixtures__/macos/clean--success.txt new file mode 100644 index 00000000..1b6d96de --- /dev/null +++ b/src/snapshot-tests/__flowdeck_fixtures__/macos/clean--success.txt @@ -0,0 +1,10 @@ +๐Ÿงน Clean Started + + Workspace: MCPTest.xcodeproj + Scheme: MCPTest + Derived Data: /Users/cameroncooke/Library/Developer/FlowDeck/DerivedData/XcodeBuildMCP-21505d5e586f + + โ†’ ๐Ÿงน Cleaning build folder... + +โœ“ Clean Completed + diff --git a/src/snapshot-tests/__flowdeck_fixtures__/macos/stop--error-no-app.txt b/src/snapshot-tests/__flowdeck_fixtures__/macos/stop--error-no-app.txt new file mode 100644 index 00000000..62109a87 --- /dev/null +++ b/src/snapshot-tests/__flowdeck_fixtures__/macos/stop--error-no-app.txt @@ -0,0 +1,2 @@ +โœ— App Not Found: com.nonexistent.app +โœ— The operation couldnโ€™t be completed. (ArgumentParser.ExitCode error 1.) diff --git a/src/snapshot-tests/__flowdeck_fixtures__/macos/stop--success.txt b/src/snapshot-tests/__flowdeck_fixtures__/macos/stop--success.txt new file mode 100644 index 00000000..22b4c865 --- /dev/null +++ b/src/snapshot-tests/__flowdeck_fixtures__/macos/stop--success.txt @@ -0,0 +1,3 @@ +Stopping app: ACFBF7F0 +โœ“ Stopped io.sentry.MCPTest (ACFBF7F0) + Killed: app process, launch process diff --git a/src/snapshot-tests/__flowdeck_fixtures__/macos/test--error-wrong-scheme.txt b/src/snapshot-tests/__flowdeck_fixtures__/macos/test--error-wrong-scheme.txt new file mode 100644 index 00000000..da2ffa10 --- /dev/null +++ b/src/snapshot-tests/__flowdeck_fixtures__/macos/test--error-wrong-scheme.txt @@ -0,0 +1,16 @@ +๐Ÿงช Test: NONEXISTENT + Workspace: /Users/cameroncooke/.codex/worktrees/43f4/XcodeBuildMCP/example_projects/macOS/MCPTest.xcodeproj + Configuration: Debug + Target: My Mac + + +Error + + error: The project named "MCPTest" does not contain a scheme named "NONEXISTENT". The "-list" option can be used to find the names of the schemes in the project. + Hint: Scheme not found in project +  - List schemes: flowdeck project schemes -w  +  - Check scheme is shared: Manage Schemes > Shared checkbox +  - Verify scheme name spelling (case-sensitive) +  - Recreate scheme: Product > Scheme > New Scheme +  + diff --git a/src/snapshot-tests/__flowdeck_fixtures__/macos/test--failure.txt b/src/snapshot-tests/__flowdeck_fixtures__/macos/test--failure.txt new file mode 100644 index 00000000..bdf71e63 --- /dev/null +++ b/src/snapshot-tests/__flowdeck_fixtures__/macos/test--failure.txt @@ -0,0 +1,26 @@ +Using scheme test configuration: Debug +๐Ÿงช Test: MCPTest + Workspace: /Users/cameroncooke/.codex/worktrees/43f4/XcodeBuildMCP/example_projects/macOS/MCPTest.xcodeproj + Configuration: Debug + Target: My Mac + + โ†’ Running Tests + +Failed Tests + +MCPTestTests + โœ— deliberateFailure() (0.000s) + โ””โ”€ Test failed + +Test Summary + +โ•ญโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎ +โ”‚ Total: 2 โ”‚ +โ”‚ Passed: 1 โ”‚ +โ”‚ Failed: 1 โ”‚ +โ”‚ Skipped: 0 โ”‚ +โ”‚ Duration: 12.78s โ”‚ +โ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏ + +โœ— 1 test(s) failed. + diff --git a/src/snapshot-tests/__flowdeck_fixtures__/macos/test--success.txt b/src/snapshot-tests/__flowdeck_fixtures__/macos/test--success.txt new file mode 100644 index 00000000..cb4cbc6e --- /dev/null +++ b/src/snapshot-tests/__flowdeck_fixtures__/macos/test--success.txt @@ -0,0 +1,19 @@ +Using scheme test configuration: Debug +๐Ÿงช Test: MCPTest + Workspace: /Users/cameroncooke/.codex/worktrees/43f4/XcodeBuildMCP/example_projects/macOS/MCPTest.xcodeproj + Configuration: Debug + Target: My Mac + + โ†’ โš’๏ธ Compiling + +The selected scheme has no tests. Make sure you have selected a valid test target. + +If this seems unexpected, check: + - The simulator/device is not available or was deleted + - A build or configuration error occurred + +Try: + - Check available simulators: flowdeck simulator list + - Run with --verbose to see detailed output + - Verify scheme has test targets: flowdeck test discover + diff --git a/src/snapshot-tests/__flowdeck_fixtures__/project-discovery/list-schemes--error-invalid-workspace.txt b/src/snapshot-tests/__flowdeck_fixtures__/project-discovery/list-schemes--error-invalid-workspace.txt new file mode 100644 index 00000000..957f555a --- /dev/null +++ b/src/snapshot-tests/__flowdeck_fixtures__/project-discovery/list-schemes--error-invalid-workspace.txt @@ -0,0 +1 @@ +Path not found: /nonexistent/path/Fake.xcworkspace diff --git a/src/snapshot-tests/__flowdeck_fixtures__/project-discovery/list-schemes--success.txt b/src/snapshot-tests/__flowdeck_fixtures__/project-discovery/list-schemes--success.txt new file mode 100644 index 00000000..14e082e7 --- /dev/null +++ b/src/snapshot-tests/__flowdeck_fixtures__/project-discovery/list-schemes--success.txt @@ -0,0 +1,10 @@ +Schemes in /Users/cameroncooke/.codex/worktrees/43f4/XcodeBuildMCP/example_projects/iOS_Calculator/CalculatorApp.xcworkspace: + +iOS: + CalculatorApp + +Packages: + CalculatorAppFeature + CalculatorAppFeatureTests + +Found 3 scheme(s) diff --git a/src/snapshot-tests/__flowdeck_fixtures__/project-discovery/show-build-settings--success.txt b/src/snapshot-tests/__flowdeck_fixtures__/project-discovery/show-build-settings--success.txt new file mode 100644 index 00000000..c1d9c287 --- /dev/null +++ b/src/snapshot-tests/__flowdeck_fixtures__/project-discovery/show-build-settings--success.txt @@ -0,0 +1,6 @@ +Build Configurations in /Users/cameroncooke/.codex/worktrees/43f4/XcodeBuildMCP/example_projects/iOS_Calculator/CalculatorApp.xcworkspace: + + Debug + Release + +Found 2 configuration(s) diff --git a/src/snapshot-tests/__flowdeck_fixtures__/project-scaffolding/scaffold-ios--error-existing.txt b/src/snapshot-tests/__flowdeck_fixtures__/project-scaffolding/scaffold-ios--error-existing.txt new file mode 100644 index 00000000..91852b0a --- /dev/null +++ b/src/snapshot-tests/__flowdeck_fixtures__/project-scaffolding/scaffold-ios--error-existing.txt @@ -0,0 +1,4 @@ +Creating SnapshotTestApp... +Output directory already exists: /var/folders/_t/2njffz894t57qpp76v1sw__h0000gn/T/fd-scaffold-EOFXZY/ios-existing/SnapshotTestApp + +Choose a different name or remove the existing directory. diff --git a/src/snapshot-tests/__flowdeck_fixtures__/project-scaffolding/scaffold-ios--success.txt b/src/snapshot-tests/__flowdeck_fixtures__/project-scaffolding/scaffold-ios--success.txt new file mode 100644 index 00000000..7b4da6de --- /dev/null +++ b/src/snapshot-tests/__flowdeck_fixtures__/project-scaffolding/scaffold-ios--success.txt @@ -0,0 +1,2 @@ +Creating SnapshotTestApp... +Created SnapshotTestApp at /var/folders/_t/2njffz894t57qpp76v1sw__h0000gn/T/fd-scaffold-EOFXZY/ios diff --git a/src/snapshot-tests/__flowdeck_fixtures__/project-scaffolding/scaffold-macos--error-existing.txt b/src/snapshot-tests/__flowdeck_fixtures__/project-scaffolding/scaffold-macos--error-existing.txt new file mode 100644 index 00000000..66c359b9 --- /dev/null +++ b/src/snapshot-tests/__flowdeck_fixtures__/project-scaffolding/scaffold-macos--error-existing.txt @@ -0,0 +1,4 @@ +Creating SnapshotTestMacApp... +Output directory already exists: /var/folders/_t/2njffz894t57qpp76v1sw__h0000gn/T/fd-scaffold-EOFXZY/macos-existing/SnapshotTestMacApp + +Choose a different name or remove the existing directory. diff --git a/src/snapshot-tests/__flowdeck_fixtures__/project-scaffolding/scaffold-macos--success.txt b/src/snapshot-tests/__flowdeck_fixtures__/project-scaffolding/scaffold-macos--success.txt new file mode 100644 index 00000000..2c37528f --- /dev/null +++ b/src/snapshot-tests/__flowdeck_fixtures__/project-scaffolding/scaffold-macos--success.txt @@ -0,0 +1,2 @@ +Creating SnapshotTestMacApp... +Created SnapshotTestMacApp at /var/folders/_t/2njffz894t57qpp76v1sw__h0000gn/T/fd-scaffold-EOFXZY/macos diff --git a/src/snapshot-tests/__flowdeck_fixtures__/session-management/session-clear-defaults--success.txt b/src/snapshot-tests/__flowdeck_fixtures__/session-management/session-clear-defaults--success.txt new file mode 100644 index 00000000..49314cab --- /dev/null +++ b/src/snapshot-tests/__flowdeck_fixtures__/session-management/session-clear-defaults--success.txt @@ -0,0 +1,3 @@ +โœ“ Project configuration reset + +Run: flowdeck config set -w -s diff --git a/src/snapshot-tests/__flowdeck_fixtures__/session-management/session-set-defaults--success.txt b/src/snapshot-tests/__flowdeck_fixtures__/session-management/session-set-defaults--success.txt new file mode 100644 index 00000000..6541810d --- /dev/null +++ b/src/snapshot-tests/__flowdeck_fixtures__/session-management/session-set-defaults--success.txt @@ -0,0 +1,6 @@ +โœ“ Project configuration saved + + Workspace: CalculatorApp.xcworkspace + Scheme: CalculatorApp + Target: My Mac + Configuration: Debug diff --git a/src/snapshot-tests/__flowdeck_fixtures__/session-management/session-show-defaults--success.txt b/src/snapshot-tests/__flowdeck_fixtures__/session-management/session-show-defaults--success.txt new file mode 100644 index 00000000..5719e5e6 --- /dev/null +++ b/src/snapshot-tests/__flowdeck_fixtures__/session-management/session-show-defaults--success.txt @@ -0,0 +1,6 @@ +Current project configuration: + Workspace: /Users/cameroncooke/.codex/worktrees/43f4/XcodeBuildMCP/example_projects/iOS_Calculator/CalculatorApp.xcworkspace + Scheme: CalculatorApp + Configuration: Debug + Target: My Mac + Target Type: macOS diff --git a/src/snapshot-tests/__flowdeck_fixtures__/session-management/session-sync-xcode-defaults--success.txt b/src/snapshot-tests/__flowdeck_fixtures__/session-management/session-sync-xcode-defaults--success.txt new file mode 100644 index 00000000..4565ace6 --- /dev/null +++ b/src/snapshot-tests/__flowdeck_fixtures__/session-management/session-sync-xcode-defaults--success.txt @@ -0,0 +1,27 @@ + +Project Context (Discovery Mode) +โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +No saved config found for: /Users/cameroncooke/.codex/worktrees/43f4/XcodeBuildMCP + +Multiple projects/workspaces found (3). +Choose one and save it with: + flowdeck config set -w -s + +Discovered: + โ€ข MCPTest.xcodeproj + Path: /Users/cameroncooke/.codex/worktrees/43f4/XcodeBuildMCP/example_projects/iOS/MCPTest.xcodeproj + Schemes (1): MCPTest + Configs: Debug, Release + Use: flowdeck config set -w "/Users/cameroncooke/.codex/worktrees/43f4/XcodeBuildMCP/example_projects/iOS/MCPTest.xcodeproj" -s + โ€ข CalculatorApp.xcodeproj + Path: /Users/cameroncooke/.codex/worktrees/43f4/XcodeBuildMCP/example_projects/iOS_Calculator/CalculatorApp.xcodeproj + Schemes (3): CalculatorApp, CalculatorAppFeature, CalculatorAppFeatureTests + Configs: Debug, Release + Use: flowdeck config set -w "/Users/cameroncooke/.codex/worktrees/43f4/XcodeBuildMCP/example_projects/iOS_Calculator/CalculatorApp.xcodeproj" -s + โ€ข MCPTest.xcodeproj + Path: /Users/cameroncooke/.codex/worktrees/43f4/XcodeBuildMCP/example_projects/macOS/MCPTest.xcodeproj + Schemes (1): MCPTest + Configs: Debug, Release + Use: flowdeck config set -w "/Users/cameroncooke/.codex/worktrees/43f4/XcodeBuildMCP/example_projects/macOS/MCPTest.xcodeproj" -s + diff --git a/src/snapshot-tests/__flowdeck_fixtures__/simulator-management/boot--error-invalid-id.txt b/src/snapshot-tests/__flowdeck_fixtures__/simulator-management/boot--error-invalid-id.txt new file mode 100644 index 00000000..a22c043a --- /dev/null +++ b/src/snapshot-tests/__flowdeck_fixtures__/simulator-management/boot--error-invalid-id.txt @@ -0,0 +1,3 @@ +๐Ÿš€ Booting simulator (00000000-0000-0000-0000-000000000000)... +Simulator operation failed: Failed to boot simulator: Invalid device or device pair: 00000000-0000-0000-0000-000000000000 + diff --git a/src/snapshot-tests/__flowdeck_fixtures__/simulator-management/erase--error-invalid-id.txt b/src/snapshot-tests/__flowdeck_fixtures__/simulator-management/erase--error-invalid-id.txt new file mode 100644 index 00000000..212b7811 --- /dev/null +++ b/src/snapshot-tests/__flowdeck_fixtures__/simulator-management/erase--error-invalid-id.txt @@ -0,0 +1,4 @@ +๐Ÿ—‘๏ธ Erasing simulator 00000000-0000-0000-0000-000000000000... +โš ๏ธ This will delete all data and settings from the simulator. +Simulator operation failed: Failed to erase simulator: Invalid device: 00000000-0000-0000-0000-000000000000 + diff --git a/src/snapshot-tests/__flowdeck_fixtures__/simulator-management/list--success.txt b/src/snapshot-tests/__flowdeck_fixtures__/simulator-management/list--success.txt new file mode 100644 index 00000000..56677b29 --- /dev/null +++ b/src/snapshot-tests/__flowdeck_fixtures__/simulator-management/list--success.txt @@ -0,0 +1,74 @@ + +xrOS 26 2 26.2 + Apple Vision Pro + CE3C0D03-8F60-497A-A3B9-6A80BA997FC2 + +iOS 26.4 + iPad (A16) + A29F4D19-7093-4C7F-AB11-6B35B79FB628 + iPad Air 11-inch (M4) + AA14371A-F88F-4E79-BC61-29DEEC777D04 + iPad Air 13-inch (M4) + 5104B6B7-C0EC-464E-B7A2-E4DFCADC8C44 + iPad Pro 11-inch (M5) + 6A7E4E66-A156-41C0-9EFA-F23BFC0A20E9 + iPad Pro 13-inch (M5) + DFA87889-6433-4F86-9CE9-5BC24B835631 + iPad mini (A17 Pro) + EA43D962-29D3-4997-814F-B167BEA2AE50 + โœ“ iPhone 17 (Booted) + 01DA97D9-3856-46C5-A75E-DDD48100B2DB + iPhone 17 Pro + A2C64636-37E9-4B68-B872-E7F0A82A5670 + iPhone 17 Pro Max + 5213C8D8-61D0-4CD7-B468-B463C6206C7D + iPhone 17e + 576E3635-AAB6-481B-9C33-7EAA20B1083F + iPhone Air + 3C76234F-F3E7-4991-85C1-FB302127D84E + +watchOS 26.2 + Apple Watch SE 3 (40mm) + 07379F3B-614F-4E17-A56A-7897A9DB29DD + Apple Watch SE 3 (44mm) + BE81BC98-A2B0-4FCD-963C-B71717B21084 + Apple Watch Series 11 (42mm) + 117718C4-32F4-45F4-A59E-0EA9B882B8EC + Apple Watch Series 11 (46mm) + B9290624-12D3-4CB2-87E5-19C9464A2A89 + Apple Watch Ultra 3 (49mm) + FA216548-E7AF-4739-A3D8-489A6D35D1B5 + +iOS 26.2 + iPad (A16) + F57BAB36-B6CD-48D2-859C-80A800F74A0F + iPad Air 11-inch (M3) + F8A5DC33-877D-4822-B2FB-575FA6E5D498 + iPad Air 13-inch (M3) + 491E8F59-046E-4086-9CDF-00199C278129 + iPad Pro 11-inch (M5) + FB22CCA5-651D-48B5-BA1E-B36C7EE5D76D + iPad Pro 13-inch (M5) + D1B07075-6396-4D6E-B94F-8BD5182AC5F5 + iPad mini (A17 Pro) + 17218729-C154-4094-8FBF-B61CE7B47017 + iPhone 16e + 5A6209BA-E989-46EE-BC3A-2E65C53D5E79 + iPhone 17 + E5D1716B-28D3-45FC-A1DD-41FB13CD58C9 + iPhone 17 Pro + B38FE93D-578B-454B-BE9A-C6FA0CE5F096 + iPhone 17 Pro Max + 023BBD4A-62FC-4E96-8B95-845F53F6F0B4 + iPhone Air + BCB58C03-288E-4DFD-A2E8-4E852E91426D + +tvOS 26.2 + Apple TV + A7A45FF5-1ED7-4E7C-A109-8724D7BFB702 + Apple TV 4K (3rd generation) + 9CDC6CD8-17C7-4804-9E3D-D0A54B6F4A80 + Apple TV 4K (3rd generation) (at 1080p) + 85B0BDC1-6BEA-4BDC-90EC-2CB2E084F573 + +Total: 31 simulator(s), 1 booted diff --git a/src/snapshot-tests/__flowdeck_fixtures__/simulator-management/open--success.txt b/src/snapshot-tests/__flowdeck_fixtures__/simulator-management/open--success.txt new file mode 100644 index 00000000..c20b51f0 --- /dev/null +++ b/src/snapshot-tests/__flowdeck_fixtures__/simulator-management/open--success.txt @@ -0,0 +1,2 @@ +๐Ÿ“ฑ Opening Simulator.app... +โœ“ Simulator.app opened diff --git a/src/snapshot-tests/__flowdeck_fixtures__/simulator-management/set-appearance--error-invalid-simulator.txt b/src/snapshot-tests/__flowdeck_fixtures__/simulator-management/set-appearance--error-invalid-simulator.txt new file mode 100644 index 00000000..7605027d --- /dev/null +++ b/src/snapshot-tests/__flowdeck_fixtures__/simulator-management/set-appearance--error-invalid-simulator.txt @@ -0,0 +1 @@ +โœ— Failed to set simulator appearance: Invalid device: 00000000-0000-0000-0000-000000000000 diff --git a/src/snapshot-tests/__flowdeck_fixtures__/simulator-management/set-appearance--success.txt b/src/snapshot-tests/__flowdeck_fixtures__/simulator-management/set-appearance--success.txt new file mode 100644 index 00000000..f1145805 --- /dev/null +++ b/src/snapshot-tests/__flowdeck_fixtures__/simulator-management/set-appearance--success.txt @@ -0,0 +1 @@ +โœ“ Set simulator appearance to dark diff --git a/src/snapshot-tests/__flowdeck_fixtures__/simulator-management/set-location--error-invalid-simulator.txt b/src/snapshot-tests/__flowdeck_fixtures__/simulator-management/set-location--error-invalid-simulator.txt new file mode 100644 index 00000000..b7a86e20 --- /dev/null +++ b/src/snapshot-tests/__flowdeck_fixtures__/simulator-management/set-location--error-invalid-simulator.txt @@ -0,0 +1,2 @@ +โœ— Failed to set location: Invalid device: 00000000-0000-0000-0000-000000000000 + diff --git a/src/snapshot-tests/__flowdeck_fixtures__/simulator-management/set-location--success.txt b/src/snapshot-tests/__flowdeck_fixtures__/simulator-management/set-location--success.txt new file mode 100644 index 00000000..e6b248bc --- /dev/null +++ b/src/snapshot-tests/__flowdeck_fixtures__/simulator-management/set-location--success.txt @@ -0,0 +1 @@ +โœ“ Location set to 37.7749, -122.4194 diff --git a/src/snapshot-tests/__flowdeck_fixtures__/simulator/build--error-wrong-scheme.txt b/src/snapshot-tests/__flowdeck_fixtures__/simulator/build--error-wrong-scheme.txt new file mode 100644 index 00000000..98547981 --- /dev/null +++ b/src/snapshot-tests/__flowdeck_fixtures__/simulator/build--error-wrong-scheme.txt @@ -0,0 +1,29 @@ +๐Ÿš€ Build Started + + Workspace: CalculatorApp.xcworkspace + Scheme: NONEXISTENT + Configuration: Debug + Platform: iOS Simulator + Simulator: 01DA97D9-3856-46C5-A75E-DDD48100B2DB + Derived Data: /Users/cameroncooke/Library/Developer/FlowDeck/DerivedData/XcodeBuildMCP-21505d5e586f + + โ†’ Resolving Package Graph... + +โœ— Build failed: +โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +Build failed (exit code: 65) +Command: /usr/bin/xcrun xcodebuild DEBUG_INFORMATION_FORMAT=dwarf COMPILER_INDEX_STORE_ENABLE=NO -workspace /Users/cameroncooke/.codex/worktrees/43f4/XcodeBuildMCP/example_projects/iOS_Calculator/CalculatorApp.xcworkspace -scheme NONEXISTENT -configuration Debug -destination id=01DA97D9-3856-46C5-A75E-DDD48100B2DB -derivedDataPath /Users/cameroncooke/Library/Developer/FlowDeck/DerivedData/XcodeBuildMCP-21505d5e586f -skipPackageUpdates -parallelizeTargets -skipMacroValidation -packageCachePath /Users/cameroncooke/Library/Caches/org.swift.swiftpm -resultBundlePath /Users/cameroncooke/.flowdeck/logs/21505d5e586f/build.xcresult build: + +Xcode Build Errors (1): + + โœ— The workspace named "CalculatorApp" does not contain a scheme named "NONEXISTENT". The "-list" option can be used to find the names of the schemes in the workspace. + + +Hint: Scheme not found in project + โ€ข List schemes: flowdeck project schemes -w + โ€ข Check scheme is shared: Manage Schemes > Shared checkbox + โ€ข Verify scheme name spelling (case-sensitive) + โ€ข Recreate scheme: Product > Scheme > New Scheme + +Full build log: /Users/cameroncooke/.flowdeck/logs/21505d5e586f/build.log +โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ diff --git a/src/snapshot-tests/__flowdeck_fixtures__/simulator/build--success.txt b/src/snapshot-tests/__flowdeck_fixtures__/simulator/build--success.txt new file mode 100644 index 00000000..41ba4218 --- /dev/null +++ b/src/snapshot-tests/__flowdeck_fixtures__/simulator/build--success.txt @@ -0,0 +1,14 @@ +๐Ÿš€ Build Started + + Workspace: CalculatorApp.xcworkspace + Scheme: CalculatorApp + Configuration: Debug + Platform: iOS Simulator + Simulator: 01DA97D9-3856-46C5-A75E-DDD48100B2DB + Derived Data: /Users/cameroncooke/Library/Developer/FlowDeck/DerivedData/XcodeBuildMCP-21505d5e586f + + โ†’ Resolving Package Graph... + โ†’ Compiling... + +โœ“ Build Completed + diff --git a/src/snapshot-tests/__flowdeck_fixtures__/simulator/build-and-run--error-wrong-scheme.txt b/src/snapshot-tests/__flowdeck_fixtures__/simulator/build-and-run--error-wrong-scheme.txt new file mode 100644 index 00000000..dc43965f --- /dev/null +++ b/src/snapshot-tests/__flowdeck_fixtures__/simulator/build-and-run--error-wrong-scheme.txt @@ -0,0 +1,29 @@ +๐Ÿš€ Build Started + + Workspace: CalculatorApp.xcworkspace + Scheme: NONEXISTENT + Configuration: Debug + Platform: iOS Simulator + Simulator: 01DA97D9-3856-46C5-A75E-DDD48100B2DB + Derived Data: /Users/cameroncooke/Library/Developer/FlowDeck/DerivedData/XcodeBuildMCP-21505d5e586f + + โ†’ Resolving Package Graph... + +โœ— Run failed: +โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +Build failed (exit code: 65) +Command: /usr/bin/xcrun xcodebuild DEBUG_INFORMATION_FORMAT=dwarf COMPILER_INDEX_STORE_ENABLE=NO -workspace /Users/cameroncooke/.codex/worktrees/43f4/XcodeBuildMCP/example_projects/iOS_Calculator/CalculatorApp.xcworkspace -scheme NONEXISTENT -configuration Debug -destination id=01DA97D9-3856-46C5-A75E-DDD48100B2DB -derivedDataPath /Users/cameroncooke/Library/Developer/FlowDeck/DerivedData/XcodeBuildMCP-21505d5e586f -skipPackageUpdates -parallelizeTargets -skipMacroValidation -packageCachePath /Users/cameroncooke/Library/Caches/org.swift.swiftpm -resultBundlePath /Users/cameroncooke/.flowdeck/logs/21505d5e586f/build.xcresult build: + +Xcode Build Errors (1): + + โœ— The workspace named "CalculatorApp" does not contain a scheme named "NONEXISTENT". The "-list" option can be used to find the names of the schemes in the workspace. + + +Hint: Scheme not found in project + โ€ข List schemes: flowdeck project schemes -w + โ€ข Check scheme is shared: Manage Schemes > Shared checkbox + โ€ข Verify scheme name spelling (case-sensitive) + โ€ข Recreate scheme: Product > Scheme > New Scheme + +Full build log: /Users/cameroncooke/.flowdeck/logs/21505d5e586f/build.log +โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ diff --git a/src/snapshot-tests/__flowdeck_fixtures__/simulator/build-and-run--success.txt b/src/snapshot-tests/__flowdeck_fixtures__/simulator/build-and-run--success.txt new file mode 100644 index 00000000..45e867a1 --- /dev/null +++ b/src/snapshot-tests/__flowdeck_fixtures__/simulator/build-and-run--success.txt @@ -0,0 +1,38 @@ +๐Ÿš€ Build Started + + Workspace: CalculatorApp.xcworkspace + Scheme: CalculatorApp + Configuration: Debug + Platform: iOS Simulator + Simulator: 01DA97D9-3856-46C5-A75E-DDD48100B2DB + Derived Data: /Users/cameroncooke/Library/Developer/FlowDeck/DerivedData/XcodeBuildMCP-21505d5e586f + + โ†’ Resolving Package Graph... + +โœ“ Build Completed + + +๐Ÿ“ฆ Preparing to launch CalculatorApp on Simulator... + + โ†’ Running health check... + โ†’ Preparing installation... + โ†’ Cleaning up previous instances... + โ†’ Installing app... + โ†’ Launching app... + โ†’ Getting process ID... + +โœ“ App Launched Successfully + + App ID: 1A3BE09F + Process ID: 10750 + Bundle ID: io.sentry.calculatorapp + Target: Simulator + Build Logs: /Users/cameroncooke/.flowdeck/logs/21505d5e586f/build.log + Runtime Logs: /Users/cameroncooke/Library/Developer/FlowDeck/DerivedData/XcodeBuildMCP-21505d5e586f/Logs/io.sentry.calculatorapp_1A3BE09F-56F8-433B-AC15-4FD4417C74CE.log + + + Use 'flowdeck apps' to list running apps + Use 'flowdeck stop 1A3BE09F' to stop this app + Use 'flowdeck logs 1A3BE09F' to stream logs + Use 'flowdeck uninstall 1A3BE09F' to uninstall this app + diff --git a/src/snapshot-tests/__flowdeck_fixtures__/simulator/install--error-invalid-app.txt b/src/snapshot-tests/__flowdeck_fixtures__/simulator/install--error-invalid-app.txt new file mode 100644 index 00000000..35423f28 --- /dev/null +++ b/src/snapshot-tests/__flowdeck_fixtures__/simulator/install--error-invalid-app.txt @@ -0,0 +1,13 @@ +โœ— Missing required parameter: --scheme + +Usage: + flowdeck run -w -s -S + flowdeck run -w -s -D + +Examples: + flowdeck run -w App.xcworkspace -s MyApp -S "iPhone 16" + flowdeck run -w App.xcworkspace -s MyApp -D "My Mac" + flowdeck run -w App.xcworkspace -s MyApp -S "iPhone 16" --log + flowdeck run -w App.xcworkspace -s MyApp -S "iPhone 16" --no-build + +Tip: Use 'flowdeck context --json' to discover schemes diff --git a/src/snapshot-tests/__flowdeck_fixtures__/simulator/install--success.txt b/src/snapshot-tests/__flowdeck_fixtures__/simulator/install--success.txt new file mode 100644 index 00000000..90b2ba9a --- /dev/null +++ b/src/snapshot-tests/__flowdeck_fixtures__/simulator/install--success.txt @@ -0,0 +1,27 @@ +โญ๏ธ Running build from 23 seconds ago + + +๐Ÿ“ฆ Preparing to launch CalculatorApp on Simulator... + + โ†’ Running health check... + โ†’ Preparing installation... + โ†’ Cleaning up previous instances... + โ†’ Installing app... + โ†’ Launching app... + โ†’ Getting process ID... + +โœ“ App Launched Successfully + + App ID: 52AE0067 + Process ID: 11135 + Bundle ID: io.sentry.calculatorapp + Target: Simulator + Build Logs: /Users/cameroncooke/.flowdeck/logs/21505d5e586f/build.log + Runtime Logs: /Users/cameroncooke/Library/Developer/FlowDeck/DerivedData/XcodeBuildMCP-21505d5e586f/Logs/io.sentry.calculatorapp_52AE0067-BE07-4391-8617-A2271D07F7A1.log + + + Use 'flowdeck apps' to list running apps + Use 'flowdeck stop 52AE0067' to stop this app + Use 'flowdeck logs 52AE0067' to stream logs + Use 'flowdeck uninstall 52AE0067' to uninstall this app + diff --git a/src/snapshot-tests/__flowdeck_fixtures__/simulator/launch-app--success.txt b/src/snapshot-tests/__flowdeck_fixtures__/simulator/launch-app--success.txt new file mode 100644 index 00000000..c269c7bd --- /dev/null +++ b/src/snapshot-tests/__flowdeck_fixtures__/simulator/launch-app--success.txt @@ -0,0 +1,27 @@ +โญ๏ธ Running build from 25 seconds ago + + +๐Ÿ“ฆ Preparing to launch CalculatorApp on Simulator... + + โ†’ Running health check... + โ†’ Preparing installation... + โ†’ Cleaning up previous instances... + โ†’ Installing app... + โ†’ Launching app... + โ†’ Getting process ID... + +โœ“ App Launched Successfully + + App ID: 86D0D126 + Process ID: 11214 + Bundle ID: io.sentry.calculatorapp + Target: Simulator + Build Logs: /Users/cameroncooke/.flowdeck/logs/21505d5e586f/build.log + Runtime Logs: /Users/cameroncooke/Library/Developer/FlowDeck/DerivedData/XcodeBuildMCP-21505d5e586f/Logs/io.sentry.calculatorapp_86D0D126-6CC9-438B-B433-00EB38F0CFA4.log + + + Use 'flowdeck apps' to list running apps + Use 'flowdeck stop 86D0D126' to stop this app + Use 'flowdeck logs 86D0D126' to stream logs + Use 'flowdeck uninstall 86D0D126' to uninstall this app + diff --git a/src/snapshot-tests/__flowdeck_fixtures__/simulator/list--success.txt b/src/snapshot-tests/__flowdeck_fixtures__/simulator/list--success.txt new file mode 100644 index 00000000..56677b29 --- /dev/null +++ b/src/snapshot-tests/__flowdeck_fixtures__/simulator/list--success.txt @@ -0,0 +1,74 @@ + +xrOS 26 2 26.2 + Apple Vision Pro + CE3C0D03-8F60-497A-A3B9-6A80BA997FC2 + +iOS 26.4 + iPad (A16) + A29F4D19-7093-4C7F-AB11-6B35B79FB628 + iPad Air 11-inch (M4) + AA14371A-F88F-4E79-BC61-29DEEC777D04 + iPad Air 13-inch (M4) + 5104B6B7-C0EC-464E-B7A2-E4DFCADC8C44 + iPad Pro 11-inch (M5) + 6A7E4E66-A156-41C0-9EFA-F23BFC0A20E9 + iPad Pro 13-inch (M5) + DFA87889-6433-4F86-9CE9-5BC24B835631 + iPad mini (A17 Pro) + EA43D962-29D3-4997-814F-B167BEA2AE50 + โœ“ iPhone 17 (Booted) + 01DA97D9-3856-46C5-A75E-DDD48100B2DB + iPhone 17 Pro + A2C64636-37E9-4B68-B872-E7F0A82A5670 + iPhone 17 Pro Max + 5213C8D8-61D0-4CD7-B468-B463C6206C7D + iPhone 17e + 576E3635-AAB6-481B-9C33-7EAA20B1083F + iPhone Air + 3C76234F-F3E7-4991-85C1-FB302127D84E + +watchOS 26.2 + Apple Watch SE 3 (40mm) + 07379F3B-614F-4E17-A56A-7897A9DB29DD + Apple Watch SE 3 (44mm) + BE81BC98-A2B0-4FCD-963C-B71717B21084 + Apple Watch Series 11 (42mm) + 117718C4-32F4-45F4-A59E-0EA9B882B8EC + Apple Watch Series 11 (46mm) + B9290624-12D3-4CB2-87E5-19C9464A2A89 + Apple Watch Ultra 3 (49mm) + FA216548-E7AF-4739-A3D8-489A6D35D1B5 + +iOS 26.2 + iPad (A16) + F57BAB36-B6CD-48D2-859C-80A800F74A0F + iPad Air 11-inch (M3) + F8A5DC33-877D-4822-B2FB-575FA6E5D498 + iPad Air 13-inch (M3) + 491E8F59-046E-4086-9CDF-00199C278129 + iPad Pro 11-inch (M5) + FB22CCA5-651D-48B5-BA1E-B36C7EE5D76D + iPad Pro 13-inch (M5) + D1B07075-6396-4D6E-B94F-8BD5182AC5F5 + iPad mini (A17 Pro) + 17218729-C154-4094-8FBF-B61CE7B47017 + iPhone 16e + 5A6209BA-E989-46EE-BC3A-2E65C53D5E79 + iPhone 17 + E5D1716B-28D3-45FC-A1DD-41FB13CD58C9 + iPhone 17 Pro + B38FE93D-578B-454B-BE9A-C6FA0CE5F096 + iPhone 17 Pro Max + 023BBD4A-62FC-4E96-8B95-845F53F6F0B4 + iPhone Air + BCB58C03-288E-4DFD-A2E8-4E852E91426D + +tvOS 26.2 + Apple TV + A7A45FF5-1ED7-4E7C-A109-8724D7BFB702 + Apple TV 4K (3rd generation) + 9CDC6CD8-17C7-4804-9E3D-D0A54B6F4A80 + Apple TV 4K (3rd generation) (at 1080p) + 85B0BDC1-6BEA-4BDC-90EC-2CB2E084F573 + +Total: 31 simulator(s), 1 booted diff --git a/src/snapshot-tests/__flowdeck_fixtures__/simulator/screenshot--error-invalid-simulator.txt b/src/snapshot-tests/__flowdeck_fixtures__/simulator/screenshot--error-invalid-simulator.txt new file mode 100644 index 00000000..b6cd2f1e --- /dev/null +++ b/src/snapshot-tests/__flowdeck_fixtures__/simulator/screenshot--error-invalid-simulator.txt @@ -0,0 +1 @@ +Simulator not found: 00000000-0000-0000-0000-000000000000 diff --git a/src/snapshot-tests/__flowdeck_fixtures__/simulator/screenshot--success.txt b/src/snapshot-tests/__flowdeck_fixtures__/simulator/screenshot--success.txt new file mode 100644 index 00000000..912d0720 --- /dev/null +++ b/src/snapshot-tests/__flowdeck_fixtures__/simulator/screenshot--success.txt @@ -0,0 +1,4 @@ +โœ“ Screen captured + Screenshot: /var/folders/_t/2njffz894t57qpp76v1sw__h0000gn/T/flowdeck-snapshot-0C277546-9D27-4CFB-8960-D01B8CA741B8.png + Elements: 21 + Size: 402x874 points (402x874 px) diff --git a/src/snapshot-tests/__flowdeck_fixtures__/simulator/stop--error-no-app.txt b/src/snapshot-tests/__flowdeck_fixtures__/simulator/stop--error-no-app.txt new file mode 100644 index 00000000..62109a87 --- /dev/null +++ b/src/snapshot-tests/__flowdeck_fixtures__/simulator/stop--error-no-app.txt @@ -0,0 +1,2 @@ +โœ— App Not Found: com.nonexistent.app +โœ— The operation couldnโ€™t be completed. (ArgumentParser.ExitCode error 1.) diff --git a/src/snapshot-tests/__flowdeck_fixtures__/simulator/stop--success.txt b/src/snapshot-tests/__flowdeck_fixtures__/simulator/stop--success.txt new file mode 100644 index 00000000..ae73c165 --- /dev/null +++ b/src/snapshot-tests/__flowdeck_fixtures__/simulator/stop--success.txt @@ -0,0 +1,3 @@ +Stopping app: BDBFD278 +โœ“ Stopped io.sentry.calculatorapp (BDBFD278) + Killed: app process, launch process diff --git a/src/snapshot-tests/__flowdeck_fixtures__/simulator/test--error-wrong-scheme.txt b/src/snapshot-tests/__flowdeck_fixtures__/simulator/test--error-wrong-scheme.txt new file mode 100644 index 00000000..1fb7120d --- /dev/null +++ b/src/snapshot-tests/__flowdeck_fixtures__/simulator/test--error-wrong-scheme.txt @@ -0,0 +1,17 @@ +๐Ÿงช Test: NONEXISTENT + Workspace: /Users/cameroncooke/.codex/worktrees/43f4/XcodeBuildMCP/example_projects/iOS_Calculator/CalculatorApp.xcworkspace + Configuration: Debug + Target: 01DA97D9-3856-46C5-A75E-DDD48100B2DB + + โ†’ ๐Ÿ”— Resolving Package Graph + +Error + + error: The workspace named "CalculatorApp" does not contain a scheme named "NONEXISTENT". The "-list" option can be used to find the names of the schemes in the workspace. + Hint: Scheme not found in project +  - List schemes: flowdeck project schemes -w  +  - Check scheme is shared: Manage Schemes > Shared checkbox +  - Verify scheme name spelling (case-sensitive) +  - Recreate scheme: Product > Scheme > New Scheme +  + diff --git a/src/snapshot-tests/__flowdeck_fixtures__/simulator/test--failure.txt b/src/snapshot-tests/__flowdeck_fixtures__/simulator/test--failure.txt new file mode 100644 index 00000000..2c115874 --- /dev/null +++ b/src/snapshot-tests/__flowdeck_fixtures__/simulator/test--failure.txt @@ -0,0 +1,34 @@ +Using scheme test configuration: Debug +๐Ÿงช Test: CalculatorApp + Workspace: /Users/cameroncooke/.codex/worktrees/43f4/XcodeBuildMCP/example_projects/iOS_Calculator/CalculatorApp.xcworkspace + Configuration: Debug + Target: 01DA97D9-3856-46C5-A75E-DDD48100B2DB + + โ†’ ๐Ÿ”— Resolving Package Graph + โ†’ Running Tests + +Failed Tests + +CalculatorAppTests + โœ— testCalculatorServiceFailure (0.005s) + โ””โ”€ XCTAssertEqual failed: ("0") is not + equal to ("999") - This test should fail + - display should be 0, not 999 + (CalculatorAppTests.swift:52) + +This test should fail to verify error reporting + โœ— test + โ””โ”€ (calculator.display โ†’ "0") == "999" + +Test Summary + +โ•ญโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎ +โ”‚ Total: 55 โ”‚ +โ”‚ Passed: 53 โ”‚ +โ”‚ Failed: 2 โ”‚ +โ”‚ Skipped: 0 โ”‚ +โ”‚ Duration: 21.90s โ”‚ +โ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏ + +โœ— 2 test(s) failed. + diff --git a/src/snapshot-tests/__flowdeck_fixtures__/simulator/test--success.txt b/src/snapshot-tests/__flowdeck_fixtures__/simulator/test--success.txt new file mode 100644 index 00000000..a5453a00 --- /dev/null +++ b/src/snapshot-tests/__flowdeck_fixtures__/simulator/test--success.txt @@ -0,0 +1,23 @@ +Using scheme test configuration: Debug +๐Ÿงช Test: CalculatorApp + Workspace: /Users/cameroncooke/.codex/worktrees/43f4/XcodeBuildMCP/example_projects/iOS_Calculator/CalculatorApp.xcworkspace + Configuration: Debug + Target: 01DA97D9-3856-46C5-A75E-DDD48100B2DB + + โ†’ ๐Ÿ”— Resolving Package Graph + โ†’ โš’๏ธ Compiling + โ†’ Running Tests + +[DEBUG] Parsing issue detected - debug log: /var/folders/_t/2njffz894t57qpp76v1sw__h0000gn/T/flowdeck-debug/parsing-failed-1774639308.3555841.txt + +The selected scheme has no tests. Make sure you have selected a valid test target. + +If this seems unexpected, check: + - The simulator/device is not available or was deleted + - A build or configuration error occurred + +Try: + - Check available simulators: flowdeck simulator list + - Run with --verbose to see detailed output + - Verify scheme has test targets: flowdeck test discover + diff --git a/src/snapshot-tests/__flowdeck_fixtures__/swift-package/list--success.txt b/src/snapshot-tests/__flowdeck_fixtures__/swift-package/list--success.txt new file mode 100644 index 00000000..84f40a1f --- /dev/null +++ b/src/snapshot-tests/__flowdeck_fixtures__/swift-package/list--success.txt @@ -0,0 +1,7 @@ +Running Apps (1): + + ๐ŸŸข io.sentry.calculatorapp + ID: 3FA6B156 | Target: Simulator + PID: 12824 | Uptime: 18s + Scheme: CalculatorApp | Type: simulator + diff --git a/src/snapshot-tests/__flowdeck_fixtures__/swift-package/stop--error-no-process.txt b/src/snapshot-tests/__flowdeck_fixtures__/swift-package/stop--error-no-process.txt new file mode 100644 index 00000000..a4566d80 --- /dev/null +++ b/src/snapshot-tests/__flowdeck_fixtures__/swift-package/stop--error-no-process.txt @@ -0,0 +1,2 @@ +โœ— App Not Found: com.nonexistent.spm.app +โœ— The operation couldnโ€™t be completed. (ArgumentParser.ExitCode error 1.) diff --git a/src/snapshot-tests/__flowdeck_fixtures__/ui-automation/button--error-no-simulator.txt b/src/snapshot-tests/__flowdeck_fixtures__/ui-automation/button--error-no-simulator.txt new file mode 100644 index 00000000..b6cd2f1e --- /dev/null +++ b/src/snapshot-tests/__flowdeck_fixtures__/ui-automation/button--error-no-simulator.txt @@ -0,0 +1 @@ +Simulator not found: 00000000-0000-0000-0000-000000000000 diff --git a/src/snapshot-tests/__flowdeck_fixtures__/ui-automation/button--success.txt b/src/snapshot-tests/__flowdeck_fixtures__/ui-automation/button--success.txt new file mode 100644 index 00000000..3b699228 --- /dev/null +++ b/src/snapshot-tests/__flowdeck_fixtures__/ui-automation/button--success.txt @@ -0,0 +1 @@ +โœ“ Pressed home button diff --git a/src/snapshot-tests/__flowdeck_fixtures__/ui-automation/gesture--error-no-simulator.txt b/src/snapshot-tests/__flowdeck_fixtures__/ui-automation/gesture--error-no-simulator.txt new file mode 100644 index 00000000..b6cd2f1e --- /dev/null +++ b/src/snapshot-tests/__flowdeck_fixtures__/ui-automation/gesture--error-no-simulator.txt @@ -0,0 +1 @@ +Simulator not found: 00000000-0000-0000-0000-000000000000 diff --git a/src/snapshot-tests/__flowdeck_fixtures__/ui-automation/gesture--success.txt b/src/snapshot-tests/__flowdeck_fixtures__/ui-automation/gesture--success.txt new file mode 100644 index 00000000..e00c5442 --- /dev/null +++ b/src/snapshot-tests/__flowdeck_fixtures__/ui-automation/gesture--success.txt @@ -0,0 +1 @@ +โœ“ Scrolled down diff --git a/src/snapshot-tests/__flowdeck_fixtures__/ui-automation/key-press--error-no-simulator.txt b/src/snapshot-tests/__flowdeck_fixtures__/ui-automation/key-press--error-no-simulator.txt new file mode 100644 index 00000000..b6cd2f1e --- /dev/null +++ b/src/snapshot-tests/__flowdeck_fixtures__/ui-automation/key-press--error-no-simulator.txt @@ -0,0 +1 @@ +Simulator not found: 00000000-0000-0000-0000-000000000000 diff --git a/src/snapshot-tests/__flowdeck_fixtures__/ui-automation/key-press--success.txt b/src/snapshot-tests/__flowdeck_fixtures__/ui-automation/key-press--success.txt new file mode 100644 index 00000000..ba8eb0aa --- /dev/null +++ b/src/snapshot-tests/__flowdeck_fixtures__/ui-automation/key-press--success.txt @@ -0,0 +1 @@ +โœ“ Pressed key: 4 diff --git a/src/snapshot-tests/__flowdeck_fixtures__/ui-automation/key-sequence--error-no-simulator.txt b/src/snapshot-tests/__flowdeck_fixtures__/ui-automation/key-sequence--error-no-simulator.txt new file mode 100644 index 00000000..b6cd2f1e --- /dev/null +++ b/src/snapshot-tests/__flowdeck_fixtures__/ui-automation/key-sequence--error-no-simulator.txt @@ -0,0 +1 @@ +Simulator not found: 00000000-0000-0000-0000-000000000000 diff --git a/src/snapshot-tests/__flowdeck_fixtures__/ui-automation/key-sequence--success.txt b/src/snapshot-tests/__flowdeck_fixtures__/ui-automation/key-sequence--success.txt new file mode 100644 index 00000000..b900d2b4 --- /dev/null +++ b/src/snapshot-tests/__flowdeck_fixtures__/ui-automation/key-sequence--success.txt @@ -0,0 +1 @@ +โœ“ Pressed key sequence: [4, 5, 6] diff --git a/src/snapshot-tests/__flowdeck_fixtures__/ui-automation/long-press--error-no-simulator.txt b/src/snapshot-tests/__flowdeck_fixtures__/ui-automation/long-press--error-no-simulator.txt new file mode 100644 index 00000000..fd0d672f --- /dev/null +++ b/src/snapshot-tests/__flowdeck_fixtures__/ui-automation/long-press--error-no-simulator.txt @@ -0,0 +1,2 @@ +โš ๏ธ Last UI snapshot was taken on a different simulator. Coordinates may be stale. +Simulator not found: 00000000-0000-0000-0000-000000000000 diff --git a/src/snapshot-tests/__flowdeck_fixtures__/ui-automation/long-press--success.txt b/src/snapshot-tests/__flowdeck_fixtures__/ui-automation/long-press--success.txt new file mode 100644 index 00000000..703f9375 --- /dev/null +++ b/src/snapshot-tests/__flowdeck_fixtures__/ui-automation/long-press--success.txt @@ -0,0 +1 @@ +โœ“ Tapped at (100, 400) diff --git a/src/snapshot-tests/__flowdeck_fixtures__/ui-automation/snapshot-ui--error-no-simulator.txt b/src/snapshot-tests/__flowdeck_fixtures__/ui-automation/snapshot-ui--error-no-simulator.txt new file mode 100644 index 00000000..b6cd2f1e --- /dev/null +++ b/src/snapshot-tests/__flowdeck_fixtures__/ui-automation/snapshot-ui--error-no-simulator.txt @@ -0,0 +1 @@ +Simulator not found: 00000000-0000-0000-0000-000000000000 diff --git a/src/snapshot-tests/__flowdeck_fixtures__/ui-automation/snapshot-ui--success.txt b/src/snapshot-tests/__flowdeck_fixtures__/ui-automation/snapshot-ui--success.txt new file mode 100644 index 00000000..524eba58 --- /dev/null +++ b/src/snapshot-tests/__flowdeck_fixtures__/ui-automation/snapshot-ui--success.txt @@ -0,0 +1,4 @@ +โœ“ Screen captured + Screenshot: /var/folders/_t/2njffz894t57qpp76v1sw__h0000gn/T/flowdeck-snapshot-6964B0E1-603B-4174-9211-F97A6112E6C7.png + Elements: 21 + Size: 402x874 points (402x874 px) diff --git a/src/snapshot-tests/__flowdeck_fixtures__/ui-automation/swipe--error-no-simulator.txt b/src/snapshot-tests/__flowdeck_fixtures__/ui-automation/swipe--error-no-simulator.txt new file mode 100644 index 00000000..fd0d672f --- /dev/null +++ b/src/snapshot-tests/__flowdeck_fixtures__/ui-automation/swipe--error-no-simulator.txt @@ -0,0 +1,2 @@ +โš ๏ธ Last UI snapshot was taken on a different simulator. Coordinates may be stale. +Simulator not found: 00000000-0000-0000-0000-000000000000 diff --git a/src/snapshot-tests/__flowdeck_fixtures__/ui-automation/swipe--success.txt b/src/snapshot-tests/__flowdeck_fixtures__/ui-automation/swipe--success.txt new file mode 100644 index 00000000..a0bcaefb --- /dev/null +++ b/src/snapshot-tests/__flowdeck_fixtures__/ui-automation/swipe--success.txt @@ -0,0 +1 @@ +โœ“ Swiped from (200, 400) to (200, 200) diff --git a/src/snapshot-tests/__flowdeck_fixtures__/ui-automation/tap--error-no-simulator.txt b/src/snapshot-tests/__flowdeck_fixtures__/ui-automation/tap--error-no-simulator.txt new file mode 100644 index 00000000..fd0d672f --- /dev/null +++ b/src/snapshot-tests/__flowdeck_fixtures__/ui-automation/tap--error-no-simulator.txt @@ -0,0 +1,2 @@ +โš ๏ธ Last UI snapshot was taken on a different simulator. Coordinates may be stale. +Simulator not found: 00000000-0000-0000-0000-000000000000 diff --git a/src/snapshot-tests/__flowdeck_fixtures__/ui-automation/tap--success.txt b/src/snapshot-tests/__flowdeck_fixtures__/ui-automation/tap--success.txt new file mode 100644 index 00000000..703f9375 --- /dev/null +++ b/src/snapshot-tests/__flowdeck_fixtures__/ui-automation/tap--success.txt @@ -0,0 +1 @@ +โœ“ Tapped at (100, 400) diff --git a/src/snapshot-tests/__flowdeck_fixtures__/ui-automation/touch--error-no-simulator.txt b/src/snapshot-tests/__flowdeck_fixtures__/ui-automation/touch--error-no-simulator.txt new file mode 100644 index 00000000..fd0d672f --- /dev/null +++ b/src/snapshot-tests/__flowdeck_fixtures__/ui-automation/touch--error-no-simulator.txt @@ -0,0 +1,2 @@ +โš ๏ธ Last UI snapshot was taken on a different simulator. Coordinates may be stale. +Simulator not found: 00000000-0000-0000-0000-000000000000 diff --git a/src/snapshot-tests/__flowdeck_fixtures__/ui-automation/touch--success.txt b/src/snapshot-tests/__flowdeck_fixtures__/ui-automation/touch--success.txt new file mode 100644 index 00000000..6465b823 --- /dev/null +++ b/src/snapshot-tests/__flowdeck_fixtures__/ui-automation/touch--success.txt @@ -0,0 +1,2 @@ +โœ“ Touch down at (100, 400) +โœ“ Touch up at (100, 400) diff --git a/src/snapshot-tests/__flowdeck_fixtures__/ui-automation/type-text--error-no-simulator.txt b/src/snapshot-tests/__flowdeck_fixtures__/ui-automation/type-text--error-no-simulator.txt new file mode 100644 index 00000000..b6cd2f1e --- /dev/null +++ b/src/snapshot-tests/__flowdeck_fixtures__/ui-automation/type-text--error-no-simulator.txt @@ -0,0 +1 @@ +Simulator not found: 00000000-0000-0000-0000-000000000000 diff --git a/src/snapshot-tests/__flowdeck_fixtures__/ui-automation/type-text--success.txt b/src/snapshot-tests/__flowdeck_fixtures__/ui-automation/type-text--success.txt new file mode 100644 index 00000000..b8614dd7 --- /dev/null +++ b/src/snapshot-tests/__flowdeck_fixtures__/ui-automation/type-text--success.txt @@ -0,0 +1 @@ +โœ“ Typed: hello diff --git a/src/snapshot-tests/__flowdeck_fixtures__/utilities/clean--error-wrong-scheme.txt b/src/snapshot-tests/__flowdeck_fixtures__/utilities/clean--error-wrong-scheme.txt new file mode 100644 index 00000000..a0070045 --- /dev/null +++ b/src/snapshot-tests/__flowdeck_fixtures__/utilities/clean--error-wrong-scheme.txt @@ -0,0 +1,8 @@ +๐Ÿงน Clean Started + + Workspace: CalculatorApp.xcworkspace + Scheme: NONEXISTENT + Derived Data: /Users/cameroncooke/Library/Developer/FlowDeck/DerivedData/XcodeBuildMCP-21505d5e586f + + โ†’ ๐Ÿงน Cleaning build folder... +โœ— Clean failed diff --git a/src/snapshot-tests/__flowdeck_fixtures__/utilities/clean--success.txt b/src/snapshot-tests/__flowdeck_fixtures__/utilities/clean--success.txt new file mode 100644 index 00000000..97e02da4 --- /dev/null +++ b/src/snapshot-tests/__flowdeck_fixtures__/utilities/clean--success.txt @@ -0,0 +1,10 @@ +๐Ÿงน Clean Started + + Workspace: CalculatorApp.xcworkspace + Scheme: CalculatorApp + Derived Data: /Users/cameroncooke/Library/Developer/FlowDeck/DerivedData/XcodeBuildMCP-21505d5e586f + + โ†’ ๐Ÿงน Cleaning build folder... + +โœ“ Clean Completed + diff --git a/src/snapshot-tests/__tests__/coverage.flowdeck.test.ts b/src/snapshot-tests/__tests__/coverage.flowdeck.test.ts new file mode 100644 index 00000000..cfb7861f --- /dev/null +++ b/src/snapshot-tests/__tests__/coverage.flowdeck.test.ts @@ -0,0 +1,8 @@ +import { describe, it } from 'vitest'; + +describe('coverage workflow (flowdeck)', () => { + // flowdeck doesn't have coverage report extraction tools + // Coverage is generated by xcodebuild test but flowdeck doesn't expose + // get-coverage-report or get-file-coverage equivalents + it.skip('no flowdeck equivalent for coverage report extraction', () => {}); +}); diff --git a/src/snapshot-tests/__tests__/debugging.flowdeck.test.ts b/src/snapshot-tests/__tests__/debugging.flowdeck.test.ts new file mode 100644 index 00000000..41203567 --- /dev/null +++ b/src/snapshot-tests/__tests__/debugging.flowdeck.test.ts @@ -0,0 +1,6 @@ +import { describe, it } from 'vitest'; + +describe('debugging workflow (flowdeck)', () => { + // flowdeck doesn't have debugging tools (attach, detach, breakpoints, lldb, stack, variables) + it.skip('no flowdeck equivalent for debugging workflow', () => {}); +}); diff --git a/src/snapshot-tests/__tests__/device.flowdeck.test.ts b/src/snapshot-tests/__tests__/device.flowdeck.test.ts new file mode 100644 index 00000000..65cbfd9a --- /dev/null +++ b/src/snapshot-tests/__tests__/device.flowdeck.test.ts @@ -0,0 +1,140 @@ +import { describe, it, beforeAll, afterAll, vi } from 'vitest'; +import { execSync } from 'node:child_process'; +import { createFlowdeckHarness } from '../flowdeck-harness.ts'; +import { writeFlowdeckFixture } from '../flowdeck-fixture-io.ts'; +import type { FlowdeckHarness } from '../flowdeck-harness.ts'; + +const WORKSPACE = 'example_projects/iOS_Calculator/CalculatorApp.xcworkspace'; +const BUNDLE_ID = 'io.sentry.calculatorapp'; + +function findDevice(): string | undefined { + if (process.env.DEVICE_ID) return process.env.DEVICE_ID; + try { + const output = execSync('flowdeck device list --json', { + encoding: 'utf8', + timeout: 10_000, + stdio: ['pipe', 'pipe', 'pipe'], + }); + const devices = JSON.parse(output); + for (const device of devices) { + if (device.platform === 'iOS' && !device.isVirtual && device.isAvailable) { + return device.udid; + } + } + } catch { /* no device available */ } + return undefined; +} + +const deviceId = findDevice(); + +describe('device workflow (flowdeck)', () => { + let harness: FlowdeckHarness; + + beforeAll(() => { + vi.setConfig({ testTimeout: 120_000 }); + harness = createFlowdeckHarness(); + }, 120_000); + + afterAll(() => { + harness.cleanup(); + }); + + describe('list', () => { + it('success', () => { + const result = harness.run(['device', 'list']); + writeFlowdeckFixture(__filename, 'list--success', result.text); + }); + }); + + describe('build', () => { + it.runIf(deviceId)('success', () => { + const result = harness.run([ + 'build', '-w', WORKSPACE, '-s', 'CalculatorApp', '-D', deviceId!, + ]); + writeFlowdeckFixture(__filename, 'build--success', result.text); + }); + + it.runIf(deviceId)('error - wrong scheme', () => { + const result = harness.run([ + 'build', '-w', WORKSPACE, '-s', 'NONEXISTENT', '-D', deviceId!, + ]); + writeFlowdeckFixture(__filename, 'build--error-wrong-scheme', result.text); + }); + }); + + describe('build-and-run', () => { + it.runIf(deviceId)('success', () => { + const result = harness.run([ + 'run', '-w', WORKSPACE, '-s', 'CalculatorApp', '-D', deviceId!, + ]); + writeFlowdeckFixture(__filename, 'build-and-run--success', result.text); + }); + }); + + describe('install', () => { + it('error - invalid app path', () => { + const result = harness.run([ + 'device', 'install', '00000000-0000-0000-0000-000000000000', '/tmp/nonexistent.app', + ]); + writeFlowdeckFixture(__filename, 'install--error-invalid-app', result.text); + }); + + it.runIf(deviceId)('success', () => { + const result = harness.run([ + 'device', 'install', deviceId!, '/tmp/nonexistent-device.app', + ]); + writeFlowdeckFixture(__filename, 'install--success-attempt', result.text); + }); + }); + + describe('launch', () => { + it('error - invalid bundle', () => { + const result = harness.run([ + 'device', 'launch', '00000000-0000-0000-0000-000000000000', 'com.nonexistent.app', + ]); + writeFlowdeckFixture(__filename, 'launch--error-invalid-bundle', result.text); + }); + + it.runIf(deviceId)('success', () => { + const result = harness.run([ + 'device', 'launch', deviceId!, BUNDLE_ID, + ]); + writeFlowdeckFixture(__filename, 'launch--success', result.text); + }, 60_000); + }); + + describe('stop', () => { + it('error - no app', () => { + const result = harness.run(['stop', 'com.nonexistent.app']); + writeFlowdeckFixture(__filename, 'stop--error-no-app', result.text); + }); + + it.runIf(deviceId)('success', () => { + const runResult = harness.run([ + 'run', '-w', WORKSPACE, '-s', 'CalculatorApp', '-D', deviceId!, + ]); + const appIdMatch = runResult.text.match(/App ID: ([A-F0-9]+)/); + const appId = appIdMatch ? appIdMatch[1] : BUNDLE_ID; + + const result = harness.run(['stop', appId]); + writeFlowdeckFixture(__filename, 'stop--success', result.text); + }, 120_000); + }); + + describe('test', () => { + it.runIf(deviceId)('success - targeted passing test', () => { + const result = harness.run([ + 'test', '-w', WORKSPACE, '-s', 'CalculatorApp', '-D', deviceId!, + '--only', 'CalculatorAppTests/CalculatorAppTests/testAddition', + ]); + writeFlowdeckFixture(__filename, 'test--success', result.text); + }, 300_000); + + it.runIf(deviceId)('failure - intentional test failure', () => { + const result = harness.run([ + 'test', '-w', WORKSPACE, '-s', 'CalculatorApp', '-D', deviceId!, + ]); + writeFlowdeckFixture(__filename, 'test--failure', result.text); + }, 300_000); + }); +}); diff --git a/src/snapshot-tests/__tests__/doctor.flowdeck.test.ts b/src/snapshot-tests/__tests__/doctor.flowdeck.test.ts new file mode 100644 index 00000000..18373dae --- /dev/null +++ b/src/snapshot-tests/__tests__/doctor.flowdeck.test.ts @@ -0,0 +1,6 @@ +import { describe, it } from 'vitest'; + +describe('doctor workflow (flowdeck)', () => { + // flowdeck doesn't have a doctor/diagnostics command + it.skip('no flowdeck equivalent for doctor workflow', () => {}); +}); diff --git a/src/snapshot-tests/__tests__/logging.flowdeck.test.ts b/src/snapshot-tests/__tests__/logging.flowdeck.test.ts new file mode 100644 index 00000000..4e924c0b --- /dev/null +++ b/src/snapshot-tests/__tests__/logging.flowdeck.test.ts @@ -0,0 +1,52 @@ +import { describe, it, beforeAll, afterAll } from 'vitest'; +import { createFlowdeckHarness } from '../flowdeck-harness.ts'; +import { writeFlowdeckFixture } from '../flowdeck-fixture-io.ts'; +import { ensureSimulatorBooted } from '../harness.ts'; +import type { FlowdeckHarness } from '../flowdeck-harness.ts'; + +const WORKSPACE = 'example_projects/iOS_Calculator/CalculatorApp.xcworkspace'; +const BUNDLE_ID = 'io.sentry.calculatorapp'; + +describe('logging workflow (flowdeck)', () => { + let harness: FlowdeckHarness; + let simulatorUdid: string; + + beforeAll(async () => { + simulatorUdid = await ensureSimulatorBooted('iPhone 17'); + harness = createFlowdeckHarness(); + + // Ensure app is running for log capture + harness.run([ + 'run', '-w', WORKSPACE, '-s', 'CalculatorApp', '-S', simulatorUdid, + ]); + await new Promise((resolve) => setTimeout(resolve, 3000)); + }, 120_000); + + afterAll(() => { + harness.cleanup(); + }); + + describe('logs', () => { + it('success - stream logs (brief capture)', () => { + // flowdeck logs streams continuously; we capture briefly with a timeout + const { spawnSync } = require('node:child_process'); + const result = spawnSync('flowdeck', ['logs', BUNDLE_ID], { + encoding: 'utf8', + timeout: 5_000, + cwd: process.cwd(), + }); + const text = (result.stdout ?? '') + (result.stderr ?? ''); + writeFlowdeckFixture(__filename, 'logs--success', text); + }, 30_000); + }); + + // flowdeck doesn't have start/stop log capture sessions like XcodeBuildMCP + // It only has a streaming `logs` command + describe('start-device-log-capture', () => { + it.skip('flowdeck uses streaming logs, no start/stop session model', () => {}); + }); + + describe('stop-device-log-capture', () => { + it.skip('flowdeck uses streaming logs, no start/stop session model', () => {}); + }); +}); diff --git a/src/snapshot-tests/__tests__/macos.flowdeck.test.ts b/src/snapshot-tests/__tests__/macos.flowdeck.test.ts new file mode 100644 index 00000000..a1dce1cb --- /dev/null +++ b/src/snapshot-tests/__tests__/macos.flowdeck.test.ts @@ -0,0 +1,110 @@ +import { describe, it, beforeAll, afterAll } from 'vitest'; +import { execSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { createFlowdeckHarness } from '../flowdeck-harness.ts'; +import { writeFlowdeckFixture } from '../flowdeck-fixture-io.ts'; +import type { FlowdeckHarness } from '../flowdeck-harness.ts'; + +const PROJECT = 'example_projects/macOS/MCPTest.xcodeproj'; + +describe('macos workflow (flowdeck)', () => { + let harness: FlowdeckHarness; + let tmpDir: string; + + beforeAll(() => { + harness = createFlowdeckHarness(); + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'fd-macos-snapshot-')); + }); + + afterAll(() => { + harness.cleanup(); + if (tmpDir) { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + + describe('build', () => { + it('success', { timeout: 120000 }, () => { + const result = harness.run([ + 'build', '-w', PROJECT, '-s', 'MCPTest', '-S', 'none', + ]); + writeFlowdeckFixture(__filename, 'build--success', result.text); + }); + + it('error - wrong scheme', { timeout: 120000 }, () => { + const result = harness.run([ + 'build', '-w', PROJECT, '-s', 'NONEXISTENT', '-S', 'none', + ]); + writeFlowdeckFixture(__filename, 'build--error-wrong-scheme', result.text); + }); + }); + + describe('build-and-run', () => { + it('success', { timeout: 120000 }, () => { + const result = harness.run([ + 'run', '-w', PROJECT, '-s', 'MCPTest', '-S', 'none', + ]); + writeFlowdeckFixture(__filename, 'build-and-run--success', result.text); + }); + + it('error - wrong scheme', { timeout: 120000 }, () => { + const result = harness.run([ + 'run', '-w', PROJECT, '-s', 'NONEXISTENT', '-S', 'none', + ]); + writeFlowdeckFixture(__filename, 'build-and-run--error-wrong-scheme', result.text); + }); + }); + + describe('test', () => { + it('success', { timeout: 120000 }, () => { + const result = harness.run([ + 'test', '-w', PROJECT, '-s', 'MCPTest', '-D', 'My Mac', + '--only', 'MCPTestTests/MCPTestTests/appNameIsCorrect', + ]); + writeFlowdeckFixture(__filename, 'test--success', result.text); + }); + + it('failure - intentional test failure', { timeout: 120000 }, () => { + const result = harness.run([ + 'test', '-w', PROJECT, '-s', 'MCPTest', '-D', 'My Mac', + ]); + writeFlowdeckFixture(__filename, 'test--failure', result.text); + }); + + it('error - wrong scheme', { timeout: 120000 }, () => { + const result = harness.run([ + 'test', '-w', PROJECT, '-s', 'NONEXISTENT', '-D', 'My Mac', + ]); + writeFlowdeckFixture(__filename, 'test--error-wrong-scheme', result.text); + }); + }); + + describe('stop', () => { + it('success', { timeout: 120000 }, () => { + const runResult = harness.run([ + 'run', '-w', PROJECT, '-s', 'MCPTest', '-S', 'none', + ]); + const appIdMatch = runResult.text.match(/App ID: ([A-F0-9]+)/); + const appId = appIdMatch ? appIdMatch[1] : 'io.sentry.MCPTest'; + + const result = harness.run(['stop', appId]); + writeFlowdeckFixture(__filename, 'stop--success', result.text); + }); + + it('error - no app', { timeout: 120000 }, () => { + const result = harness.run(['stop', 'com.nonexistent.app']); + writeFlowdeckFixture(__filename, 'stop--error-no-app', result.text); + }); + }); + + describe('clean', () => { + it('success', { timeout: 120000 }, () => { + const result = harness.run([ + 'clean', '-w', PROJECT, '-s', 'MCPTest', + ]); + writeFlowdeckFixture(__filename, 'clean--success', result.text); + }); + }); +}); diff --git a/src/snapshot-tests/__tests__/project-discovery.flowdeck.test.ts b/src/snapshot-tests/__tests__/project-discovery.flowdeck.test.ts new file mode 100644 index 00000000..59e9205e --- /dev/null +++ b/src/snapshot-tests/__tests__/project-discovery.flowdeck.test.ts @@ -0,0 +1,61 @@ +import { describe, it, beforeAll, afterAll } from 'vitest'; +import { createFlowdeckHarness } from '../flowdeck-harness.ts'; +import { writeFlowdeckFixture } from '../flowdeck-fixture-io.ts'; +import type { FlowdeckHarness } from '../flowdeck-harness.ts'; + +const WORKSPACE = 'example_projects/iOS_Calculator/CalculatorApp.xcworkspace'; + +describe('project-discovery workflow (flowdeck)', () => { + let harness: FlowdeckHarness; + + beforeAll(() => { + harness = createFlowdeckHarness(); + }); + + afterAll(() => { + harness.cleanup(); + }); + + describe('list-schemes', () => { + it('success', () => { + const result = harness.run([ + 'project', 'schemes', '-w', WORKSPACE, + ]); + writeFlowdeckFixture(__filename, 'list-schemes--success', result.text); + }); + + it('error - invalid workspace', () => { + const result = harness.run([ + 'project', 'schemes', '-w', '/nonexistent/path/Fake.xcworkspace', + ]); + writeFlowdeckFixture(__filename, 'list-schemes--error-invalid-workspace', result.text); + }); + }); + + describe('show-build-settings', () => { + // flowdeck doesn't have a direct show-build-settings command + // The closest is `flowdeck project configs` which lists build configurations + it('success (configs)', () => { + const result = harness.run([ + 'project', 'configs', '-w', WORKSPACE, + ]); + writeFlowdeckFixture(__filename, 'show-build-settings--success', result.text); + }); + }); + + describe('discover-projs', () => { + // flowdeck doesn't have a direct discover-projects command + // project schemes is the closest for discovery + it.skip('no direct flowdeck equivalent', () => {}); + }); + + describe('get-app-bundle-id', () => { + // flowdeck doesn't expose bundle ID extraction + it.skip('no direct flowdeck equivalent', () => {}); + }); + + describe('get-macos-bundle-id', () => { + // flowdeck doesn't expose bundle ID extraction + it.skip('no direct flowdeck equivalent', () => {}); + }); +}); diff --git a/src/snapshot-tests/__tests__/project-scaffolding.flowdeck.test.ts b/src/snapshot-tests/__tests__/project-scaffolding.flowdeck.test.ts new file mode 100644 index 00000000..efc16516 --- /dev/null +++ b/src/snapshot-tests/__tests__/project-scaffolding.flowdeck.test.ts @@ -0,0 +1,78 @@ +import { describe, it, beforeAll, afterAll } from 'vitest'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { createFlowdeckHarness } from '../flowdeck-harness.ts'; +import { writeFlowdeckFixture } from '../flowdeck-fixture-io.ts'; +import type { FlowdeckHarness } from '../flowdeck-harness.ts'; + +describe('project-scaffolding workflow (flowdeck)', () => { + let harness: FlowdeckHarness; + let tmpDir: string; + + beforeAll(() => { + harness = createFlowdeckHarness(); + tmpDir = mkdtempSync(join(tmpdir(), 'fd-scaffold-')); + }); + + afterAll(() => { + harness.cleanup(); + rmSync(tmpDir, { recursive: true, force: true }); + }); + + describe('scaffold-ios', () => { + it('success', () => { + const outputPath = join(tmpDir, 'ios'); + const result = harness.run([ + 'project', 'create', 'SnapshotTestApp', + '--platforms', 'ios', + '--path', outputPath, + ]); + writeFlowdeckFixture(__filename, 'scaffold-ios--success', result.text); + }, 120000); + + it('error - existing project', () => { + const outputPath = join(tmpDir, 'ios-existing'); + // Create first + harness.run([ + 'project', 'create', 'SnapshotTestApp', + '--platforms', 'ios', + '--path', outputPath, + ]); + // Create again to trigger error + const result = harness.run([ + 'project', 'create', 'SnapshotTestApp', + '--platforms', 'ios', + '--path', outputPath, + ]); + writeFlowdeckFixture(__filename, 'scaffold-ios--error-existing', result.text); + }, 120000); + }); + + describe('scaffold-macos', () => { + it('success', () => { + const outputPath = join(tmpDir, 'macos'); + const result = harness.run([ + 'project', 'create', 'SnapshotTestMacApp', + '--platforms', 'macos', + '--path', outputPath, + ]); + writeFlowdeckFixture(__filename, 'scaffold-macos--success', result.text); + }, 120000); + + it('error - existing project', () => { + const outputPath = join(tmpDir, 'macos-existing'); + harness.run([ + 'project', 'create', 'SnapshotTestMacApp', + '--platforms', 'macos', + '--path', outputPath, + ]); + const result = harness.run([ + 'project', 'create', 'SnapshotTestMacApp', + '--platforms', 'macos', + '--path', outputPath, + ]); + writeFlowdeckFixture(__filename, 'scaffold-macos--error-existing', result.text); + }, 120000); + }); +}); diff --git a/src/snapshot-tests/__tests__/session-management.flowdeck.test.ts b/src/snapshot-tests/__tests__/session-management.flowdeck.test.ts new file mode 100644 index 00000000..b567d0aa --- /dev/null +++ b/src/snapshot-tests/__tests__/session-management.flowdeck.test.ts @@ -0,0 +1,59 @@ +import { describe, it, beforeAll, afterAll } from 'vitest'; +import { createFlowdeckHarness } from '../flowdeck-harness.ts'; +import { writeFlowdeckFixture } from '../flowdeck-fixture-io.ts'; +import type { FlowdeckHarness } from '../flowdeck-harness.ts'; + +const WORKSPACE = 'example_projects/iOS_Calculator/CalculatorApp.xcworkspace'; + +describe('session-management workflow (flowdeck)', () => { + let harness: FlowdeckHarness; + + beforeAll(() => { + harness = createFlowdeckHarness(); + }); + + afterAll(() => { + harness.cleanup(); + }); + + describe('session-set-defaults', () => { + it('success', () => { + // flowdeck equivalent: config set + const result = harness.run([ + 'config', 'set', + '-w', WORKSPACE, + '-s', 'CalculatorApp', + ]); + writeFlowdeckFixture(__filename, 'session-set-defaults--success', result.text); + }); + }); + + describe('session-show-defaults', () => { + it('success', () => { + // flowdeck equivalent: config get + const result = harness.run(['config', 'get']); + writeFlowdeckFixture(__filename, 'session-show-defaults--success', result.text); + }); + }); + + describe('session-clear-defaults', () => { + it('success', () => { + // flowdeck equivalent: config reset + const result = harness.run(['config', 'reset']); + writeFlowdeckFixture(__filename, 'session-clear-defaults--success', result.text); + }); + }); + + describe('session-use-defaults-profile', () => { + // flowdeck doesn't have a direct profile concept + it.skip('no direct flowdeck equivalent', () => {}); + }); + + describe('session-sync-xcode-defaults', () => { + // flowdeck's project sync-profiles is the closest + it('success', () => { + const result = harness.run(['context']); + writeFlowdeckFixture(__filename, 'session-sync-xcode-defaults--success', result.text); + }); + }); +}); diff --git a/src/snapshot-tests/__tests__/simulator-management.flowdeck.test.ts b/src/snapshot-tests/__tests__/simulator-management.flowdeck.test.ts new file mode 100644 index 00000000..0c449d19 --- /dev/null +++ b/src/snapshot-tests/__tests__/simulator-management.flowdeck.test.ts @@ -0,0 +1,95 @@ +import { describe, it, beforeAll, afterAll } from 'vitest'; +import { createFlowdeckHarness } from '../flowdeck-harness.ts'; +import { writeFlowdeckFixture } from '../flowdeck-fixture-io.ts'; +import { ensureSimulatorBooted } from '../harness.ts'; +import type { FlowdeckHarness } from '../flowdeck-harness.ts'; + +describe('simulator-management workflow (flowdeck)', () => { + let harness: FlowdeckHarness; + let simulatorUdid: string; + + beforeAll(async () => { + simulatorUdid = await ensureSimulatorBooted('iPhone 17'); + harness = createFlowdeckHarness(); + }); + + afterAll(() => { + harness.cleanup(); + }); + + describe('list', () => { + it('success', () => { + const result = harness.run(['simulator', 'list']); + writeFlowdeckFixture(__filename, 'list--success', result.text); + }); + }); + + describe('boot', () => { + it('error - invalid id', () => { + const result = harness.run([ + 'simulator', 'boot', '00000000-0000-0000-0000-000000000000', + ]); + writeFlowdeckFixture(__filename, 'boot--error-invalid-id', result.text); + }); + }); + + describe('open', () => { + it('success', () => { + const result = harness.run(['simulator', 'open']); + writeFlowdeckFixture(__filename, 'open--success', result.text); + }); + }); + + describe('set-appearance', () => { + it('success', () => { + const result = harness.run([ + 'ui', 'simulator', 'set-appearance', 'dark', '-S', simulatorUdid, + ]); + writeFlowdeckFixture(__filename, 'set-appearance--success', result.text); + }); + + it('error - invalid simulator', () => { + const result = harness.run([ + 'ui', 'simulator', 'set-appearance', 'dark', + '-S', '00000000-0000-0000-0000-000000000000', + ]); + writeFlowdeckFixture(__filename, 'set-appearance--error-invalid-simulator', result.text); + }); + }); + + describe('set-location', () => { + it('success', () => { + const result = harness.run([ + 'simulator', 'location', 'set', '37.7749,-122.4194', '--udid', simulatorUdid, + ]); + writeFlowdeckFixture(__filename, 'set-location--success', result.text); + }); + + it('error - invalid simulator', () => { + const result = harness.run([ + 'simulator', 'location', 'set', '37.7749,-122.4194', + '--udid', '00000000-0000-0000-0000-000000000000', + ]); + writeFlowdeckFixture(__filename, 'set-location--error-invalid-simulator', result.text); + }); + }); + + describe('erase', () => { + it('error - invalid id', () => { + const result = harness.run([ + 'simulator', 'erase', '00000000-0000-0000-0000-000000000000', + ]); + writeFlowdeckFixture(__filename, 'erase--error-invalid-id', result.text); + }); + }); + + describe('statusbar', () => { + // flowdeck doesn't have a direct statusbar command + it.skip('no flowdeck equivalent', () => {}); + }); + + describe('reset-location', () => { + // flowdeck doesn't have a reset-location command + it.skip('no flowdeck equivalent', () => {}); + }); +}); diff --git a/src/snapshot-tests/__tests__/simulator.flowdeck.test.ts b/src/snapshot-tests/__tests__/simulator.flowdeck.test.ts new file mode 100644 index 00000000..485f3b3d --- /dev/null +++ b/src/snapshot-tests/__tests__/simulator.flowdeck.test.ts @@ -0,0 +1,168 @@ +import { describe, it, beforeAll, afterAll, vi } from 'vitest'; +import { execSync } from 'node:child_process'; +import { createFlowdeckHarness } from '../flowdeck-harness.ts'; +import { writeFlowdeckFixture } from '../flowdeck-fixture-io.ts'; +import type { FlowdeckHarness } from '../flowdeck-harness.ts'; +import { ensureSimulatorBooted } from '../harness.ts'; + +const WORKSPACE = 'example_projects/iOS_Calculator/CalculatorApp.xcworkspace'; + +describe('simulator workflow (flowdeck)', () => { + let harness: FlowdeckHarness; + let simulatorUdid: string; + + beforeAll(async () => { + vi.setConfig({ testTimeout: 120_000 }); + harness = createFlowdeckHarness(); + simulatorUdid = await ensureSimulatorBooted('iPhone 17'); + }, 120_000); + + afterAll(() => { + harness.cleanup(); + }); + + describe('build', () => { + it('success', () => { + const result = harness.run([ + 'build', '-w', WORKSPACE, '-s', 'CalculatorApp', '-S', simulatorUdid, + ]); + writeFlowdeckFixture(__filename, 'build--success', result.text); + }, 120_000); + + it('error - wrong scheme', () => { + const result = harness.run([ + 'build', '-w', WORKSPACE, '-s', 'NONEXISTENT', '-S', simulatorUdid, + ]); + writeFlowdeckFixture(__filename, 'build--error-wrong-scheme', result.text); + }, 120_000); + }); + + describe('build-and-run', () => { + it('success', () => { + const result = harness.run([ + 'run', '-w', WORKSPACE, '-s', 'CalculatorApp', '-S', simulatorUdid, + ]); + writeFlowdeckFixture(__filename, 'build-and-run--success', result.text); + }, 120_000); + + it('error - wrong scheme', () => { + const result = harness.run([ + 'run', '-w', WORKSPACE, '-s', 'NONEXISTENT', '-S', simulatorUdid, + ]); + writeFlowdeckFixture(__filename, 'build-and-run--error-wrong-scheme', result.text); + }, 120_000); + }); + + describe('test', () => { + it('success', () => { + const result = harness.run([ + 'test', '-w', WORKSPACE, '-s', 'CalculatorApp', '-S', simulatorUdid, + '--only', 'CalculatorAppTests/CalculatorAppTests/testAddition', + ]); + writeFlowdeckFixture(__filename, 'test--success', result.text); + }, 120_000); + + it('failure - intentional test failure', () => { + const result = harness.run([ + 'test', '-w', WORKSPACE, '-s', 'CalculatorApp', '-S', simulatorUdid, + ]); + writeFlowdeckFixture(__filename, 'test--failure', result.text); + }, 120_000); + + it('error - wrong scheme', () => { + const result = harness.run([ + 'test', '-w', WORKSPACE, '-s', 'NONEXISTENT', '-S', simulatorUdid, + ]); + writeFlowdeckFixture(__filename, 'test--error-wrong-scheme', result.text); + }, 120_000); + }); + + describe('list', () => { + it('success', () => { + const result = harness.run(['simulator', 'list']); + writeFlowdeckFixture(__filename, 'list--success', result.text); + }, 120_000); + }); + + describe('install', () => { + it('success', () => { + const settingsOutput = execSync( + `xcodebuild -workspace ${WORKSPACE} -scheme CalculatorApp -showBuildSettings -destination 'platform=iOS Simulator,name=iPhone 17' 2>/dev/null`, + { encoding: 'utf8' }, + ); + const match = settingsOutput.match(/BUILT_PRODUCTS_DIR = (.+)/); + const appPath = `${match![1]!.trim()}/CalculatorApp.app`; + + const result = harness.run([ + 'uninstall', 'io.sentry.calculatorapp', '-s', simulatorUdid, + ]); + // Reinstall by running + const installResult = harness.run([ + 'run', '-w', WORKSPACE, '-s', 'CalculatorApp', '-S', simulatorUdid, '--no-build', + ]); + writeFlowdeckFixture(__filename, 'install--success', installResult.text); + }, 120_000); + + it('error - invalid app', () => { + const fs = require('node:fs'); + const os = require('node:os'); + const path = require('node:path'); + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'fd-sim-install-')); + const fakeApp = path.join(tmpDir, 'NotAnApp.app'); + fs.mkdirSync(fakeApp); + try { + // flowdeck doesn't have a direct install-to-sim command + // The closest is run --no-build which will fail with invalid app + const result = harness.run([ + 'run', '-w', fakeApp, '-S', simulatorUdid, '--no-build', + ]); + writeFlowdeckFixture(__filename, 'install--error-invalid-app', result.text); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }, 120_000); + }); + + describe('launch-app', () => { + it('success', () => { + const result = harness.run([ + 'run', '-w', WORKSPACE, '-s', 'CalculatorApp', '-S', simulatorUdid, '--no-build', + ]); + writeFlowdeckFixture(__filename, 'launch-app--success', result.text); + }, 120_000); + }); + + describe('screenshot', () => { + it('success', () => { + const result = harness.run([ + 'ui', 'simulator', 'screen', '-S', simulatorUdid, + ]); + writeFlowdeckFixture(__filename, 'screenshot--success', result.text); + }, 120_000); + + it('error - invalid simulator', () => { + const result = harness.run([ + 'ui', 'simulator', 'screen', '-S', '00000000-0000-0000-0000-000000000000', + ]); + writeFlowdeckFixture(__filename, 'screenshot--error-invalid-simulator', result.text); + }, 120_000); + }); + + describe('stop', () => { + it('success', () => { + const runResult = harness.run([ + 'run', '-w', WORKSPACE, '-s', 'CalculatorApp', '-S', simulatorUdid, '--no-build', + ]); + const appIdMatch = runResult.text.match(/App ID: ([A-F0-9]+)/); + const appId = appIdMatch ? appIdMatch[1] : 'io.sentry.calculatorapp'; + + const result = harness.run(['stop', appId]); + writeFlowdeckFixture(__filename, 'stop--success', result.text); + }, 120_000); + + it('error - no app', () => { + const result = harness.run(['stop', 'com.nonexistent.app']); + writeFlowdeckFixture(__filename, 'stop--error-no-app', result.text); + }, 120_000); + }); +}); diff --git a/src/snapshot-tests/__tests__/swift-package.flowdeck.test.ts b/src/snapshot-tests/__tests__/swift-package.flowdeck.test.ts new file mode 100644 index 00000000..29f951c3 --- /dev/null +++ b/src/snapshot-tests/__tests__/swift-package.flowdeck.test.ts @@ -0,0 +1,49 @@ +import { describe, it, beforeAll, afterAll, vi } from 'vitest'; +import { createFlowdeckHarness } from '../flowdeck-harness.ts'; +import { writeFlowdeckFixture } from '../flowdeck-fixture-io.ts'; +import type { FlowdeckHarness } from '../flowdeck-harness.ts'; + +describe('swift-package workflow (flowdeck)', () => { + let harness: FlowdeckHarness; + + beforeAll(() => { + vi.setConfig({ testTimeout: 120_000 }); + harness = createFlowdeckHarness(); + }, 120_000); + + afterAll(() => { + harness.cleanup(); + }); + + // flowdeck doesn't support raw Swift Package Manager directories. + // Its build/test/clean commands require an Xcode project or workspace. + describe('build', () => { + it.skip('flowdeck requires Xcode project, not raw SPM directory', () => {}); + }); + + describe('test', () => { + it.skip('flowdeck requires Xcode project, not raw SPM directory', () => {}); + }); + + describe('clean', () => { + it.skip('flowdeck requires Xcode project, not raw SPM directory', () => {}); + }); + + describe('run', () => { + it.skip('flowdeck has no SPM executable run equivalent', () => {}); + }); + + describe('list', () => { + it('success', () => { + const result = harness.run(['apps']); + writeFlowdeckFixture(__filename, 'list--success', result.text); + }); + }); + + describe('stop', () => { + it('error - no process', () => { + const result = harness.run(['stop', 'com.nonexistent.spm.app']); + writeFlowdeckFixture(__filename, 'stop--error-no-process', result.text); + }); + }); +}); diff --git a/src/snapshot-tests/__tests__/ui-automation.flowdeck.test.ts b/src/snapshot-tests/__tests__/ui-automation.flowdeck.test.ts new file mode 100644 index 00000000..696c0ffa --- /dev/null +++ b/src/snapshot-tests/__tests__/ui-automation.flowdeck.test.ts @@ -0,0 +1,205 @@ +import { execSync } from 'node:child_process'; +import { describe, it, beforeAll, afterAll, vi } from 'vitest'; +import { createFlowdeckHarness } from '../flowdeck-harness.ts'; +import { writeFlowdeckFixture } from '../flowdeck-fixture-io.ts'; +import { ensureSimulatorBooted } from '../harness.ts'; +import type { FlowdeckHarness } from '../flowdeck-harness.ts'; + +const WORKSPACE = 'example_projects/iOS_Calculator/CalculatorApp.xcworkspace'; +const BUNDLE_ID = 'io.sentry.calculatorapp'; +const INVALID_SIMULATOR_ID = '00000000-0000-0000-0000-000000000000'; + +describe('ui-automation workflow (flowdeck)', () => { + let harness: FlowdeckHarness; + let simulatorUdid: string; + + beforeAll(async () => { + vi.setConfig({ testTimeout: 120_000 }); + simulatorUdid = await ensureSimulatorBooted('iPhone 17'); + harness = createFlowdeckHarness(); + + harness.run([ + 'run', '-w', WORKSPACE, '-s', 'CalculatorApp', '-S', simulatorUdid, + ]); + + try { + execSync(`xcrun simctl launch ${simulatorUdid} ${BUNDLE_ID}`, { encoding: 'utf8' }); + } catch { + // App may already be running + } + await new Promise((resolve) => setTimeout(resolve, 3000)); + }); + + afterAll(() => { + harness.cleanup(); + }); + + describe('snapshot-ui', () => { + it('success - calculator app', () => { + const result = harness.run([ + 'ui', 'simulator', 'screen', '-S', simulatorUdid, + ]); + writeFlowdeckFixture(__filename, 'snapshot-ui--success', result.text); + }); + + it('error - invalid simulator', () => { + const result = harness.run([ + 'ui', 'simulator', 'screen', '-S', INVALID_SIMULATOR_ID, + ]); + writeFlowdeckFixture(__filename, 'snapshot-ui--error-no-simulator', result.text); + }); + }); + + describe('tap', () => { + it('success', () => { + const result = harness.run([ + 'ui', 'simulator', 'tap', '--point', '100,400', '-S', simulatorUdid, + ]); + writeFlowdeckFixture(__filename, 'tap--success', result.text); + }); + + it('error - invalid simulator', () => { + const result = harness.run([ + 'ui', 'simulator', 'tap', '--point', '100,100', '-S', INVALID_SIMULATOR_ID, + ]); + writeFlowdeckFixture(__filename, 'tap--error-no-simulator', result.text); + }); + }); + + describe('touch', () => { + it('success', () => { + const downResult = harness.run([ + 'ui', 'simulator', 'touch', 'down', '100,400', '-S', simulatorUdid, + ]); + const upResult = harness.run([ + 'ui', 'simulator', 'touch', 'up', '100,400', '-S', simulatorUdid, + ]); + writeFlowdeckFixture(__filename, 'touch--success', downResult.text + upResult.text); + }); + + it('error - invalid simulator', () => { + const result = harness.run([ + 'ui', 'simulator', 'touch', 'down', '100,400', '-S', INVALID_SIMULATOR_ID, + ]); + writeFlowdeckFixture(__filename, 'touch--error-no-simulator', result.text); + }); + }); + + describe('long-press', () => { + it('success', () => { + const result = harness.run([ + 'ui', 'simulator', 'tap', '--point', '100,400', '--duration', '0.5', + '-S', simulatorUdid, + ]); + writeFlowdeckFixture(__filename, 'long-press--success', result.text); + }); + + it('error - invalid simulator', () => { + const result = harness.run([ + 'ui', 'simulator', 'tap', '--point', '100,400', '--duration', '0.5', + '-S', INVALID_SIMULATOR_ID, + ]); + writeFlowdeckFixture(__filename, 'long-press--error-no-simulator', result.text); + }); + }); + + describe('swipe', () => { + it('success', () => { + const result = harness.run([ + 'ui', 'simulator', 'swipe', '--from', '200,400', '--to', '200,200', + '-S', simulatorUdid, + ]); + writeFlowdeckFixture(__filename, 'swipe--success', result.text); + }); + + it('error - invalid simulator', () => { + const result = harness.run([ + 'ui', 'simulator', 'swipe', '--from', '200,400', '--to', '200,200', + '-S', INVALID_SIMULATOR_ID, + ]); + writeFlowdeckFixture(__filename, 'swipe--error-no-simulator', result.text); + }); + }); + + describe('gesture (scroll-down)', () => { + it('success', () => { + const result = harness.run([ + 'ui', 'simulator', 'scroll', '--direction', 'DOWN', + '-S', simulatorUdid, + ]); + writeFlowdeckFixture(__filename, 'gesture--success', result.text); + }); + + it('error - invalid simulator', () => { + const result = harness.run([ + 'ui', 'simulator', 'scroll', '--direction', 'DOWN', + '-S', INVALID_SIMULATOR_ID, + ]); + writeFlowdeckFixture(__filename, 'gesture--error-no-simulator', result.text); + }); + }); + + describe('button', () => { + it('success', () => { + const result = harness.run([ + 'ui', 'simulator', 'button', 'home', '-S', simulatorUdid, + ]); + writeFlowdeckFixture(__filename, 'button--success', result.text); + }); + + it('error - invalid simulator', () => { + const result = harness.run([ + 'ui', 'simulator', 'button', 'home', '-S', INVALID_SIMULATOR_ID, + ]); + writeFlowdeckFixture(__filename, 'button--error-no-simulator', result.text); + }); + }); + + describe('key-press', () => { + it('success', () => { + const result = harness.run([ + 'ui', 'simulator', 'key', '4', '-S', simulatorUdid, + ]); + writeFlowdeckFixture(__filename, 'key-press--success', result.text); + }); + + it('error - invalid simulator', () => { + const result = harness.run([ + 'ui', 'simulator', 'key', '4', '-S', INVALID_SIMULATOR_ID, + ]); + writeFlowdeckFixture(__filename, 'key-press--error-no-simulator', result.text); + }); + }); + + describe('key-sequence', () => { + it('success', () => { + const result = harness.run([ + 'ui', 'simulator', 'key', '--sequence', '4,5,6', '-S', simulatorUdid, + ]); + writeFlowdeckFixture(__filename, 'key-sequence--success', result.text); + }); + + it('error - invalid simulator', () => { + const result = harness.run([ + 'ui', 'simulator', 'key', '--sequence', '4,5,6', '-S', INVALID_SIMULATOR_ID, + ]); + writeFlowdeckFixture(__filename, 'key-sequence--error-no-simulator', result.text); + }); + }); + + describe('type-text', () => { + it('success', () => { + const result = harness.run([ + 'ui', 'simulator', 'type', 'hello', '-S', simulatorUdid, + ]); + writeFlowdeckFixture(__filename, 'type-text--success', result.text); + }); + + it('error - invalid simulator', () => { + const result = harness.run([ + 'ui', 'simulator', 'type', 'hello', '-S', INVALID_SIMULATOR_ID, + ]); + writeFlowdeckFixture(__filename, 'type-text--error-no-simulator', result.text); + }); + }); +}); diff --git a/src/snapshot-tests/__tests__/utilities.flowdeck.test.ts b/src/snapshot-tests/__tests__/utilities.flowdeck.test.ts new file mode 100644 index 00000000..228fbaed --- /dev/null +++ b/src/snapshot-tests/__tests__/utilities.flowdeck.test.ts @@ -0,0 +1,34 @@ +import { describe, it, beforeAll, afterAll } from 'vitest'; +import { createFlowdeckHarness } from '../flowdeck-harness.ts'; +import { writeFlowdeckFixture } from '../flowdeck-fixture-io.ts'; +import type { FlowdeckHarness } from '../flowdeck-harness.ts'; + +const WORKSPACE = 'example_projects/iOS_Calculator/CalculatorApp.xcworkspace'; + +describe('utilities workflow (flowdeck)', () => { + let harness: FlowdeckHarness; + + beforeAll(() => { + harness = createFlowdeckHarness(); + }); + + afterAll(() => { + harness.cleanup(); + }); + + describe('clean', () => { + it('success', () => { + const result = harness.run([ + 'clean', '-w', WORKSPACE, '-s', 'CalculatorApp', + ]); + writeFlowdeckFixture(__filename, 'clean--success', result.text); + }, 120000); + + it('error - wrong scheme', () => { + const result = harness.run([ + 'clean', '-w', WORKSPACE, '-s', 'NONEXISTENT', + ]); + writeFlowdeckFixture(__filename, 'clean--error-wrong-scheme', result.text); + }, 120000); + }); +}); diff --git a/src/snapshot-tests/__tests__/workflow-discovery.flowdeck.test.ts b/src/snapshot-tests/__tests__/workflow-discovery.flowdeck.test.ts new file mode 100644 index 00000000..813dc076 --- /dev/null +++ b/src/snapshot-tests/__tests__/workflow-discovery.flowdeck.test.ts @@ -0,0 +1,6 @@ +import { describe, it } from 'vitest'; + +describe('workflow-discovery workflow (flowdeck)', () => { + // flowdeck doesn't have a workflow discovery/management system + it.skip('no flowdeck equivalent for workflow discovery', () => {}); +}); diff --git a/src/snapshot-tests/__tests__/xcode-ide.flowdeck.test.ts b/src/snapshot-tests/__tests__/xcode-ide.flowdeck.test.ts new file mode 100644 index 00000000..758cf066 --- /dev/null +++ b/src/snapshot-tests/__tests__/xcode-ide.flowdeck.test.ts @@ -0,0 +1,6 @@ +import { describe, it } from 'vitest'; + +describe('xcode-ide workflow (flowdeck)', () => { + // flowdeck doesn't have Xcode IDE bridge tools + it.skip('no flowdeck equivalent for xcode-ide workflow', () => {}); +}); diff --git a/src/snapshot-tests/flowdeck-fixture-io.ts b/src/snapshot-tests/flowdeck-fixture-io.ts new file mode 100644 index 00000000..e7cf81da --- /dev/null +++ b/src/snapshot-tests/flowdeck-fixture-io.ts @@ -0,0 +1,12 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +const FIXTURES_DIR = path.resolve(process.cwd(), 'src/snapshot-tests/__flowdeck_fixtures__'); + +export function writeFlowdeckFixture(testFilePath: string, scenario: string, content: string): void { + const workflow = path.basename(testFilePath, '.flowdeck.test.ts'); + const fixturePath = path.join(FIXTURES_DIR, workflow, `${scenario}.txt`); + const dir = path.dirname(fixturePath); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(fixturePath, content, 'utf8'); +} diff --git a/src/snapshot-tests/flowdeck-harness.ts b/src/snapshot-tests/flowdeck-harness.ts new file mode 100644 index 00000000..9fe4650a --- /dev/null +++ b/src/snapshot-tests/flowdeck-harness.ts @@ -0,0 +1,36 @@ +import { spawnSync } from 'node:child_process'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +export interface FlowdeckResult { + text: string; + isError: boolean; +} + +export interface FlowdeckHarness { + run(args: string[]): FlowdeckResult; + cleanup(): void; +} + +const PTY_HELPER = join(fileURLToPath(import.meta.url), '..', 'flowdeck-pty.py'); + +export function createFlowdeckHarness(): FlowdeckHarness { + function run(args: string[]): FlowdeckResult { + const result = spawnSync('python3', [PTY_HELPER, ...args], { + encoding: 'utf8', + timeout: 120_000, + cwd: process.cwd(), + }); + + const text = result.stdout ?? ''; + + return { + text, + isError: result.status !== 0, + }; + } + + function cleanup(): void {} + + return { run, cleanup }; +} diff --git a/src/snapshot-tests/flowdeck-pty.py b/src/snapshot-tests/flowdeck-pty.py new file mode 100644 index 00000000..db7bfcc1 --- /dev/null +++ b/src/snapshot-tests/flowdeck-pty.py @@ -0,0 +1,36 @@ +"""Spawn flowdeck inside a PTY so it emits full ANSI colour sequences.""" +import os +import pty +import subprocess +import sys + +def main(): + master, slave = pty.openpty() + env = dict(os.environ, TERM="xterm-256color", COLUMNS="120", LINES="50") + p = subprocess.Popen( + ["flowdeck"] + sys.argv[1:], + stdout=slave, + stderr=slave, + stdin=slave, + env=env, + close_fds=True, + ) + os.close(slave) + + output = b"" + while True: + try: + data = os.read(master, 4096) + if not data: + break + output += data + except OSError: + break + + os.close(master) + rc = p.wait() + sys.stdout.buffer.write(output) + sys.exit(rc) + +if __name__ == "__main__": + main() diff --git a/src/snapshot-tests/normalize.ts b/src/snapshot-tests/normalize.ts index a381c19c..7582bc4b 100644 --- a/src/snapshot-tests/normalize.ts +++ b/src/snapshot-tests/normalize.ts @@ -11,7 +11,7 @@ const PROCESS_ID_REGEX = /Process ID: \d+/g; const PROCESS_INLINE_PID_REGEX = /process \d+/g; const THREAD_ID_REGEX = /Thread \d{5,}/g; const HEX_ADDRESS_REGEX = /0x[0-9a-fA-F]{8,}/g; -const LLDB_MODULE_DYLIB_REGEX = /CalculatorApp[^\s`]*/g; + const LLDB_FRAME_OFFSET_REGEX = /(`[^`]+):(\d+)$/gm; const DERIVED_DATA_HASH_REGEX = /(DerivedData\/[A-Za-z0-9_]+)-[a-z]{28}\b/g; const PROGRESS_LINE_REGEX = /^โ€บ.*\n*/gm; @@ -85,7 +85,6 @@ export function normalizeSnapshotOutput(text: string): string { normalized = normalized.replace(PROCESS_INLINE_PID_REGEX, 'process '); normalized = normalized.replace(THREAD_ID_REGEX, 'Thread '); normalized = normalized.replace(HEX_ADDRESS_REGEX, ''); - normalized = normalized.replace(LLDB_MODULE_DYLIB_REGEX, ''); normalized = normalized.replace(LLDB_FRAME_OFFSET_REGEX, '$1:'); normalized = normalized.replace(RESULT_BUNDLE_LINE_REGEX, ''); normalized = normalized.replace(PROGRESS_LINE_REGEX, ''); diff --git a/src/types/pipeline-events.ts b/src/types/pipeline-events.ts index 60eee547..751b8475 100644 --- a/src/types/pipeline-events.ts +++ b/src/types/pipeline-events.ts @@ -183,6 +183,7 @@ export interface BuildRunResultNoticeData { bundleId?: string; appId?: string; processId?: number; + buildLogPath?: string; } export type NoticeCode = 'build-run-step' | 'build-run-result'; diff --git a/src/utils/__tests__/test-common.test.ts b/src/utils/__tests__/test-common.test.ts index 68fea394..0487031a 100644 --- a/src/utils/__tests__/test-common.test.ts +++ b/src/utils/__tests__/test-common.test.ts @@ -123,8 +123,8 @@ describe('handleTestLogic (pipeline)', () => { expect(renderedText).toContain('Resolving packages'); expect(renderedText).toContain('Compiling'); expect(renderedText).toContain('Running tests'); - expect(renderedText).toContain('AppTests/testFailure: XCTAssertEqual failed'); - expect(renderedText).toContain('XCTAssertEqual failed'); + expect(renderedText).toContain('AppTests'); + expect(renderedText).toContain('testFailure: XCTAssertEqual failed'); expect(renderedText).toContain('Test failed.'); expect(renderedText).toContain('Total: 1'); expect(renderedText).toContain('Failed: 1'); diff --git a/src/utils/__tests__/xcodebuild-output.test.ts b/src/utils/__tests__/xcodebuild-output.test.ts index 66d0c3ff..f1de19dd 100644 --- a/src/utils/__tests__/xcodebuild-output.test.ts +++ b/src/utils/__tests__/xcodebuild-output.test.ts @@ -124,6 +124,81 @@ describe('xcodebuild-output', () => { expect(textContent).not.toContain('Should not render'); }); + it('renders build logs in a metadata tree after the summary when no tail detail tree exists', () => { + const started = startBuildPipeline({ + operation: 'BUILD', + toolName: 'build_macos', + params: { scheme: 'MyApp' }, + message: '\u{1F528} Build\n\n Scheme: MyApp', + }); + + const pending = createPendingXcodebuildResponse(started, { + content: [], + isError: false, + }); + + const finalized = finalizePendingXcodebuildResponse(pending, { + nextSteps: [{ label: 'Get built macOS app path', cliTool: 'get-app-path', workflow: 'macos' }], + }); + const events = (finalized._meta?.events ?? []) as Array<{ + type: string; + items?: Array<{ label: string; value: string }>; + }>; + expect(events.at(-3)?.type).toBe('summary'); + expect(events.at(-2)).toEqual( + expect.objectContaining({ + type: 'detail-tree', + items: [ + expect.objectContaining({ + label: 'Build Logs', + value: expect.stringContaining('build_macos_'), + }), + ], + }), + ); + expect(events.at(-1)?.type).toBe('next-steps'); + + const textContent = finalized.content + .filter((item) => item.type === 'text') + .map((item) => item.text) + .join('\n'); + + expect(textContent).toContain('\u{2705} Build succeeded.'); + expect(textContent).toContain('\u2514 Build Logs:'); + expect(textContent.indexOf('\u2514 Build Logs:')).toBeLessThan(textContent.indexOf('Next steps:')); + }); + + it('surfaces parser debug logs with a warning notice before summary', () => { + const started = startBuildPipeline({ + operation: 'BUILD', + toolName: 'build_run_macos', + params: { scheme: 'MyApp' }, + message: '\u{1F680} Build & Run\n\n Scheme: MyApp', + }); + + started.pipeline.onStdout('UNRECOGNIZED LINE\n'); + + const pending = createPendingXcodebuildResponse( + started, + { + content: [], + isError: false, + }, + { + includeParserDebugFileRef: true, + }, + ); + + const finalized = finalizePendingXcodebuildResponse(pending); + const textContent = finalized.content + .filter((item) => item.type === 'text') + .map((item) => item.text) + .join('\n'); + + expect(textContent).toContain('โš ๏ธ Parsing issue detected - debug log:'); + expect(textContent).toContain('Parser Debug Log:'); + }); + it('finalizes summary, execution-derived footer, then next steps in order', () => { const started = startBuildPipeline({ operation: 'BUILD', @@ -161,13 +236,31 @@ describe('xcodebuild-output', () => { ], }); - const events = (finalized._meta?.events ?? []) as Array<{ type: string }>; + const events = (finalized._meta?.events ?? []) as Array<{ + type: string; + items?: Array<{ label: string; value: string }>; + }>; const lastThreeTypes = events.slice(-4).map((e) => e.type); expect(lastThreeTypes).toContain('summary'); expect(lastThreeTypes).toContain('status-line'); expect(lastThreeTypes).toContain('detail-tree'); expect(lastThreeTypes).toContain('next-steps'); + const detailTreeEvents = events.filter( + (event): event is { type: 'detail-tree'; items: Array<{ label: string; value: string }> } => + event.type === 'detail-tree' && Array.isArray(event.items), + ); + const lastDetailTree = detailTreeEvents[detailTreeEvents.length - 1]; + expect(lastDetailTree?.items).toEqual( + expect.arrayContaining([ + expect.objectContaining({ label: 'App Path', value: '/tmp/build/MyApp.app' }), + expect.objectContaining({ + label: 'Build Logs', + value: expect.stringContaining('build_run_macos_'), + }), + ]), + ); + const textContent = finalized.content .filter((item) => item.type === 'text') .map((item) => item.text); diff --git a/src/utils/build-preflight.ts b/src/utils/build-preflight.ts index 34dd0772..62fd9b14 100644 --- a/src/utils/build-preflight.ts +++ b/src/utils/build-preflight.ts @@ -51,46 +51,46 @@ export function formatToolPreflight(params: ToolPreflightParams): string { const lines: string[] = [`${emoji} ${params.operation}`, '']; if (params.scheme) { - lines.push(` Scheme: ${params.scheme}`); + lines.push(` Scheme: ${params.scheme}`); } if (params.workspacePath) { - lines.push(` Workspace: ${displayPath(params.workspacePath)}`); + lines.push(` Workspace: ${displayPath(params.workspacePath)}`); } else if (params.projectPath) { - lines.push(` Project: ${displayPath(params.projectPath)}`); + lines.push(` Project: ${displayPath(params.projectPath)}`); } if (params.configuration) { - lines.push(` Configuration: ${params.configuration}`); + lines.push(` Configuration: ${params.configuration}`); } if (params.platform) { - lines.push(` Platform: ${params.platform}`); + lines.push(` Platform: ${params.platform}`); } if (params.simulatorName) { - lines.push(` Simulator: ${params.simulatorName}`); + lines.push(` Simulator: ${params.simulatorName}`); } else if (params.simulatorId) { - lines.push(` Simulator: ${params.simulatorId}`); + lines.push(` Simulator: ${params.simulatorId}`); } if (params.deviceId) { - lines.push(` Device: ${params.deviceId}`); + lines.push(` Device: ${params.deviceId}`); } if (params.arch) { - lines.push(` Architecture: ${params.arch}`); + lines.push(` Architecture: ${params.arch}`); } if (params.xcresultPath) { - lines.push(` xcresult: ${displayPath(params.xcresultPath)}`); + lines.push(` xcresult: ${displayPath(params.xcresultPath)}`); } if (params.file) { - lines.push(` File: ${params.file}`); + lines.push(` File: ${params.file}`); } if (params.targetFilter) { - lines.push(` Target Filter: ${params.targetFilter}`); + lines.push(` Target Filter: ${params.targetFilter}`); } lines.push(''); diff --git a/src/utils/renderers/__tests__/event-formatting.test.ts b/src/utils/renderers/__tests__/event-formatting.test.ts index 6064005a..70724233 100644 --- a/src/utils/renderers/__tests__/event-formatting.test.ts +++ b/src/utils/renderers/__tests__/event-formatting.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from 'vitest'; import { extractGroupedCompilerError, formatGroupedCompilerErrors, + formatGroupedTestFailures, formatHumanCompilerErrorEvent, formatHumanCompilerWarningEvent, formatHeaderEvent, @@ -220,4 +221,33 @@ describe('event formatting', () => { }), ).toBe(' \u2514 App Path: /tmp/build/MyApp.app'); }); + + it('groups test failures by test case within a suite', () => { + const rendered = formatGroupedTestFailures([ + { + type: 'test-failure', + timestamp: '2026-03-20T12:00:00.000Z', + operation: 'TEST', + suite: 'MathTests', + test: 'testAdd', + message: 'XCTAssertEqual failed', + location: '/tmp/MathTests.swift:12', + }, + { + type: 'test-failure', + timestamp: '2026-03-20T12:00:01.000Z', + operation: 'TEST', + suite: 'MathTests', + test: 'testAdd', + message: 'Expected 4, got 5', + location: '/tmp/MathTests.swift:13', + }, + ]); + + expect(rendered).toContain('Test Failures (2):'); + expect(rendered).toContain(' MathTests'); + expect(rendered).toContain(' โœ— testAdd'); + expect(rendered).toContain(' XCTAssertEqual failed'); + expect(rendered).toContain(' Expected 4, got 5'); + }); }); diff --git a/src/utils/renderers/cli-text-renderer.ts b/src/utils/renderers/cli-text-renderer.ts index 79be9485..33789c95 100644 --- a/src/utils/renderers/cli-text-renderer.ts +++ b/src/utils/renderers/cli-text-renderer.ts @@ -1,6 +1,7 @@ import type { CompilerErrorEvent, CompilerWarningEvent, + TestFailureEvent, PipelineEvent, } from '../../types/pipeline-events.ts'; import { createCliProgressReporter } from '../cli-progress-reporter.ts'; @@ -19,7 +20,7 @@ import { formatFileRefEvent, formatGroupedCompilerErrors, formatGroupedWarnings, - formatTestFailureEvent, + formatGroupedTestFailures, formatSummaryEvent, formatNextStepsEvent, } from './event-formatting.ts'; @@ -36,6 +37,7 @@ export function createCliTextRenderer(options: { interactive: boolean }): Xcodeb const reporter = createCliProgressReporter(); const groupedCompilerErrors: CompilerErrorEvent[] = []; const groupedWarnings: CompilerWarningEvent[] = []; + const groupedTestFailures: TestFailureEvent[] = []; let pendingTransientRuntimeLine: string | null = null; let diagnosticBaseDir: string | null = null; let hasDurableRuntimeContent = false; @@ -87,7 +89,7 @@ export function createCliTextRenderer(options: { interactive: boolean }): Xcodeb break; } - writeDurable(formatStatusLineEvent(event)); + writeSection(formatStatusLineEvent(event)); break; } @@ -107,7 +109,7 @@ export function createCliTextRenderer(options: { interactive: boolean }): Xcodeb } case 'file-ref': { - writeDurable(formatFileRefEvent(event)); + writeSection(formatFileRefEvent(event)); break; } @@ -135,8 +137,7 @@ export function createCliTextRenderer(options: { interactive: boolean }): Xcodeb } case 'test-failure': { - flushPendingTransientRuntimeLine(); - writeDurable(formatTestFailureEvent(event, { baseDir: diagnosticBaseDir ?? undefined })); + groupedTestFailures.push(event); break; } @@ -144,6 +145,11 @@ export function createCliTextRenderer(options: { interactive: boolean }): Xcodeb const diagOpts = { baseDir: diagnosticBaseDir ?? undefined }; const diagnosticSections: string[] = []; + if (groupedTestFailures.length > 0) { + diagnosticSections.push(formatGroupedTestFailures(groupedTestFailures, diagOpts)); + groupedTestFailures.length = 0; + } + if (groupedWarnings.length > 0) { diagnosticSections.push(formatGroupedWarnings(groupedWarnings, diagOpts)); groupedWarnings.length = 0; diff --git a/src/utils/renderers/event-formatting.ts b/src/utils/renderers/event-formatting.ts index 6734497e..f0f1597e 100644 --- a/src/utils/renderers/event-formatting.ts +++ b/src/utils/renderers/event-formatting.ts @@ -496,3 +496,49 @@ export function formatTestDiscoveryEvent(event: TestDiscoveryEvent): string { export function formatNextStepsEvent(event: NextStepsEvent, runtime: 'cli' | 'mcp'): string { return renderNextStepsSection(event.steps, runtime); } + +export function formatGroupedTestFailures( + events: TestFailureEvent[], + options?: DiagnosticFormattingOptions, +): string { + if (events.length === 0) return ''; + + const groupedSuites = new Map>(); + for (const event of events) { + const suiteKey = event.suite ?? '(Unknown Suite)'; + const testKey = event.test ?? '(unknown test)'; + const suiteGroup = groupedSuites.get(suiteKey) ?? new Map(); + const testGroup = suiteGroup.get(testKey) ?? []; + testGroup.push(event); + suiteGroup.set(testKey, testGroup); + groupedSuites.set(suiteKey, suiteGroup); + } + + const lines: string[] = [`Test Failures (${events.length}):`, '']; + + for (const [suite, tests] of groupedSuites.entries()) { + lines.push(` ${suite}`); + for (const [testName, failures] of tests.entries()) { + lines.push(` โœ— ${testName}`); + for (const failure of failures) { + lines.push(` ${failure.message}`); + if (failure.location) { + const locParts = failure.location.match(/^(.+?)(:(?:\d+)(?::\d+)?)$/); + if (locParts) { + const filePath = formatDiagnosticFilePath(locParts[1], options); + lines.push(` ${filePath}${locParts[2]}`); + } else { + lines.push(` ${failure.location}`); + } + } + } + } + lines.push(''); + } + + while (lines.at(-1) === '') { + lines.pop(); + } + + return lines.join('\n'); +} diff --git a/src/utils/renderers/mcp-renderer.ts b/src/utils/renderers/mcp-renderer.ts index b9dcba54..1b916221 100644 --- a/src/utils/renderers/mcp-renderer.ts +++ b/src/utils/renderers/mcp-renderer.ts @@ -1,6 +1,7 @@ import type { CompilerErrorEvent, CompilerWarningEvent, + TestFailureEvent, PipelineEvent, } from '../../types/pipeline-events.ts'; import type { ToolResponseContent } from '../../types/common.ts'; @@ -17,7 +18,7 @@ import { formatFileRefEvent, formatGroupedCompilerErrors, formatGroupedWarnings, - formatTestFailureEvent, + formatGroupedTestFailures, formatTestDiscoveryEvent, formatSummaryEvent, formatNextStepsEvent, @@ -26,14 +27,15 @@ import { export function createMcpRenderer(): XcodebuildRenderer & { getContent(): ToolResponseContent[]; } { - const content: ToolResponseContent[] = []; + const contentParts: string[] = []; const suppressWarnings = sessionStore.get('suppressWarnings'); const groupedCompilerErrors: CompilerErrorEvent[] = []; const groupedWarnings: CompilerWarningEvent[] = []; + const groupedTestFailures: TestFailureEvent[] = []; let diagnosticBaseDir: string | null = null; function pushText(text: string): void { - content.push({ type: 'text', text }); + contentParts.push(text); } function pushSection(text: string): void { @@ -55,7 +57,7 @@ export function createMcpRenderer(): XcodebuildRenderer & { } case 'status-line': { - pushText(formatStatusLineEvent(event)); + pushSection(formatStatusLineEvent(event)); break; } @@ -75,7 +77,7 @@ export function createMcpRenderer(): XcodebuildRenderer & { } case 'file-ref': { - pushText(formatFileRefEvent(event)); + pushSection(formatFileRefEvent(event)); break; } @@ -102,13 +104,17 @@ export function createMcpRenderer(): XcodebuildRenderer & { } case 'test-failure': { - pushText(formatTestFailureEvent(event, { baseDir: diagnosticBaseDir ?? undefined })); + groupedTestFailures.push(event); break; } case 'summary': { const diagOpts = { baseDir: diagnosticBaseDir ?? undefined }; const diagnosticSections: string[] = []; + if (groupedTestFailures.length > 0) { + diagnosticSections.push(formatGroupedTestFailures(groupedTestFailures, diagOpts)); + groupedTestFailures.length = 0; + } if (groupedWarnings.length > 0) { diagnosticSections.push(formatGroupedWarnings(groupedWarnings, diagOpts)); groupedWarnings.length = 0; @@ -137,7 +143,11 @@ export function createMcpRenderer(): XcodebuildRenderer & { }, getContent(): ToolResponseContent[] { - return [...content]; + if (contentParts.length === 0) { + return []; + } + + return [{ type: 'text', text: contentParts.join('') }]; }, }; } diff --git a/src/utils/xcodebuild-event-parser.ts b/src/utils/xcodebuild-event-parser.ts index 3b731f2d..8700fcd9 100644 --- a/src/utils/xcodebuild-event-parser.ts +++ b/src/utils/xcodebuild-event-parser.ts @@ -30,7 +30,11 @@ function resolveStageFromLine(line: string): XcodebuildStage | null { if (linkPatterns.some((pattern) => pattern.test(line))) { return 'LINKING'; } - if (/^Testing started$/u.test(line) || /^Test Suite .+ started/u.test(line) || /^[โ—‡] Test run started/u.test(line)) { + if ( + /^Testing started$/u.test(line) || + /^Test Suite .+ started/u.test(line) || + /^[โ—‡] Test run started/u.test(line) + ) { return 'RUN_TESTS'; } return null; @@ -63,6 +67,29 @@ function parseWarningLine(line: string): { location?: string; message: string } return null; } +const IGNORED_NOISE_PATTERNS = [ + /^Command line invocation:$/u, + /^\s*\/Applications\/Xcode[^\s]+\/Contents\/Developer\/usr\/bin\/xcodebuild\b/u, + /^\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2}\.\d+\s+xcodebuild\[.+\]\s+Writing error result bundle to\s+/u, + /^Build settings from command line:$/u, + /^(?:COMPILER_INDEX_STORE_ENABLE|ONLY_ACTIVE_ARCH)\s*=\s*.+$/u, + /^Resolve Package Graph$/u, + /^Resolved source packages:$/u, + /^\s*[A-Za-z0-9_.-]+:\s+.+$/u, + /^--- xcodebuild: WARNING: Using the first of multiple matching destinations:$/u, + /^\{\s*platform:.+\}$/u, + /^(?:ComputePackagePrebuildTargetDependencyGraph|Prepare packages|CreateBuildRequest|SendProjectDescription|CreateBuildOperation|ComputeTargetDependencyGraph|GatherProvisioningInputs|CreateBuildDescription)$/u, + /^Target '.+' in project '.+' \(no dependencies\)$/u, + /^(?:Build description signature|Build description path):\s+.+$/u, + /^(?:ExecuteExternalTool|ClangStatCache|CopySwiftLibs|builtin-infoPlistUtility|builtin-swiftStdLibTool)\b/u, + /^cd\s+.+$/u, + /^\*\* BUILD SUCCEEDED \*\*$/u, +]; + +function isIgnoredNoiseLine(line: string): boolean { + return IGNORED_NOISE_PATTERNS.some((pattern) => pattern.test(line)); +} + function now(): string { return new Date().toISOString(); } @@ -70,6 +97,7 @@ function now(): string { export interface EventParserOptions { operation: XcodebuildOperation; onEvent: (event: PipelineEvent) => void; + onUnrecognizedLine?: (line: string) => void; } export interface XcodebuildEventParser { @@ -80,7 +108,7 @@ export interface XcodebuildEventParser { } export function createXcodebuildEventParser(options: EventParserOptions): XcodebuildEventParser { - const { operation, onEvent } = options; + const { operation, onEvent, onUnrecognizedLine } = options; let stdoutBuffer = ''; let stderrBuffer = ''; @@ -331,12 +359,20 @@ export function createXcodebuildEventParser(options: EventParserOptions): Xcodeb return; } + if (isIgnoredNoiseLine(line)) { + return; + } + // Capture xcresult path from xcodebuild output const xcresultMatch = line.match(/^\s*(\S+\.xcresult)\s*$/u); if (xcresultMatch) { detectedXcresultPath = xcresultMatch[1]; return; } + + if (onUnrecognizedLine) { + onUnrecognizedLine(line); + } } function drainLines(buffer: string, chunk: string): string { diff --git a/src/utils/xcodebuild-log-capture.ts b/src/utils/xcodebuild-log-capture.ts new file mode 100644 index 00000000..5e56684c --- /dev/null +++ b/src/utils/xcodebuild-log-capture.ts @@ -0,0 +1,72 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import * as os from 'node:os'; + +const LOG_DIR = path.join(os.tmpdir(), 'xcodebuildmcp', 'logs'); + +function ensureLogDir(): void { + fs.mkdirSync(LOG_DIR, { recursive: true }); +} + +function generateLogFileName(toolName: string): string { + const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); + return `${toolName}_${timestamp}.log`; +} + +export interface LogCapture { + write(chunk: string): void; + readonly path: string; + close(): void; +} + +export function createLogCapture(toolName: string): LogCapture { + ensureLogDir(); + const logPath = path.join(LOG_DIR, 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; + ensureLogDir(); + const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); + const debugPath = path.join(LOG_DIR, `${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 index 4355fd42..474ec65f 100644 --- a/src/utils/xcodebuild-output.ts +++ b/src/utils/xcodebuild-output.ts @@ -23,6 +23,8 @@ interface PendingXcodebuildState { fallbackContent: ToolResponseContent[]; tailEvents: PipelineEvent[]; errorFallbackPolicy: ErrorFallbackPolicy; + includeBuildLogFileRef: boolean; + includeParserDebugFileRef: boolean; } function createPipelineOutputMeta( @@ -131,6 +133,10 @@ export function createBuildRunResultEvents(data: BuildRunResultNoticeData): Pipe items.push({ label: 'Process ID', value: String(data.processId) }); } + if (data.buildLogPath) { + items.push({ label: 'Build Logs', value: data.buildLogPath }); + } + if (data.launchState !== 'requested') { items.push({ label: 'Launch', value: 'Running' }); } @@ -194,6 +200,8 @@ interface PendingXcodebuildResponseOptions { emitSummary?: boolean; tailEvents?: PipelineEvent[]; errorFallbackPolicy?: ErrorFallbackPolicy; + includeBuildLogFileRef?: boolean; + includeParserDebugFileRef?: boolean; } export function createPendingXcodebuildResponse( @@ -214,6 +222,8 @@ export function createPendingXcodebuildResponse( fallbackContent: response.isError ? response.content : [], tailEvents: options.tailEvents ?? [], errorFallbackPolicy: options.errorFallbackPolicy ?? 'always', + includeBuildLogFileRef: options.includeBuildLogFileRef ?? true, + includeParserDebugFileRef: options.includeParserDebugFileRef ?? false, } satisfies PendingXcodebuildState, }, }; @@ -251,6 +261,8 @@ export function finalizePendingXcodebuildResponse( const pipelineResult = pending.started.pipeline.finalize(!response.isError, durationMs, { emitSummary: pending.emitSummary, tailEvents, + includeBuildLogFileRef: pending.includeBuildLogFileRef, + includeParserDebugFileRef: pending.includeParserDebugFileRef, }); const hasStructuredDiagnostics = diff --git a/src/utils/xcodebuild-pipeline.ts b/src/utils/xcodebuild-pipeline.ts index 3e3da31a..7b515dcf 100644 --- a/src/utils/xcodebuild-pipeline.ts +++ b/src/utils/xcodebuild-pipeline.ts @@ -11,6 +11,8 @@ import { resolveRenderers } from './renderers/index.ts'; import type { XcodebuildRenderer } from './renderers/index.ts'; import { displayPath } from './build-preflight.ts'; import { formatDeviceId } from './device-name-resolver.ts'; +import { createLogCapture, createParserDebugCapture } from './xcodebuild-log-capture.ts'; +import { log as appLog } from './logging/index.ts'; export interface PipelineOptions { operation: XcodebuildOperation; @@ -28,6 +30,8 @@ export interface PipelineResult { export interface PipelineFinalizeOptions { emitSummary?: boolean; tailEvents?: PipelineEvent[]; + includeBuildLogFileRef?: boolean; + includeParserDebugFileRef?: boolean; } export interface XcodebuildPipeline { @@ -41,6 +45,7 @@ export interface XcodebuildPipeline { ): PipelineResult; highestStageRank(): number; xcresultPath: string | null; + logPath: string; } export interface StartedPipeline { @@ -48,6 +53,54 @@ export interface StartedPipeline { 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 existingBuildLogTreeIndex = tailEvents.findIndex( + (event) => + event.type === 'detail-tree' && + event.items.some((item) => item.label === 'Build Logs'), + ); + if (existingBuildLogTreeIndex !== -1) { + 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 }> { @@ -118,6 +171,8 @@ export function startBuildPipeline( export function createXcodebuildPipeline(options: PipelineOptions): XcodebuildPipeline { const { renderers, mcpRenderer } = resolveRenderers(); + const logCapture = createLogCapture(options.toolName); + const debugCapture = createParserDebugCapture(options.toolName); const runState = createXcodebuildRunState({ operation: options.operation, @@ -134,14 +189,19 @@ export function createXcodebuildPipeline(options: PipelineOptions): XcodebuildPi 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); }, @@ -155,9 +215,38 @@ export function createXcodebuildPipeline(options: PipelineOptions): XcodebuildPi 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: finalizeOptions?.tailEvents, + tailEvents, }); for (const renderer of renderers) { @@ -178,5 +267,9 @@ export function createXcodebuildPipeline(options: PipelineOptions): XcodebuildPi get xcresultPath(): string | null { return parser.xcresultPath; }, + + get logPath(): string { + return logCapture.path; + }, }; } diff --git a/vitest.flowdeck.config.ts b/vitest.flowdeck.config.ts new file mode 100644 index 00000000..5ff94a51 --- /dev/null +++ b/vitest.flowdeck.config.ts @@ -0,0 +1,23 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + environment: 'node', + globals: true, + include: ['src/snapshot-tests/__tests__/**/*.flowdeck.test.ts'], + pool: 'forks', + poolOptions: { + forks: { + maxForks: 1, + }, + }, + testTimeout: 120000, + hookTimeout: 120000, + teardownTimeout: 10000, + }, + resolve: { + alias: { + '^(\\.{1,2}/.*)\\.js$': '$1', + }, + }, +}); diff --git a/vitest.snapshot.config.ts b/vitest.snapshot.config.ts index e74ab302..88e795f1 100644 --- a/vitest.snapshot.config.ts +++ b/vitest.snapshot.config.ts @@ -5,6 +5,7 @@ export default defineConfig({ environment: 'node', globals: true, include: ['src/snapshot-tests/__tests__/**/*.test.ts'], + exclude: ['src/snapshot-tests/__tests__/**/*.flowdeck.test.ts'], pool: 'forks', poolOptions: { forks: { From a3eefb3c8dec661f107b0bb399e12fa18a073560 Mon Sep 17 00:00:00 2001 From: Cameron Cooke Date: Mon, 30 Mar 2026 11:16:09 +0100 Subject: [PATCH 10/50] WIP --- .../CalculatorAppTests.swift | 18 ++ .../macOS/MCPTest.xcodeproj/project.pbxproj | 4 +- .../spm/Tests/TestLibTests/SimpleTests.swift | 18 +- src/cli/register-tool-commands.ts | 5 +- src/core/manifest/__tests__/schema.test.ts | 223 +++--------------- src/mcp/tools/debugging/debug_attach_sim.ts | 19 ++ .../tools/debugging/debug_breakpoint_add.ts | 2 +- .../debugging/debug_breakpoint_remove.ts | 2 +- src/mcp/tools/debugging/debug_lldb_command.ts | 2 +- src/mcp/tools/debugging/debug_stack.ts | 2 +- src/mcp/tools/debugging/debug_variables.ts | 2 +- .../device/__tests__/build_device.test.ts | 129 +++------- .../device/__tests__/build_run_device.test.ts | 3 +- .../device/__tests__/list_devices.test.ts | 16 +- src/mcp/tools/device/build_run_device.ts | 82 ++++--- src/mcp/tools/device/get_device_app_path.ts | 19 +- src/mcp/tools/device/list_devices.ts | 198 +++++++++------- src/mcp/tools/device/stop_app_device.ts | 2 +- .../__tests__/start_device_log_cap.test.ts | 27 +-- .../__tests__/start_sim_log_cap.test.ts | 142 +---------- src/mcp/tools/logging/start_device_log_cap.ts | 42 ++-- src/mcp/tools/logging/start_sim_log_cap.ts | 28 +-- src/mcp/tools/logging/stop_device_log_cap.ts | 10 +- src/mcp/tools/logging/stop_sim_log_cap.ts | 17 +- .../tools/macos/__tests__/build_macos.test.ts | 126 +++------- .../macos/__tests__/build_run_macos.test.ts | 5 +- .../macos/__tests__/launch_mac_app.test.ts | 4 - src/mcp/tools/macos/build_macos.ts | 70 ++++-- src/mcp/tools/macos/build_run_macos.ts | 44 +++- src/mcp/tools/macos/get_mac_app_path.ts | 22 +- src/mcp/tools/macos/launch_mac_app.ts | 57 ++++- src/mcp/tools/macos/stop_mac_app.ts | 2 +- .../__tests__/discover_projs.test.ts | 16 +- .../__tests__/get_app_bundle_id.test.ts | 6 +- .../__tests__/get_mac_bundle_id.test.ts | 37 +-- .../tools/project-discovery/discover_projs.ts | 73 ++++-- .../project-discovery/get_app_bundle_id.ts | 25 +- .../project-discovery/get_mac_bundle_id.ts | 22 +- .../tools/project-discovery/list_schemes.ts | 17 +- .../project-discovery/show_build_settings.ts | 32 +-- .../__tests__/scaffold_ios_project.test.ts | 154 ------------ .../scaffold_ios_project.ts | 20 +- .../scaffold_macos_project.ts | 20 +- .../__tests__/session_set_defaults.test.ts | 2 +- .../__tests__/session_show_defaults.test.ts | 3 +- .../session_use_defaults_profile.test.ts | 2 +- .../session-format-helpers.ts | 30 +++ .../session_clear_defaults.ts | 10 +- .../session_set_defaults.ts | 92 +++++--- .../session_show_defaults.ts | 43 ++-- .../session_use_defaults_profile.ts | 25 +- .../__tests__/erase_sims.test.ts | 15 +- .../__tests__/reset_sim_location.test.ts | 2 +- .../__tests__/set_sim_appearance.test.ts | 2 +- .../__tests__/set_sim_location.test.ts | 9 +- .../__tests__/sim_statusbar.test.ts | 4 +- .../tools/simulator-management/erase_sims.ts | 5 +- .../reset_sim_location.ts | 7 +- .../set_sim_appearance.ts | 2 +- .../simulator-management/set_sim_location.ts | 4 +- .../simulator-management/sim_statusbar.ts | 2 +- .../simulator/__tests__/build_run_sim.test.ts | 5 +- .../simulator/__tests__/build_sim.test.ts | 26 +- .../__tests__/get_sim_app_path.test.ts | 2 +- .../__tests__/launch_app_logs_sim.test.ts | 4 +- .../simulator/__tests__/open_sim.test.ts | 2 +- .../simulator/__tests__/screenshot.test.ts | 2 +- src/mcp/tools/simulator/build_sim.ts | 37 +-- src/mcp/tools/simulator/get_sim_app_path.ts | 63 ++--- src/mcp/tools/simulator/install_app_sim.ts | 26 +- .../tools/simulator/launch_app_logs_sim.ts | 30 +-- src/mcp/tools/simulator/launch_app_sim.ts | 38 ++- src/mcp/tools/simulator/list_sims.ts | 112 +++++++-- src/mcp/tools/simulator/open_sim.ts | 2 +- src/mcp/tools/simulator/stop_app_sim.ts | 8 +- .../__tests__/swift_package_build.test.ts | 14 +- .../__tests__/swift_package_list.test.ts | 6 +- .../__tests__/swift_package_run.test.ts | 122 +++++----- .../__tests__/swift_package_test.test.ts | 15 +- .../swift-package/swift_package_build.ts | 37 +-- .../tools/swift-package/swift_package_list.ts | 49 ++-- .../tools/swift-package/swift_package_run.ts | 151 +++++++++--- .../tools/swift-package/swift_package_test.ts | 5 +- src/mcp/tools/ui-automation/screenshot.ts | 42 ++-- src/mcp/tools/utilities/clean.ts | 120 +++++----- .../__tests__/sync_xcode_defaults.test.ts | 128 ---------- .../tools/xcode-ide/sync_xcode_defaults.ts | 5 +- src/runtime/__tests__/tool-invoker.test.ts | 37 +++ src/runtime/tool-invoker.ts | 8 +- .../coverage/get-coverage-report--success.txt | 4 +- .../debugging/lldb-command--success.txt | 2 +- .../__fixtures__/debugging/stack--success.txt | 21 +- .../device/build--error-wrong-scheme.txt | 2 +- .../__fixtures__/device/build--success.txt | 2 +- .../device/build-and-run--success.txt | 3 +- .../__fixtures__/device/test--failure.txt | 13 +- .../__fixtures__/device/test--success.txt | 4 +- .../logging/stop-device-log--success.txt | 4 +- .../logging/stop-sim-log--success.txt | 2 +- .../macos/build--error-wrong-scheme.txt | 2 +- .../__fixtures__/macos/build--success.txt | 2 +- .../build-and-run--error-wrong-scheme.txt | 2 +- .../macos/build-and-run--success.txt | 2 +- .../macos/get-macos-bundle-id--success.txt | 4 +- .../__fixtures__/macos/launch--success.txt | 4 +- .../__fixtures__/macos/stop--success.txt | 2 +- .../macos/test--error-wrong-scheme.txt | 4 +- .../__fixtures__/macos/test--failure.txt | 14 +- .../__fixtures__/macos/test--success.txt | 2 +- .../get-app-bundle-id--success.txt | 2 +- .../show-build-settings--success.txt | 4 +- .../scaffold-macos--success.txt | 2 +- .../session-show-defaults--success.txt | 2 +- .../session-sync-xcode-defaults--success.txt | 3 +- .../simulator/build--error-wrong-scheme.txt | 2 +- .../__fixtures__/simulator/build--success.txt | 2 +- .../build-and-run--error-wrong-scheme.txt | 2 +- .../simulator/build-and-run--success.txt | 2 +- .../simulator/launch-app--success.txt | 2 +- .../launch-app-with-logs--success.txt | 1 - .../__fixtures__/simulator/list--success.txt | 76 +----- .../simulator/test--error-wrong-scheme.txt | 4 +- .../__fixtures__/simulator/test--failure.txt | 13 +- .../__fixtures__/simulator/test--success.txt | 4 +- .../swift-package/build--success.txt | 2 +- .../swift-package/list--success.txt | 4 +- .../run--error-bad-executable.txt | 3 +- .../swift-package/run--success.txt | 7 +- .../swift-package/test--error-bad-path.txt | 4 +- .../swift-package/test--failure.txt | 16 +- .../swift-package/test--success.txt | 4 +- .../utilities/clean--error-wrong-scheme.txt | 2 +- .../__tests__/debugging.snapshot.test.ts | 23 +- .../__tests__/device.flowdeck.test.ts | 127 ++++++---- .../__tests__/device.snapshot.test.ts | 7 +- .../__tests__/doctor.snapshot.test.ts | 19 -- .../__tests__/logging.flowdeck.test.ts | 4 +- .../__tests__/logging.snapshot.test.ts | 78 +++--- .../__tests__/macos.flowdeck.test.ts | 43 ++-- .../__tests__/macos.snapshot.test.ts | 5 +- .../project-discovery.flowdeck.test.ts | 13 +- .../project-discovery.snapshot.test.ts | 2 +- .../project-scaffolding.flowdeck.test.ts | 60 +++-- .../session-management.flowdeck.test.ts | 6 +- .../session-management.snapshot.test.ts | 23 +- .../simulator-management.flowdeck.test.ts | 38 ++- .../simulator-management.snapshot.test.ts | 148 +++++++++++- .../__tests__/simulator.flowdeck.test.ts | 104 ++++++-- .../__tests__/simulator.snapshot.test.ts | 6 - .../__tests__/swift-package.snapshot.test.ts | 34 ++- .../__tests__/ui-automation.flowdeck.test.ts | 154 ++++++++---- .../__tests__/utilities.flowdeck.test.ts | 8 +- .../workflow-discovery.snapshot.test.ts | 20 -- .../__tests__/xcode-ide.snapshot.test.ts | 62 ----- src/snapshot-tests/capture-debug-output.mjs | 51 ++++ src/snapshot-tests/flowdeck-fixture-io.ts | 6 +- src/snapshot-tests/harness.ts | 80 ++++++- src/snapshot-tests/normalize.ts | 55 ++++- src/test-utils/test-helpers.ts | 26 ++ src/utils/__tests__/build-preflight.test.ts | 103 +++++--- src/utils/__tests__/build-utils.test.ts | 36 ++- .../__tests__/nskeyedarchiver-parser.test.ts | 83 +------ .../swift-testing-line-parsers.test.ts | 6 +- src/utils/__tests__/test-common.test.ts | 7 +- .../__tests__/xcode-state-reader.test.ts | 78 ------ .../__tests__/xcodebuild-event-parser.test.ts | 45 ++++ .../__tests__/xcodebuild-line-parsers.test.ts | 38 +++ src/utils/__tests__/xcodebuild-output.test.ts | 8 +- .../__tests__/xcodebuild-run-state.test.ts | 66 ++++++ src/utils/build-preflight.ts | 6 +- .../backends/__tests__/dap-backend.test.ts | 29 +++ src/utils/debugger/backends/dap-backend.ts | 45 ++++ src/utils/debugger/dap/transport.ts | 4 +- src/utils/device-name-resolver.ts | 25 +- src/utils/execution/interactive-process.ts | 1 + src/utils/log-capture/device-log-sessions.ts | 8 +- src/utils/log_capture.ts | 59 ++--- .../__tests__/event-formatting.test.ts | 10 +- .../renderers/__tests__/mcp-renderer.test.ts | 20 +- src/utils/renderers/cli-text-renderer.ts | 36 ++- src/utils/renderers/event-formatting.ts | 123 +++++----- src/utils/renderers/mcp-renderer.ts | 4 +- src/utils/swift-testing-line-parsers.ts | 55 +++-- src/utils/test-common.ts | 20 +- src/utils/tool-event-builders.ts | 9 + src/utils/xcode-state-watcher.ts | 11 +- src/utils/xcodebuild-event-parser.ts | 140 ++++++++--- src/utils/xcodebuild-line-parsers.ts | 40 +++- src/utils/xcodebuild-pipeline.ts | 3 +- src/utils/xcodebuild-run-state.ts | 48 +++- src/utils/xcresult-test-failures.ts | 20 +- 191 files changed, 2989 insertions(+), 2776 deletions(-) create mode 100644 src/mcp/tools/session-management/session-format-helpers.ts delete mode 100644 src/snapshot-tests/__tests__/doctor.snapshot.test.ts delete mode 100644 src/snapshot-tests/__tests__/workflow-discovery.snapshot.test.ts delete mode 100644 src/snapshot-tests/__tests__/xcode-ide.snapshot.test.ts create mode 100644 src/snapshot-tests/capture-debug-output.mjs create mode 100644 src/utils/__tests__/xcodebuild-line-parsers.test.ts diff --git a/example_projects/iOS_Calculator/CalculatorAppTests/CalculatorAppTests.swift b/example_projects/iOS_Calculator/CalculatorAppTests/CalculatorAppTests.swift index 4e359623..d0054bfe 100644 --- a/example_projects/iOS_Calculator/CalculatorAppTests/CalculatorAppTests.swift +++ b/example_projects/iOS_Calculator/CalculatorAppTests/CalculatorAppTests.swift @@ -63,6 +63,17 @@ extension CalculatorAppTests { XCTAssertEqual(service.display, "8", "5 + 3 should equal 8") } + + func testAddition() throws { + let service = CalculatorService() + + service.inputNumber("5") + service.setOperation(.add) + service.inputNumber("3") + service.calculate() + + XCTAssertEqual(service.display, "8", "5 + 3 should equal 8") + } func testCalculatorServiceChainedOperations() throws { let service = CalculatorService() @@ -269,6 +280,13 @@ extension CalculatorAppTests { } } +final class IntentionalFailureTests: XCTestCase { + + func test() throws { + XCTAssertTrue(false, "This test should fail to verify error reporting") + } +} + // MARK: - Component Integration Tests extension CalculatorAppTests { diff --git a/example_projects/macOS/MCPTest.xcodeproj/project.pbxproj b/example_projects/macOS/MCPTest.xcodeproj/project.pbxproj index 2efd7d0b..23d6bf55 100644 --- a/example_projects/macOS/MCPTest.xcodeproj/project.pbxproj +++ b/example_projects/macOS/MCPTest.xcodeproj/project.pbxproj @@ -338,7 +338,7 @@ "@executable_path/../Frameworks", ); MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = io.sentry.MCPTest; + PRODUCT_BUNDLE_IDENTIFIER = io.sentry.calculatorapp; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.0; @@ -365,7 +365,7 @@ "@executable_path/../Frameworks", ); MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = io.sentry.MCPTest; + PRODUCT_BUNDLE_IDENTIFIER = io.sentry.calculatorapp; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.0; diff --git a/example_projects/spm/Tests/TestLibTests/SimpleTests.swift b/example_projects/spm/Tests/TestLibTests/SimpleTests.swift index c16c3fd1..e44d6bb5 100644 --- a/example_projects/spm/Tests/TestLibTests/SimpleTests.swift +++ b/example_projects/spm/Tests/TestLibTests/SimpleTests.swift @@ -1,4 +1,5 @@ import Testing +import XCTest @Test("Basic truth assertions") func basicTruthTest() { @@ -37,13 +38,22 @@ func arrayTest() { func optionalTest() { let someValue: Int? = 42 let nilValue: Int? = nil - + #expect(someValue != nil) #expect(nilValue == nil) #expect(someValue! == 42) } -@Test("Expected failure") -func testFail() { - #expect(true == false, "This test should fail, and is for simulating a test failure") +final class CalculatorAppTests: XCTestCase { + func testCalculatorServiceFailure() { + XCTAssertEqual(0, 999, "This test should fail - display should be 0, not 999") + } +} + +@Suite("This test should fail to verify error reporting") +struct IntentionalFailureSuite { + @Test("test") + func test() { + #expect(Bool(false), "Test failed") + } } diff --git a/src/cli/register-tool-commands.ts b/src/cli/register-tool-commands.ts index 42ccea96..56ffcfbf 100644 --- a/src/cli/register-tool-commands.ts +++ b/src/cli/register-tool-commands.ts @@ -141,9 +141,7 @@ function registerToolSubcommand( tool.description ?? `Run the ${tool.mcpName} tool`, (subYargs) => { // Hide root-level options from tool help - subYargs - .option('log-level', { hidden: true }) - .option('style', { hidden: true }); + subYargs.option('log-level', { hidden: true }).option('style', { hidden: true }); // Parse option-like values as arguments (e.g. --extra-args "-only-testing:...") subYargs.parserConfiguration({ @@ -214,7 +212,6 @@ function registerToolSubcommand( const socketPath = argv.socket as string; const logLevel = argv['log-level'] as string | undefined; - if ( profileOverride && !isKnownCliSessionDefaultsProfile(opts.runtimeConfig, profileOverride) diff --git a/src/core/manifest/__tests__/schema.test.ts b/src/core/manifest/__tests__/schema.test.ts index a50b9255..14c349fc 100644 --- a/src/core/manifest/__tests__/schema.test.ts +++ b/src/core/manifest/__tests__/schema.test.ts @@ -8,194 +8,48 @@ import { } from '../schema.ts'; describe('schema', () => { - describe('toolManifestEntrySchema', () => { - it('should parse valid tool manifest', () => { - const input = { - id: 'build_sim', - module: 'mcp/tools/simulator/build_sim', - names: { mcp: 'build_sim' }, - description: 'Build iOS app for simulator', - availability: { mcp: true, cli: true }, - predicates: [], - routing: { stateful: false }, - }; - - const result = toolManifestEntrySchema.safeParse(input); - expect(result.success).toBe(true); - if (result.success) { - expect(result.data.id).toBe('build_sim'); - expect(result.data.names.mcp).toBe('build_sim'); - } - }); - - it('should apply default availability', () => { - const input = { - id: 'test_tool', - module: 'mcp/tools/test/test_tool', - names: { mcp: 'test_tool' }, - }; - - const result = toolManifestEntrySchema.safeParse(input); - expect(result.success).toBe(true); - if (result.success) { - expect(result.data.availability).toEqual({ mcp: true, cli: true }); - expect(result.data.predicates).toEqual([]); - expect(result.data.nextSteps).toEqual([]); - } - }); - - it('should reject missing required fields', () => { - const input = { - id: 'test_tool', - // missing module and names - }; - - const result = toolManifestEntrySchema.safeParse(input); - expect(result.success).toBe(false); - }); - - it('should accept optional CLI name', () => { - const input = { - id: 'build_sim', - module: 'mcp/tools/simulator/build_sim', - names: { mcp: 'build_sim', cli: 'build-simulator' }, - }; - - const result = toolManifestEntrySchema.safeParse(input); - expect(result.success).toBe(true); - if (result.success) { - expect(result.data.names.cli).toBe('build-simulator'); - } - }); - - it('should reject availability.daemon', () => { - const input = { - id: 'tool1', - module: 'mcp/tools/test/tool1', - names: { mcp: 'tool1' }, - availability: { mcp: true, cli: true, daemon: true }, - }; - - expect(toolManifestEntrySchema.safeParse(input).success).toBe(false); - }); - - it('should reject routing.daemonAffinity', () => { - const input = { - id: 'tool2', - module: 'mcp/tools/test/tool2', - names: { mcp: 'tool2' }, - routing: { stateful: true, daemonAffinity: 'required' }, - }; - - expect(toolManifestEntrySchema.safeParse(input).success).toBe(false); - }); - }); - - describe('workflowManifestEntrySchema', () => { - it('should parse valid workflow manifest', () => { - const input = { - id: 'simulator', - title: 'iOS Simulator Development', - description: 'Build and test iOS apps on simulators', - availability: { mcp: true, cli: true }, - selection: { - mcp: { - defaultEnabled: true, - autoInclude: false, - }, - }, - predicates: [], - tools: ['build_sim', 'test_sim', 'boot_sim'], - }; - - const result = workflowManifestEntrySchema.safeParse(input); - expect(result.success).toBe(true); - if (result.success) { - expect(result.data.id).toBe('simulator'); - expect(result.data.tools).toHaveLength(3); - expect(result.data.selection?.mcp?.defaultEnabled).toBe(true); - } - }); - - it('should apply default values', () => { - const input = { - id: 'test-workflow', - title: 'Test Workflow', - description: 'A test workflow', - tools: ['tool1'], - }; - - const result = workflowManifestEntrySchema.safeParse(input); - expect(result.success).toBe(true); - if (result.success) { - expect(result.data.availability).toEqual({ mcp: true, cli: true }); - expect(result.data.predicates).toEqual([]); - } - }); - - it('should reject empty tools array', () => { - const input = { - id: 'empty-workflow', - title: 'Empty Workflow', - description: 'A workflow with no tools', - tools: [], - }; - - // Empty tools array is technically valid per schema - const result = workflowManifestEntrySchema.safeParse(input); - expect(result.success).toBe(true); - }); - - it('should parse autoInclude workflow', () => { - const input = { - id: 'session-management', - title: 'Session Management', - description: 'Manage session defaults', - availability: { mcp: true, cli: false }, - selection: { - mcp: { - defaultEnabled: true, - autoInclude: true, - }, - }, - tools: ['session_show_defaults'], - }; - - const result = workflowManifestEntrySchema.safeParse(input); - expect(result.success).toBe(true); - if (result.success) { - expect(result.data.selection?.mcp?.autoInclude).toBe(true); - expect(result.data.availability.cli).toBe(false); - } - }); + it('parses a representative manifest/tool naming pipeline', () => { + const toolInput = { + id: 'build_sim', + module: 'mcp/tools/simulator/build_sim', + names: { mcp: 'build_sim' }, + }; + const workflowInput = { + id: 'simulator', + title: 'iOS Simulator Development', + description: 'Build and test iOS apps on simulators', + tools: ['build_sim'], + }; + + const toolResult = toolManifestEntrySchema.safeParse(toolInput); + const workflowResult = workflowManifestEntrySchema.safeParse(workflowInput); + + expect(toolResult.success).toBe(true); + expect(workflowResult.success).toBe(true); + + if (!toolResult.success || !workflowResult.success) { + throw new Error('Expected representative manifest inputs to parse'); + } + + expect(toolResult.data.availability).toEqual({ mcp: true, cli: true }); + expect(toolResult.data.nextSteps).toEqual([]); + expect(toolResult.data.predicates).toEqual([]); + expect(workflowResult.data.availability).toEqual({ mcp: true, cli: true }); + expect(workflowResult.data.predicates).toEqual([]); + expect(workflowResult.data.tools).toEqual(['build_sim']); + expect(getEffectiveCliName(toolResult.data)).toBe('build-sim'); }); describe('deriveCliName', () => { - it('should convert underscores to hyphens', () => { + it('converts common identifier styles to kebab-case', () => { expect(deriveCliName('build_sim')).toBe('build-sim'); - expect(deriveCliName('get_app_bundle_id')).toBe('get-app-bundle-id'); - }); - - it('should convert camelCase to kebab-case', () => { - expect(deriveCliName('buildSim')).toBe('build-sim'); expect(deriveCliName('getAppBundleId')).toBe('get-app-bundle-id'); - }); - - it('should handle mixed underscores and camelCase', () => { - expect(deriveCliName('build_simApp')).toBe('build-sim-app'); - }); - - it('should handle already kebab-case', () => { expect(deriveCliName('build-sim')).toBe('build-sim'); }); - - it('should lowercase the result', () => { - expect(deriveCliName('BUILD_SIM')).toBe('build-sim'); - }); }); describe('getEffectiveCliName', () => { - it('should use explicit CLI name when provided', () => { + it('prefers an explicit CLI name over the derived one', () => { const tool: ToolManifestEntry = { id: 'build_sim', module: 'mcp/tools/simulator/build_sim', @@ -207,18 +61,5 @@ describe('schema', () => { expect(getEffectiveCliName(tool)).toBe('build-simulator'); }); - - it('should derive CLI name when not provided', () => { - const tool: ToolManifestEntry = { - id: 'build_sim', - module: 'mcp/tools/simulator/build_sim', - names: { mcp: 'build_sim' }, - availability: { mcp: true, cli: true }, - predicates: [], - nextSteps: [], - }; - - expect(getEffectiveCliName(tool)).toBe('build-sim'); - }); }); }); diff --git a/src/mcp/tools/debugging/debug_attach_sim.ts b/src/mcp/tools/debugging/debug_attach_sim.ts index b092b081..567397d0 100644 --- a/src/mcp/tools/debugging/debug_attach_sim.ts +++ b/src/mcp/tools/debugging/debug_attach_sim.ts @@ -140,6 +140,25 @@ export async function debug_attach_simLogic( ]); } } + } else { + try { + await debuggerManager.runCommand(session.id, 'process interrupt'); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (!/already stopped|not running/i.test(message)) { + try { + await debuggerManager.detachSession(session.id); + } catch (detachError) { + const detachMessage = + detachError instanceof Error ? detachError.message : String(detachError); + log('warn', `Failed to detach debugger session after pause failure: ${detachMessage}`); + } + return toolResponse([ + headerEvent, + statusLine('error', `Failed to pause debugger after attach: ${message}`), + ]); + } + } } const backendLabel = session.backend === 'dap' ? 'DAP debugger' : 'LLDB'; diff --git a/src/mcp/tools/debugging/debug_breakpoint_add.ts b/src/mcp/tools/debugging/debug_breakpoint_add.ts index 962d1bee..a8cd9d78 100644 --- a/src/mcp/tools/debugging/debug_breakpoint_add.ts +++ b/src/mcp/tools/debugging/debug_breakpoint_add.ts @@ -53,7 +53,7 @@ export async function debug_breakpoint_addLogic( const events = [ headerEvent, statusLine('success', `Breakpoint ${result.id} set`), - ...(rawOutput ? [section('Output', rawOutput.split('\n'))] : []), + ...(rawOutput ? [section('Output:', rawOutput.split('\n'))] : []), ]; return toolResponse(events); diff --git a/src/mcp/tools/debugging/debug_breakpoint_remove.ts b/src/mcp/tools/debugging/debug_breakpoint_remove.ts index 5cdefa66..a4d3a7fc 100644 --- a/src/mcp/tools/debugging/debug_breakpoint_remove.ts +++ b/src/mcp/tools/debugging/debug_breakpoint_remove.ts @@ -27,7 +27,7 @@ export async function debug_breakpoint_removeLogic( const events = [ headerEvent, statusLine('success', `Breakpoint ${params.breakpointId} removed`), - ...(rawOutput ? [section('Output', rawOutput.split('\n'))] : []), + ...(rawOutput ? [section('Output:', rawOutput.split('\n'))] : []), ]; return toolResponse(events); diff --git a/src/mcp/tools/debugging/debug_lldb_command.ts b/src/mcp/tools/debugging/debug_lldb_command.ts index 68b316e2..94b09ed8 100644 --- a/src/mcp/tools/debugging/debug_lldb_command.ts +++ b/src/mcp/tools/debugging/debug_lldb_command.ts @@ -34,7 +34,7 @@ export async function debug_lldb_commandLogic( return toolResponse([ headerEvent, statusLine('success', 'Command executed'), - ...(trimmed ? [section('Output', trimmed.split('\n'))] : []), + ...(trimmed ? [section('Output:', trimmed.split('\n'))] : []), ]); } catch (error) { const message = error instanceof Error ? error.message : String(error); diff --git a/src/mcp/tools/debugging/debug_stack.ts b/src/mcp/tools/debugging/debug_stack.ts index 493e42a1..6256bca3 100644 --- a/src/mcp/tools/debugging/debug_stack.ts +++ b/src/mcp/tools/debugging/debug_stack.ts @@ -32,7 +32,7 @@ export async function debug_stackLogic( return toolResponse([ headerEvent, statusLine('success', 'Stack trace retrieved'), - ...(trimmed ? [section('Frames', trimmed.split('\n'))] : []), + ...(trimmed ? [section('Frames:', trimmed.split('\n'))] : []), ]); } catch (error) { const message = error instanceof Error ? error.message : String(error); diff --git a/src/mcp/tools/debugging/debug_variables.ts b/src/mcp/tools/debugging/debug_variables.ts index 5e6f80be..782e7cc4 100644 --- a/src/mcp/tools/debugging/debug_variables.ts +++ b/src/mcp/tools/debugging/debug_variables.ts @@ -30,7 +30,7 @@ export async function debug_variablesLogic( return toolResponse([ headerEvent, statusLine('success', 'Variables retrieved'), - ...(trimmed ? [section('Values', trimmed.split('\n'))] : []), + ...(trimmed ? [section('Values:', trimmed.split('\n'))] : []), ]); } catch (error) { const message = error instanceof Error ? error.message : String(error); diff --git a/src/mcp/tools/device/__tests__/build_device.test.ts b/src/mcp/tools/device/__tests__/build_device.test.ts index c45b874c..20309d17 100644 --- a/src/mcp/tools/device/__tests__/build_device.test.ts +++ b/src/mcp/tools/device/__tests__/build_device.test.ts @@ -1,35 +1,23 @@ import { describe, it, expect, beforeEach } from 'vitest'; import * as z from 'zod'; -import { - createMockCommandResponse, - createMockExecutor, - createNoopExecutor, -} from '../../../../test-utils/mock-executors.ts'; +import { createMockExecutor } from '../../../../test-utils/mock-executors.ts'; +import { expectPendingBuildResponse } from '../../../../test-utils/test-helpers.ts'; import { schema, handler, buildDeviceLogic } from '../build_device.ts'; import { sessionStore } from '../../../../utils/session-store.ts'; -function expectPendingBuildResponse( - result: Awaited>, - nextStepToolId?: string, -): void { - 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(); - } +function createSpyExecutor(): { + commandCalls: Array<{ args: string[]; logPrefix?: string }>; + executor: ReturnType; +} { + const commandCalls: Array<{ args: string[]; logPrefix?: string }> = []; + const executor = createMockExecutor({ + success: true, + output: 'Build succeeded', + onExecute: (command, logPrefix) => { + commandCalls.push({ args: command, logPrefix }); + }, + }); + return { commandCalls, executor }; } describe('build_device plugin', () => { @@ -142,37 +130,18 @@ describe('build_device plugin', () => { }); it('should verify workspace command generation with mock executor', async () => { - const commandCalls: Array<{ - args: string[]; - logPrefix?: string; - silent?: boolean; - }> = []; - - const stubExecutor = async ( - args: string[], - logPrefix?: string, - silent?: boolean, - opts?: { cwd?: string }, - _detached?: boolean, - ) => { - commandCalls.push({ args, logPrefix, silent }); - return createMockCommandResponse({ - success: true, - output: 'Build succeeded', - error: undefined, - }); - }; + const spy = createSpyExecutor(); await buildDeviceLogic( { workspacePath: '/path/to/MyProject.xcworkspace', scheme: 'MyScheme', }, - stubExecutor, + spy.executor, ); - expect(commandCalls).toHaveLength(1); - expect(commandCalls[0].args).toEqual([ + expect(spy.commandCalls).toHaveLength(1); + expect(spy.commandCalls[0].args).toEqual([ 'xcodebuild', '-workspace', '/path/to/MyProject.xcworkspace', @@ -185,41 +154,22 @@ describe('build_device plugin', () => { 'generic/platform=iOS', 'build', ]); - expect(commandCalls[0].logPrefix).toBe('iOS Device Build'); + expect(spy.commandCalls[0].logPrefix).toBe('iOS Device Build'); }); it('should verify command generation with mock executor', async () => { - const commandCalls: Array<{ - args: string[]; - logPrefix?: string; - silent?: boolean; - }> = []; - - const stubExecutor = async ( - args: string[], - logPrefix?: string, - silent?: boolean, - opts?: { cwd?: string }, - _detached?: boolean, - ) => { - commandCalls.push({ args, logPrefix, silent }); - return createMockCommandResponse({ - success: true, - output: 'Build succeeded', - error: undefined, - }); - }; + const spy = createSpyExecutor(); await buildDeviceLogic( { projectPath: '/path/to/MyProject.xcodeproj', scheme: 'MyScheme', }, - stubExecutor, + spy.executor, ); - expect(commandCalls).toHaveLength(1); - expect(commandCalls[0].args).toEqual([ + expect(spy.commandCalls).toHaveLength(1); + expect(spy.commandCalls[0].args).toEqual([ 'xcodebuild', '-project', '/path/to/MyProject.xcodeproj', @@ -232,7 +182,7 @@ describe('build_device plugin', () => { 'generic/platform=iOS', 'build', ]); - expect(commandCalls[0].logPrefix).toBe('iOS Device Build'); + expect(spy.commandCalls[0].logPrefix).toBe('iOS Device Build'); }); it('should return exact successful build response', async () => { @@ -272,26 +222,7 @@ describe('build_device plugin', () => { }); it('should include optional parameters in command', async () => { - const commandCalls: Array<{ - args: string[]; - logPrefix?: string; - silent?: boolean; - }> = []; - - const stubExecutor = async ( - args: string[], - logPrefix?: string, - silent?: boolean, - opts?: { cwd?: string }, - _detached?: boolean, - ) => { - commandCalls.push({ args, logPrefix, silent }); - return createMockCommandResponse({ - success: true, - output: 'Build succeeded', - error: undefined, - }); - }; + const spy = createSpyExecutor(); await buildDeviceLogic( { @@ -301,11 +232,11 @@ describe('build_device plugin', () => { derivedDataPath: '/tmp/derived-data', extraArgs: ['--verbose'], }, - stubExecutor, + spy.executor, ); - expect(commandCalls).toHaveLength(1); - expect(commandCalls[0].args).toEqual([ + expect(spy.commandCalls).toHaveLength(1); + expect(spy.commandCalls[0].args).toEqual([ 'xcodebuild', '-project', '/path/to/MyProject.xcodeproj', @@ -321,7 +252,7 @@ describe('build_device plugin', () => { '--verbose', 'build', ]); - expect(commandCalls[0].logPrefix).toBe('iOS Device Build'); + expect(spy.commandCalls[0].logPrefix).toBe('iOS Device Build'); }); }); }); diff --git a/src/mcp/tools/device/__tests__/build_run_device.test.ts b/src/mcp/tools/device/__tests__/build_run_device.test.ts index 1f027a4e..68f83475 100644 --- a/src/mcp/tools/device/__tests__/build_run_device.test.ts +++ b/src/mcp/tools/device/__tests__/build_run_device.test.ts @@ -228,7 +228,6 @@ describe('build_run_device tool', () => { expect.objectContaining({ label: 'App Path', value: '/tmp/build/MyApp.app' }), expect.objectContaining({ label: 'Bundle ID', value: 'io.sentry.MyApp' }), expect.objectContaining({ label: 'Process ID', value: '1234' }), - expect.objectContaining({ label: 'Build Logs', value: expect.stringContaining('build_run_device_') }), ]), }), ], @@ -281,7 +280,7 @@ describe('build_run_device tool', () => { const detailTree = tailEvents[1]; expect(detailTree.type).toBe('detail-tree'); expect(detailTree.items?.some((item) => item.label === 'Process ID')).toBe(false); - expect(detailTree.items?.some((item) => item.label === 'Build Logs')).toBe(true); + expect(detailTree.items?.some((item) => item.label === 'Build Logs')).toBe(false); }); it('uses generic destination for build-settings lookup', async () => { diff --git a/src/mcp/tools/device/__tests__/list_devices.test.ts b/src/mcp/tools/device/__tests__/list_devices.test.ts index 9e1e4425..a00f354b 100644 --- a/src/mcp/tools/device/__tests__/list_devices.test.ts +++ b/src/mcp/tools/device/__tests__/list_devices.test.ts @@ -201,16 +201,12 @@ describe('list_devices plugin (device-shared)', () => { expect(text).toContain('List Devices'); expect(text).toContain('Test iPhone'); expect(text).toContain('test-device-123'); - expect(text).toContain('iPhone15,2'); - expect(text).toContain('iOS 17.0'); - expect(text).toContain('USB'); - expect(text).toContain('Devices discovered'); - expect(result.nextStepParams).toEqual({ - build_device: { scheme: 'SCHEME', deviceId: 'DEVICE_UDID' }, - build_run_device: { scheme: 'SCHEME', deviceId: 'DEVICE_UDID' }, - test_device: { scheme: 'SCHEME', deviceId: 'DEVICE_UDID' }, - get_device_app_path: { scheme: 'SCHEME' }, - }); + expect(text).toContain('iOS Devices:'); + expect(text).toContain('๐Ÿ“ฑ [โœ“] Test iPhone'); + expect(text).toContain('OS: 17.0'); + expect(text).toContain('UDID: test-device-123'); + expect(text).toContain('1 physical devices discovered (1 iOS).'); + expect(result.nextStepParams).toBeUndefined(); }); it('should return successful xctrace fallback response', async () => { diff --git a/src/mcp/tools/device/build_run_device.ts b/src/mcp/tools/device/build_run_device.ts index 64ed3998..e2b24a11 100644 --- a/src/mcp/tools/device/build_run_device.ts +++ b/src/mcp/tools/device/build_run_device.ts @@ -32,6 +32,7 @@ import { emitPipelineNotice, } from '../../../utils/xcodebuild-output.ts'; import { resolveAppPathFromBuildSettings } from '../../../utils/app-path-resolver.ts'; +import { resolveDeviceName } from '../../../utils/device-name-resolver.ts'; const baseSchemaObject = z.object({ projectPath: z.string().optional().describe('Path to the .xcodeproj file'), @@ -62,6 +63,19 @@ const buildRunDeviceSchema = z.preprocess( export type BuildRunDeviceParams = z.infer; +function bailWithError( + started: ReturnType, + logMessage: string, + pipelineMessage: string, +): ToolResponse { + log('error', logMessage); + emitPipelineError(started, 'BUILD', pipelineMessage); + return createPendingXcodebuildResponse(started, { + content: [], + isError: true, + }); +} + export async function build_run_deviceLogic( params: BuildRunDeviceParams, executor: CommandExecutor, @@ -86,6 +100,8 @@ export async function build_run_deviceLogic( logPrefix: `${platform} Device Build`, }; + const deviceName = resolveDeviceName(params.deviceId); + const preflightText = formatToolPreflight({ operation: 'Build & Run', scheme: params.scheme, @@ -94,6 +110,7 @@ export async function build_run_deviceLogic( configuration, platform: String(platform), deviceId: params.deviceId, + deviceName, }); const started = startBuildPipeline({ @@ -150,12 +167,11 @@ export async function build_run_deviceLogic( ); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); - log('error', 'Build succeeded, but failed to get app path to launch.'); - emitPipelineError(started, 'BUILD', `Failed to get app path to launch: ${errorMessage}`); - return createPendingXcodebuildResponse(started, { - content: [], - isError: true, - }); + return bailWithError( + started, + 'Build succeeded, but failed to get app path to launch.', + `Failed to get app path to launch: ${errorMessage}`, + ); } log('info', `App path determined as: ${appPath}`); @@ -174,12 +190,11 @@ export async function build_run_deviceLogic( log('info', `Bundle ID for run: ${bundleId}`); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); - log('error', `Failed to extract bundle ID: ${errorMessage}`); - emitPipelineError(started, 'BUILD', `Failed to extract bundle ID: ${errorMessage}`); - return createPendingXcodebuildResponse(started, { - content: [], - isError: true, - }); + return bailWithError( + started, + `Failed to extract bundle ID: ${errorMessage}`, + `Failed to extract bundle ID: ${errorMessage}`, + ); } // Install app on device @@ -199,12 +214,11 @@ export async function build_run_deviceLogic( } } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); - log('error', `Failed to install app on device: ${errorMessage}`); - emitPipelineError(started, 'BUILD', `Failed to install app on device: ${errorMessage}`); - return createPendingXcodebuildResponse(started, { - content: [], - isError: true, - }); + return bailWithError( + started, + `Failed to install app on device: ${errorMessage}`, + `Failed to install app on device: ${errorMessage}`, + ); } emitPipelineNotice(started, 'BUILD', 'App installed', 'success', { @@ -247,20 +261,12 @@ export async function build_run_deviceLogic( try { const jsonContent = await fileSystemExecutor.readFile(tempJsonPath, 'utf8'); - const parsedData: unknown = JSON.parse(jsonContent); - if ( - parsedData && - typeof parsedData === 'object' && - 'result' in parsedData && - parsedData.result && - typeof parsedData.result === 'object' && - 'process' in parsedData.result && - parsedData.result.process && - typeof parsedData.result.process === 'object' && - 'processIdentifier' in parsedData.result.process && - typeof parsedData.result.process.processIdentifier === 'number' - ) { - processId = parsedData.result.process.processIdentifier as number; + 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'); @@ -269,12 +275,11 @@ export async function build_run_deviceLogic( } } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); - log('error', `Failed to launch app on device: ${errorMessage}`); - emitPipelineError(started, 'BUILD', `Failed to launch app on device: ${errorMessage}`); - return createPendingXcodebuildResponse(started, { - content: [], - isError: true, - }); + return bailWithError( + started, + `Failed to launch app on device: ${errorMessage}`, + `Failed to launch app on device: ${errorMessage}`, + ); } log('info', `Device build and run succeeded for scheme ${params.scheme}.`); @@ -309,7 +314,6 @@ export async function build_run_deviceLogic( bundleId, launchState: 'requested', ...(processId !== undefined ? { processId } : {}), - buildLogPath: started.pipeline.logPath, }), includeBuildLogFileRef: false, }, diff --git a/src/mcp/tools/device/get_device_app_path.ts b/src/mcp/tools/device/get_device_app_path.ts index c80b9b8b..d2bb6a75 100644 --- a/src/mcp/tools/device/get_device_app_path.ts +++ b/src/mcp/tools/device/get_device_app_path.ts @@ -18,7 +18,10 @@ import { import { nullifyEmptyStrings } from '../../../utils/schema-helpers.ts'; import { mapDevicePlatform } from './build-settings.ts'; import { formatToolPreflight } from '../../../utils/build-preflight.ts'; -import { formatQueryError, formatQueryFailureSummary } from '../../../utils/xcodebuild-error-utils.ts'; +import { + formatQueryError, + formatQueryFailureSummary, +} from '../../../utils/xcodebuild-error-utils.ts'; import { extractAppPathFromBuildSettingsOutput, getBuildSettingsDestination, @@ -78,8 +81,12 @@ export async function get_device_app_pathLogic( try { const command = ['xcodebuild', '-showBuildSettings']; - const projectPath = params.projectPath ? path.resolve(process.cwd(), params.projectPath) : undefined; - const workspacePath = params.workspacePath ? path.resolve(process.cwd(), params.workspacePath) : undefined; + const projectPath = params.projectPath + ? path.resolve(process.cwd(), params.projectPath) + : undefined; + const workspacePath = params.workspacePath + ? path.resolve(process.cwd(), params.workspacePath) + : undefined; if (projectPath) { command.push('-project', projectPath); @@ -110,7 +117,7 @@ export async function get_device_app_pathLogic( content: [ { type: 'text', - text: `\n${preflightText}${formatQueryError(rawOutput)}\n\n${formatQueryFailureSummary()}`, + text: `\n${preflightText}\n${formatQueryError(rawOutput)}\n\n${formatQueryFailureSummary()}`, }, ], isError: true, @@ -123,7 +130,7 @@ export async function get_device_app_pathLogic( content: [ { type: 'text', - text: `\n${preflightText} โ”” App Path: ${appPath}`, + text: `\n${preflightText}\nโœ… Success\n โ”” App Path: ${appPath}`, }, ], nextStepParams: { @@ -140,7 +147,7 @@ export async function get_device_app_pathLogic( content: [ { type: 'text', - text: `\n${preflightText}${formatQueryError(errorMessage)}\n\n${formatQueryFailureSummary()}`, + text: `\n${preflightText}\n${formatQueryError(errorMessage)}\n\n${formatQueryFailureSummary()}`, }, ], isError: true, diff --git a/src/mcp/tools/device/list_devices.ts b/src/mcp/tools/device/list_devices.ts index 5654f5e0..dac76f40 100644 --- a/src/mcp/tools/device/list_devices.ts +++ b/src/mcp/tools/device/list_devices.ts @@ -1,22 +1,15 @@ -/** - * Device Workspace Plugin: List Devices - * - * Lists connected physical Apple devices (iPhone, iPad, Apple Watch, Apple TV, Apple Vision Pro) - * with their UUIDs, names, and connection status. Use this to discover physical devices for testing. - */ - import * as z from 'zod'; import type { ToolResponse } from '../../../types/common.ts'; import { log } from '../../../utils/logging/index.ts'; import type { CommandExecutor } from '../../../utils/execution/index.ts'; import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; import { createTypedTool } from '../../../utils/typed-tool-factory.ts'; -import { promises as fs } from 'fs'; -import { tmpdir } from 'os'; -import { join } from 'path'; +import { promises as fs } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; import type { PipelineEvent } from '../../../types/pipeline-events.ts'; import { toolResponse } from '../../../utils/tool-response.ts'; -import { header, statusLine, section, table } from '../../../utils/tool-event-builders.ts'; +import { header, statusLine, section } from '../../../utils/tool-event-builders.ts'; const listDevicesSchema = z.object({}); @@ -38,7 +31,11 @@ function getPlatformLabel(platformIdentifier?: string): string { if (platformId.includes('watch')) { return 'watchOS'; } - if (platformId.includes('appletv') || platformId.includes('tvos') || platformId.includes('apple tv')) { + if ( + platformId.includes('appletv') || + platformId.includes('tvos') || + platformId.includes('apple tv') + ) { return 'tvOS'; } if (platformId.includes('xros') || platformId.includes('vision')) { @@ -51,6 +48,82 @@ function getPlatformLabel(platformIdentifier?: string): string { return 'Unknown'; } +function getPlatformOrder(platform: string): number { + switch (platform) { + case 'iOS': + return 0; + case 'iPadOS': + return 1; + case 'watchOS': + return 2; + case 'tvOS': + return 3; + case 'visionOS': + return 4; + case 'macOS': + return 5; + default: + return 6; + } +} + +function getDeviceEmoji(platform: string): string { + switch (platform) { + case 'watchOS': + return 'โŒš๏ธ'; + case 'tvOS': + return '๐Ÿ“บ'; + case 'visionOS': + return '๐Ÿฅฝ'; + case 'macOS': + return '๐Ÿ’ป'; + default: + return '๐Ÿ“ฑ'; + } +} + +function renderGroupedDevices( + devices: Array<{ name: string; identifier: string; platform: string; osVersion?: string; state: string }>, +): string { + const grouped = new Map(); + + for (const device of devices) { + const group = grouped.get(device.platform) ?? []; + group.push(device); + grouped.set(device.platform, group); + } + + const lines: string[] = ['๐Ÿ“ฑ List Devices', '']; + const orderedPlatforms = [...grouped.keys()].sort((a, b) => getPlatformOrder(a) - getPlatformOrder(b)); + + for (const platform of orderedPlatforms) { + const platformDevices = grouped.get(platform) ?? []; + if (platformDevices.length === 0) { + continue; + } + + lines.push(`${platform} Devices:`); + lines.push(''); + + for (const device of platformDevices) { + const availability = isAvailableState(device.state) ? 'โœ“' : 'โœ—'; + lines.push(` ${getDeviceEmoji(platform)} [${availability}] ${device.name}`); + lines.push(` OS: ${device.osVersion ?? 'Unknown'}`); + lines.push(` UDID: ${device.identifier}`); + lines.push(''); + } + } + + const platformCounts = orderedPlatforms.map((platform) => { + const count = grouped.get(platform)?.length ?? 0; + return `${count} ${platform}`; + }); + + lines.push(`โœ… ${devices.length} physical devices discovered (${platformCounts.join(', ')}).`); + + return lines.join('\n'); +} + /** * Business logic for listing connected devices */ @@ -127,20 +200,31 @@ export async function list_devicesLogic( continue; } - const platform = getPlatformLabel(device.deviceProperties?.platformIdentifier); + const platform = getPlatformLabel( + [ + device.deviceProperties?.platformIdentifier, + device.deviceProperties?.marketingName, + device.hardwareProperties?.productType, + device.deviceProperties?.name, + ] + .filter((value): value is string => typeof value === 'string' && value.length > 0) + .join(' '), + ); // Determine connection state const pairingState = device.connectionProperties?.pairingState ?? ''; const tunnelState = device.connectionProperties?.tunnelState ?? ''; const transportType = device.connectionProperties?.transportType ?? ''; + const hasDirectConnection = + tunnelState === 'connected' || transportType === 'wired' || transportType === 'localNetwork'; let state: string; if (pairingState !== 'paired') { state = 'Unpaired'; - } else if (tunnelState === 'connected') { + } else if (hasDirectConnection) { state = 'Available'; } else { - state = 'Available (WiFi)'; + state = 'Paired (not connected)'; } devices.push({ @@ -226,75 +310,27 @@ export async function list_devicesLogic( return toolResponse(events); } - const availableDevices = uniqueDevices.filter((d) => isAvailableState(d.state)); - const pairedDevices = uniqueDevices.filter((d) => d.state === 'Paired (not connected)'); - const unpairedDevices = uniqueDevices.filter((d) => d.state === 'Unpaired'); - - if (availableDevices.length > 0) { - events.push( - table( - ['Name', 'Identifier', 'Platform', 'Model', 'Connection', 'Developer Mode'], - availableDevices.map((device) => ({ - Name: device.name, - Identifier: device.identifier, - Platform: `${device.platform} ${device.osVersion ?? ''}`.trim(), - Model: device.model ?? device.productType ?? 'Unknown', - Connection: device.connectionType || 'Unknown', - 'Developer Mode': device.developerModeStatus ?? 'Unknown', - })), - 'Available Devices', - ), - ); - } - - if (pairedDevices.length > 0) { - events.push( - table( - ['Name', 'Identifier', 'Platform', 'Model'], - pairedDevices.map((device) => ({ - Name: device.name, - Identifier: device.identifier, - Platform: `${device.platform} ${device.osVersion ?? ''}`.trim(), - Model: device.model ?? device.productType ?? 'Unknown', - })), - 'Paired Devices', - ), - ); - } - - if (unpairedDevices.length > 0) { - events.push( - table( - ['Name', 'Identifier', 'Platform'], - unpairedDevices.map((device) => ({ - Name: device.name, - Identifier: device.identifier, - Platform: `${device.platform} ${device.osVersion ?? ''}`.trim(), - })), - 'Unpaired Devices', - ), - ); - } - const availableDevicesExist = uniqueDevices.some((d) => isAvailableState(d.state)); - let nextStepParams: Record> | undefined; + const renderedDeviceList = renderGroupedDevices( + uniqueDevices.map((device) => ({ + name: device.name, + identifier: device.identifier, + platform: device.platform, + osVersion: device.osVersion, + state: device.state, + })), + ); if (availableDevicesExist) { - events.push( - statusLine('success', 'Devices discovered.'), - section('Hints', [ - 'Use the device ID/UDID from above when required by other tools.', - "Save a default device with session-set-defaults { deviceId: 'DEVICE_UDID' }.", - 'Before running build/run/test/UI automation tools, set the desired device identifier in session defaults.', - ]), - ); - - nextStepParams = { - build_device: { scheme: 'SCHEME', deviceId: 'DEVICE_UDID' }, - build_run_device: { scheme: 'SCHEME', deviceId: 'DEVICE_UDID' }, - test_device: { scheme: 'SCHEME', deviceId: 'DEVICE_UDID' }, - get_device_app_path: { scheme: 'SCHEME' }, + return { + content: [ + { + type: 'text', + text: `\n${renderedDeviceList}\n\nHints\n Use the device ID/UDID from above when required by other tools.\n Save a default device with session-set-defaults { deviceId: 'DEVICE_UDID' }.\n Before running build/run/test/UI automation tools, set the desired device identifier in session defaults.`, + }, + ], + nextSteps: [], }; } else if (uniqueDevices.length > 0) { events.push( @@ -308,7 +344,7 @@ export async function list_devicesLogic( ); } - return toolResponse(events, nextStepParams ? { nextStepParams } : undefined); + return toolResponse(events); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); log('error', `Error listing devices: ${errorMessage}`); diff --git a/src/mcp/tools/device/stop_app_device.ts b/src/mcp/tools/device/stop_app_device.ts index 44e68df5..8bdcb930 100644 --- a/src/mcp/tools/device/stop_app_device.ts +++ b/src/mcp/tools/device/stop_app_device.ts @@ -63,7 +63,7 @@ export async function stop_app_deviceLogic( ]); } - return toolResponse([headerEvent, statusLine('success', 'App stopped successfully.')]); + return toolResponse([headerEvent, statusLine('success', 'App stopped successfully')]); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); log('error', `Error stopping app on device: ${errorMessage}`); diff --git a/src/mcp/tools/logging/__tests__/start_device_log_cap.test.ts b/src/mcp/tools/logging/__tests__/start_device_log_cap.test.ts index 2cbca494..a9409315 100644 --- a/src/mcp/tools/logging/__tests__/start_device_log_cap.test.ts +++ b/src/mcp/tools/logging/__tests__/start_device_log_cap.test.ts @@ -2,7 +2,6 @@ import { describe, it, expect, beforeEach } from 'vitest'; import { EventEmitter } from 'events'; import { Readable } from 'stream'; import type { ChildProcess } from 'child_process'; -import * as z from 'zod'; import { createMockExecutor, createMockFileSystemExecutor, @@ -55,15 +54,6 @@ describe('start_device_log_cap plugin', () => { expect(handler).toBeDefined(); }); - it('should have correct schema structure', () => { - expect(typeof schema).toBe('object'); - expect(Object.keys(schema)).toEqual([]); - - const schemaObj = z.strictObject(schema); - expect(schemaObj.safeParse({ bundleId: 'com.test.app' }).success).toBe(false); - expect(schemaObj.safeParse({}).success).toBe(true); - }); - it('should have handler as a function', () => { expect(typeof handler).toBe('function'); }); @@ -106,9 +96,9 @@ describe('start_device_log_cap plugin', () => { ); const text = allText(result); - expect(text).toContain('Log capture started'); expect(text).toMatch(/Session ID: [a-f0-9-]{36}/); expect(result.isError ?? false).toBe(false); + expect(activeDeviceLogSessions.size).toBe(1); }); it('should include next steps in success response', async () => { @@ -135,17 +125,12 @@ describe('start_device_log_cap plugin', () => { mockFileSystemExecutor, ); - const text = allText(result); - expect(text).toContain('Do not call launch_app_device during this session'); - const sessionIdMatch = text.match(/Session ID: ([a-f0-9-]{36})/); - expect(sessionIdMatch).not.toBeNull(); - const sessionId = sessionIdMatch?.[1]; + const stopParams = result.nextStepParams?.stop_device_log_cap; + expect(stopParams).toBeDefined(); + expect(Array.isArray(stopParams)).toBe(false); + const sessionId = !Array.isArray(stopParams) ? stopParams?.logSessionId : undefined; expect(typeof sessionId).toBe('string'); - - expect(result.nextStepParams?.stop_device_log_cap).toBeDefined(); - expect(result.nextStepParams?.stop_device_log_cap).toMatchObject({ - logSessionId: sessionId, - }); + expect(activeDeviceLogSessions.has(sessionId as string)).toBe(true); }); it('should surface early launch failures when process exits immediately', async () => { diff --git a/src/mcp/tools/logging/__tests__/start_sim_log_cap.test.ts b/src/mcp/tools/logging/__tests__/start_sim_log_cap.test.ts index eba20546..3dedbe91 100644 --- a/src/mcp/tools/logging/__tests__/start_sim_log_cap.test.ts +++ b/src/mcp/tools/logging/__tests__/start_sim_log_cap.test.ts @@ -1,11 +1,10 @@ import { describe, it, expect } from 'vitest'; -import * as z from 'zod'; import { schema, handler, start_sim_log_capLogic } from '../start_sim_log_cap.ts'; import { createMockExecutor } from '../../../../test-utils/mock-executors.ts'; import { allText } from '../../../../test-utils/test-helpers.ts'; describe('start_sim_log_cap plugin', () => { - describe('Export Field Validation (Literal)', () => { + describe('Plugin Structure', () => { it('should export schema and handler', () => { expect(schema).toBeDefined(); expect(handler).toBeDefined(); @@ -14,37 +13,6 @@ describe('start_sim_log_cap plugin', () => { it('should have handler as a function', () => { expect(typeof handler).toBe('function'); }); - - it('should validate schema with valid parameters', () => { - const schemaObj = z.object(schema); - expect(schemaObj.safeParse({}).success).toBe(true); - expect(schemaObj.safeParse({ captureConsole: true }).success).toBe(true); - expect(schemaObj.safeParse({ captureConsole: false }).success).toBe(true); - }); - - it('should validate schema with subsystemFilter parameter', () => { - const schemaObj = z.object(schema); - expect(schemaObj.safeParse({ subsystemFilter: 'app' }).success).toBe(true); - expect(schemaObj.safeParse({ subsystemFilter: 'all' }).success).toBe(true); - expect(schemaObj.safeParse({ subsystemFilter: 'swiftui' }).success).toBe(true); - expect(schemaObj.safeParse({ subsystemFilter: ['com.apple.UIKit'] }).success).toBe(true); - expect( - schemaObj.safeParse({ subsystemFilter: ['com.apple.UIKit', 'com.apple.CoreData'] }).success, - ).toBe(true); - expect(schemaObj.safeParse({ subsystemFilter: [] }).success).toBe(false); - expect(schemaObj.safeParse({ subsystemFilter: 'invalid' }).success).toBe(false); - expect(schemaObj.safeParse({ subsystemFilter: 123 }).success).toBe(false); - }); - - it('should reject invalid schema parameters', () => { - const schemaObj = z.object(schema); - expect(schemaObj.safeParse({ captureConsole: 'yes' }).success).toBe(false); - expect(schemaObj.safeParse({ captureConsole: 123 }).success).toBe(false); - - const withSimId = schemaObj.safeParse({ simulatorId: 'test-uuid' }); - expect(withSimId.success).toBe(true); - expect('simulatorId' in (withSimId.data as any)).toBe(false); - }); }); describe('Handler Behavior (Complete Literal Returns)', () => { @@ -99,120 +67,12 @@ describe('start_sim_log_cap plugin', () => { expect(result.isError).toBeUndefined(); const text = allText(result); expect(text).toContain('test-uuid-123'); - expect(text).toContain('app subsystem'); expect(result.nextStepParams?.stop_sim_log_cap).toBeDefined(); expect(result.nextStepParams?.stop_sim_log_cap).toMatchObject({ logSessionId: 'test-uuid-123', }); }); - it('should indicate swiftui capture when subsystemFilter is swiftui', async () => { - const mockExecutor = createMockExecutor({ success: true, output: '' }); - const logCaptureStub = (params: any, executor: any) => { - return Promise.resolve({ - sessionId: 'test-uuid-123', - logFilePath: '/tmp/test.log', - processes: [], - error: undefined, - }); - }; - - const result = await start_sim_log_capLogic( - { - simulatorId: 'test-uuid', - bundleId: 'io.sentry.app', - subsystemFilter: 'swiftui', - }, - mockExecutor, - logCaptureStub, - ); - - expect(result.isError).toBeUndefined(); - const text = allText(result); - expect(text).toContain('SwiftUI logs'); - expect(text).toContain('Self._printChanges()'); - }); - - it('should indicate all logs capture when subsystemFilter is all', async () => { - const mockExecutor = createMockExecutor({ success: true, output: '' }); - const logCaptureStub = (params: any, executor: any) => { - return Promise.resolve({ - sessionId: 'test-uuid-123', - logFilePath: '/tmp/test.log', - processes: [], - error: undefined, - }); - }; - - const result = await start_sim_log_capLogic( - { - simulatorId: 'test-uuid', - bundleId: 'io.sentry.app', - subsystemFilter: 'all', - }, - mockExecutor, - logCaptureStub, - ); - - expect(result.isError).toBeUndefined(); - const text = allText(result); - expect(text).toContain('all system logs'); - }); - - it('should indicate custom subsystems when array is provided', async () => { - const mockExecutor = createMockExecutor({ success: true, output: '' }); - const logCaptureStub = (params: any, executor: any) => { - return Promise.resolve({ - sessionId: 'test-uuid-123', - logFilePath: '/tmp/test.log', - processes: [], - error: undefined, - }); - }; - - const result = await start_sim_log_capLogic( - { - simulatorId: 'test-uuid', - bundleId: 'io.sentry.app', - subsystemFilter: ['com.apple.UIKit', 'com.apple.CoreData'], - }, - mockExecutor, - logCaptureStub, - ); - - expect(result.isError).toBeUndefined(); - const text = allText(result); - expect(text).toContain('com.apple.UIKit'); - expect(text).toContain('com.apple.CoreData'); - }); - - it('should indicate console capture when captureConsole is true', async () => { - const mockExecutor = createMockExecutor({ success: true, output: '' }); - const logCaptureStub = (params: any, executor: any) => { - return Promise.resolve({ - sessionId: 'test-uuid-123', - logFilePath: '/tmp/test.log', - processes: [], - error: undefined, - }); - }; - - const result = await start_sim_log_capLogic( - { - simulatorId: 'test-uuid', - bundleId: 'io.sentry.app', - captureConsole: true, - subsystemFilter: 'app', - }, - mockExecutor, - logCaptureStub, - ); - - const text = allText(result); - expect(text).toContain('App relaunched to capture console output'); - expect(text).toContain('test-uuid-123'); - }); - it('should create correct spawn commands for console capture', async () => { const mockExecutor = createMockExecutor({ success: true, output: '' }); const spawnCalls: Array<{ diff --git a/src/mcp/tools/logging/start_device_log_cap.ts b/src/mcp/tools/logging/start_device_log_cap.ts index 4500f2cf..dd095bac 100644 --- a/src/mcp/tools/logging/start_device_log_cap.ts +++ b/src/mcp/tools/logging/start_device_log_cap.ts @@ -1,11 +1,5 @@ -/** - * Logging Plugin: Start Device Log Capture - * - * Starts capturing logs from a specified Apple device by launching the app with console output. - */ - -import * as path from 'path'; -import type { ChildProcess } from 'child_process'; +import * as path from 'node:path'; +import type { ChildProcess } from 'node:child_process'; import { v4 as uuidv4 } from 'uuid'; import * as z from 'zod'; import { log } from '../../../utils/logging/index.ts'; @@ -25,7 +19,7 @@ import { activeDeviceLogSessions, type DeviceLogSession, } from '../../../utils/log-capture/device-log-sessions.ts'; -import type { WriteStream } from 'fs'; +import type { WriteStream } from 'node:fs'; import { getConfig } from '../../../utils/config-store.ts'; import { acquireDaemonActivity } from '../../../daemon/activity-registry.ts'; @@ -35,7 +29,8 @@ import { acquireDaemonActivity } from '../../../daemon/activity-registry.ts'; * - Cleanup runs on every new log capture start */ const LOG_RETENTION_DAYS = 3; -const DEVICE_LOG_FILE_PREFIX = 'xcodemcp_device_log_'; +const LOG_SUBDIR = 'xcodemcp'; +const LOG_DIR = 'logs'; // Note: Device and simulator logging use different approaches due to platform constraints: // - Simulators use 'xcrun simctl' with console-pty and OSLog stream capabilities @@ -234,15 +229,17 @@ export async function startDeviceLogCapture( const { deviceUuid, bundleId } = params; const logSessionId = uuidv4(); - const logFileName = `${DEVICE_LOG_FILE_PREFIX}${logSessionId}.log`; + const ts = new Date().toISOString().replace(/:/g, '-').replace('.', '-').slice(0, -1) + 'Z'; + const logFileName = `${bundleId}_${ts}.log`; const tempDir = fileSystemExecutor.tmpdir(); - const logFilePath = path.join(tempDir, logFileName); + const logsDir = path.join(tempDir, LOG_SUBDIR, LOG_DIR); + const logFilePath = path.join(logsDir, logFileName); const launchJsonPath = path.join(tempDir, `devicectl-launch-${logSessionId}.json`); let logStream: WriteStream | undefined; try { - await fileSystemExecutor.mkdir(tempDir, { recursive: true }); + await fileSystemExecutor.mkdir(logsDir, { recursive: true }); await fileSystemExecutor.writeFile(logFilePath, ''); logStream = fileSystemExecutor.createWriteStream(logFilePath, { flags: 'a' }); @@ -567,15 +564,11 @@ function extractFailureMessage(output?: string): string | undefined { // Device logs follow the same retention policy as simulator logs but use a different prefix // to avoid conflicts. Both clean up logs older than LOG_RETENTION_DAYS automatically. async function cleanOldDeviceLogs(fileSystemExecutor: FileSystemExecutor): Promise { - const tempDir = fileSystemExecutor.tmpdir(); + const logsDir = path.join(fileSystemExecutor.tmpdir(), LOG_SUBDIR, LOG_DIR); let files: unknown[]; try { - files = await fileSystemExecutor.readdir(tempDir); - } catch (err) { - log( - 'warn', - `Could not read temp dir for device log cleanup: ${err instanceof Error ? err.message : String(err)}`, - ); + files = await fileSystemExecutor.readdir(logsDir); + } catch { return; } const now = Date.now(); @@ -584,9 +577,9 @@ async function cleanOldDeviceLogs(fileSystemExecutor: FileSystemExecutor): Promi await Promise.all( fileNames - .filter((f) => f.startsWith(DEVICE_LOG_FILE_PREFIX) && f.endsWith('.log')) + .filter((f) => f.endsWith('.log')) .map(async (f) => { - const filePath = path.join(tempDir, f); + const filePath = path.join(logsDir, f); try { const stat = await fileSystemExecutor.stat(filePath); if (now - stat.mtimeMs > retentionMs) { @@ -648,10 +641,7 @@ export async function start_device_log_capLogic( [ headerEvent, statusLine('success', 'Log capture started.'), - detailTree([ - { label: 'Session ID', value: sessionId }, - { label: 'Note', value: 'Do not call launch_app_device during this session' }, - ]), + detailTree([{ label: 'Session ID', value: sessionId }]), ], { nextStepParams: { diff --git a/src/mcp/tools/logging/start_sim_log_cap.ts b/src/mcp/tools/logging/start_sim_log_cap.ts index 25037ab4..9ca79ff2 100644 --- a/src/mcp/tools/logging/start_sim_log_cap.ts +++ b/src/mcp/tools/logging/start_sim_log_cap.ts @@ -33,19 +33,16 @@ type StartSimLogCapParams = z.infer; function buildSubsystemFilterDescription(subsystemFilter: SubsystemFilter): string { if (subsystemFilter === 'all') { - return 'Capturing all system logs (no subsystem filtering).'; + return 'All logs'; } if (subsystemFilter === 'swiftui') { - return 'Capturing app logs + SwiftUI logs (includes Self._printChanges()).'; + return 'App + SwiftUI logs'; } - if (Array.isArray(subsystemFilter)) { - if (subsystemFilter.length === 0) { - return 'Only structured logs from the app subsystem are being captured.'; - } - return `Capturing logs from subsystems: ${subsystemFilter.join(', ')} (plus app bundle ID).`; + if (Array.isArray(subsystemFilter) && subsystemFilter.length > 0) { + return `Custom subsystems: ${subsystemFilter.join(', ')}`; } - return 'Only structured logs from the app subsystem are being captured.'; + return 'Structured logs only'; } export async function start_sim_log_capLogic( @@ -55,9 +52,11 @@ export async function start_sim_log_capLogic( ): Promise { const { bundleId, simulatorId, subsystemFilter } = params; const captureConsole = params.captureConsole ?? false; + const filterDescription = buildSubsystemFilterDescription(subsystemFilter); const headerEvent = header('Start Log Capture', [ { label: 'Simulator', value: simulatorId }, { label: 'Bundle ID', value: bundleId }, + { label: 'Filter', value: filterDescription }, ]); const logCaptureParams: Parameters[0] = { @@ -71,22 +70,13 @@ export async function start_sim_log_capLogic( return toolResponse([headerEvent, statusLine('error', `Error starting log capture: ${error}`)]); } - const filterDescription = buildSubsystemFilterDescription(subsystemFilter); - - const items: Array<{ label: string; value: string }> = [ - { label: 'Session ID', value: sessionId }, - { label: 'Filter', value: filterDescription }, - ]; + const items: Array<{ label: string; value: string }> = [{ label: 'Session ID', value: sessionId }]; if (captureConsole) { items.push({ label: 'Console', value: 'App relaunched to capture console output' }); } return toolResponse( - [ - headerEvent, - statusLine('success', 'Log capture started.'), - detailTree(items), - ], + [headerEvent, statusLine('success', 'Log capture started.'), detailTree(items)], { nextStepParams: { stop_sim_log_cap: { logSessionId: sessionId }, diff --git a/src/mcp/tools/logging/stop_device_log_cap.ts b/src/mcp/tools/logging/stop_device_log_cap.ts index 1980648d..9d227e0b 100644 --- a/src/mcp/tools/logging/stop_device_log_cap.ts +++ b/src/mcp/tools/logging/stop_device_log_cap.ts @@ -15,7 +15,7 @@ import { getDefaultFileSystemExecutor, getDefaultCommandExecutor } from '../../. import type { FileSystemExecutor } from '../../../utils/FileSystemExecutor.ts'; import { createTypedTool } from '../../../utils/typed-tool-factory.ts'; import { toolResponse } from '../../../utils/tool-response.ts'; -import { header, section, statusLine } from '../../../utils/tool-event-builders.ts'; +import { header, section, statusLine, detailTree } from '../../../utils/tool-event-builders.ts'; const stopDeviceLogCapSchema = z.object({ logSessionId: z.string(), @@ -49,11 +49,13 @@ export async function stop_device_log_capLogic( ]); } - return toolResponse([ + const events = [ headerEvent, statusLine('success', 'Log capture stopped.'), - section('Captured Logs', result.logContent.split('\n')), - ]); + ...(result.logFilePath ? [detailTree([{ label: 'Logs', value: result.logFilePath }])] : []), + section('Captured Logs:', result.logContent.split('\n')), + ]; + return toolResponse(events); } catch (error) { const message = error instanceof Error ? error.message : String(error); log('error', `Failed to stop device log capture session ${logSessionId}: ${message}`); diff --git a/src/mcp/tools/logging/stop_sim_log_cap.ts b/src/mcp/tools/logging/stop_sim_log_cap.ts index 9153ebfc..4e5793d0 100644 --- a/src/mcp/tools/logging/stop_sim_log_cap.ts +++ b/src/mcp/tools/logging/stop_sim_log_cap.ts @@ -12,7 +12,7 @@ import type { CommandExecutor } from '../../../utils/command.ts'; import { getDefaultCommandExecutor, getDefaultFileSystemExecutor } from '../../../utils/command.ts'; import type { FileSystemExecutor } from '../../../utils/FileSystemExecutor.ts'; import { toolResponse } from '../../../utils/tool-response.ts'; -import { header, section, statusLine } from '../../../utils/tool-event-builders.ts'; +import { header, section, statusLine, detailTree } from '../../../utils/tool-event-builders.ts'; const stopSimLogCapSchema = z.object({ logSessionId: z.string(), @@ -26,7 +26,7 @@ type StopSimLogCapParams = z.infer; export type StopLogCaptureFunction = ( logSessionId: string, fileSystem?: FileSystemExecutor, -) => Promise<{ logContent: string; error?: string }>; +) => Promise<{ logContent: string; logFilePath?: string; error?: string }>; export async function stop_sim_log_capLogic( params: StopSimLogCapParams, @@ -37,18 +37,23 @@ export async function stop_sim_log_capLogic( const headerEvent = header('Stop Log Capture', [ { label: 'Session ID', value: params.logSessionId }, ]); - const { logContent, error } = await stopLogCaptureFunction(params.logSessionId, fileSystem); + const { logContent, logFilePath, error } = await stopLogCaptureFunction( + params.logSessionId, + fileSystem, + ); if (error) { return toolResponse([ headerEvent, statusLine('error', `Error stopping log capture session ${params.logSessionId}: ${error}`), ]); } - return toolResponse([ + const events = [ headerEvent, statusLine('success', 'Log capture stopped.'), - section('Captured Logs', logContent.split('\n')), - ]); + ...(logFilePath ? [detailTree([{ label: 'Logs', value: logFilePath }])] : []), + section('Captured Logs:', logContent.split('\n')), + ]; + return toolResponse(events); } export const schema = stopSimLogCapSchema.shape; // MCP SDK compatibility diff --git a/src/mcp/tools/macos/__tests__/build_macos.test.ts b/src/mcp/tools/macos/__tests__/build_macos.test.ts index a92ceb9e..1a808dd0 100644 --- a/src/mcp/tools/macos/__tests__/build_macos.test.ts +++ b/src/mcp/tools/macos/__tests__/build_macos.test.ts @@ -1,31 +1,23 @@ import { describe, it, expect, beforeEach } from 'vitest'; import * as z from 'zod'; import { createMockExecutor } from '../../../../test-utils/mock-executors.ts'; +import { expectPendingBuildResponse } from '../../../../test-utils/test-helpers.ts'; import { sessionStore } from '../../../../utils/session-store.ts'; import { schema, handler, buildMacOSLogic } from '../build_macos.ts'; -function expectPendingBuildResponse( - result: Awaited>, - nextStepToolId?: string, -): void { - 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(); - } +function createSpyExecutor(): { + capturedCommand: string[]; + executor: ReturnType; +} { + const capturedCommand: string[] = []; + const executor = createMockExecutor({ + success: true, + output: 'BUILD SUCCEEDED', + onExecute: (command) => { + if (capturedCommand.length === 0) capturedCommand.push(...command); + }, + }); + return { capturedCommand, executor }; } describe('build_macos plugin', () => { @@ -183,24 +175,17 @@ describe('build_macos plugin', () => { describe('Command Generation', () => { it('should generate correct xcodebuild command with minimal parameters', async () => { - let capturedCommand: string[] = []; - const mockExecutor = createMockExecutor({ success: true, output: 'BUILD SUCCEEDED' }); - - // Override the executor to capture the command - const spyExecutor = async (command: string[]) => { - capturedCommand = command; - return mockExecutor(command); - }; + const spy = createSpyExecutor(); - const result = await buildMacOSLogic( + await buildMacOSLogic( { projectPath: '/path/to/project.xcodeproj', scheme: 'MyScheme', }, - spyExecutor, + spy.executor, ); - expect(capturedCommand).toEqual([ + expect(spy.capturedCommand).toEqual([ 'xcodebuild', '-project', '/path/to/project.xcodeproj', @@ -216,16 +201,9 @@ describe('build_macos plugin', () => { }); it('should generate correct xcodebuild command with all parameters', async () => { - let capturedCommand: string[] = []; - const mockExecutor = createMockExecutor({ success: true, output: 'BUILD SUCCEEDED' }); - - // Override the executor to capture the command - const spyExecutor = async (command: string[]) => { - capturedCommand = command; - return mockExecutor(command); - }; + const spy = createSpyExecutor(); - const result = await buildMacOSLogic( + await buildMacOSLogic( { projectPath: '/path/to/project.xcodeproj', scheme: 'MyScheme', @@ -235,10 +213,10 @@ describe('build_macos plugin', () => { extraArgs: ['--verbose'], preferXcodebuild: true, }, - spyExecutor, + spy.executor, ); - expect(capturedCommand).toEqual([ + expect(spy.capturedCommand).toEqual([ 'xcodebuild', '-project', '/path/to/project.xcodeproj', @@ -257,25 +235,18 @@ describe('build_macos plugin', () => { }); it('should generate correct xcodebuild command with only derivedDataPath', async () => { - let capturedCommand: string[] = []; - const mockExecutor = createMockExecutor({ success: true, output: 'BUILD SUCCEEDED' }); + const spy = createSpyExecutor(); - // Override the executor to capture the command - const spyExecutor = async (command: string[]) => { - capturedCommand = command; - return mockExecutor(command); - }; - - const result = await buildMacOSLogic( + await buildMacOSLogic( { projectPath: '/path/to/project.xcodeproj', scheme: 'MyScheme', derivedDataPath: '/custom/derived/data', }, - spyExecutor, + spy.executor, ); - expect(capturedCommand).toEqual([ + expect(spy.capturedCommand).toEqual([ 'xcodebuild', '-project', '/path/to/project.xcodeproj', @@ -293,25 +264,18 @@ describe('build_macos plugin', () => { }); it('should generate correct xcodebuild command with arm64 architecture only', async () => { - let capturedCommand: string[] = []; - const mockExecutor = createMockExecutor({ success: true, output: 'BUILD SUCCEEDED' }); + const spy = createSpyExecutor(); - // Override the executor to capture the command - const spyExecutor = async (command: string[]) => { - capturedCommand = command; - return mockExecutor(command); - }; - - const result = await buildMacOSLogic( + await buildMacOSLogic( { projectPath: '/path/to/project.xcodeproj', scheme: 'MyScheme', arch: 'arm64', }, - spyExecutor, + spy.executor, ); - expect(capturedCommand).toEqual([ + expect(spy.capturedCommand).toEqual([ 'xcodebuild', '-project', '/path/to/project.xcodeproj', @@ -327,24 +291,17 @@ describe('build_macos plugin', () => { }); it('should handle paths with spaces in command generation', async () => { - let capturedCommand: string[] = []; - const mockExecutor = createMockExecutor({ success: true, output: 'BUILD SUCCEEDED' }); - - // Override the executor to capture the command - const spyExecutor = async (command: string[]) => { - capturedCommand = command; - return mockExecutor(command); - }; + const spy = createSpyExecutor(); - const result = await buildMacOSLogic( + await buildMacOSLogic( { projectPath: '/Users/dev/My Project/MyProject.xcodeproj', scheme: 'MyScheme', }, - spyExecutor, + spy.executor, ); - expect(capturedCommand).toEqual([ + expect(spy.capturedCommand).toEqual([ 'xcodebuild', '-project', '/Users/dev/My Project/MyProject.xcodeproj', @@ -360,24 +317,17 @@ describe('build_macos plugin', () => { }); it('should generate correct xcodebuild workspace command with minimal parameters', async () => { - let capturedCommand: string[] = []; - const mockExecutor = createMockExecutor({ success: true, output: 'BUILD SUCCEEDED' }); - - // Override the executor to capture the command - const spyExecutor = async (command: string[]) => { - capturedCommand = command; - return mockExecutor(command); - }; + const spy = createSpyExecutor(); - const result = await buildMacOSLogic( + await buildMacOSLogic( { workspacePath: '/path/to/workspace.xcworkspace', scheme: 'MyScheme', }, - spyExecutor, + spy.executor, ); - expect(capturedCommand).toEqual([ + expect(spy.capturedCommand).toEqual([ 'xcodebuild', '-workspace', '/path/to/workspace.xcworkspace', diff --git a/src/mcp/tools/macos/__tests__/build_run_macos.test.ts b/src/mcp/tools/macos/__tests__/build_run_macos.test.ts index a3cb547a..2b68541c 100644 --- a/src/mcp/tools/macos/__tests__/build_run_macos.test.ts +++ b/src/mcp/tools/macos/__tests__/build_run_macos.test.ts @@ -146,7 +146,10 @@ describe('build_run_macos', () => { type: 'detail-tree', items: expect.arrayContaining([ expect.objectContaining({ label: 'App Path', value: '/path/to/build/MyApp.app' }), - expect.objectContaining({ label: 'Build Logs', value: expect.stringContaining('build_run_macos_') }), + expect.objectContaining({ + label: 'Build Logs', + value: expect.stringContaining('build_run_macos_'), + }), ]), }), ], diff --git a/src/mcp/tools/macos/__tests__/launch_mac_app.test.ts b/src/mcp/tools/macos/__tests__/launch_mac_app.test.ts index 3cc7750a..6d201d67 100644 --- a/src/mcp/tools/macos/__tests__/launch_mac_app.test.ts +++ b/src/mcp/tools/macos/__tests__/launch_mac_app.test.ts @@ -89,7 +89,6 @@ describe('launch_mac_app plugin', () => { mockFileSystem, ); - expect(calls).toHaveLength(1); expect(calls[0].command).toEqual(['open', '/path/to/MyApp.app']); }); @@ -113,7 +112,6 @@ describe('launch_mac_app plugin', () => { mockFileSystem, ); - expect(calls).toHaveLength(1); expect(calls[0].command).toEqual([ 'open', '/path/to/MyApp.app', @@ -143,7 +141,6 @@ describe('launch_mac_app plugin', () => { mockFileSystem, ); - expect(calls).toHaveLength(1); expect(calls[0].command).toEqual(['open', '/path/to/MyApp.app']); }); @@ -166,7 +163,6 @@ describe('launch_mac_app plugin', () => { mockFileSystem, ); - expect(calls).toHaveLength(1); expect(calls[0].command).toEqual(['open', '/Applications/My App.app']); }); }); diff --git a/src/mcp/tools/macos/build_macos.ts b/src/mcp/tools/macos/build_macos.ts index c58fd7c5..6d808bab 100644 --- a/src/mcp/tools/macos/build_macos.ts +++ b/src/mcp/tools/macos/build_macos.ts @@ -1,10 +1,3 @@ -/** - * macOS Shared Plugin: Build macOS (Unified) - * - * Builds a macOS app using xcodebuild from a project or workspace. - * Accepts mutually exclusive `projectPath` or `workspacePath`. - */ - import * as z from 'zod'; import { log } from '../../../utils/logging/index.ts'; import { executeXcodeBuildCommand } from '../../../utils/build/index.ts'; @@ -20,8 +13,9 @@ import { nullifyEmptyStrings } from '../../../utils/schema-helpers.ts'; import { startBuildPipeline } from '../../../utils/xcodebuild-pipeline.ts'; import { createPendingXcodebuildResponse } from '../../../utils/xcodebuild-output.ts'; import { formatToolPreflight } from '../../../utils/build-preflight.ts'; +import { resolveAppPathFromBuildSettings } from '../../../utils/app-path-resolver.ts'; +import { detailTree } from '../../../utils/tool-event-builders.ts'; -// Unified schema: XOR between projectPath and workspacePath const baseSchemaObject = z.object({ projectPath: z.string().optional().describe('Path to the .xcodeproj file'), workspacePath: z.string().optional().describe('Path to the .xcworkspace file'), @@ -59,15 +53,11 @@ const buildMacOSSchema = z.preprocess( export type BuildMacOSParams = z.infer; -/** - * Business logic for building macOS apps from project or workspace with dependency injection. - * Exported for direct testing and reuse. - */ export async function buildMacOSLogic( params: BuildMacOSParams, executor: CommandExecutor, ): Promise { - log('info', `Starting macOS build for scheme ${params.scheme} (internal)`); + log('info', `Starting macOS build for scheme ${params.scheme}`); const processedParams = { ...params, @@ -110,25 +100,59 @@ export async function buildMacOSLogic( const buildResult = await executeXcodeBuildCommand( processedParams, platformOptions, - processedParams.preferXcodebuild ?? false, + processedParams.preferXcodebuild, 'build', executor, undefined, started.pipeline, ); + if (buildResult.isError) { + return createPendingXcodebuildResponse(started, buildResult); + } + + let bundleId: string | undefined; + try { + const appPath = await resolveAppPathFromBuildSettings( + { + projectPath: params.projectPath, + workspacePath: params.workspacePath, + scheme: params.scheme, + configuration: processedParams.configuration, + platform: XcodePlatform.macOS, + derivedDataPath: params.derivedDataPath, + extraArgs: params.extraArgs, + }, + executor, + ); + + 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: bundle ID is informational + } + + const tailEvents = bundleId ? [detailTree([{ label: 'Bundle ID', value: bundleId }])] : []; + return createPendingXcodebuildResponse( started, - buildResult.isError - ? buildResult - : { - ...buildResult, - nextStepParams: { - get_mac_app_path: { - scheme: params.scheme, - }, - }, + { + ...buildResult, + nextStepParams: { + get_mac_app_path: { + scheme: params.scheme, }, + }, + }, + { + tailEvents, + }, ); } diff --git a/src/mcp/tools/macos/build_run_macos.ts b/src/mcp/tools/macos/build_run_macos.ts index 64e2ed70..2d12c5e4 100644 --- a/src/mcp/tools/macos/build_run_macos.ts +++ b/src/mcp/tools/macos/build_run_macos.ts @@ -1,10 +1,3 @@ -/** - * macOS Shared Plugin: Build and Run macOS (Unified) - * - * Builds and runs a macOS app from a project or workspace in one step. - * Accepts mutually exclusive `projectPath` or `workspacePath`. - */ - import * as z from 'zod'; import { log } from '../../../utils/logging/index.ts'; import { executeXcodeBuildCommand } from '../../../utils/build/index.ts'; @@ -28,8 +21,8 @@ import { emitPipelineNotice, } from '../../../utils/xcodebuild-output.ts'; import { resolveAppPathFromBuildSettings } from '../../../utils/app-path-resolver.ts'; +import path from 'node:path'; -// Unified schema: XOR between projectPath and workspacePath const baseSchemaObject = z.object({ projectPath: z.string().optional().describe('Path to the .xcodeproj file'), workspacePath: z.string().optional().describe('Path to the .xcworkspace file'), @@ -67,15 +60,10 @@ const buildRunMacOSSchema = z.preprocess( export type BuildRunMacOSParams = z.infer; -/** - * Business logic for building and running macOS apps. - */ export async function buildRunMacOSLogic( params: BuildRunMacOSParams, executor: CommandExecutor, ): Promise { - log('info', 'Handling macOS build & run logic...'); - try { const configuration = params.configuration ?? 'Debug'; @@ -175,6 +163,34 @@ export async function buildRunMacOSLogic( data: { step: 'launch-app', status: 'succeeded', appPath }, }); + 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'); + let processId: number | undefined; + 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)) { + processId = pid; + } + } + } catch { + // non-fatal + } + return createPendingXcodebuildResponse( started, { @@ -187,6 +203,8 @@ export async function buildRunMacOSLogic( platform: 'macOS', target: 'macOS', appPath, + bundleId, + processId, launchState: 'requested', buildLogPath: started.pipeline.logPath, }), diff --git a/src/mcp/tools/macos/get_mac_app_path.ts b/src/mcp/tools/macos/get_mac_app_path.ts index 4a8d68bc..2232801a 100644 --- a/src/mcp/tools/macos/get_mac_app_path.ts +++ b/src/mcp/tools/macos/get_mac_app_path.ts @@ -1,10 +1,3 @@ -/** - * macOS Shared Plugin: Get macOS App Path (Unified) - * - * Gets the app bundle path for a macOS application using either a project or workspace. - * Accepts mutually exclusive `projectPath` or `workspacePath`. - */ - import * as z from 'zod'; import type { ToolResponse } from '../../../types/common.ts'; import { log } from '../../../utils/logging/index.ts'; @@ -16,7 +9,10 @@ import { } from '../../../utils/typed-tool-factory.ts'; import { nullifyEmptyStrings } from '../../../utils/schema-helpers.ts'; import { formatToolPreflight } from '../../../utils/build-preflight.ts'; -import { formatQueryError, formatQueryFailureSummary } from '../../../utils/xcodebuild-error-utils.ts'; +import { + formatQueryError, + formatQueryFailureSummary, +} from '../../../utils/xcodebuild-error-utils.ts'; import { extractAppPathFromBuildSettingsOutput } from '../../../utils/app-path-resolver.ts'; const baseOptions = { @@ -107,7 +103,7 @@ export async function get_mac_app_pathLogic( content: [ { type: 'text', - text: `\n${preflightText}${formatQueryError(rawOutput)}\n\n${formatQueryFailureSummary()}`, + text: `\n${preflightText}\n${formatQueryError(rawOutput)}\n\n${formatQueryFailureSummary()}`, }, ], isError: true, @@ -119,7 +115,7 @@ export async function get_mac_app_pathLogic( content: [ { type: 'text', - text: `\n${preflightText}${formatQueryError('Failed to extract build settings output from the result.')}\n\n${formatQueryFailureSummary()}`, + text: `\n${preflightText}\n${formatQueryError('Failed to extract build settings output from the result.')}\n\n${formatQueryFailureSummary()}`, }, ], isError: true, @@ -134,7 +130,7 @@ export async function get_mac_app_pathLogic( content: [ { type: 'text', - text: `\n${preflightText}${formatQueryError('Could not extract app path from build settings.')}\n\n${formatQueryFailureSummary()}`, + text: `\n${preflightText}\n${formatQueryError('Could not extract app path from build settings.')}\n\n${formatQueryFailureSummary()}`, }, ], isError: true, @@ -145,7 +141,7 @@ export async function get_mac_app_pathLogic( content: [ { type: 'text', - text: `\n${preflightText} โ”” App Path: ${appPath}`, + text: `\n${preflightText}\nโœ… Success\n โ”” App Path: ${appPath}`, }, ], nextStepParams: { @@ -160,7 +156,7 @@ export async function get_mac_app_pathLogic( content: [ { type: 'text', - text: `\n${preflightText}${formatQueryError(errorMessage)}\n\n${formatQueryFailureSummary()}`, + text: `\n${preflightText}\n${formatQueryError(errorMessage)}\n\n${formatQueryFailureSummary()}`, }, ], isError: true, diff --git a/src/mcp/tools/macos/launch_mac_app.ts b/src/mcp/tools/macos/launch_mac_app.ts index c9594311..7b1dc388 100644 --- a/src/mcp/tools/macos/launch_mac_app.ts +++ b/src/mcp/tools/macos/launch_mac_app.ts @@ -1,19 +1,14 @@ -/** - * macOS Workspace Plugin: Launch macOS App - * - * Launches a macOS application using the 'open' command. - * IMPORTANT: You MUST provide the appPath parameter. - */ - import * as z from 'zod'; import { log } from '../../../utils/logging/index.ts'; import { validateFileExists } from '../../../utils/validation.ts'; import type { ToolResponse } from '../../../types/common.ts'; +import type { PipelineEvent } from '../../../types/pipeline-events.ts'; import type { CommandExecutor, FileSystemExecutor } from '../../../utils/execution/index.ts'; import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; import { createTypedTool } from '../../../utils/typed-tool-factory.ts'; import { toolResponse } from '../../../utils/tool-response.ts'; -import { header, statusLine } from '../../../utils/tool-event-builders.ts'; +import { header, statusLine, detailTree } from '../../../utils/tool-event-builders.ts'; +import path from 'node:path'; const launchMacAppSchema = z.object({ appPath: z.string(), @@ -45,7 +40,51 @@ export async function launch_mac_appLogic( await executor(command, 'Launch macOS App'); - return toolResponse([headerEvent, statusLine('success', 'App launched successfully.')]); + const appName = path.basename(params.appPath, '.app'); + let bundleId: string | undefined; + try { + const plistResult = await executor( + ['/bin/sh', '-c', `defaults read "${params.appPath}/Contents/Info" CFBundleIdentifier`], + 'Extract Bundle ID', + false, + ); + if (plistResult.success && plistResult.output) { + bundleId = plistResult.output.trim(); + } + } catch { + // non-fatal + } + + let processId: number | undefined; + 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)) { + processId = pid; + } + } + } catch { + // non-fatal + } + + const details: Array<{ label: string; value: string }> = []; + if (bundleId) { + details.push({ label: 'Bundle ID', value: bundleId }); + } + if (processId !== undefined) { + details.push({ label: 'Process ID', value: String(processId) }); + } + + const events: PipelineEvent[] = [ + headerEvent, + statusLine('success', 'App launched successfully'), + ]; + if (details.length > 0) { + events.push(detailTree(details)); + } + + return toolResponse(events); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); log('error', `Error during launch macOS app operation: ${errorMessage}`); diff --git a/src/mcp/tools/macos/stop_mac_app.ts b/src/mcp/tools/macos/stop_mac_app.ts index a5c0918e..66fa6088 100644 --- a/src/mcp/tools/macos/stop_mac_app.ts +++ b/src/mcp/tools/macos/stop_mac_app.ts @@ -48,7 +48,7 @@ export async function stop_mac_appLogic( ]); } - return toolResponse([headerEvent, statusLine('success', 'App stopped successfully.')]); + return toolResponse([headerEvent, statusLine('success', 'App stopped successfully')]); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); log('error', `Error stopping macOS app: ${errorMessage}`); diff --git a/src/mcp/tools/project-discovery/__tests__/discover_projs.test.ts b/src/mcp/tools/project-discovery/__tests__/discover_projs.test.ts index 063b96a4..92e56288 100644 --- a/src/mcp/tools/project-discovery/__tests__/discover_projs.test.ts +++ b/src/mcp/tools/project-discovery/__tests__/discover_projs.test.ts @@ -77,7 +77,7 @@ describe('discover_projs plugin', () => { expect(result.isError).toBeFalsy(); const text = allText(result); expect(text).toContain('Discover Projects'); - expect(text).toContain('Found 0 project(s) and 0 workspace(s).'); + expect(text).toContain('Found 0 projects and 0 workspaces'); }); it('should return error when scan path does not exist', async () => { @@ -134,7 +134,7 @@ describe('discover_projs plugin', () => { expect(result.isError).toBeFalsy(); const text = allText(result); - expect(text).toContain('Found 0 project(s) and 0 workspace(s).'); + expect(text).toContain('Found 0 projects and 0 workspaces'); }); it('should return success with projects found', async () => { @@ -155,7 +155,7 @@ describe('discover_projs plugin', () => { expect(result.isError).toBeFalsy(); const text = allText(result); - expect(text).toContain('Found 1 project(s) and 1 workspace(s).'); + expect(text).toContain('Found 1 project and 1 workspace'); expect(text).toContain('/workspace/MyApp.xcodeproj'); expect(text).toContain('/workspace/MyWorkspace.xcworkspace'); }); @@ -213,7 +213,7 @@ describe('discover_projs plugin', () => { expect(result.isError).toBeFalsy(); const text = allText(result); - expect(text).toContain('Found 0 project(s) and 0 workspace(s).'); + expect(text).toContain('Found 0 projects and 0 workspaces'); }); it('should handle scan path outside workspace root', async () => { @@ -231,7 +231,7 @@ describe('discover_projs plugin', () => { expect(result.isError).toBeFalsy(); const text = allText(result); - expect(text).toContain('Found 0 project(s) and 0 workspace(s).'); + expect(text).toContain('Found 0 projects and 0 workspaces'); }); it('should handle error with object containing message and code properties', async () => { @@ -286,7 +286,7 @@ describe('discover_projs plugin', () => { expect(result.isError).toBeFalsy(); const text = allText(result); - expect(text).toContain('Found 0 project(s) and 0 workspace(s).'); + expect(text).toContain('Found 0 projects and 0 workspaces'); }); it('should handle skipped directory types during scan', async () => { @@ -309,7 +309,7 @@ describe('discover_projs plugin', () => { expect(result.isError).toBeFalsy(); const text = allText(result); - expect(text).toContain('Found 0 project(s) and 0 workspace(s).'); + expect(text).toContain('Found 0 projects and 0 workspaces'); }); it('should handle error during recursive directory reading', async () => { @@ -331,7 +331,7 @@ describe('discover_projs plugin', () => { expect(result.isError).toBeFalsy(); const text = allText(result); - expect(text).toContain('Found 0 project(s) and 0 workspace(s).'); + expect(text).toContain('Found 0 projects and 0 workspaces'); }); }); }); diff --git a/src/mcp/tools/project-discovery/__tests__/get_app_bundle_id.test.ts b/src/mcp/tools/project-discovery/__tests__/get_app_bundle_id.test.ts index aab78119..89bc1f29 100644 --- a/src/mcp/tools/project-discovery/__tests__/get_app_bundle_id.test.ts +++ b/src/mcp/tools/project-discovery/__tests__/get_app_bundle_id.test.ts @@ -85,7 +85,8 @@ describe('get_app_bundle_id plugin', () => { expect(result.isError).toBeFalsy(); const text = allText(result); expect(text).toContain('Get Bundle ID'); - expect(text).toContain('Bundle ID: io.sentry.MyApp'); + expect(text).toContain('Bundle ID'); + expect(text).toContain('io.sentry.MyApp'); expect(result.nextStepParams).toEqual({ install_app_sim: { simulatorId: 'SIMULATOR_UUID', appPath: '/path/to/MyApp.app' }, launch_app_sim: { simulatorId: 'SIMULATOR_UUID', bundleId: 'io.sentry.MyApp' }, @@ -114,7 +115,8 @@ describe('get_app_bundle_id plugin', () => { expect(result.isError).toBeFalsy(); const text = allText(result); - expect(text).toContain('Bundle ID: io.sentry.MyApp'); + expect(text).toContain('Bundle ID'); + expect(text).toContain('io.sentry.MyApp'); expect(result.nextStepParams).toEqual({ install_app_sim: { simulatorId: 'SIMULATOR_UUID', appPath: '/path/to/MyApp.app' }, launch_app_sim: { simulatorId: 'SIMULATOR_UUID', bundleId: 'io.sentry.MyApp' }, diff --git a/src/mcp/tools/project-discovery/__tests__/get_mac_bundle_id.test.ts b/src/mcp/tools/project-discovery/__tests__/get_mac_bundle_id.test.ts index 8a0ba368..90ccf35b 100644 --- a/src/mcp/tools/project-discovery/__tests__/get_mac_bundle_id.test.ts +++ b/src/mcp/tools/project-discovery/__tests__/get_mac_bundle_id.test.ts @@ -1,5 +1,4 @@ import { describe, it, expect, beforeEach } from 'vitest'; -import * as z from 'zod'; import { schema, handler, get_mac_bundle_idLogic } from '../get_mac_bundle_id.ts'; import { createMockFileSystemExecutor, @@ -21,24 +20,11 @@ describe('get_mac_bundle_id plugin', () => { ); }; - describe('Export Field Validation (Literal)', () => { - it('should have handler function', () => { + describe('Plugin Structure', () => { + it('should expose schema and handler', () => { + expect(schema).toBeDefined(); expect(typeof handler).toBe('function'); }); - - it('should validate schema with valid inputs', () => { - const schemaObj = z.object(schema); - expect(schemaObj.safeParse({ appPath: '/Applications/TextEdit.app' }).success).toBe(true); - expect(schemaObj.safeParse({ appPath: '/Users/dev/MyApp.app' }).success).toBe(true); - }); - - it('should validate schema with invalid inputs', () => { - const schemaObj = z.object(schema); - expect(schemaObj.safeParse({}).success).toBe(false); - expect(schemaObj.safeParse({ appPath: 123 }).success).toBe(false); - expect(schemaObj.safeParse({ appPath: null }).success).toBe(false); - expect(schemaObj.safeParse({ appPath: undefined }).success).toBe(false); - }); }); describe('Handler Behavior (Complete Literal Returns)', () => { @@ -76,9 +62,6 @@ describe('get_mac_bundle_id plugin', () => { ); expect(result.isError).toBeFalsy(); - const text = allText(result); - expect(text).toContain('Get macOS Bundle ID'); - expect(text).toContain('Bundle ID: io.sentry.MyMacApp'); expect(result.nextStepParams).toEqual({ launch_mac_app: { appPath: '/Applications/MyApp.app' }, build_macos: { scheme: 'SCHEME_NAME' }, @@ -104,8 +87,6 @@ describe('get_mac_bundle_id plugin', () => { ); expect(result.isError).toBeFalsy(); - const text = allText(result); - expect(text).toContain('Bundle ID: io.sentry.MyMacApp'); expect(result.nextStepParams).toEqual({ launch_mac_app: { appPath: '/Applications/MyApp.app' }, build_macos: { scheme: 'SCHEME_NAME' }, @@ -133,10 +114,6 @@ describe('get_mac_bundle_id plugin', () => { expect(result.isError).toBe(true); const text = allText(result); expect(text).toContain('Could not extract bundle ID from Info.plist'); - expect(text).toContain('Command failed'); - expect(text).toContain( - 'Make sure the path points to a valid macOS app bundle (.app directory).', - ); }); it('should handle Error objects in catch blocks', async () => { @@ -160,10 +137,6 @@ describe('get_mac_bundle_id plugin', () => { expect(result.isError).toBe(true); const text = allText(result); expect(text).toContain('Could not extract bundle ID from Info.plist'); - expect(text).toContain('Custom error message'); - expect(text).toContain( - 'Make sure the path points to a valid macOS app bundle (.app directory).', - ); }); it('should handle string errors in catch blocks', async () => { @@ -187,10 +160,6 @@ describe('get_mac_bundle_id plugin', () => { expect(result.isError).toBe(true); const text = allText(result); expect(text).toContain('Could not extract bundle ID from Info.plist'); - expect(text).toContain('String error'); - expect(text).toContain( - 'Make sure the path points to a valid macOS app bundle (.app directory).', - ); }); }); }); diff --git a/src/mcp/tools/project-discovery/discover_projs.ts b/src/mcp/tools/project-discovery/discover_projs.ts index bcf1ec4a..846e647f 100644 --- a/src/mcp/tools/project-discovery/discover_projs.ts +++ b/src/mcp/tools/project-discovery/discover_projs.ts @@ -16,11 +16,9 @@ import { toolResponse } from '../../../utils/tool-response.ts'; import { header, statusLine, section } from '../../../utils/tool-event-builders.ts'; import type { PipelineEvent } from '../../../types/pipeline-events.ts'; -// Constants -const DEFAULT_MAX_DEPTH = 5; +const DEFAULT_MAX_DEPTH = 3; const SKIPPED_DIRS = new Set(['build', 'DerivedData', 'Pods', '.git', 'node_modules']); -// Type definition for Dirent-like objects returned by readdir with withFileTypes: true interface DirentLike { name: string; isDirectory(): boolean; @@ -32,11 +30,8 @@ function getErrorDetails( fallbackMessage: string, ): { code?: string; message: string } { if (error instanceof Error) { - const errorWithCode = error as Error & { code?: unknown }; - return { - code: typeof errorWithCode.code === 'string' ? errorWithCode.code : undefined, - message: error.message, - }; + const nodeError = error as NodeJS.ErrnoException; + return { code: nodeError.code, message: error.message }; } if (typeof error === 'object' && error !== null) { @@ -61,7 +56,6 @@ async function _findProjectsRecursive( results: { projects: string[]; workspaces: string[] }, fileSystemExecutor: FileSystemExecutor = getDefaultFileSystemExecutor(), ): Promise { - // Explicit depth check (now simplified as maxDepth is always non-negative) if (currentDepth >= maxDepth) { log('debug', `Max depth ${maxDepth} reached at ${currentDirAbs}, stopping recursion.`); return; @@ -71,27 +65,22 @@ async function _findProjectsRecursive( const normalizedWorkspaceRoot = path.normalize(workspaceRootAbs); try { - // Use the injected fileSystemExecutor const entries = await fileSystemExecutor.readdir(currentDirAbs, { withFileTypes: true }); for (const rawEntry of entries) { - // Cast the unknown entry to DirentLike interface for type safety const entry = rawEntry as DirentLike; const absoluteEntryPath = path.join(currentDirAbs, entry.name); const relativePath = path.relative(workspaceRootAbs, absoluteEntryPath); - // --- Skip conditions --- if (entry.isSymbolicLink()) { log('debug', `Skipping symbolic link: ${relativePath}`); continue; } - // Skip common build/dependency directories by name if (entry.isDirectory() && SKIPPED_DIRS.has(entry.name)) { log('debug', `Skipping standard directory: ${relativePath}`); continue; } - // Ensure entry is within the workspace root (security/sanity check) if (!path.normalize(absoluteEntryPath).startsWith(normalizedWorkspaceRoot)) { log( 'warn', @@ -100,21 +89,19 @@ async function _findProjectsRecursive( continue; } - // --- Process entries --- if (entry.isDirectory()) { let isXcodeBundle = false; if (entry.name.endsWith('.xcodeproj')) { - results.projects.push(absoluteEntryPath); // Use absolute path + results.projects.push(absoluteEntryPath); log('debug', `Found project: ${absoluteEntryPath}`); isXcodeBundle = true; } else if (entry.name.endsWith('.xcworkspace')) { - results.workspaces.push(absoluteEntryPath); // Use absolute path + results.workspaces.push(absoluteEntryPath); log('debug', `Found workspace: ${absoluteEntryPath}`); isXcodeBundle = true; } - // Recurse into regular directories, but not into found project/workspace bundles if (!isXcodeBundle) { await _findProjectsRecursive( absoluteEntryPath, @@ -157,17 +144,40 @@ export interface DiscoverProjectsResult { type DiscoverProjsParams = z.infer; +function isBundleLikePath(workspaceRoot: string): boolean { + return ( + workspaceRoot.endsWith('.app') || + workspaceRoot.endsWith('.xcworkspace') || + workspaceRoot.endsWith('.xcodeproj') + ); +} + +function resolveScanBase(workspaceRoot: string, scanPath?: string): string { + if (scanPath) { + return scanPath; + } + + if (isBundleLikePath(workspaceRoot)) { + return path.dirname(workspaceRoot); + } + + return '.'; +} + async function discoverProjectsOrError( params: DiscoverProjectsParams, fileSystemExecutor: FileSystemExecutor, ): Promise { - const scanPath = params.scanPath ?? '.'; + const scanPath = resolveScanBase(params.workspaceRoot, params.scanPath); const maxDepth = params.maxDepth ?? DEFAULT_MAX_DEPTH; const workspaceRoot = params.workspaceRoot; const requestedScanPath = path.resolve(workspaceRoot, scanPath); let absoluteScanPath = requestedScanPath; - const normalizedWorkspaceRoot = path.normalize(workspaceRoot); + const workspaceBoundary = isBundleLikePath(workspaceRoot) + ? path.dirname(workspaceRoot) + : workspaceRoot; + const normalizedWorkspaceRoot = path.normalize(workspaceBoundary); if (!path.normalize(absoluteScanPath).startsWith(normalizedWorkspaceRoot)) { log( 'warn', @@ -229,7 +239,16 @@ export async function discover_projsLogic( params: DiscoverProjsParams, fileSystemExecutor: FileSystemExecutor, ): Promise { - const headerEvent = header('Discover Projects'); + const scanPath = resolveScanBase(params.workspaceRoot, params.scanPath); + const maxDepth = params.maxDepth ?? DEFAULT_MAX_DEPTH; + const resolvedWorkspaceRoot = path.resolve(params.workspaceRoot); + const resolvedScanPath = path.resolve(params.workspaceRoot, scanPath); + + const headerEvent = header('Discover Projects', [ + { label: 'Workspace root', value: resolvedWorkspaceRoot }, + { label: 'Scan path', value: resolvedScanPath }, + { label: 'Max depth', value: String(maxDepth) }, + ]); const results = await discoverProjectsOrError(params, fileSystemExecutor); if ('error' in results) { return toolResponse([headerEvent, statusLine('error', results.error)]); @@ -240,20 +259,26 @@ export async function discover_projsLogic( `Discovery finished. Found ${results.projects.length} projects and ${results.workspaces.length} workspaces.`, ); + const projectWord = results.projects.length === 1 ? 'project' : 'projects'; + const workspaceWord = results.workspaces.length === 1 ? 'workspace' : 'workspaces'; + const events: PipelineEvent[] = [ headerEvent, statusLine( 'success', - `Found ${results.projects.length} project(s) and ${results.workspaces.length} workspace(s).`, + `Found ${results.projects.length} ${projectWord} and ${results.workspaces.length} ${workspaceWord}`, ), ]; + const cwd = process.cwd(); + const toRelative = (p: string) => path.relative(cwd, p) || p; + if (results.projects.length > 0) { - events.push(section('Projects', results.projects)); + events.push(section('Projects:', results.projects.map(toRelative))); } if (results.workspaces.length > 0) { - events.push(section('Workspaces', results.workspaces)); + events.push(section('Workspaces:', results.workspaces.map(toRelative))); } return toolResponse(events); diff --git a/src/mcp/tools/project-discovery/get_app_bundle_id.ts b/src/mcp/tools/project-discovery/get_app_bundle_id.ts index f21ba9ac..62466e26 100644 --- a/src/mcp/tools/project-discovery/get_app_bundle_id.ts +++ b/src/mcp/tools/project-discovery/get_app_bundle_id.ts @@ -44,26 +44,25 @@ export async function get_app_bundle_idLogic( log('info', `Starting bundle ID extraction for app: ${appPath}`); try { - let bundleId; - - try { - bundleId = await extractBundleIdFromAppPath(appPath, executor); - } catch (innerError) { + const bundleId = await extractBundleIdFromAppPath(appPath, executor).catch((innerError) => { throw new Error( `Could not extract bundle ID from Info.plist: ${innerError instanceof Error ? innerError.message : String(innerError)}`, ); - } + }); log('info', `Extracted app bundle ID: ${bundleId}`); - return toolResponse([headerEvent, statusLine('success', `Bundle ID: ${bundleId}`)], { - nextStepParams: { - install_app_sim: { simulatorId: 'SIMULATOR_UUID', appPath }, - launch_app_sim: { simulatorId: 'SIMULATOR_UUID', bundleId: bundleId.trim() }, - install_app_device: { deviceId: 'DEVICE_UDID', appPath }, - launch_app_device: { deviceId: 'DEVICE_UDID', bundleId: bundleId.trim() }, + return toolResponse( + [headerEvent, statusLine('success', `Bundle ID\n \u2514 ${bundleId.trim()}`)], + { + nextStepParams: { + install_app_sim: { simulatorId: 'SIMULATOR_UUID', appPath }, + launch_app_sim: { simulatorId: 'SIMULATOR_UUID', bundleId: bundleId.trim() }, + install_app_device: { deviceId: 'DEVICE_UDID', appPath }, + launch_app_device: { deviceId: 'DEVICE_UDID', bundleId: bundleId.trim() }, + }, }, - }); + ); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); log('error', `Error extracting app bundle ID: ${errorMessage}`); diff --git a/src/mcp/tools/project-discovery/get_mac_bundle_id.ts b/src/mcp/tools/project-discovery/get_mac_bundle_id.ts index b73af8aa..7dbabe87 100644 --- a/src/mcp/tools/project-discovery/get_mac_bundle_id.ts +++ b/src/mcp/tools/project-discovery/get_mac_bundle_id.ts @@ -1,9 +1,3 @@ -/** - * Project Discovery Plugin: Get macOS Bundle ID - * - * Extracts the bundle identifier from a macOS app bundle (.app). - */ - import * as z from 'zod'; import { log } from '../../../utils/logging/index.ts'; import type { ToolResponse } from '../../../types/common.ts'; @@ -28,9 +22,6 @@ const getMacBundleIdSchema = z.object({ type GetMacBundleIdParams = z.infer; -/** - * Business logic for extracting macOS bundle ID - */ export async function get_mac_bundle_idLogic( params: GetMacBundleIdParams, executor: CommandExecutor, @@ -71,12 +62,15 @@ export async function get_mac_bundle_idLogic( log('info', `Extracted macOS bundle ID: ${bundleId}`); - return toolResponse([headerEvent, statusLine('success', `Bundle ID: ${bundleId}`)], { - nextStepParams: { - launch_mac_app: { appPath }, - build_macos: { scheme: 'SCHEME_NAME' }, + return toolResponse( + [headerEvent, statusLine('success', `Bundle ID\n \u2514 ${bundleId.trim()}`)], + { + nextStepParams: { + launch_mac_app: { appPath }, + build_macos: { scheme: 'SCHEME_NAME' }, + }, }, - }); + ); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); log('error', `Error extracting macOS bundle ID: ${errorMessage}`); diff --git a/src/mcp/tools/project-discovery/list_schemes.ts b/src/mcp/tools/project-discovery/list_schemes.ts index 4d79c776..611e384e 100644 --- a/src/mcp/tools/project-discovery/list_schemes.ts +++ b/src/mcp/tools/project-discovery/list_schemes.ts @@ -1,10 +1,3 @@ -/** - * Project Discovery Plugin: List Schemes (Unified) - * - * Lists available schemes for either a project or workspace using xcodebuild. - * Accepts mutually exclusive `projectPath` or `workspacePath`. - */ - import * as z from 'zod'; import { log } from '../../../utils/logging/index.ts'; import type { CommandExecutor } from '../../../utils/execution/index.ts'; @@ -18,7 +11,6 @@ import { nullifyEmptyStrings } from '../../../utils/schema-helpers.ts'; import { toolResponse } from '../../../utils/tool-response.ts'; import { header, statusLine, section } from '../../../utils/tool-event-builders.ts'; -// Unified schema: XOR between projectPath and workspacePath const baseSchemaObject = z.object({ projectPath: z.string().optional().describe('Path to the .xcodeproj file'), workspacePath: z.string().optional().describe('Path to the .xcworkspace file'), @@ -70,10 +62,6 @@ export async function listSchemes( return parseSchemesFromXcodebuildListOutput(result.output); } -/** - * Business logic for listing schemes in a project or workspace. - * Exported for direct testing and reuse. - */ export async function listSchemesLogic( params: ListSchemesParams, executor: CommandExecutor, @@ -116,12 +104,13 @@ export async function listSchemesLogic( } const schemeItems = schemes.length > 0 ? schemes : ['(none)']; + const schemeWord = schemes.length === 1 ? 'scheme' : 'schemes'; return toolResponse( [ headerEvent, - statusLine('success', `Found ${schemes.length} scheme(s).`), - section('Schemes', schemeItems), + statusLine('success', `Found ${schemes.length} ${schemeWord}`), + section('Schemes:', schemeItems), ], nextStepParams ? { nextStepParams } : undefined, ); diff --git a/src/mcp/tools/project-discovery/show_build_settings.ts b/src/mcp/tools/project-discovery/show_build_settings.ts index 895ed353..1d9d1d80 100644 --- a/src/mcp/tools/project-discovery/show_build_settings.ts +++ b/src/mcp/tools/project-discovery/show_build_settings.ts @@ -1,10 +1,3 @@ -/** - * Project Discovery Plugin: Show Build Settings (Unified) - * - * Shows build settings from either a project or workspace using xcodebuild. - * Accepts mutually exclusive `projectPath` or `workspacePath`. - */ - import * as z from 'zod'; import { log } from '../../../utils/logging/index.ts'; import type { CommandExecutor } from '../../../utils/execution/index.ts'; @@ -18,7 +11,6 @@ import { nullifyEmptyStrings } from '../../../utils/schema-helpers.ts'; import { toolResponse } from '../../../utils/tool-response.ts'; import { header, statusLine, section } from '../../../utils/tool-event-builders.ts'; -// Unified schema: XOR between projectPath and workspacePath const baseSchemaObject = z.object({ projectPath: z.string().optional().describe('Path to the .xcodeproj file'), workspacePath: z.string().optional().describe('Path to the .xcworkspace file'), @@ -47,10 +39,6 @@ function stripXcodebuildPreamble(output: string): string { return lines.slice(startIndex).join('\n'); } -/** - * Business logic for showing build settings from a project or workspace. - * Exported for direct testing and reuse. - */ export async function showBuildSettingsLogic( params: ShowBuildSettingsParams, executor: CommandExecutor, @@ -88,26 +76,22 @@ export async function showBuildSettingsLogic( result.output || 'Build settings retrieved successfully.', ); - let nextStepParams: Record> | undefined; - - if (pathValue) { - const pathKey = hasProjectPath ? 'projectPath' : 'workspacePath'; - nextStepParams = { - build_macos: { [pathKey]: pathValue, scheme: params.scheme }, - build_sim: { [pathKey]: pathValue, scheme: params.scheme, simulatorName: 'iPhone 17' }, - list_schemes: { [pathKey]: pathValue }, - }; - } + const pathKey = hasProjectPath ? 'projectPath' : 'workspacePath'; + const nextStepParams = { + build_macos: { [pathKey]: pathValue!, scheme: params.scheme }, + build_sim: { [pathKey]: pathValue!, scheme: params.scheme, simulatorName: 'iPhone 17' }, + list_schemes: { [pathKey]: pathValue! }, + }; const settingsLines = settingsOutput.split('\n').filter((l) => l.trim()); return toolResponse( [ headerEvent, - statusLine('success', 'Build settings retrieved.'), + statusLine('success', 'Build settings retrieved'), section('Settings', settingsLines), ], - nextStepParams ? { nextStepParams } : undefined, + { nextStepParams }, ); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); diff --git a/src/mcp/tools/project-scaffolding/__tests__/scaffold_ios_project.test.ts b/src/mcp/tools/project-scaffolding/__tests__/scaffold_ios_project.test.ts index ca52325f..2c9d5ea7 100644 --- a/src/mcp/tools/project-scaffolding/__tests__/scaffold_ios_project.test.ts +++ b/src/mcp/tools/project-scaffolding/__tests__/scaffold_ios_project.test.ts @@ -164,56 +164,6 @@ describe('scaffold_ios_project plugin', () => { await initConfigStoreForTest({ iosTemplatePath: '/mock/template/path' }); }); - it.skip('should generate correct unzip command for iOS template extraction', async () => { - await initConfigStoreForTest({ iosTemplatePath: '' }); - - const downloadMockFileSystemExecutor = createMockFileSystemExecutor({ - existsSync: (path) => { - return ( - path.includes('xcodebuild-mcp-template') || - path.includes('XcodeBuildMCP-iOS-Template') || - path.includes('extracted') - ); - }, - readFile: async () => 'template content with MyProject placeholder', - readdir: async () => [ - { name: 'Package.swift', isDirectory: () => false, isFile: () => true } as any, - { name: 'MyProject.swift', isDirectory: () => false, isFile: () => true } as any, - ], - mkdir: async () => {}, - rm: async () => {}, - cp: async () => {}, - writeFile: async () => {}, - stat: async () => ({ isDirectory: () => true, mtimeMs: 0 }), - }); - - let capturedCommands: string[][] = []; - const trackingCommandExecutor = createMockExecutor({ - success: true, - output: 'Command executed successfully', - }); - const capturingExecutor = async (command: string[], ...args: any[]) => { - capturedCommands.push(command); - return trackingCommandExecutor(command, ...args); - }; - - await scaffold_ios_projectLogic( - { - projectName: 'TestIOSApp', - customizeNames: true, - outputPath: '/tmp/test-projects', - }, - capturingExecutor, - downloadMockFileSystemExecutor, - ); - - const unzipCommand = capturedCommands.find((cmd) => cmd.includes('unzip')); - expect(unzipCommand).toBeDefined(); - expect(unzipCommand).toEqual(['unzip', '-q', expect.stringMatching(/template\.zip$/)]); - - await initConfigStoreForTest({ iosTemplatePath: '/mock/template/path' }); - }); - it('should generate correct commands when using custom template version', async () => { await initConfigStoreForTest({ iosTemplatePath: '', iosTemplateVersion: 'v2.0.0' }); @@ -251,64 +201,6 @@ describe('scaffold_ios_project plugin', () => { await initConfigStoreForTest({ iosTemplatePath: '/mock/template/path' }); }); - it.skip('should generate correct commands with no command executor passed', async () => { - await initConfigStoreForTest({ iosTemplatePath: '' }); - - const downloadMockFileSystemExecutor = createMockFileSystemExecutor({ - existsSync: (path) => { - return ( - path.includes('xcodebuild-mcp-template') || - path.includes('XcodeBuildMCP-iOS-Template') || - path.includes('extracted') - ); - }, - readFile: async () => 'template content with MyProject placeholder', - readdir: async () => [ - { name: 'Package.swift', isDirectory: () => false, isFile: () => true } as any, - { name: 'MyProject.swift', isDirectory: () => false, isFile: () => true } as any, - ], - mkdir: async () => {}, - rm: async () => {}, - cp: async () => {}, - writeFile: async () => {}, - stat: async () => ({ isDirectory: () => true, mtimeMs: 0 }), - }); - - let capturedCommands: string[][] = []; - const trackingCommandExecutor = createMockExecutor({ - success: true, - output: 'Command executed successfully', - }); - const capturingExecutor = async (command: string[], ...args: any[]) => { - capturedCommands.push(command); - return trackingCommandExecutor(command, ...args); - }; - - await scaffold_ios_projectLogic( - { - projectName: 'TestIOSApp', - customizeNames: true, - outputPath: '/tmp/test-projects', - }, - capturingExecutor, - downloadMockFileSystemExecutor, - ); - - expect(capturedCommands.length).toBeGreaterThanOrEqual(2); - - const curlCommand = capturedCommands.find((cmd) => cmd.includes('curl')); - const unzipCommand = capturedCommands.find((cmd) => cmd.includes('unzip')); - - expect(curlCommand).toBeDefined(); - expect(unzipCommand).toBeDefined(); - if (!curlCommand || !unzipCommand) { - throw new Error('Expected curl and unzip commands to be captured'); - } - expect(curlCommand[0]).toBe('curl'); - expect(unzipCommand[0]).toBe('unzip'); - - await initConfigStoreForTest({ iosTemplatePath: '/mock/template/path' }); - }); }); describe('Handler Behavior (Complete Literal Returns)', () => { @@ -476,51 +368,5 @@ describe('scaffold_ios_project plugin', () => { await initConfigStoreForTest({ iosTemplatePath: '/mock/template/path' }); }); - it.skip('should return error response for template extraction failure', async () => { - await initConfigStoreForTest({ iosTemplatePath: '' }); - - const downloadMockFileSystemExecutor = createMockFileSystemExecutor({ - existsSync: (path) => { - return ( - path.includes('xcodebuild-mcp-template') || - path.includes('XcodeBuildMCP-iOS-Template') || - path.includes('extracted') - ); - }, - readFile: async () => 'template content with MyProject placeholder', - readdir: async () => [ - { name: 'Package.swift', isDirectory: () => false, isFile: () => true } as any, - { name: 'MyProject.swift', isDirectory: () => false, isFile: () => true } as any, - ], - mkdir: async () => {}, - rm: async () => {}, - cp: async () => {}, - writeFile: async () => {}, - stat: async () => ({ isDirectory: () => true, mtimeMs: 0 }), - }); - - const failingMockCommandExecutor = createMockExecutor({ - success: false, - output: '', - error: 'Extraction failed', - }); - - const result = await scaffold_ios_projectLogic( - { - projectName: 'TestIOSApp', - customizeNames: true, - outputPath: '/tmp/test-projects', - }, - failingMockCommandExecutor, - downloadMockFileSystemExecutor, - ); - - expect(result.isError).toBe(true); - const text = allText(result); - expect(text).toContain('Failed to get template for iOS'); - expect(text).toContain('Extraction failed'); - - await initConfigStoreForTest({ iosTemplatePath: '/mock/template/path' }); - }); }); }); diff --git a/src/mcp/tools/project-scaffolding/scaffold_ios_project.ts b/src/mcp/tools/project-scaffolding/scaffold_ios_project.ts index 2cfc0641..5b8fcec4 100644 --- a/src/mcp/tools/project-scaffolding/scaffold_ios_project.ts +++ b/src/mcp/tools/project-scaffolding/scaffold_ios_project.ts @@ -1,11 +1,5 @@ -/** - * Utilities Plugin: Scaffold iOS Project - * - * Scaffold a new iOS project from templates. - */ - import * as z from 'zod'; -import { join, dirname, basename } from 'path'; +import { join, dirname, basename } from 'node:path'; import { log } from '../../../utils/logging/index.ts'; import { ValidationError } from '../../../utils/errors.ts'; import { TemplateManager } from '../../../utils/template/index.ts'; @@ -18,7 +12,6 @@ import type { ToolResponse } from '../../../types/common.ts'; import { toolResponse } from '../../../utils/tool-response.ts'; import { header, statusLine } from '../../../utils/tool-event-builders.ts'; -// Common base schema for both iOS and macOS const BaseScaffoldSchema = z.object({ projectName: z.string().min(1), outputPath: z.string(), @@ -29,7 +22,6 @@ const BaseScaffoldSchema = z.object({ customizeNames: z.boolean().default(true), }); -// iOS-specific schema const ScaffoldiOSProjectSchema = BaseScaffoldSchema.extend({ deploymentTarget: z.string().optional(), targetedDeviceFamily: z.array(z.enum(['iphone', 'ipad', 'universal'])).optional(), @@ -369,7 +361,7 @@ export async function scaffold_ios_projectLogic( { label: 'Path', value: projectPath }, { label: 'Platform', value: 'iOS' }, ]), - statusLine('success', `Project scaffolded successfully at ${projectPath}.`), + statusLine('success', `Project scaffolded successfully\n โ”” ${projectPath}`), ], { nextStepParams: { @@ -387,10 +379,8 @@ export async function scaffold_ios_projectLogic( }, ); } catch (error) { - log( - 'error', - `Failed to scaffold iOS project: ${error instanceof Error ? error.message : String(error)}`, - ); + const errorMessage = error instanceof Error ? error.message : String(error); + log('error', `Failed to scaffold iOS project: ${errorMessage}`); return toolResponse([ header('Scaffold iOS Project', [ @@ -398,7 +388,7 @@ export async function scaffold_ios_projectLogic( { label: 'Path', value: params.outputPath }, { label: 'Platform', value: 'iOS' }, ]), - statusLine('error', error instanceof Error ? error.message : 'Unknown error occurred'), + statusLine('error', errorMessage), ]); } } diff --git a/src/mcp/tools/project-scaffolding/scaffold_macos_project.ts b/src/mcp/tools/project-scaffolding/scaffold_macos_project.ts index 52edaf84..753dfcf3 100644 --- a/src/mcp/tools/project-scaffolding/scaffold_macos_project.ts +++ b/src/mcp/tools/project-scaffolding/scaffold_macos_project.ts @@ -1,11 +1,5 @@ -/** - * Utilities Plugin: Scaffold macOS Project - * - * Scaffold a new macOS project from templates. - */ - import * as z from 'zod'; -import { join, dirname, basename } from 'path'; +import { join, dirname, basename } from 'node:path'; import { log } from '../../../utils/logging/index.ts'; import { ValidationError } from '../../../utils/errors.ts'; import { TemplateManager } from '../../../utils/template/index.ts'; @@ -16,7 +10,6 @@ import type { FileSystemExecutor } from '../../../utils/FileSystemExecutor.ts'; import { toolResponse } from '../../../utils/tool-response.ts'; import { header, statusLine } from '../../../utils/tool-event-builders.ts'; -// Common base schema for both iOS and macOS const BaseScaffoldSchema = z.object({ projectName: z.string().min(1), outputPath: z.string(), @@ -27,7 +20,6 @@ const BaseScaffoldSchema = z.object({ customizeNames: z.boolean().default(true), }); -// macOS-specific schema const ScaffoldmacOSProjectSchema = BaseScaffoldSchema.extend({ deploymentTarget: z.string().optional(), }); @@ -343,7 +335,7 @@ export async function scaffold_macos_projectLogic( { label: 'Path', value: projectPath }, { label: 'Platform', value: 'macOS' }, ]), - statusLine('success', `Project scaffolded successfully at ${projectPath}.`), + statusLine('success', `Project scaffolded successfully\n โ”” ${projectPath}`), ], { nextStepParams: { @@ -359,10 +351,8 @@ export async function scaffold_macos_projectLogic( }, ); } catch (error) { - log( - 'error', - `Failed to scaffold macOS project: ${error instanceof Error ? error.message : String(error)}`, - ); + const errorMessage = error instanceof Error ? error.message : String(error); + log('error', `Failed to scaffold macOS project: ${errorMessage}`); return toolResponse([ header('Scaffold macOS Project', [ @@ -370,7 +360,7 @@ export async function scaffold_macos_projectLogic( { label: 'Path', value: params.outputPath }, { label: 'Platform', value: 'macOS' }, ]), - statusLine('error', error instanceof Error ? error.message : 'Unknown error occurred'), + statusLine('error', errorMessage), ]); } } diff --git a/src/mcp/tools/session-management/__tests__/session_set_defaults.test.ts b/src/mcp/tools/session-management/__tests__/session_set_defaults.test.ts index 8a8443d8..456db499 100644 --- a/src/mcp/tools/session-management/__tests__/session_set_defaults.test.ts +++ b/src/mcp/tools/session-management/__tests__/session_set_defaults.test.ts @@ -67,7 +67,7 @@ describe('session-set-defaults tool', () => { ); expect(result.isError).toBeFalsy(); - expect(allText(result)).toContain('Session defaults updated.'); + expect(allText(result)).toContain('Session defaults updated'); const current = sessionStore.getAll(); expect(current.scheme).toBe('MyScheme'); diff --git a/src/mcp/tools/session-management/__tests__/session_show_defaults.test.ts b/src/mcp/tools/session-management/__tests__/session_show_defaults.test.ts index a77be6df..05a60393 100644 --- a/src/mcp/tools/session-management/__tests__/session_show_defaults.test.ts +++ b/src/mcp/tools/session-management/__tests__/session_show_defaults.test.ts @@ -28,7 +28,8 @@ describe('session-show-defaults tool', () => { expect(result.isError).toBeFalsy(); const text = allText(result); expect(text).toContain('Show Defaults'); - expect(text).toContain('No session defaults are set'); + expect(text).toContain('(default)'); + expect(text).toContain('(not set)'); }); it('should return current defaults when set', async () => { diff --git a/src/mcp/tools/session-management/__tests__/session_use_defaults_profile.test.ts b/src/mcp/tools/session-management/__tests__/session_use_defaults_profile.test.ts index 03b333ce..e160517a 100644 --- a/src/mcp/tools/session-management/__tests__/session_use_defaults_profile.test.ts +++ b/src/mcp/tools/session-management/__tests__/session_use_defaults_profile.test.ts @@ -64,7 +64,7 @@ describe('session-use-defaults-profile tool', () => { it('returns status for empty args', async () => { const result = await sessionUseDefaultsProfileLogic({}); expect(result.isError).toBeFalsy(); - expect(allText(result)).toContain('Active profile: global defaults'); + expect(allText(result)).toContain('Activated profile (default profile)'); }); it('persists active profile when persist=true', async () => { diff --git a/src/mcp/tools/session-management/session-format-helpers.ts b/src/mcp/tools/session-management/session-format-helpers.ts new file mode 100644 index 00000000..cb2e1650 --- /dev/null +++ b/src/mcp/tools/session-management/session-format-helpers.ts @@ -0,0 +1,30 @@ +import { sessionDefaultKeys } from '../../../utils/session-defaults-schema.ts'; +import type { SessionDefaults } from '../../../utils/session-store.ts'; + +export function formatProfileLabel(profile: string | null): string { + return profile ?? '(default)'; +} + +export function formatProfileAnnotation(profile: string | null): string { + if (profile === null) { + return '(default profile)'; + } + return `(${profile} profile)`; +} + +export function buildFullDetailTree( + defaults: SessionDefaults, +): Array<{ label: string; value: string }> { + return sessionDefaultKeys.map((key) => { + const raw = defaults[key]; + const value = raw !== undefined ? String(raw) : '(not set)'; + return { label: key, value }; + }); +} + +export function formatDetailLines(items: Array<{ label: string; value: string }>): string[] { + return items.map((item, index) => { + const branch = index === items.length - 1 ? '\u2514' : '\u251C'; + return `${branch} ${item.label}: ${item.value}`; + }); +} diff --git a/src/mcp/tools/session-management/session_clear_defaults.ts b/src/mcp/tools/session-management/session_clear_defaults.ts index d9fd9037..5000d4ba 100644 --- a/src/mcp/tools/session-management/session_clear_defaults.ts +++ b/src/mcp/tools/session-management/session_clear_defaults.ts @@ -6,6 +6,7 @@ import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; import type { ToolResponse } from '../../../types/common.ts'; import { toolResponse } from '../../../utils/tool-response.ts'; import { header, statusLine } from '../../../utils/tool-event-builders.ts'; +import { formatProfileLabel, formatProfileAnnotation } from './session-format-helpers.ts'; const keys = sessionDefaultKeys; @@ -70,15 +71,20 @@ export async function sessionClearDefaultsLogic(params: Params): Promise = { + projectPath: 'Project Path', + workspacePath: 'Workspace Path', + scheme: 'Scheme', + configuration: 'Configuration', + simulatorName: 'Simulator Name', + simulatorId: 'Simulator ID', + simulatorPlatform: 'Simulator Platform', + deviceId: 'Device ID', + useLatestOS: 'Use Latest OS', + arch: 'Architecture', + suppressWarnings: 'Suppress Warnings', + derivedDataPath: 'Derived Data Path', + preferXcodebuild: 'Prefer xcodebuild', + platform: 'Platform', + bundleId: 'Bundle ID', + env: 'Environment', +}; + export async function sessionSetDefaultsLogic( params: Params, context: SessionSetDefaultsContext, ): Promise { - const headerEvent = header('Set Defaults'); const notices: string[] = []; let activeProfile = sessionStore.getActiveProfile(); - const { - persist, - profile: rawProfile, - createIfNotExists: rawCreateIfNotExists, - ...rawParams - } = params; - const createIfNotExists = rawCreateIfNotExists ?? false; + const { persist, profile: rawProfile, createIfNotExists = false, ...rawParams } = params; if (rawProfile !== undefined) { const profile = rawProfile.trim(); if (profile.length === 0) { - return toolResponse([headerEvent, statusLine('error', 'Profile name cannot be empty.')]); + return toolResponse([ + header('Set Defaults'), + statusLine('error', 'Profile name cannot be empty.'), + ]); } const profileExists = sessionStore.listProfiles().includes(profile); if (!profileExists && !createIfNotExists) { return toolResponse([ - headerEvent, + header('Set Defaults'), statusLine( 'error', `Profile "${profile}" does not exist. Pass createIfNotExists=true to create it.`, @@ -84,18 +107,10 @@ export async function sessionSetDefaultsLogic( rawParams as Record, ) as Partial; - const hasProjectPath = - Object.prototype.hasOwnProperty.call(nextParams, 'projectPath') && - nextParams.projectPath !== undefined; - const hasWorkspacePath = - Object.prototype.hasOwnProperty.call(nextParams, 'workspacePath') && - nextParams.workspacePath !== undefined; - const hasSimulatorId = - Object.prototype.hasOwnProperty.call(nextParams, 'simulatorId') && - nextParams.simulatorId !== undefined; - const hasSimulatorName = - Object.prototype.hasOwnProperty.call(nextParams, 'simulatorName') && - nextParams.simulatorName !== undefined; + const hasProjectPath = nextParams.projectPath !== undefined; + const hasWorkspacePath = nextParams.workspacePath !== undefined; + const hasSimulatorId = nextParams.simulatorId !== undefined; + const hasSimulatorName = nextParams.simulatorName !== undefined; if (hasProjectPath && hasWorkspacePath) { delete nextParams.projectPath; @@ -105,19 +120,13 @@ export async function sessionSetDefaultsLogic( } const toClear = new Set(); - if ( - Object.prototype.hasOwnProperty.call(nextParams, 'projectPath') && - nextParams.projectPath !== undefined - ) { + if (hasProjectPath) { toClear.add('workspacePath'); if (current.workspacePath !== undefined) { notices.push('Cleared workspacePath because projectPath was set.'); } } - if ( - Object.prototype.hasOwnProperty.call(nextParams, 'workspacePath') && - nextParams.workspacePath !== undefined - ) { + if (hasWorkspacePath) { toClear.add('projectPath'); if (current.projectPath !== undefined) { notices.push('Cleared projectPath because workspacePath was set.'); @@ -209,21 +218,26 @@ export async function sessionSetDefaultsLogic( } const updated = sessionStore.getAll(); - const events: PipelineEvent[] = [headerEvent]; - const items = Object.entries(updated) - .filter(([, v]) => v !== undefined) - .map(([k, v]) => ({ label: k, value: String(v) })); - if (items.length > 0) { - events.push(detailTree(items)); + const headerParams: Array<{ label: string; value: string }> = []; + for (const [key, value] of Object.entries(rawParams)) { + if (value !== undefined) { + const label = PARAM_LABEL_MAP[key] ?? key; + headerParams.push({ label, value: String(value) }); + } } + headerParams.push({ label: 'Profile', value: formatProfileLabel(activeProfile) }); + + const events: PipelineEvent[] = [header('Set Defaults', headerParams)]; + + const profileAnnotation = formatProfileAnnotation(activeProfile); + events.push(statusLine('success', `Session defaults updated ${profileAnnotation}`)); + events.push(detailTree(buildFullDetailTree(updated))); if (notices.length > 0) { events.push(section('Notices', notices)); } - events.push(statusLine('success', 'Session defaults updated.')); - return toolResponse(events); } diff --git a/src/mcp/tools/session-management/session_show_defaults.ts b/src/mcp/tools/session-management/session_show_defaults.ts index bd6ea254..c7d5ecb0 100644 --- a/src/mcp/tools/session-management/session_show_defaults.ts +++ b/src/mcp/tools/session-management/session_show_defaults.ts @@ -1,35 +1,28 @@ import { sessionStore } from '../../../utils/session-store.ts'; import type { ToolResponse } from '../../../types/common.ts'; import { toolResponse } from '../../../utils/tool-response.ts'; -import { header, detailTree, statusLine } from '../../../utils/tool-event-builders.ts'; +import { header, section } from '../../../utils/tool-event-builders.ts'; +import type { PipelineEvent } from '../../../types/pipeline-events.ts'; +import { + formatProfileLabel, + buildFullDetailTree, + formatDetailLines, +} from './session-format-helpers.ts'; export const schema = {}; -function formatActiveProfileLabel(activeProfile: string | null): string { - return activeProfile ?? 'global defaults'; -} - -export const handler = async (): Promise => { - const current = sessionStore.getAll(); - const activeProfile = sessionStore.getActiveProfile(); - const activeProfileLabel = formatActiveProfileLabel(activeProfile); +export async function handler(): Promise { + const namedProfiles = sessionStore.listProfiles(); + const profileKeys: Array = [null, ...namedProfiles]; - const items = Object.entries(current) - .filter(([, v]) => v !== undefined) - .map(([k, v]) => ({ label: k, value: String(v) })); + const events: PipelineEvent[] = [header('Show Defaults')]; - if (items.length === 0) { - return toolResponse([ - header('Show Defaults'), - statusLine( - 'info', - `No session defaults are set. Active profile: ${activeProfileLabel}`, - ), - ]); + for (const profileKey of profileKeys) { + const defaults = sessionStore.getAllForProfile(profileKey); + const label = `\u{1F4C1} ${formatProfileLabel(profileKey)}`; + const items = buildFullDetailTree(defaults); + events.push(section(label, formatDetailLines(items))); } - return toolResponse([ - header('Show Defaults', [{ label: 'Active Profile', value: activeProfileLabel }]), - detailTree(items), - ]); -}; + return toolResponse(events); +} diff --git a/src/mcp/tools/session-management/session_use_defaults_profile.ts b/src/mcp/tools/session-management/session_use_defaults_profile.ts index e6cf64fe..865d866a 100644 --- a/src/mcp/tools/session-management/session_use_defaults_profile.ts +++ b/src/mcp/tools/session-management/session_use_defaults_profile.ts @@ -5,8 +5,9 @@ import { persistActiveSessionDefaultsProfile } from '../../../utils/config-store import { sessionStore } from '../../../utils/session-store.ts'; import type { ToolResponse } from '../../../types/common.ts'; import { toolResponse } from '../../../utils/tool-response.ts'; -import { header, statusLine, detailTree, section } from '../../../utils/tool-event-builders.ts'; +import { header, statusLine, section } from '../../../utils/tool-event-builders.ts'; import type { PipelineEvent } from '../../../types/pipeline-events.ts'; +import { formatProfileLabel, formatProfileAnnotation } from './session-format-helpers.ts'; const schemaObj = z.object({ profile: z @@ -23,10 +24,6 @@ const schemaObj = z.object({ type Params = z.input; -function formatActiveProfileLabel(activeProfile: string | null): string { - return activeProfile ?? 'global defaults'; -} - function resolveProfileToActivate(params: Params): string | null | undefined { if (params.global === true) return null; if (params.profile === undefined) return undefined; @@ -44,6 +41,7 @@ export async function sessionUseDefaultsProfileLogic(params: Params): Promise 0 ? profiles.join(', ') : '(none)' }, + { label: 'Current profile', value: formatProfileLabel(beforeProfile) }, ]), ]; - const items = Object.entries(current) - .filter(([, v]) => v !== undefined) - .map(([k, v]) => ({ label: k, value: String(v) })); - if (items.length > 0) { - events.push(detailTree(items)); - } - if (notices.length > 0) { events.push(section('Notices', notices)); } - events.push(statusLine('success', `Active profile: ${activeLabel}`)); + events.push(statusLine('success', `Activated profile ${profileAnnotation}`)); return toolResponse(events); } diff --git a/src/mcp/tools/simulator-management/__tests__/erase_sims.test.ts b/src/mcp/tools/simulator-management/__tests__/erase_sims.test.ts index 422d8f0b..cf471302 100644 --- a/src/mcp/tools/simulator-management/__tests__/erase_sims.test.ts +++ b/src/mcp/tools/simulator-management/__tests__/erase_sims.test.ts @@ -1,15 +1,12 @@ import { describe, it, expect } from 'vitest'; -import * as z from 'zod'; import { schema, erase_simsLogic } from '../erase_sims.ts'; import { createMockExecutor } from '../../../../test-utils/mock-executors.ts'; import { allText } from '../../../../test-utils/test-helpers.ts'; describe('erase_sims tool (single simulator)', () => { - describe('Schema Validation', () => { - it('should validate schema fields (shape only)', () => { - const schemaObj = z.object(schema); - expect(schemaObj.safeParse({ shutdownFirst: true }).success).toBe(true); - expect(schemaObj.safeParse({}).success).toBe(true); + describe('Plugin Structure', () => { + it('should expose schema', () => { + expect(schema).toBeDefined(); }); }); @@ -17,16 +14,12 @@ describe('erase_sims tool (single simulator)', () => { it('erases a simulator successfully', async () => { const mock = createMockExecutor({ success: true, output: 'OK' }); const res = await erase_simsLogic({ simulatorId: 'UD1' }, mock); - const text = allText(res); - expect(text).toContain('Simulator UD1 erased'); expect(res.isError).toBeFalsy(); }); it('returns failure when erase fails', async () => { const mock = createMockExecutor({ success: false, error: 'Booted device' }); const res = await erase_simsLogic({ simulatorId: 'UD1' }, mock); - const text = allText(res); - expect(text).toContain('Failed to erase simulator: Booted device'); expect(res.isError).toBe(true); }); @@ -51,8 +44,6 @@ describe('erase_sims tool (single simulator)', () => { ['xcrun', 'simctl', 'shutdown', 'UD1'], ['xcrun', 'simctl', 'erase', 'UD1'], ]); - const text = allText(res); - expect(text).toContain('Simulator UD1 erased'); expect(res.isError).toBeFalsy(); }); }); diff --git a/src/mcp/tools/simulator-management/__tests__/reset_sim_location.test.ts b/src/mcp/tools/simulator-management/__tests__/reset_sim_location.test.ts index d3be8751..59ebaf4e 100644 --- a/src/mcp/tools/simulator-management/__tests__/reset_sim_location.test.ts +++ b/src/mcp/tools/simulator-management/__tests__/reset_sim_location.test.ts @@ -33,7 +33,7 @@ describe('reset_sim_location plugin', () => { const text = allText(result); expect(text).toContain('Reset Location'); - expect(text).toContain('Location reset to default'); + expect(text).toContain('Location successfully reset to default'); expect(result.isError).toBeFalsy(); }); diff --git a/src/mcp/tools/simulator-management/__tests__/set_sim_appearance.test.ts b/src/mcp/tools/simulator-management/__tests__/set_sim_appearance.test.ts index 49316e6a..ed42e985 100644 --- a/src/mcp/tools/simulator-management/__tests__/set_sim_appearance.test.ts +++ b/src/mcp/tools/simulator-management/__tests__/set_sim_appearance.test.ts @@ -44,7 +44,7 @@ describe('set_sim_appearance plugin', () => { const text = allText(result); expect(text).toContain('Set Appearance'); - expect(text).toContain('Appearance set to dark mode'); + expect(text).toContain('Appearance successfully set to dark mode'); expect(result.isError).toBeFalsy(); }); diff --git a/src/mcp/tools/simulator-management/__tests__/set_sim_location.test.ts b/src/mcp/tools/simulator-management/__tests__/set_sim_location.test.ts index 57daed8f..db5a2920 100644 --- a/src/mcp/tools/simulator-management/__tests__/set_sim_location.test.ts +++ b/src/mcp/tools/simulator-management/__tests__/set_sim_location.test.ts @@ -145,7 +145,7 @@ describe('set_sim_location tool', () => { const text = allText(result); expect(text).toContain('Set Location'); - expect(text).toContain('Location set to 37.7749,-122.4194'); + expect(text).toContain('Location set successfully'); expect(result.isError).toBeFalsy(); }); @@ -251,7 +251,7 @@ describe('set_sim_location tool', () => { ); const text = allText(result); - expect(text).toContain('Location set to 90,180'); + expect(text).toContain('Location set successfully'); expect(result.isError).toBeFalsy(); }); @@ -272,7 +272,7 @@ describe('set_sim_location tool', () => { ); const text = allText(result); - expect(text).toContain('Location set to -90,-180'); + expect(text).toContain('Location set successfully'); expect(result.isError).toBeFalsy(); }); @@ -293,7 +293,7 @@ describe('set_sim_location tool', () => { ); const text = allText(result); - expect(text).toContain('Location set to 0,0'); + expect(text).toContain('Location set successfully'); expect(result.isError).toBeFalsy(); }); @@ -322,7 +322,6 @@ describe('set_sim_location tool', () => { ['xcrun', 'simctl', 'location', 'test-uuid-123', 'set', '37.7749,-122.4194'], 'Set Simulator Location', false, - {}, ]); }); }); diff --git a/src/mcp/tools/simulator-management/__tests__/sim_statusbar.test.ts b/src/mcp/tools/simulator-management/__tests__/sim_statusbar.test.ts index 45122d0c..e6c398fe 100644 --- a/src/mcp/tools/simulator-management/__tests__/sim_statusbar.test.ts +++ b/src/mcp/tools/simulator-management/__tests__/sim_statusbar.test.ts @@ -40,7 +40,7 @@ describe('sim_statusbar tool', () => { const text = allText(result); expect(text).toContain('Statusbar'); - expect(text).toContain('Status bar data network set to wifi'); + expect(text).toContain('Status bar data network set successfully'); expect(result.isError).toBeFalsy(); }); @@ -60,7 +60,7 @@ describe('sim_statusbar tool', () => { expect(result.isError).toBeFalsy(); const text = allText(result); - expect(text).toContain('Status bar data network set to wifi'); + expect(text).toContain('Status bar data network set successfully'); }); it('should handle command failure', async () => { diff --git a/src/mcp/tools/simulator-management/erase_sims.ts b/src/mcp/tools/simulator-management/erase_sims.ts index 3ce61d41..90cd9a06 100644 --- a/src/mcp/tools/simulator-management/erase_sims.ts +++ b/src/mcp/tools/simulator-management/erase_sims.ts @@ -55,7 +55,10 @@ export async function erase_simsLogic( undefined, ); if (result.success) { - return toolResponse([headerEvent, statusLine('success', `Simulator ${simulatorId} erased`)]); + return toolResponse([ + headerEvent, + statusLine('success', 'Simulators were erased successfully'), + ]); } const errText = result.error ?? 'Unknown error'; diff --git a/src/mcp/tools/simulator-management/reset_sim_location.ts b/src/mcp/tools/simulator-management/reset_sim_location.ts index ee45bbac..a85b1e28 100644 --- a/src/mcp/tools/simulator-management/reset_sim_location.ts +++ b/src/mcp/tools/simulator-management/reset_sim_location.ts @@ -26,7 +26,7 @@ export async function reset_sim_locationLogic( try { const command = ['xcrun', 'simctl', 'location', params.simulatorId, 'clear']; - const result = await executor(command, 'Reset Simulator Location', false, {}); + const result = await executor(command, 'Reset Simulator Location', false); if (!result.success) { log( @@ -40,7 +40,10 @@ export async function reset_sim_locationLogic( } log('info', `Reset simulator ${params.simulatorId} location`); - return toolResponse([headerEvent, statusLine('success', 'Location reset to default')]); + return toolResponse([ + headerEvent, + statusLine('success', 'Location successfully reset to default'), + ]); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); log( diff --git a/src/mcp/tools/simulator-management/set_sim_appearance.ts b/src/mcp/tools/simulator-management/set_sim_appearance.ts index 4c4f8903..31572b1b 100644 --- a/src/mcp/tools/simulator-management/set_sim_appearance.ts +++ b/src/mcp/tools/simulator-management/set_sim_appearance.ts @@ -46,7 +46,7 @@ export async function set_sim_appearanceLogic( log('info', `Set simulator ${params.simulatorId} appearance to ${params.mode} mode`); return toolResponse([ headerEvent, - statusLine('success', `Appearance set to ${params.mode} mode`), + statusLine('success', `Appearance successfully set to ${params.mode} mode`), ]); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); diff --git a/src/mcp/tools/simulator-management/set_sim_location.ts b/src/mcp/tools/simulator-management/set_sim_location.ts index b368cb55..fce7cc87 100644 --- a/src/mcp/tools/simulator-management/set_sim_location.ts +++ b/src/mcp/tools/simulator-management/set_sim_location.ts @@ -45,7 +45,7 @@ export async function set_sim_locationLogic( try { const command = ['xcrun', 'simctl', 'location', params.simulatorId, 'set', coords]; - const result = await executor(command, 'Set Simulator Location', false, {}); + const result = await executor(command, 'Set Simulator Location', false); if (!result.success) { log( @@ -59,7 +59,7 @@ export async function set_sim_locationLogic( } log('info', `Set simulator ${params.simulatorId} location to ${coords}`); - return toolResponse([headerEvent, statusLine('success', `Location set to ${coords}`)]); + return toolResponse([headerEvent, statusLine('success', 'Location set successfully')]); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); log( diff --git a/src/mcp/tools/simulator-management/sim_statusbar.ts b/src/mcp/tools/simulator-management/sim_statusbar.ts index 45b64dfc..0092e36f 100644 --- a/src/mcp/tools/simulator-management/sim_statusbar.ts +++ b/src/mcp/tools/simulator-management/sim_statusbar.ts @@ -76,7 +76,7 @@ export async function sim_statusbarLogic( const successMsg = params.dataNetwork === 'clear' ? 'Status bar overrides cleared' - : `Status bar data network set to ${params.dataNetwork}`; + : 'Status bar data network set successfully'; log('info', `${successMsg} (simulator: ${params.simulatorId})`); return toolResponse([headerEvent, statusLine('success', successMsg)]); diff --git a/src/mcp/tools/simulator/__tests__/build_run_sim.test.ts b/src/mcp/tools/simulator/__tests__/build_run_sim.test.ts index 49bee8fa..6328c045 100644 --- a/src/mcp/tools/simulator/__tests__/build_run_sim.test.ts +++ b/src/mcp/tools/simulator/__tests__/build_run_sim.test.ts @@ -250,7 +250,10 @@ describe('build_run_sim tool', () => { items: expect.arrayContaining([ expect.objectContaining({ label: 'App Path', value: '/path/to/build/MyApp.app' }), expect.objectContaining({ label: 'Bundle ID', value: 'io.sentry.MyApp' }), - expect.objectContaining({ label: 'Build Logs', value: expect.stringContaining('build_run_sim_') }), + expect.objectContaining({ + label: 'Build Logs', + value: expect.stringContaining('build_run_sim_'), + }), ]), }), ], diff --git a/src/mcp/tools/simulator/__tests__/build_sim.test.ts b/src/mcp/tools/simulator/__tests__/build_sim.test.ts index 2e97212d..0105b3f9 100644 --- a/src/mcp/tools/simulator/__tests__/build_sim.test.ts +++ b/src/mcp/tools/simulator/__tests__/build_sim.test.ts @@ -4,35 +4,11 @@ import { createMockExecutor, createMockCommandResponse, } from '../../../../test-utils/mock-executors.ts'; -import type { CommandExecutor } from '../../../../utils/execution/index.ts'; +import { expectPendingBuildResponse } from '../../../../test-utils/test-helpers.ts'; import { sessionStore } from '../../../../utils/session-store.ts'; import { schema, handler, build_simLogic } from '../build_sim.ts'; -function expectPendingBuildResponse( - result: Awaited>, - nextStepToolId?: string, -): void { - 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(); - } -} - describe('build_sim tool', () => { beforeEach(() => { sessionStore.clear(); diff --git a/src/mcp/tools/simulator/__tests__/get_sim_app_path.test.ts b/src/mcp/tools/simulator/__tests__/get_sim_app_path.test.ts index 2fb4d44d..e7d75d74 100644 --- a/src/mcp/tools/simulator/__tests__/get_sim_app_path.test.ts +++ b/src/mcp/tools/simulator/__tests__/get_sim_app_path.test.ts @@ -187,7 +187,7 @@ describe('get_sim_app_path tool', () => { expect(text).toContain('MyScheme'); expect(text).toContain('Errors (1):'); expect(text).toContain('โœ— Failed to run xcodebuild'); - expect(text).toContain('Query failed.'); + expect(text).toContain('Failed to get app path'); expect(result.nextStepParams).toBeUndefined(); }); }); diff --git a/src/mcp/tools/simulator/__tests__/launch_app_logs_sim.test.ts b/src/mcp/tools/simulator/__tests__/launch_app_logs_sim.test.ts index 2bc90fa7..e1d97583 100644 --- a/src/mcp/tools/simulator/__tests__/launch_app_logs_sim.test.ts +++ b/src/mcp/tools/simulator/__tests__/launch_app_logs_sim.test.ts @@ -86,9 +86,7 @@ describe('launch_app_logs_sim tool', () => { expect(text.indexOf('App launched successfully')).toBeLessThan( text.indexOf('Log Session ID: test-session-123'), ); - expect(result.nextStepParams).toEqual({ - stop_sim_log_cap: { logSessionId: 'test-session-123' }, - }); + expect(result.nextStepParams).toBeUndefined(); expect(result.isError).toBeFalsy(); expect(capturedParams).toEqual({ diff --git a/src/mcp/tools/simulator/__tests__/open_sim.test.ts b/src/mcp/tools/simulator/__tests__/open_sim.test.ts index b4c730cd..a884fa4f 100644 --- a/src/mcp/tools/simulator/__tests__/open_sim.test.ts +++ b/src/mcp/tools/simulator/__tests__/open_sim.test.ts @@ -44,7 +44,7 @@ describe('open_sim tool', () => { const text = allText(result); expect(text).toContain('Open Simulator'); - expect(text).toContain('Simulator app opened'); + expect(text).toContain('Simulator opened successfully'); expect(result.isError).toBeFalsy(); expect(result.nextStepParams).toEqual({ boot_sim: { simulatorId: 'UUID_FROM_LIST_SIMS' }, diff --git a/src/mcp/tools/simulator/__tests__/screenshot.test.ts b/src/mcp/tools/simulator/__tests__/screenshot.test.ts index c16ba583..46fcd052 100644 --- a/src/mcp/tools/simulator/__tests__/screenshot.test.ts +++ b/src/mcp/tools/simulator/__tests__/screenshot.test.ts @@ -291,7 +291,7 @@ describe('screenshot plugin', () => { expect(result.isError).toBeUndefined(); const text = allText(result); expect(text).toContain('Screenshot'); - expect(text).toContain('Screenshot captured.'); + expect(text).toContain('Screenshot captured'); expect(text).toContain('Format: image/jpeg'); const imageContent = result.content.find((c) => c.type === 'image'); expect(imageContent).toEqual({ diff --git a/src/mcp/tools/simulator/build_sim.ts b/src/mcp/tools/simulator/build_sim.ts index fcf54bfe..199a62ce 100644 --- a/src/mcp/tools/simulator/build_sim.ts +++ b/src/mcp/tools/simulator/build_sim.ts @@ -22,7 +22,6 @@ import { startBuildPipeline } from '../../../utils/xcodebuild-pipeline.ts'; import { createPendingXcodebuildResponse } from '../../../utils/xcodebuild-output.ts'; import { formatToolPreflight } from '../../../utils/build-preflight.ts'; -// Unified schema: XOR between projectPath and workspacePath, and XOR between simulatorId and simulatorName const baseOptions = { scheme: z.string().describe('The scheme to use (Required)'), simulatorId: z @@ -78,18 +77,19 @@ const buildSimulatorSchema = z.preprocess( export type BuildSimulatorParams = z.infer; -// Internal logic for building Simulator apps. -async function _handleSimulatorBuildLogic( +export async function build_simLogic( params: BuildSimulatorParams, - executor: CommandExecutor = getDefaultCommandExecutor(), + executor: CommandExecutor, ): Promise { + const configuration = params.configuration ?? 'Debug'; + const useLatestOS = params.useLatestOS ?? true; const projectType = params.projectPath ? 'project' : 'workspace'; const filePath = params.projectPath ?? params.workspacePath; if (params.simulatorId && params.useLatestOS !== undefined) { log( 'warn', - `useLatestOS parameter is ignored when using simulatorId (UUID implies exact device/OS)`, + 'useLatestOS parameter is ignored when using simulatorId (UUID implies exact device/OS)', ); } @@ -110,16 +110,13 @@ async function _handleSimulatorBuildLogic( log('info', `Starting ${logPrefix} for scheme ${params.scheme} from ${projectType}: ${filePath}`); log('info', `Inferred simulator platform: ${detectedPlatform} (source: ${inferred.source})`); - const sharedBuildParams = { - ...params, - configuration: params.configuration ?? 'Debug', - }; + const sharedBuildParams = { ...params, configuration }; const platformOptions = { platform: detectedPlatform, simulatorName: params.simulatorName, simulatorId: params.simulatorId, - useLatestOS: params.simulatorId ? false : params.useLatestOS, + useLatestOS: params.simulatorId ? false : useLatestOS, logPrefix, }; @@ -128,7 +125,7 @@ async function _handleSimulatorBuildLogic( scheme: params.scheme, workspacePath: params.workspacePath, projectPath: params.projectPath, - configuration: sharedBuildParams.configuration, + configuration, platform: String(detectedPlatform), simulatorName: params.simulatorName, simulatorId: params.simulatorId, @@ -138,7 +135,7 @@ async function _handleSimulatorBuildLogic( scheme: params.scheme, workspacePath: params.workspacePath, projectPath: params.projectPath, - configuration: sharedBuildParams.configuration, + configuration, platform: String(detectedPlatform), simulatorName: params.simulatorName, simulatorId: params.simulatorId, @@ -181,22 +178,6 @@ async function _handleSimulatorBuildLogic( ); } -export async function build_simLogic( - params: BuildSimulatorParams, - executor: CommandExecutor, -): Promise { - // Provide defaults - const processedParams: BuildSimulatorParams = { - ...params, - configuration: params.configuration ?? 'Debug', - useLatestOS: params.useLatestOS ?? true, // May be ignored if simulatorId is provided - preferXcodebuild: params.preferXcodebuild ?? false, - }; - - return _handleSimulatorBuildLogic(processedParams, executor); -} - -// Public schema = internal minus session-managed fields const publicSchemaObject = baseSchemaObject.omit({ projectPath: true, workspacePath: true, diff --git a/src/mcp/tools/simulator/get_sim_app_path.ts b/src/mcp/tools/simulator/get_sim_app_path.ts index 7bc79e1d..f3192a0f 100644 --- a/src/mcp/tools/simulator/get_sim_app_path.ts +++ b/src/mcp/tools/simulator/get_sim_app_path.ts @@ -19,7 +19,7 @@ import { } from '../../../utils/typed-tool-factory.ts'; import { nullifyEmptyStrings } from '../../../utils/schema-helpers.ts'; import { formatToolPreflight } from '../../../utils/build-preflight.ts'; -import { formatQueryError, formatQueryFailureSummary } from '../../../utils/xcodebuild-error-utils.ts'; +import { formatQueryError } from '../../../utils/xcodebuild-error-utils.ts'; import { extractAppPathFromBuildSettingsOutput } from '../../../utils/app-path-resolver.ts'; const SIMULATOR_PLATFORMS = [ @@ -110,6 +110,20 @@ export async function get_sim_app_pathLogic( simulatorId: params.simulatorId, }); + function buildErrorResponse(rawOutput: string): ToolResponse { + return { + content: [ + { + type: 'text', + text: `\n${preflightText}\n${formatQueryError(rawOutput)}\n\n\u{274C} Failed to get app path`, + }, + ], + isError: true, + }; + } + + const startedAt = Date.now(); + try { const command = ['xcodebuild', '-showBuildSettings']; @@ -132,49 +146,30 @@ export async function get_sim_app_pathLogic( if (!result.success) { const rawOutput = [result.error, result.output].filter(Boolean).join('\n'); - return { - content: [ - { - type: 'text', - text: `\n${preflightText}${formatQueryError(rawOutput)}\n\n${formatQueryFailureSummary()}`, - }, - ], - isError: true, - }; + return buildErrorResponse(rawOutput); } if (!result.output) { - return { - content: [ - { - type: 'text', - text: `\n${preflightText}${formatQueryError('Failed to extract build settings output from the result.')}\n\n${formatQueryFailureSummary()}`, - }, - ], - isError: true, - }; + return buildErrorResponse('Failed to extract build settings output from the result.'); } let appPath: string; try { appPath = extractAppPathFromBuildSettingsOutput(result.output); } catch { - return { - content: [ - { - type: 'text', - text: `\n${preflightText}${formatQueryError('Failed to extract app path from build settings. Make sure the app has been built first.')}\n\n${formatQueryFailureSummary()}`, - }, - ], - isError: true, - }; + return buildErrorResponse( + 'Failed to extract app path from build settings. Make sure the app has been built first.', + ); } + const durationMs = Date.now() - startedAt; + const durationStr = (durationMs / 1000).toFixed(1); + return { content: [ { type: 'text', - text: `\n${preflightText} โ”” App Path: ${appPath}`, + text: `\n${preflightText}\n\u{2705} Get app path successful (\u{23F1}\u{FE0F} ${durationStr}s)\n \u{2514} App Path: ${appPath}`, }, ], nextStepParams: { @@ -187,15 +182,7 @@ export async function get_sim_app_pathLogic( } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); log('error', `Error retrieving app path: ${errorMessage}`); - return { - content: [ - { - type: 'text', - text: `\n${preflightText}${formatQueryError(errorMessage)}\n\n${formatQueryFailureSummary()}`, - }, - ], - isError: true, - }; + return buildErrorResponse(errorMessage); } } diff --git a/src/mcp/tools/simulator/install_app_sim.ts b/src/mcp/tools/simulator/install_app_sim.ts index 5d580cf9..c7c8ecdd 100644 --- a/src/mcp/tools/simulator/install_app_sim.ts +++ b/src/mcp/tools/simulator/install_app_sim.ts @@ -47,8 +47,12 @@ export async function install_app_simLogic( executor: CommandExecutor, fileSystem?: FileSystemExecutor, ): Promise { + const simulatorDisplayName = params.simulatorName + ? `"${params.simulatorName}" (${params.simulatorId})` + : params.simulatorId; + const headerEvent = header('Install App', [ - { label: 'Simulator', value: params.simulatorId }, + { label: 'Simulator', value: simulatorDisplayName }, { label: 'App Path', value: params.appPath }, ]); @@ -84,21 +88,15 @@ export async function install_app_simLogic( log('warn', `Could not extract bundle ID from app: ${error}`); } - return toolResponse( - [ - headerEvent, - statusLine('success', `App installed successfully in simulator ${params.simulatorId}`), - ], - { - nextStepParams: { - open_sim: {}, - launch_app_sim: { - simulatorId: params.simulatorId, - bundleId: bundleId || 'YOUR_APP_BUNDLE_ID', - }, + return toolResponse([headerEvent, statusLine('success', 'App installed successfully')], { + nextStepParams: { + open_sim: {}, + launch_app_sim: { + simulatorId: params.simulatorId, + bundleId: bundleId || 'YOUR_APP_BUNDLE_ID', }, }, - ); + }); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); log('error', `Error during install app in simulator operation: ${errorMessage}`); diff --git a/src/mcp/tools/simulator/launch_app_logs_sim.ts b/src/mcp/tools/simulator/launch_app_logs_sim.ts index 7abce3b3..e263917f 100644 --- a/src/mcp/tools/simulator/launch_app_logs_sim.ts +++ b/src/mcp/tools/simulator/launch_app_logs_sim.ts @@ -9,7 +9,7 @@ import { getSessionAwareToolSchemaShape, } from '../../../utils/typed-tool-factory.ts'; import { toolResponse } from '../../../utils/tool-response.ts'; -import { header, statusLine, detailTree } from '../../../utils/tool-event-builders.ts'; +import { header, statusLine, detailTree, nextSteps } from '../../../utils/tool-event-builders.ts'; export type LogCaptureFunction = ( params: { @@ -97,21 +97,21 @@ export async function launch_app_logs_simLogic( ]); } - return toolResponse( - [ - headerEvent, - statusLine( - 'success', - `App launched successfully in simulator ${params.simulatorId} with log capture enabled`, - ), - detailTree([{ label: 'Log Session ID', value: sessionId }]), - ], - { - nextStepParams: { - stop_sim_log_cap: { logSessionId: sessionId }, + return toolResponse([ + headerEvent, + statusLine( + 'success', + `App launched successfully in simulator ${params.simulatorId} with log capture enabled`, + ), + detailTree([{ label: 'Log Session ID', value: sessionId }]), + nextSteps([ + { + label: 'Stop capture and retrieve logs', + tool: 'stop_sim_log_cap', + params: { logSessionId: sessionId }, }, - }, - ); + ]), + ]); } export const schema = getSessionAwareToolSchemaShape({ diff --git a/src/mcp/tools/simulator/launch_app_sim.ts b/src/mcp/tools/simulator/launch_app_sim.ts index c6474e40..140c1094 100644 --- a/src/mcp/tools/simulator/launch_app_sim.ts +++ b/src/mcp/tools/simulator/launch_app_sim.ts @@ -9,7 +9,7 @@ import { } from '../../../utils/typed-tool-factory.ts'; import { normalizeSimctlChildEnv } from '../../../utils/environment.ts'; import { toolResponse } from '../../../utils/tool-response.ts'; -import { header, statusLine } from '../../../utils/tool-event-builders.ts'; +import { header, statusLine, detailTree } from '../../../utils/tool-event-builders.ts'; const baseSchemaObject = z.object({ simulatorId: z @@ -39,12 +39,7 @@ const internalSchemaObject = z.object({ simulatorName: z.string().optional(), bundleId: z.string(), args: z.array(z.string()).optional(), - env: z - .record(z.string(), z.string()) - .optional() - .describe( - 'Environment variables to pass to the launched app (SIMCTL_CHILD_ prefix added automatically)', - ), + env: z.record(z.string(), z.string()).optional(), }); export type LaunchAppSimParams = z.infer; @@ -110,21 +105,22 @@ export async function launch_app_simLogic( ]); } - return toolResponse( - [ - headerEvent, - statusLine('success', `App launched successfully in simulator ${simulatorDisplayName}`), - ], - { - nextStepParams: { - open_sim: {}, - start_sim_log_cap: [ - { simulatorId, bundleId: params.bundleId }, - { simulatorId, bundleId: params.bundleId, captureConsole: true }, - ], - }, + const pidMatch = result.output?.match(/:\s*(\d+)\s*$/); + const events = [ + headerEvent, + statusLine('success', 'App launched successfully'), + ...(pidMatch ? [detailTree([{ label: 'Process ID', value: pidMatch[1] }])] : []), + ]; + + return toolResponse(events, { + nextStepParams: { + open_sim: {}, + start_sim_log_cap: [ + { simulatorId, bundleId: params.bundleId }, + { simulatorId, bundleId: params.bundleId, captureConsole: true }, + ], }, - ); + }); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); log('error', `Error during launch app in simulator operation: ${errorMessage}`); diff --git a/src/mcp/tools/simulator/list_sims.ts b/src/mcp/tools/simulator/list_sims.ts index 7367c45a..92aebfb4 100644 --- a/src/mcp/tools/simulator/list_sims.ts +++ b/src/mcp/tools/simulator/list_sims.ts @@ -5,7 +5,7 @@ import type { CommandExecutor } from '../../../utils/execution/index.ts'; import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; import { createTypedTool } from '../../../utils/typed-tool-factory.ts'; import { toolResponse } from '../../../utils/tool-response.ts'; -import { header, statusLine, table } from '../../../utils/tool-event-builders.ts'; +import { header, section, statusLine } from '../../../utils/tool-event-builders.ts'; const listSimsSchema = z.object({ enabled: z.boolean().optional(), @@ -32,22 +32,18 @@ interface SimulatorData { devices: Record; } -// Parse text output as fallback for Apple simctl JSON bugs (e.g., duplicate runtime IDs) function parseTextOutput(textOutput: string): SimulatorDevice[] { const devices: SimulatorDevice[] = []; const lines = textOutput.split('\n'); let currentRuntime = ''; for (const line of lines) { - // Match runtime headers like "-- iOS 26.0 --" or "-- iOS 18.6 --" const runtimeMatch = line.match(/^-- ([\w\s.]+) --$/); if (runtimeMatch) { currentRuntime = runtimeMatch[1]; continue; } - // Match device lines like " iPhone 17 Pro (UUID) (Booted)" - // UUID pattern is flexible to handle test UUIDs like "test-uuid-123" const deviceMatch = line.match( /^\s+(.+?)\s+\(([^)]+)\)\s+\((Booted|Shutdown|Booting|Shutting Down)\)(\s+\(unavailable.*\))?$/i, ); @@ -177,6 +173,32 @@ function formatRuntimeName(runtime: string): string { return runtime; } +interface PlatformInfo { + label: string; + emoji: string; + order: number; +} + +const PLATFORM_MAP: Record = { + iOS: { label: 'iOS Simulators', emoji: '\u{1F4F1}', order: 0 }, + visionOS: { label: 'visionOS Simulators', emoji: '\u{1F97D}', order: 1 }, + watchOS: { label: 'watchOS Simulators', emoji: '\u{231A}\u{FE0F}', order: 2 }, + tvOS: { label: 'tvOS Simulators', emoji: '\u{1F4FA}', order: 3 }, +}; + +function detectPlatform(runtimeName: string): string { + if (/xrOS|visionOS/i.test(runtimeName)) return 'visionOS'; + if (/watchOS/i.test(runtimeName)) return 'watchOS'; + if (/tvOS/i.test(runtimeName)) return 'tvOS'; + return 'iOS'; +} + +function getPlatformInfo(platform: string): PlatformInfo { + return ( + PLATFORM_MAP[platform] ?? { label: `${platform} Simulators`, emoji: '\u{1F4F1}', order: 99 } + ); +} + export async function list_simsLogic( _params: ListSimsParams, executor: CommandExecutor, @@ -195,33 +217,73 @@ export async function list_simsLogic( grouped.set(simulator.runtime, runtimeGroup); } - const tables = []; + const platformGroups = new Map>(); for (const [runtime, devices] of grouped.entries()) { if (devices.length === 0) continue; + const runtimeName = formatRuntimeName(runtime); + const platform = detectPlatform(runtimeName); + let platformMap = platformGroups.get(platform); + if (!platformMap) { + platformMap = new Map(); + platformGroups.set(platform, platformMap); + } + platformMap.set(runtimeName, devices); + } + + const platformCounts: Record = {}; + let totalCount = 0; - const rows = devices.map((d) => ({ - Name: d.name, - UUID: d.udid, - State: d.state, - })); - tables.push(table(['Name', 'UUID', 'State'], rows, formatRuntimeName(runtime))); + const sortedPlatforms = [...platformGroups.entries()].sort( + ([a], [b]) => getPlatformInfo(a).order - getPlatformInfo(b).order, + ); + + const sections = []; + for (const [platform, runtimes] of sortedPlatforms) { + const info = getPlatformInfo(platform); + const lines: string[] = []; + let platformTotal = 0; + + for (const [runtimeName, devices] of runtimes.entries()) { + lines.push(''); + lines.push(`${runtimeName}:`); + + for (const device of devices) { + lines.push(''); + const marker = device.state === 'Booted' ? '\u{2713}' : '\u{2717}'; + lines.push(` ${info.emoji} [${marker}] ${device.name} (${device.state})`); + lines.push(` UDID: ${device.udid}`); + platformTotal++; + } + } + + platformCounts[platform] = platformTotal; + totalCount += platformTotal; + sections.push(section(`${info.label}:`, lines)); } - return toolResponse( - [headerEvent, ...tables, statusLine('success', 'Listed available simulators')], - { - nextStepParams: { - boot_sim: { simulatorId: 'UUID_FROM_ABOVE' }, - open_sim: {}, - build_sim: { scheme: 'YOUR_SCHEME', simulatorId: 'UUID_FROM_ABOVE' }, - get_sim_app_path: { - scheme: 'YOUR_SCHEME', - platform: 'iOS Simulator', - simulatorId: 'UUID_FROM_ABOVE', - }, + const countParts = sortedPlatforms + .map(([platform]) => `${platformCounts[platform]} ${platform}`) + .join(', '); + const summaryMsg = `${totalCount} simulators available (${countParts}).`; + + const hints = section('Hints', [ + 'Use the simulator ID/UDID from above when required by other tools.', + "Save a default simulator with session-set-defaults { simulatorId: 'SIMULATOR_UDID' }.", + 'Before running boot/build/run tools, set the desired simulator identifier in session defaults.', + ]); + + return toolResponse([headerEvent, ...sections, statusLine('success', summaryMsg), hints], { + nextStepParams: { + boot_sim: { simulatorId: 'UUID_FROM_ABOVE' }, + open_sim: {}, + build_sim: { scheme: 'YOUR_SCHEME', simulatorId: 'UUID_FROM_ABOVE' }, + get_sim_app_path: { + scheme: 'YOUR_SCHEME', + platform: 'iOS Simulator', + simulatorId: 'UUID_FROM_ABOVE', }, }, - ); + }); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); if (errorMessage.startsWith('Failed to list simulators:')) { diff --git a/src/mcp/tools/simulator/open_sim.ts b/src/mcp/tools/simulator/open_sim.ts index 6b0f5944..1cc39d81 100644 --- a/src/mcp/tools/simulator/open_sim.ts +++ b/src/mcp/tools/simulator/open_sim.ts @@ -30,7 +30,7 @@ export async function open_simLogic( ]); } - return toolResponse([headerEvent, statusLine('success', 'Simulator app opened')], { + return toolResponse([headerEvent, statusLine('success', 'Simulator opened successfully')], { nextStepParams: { boot_sim: { simulatorId: 'UUID_FROM_LIST_SIMS' }, start_sim_log_cap: [ diff --git a/src/mcp/tools/simulator/stop_app_sim.ts b/src/mcp/tools/simulator/stop_app_sim.ts index aba3cb38..993d73fc 100644 --- a/src/mcp/tools/simulator/stop_app_sim.ts +++ b/src/mcp/tools/simulator/stop_app_sim.ts @@ -61,13 +61,7 @@ export async function stop_app_simLogic( ]); } - return toolResponse([ - headerEvent, - statusLine( - 'success', - `App ${params.bundleId} stopped successfully in simulator ${simulatorDisplayName}`, - ), - ]); + return toolResponse([headerEvent, statusLine('success', 'App stopped successfully')]); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); log('error', `Error stopping app in simulator: ${errorMessage}`); diff --git a/src/mcp/tools/swift-package/__tests__/swift_package_build.test.ts b/src/mcp/tools/swift-package/__tests__/swift_package_build.test.ts index 73cfcc76..b0d3a0a4 100644 --- a/src/mcp/tools/swift-package/__tests__/swift_package_build.test.ts +++ b/src/mcp/tools/swift-package/__tests__/swift_package_build.test.ts @@ -165,7 +165,7 @@ describe('swift_package_build plugin', () => { const result = await swift_package_buildLogic({ packagePath: '/test/package' }, executor); - expect(result.isError).toBeUndefined(); + expect(result.isError).toBeFalsy(); }); it('should return successful build response', async () => { @@ -181,11 +181,7 @@ describe('swift_package_build plugin', () => { executor, ); - expect(result.isError).toBeUndefined(); - const text = result.content.map((c) => c.text).join('\n'); - expect(text).toContain('Swift Package Build'); - expect(text).toContain('Swift package build succeeded'); - expect(text).toContain('Build complete.'); + expect(result.isError).toBeFalsy(); }); it('should return error response for build failure', async () => { @@ -263,11 +259,7 @@ describe('swift_package_build plugin', () => { executor, ); - expect(result.isError).toBeUndefined(); - const text = result.content.map((c) => c.text).join('\n'); - expect(text).toContain('Swift Package Build'); - expect(text).toContain('Swift package build succeeded'); - expect(text).toContain('Build complete.'); + expect(result.isError).toBeFalsy(); }); }); }); diff --git a/src/mcp/tools/swift-package/__tests__/swift_package_list.test.ts b/src/mcp/tools/swift-package/__tests__/swift_package_list.test.ts index 4002afb2..d10a1c46 100644 --- a/src/mcp/tools/swift-package/__tests__/swift_package_list.test.ts +++ b/src/mcp/tools/swift-package/__tests__/swift_package_list.test.ts @@ -33,7 +33,7 @@ describe('swift_package_list plugin', () => { expect(result.isError).toBeUndefined(); const text = result.content.map((c) => c.text).join('\n'); - expect(text).toContain('Swift Package List'); + expect(text).toContain('Swift Package Processes'); expect(text).toContain('No Swift Package processes currently running'); }); @@ -133,7 +133,7 @@ describe('swift_package_list plugin', () => { expect(result.isError).toBeUndefined(); const text = result.content.map((c) => c.text).join('\n'); - expect(text).toContain('Active Processes (1)'); + expect(text).toContain('Running Processes (1)'); expect(text).toContain('12345'); expect(text).toContain('MyApp'); expect(text).toContain('/test/package'); @@ -178,7 +178,7 @@ describe('swift_package_list plugin', () => { expect(result.isError).toBeUndefined(); const text = result.content.map((c) => c.text).join('\n'); - expect(text).toContain('Active Processes (2)'); + expect(text).toContain('Running Processes (2)'); expect(text).toContain('12345'); expect(text).toContain('MyApp'); expect(text).toContain('/test/package1'); diff --git a/src/mcp/tools/swift-package/__tests__/swift_package_run.test.ts b/src/mcp/tools/swift-package/__tests__/swift_package_run.test.ts index a4576eb6..0fc452f5 100644 --- a/src/mcp/tools/swift-package/__tests__/swift_package_run.test.ts +++ b/src/mcp/tools/swift-package/__tests__/swift_package_run.test.ts @@ -90,12 +90,9 @@ describe('swift_package_run plugin', () => { mockExecutor, ); - expect(executorCalls[0]).toEqual({ - command: ['swift', 'run', '--package-path', '/test/package'], - logPrefix: 'Swift Package Run', - useShell: false, - opts: undefined, - }); + expect(executorCalls[0].command).toEqual(['swift', 'run', '--package-path', '/test/package']); + expect(executorCalls[0].logPrefix).toBe('Swift Package Run'); + expect(executorCalls[0].useShell).toBe(false); }); it('should build correct command with release configuration', async () => { @@ -118,12 +115,16 @@ describe('swift_package_run plugin', () => { mockExecutor, ); - expect(executorCalls[0]).toEqual({ - command: ['swift', 'run', '--package-path', '/test/package', '-c', 'release'], - logPrefix: 'Swift Package Run', - useShell: false, - opts: undefined, - }); + expect(executorCalls[0].command).toEqual([ + 'swift', + 'run', + '--package-path', + '/test/package', + '-c', + 'release', + ]); + expect(executorCalls[0].logPrefix).toBe('Swift Package Run'); + expect(executorCalls[0].useShell).toBe(false); }); it('should build correct command with executable name', async () => { @@ -146,12 +147,15 @@ describe('swift_package_run plugin', () => { mockExecutor, ); - expect(executorCalls[0]).toEqual({ - command: ['swift', 'run', '--package-path', '/test/package', 'MyApp'], - logPrefix: 'Swift Package Run', - useShell: false, - opts: undefined, - }); + expect(executorCalls[0].command).toEqual([ + 'swift', + 'run', + '--package-path', + '/test/package', + 'MyApp', + ]); + expect(executorCalls[0].logPrefix).toBe('Swift Package Run'); + expect(executorCalls[0].useShell).toBe(false); }); it('should build correct command with arguments', async () => { @@ -174,12 +178,17 @@ describe('swift_package_run plugin', () => { mockExecutor, ); - expect(executorCalls[0]).toEqual({ - command: ['swift', 'run', '--package-path', '/test/package', '--', 'arg1', 'arg2'], - logPrefix: 'Swift Package Run', - useShell: false, - opts: undefined, - }); + expect(executorCalls[0].command).toEqual([ + 'swift', + 'run', + '--package-path', + '/test/package', + '--', + 'arg1', + 'arg2', + ]); + expect(executorCalls[0].logPrefix).toBe('Swift Package Run'); + expect(executorCalls[0].useShell).toBe(false); }); it('should build correct command with parseAsLibrary flag', async () => { @@ -202,19 +211,16 @@ describe('swift_package_run plugin', () => { mockExecutor, ); - expect(executorCalls[0]).toEqual({ - command: [ - 'swift', - 'run', - '--package-path', - '/test/package', - '-Xswiftc', - '-parse-as-library', - ], - logPrefix: 'Swift Package Run', - useShell: false, - opts: undefined, - }); + expect(executorCalls[0].command).toEqual([ + 'swift', + 'run', + '--package-path', + '/test/package', + '-Xswiftc', + '-parse-as-library', + ]); + expect(executorCalls[0].logPrefix).toBe('Swift Package Run'); + expect(executorCalls[0].useShell).toBe(false); }); it('should build correct command with all parameters', async () => { @@ -240,24 +246,21 @@ describe('swift_package_run plugin', () => { mockExecutor, ); - expect(executorCalls[0]).toEqual({ - command: [ - 'swift', - 'run', - '--package-path', - '/test/package', - '-c', - 'release', - '-Xswiftc', - '-parse-as-library', - 'MyApp', - '--', - 'arg1', - ], - logPrefix: 'Swift Package Run', - useShell: false, - opts: undefined, - }); + expect(executorCalls[0].command).toEqual([ + 'swift', + 'run', + '--package-path', + '/test/package', + '-c', + 'release', + '-Xswiftc', + '-parse-as-library', + 'MyApp', + '--', + 'arg1', + ]); + expect(executorCalls[0].logPrefix).toBe('Swift Package Run'); + expect(executorCalls[0].useShell).toBe(false); }); it('should not call executor for background mode', async () => { @@ -313,11 +316,7 @@ describe('swift_package_run plugin', () => { mockExecutor, ); - expect(result.isError).toBeUndefined(); - const text = result.content.map((c) => c.text).join('\n'); - expect(text).toContain('Swift Package Run'); - expect(text).toContain('Swift executable completed successfully'); - expect(text).toContain('Hello, World!'); + expect(result.isError).toBeFalsy(); }); it('should return error response for failed execution', async () => { @@ -335,9 +334,6 @@ describe('swift_package_run plugin', () => { ); expect(result.isError).toBe(true); - const text = result.content.map((c) => c.text).join('\n'); - expect(text).toContain('Swift executable failed'); - expect(text).toContain('Compilation failed'); }); it('should handle executor error', async () => { diff --git a/src/mcp/tools/swift-package/__tests__/swift_package_test.test.ts b/src/mcp/tools/swift-package/__tests__/swift_package_test.test.ts index df802f3c..a6663b71 100644 --- a/src/mcp/tools/swift-package/__tests__/swift_package_test.test.ts +++ b/src/mcp/tools/swift-package/__tests__/swift_package_test.test.ts @@ -133,10 +133,7 @@ describe('swift_package_test plugin', () => { output: 'All tests passed.', }); - const result = await swift_package_testLogic( - { packagePath: '/test/package' }, - mockExecutor, - ); + const result = await swift_package_testLogic({ packagePath: '/test/package' }, mockExecutor); expect(result.isError).toBeFalsy(); }); @@ -147,10 +144,7 @@ describe('swift_package_test plugin', () => { error: '2 tests failed', }); - const result = await swift_package_testLogic( - { packagePath: '/test/package' }, - mockExecutor, - ); + const result = await swift_package_testLogic({ packagePath: '/test/package' }, mockExecutor); expect(result.isError).toBe(true); }); @@ -160,10 +154,7 @@ describe('swift_package_test plugin', () => { throw new Error('spawn ENOENT'); }; - const result = await swift_package_testLogic( - { packagePath: '/test/package' }, - mockExecutor, - ); + const result = await swift_package_testLogic({ packagePath: '/test/package' }, mockExecutor); expect(result.isError).toBe(true); const text = allText(result); diff --git a/src/mcp/tools/swift-package/swift_package_build.ts b/src/mcp/tools/swift-package/swift_package_build.ts index 2f2df2cf..9e035159 100644 --- a/src/mcp/tools/swift-package/swift_package_build.ts +++ b/src/mcp/tools/swift-package/swift_package_build.ts @@ -9,7 +9,10 @@ import { getSessionAwareToolSchemaShape, } from '../../../utils/typed-tool-factory.ts'; import { toolResponse } from '../../../utils/tool-response.ts'; -import { header, statusLine, section } from '../../../utils/tool-event-builders.ts'; +import { header, statusLine } from '../../../utils/tool-event-builders.ts'; +import { createXcodebuildPipeline } from '../../../utils/xcodebuild-pipeline.ts'; +import type { StartedPipeline } from '../../../utils/xcodebuild-pipeline.ts'; +import { createPendingXcodebuildResponse } from '../../../utils/xcodebuild-output.ts'; const baseSchemaObject = z.object({ packagePath: z.string(), @@ -60,28 +63,32 @@ export async function swift_package_buildLogic( ...(params.configuration ? [{ label: 'Configuration', value: params.configuration }] : []), ]); + const pipeline = createXcodebuildPipeline({ + operation: 'BUILD', + toolName: `build_spm`, + params: {}, + }); + + pipeline.emitEvent(headerEvent); + const started: StartedPipeline = { pipeline, startedAt: Date.now() }; + try { - const result = await executor(['swift', ...swiftArgs], 'Swift Package Build', false); + const result = await executor(['swift', ...swiftArgs], 'Swift Package Build', false, { + onStdout: (chunk: string) => pipeline.onStdout(chunk), + onStderr: (chunk: string) => pipeline.onStderr(chunk), + }); + if (!result.success) { const errorMessage = result.error || result.output || 'Unknown error'; - return toolResponse([ - headerEvent, - statusLine('error', `Swift package build failed: ${errorMessage}`), - ]); + return toolResponse([statusLine('error', `Swift package build failed: ${errorMessage}`)]); } - return toolResponse([ - headerEvent, - ...(result.output ? [section('Output', [result.output])] : []), - statusLine('success', 'Swift package build succeeded'), - ]); + const response: ToolResponse = { content: [], isError: false }; + return createPendingXcodebuildResponse(started, response); } catch (error) { const message = error instanceof Error ? error.message : String(error); log('error', `Swift package build failed: ${message}`); - return toolResponse([ - headerEvent, - statusLine('error', `Failed to execute swift build: ${message}`), - ]); + return toolResponse([statusLine('error', `Failed to execute swift build: ${message}`)]); } } diff --git a/src/mcp/tools/swift-package/swift_package_list.ts b/src/mcp/tools/swift-package/swift_package_list.ts index 09e9b47e..1b93917e 100644 --- a/src/mcp/tools/swift-package/swift_package_list.ts +++ b/src/mcp/tools/swift-package/swift_package_list.ts @@ -1,14 +1,12 @@ import * as z from 'zod'; import type { ToolResponse } from '../../../types/common.ts'; +import type { PipelineEvent } from '../../../types/pipeline-events.ts'; import { createTypedTool } from '../../../utils/typed-tool-factory.ts'; import { getDefaultCommandExecutor } from '../../../utils/command.ts'; import { activeProcesses } from './active-processes.ts'; import { toolResponse } from '../../../utils/tool-response.ts'; -import { header, statusLine, table } from '../../../utils/tool-event-builders.ts'; +import { header, statusLine, section } from '../../../utils/tool-event-builders.ts'; -/** - * Process list dependencies for dependency injection - */ type ListProcessInfo = { executableName?: string; packagePath?: string; @@ -21,12 +19,6 @@ export interface ProcessListDependencies { dateNow?: typeof Date.now; } -/** - * Swift package list business logic - extracted for testability and separation of concerns - * @param params - Parameters (unused, but maintained for consistency) - * @param dependencies - Injectable dependencies for testing - * @returns ToolResponse with process list information - */ export async function swift_package_listLogic( params?: unknown, dependencies?: ProcessListDependencies, @@ -48,7 +40,7 @@ export async function swift_package_listLogic( const processes = arrayFrom(processMap.entries()); - const headerEvent = header('Swift Package List'); + const headerEvent = header('Swift Package Processes'); if (processes.length === 0) { return toolResponse([ @@ -57,28 +49,29 @@ export async function swift_package_listLogic( ]); } - const rows = processes.map(([pid, info]: [number, ListProcessInfo]) => { + const events: PipelineEvent[] = [headerEvent]; + + const cardLines: string[] = ['']; + for (const [pid, info] of processes as Array<[number, ListProcessInfo]>) { // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing const executableName = info.executableName || 'default'; const runtime = Math.max(1, Math.round((dateNow() - info.startedAt.getTime()) / 1000)); const packagePath = info.packagePath ?? 'unknown package'; - return { - PID: String(pid), - Executable: executableName, - Package: packagePath, - Runtime: `${runtime}s`, - }; - }); + cardLines.push( + `\u{1F7E2} ${executableName}`, + ` PID: ${pid} | Uptime: ${runtime}s`, + ` Package: ${packagePath}`, + '', + ); + } + + while (cardLines.at(-1) === '') { + cardLines.pop(); + } + + events.push(section(`Running Processes (${processes.length}):`, cardLines)); - return toolResponse([ - headerEvent, - table( - ['PID', 'Executable', 'Package', 'Runtime'], - rows, - `Active Processes (${processes.length})`, - ), - statusLine('success', `${processes.length} process(es) running`), - ]); + return toolResponse(events); } const swiftPackageListSchema = z.object({}); diff --git a/src/mcp/tools/swift-package/swift_package_run.ts b/src/mcp/tools/swift-package/swift_package_run.ts index 26e14efe..5dbad037 100644 --- a/src/mcp/tools/swift-package/swift_package_run.ts +++ b/src/mcp/tools/swift-package/swift_package_run.ts @@ -1,7 +1,7 @@ import * as z from 'zod'; import path from 'node:path'; import { log } from '../../../utils/logging/index.ts'; -import type { CommandExecutor } from '../../../utils/execution/index.ts'; +import type { CommandExecutor, CommandResponse } from '../../../utils/execution/index.ts'; import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; import type { ToolResponse } from '../../../types/common.ts'; import { addProcess } from './active-processes.ts'; @@ -11,7 +11,13 @@ import { } from '../../../utils/typed-tool-factory.ts'; import { acquireDaemonActivity } from '../../../daemon/activity-registry.ts'; import { toolResponse } from '../../../utils/tool-response.ts'; -import { header, statusLine, section } from '../../../utils/tool-event-builders.ts'; +import { header, statusLine, section, detailTree } from '../../../utils/tool-event-builders.ts'; +import { createXcodebuildPipeline } from '../../../utils/xcodebuild-pipeline.ts'; +import type { StartedPipeline } from '../../../utils/xcodebuild-pipeline.ts'; +import { + createBuildRunResultEvents, + createPendingXcodebuildResponse, +} from '../../../utils/xcodebuild-output.ts'; const baseSchemaObject = z.object({ packagePath: z.string(), @@ -31,6 +37,43 @@ const swiftPackageRunSchema = baseSchemaObject; type SwiftPackageRunParams = z.infer; +type SwiftPackageRunTimeoutResult = { + success: boolean; + output: string; + error: string; + timedOut: true; +}; + +function isTimedOutResult( + result: CommandResponse | SwiftPackageRunTimeoutResult, +): result is SwiftPackageRunTimeoutResult { + return 'timedOut' in result && result.timedOut; +} + +async function resolveExecutablePath( + executor: CommandExecutor, + packagePath: string, + executableName: string, + configuration?: SwiftPackageRunParams['configuration'], +): Promise { + const command = ['swift', 'build', '--package-path', packagePath, '--show-bin-path']; + if (configuration?.toLowerCase() === 'release') { + command.push('-c', 'release'); + } + + const result = await executor(command, 'Swift Package Run (Resolve Executable Path)', false); + if (!result.success) { + return null; + } + + const binPath = result.output.trim(); + if (!binPath) { + return null; + } + + return path.join(binPath, executableName); +} + export async function swift_package_runLogic( params: SwiftPackageRunParams, executor: CommandExecutor, @@ -38,8 +81,10 @@ export async function swift_package_runLogic( const resolvedPath = path.resolve(params.packagePath); const timeout = Math.min(params.timeout ?? 30, 300) * 1000; // Convert to ms, max 5 minutes - // Detect test environment to prevent real spawn calls during testing - const isTestEnvironment = process.env.VITEST === 'true' || process.env.NODE_ENV === 'test'; + const isSnapshotRealExecutor = process.env.SNAPSHOT_TEST_REAL_EXECUTOR === '1'; + const isTestEnvironment = + !isSnapshotRealExecutor && + (process.env.VITEST === 'true' || process.env.NODE_ENV === 'test'); const swiftArgs = ['run', '--package-path', resolvedPath]; @@ -66,7 +111,6 @@ export async function swift_package_runLogic( swiftArgs.push(params.executableName); } - // Add double dash before executable arguments if (params.arguments && params.arguments.length > 0) { swiftArgs.push('--'); swiftArgs.push(...params.arguments); @@ -76,7 +120,6 @@ export async function swift_package_runLogic( try { if (params.background) { - // Background mode: Use CommandExecutor but don't wait for completion if (isTestEnvironment) { const mockPid = 12345; return toolResponse([ @@ -87,9 +130,7 @@ export async function swift_package_runLogic( ]), ]); } else { - // Production: use CommandExecutor to start the process const command = ['swift', ...swiftArgs]; - // Filter out undefined values from process.env const cleanEnv = Object.fromEntries( Object.entries(process.env).filter(([, value]) => value !== undefined), ) as Record; @@ -101,12 +142,10 @@ export async function swift_package_runLogic( true, ); - // Store the process in active processes system if available if (result.process?.pid) { addProcess(result.process.pid, { process: { kill: (signal?: string) => { - // Adapt string signal to NodeJS.Signals if (result.process) { result.process.kill(signal as NodeJS.Signals); } @@ -140,18 +179,28 @@ export async function swift_package_runLogic( } } } else { - // Foreground mode: use CommandExecutor but handle long-running processes const command = ['swift', ...swiftArgs]; - // Create a promise that will either complete with the command result or timeout - const commandPromise = executor(command, 'Swift Package Run', false); + const pipeline = createXcodebuildPipeline({ + operation: 'BUILD', + toolName: 'build_run_spm', + params: {}, + }); + + pipeline.emitEvent(headerEvent); + const started: StartedPipeline = { pipeline, startedAt: Date.now() }; - const timeoutPromise = new Promise<{ - success: boolean; - output: string; - error: string; - timedOut: boolean; - }>((resolve) => { + const stdoutChunks: string[] = []; + + const commandPromise = executor(command, 'Swift Package Run', false, { + onStdout: (chunk: string) => { + stdoutChunks.push(chunk); + pipeline.onStdout(chunk); + }, + onStderr: (chunk: string) => pipeline.onStderr(chunk), + }); + + const timeoutPromise = new Promise((resolve) => { setTimeout(() => { resolve({ success: false, @@ -162,11 +211,9 @@ export async function swift_package_runLogic( }, timeout); }); - // Race between command completion and timeout const result = await Promise.race([commandPromise, timeoutPromise]); - if ('timedOut' in result && result.timedOut) { - // For timeout case, the process may still be running - provide timeout response + if (isTimedOutResult(result)) { if (isTestEnvironment) { const mockPid = 12345; return toolResponse([ @@ -193,22 +240,50 @@ export async function swift_package_runLogic( } } - if (result.success) { - return toolResponse([ - headerEvent, - ...(result.output ? [section('Output', [result.output])] : []), - statusLine('success', 'Swift executable completed successfully'), - ]); - } else { - const errorDetail = result.error - ? `${result.output || '(no output)'}\nErrors:\n${result.error}` - : result.output || '(no output)'; - return toolResponse([ - headerEvent, - section('Output', [errorDetail]), - statusLine('error', 'Swift executable failed'), - ]); - } + const capturedOutput = stdoutChunks.join('').trim(); + const resolvedExecutableName = params.executableName ?? path.basename(resolvedPath); + const executablePath = isTestEnvironment + ? null + : await resolveExecutablePath( + executor, + resolvedPath, + resolvedExecutableName, + params.configuration, + ); + const processId = result.process?.pid; + const buildRunEvents = + result.success && executablePath + ? createBuildRunResultEvents({ + scheme: resolvedExecutableName, + platform: 'Swift Package', + target: resolvedExecutableName, + appPath: executablePath, + processId, + buildLogPath: pipeline.logPath, + launchState: 'requested', + }) + : []; + const tailEvents = [ + ...buildRunEvents, + ...(result.success && !executablePath + ? [detailTree([{ label: 'Build Logs', value: pipeline.logPath }])] + : []), + ...(capturedOutput ? [section('Output', [capturedOutput])] : []), + ]; + + const response: ToolResponse = result.success + ? { content: [], isError: false } + : { + content: [{ type: 'text', text: result.error || result.output || 'Unknown error' }], + isError: true, + }; + + return createPendingXcodebuildResponse(started, response, { + tailEvents, + emitSummary: true, + errorFallbackPolicy: 'if-no-structured-diagnostics', + includeBuildLogFileRef: false, + }); } } catch (error) { const message = error instanceof Error ? error.message : String(error); diff --git a/src/mcp/tools/swift-package/swift_package_test.ts b/src/mcp/tools/swift-package/swift_package_test.ts index cca925dd..7388e2d1 100644 --- a/src/mcp/tools/swift-package/swift_package_test.ts +++ b/src/mcp/tools/swift-package/swift_package_test.ts @@ -99,7 +99,10 @@ export async function swift_package_testLogic( const response: ToolResponse = result.success ? { content: [], isError: false } - : { content: [{ type: 'text', text: result.error || result.output || 'Unknown error' }], isError: true }; + : { + content: [{ type: 'text', text: result.error || result.output || 'Unknown error' }], + isError: true, + }; return createPendingXcodebuildResponse(started, response); } catch (error) { diff --git a/src/mcp/tools/ui-automation/screenshot.ts b/src/mcp/tools/ui-automation/screenshot.ts index bcac4529..fe77f838 100644 --- a/src/mcp/tools/ui-automation/screenshot.ts +++ b/src/mcp/tools/ui-automation/screenshot.ts @@ -1,13 +1,5 @@ -/** - * Screenshot tool plugin - Capture screenshots from iOS Simulator - * - * Note: The simctl screenshot command captures the raw framebuffer in portrait orientation - * regardless of the device's actual rotation. When the simulator is in landscape mode, - * this results in a rotated image. This plugin detects the simulator window orientation - * and applies a +90ยฐ rotation to correct landscape screenshots. - */ -import * as path from 'path'; -import { tmpdir } from 'os'; +import * as path from 'node:path'; +import { tmpdir } from 'node:os'; import * as z from 'zod'; import { v4 as uuidv4 } from 'uuid'; import type { ToolResponse } from '../../../types/common.ts'; @@ -42,7 +34,7 @@ async function getImageDimensions( const widthMatch = result.output.match(/pixelWidth:\s*(\d+)/); const heightMatch = result.output.match(/pixelHeight:\s*(\d+)/); if (widthMatch && heightMatch) { - return `${widthMatch[1]}x${heightMatch[1]}`; + return `${widthMatch[1]}x${heightMatch[1]}px`; } return null; } catch { @@ -293,7 +285,7 @@ export async function screenshotLogic( return toolResponse([ headerEvent, - statusLine('success', 'Screenshot captured.'), + statusLine('success', 'Screenshot captured'), detailTree([ { label: 'Screenshot', value: screenshotPath }, { label: 'Format', value: 'image/png (optimization failed)' }, @@ -318,13 +310,11 @@ export async function screenshotLogic( const textResponse = toolResponse([ headerEvent, - statusLine('success', 'Screenshot captured.'), - detailTree( - [ - { label: 'Format', value: 'image/jpeg' }, - ...(base64Dims ? [{ label: 'Size', value: base64Dims }] : []), - ] as Array<{ label: string; value: string }>, - ), + statusLine('success', 'Screenshot captured'), + detailTree([ + { label: 'Format', value: 'image/jpeg' }, + ...(base64Dims ? [{ label: 'Size', value: base64Dims }] : []), + ] as Array<{ label: string; value: string }>), ]); textResponse.content.push(createImageContent(base64Image, 'image/jpeg')); return textResponse; @@ -339,14 +329,12 @@ export async function screenshotLogic( const dims = await getImageDimensions(optimizedPath, executor); return toolResponse([ headerEvent, - statusLine('success', 'Screenshot captured.'), - detailTree( - [ - { label: 'Screenshot', value: optimizedPath }, - { label: 'Format', value: 'image/jpeg' }, - ...(dims ? [{ label: 'Size', value: dims }] : []), - ] as Array<{ label: string; value: string }>, - ), + statusLine('success', 'Screenshot captured'), + detailTree([ + { label: 'Screenshot', value: optimizedPath }, + { label: 'Format', value: 'image/jpeg' }, + ...(dims ? [{ label: 'Size', value: dims }] : []), + ] as Array<{ label: string; value: string }>), ]); } catch (fileError) { log('error', `${LOG_PREFIX}/screenshot: Failed to process image file: ${fileError}`); diff --git a/src/mcp/tools/utilities/clean.ts b/src/mcp/tools/utilities/clean.ts index c450fe01..647c4f16 100644 --- a/src/mcp/tools/utilities/clean.ts +++ b/src/mcp/tools/utilities/clean.ts @@ -1,19 +1,18 @@ import * as z from 'zod'; +import path from 'node:path'; import { createSessionAwareTool, getSessionAwareToolSchemaShape, } from '../../../utils/typed-tool-factory.ts'; import type { CommandExecutor } from '../../../utils/execution/index.ts'; import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; -import { executeXcodeBuildCommand } from '../../../utils/build/index.ts'; -import type { ToolResponse, SharedBuildParams } from '../../../types/common.ts'; +import type { ToolResponse } from '../../../types/common.ts'; import { XcodePlatform } from '../../../types/common.ts'; +import { constructDestinationString } from '../../../utils/xcode.ts'; import { nullifyEmptyStrings } from '../../../utils/schema-helpers.ts'; import { toolResponse } from '../../../utils/tool-response.ts'; import { header, statusLine } from '../../../utils/tool-event-builders.ts'; -import { startBuildPipeline } from '../../../utils/xcodebuild-pipeline.ts'; -import { createPendingXcodebuildResponse } from '../../../utils/xcodebuild-output.ts'; -import { formatToolPreflight } from '../../../utils/build-preflight.ts'; +import { log } from '../../../utils/logging/index.ts'; const baseOptions = { scheme: z.string().optional().describe('Optional: The scheme to clean'), @@ -97,17 +96,6 @@ export async function cleanLogic( ]); } - const hasProjectPath = typeof params.projectPath === 'string'; - const typedParams: SharedBuildParams = { - ...(hasProjectPath - ? { projectPath: params.projectPath as string } - : { workspacePath: params.workspacePath as string }), - scheme: params.scheme ?? '', - configuration: params.configuration ?? 'Debug', - derivedDataPath: params.derivedDataPath, - extraArgs: params.extraArgs, - }; - const cleanPlatformMap: Partial> = { [XcodePlatform.iOSSimulator]: XcodePlatform.iOS, [XcodePlatform.watchOSSimulator]: XcodePlatform.watchOS, @@ -116,46 +104,70 @@ export async function cleanLogic( }; const cleanPlatform = cleanPlatformMap[platformEnum] ?? platformEnum; + const scheme = params.scheme ?? ''; + const configuration = params.configuration ?? 'Debug'; + + const cleanHeaderEvent = header('Clean', [ + ...(scheme ? [{ label: 'Scheme', value: scheme }] : []), + ...(params.workspacePath ? [{ label: 'Workspace', value: params.workspacePath }] : []), + ...(params.projectPath ? [{ label: 'Project', value: params.projectPath }] : []), + { label: 'Configuration', value: configuration }, + { label: 'Platform', value: String(cleanPlatform) }, + ]); + + const command = ['xcodebuild']; + let projectDir = ''; + + if (params.workspacePath) { + const wsPath = path.isAbsolute(params.workspacePath) + ? params.workspacePath + : path.resolve(process.cwd(), params.workspacePath); + projectDir = path.dirname(wsPath); + command.push('-workspace', wsPath); + } else if (params.projectPath) { + const projPath = path.isAbsolute(params.projectPath) + ? params.projectPath + : path.resolve(process.cwd(), params.projectPath); + projectDir = path.dirname(projPath); + command.push('-project', projPath); + } - const preflightText = formatToolPreflight({ - operation: 'Clean', - scheme: typedParams.scheme, - workspacePath: params.workspacePath as string | undefined, - projectPath: params.projectPath as string | undefined, - configuration: typedParams.configuration, - platform: String(cleanPlatform), - }); - - const pipelineParams = { - scheme: typedParams.scheme, - workspacePath: params.workspacePath as string | undefined, - projectPath: params.projectPath as string | undefined, - configuration: typedParams.configuration, - platform: String(cleanPlatform), - preflight: preflightText, - }; + command.push('-scheme', scheme); + command.push('-configuration', configuration); + command.push('-destination', constructDestinationString(cleanPlatform)); - const started = startBuildPipeline({ - operation: 'BUILD', - toolName: 'clean', - params: pipelineParams, - message: preflightText, - }); - - const buildResult = await executeXcodeBuildCommand( - typedParams, - { - platform: cleanPlatform, - logPrefix: 'Clean', - }, - false, - 'clean', - executor, - undefined, - started.pipeline, - ); - - return createPendingXcodebuildResponse(started, buildResult); + if (params.derivedDataPath) { + const ddPath = path.isAbsolute(params.derivedDataPath) + ? params.derivedDataPath + : path.resolve(process.cwd(), params.derivedDataPath); + command.push('-derivedDataPath', ddPath); + } + + if (params.extraArgs && params.extraArgs.length > 0) { + command.push(...params.extraArgs); + } + + command.push('clean'); + + try { + const result = await executor(command, 'Clean', false, { cwd: projectDir }); + + if (!result.success) { + const combinedOutput = [result.error, result.output].filter(Boolean).join('\n').trim(); + const errorLines = combinedOutput + .split('\n') + .filter((line) => /error:/i.test(line)) + .map((line) => line.trim()); + const errorMessage = errorLines.length > 0 ? errorLines.join('; ') : 'Unknown error'; + return toolResponse([cleanHeaderEvent, statusLine('error', `Clean failed: ${errorMessage}`)]); + } + + return toolResponse([cleanHeaderEvent, statusLine('success', 'Clean successful')]); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + log('error', `Clean failed: ${message}`); + return toolResponse([cleanHeaderEvent, statusLine('error', `Clean failed: ${message}`)]); + } } const publicSchemaObject = baseSchemaObject.omit({ diff --git a/src/mcp/tools/xcode-ide/__tests__/sync_xcode_defaults.test.ts b/src/mcp/tools/xcode-ide/__tests__/sync_xcode_defaults.test.ts index 6b23f73b..fddded57 100644 --- a/src/mcp/tools/xcode-ide/__tests__/sync_xcode_defaults.test.ts +++ b/src/mcp/tools/xcode-ide/__tests__/sync_xcode_defaults.test.ts @@ -1,17 +1,9 @@ import { describe, it, expect, beforeEach } from 'vitest'; -import { existsSync } from 'fs'; -import { join } from 'path'; import { sessionStore } from '../../../../utils/session-store.ts'; import { createCommandMatchingMockExecutor } from '../../../../test-utils/mock-executors.ts'; import { schema, syncXcodeDefaultsLogic } from '../sync_xcode_defaults.ts'; import { allText } from '../../../../test-utils/test-helpers.ts'; -const EXAMPLE_PROJECT_PATH = join(process.cwd(), 'example_projects/iOS/MCPTest.xcodeproj'); -const EXAMPLE_XCUSERSTATE = join( - EXAMPLE_PROJECT_PATH, - 'project.xcworkspace/xcuserdata/johndoe.xcuserdatad/UserInterfaceState.xcuserstate', -); - describe('sync_xcode_defaults tool', () => { beforeEach(() => { sessionStore.clear(); @@ -51,124 +43,4 @@ describe('sync_xcode_defaults tool', () => { }); }); - describe('syncXcodeDefaultsLogic integration', () => { - it.skipIf(!existsSync(EXAMPLE_XCUSERSTATE))( - 'syncs scheme and simulator from example project', - async () => { - const simctlOutput = JSON.stringify({ - devices: { - 'com.apple.CoreSimulator.SimRuntime.xrOS-2-0': [ - { udid: 'B38FE93D-578B-454B-BE9A-C6FA0CE5F096', name: 'Apple Vision Pro' }, - ], - }, - }); - - const executor = createCommandMatchingMockExecutor({ - whoami: { output: 'johndoe\n' }, - find: { output: `${EXAMPLE_PROJECT_PATH}\n` }, - stat: { output: '1704067200\n' }, - 'xcrun simctl': { output: simctlOutput }, - xcodebuild: { output: ' PRODUCT_BUNDLE_IDENTIFIER = io.sentry.MCPTest\n' }, - }); - - const result = await syncXcodeDefaultsLogic( - {}, - { executor, cwd: join(process.cwd(), 'example_projects/iOS') }, - ); - - expect(result.isError).toBeFalsy(); - const text = result.content - .filter((i: any) => i.type === 'text') - .map((i: any) => i.text) - .join('\n'); - expect(text).toContain('Synced session defaults from Xcode IDE'); - expect(text).toContain('scheme: MCPTest'); - expect(text).toContain('simulatorId: B38FE93D-578B-454B-BE9A-C6FA0CE5F096'); - expect(text).toContain('simulatorName: Apple Vision Pro'); - expect(text).toContain('bundleId: io.sentry.MCPTest'); - - const defaults = sessionStore.getAll(); - expect(defaults.scheme).toBe('MCPTest'); - expect(defaults.simulatorId).toBe('B38FE93D-578B-454B-BE9A-C6FA0CE5F096'); - expect(defaults.simulatorName).toBe('Apple Vision Pro'); - expect(defaults.bundleId).toBe('io.sentry.MCPTest'); - }, - ); - - it.skipIf(!existsSync(EXAMPLE_XCUSERSTATE))('syncs using configured projectPath', async () => { - const simctlOutput = JSON.stringify({ - devices: { - 'com.apple.CoreSimulator.SimRuntime.xrOS-2-0': [ - { udid: 'B38FE93D-578B-454B-BE9A-C6FA0CE5F096', name: 'Apple Vision Pro' }, - ], - }, - }); - - const executor = createCommandMatchingMockExecutor({ - whoami: { output: 'johndoe\n' }, - 'test -f': { success: true }, - 'xcrun simctl': { output: simctlOutput }, - xcodebuild: { output: ' PRODUCT_BUNDLE_IDENTIFIER = io.sentry.MCPTest\n' }, - }); - - const result = await syncXcodeDefaultsLogic( - {}, - { - executor, - cwd: '/some/other/path', - projectPath: EXAMPLE_PROJECT_PATH, - }, - ); - - expect(result.isError).toBeFalsy(); - const text = result.content - .filter((i: any) => i.type === 'text') - .map((i: any) => i.text) - .join('\n'); - expect(text).toContain('scheme: MCPTest'); - - const defaults = sessionStore.getAll(); - expect(defaults.scheme).toBe('MCPTest'); - expect(defaults.simulatorId).toBe('B38FE93D-578B-454B-BE9A-C6FA0CE5F096'); - expect(defaults.bundleId).toBe('io.sentry.MCPTest'); - }); - - it.skipIf(!existsSync(EXAMPLE_XCUSERSTATE))('updates existing session defaults', async () => { - sessionStore.setDefaults({ - scheme: 'OldScheme', - simulatorId: 'OLD-SIM-UUID', - projectPath: '/some/project.xcodeproj', - }); - - const simctlOutput = JSON.stringify({ - devices: { - 'com.apple.CoreSimulator.SimRuntime.xrOS-2-0': [ - { udid: 'B38FE93D-578B-454B-BE9A-C6FA0CE5F096', name: 'Apple Vision Pro' }, - ], - }, - }); - - const executor = createCommandMatchingMockExecutor({ - whoami: { output: 'johndoe\n' }, - find: { output: `${EXAMPLE_PROJECT_PATH}\n` }, - stat: { output: '1704067200\n' }, - 'xcrun simctl': { output: simctlOutput }, - xcodebuild: { output: ' PRODUCT_BUNDLE_IDENTIFIER = io.sentry.MCPTest\n' }, - }); - - const result = await syncXcodeDefaultsLogic( - {}, - { executor, cwd: join(process.cwd(), 'example_projects/iOS') }, - ); - - expect(result.isError).toBeFalsy(); - - const defaults = sessionStore.getAll(); - expect(defaults.scheme).toBe('MCPTest'); - expect(defaults.simulatorId).toBe('B38FE93D-578B-454B-BE9A-C6FA0CE5F096'); - expect(defaults.simulatorName).toBe('Apple Vision Pro'); - expect(defaults.bundleId).toBe('io.sentry.MCPTest'); - expect(defaults.projectPath).toBe('/some/project.xcodeproj'); - }); - }); }); diff --git a/src/mcp/tools/xcode-ide/sync_xcode_defaults.ts b/src/mcp/tools/xcode-ide/sync_xcode_defaults.ts index 333a0cdf..bc5222aa 100644 --- a/src/mcp/tools/xcode-ide/sync_xcode_defaults.ts +++ b/src/mcp/tools/xcode-ide/sync_xcode_defaults.ts @@ -8,6 +8,7 @@ import { lookupBundleId } from '../../../utils/xcode-state-watcher.ts'; import * as z from 'zod'; import { toolResponse } from '../../../utils/tool-response.ts'; import { header, statusLine, detailTree } from '../../../utils/tool-event-builders.ts'; +import { formatProfileAnnotation } from '../session-management/session-format-helpers.ts'; const schemaObj = z.object({}); @@ -75,12 +76,14 @@ export async function syncXcodeDefaultsLogic( sessionStore.setDefaults(synced); + const activeProfile = sessionStore.getActiveProfile(); + const profileAnnotation = formatProfileAnnotation(activeProfile); const items = Object.entries(synced).map(([k, v]) => ({ label: k, value: v })); return toolResponse([ headerEvent, + statusLine('success', `Synced session defaults from Xcode IDE ${profileAnnotation}`), detailTree(items), - statusLine('success', 'Synced session defaults from Xcode IDE.'), ]); } diff --git a/src/runtime/__tests__/tool-invoker.test.ts b/src/runtime/__tests__/tool-invoker.test.ts index d501ed03..25a8e74d 100644 --- a/src/runtime/__tests__/tool-invoker.test.ts +++ b/src/runtime/__tests__/tool-invoker.test.ts @@ -302,6 +302,43 @@ describe('DefaultToolInvoker next steps post-processing', () => { expect(text).toContain('tap'); }); + it('does not inject manifest template next steps when the tool explicitly returns an empty list', async () => { + const directHandler = vi.fn().mockResolvedValue({ + content: [{ type: 'text', text: 'ok' }], + nextSteps: [], + } satisfies ToolResponse); + + const catalog = createToolCatalog([ + makeTool({ + id: 'list_devices', + cliName: 'list', + mcpName: 'list_devices', + workflow: 'device', + stateful: false, + nextStepTemplates: [ + { label: 'Build for device', toolId: 'build_device' }, + ], + handler: directHandler, + }), + makeTool({ + id: 'build_device', + cliName: 'build', + mcpName: 'build_device', + workflow: 'device', + stateful: false, + handler: vi.fn().mockResolvedValue(textResponse('build')), + }), + ]); + + const invoker = new DefaultToolInvoker(catalog); + const response = await invoker.invoke('list', {}, { runtime: 'cli' }); + + expect(response.nextSteps).toBeUndefined(); + const text = response.content.map((c) => (c.type === 'text' ? c.text : '')).join('\n'); + expect(text).toBe('ok'); + expect(text).not.toContain('Next steps:'); + }); + it('prefers manifest templates over tool-provided next-step labels and tools', async () => { const directHandler = vi.fn().mockResolvedValue({ content: [{ type: 'text', text: 'ok' }], diff --git a/src/runtime/tool-invoker.ts b/src/runtime/tool-invoker.ts index 160e06ba..b642566a 100644 --- a/src/runtime/tool-invoker.ts +++ b/src/runtime/tool-invoker.ts @@ -198,8 +198,14 @@ export function postProcessToolResponse(params: { return true; }); + const suppressTemplateNextSteps = + responseForNextSteps.nextSteps !== undefined && responseForNextSteps.nextSteps.length === 0; + const withTemplates = - !suppressNextStepsForStructuredFailure && applyTemplateNextSteps && templateSteps.length > 0 + !suppressNextStepsForStructuredFailure && + applyTemplateNextSteps && + !suppressTemplateNextSteps && + templateSteps.length > 0 ? { ...responseForNextSteps, nextSteps: mergeTemplateAndResponseNextSteps( diff --git a/src/snapshot-tests/__fixtures__/coverage/get-coverage-report--success.txt b/src/snapshot-tests/__fixtures__/coverage/get-coverage-report--success.txt index 710beac5..fa806f3f 100644 --- a/src/snapshot-tests/__fixtures__/coverage/get-coverage-report--success.txt +++ b/src/snapshot-tests/__fixtures__/coverage/get-coverage-report--success.txt @@ -4,7 +4,7 @@ xcresult: /TestResults.xcresult Target Filter: CalculatorAppTests -โ„น๏ธ Overall: 94.9% (354/373 lines) +โ„น๏ธ Overall: 94.9% (371/391 lines) Targets - CalculatorAppTests.xctest: 94.9% (354/373 lines) + CalculatorAppTests.xctest: 94.9% (371/391 lines) diff --git a/src/snapshot-tests/__fixtures__/debugging/lldb-command--success.txt b/src/snapshot-tests/__fixtures__/debugging/lldb-command--success.txt index c8c9afca..8a360697 100644 --- a/src/snapshot-tests/__fixtures__/debugging/lldb-command--success.txt +++ b/src/snapshot-tests/__fixtures__/debugging/lldb-command--success.txt @@ -7,6 +7,6 @@ Output: Current breakpoints: - 1: file = 'ContentView.swift', line = 42, exact_match = 0, locations = 0 (pending) + 1: file = 'ContentView.swift', line = 42, exact_match = 0, locations = Names: dap diff --git a/src/snapshot-tests/__fixtures__/debugging/stack--success.txt b/src/snapshot-tests/__fixtures__/debugging/stack--success.txt index 013eda6d..37cc8867 100644 --- a/src/snapshot-tests/__fixtures__/debugging/stack--success.txt +++ b/src/snapshot-tests/__fixtures__/debugging/stack--success.txt @@ -5,20 +5,7 @@ Frames: Thread (Thread 1) - frame #0: mach_msg2_trap at /usr/lib/system/libsystem_kernel.dylib`mach_msg2_trap: - frame #1: mach_msg2_internal at /usr/lib/system/libsystem_kernel.dylib`mach_msg2_internal: - frame #2: mach_msg_overwrite at /usr/lib/system/libsystem_kernel.dylib`mach_msg_overwrite: - frame #3: mach_msg at /usr/lib/system/libsystem_kernel.dylib`mach_msg:6 - frame #4: at :1 - frame #5: at :1 - frame #6: at :1 - frame #7: at :1 - frame #8: at :1 - frame #9: at :1 - frame #10: at :1 - frame #11: at :1 - frame #12: at : - frame #13: static CalculatorApp.$main() at /Library/Developer/CoreSimulator/Devices//data/Containers/Bundle/Application//CalculatorApp.app/CalculatorApp.debug.dylib`static CalculatorApp.CalculatorApp.$main() -> (): - frame #14: main at /Library/Developer/CoreSimulator/Devices//data/Containers/Bundle/Application//CalculatorApp.app/CalculatorApp.debug.dylib`main: - frame #15: start_sim at /Library/Developer/CoreSimulator/Volumes/iOS_23E244/Library/Developer/CoreSimulator/Profiles/Runtimes/iOS 26.4.simruntime/Contents/Resources/RuntimeRoot/usr/lib/dyld_sim`start_sim: - frame #16: start at /usr/lib/dyld`start: + + frame #: static CalculatorApp.$main() at /Library/Developer/CoreSimulator/Devices//data/Containers/Bundle/Application//CalculatorApp.app/CalculatorApp.debug.dylib`static CalculatorApp.CalculatorApp.$main() -> (): + frame #: main at /Library/Developer/CoreSimulator/Devices//data/Containers/Bundle/Application//CalculatorApp.app/CalculatorApp.debug.dylib`main: + diff --git a/src/snapshot-tests/__fixtures__/device/build--error-wrong-scheme.txt b/src/snapshot-tests/__fixtures__/device/build--error-wrong-scheme.txt index 89508ab0..84127d2b 100644 --- a/src/snapshot-tests/__fixtures__/device/build--error-wrong-scheme.txt +++ b/src/snapshot-tests/__fixtures__/device/build--error-wrong-scheme.txt @@ -11,4 +11,4 @@ Errors (1): โœ— The workspace named "CalculatorApp" does not contain a scheme named "NONEXISTENT". The "-list" option can be used to find the names of the schemes in the workspace. โŒ Build failed. (โฑ๏ธ ) - โ”” Build Logs: /logs/build_device_2026-03-28T08-19-57-988Z.log + โ”” Build Logs: /logs/build_device_.log diff --git a/src/snapshot-tests/__fixtures__/device/build--success.txt b/src/snapshot-tests/__fixtures__/device/build--success.txt index 25b7188c..cccc1e64 100644 --- a/src/snapshot-tests/__fixtures__/device/build--success.txt +++ b/src/snapshot-tests/__fixtures__/device/build--success.txt @@ -7,7 +7,7 @@ Platform: iOS โœ… Build succeeded. (โฑ๏ธ ) - โ”” Build Logs: /logs/build_device_2026-03-28T08-19-54-221Z.log + โ”” Build Logs: /logs/build_device_.log Next steps: 1. Get built device app path: xcodebuildmcp device get-app-path --scheme "CalculatorApp" diff --git a/src/snapshot-tests/__fixtures__/device/build-and-run--success.txt b/src/snapshot-tests/__fixtures__/device/build-and-run--success.txt index 38df91db..f32420be 100644 --- a/src/snapshot-tests/__fixtures__/device/build-and-run--success.txt +++ b/src/snapshot-tests/__fixtures__/device/build-and-run--success.txt @@ -2,6 +2,7 @@ ๐Ÿš€ Build & Run Scheme: CalculatorApp + Workspace: example_projects/iOS_Calculator/CalculatorApp.xcworkspace Configuration: Debug Platform: iOS Device: () @@ -20,4 +21,4 @@ Next steps: 1. Capture device logs: xcodebuildmcp device start-device-log-capture --device-id "" --bundle-id "io.sentry.calculatorapp" -2. Stop app on device: xcodebuildmcp device stop --device-id "" --process-id "22761" +2. Stop app on device: xcodebuildmcp device stop --device-id "" --process-id "" diff --git a/src/snapshot-tests/__fixtures__/device/test--failure.txt b/src/snapshot-tests/__fixtures__/device/test--failure.txt index 1bc9cd60..369cbe75 100644 --- a/src/snapshot-tests/__fixtures__/device/test--failure.txt +++ b/src/snapshot-tests/__fixtures__/device/test--failure.txt @@ -7,13 +7,14 @@ Device: () CalculatorAppTests - โœ— testCalculatorServiceFailure (โฑ๏ธ ): + โœ— testCalculatorServiceFailure: - XCTAssertEqual failed: ("0") is not equal to ("999") - This test should fail - display should be 0, not 999 example_projects/iOS_Calculator/CalculatorAppTests/CalculatorAppTests.swift:52 -This test should fail to verify error reporting - โœ— test (โฑ๏ธ ): - - Test failed +IntentionalFailureTests + โœ— test: + - XCTAssertTrue failed - This test should fail to verify error reporting + example_projects/iOS_Calculator/CalculatorAppTests/CalculatorAppTests.swift:286 -โŒ 2 tests failed, 53 passed, 0 skipped (โฑ๏ธ ) - โ”” Build Logs: /logs/test_sim_2026-03-28T08-18-25-423Z.log \ No newline at end of file +โŒ 2 tests failed, 21 passed, 0 skipped (โฑ๏ธ ) + โ”” Build Logs: /logs/test_device_.log diff --git a/src/snapshot-tests/__fixtures__/device/test--success.txt b/src/snapshot-tests/__fixtures__/device/test--success.txt index 3469553d..48a7d720 100644 --- a/src/snapshot-tests/__fixtures__/device/test--success.txt +++ b/src/snapshot-tests/__fixtures__/device/test--success.txt @@ -6,5 +6,5 @@ Platform: iOS Device: () -โœ… 53 tests passed, 0 skipped (โฑ๏ธ ) - โ”” Build Logs: /logs/test_device_2026-03-28T08-18-25-423Z.log \ No newline at end of file +โœ… 1 test passed, 0 skipped (โฑ๏ธ ) + โ”” Build Logs: /logs/test_device_.log diff --git a/src/snapshot-tests/__fixtures__/logging/stop-device-log--success.txt b/src/snapshot-tests/__fixtures__/logging/stop-device-log--success.txt index df356b7b..74320743 100644 --- a/src/snapshot-tests/__fixtures__/logging/stop-device-log--success.txt +++ b/src/snapshot-tests/__fixtures__/logging/stop-device-log--success.txt @@ -4,12 +4,12 @@ Session ID: โœ… Log capture stopped. - โ”” Logs: /logs/io.sentry.calculatorapp_2026-03-28T08-19-54-221Z.log + โ”” Logs: /logs/io.sentry.calculatorapp_.log Captured Logs: --- Device log capture for bundle ID: io.sentry.calculatorapp on device: --- - 09:50:59 Acquired usage assertion. +