diff --git a/src/mcp/tools/ui-automation/__tests__/button.test.ts b/src/mcp/tools/ui-automation/__tests__/button.test.ts index c64d426b..b58543c5 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,6 +8,8 @@ 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'; +import { allText, runLogic } from '../../../../test-utils/test-helpers.ts'; + describe('Button Plugin', () => { describe('Export Field Validation (Literal)', () => { @@ -53,19 +51,17 @@ 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( - { - simulatorId: '12345678-1234-4234-8234-123456789012', - buttonType: 'home', - }, - trackingExecutor, - mockAxeHelpers, + await runLogic(() => + buttonLogic( + { + simulatorId: '12345678-1234-4234-8234-123456789012', + buttonType: 'home', + }, + trackingExecutor, + mockAxeHelpers, + ), ); expect(capturedCommand).toEqual([ @@ -91,20 +87,18 @@ 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( - { - simulatorId: '12345678-1234-4234-8234-123456789012', - buttonType: 'side-button', - duration: 2.5, - }, - trackingExecutor, - mockAxeHelpers, + await runLogic(() => + buttonLogic( + { + simulatorId: '12345678-1234-4234-8234-123456789012', + buttonType: 'side-button', + duration: 2.5, + }, + trackingExecutor, + mockAxeHelpers, + ), ); expect(capturedCommand).toEqual([ @@ -132,19 +126,17 @@ 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( - { - simulatorId: '12345678-1234-4234-8234-123456789012', - buttonType: 'apple-pay', - }, - trackingExecutor, - mockAxeHelpers, + await runLogic(() => + buttonLogic( + { + simulatorId: '12345678-1234-4234-8234-123456789012', + buttonType: 'apple-pay', + }, + trackingExecutor, + mockAxeHelpers, + ), ); expect(capturedCommand).toEqual([ @@ -170,19 +162,17 @@ 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( - { - simulatorId: '12345678-1234-4234-8234-123456789012', - buttonType: 'siri', - }, - trackingExecutor, - mockAxeHelpers, + await runLogic(() => + buttonLogic( + { + simulatorId: '12345678-1234-4234-8234-123456789012', + buttonType: 'siri', + }, + trackingExecutor, + mockAxeHelpers, + ), ); expect(capturedCommand).toEqual([ @@ -200,8 +190,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 +200,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 +213,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 +224,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 +235,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,25 +250,21 @@ 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( - { - simulatorId: '12345678-1234-4234-8234-123456789012', - buttonType: 'home', - }, - mockExecutor, - mockAxeHelpers, + const result = await runLogic(() => + buttonLogic( + { + simulatorId: '12345678-1234-4234-8234-123456789012', + buttonType: 'home', + }, + mockExecutor, + 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,63 +278,43 @@ 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( - { - simulatorId: '12345678-1234-4234-8234-123456789012', - buttonType: 'side-button', - duration: 2.5, - }, - mockExecutor, - mockAxeHelpers, + const result = await runLogic(() => + buttonLogic( + { + simulatorId: '12345678-1234-4234-8234-123456789012', + buttonType: 'side-button', + duration: 2.5, + }, + mockExecutor, + 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( - { - simulatorId: '12345678-1234-4234-8234-123456789012', - buttonType: 'home', - }, - createNoopExecutor(), - mockAxeHelpers, - ); - - expect(result).toEqual({ - content: [ + const result = await runLogic(() => + buttonLogic( { - type: 'text' as const, - text: AXE_NOT_AVAILABLE_MESSAGE, + simulatorId: '12345678-1234-4234-8234-123456789012', + buttonType: 'home', }, - ], - isError: true, - }); + createNoopExecutor(), + mockAxeHelpers, + ), + ); + + expect(result.isError).toBe(true); + expect(allText(result)).toContain(AXE_NOT_AVAILABLE_MESSAGE); }); it('should handle AxeError from failed command execution', async () => { @@ -362,30 +328,23 @@ 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( - { - simulatorId: '12345678-1234-4234-8234-123456789012', - buttonType: 'home', - }, - mockExecutor, - mockAxeHelpers, - ); - - expect(result).toEqual({ - content: [ + const result = await runLogic(() => + buttonLogic( { - type: 'text' as const, - text: "Error: Failed to press button 'home': axe command 'button' failed.\nDetails: axe command failed", + simulatorId: '12345678-1234-4234-8234-123456789012', + buttonType: 'home', }, - ], - isError: true, - }); + mockExecutor, + mockAxeHelpers, + ), + ); + + 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,23 +355,21 @@ 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( - { - simulatorId: '12345678-1234-4234-8234-123456789012', - buttonType: 'home', - }, - mockExecutor, - mockAxeHelpers, + const result = await runLogic(() => + buttonLogic( + { + simulatorId: '12345678-1234-4234-8234-123456789012', + buttonType: 'home', + }, + mockExecutor, + 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,23 +382,21 @@ 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( - { - simulatorId: '12345678-1234-4234-8234-123456789012', - buttonType: 'home', - }, - mockExecutor, - mockAxeHelpers, + const result = await runLogic(() => + buttonLogic( + { + simulatorId: '12345678-1234-4234-8234-123456789012', + buttonType: 'home', + }, + mockExecutor, + 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,30 +409,23 @@ 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( - { - simulatorId: '12345678-1234-4234-8234-123456789012', - buttonType: 'home', - }, - mockExecutor, - mockAxeHelpers, - ); - - expect(result).toEqual({ - content: [ + const result = await runLogic(() => + buttonLogic( { - type: 'text' as const, - text: 'Error: System error executing axe: Failed to execute axe command: String error', + simulatorId: '12345678-1234-4234-8234-123456789012', + buttonType: 'home', }, - ], - isError: true, - }); + mockExecutor, + mockAxeHelpers, + ), + ); + + 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..fdd3a8a3 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,6 +8,8 @@ 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'; +import { allText, runLogic } from '../../../../test-utils/test-helpers.ts'; + describe('Gesture Plugin', () => { beforeEach(() => { @@ -92,19 +90,17 @@ 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( - { - simulatorId: '12345678-1234-4234-8234-123456789012', - preset: 'scroll-up', - }, - trackingExecutor, - mockAxeHelpers, + await runLogic(() => + gestureLogic( + { + simulatorId: '12345678-1234-4234-8234-123456789012', + preset: 'scroll-up', + }, + trackingExecutor, + mockAxeHelpers, + ), ); expect(capturedCommand).toEqual([ @@ -131,21 +127,19 @@ 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( - { - simulatorId: '12345678-1234-4234-8234-123456789012', - preset: 'swipe-from-left-edge', - screenWidth: 375, - screenHeight: 667, - }, - trackingExecutor, - mockAxeHelpers, + await runLogic(() => + gestureLogic( + { + simulatorId: '12345678-1234-4234-8234-123456789012', + preset: 'swipe-from-left-edge', + screenWidth: 375, + screenHeight: 667, + }, + trackingExecutor, + mockAxeHelpers, + ), ); expect(capturedCommand).toEqual([ @@ -176,25 +170,23 @@ 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( - { - simulatorId: '12345678-1234-4234-8234-123456789012', - preset: 'scroll-down', - screenWidth: 414, - screenHeight: 896, - duration: 2.0, - delta: 150, - preDelay: 0.5, - postDelay: 0.3, - }, - trackingExecutor, - mockAxeHelpers, + await runLogic(() => + gestureLogic( + { + simulatorId: '12345678-1234-4234-8234-123456789012', + preset: 'scroll-down', + screenWidth: 414, + screenHeight: 896, + duration: 2.0, + delta: 150, + preDelay: 0.5, + postDelay: 0.3, + }, + trackingExecutor, + mockAxeHelpers, + ), ); expect(capturedCommand).toEqual([ @@ -233,19 +225,17 @@ 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( - { - simulatorId: '12345678-1234-4234-8234-123456789012', - preset: 'swipe-from-bottom-edge', - }, - trackingExecutor, - mockAxeHelpers, + await runLogic(() => + gestureLogic( + { + simulatorId: '12345678-1234-4234-8234-123456789012', + preset: 'swipe-from-bottom-edge', + }, + trackingExecutor, + mockAxeHelpers, + ), ); expect(capturedCommand).toEqual([ @@ -274,25 +264,21 @@ 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( - { - simulatorId: '12345678-1234-4234-8234-123456789012', - preset: 'scroll-up', - }, - mockExecutor, - mockAxeHelpers, + const result = await runLogic(() => + gestureLogic( + { + simulatorId: '12345678-1234-4234-8234-123456789012', + preset: 'scroll-up', + }, + mockExecutor, + 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,68 +292,48 @@ 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( - { - simulatorId: '12345678-1234-4234-8234-123456789012', - preset: 'swipe-from-left-edge', - screenWidth: 375, - screenHeight: 667, - duration: 1.0, - delta: 50, - preDelay: 0.1, - postDelay: 0.2, - }, - mockExecutor, - mockAxeHelpers, + const result = await runLogic(() => + gestureLogic( + { + simulatorId: '12345678-1234-4234-8234-123456789012', + preset: 'swipe-from-left-edge', + screenWidth: 375, + screenHeight: 667, + duration: 1.0, + delta: 50, + preDelay: 0.1, + postDelay: 0.2, + }, + mockExecutor, + 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( - { - simulatorId: '12345678-1234-4234-8234-123456789012', - preset: 'scroll-up', - }, - createNoopExecutor(), - mockAxeHelpers, - ); - - expect(result).toEqual({ - content: [ + const result = await runLogic(() => + gestureLogic( { - type: 'text' as const, - text: AXE_NOT_AVAILABLE_MESSAGE, + simulatorId: '12345678-1234-4234-8234-123456789012', + preset: 'scroll-up', }, - ], - isError: true, - }); + createNoopExecutor(), + mockAxeHelpers, + ), + ); + + expect(result.isError).toBe(true); + expect(allText(result)).toContain(AXE_NOT_AVAILABLE_MESSAGE); }); it('should handle AxeError from failed command execution', async () => { @@ -381,30 +347,23 @@ 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( - { - simulatorId: '12345678-1234-4234-8234-123456789012', - preset: 'scroll-up', - }, - mockExecutor, - mockAxeHelpers, - ); - - expect(result).toEqual({ - content: [ + const result = await runLogic(() => + gestureLogic( { - type: 'text' as const, - text: "Error: Failed to execute gesture 'scroll-up': axe command 'gesture' failed.\nDetails: axe command failed", + simulatorId: '12345678-1234-4234-8234-123456789012', + preset: 'scroll-up', }, - ], - isError: true, - }); + mockExecutor, + mockAxeHelpers, + ), + ); + + 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,23 +372,21 @@ 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( - { - simulatorId: '12345678-1234-4234-8234-123456789012', - preset: 'scroll-up', - }, - mockExecutor, - mockAxeHelpers, + const result = await runLogic(() => + gestureLogic( + { + simulatorId: '12345678-1234-4234-8234-123456789012', + preset: 'scroll-up', + }, + mockExecutor, + 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,23 +397,21 @@ 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( - { - simulatorId: '12345678-1234-4234-8234-123456789012', - preset: 'scroll-up', - }, - mockExecutor, - mockAxeHelpers, + const result = await runLogic(() => + gestureLogic( + { + simulatorId: '12345678-1234-4234-8234-123456789012', + preset: 'scroll-up', + }, + mockExecutor, + 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,30 +422,23 @@ 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( - { - simulatorId: '12345678-1234-4234-8234-123456789012', - preset: 'scroll-up', - }, - mockExecutor, - mockAxeHelpers, - ); - - expect(result).toEqual({ - content: [ + const result = await runLogic(() => + gestureLogic( { - type: 'text' as const, - text: 'Error: System error executing axe: Failed to execute axe command: String error', + simulatorId: '12345678-1234-4234-8234-123456789012', + preset: 'scroll-up', }, - ], - isError: true, - }); + mockExecutor, + mockAxeHelpers, + ), + ); + + 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..f9e1d3d3 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,20 +9,13 @@ 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, runLogic } from '../../../../test-utils/test-helpers.ts'; + function createDefaultMockAxeHelpers() { return { getAxePath: () => '/usr/local/bin/axe', getBundledAxeEnvironment: () => ({}), - createAxeNotAvailableResponse: () => ({ - content: [ - { - type: 'text' as const, - text: AXE_NOT_AVAILABLE_MESSAGE, - }, - ], - isError: true, - }), }; } @@ -98,13 +87,15 @@ describe('Key Press Tool', () => { const mockAxeHelpers = createDefaultMockAxeHelpers(); - await key_pressLogic( - { - simulatorId: '12345678-1234-4234-8234-123456789012', - keyCode: 40, - }, - trackingExecutor, - mockAxeHelpers, + await runLogic(() => + key_pressLogic( + { + simulatorId: '12345678-1234-4234-8234-123456789012', + keyCode: 40, + }, + trackingExecutor, + mockAxeHelpers, + ), ); expect(capturedCommand).toEqual([ @@ -130,14 +121,16 @@ describe('Key Press Tool', () => { const mockAxeHelpers = createDefaultMockAxeHelpers(); - await key_pressLogic( - { - simulatorId: '12345678-1234-4234-8234-123456789012', - keyCode: 42, - duration: 1.5, - }, - trackingExecutor, - mockAxeHelpers, + await runLogic(() => + key_pressLogic( + { + simulatorId: '12345678-1234-4234-8234-123456789012', + keyCode: 42, + duration: 1.5, + }, + trackingExecutor, + mockAxeHelpers, + ), ); expect(capturedCommand).toEqual([ @@ -165,13 +158,15 @@ describe('Key Press Tool', () => { const mockAxeHelpers = createDefaultMockAxeHelpers(); - await key_pressLogic( - { - simulatorId: '12345678-1234-4234-8234-123456789012', - keyCode: 255, - }, - trackingExecutor, - mockAxeHelpers, + await runLogic(() => + key_pressLogic( + { + simulatorId: '12345678-1234-4234-8234-123456789012', + keyCode: 255, + }, + trackingExecutor, + mockAxeHelpers, + ), ); expect(capturedCommand).toEqual([ @@ -198,24 +193,17 @@ 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( - { - simulatorId: '12345678-1234-4234-8234-123456789012', - keyCode: 44, - }, - trackingExecutor, - mockAxeHelpers, + await runLogic(() => + key_pressLogic( + { + simulatorId: '12345678-1234-4234-8234-123456789012', + keyCode: 44, + }, + trackingExecutor, + mockAxeHelpers, + ), ); expect(capturedCommand).toEqual([ @@ -241,19 +229,19 @@ describe('Key Press Tool', () => { const mockAxeHelpers = createDefaultMockAxeHelpers(); - const result = await key_pressLogic( - { - simulatorId: '12345678-1234-4234-8234-123456789012', - keyCode: 40, - }, - mockExecutor, - mockAxeHelpers, + const result = await runLogic(() => + key_pressLogic( + { + simulatorId: '12345678-1234-4234-8234-123456789012', + keyCode: 40, + }, + mockExecutor, + 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 () => { @@ -265,55 +253,41 @@ describe('Key Press Tool', () => { const mockAxeHelpers = createDefaultMockAxeHelpers(); - const result = await key_pressLogic( - { - simulatorId: '12345678-1234-4234-8234-123456789012', - keyCode: 42, - duration: 1.5, - }, - mockExecutor, - mockAxeHelpers, + const result = await runLogic(() => + key_pressLogic( + { + simulatorId: '12345678-1234-4234-8234-123456789012', + keyCode: 42, + duration: 1.5, + }, + mockExecutor, + 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( - { - simulatorId: '12345678-1234-4234-8234-123456789012', - keyCode: 40, - }, - createNoopExecutor(), - mockAxeHelpers, - ); - - expect(result).toEqual({ - content: [ + const result = await runLogic(() => + key_pressLogic( { - type: 'text' as const, - text: AXE_NOT_AVAILABLE_MESSAGE, + simulatorId: '12345678-1234-4234-8234-123456789012', + keyCode: 40, }, - ], - isError: true, - }); + createNoopExecutor(), + mockAxeHelpers, + ), + ); + + expect(result.isError).toBe(true); + expect(allText(result)).toContain(AXE_NOT_AVAILABLE_MESSAGE); }); it('should handle AxeError from failed command execution', async () => { @@ -325,24 +299,21 @@ describe('Key Press Tool', () => { const mockAxeHelpers = createDefaultMockAxeHelpers(); - const result = await key_pressLogic( - { - simulatorId: '12345678-1234-4234-8234-123456789012', - keyCode: 40, - }, - mockExecutor, - mockAxeHelpers, - ); - - expect(result).toEqual({ - content: [ + const result = await runLogic(() => + key_pressLogic( { - type: 'text' as const, - text: "Error: Failed to simulate key press (code: 40): axe command 'key' failed.\nDetails: axe command failed", + simulatorId: '12345678-1234-4234-8234-123456789012', + keyCode: 40, }, - ], - isError: true, - }); + mockExecutor, + mockAxeHelpers, + ), + ); + + 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 () => { @@ -352,18 +323,20 @@ describe('Key Press Tool', () => { const mockAxeHelpers = createDefaultMockAxeHelpers(); - const result = await key_pressLogic( - { - simulatorId: '12345678-1234-4234-8234-123456789012', - keyCode: 40, - }, - mockExecutor, - mockAxeHelpers, + const result = await runLogic(() => + key_pressLogic( + { + simulatorId: '12345678-1234-4234-8234-123456789012', + keyCode: 40, + }, + mockExecutor, + mockAxeHelpers, + ), ); 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', ); }); @@ -374,18 +347,20 @@ describe('Key Press Tool', () => { const mockAxeHelpers = createDefaultMockAxeHelpers(); - const result = await key_pressLogic( - { - simulatorId: '12345678-1234-4234-8234-123456789012', - keyCode: 40, - }, - mockExecutor, - mockAxeHelpers, + const result = await runLogic(() => + key_pressLogic( + { + simulatorId: '12345678-1234-4234-8234-123456789012', + keyCode: 40, + }, + mockExecutor, + mockAxeHelpers, + ), ); 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', ); }); @@ -396,24 +371,21 @@ describe('Key Press Tool', () => { const mockAxeHelpers = createDefaultMockAxeHelpers(); - const result = await key_pressLogic( - { - simulatorId: '12345678-1234-4234-8234-123456789012', - keyCode: 40, - }, - mockExecutor, - mockAxeHelpers, - ); - - expect(result).toEqual({ - content: [ + const result = await runLogic(() => + key_pressLogic( { - type: 'text' as const, - text: 'Error: System error executing axe: Failed to execute axe command: String error', + simulatorId: '12345678-1234-4234-8234-123456789012', + keyCode: 40, }, - ], - isError: true, - }); + mockExecutor, + mockAxeHelpers, + ), + ); + + 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..5e3c75fe 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,6 +8,8 @@ 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'; +import { allText, runLogic } from '../../../../test-utils/test-helpers.ts'; + describe('Key Sequence Tool', () => { beforeEach(() => { @@ -83,24 +81,17 @@ 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( - { - simulatorId: '12345678-1234-4234-8234-123456789012', - keyCodes: [40, 42, 44], - }, - trackingExecutor, - mockAxeHelpers, + await runLogic(() => + key_sequenceLogic( + { + simulatorId: '12345678-1234-4234-8234-123456789012', + keyCodes: [40, 42, 44], + }, + trackingExecutor, + mockAxeHelpers, + ), ); expect(capturedCommand).toEqual([ @@ -128,25 +119,18 @@ 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( - { - simulatorId: '12345678-1234-4234-8234-123456789012', - keyCodes: [58, 59, 60], - delay: 0.5, - }, - trackingExecutor, - mockAxeHelpers, + await runLogic(() => + key_sequenceLogic( + { + simulatorId: '12345678-1234-4234-8234-123456789012', + keyCodes: [58, 59, 60], + delay: 0.5, + }, + trackingExecutor, + mockAxeHelpers, + ), ); expect(capturedCommand).toEqual([ @@ -176,24 +160,17 @@ 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( - { - simulatorId: '12345678-1234-4234-8234-123456789012', - keyCodes: [255], - }, - trackingExecutor, - mockAxeHelpers, + await runLogic(() => + key_sequenceLogic( + { + simulatorId: '12345678-1234-4234-8234-123456789012', + keyCodes: [255], + }, + trackingExecutor, + mockAxeHelpers, + ), ); expect(capturedCommand).toEqual([ @@ -221,25 +198,18 @@ 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( - { - simulatorId: '12345678-1234-4234-8234-123456789012', - keyCodes: [0, 1, 2, 3, 4], - delay: 1.0, - }, - trackingExecutor, - mockAxeHelpers, + await runLogic(() => + key_sequenceLogic( + { + simulatorId: '12345678-1234-4234-8234-123456789012', + keyCodes: [0, 1, 2, 3, 4], + delay: 1.0, + }, + trackingExecutor, + mockAxeHelpers, + ), ); expect(capturedCommand).toEqual([ @@ -260,8 +230,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,33 +244,22 @@ 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( - { - simulatorId: '12345678-1234-4234-8234-123456789012', - keyCodes: [40, 42, 44], - delay: 0.1, - }, - mockExecutor, - mockAxeHelpers, + const result = await runLogic(() => + key_sequenceLogic( + { + simulatorId: '12345678-1234-4234-8234-123456789012', + keyCodes: [40, 42, 44], + delay: 0.1, + }, + mockExecutor, + 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,65 +272,42 @@ 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( - { - simulatorId: '12345678-1234-4234-8234-123456789012', - keyCodes: [40], - }, - mockExecutor, - mockAxeHelpers, + const result = await runLogic(() => + key_sequenceLogic( + { + simulatorId: '12345678-1234-4234-8234-123456789012', + keyCodes: [40], + }, + mockExecutor, + 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( - { - simulatorId: '12345678-1234-4234-8234-123456789012', - keyCodes: [40], - }, - createNoopExecutor(), - mockAxeHelpers, - ); - - expect(result).toEqual({ - content: [ + const result = await runLogic(() => + key_sequenceLogic( { - type: 'text' as const, - text: AXE_NOT_AVAILABLE_MESSAGE, + simulatorId: '12345678-1234-4234-8234-123456789012', + keyCodes: [40], }, - ], - isError: true, - }); + createNoopExecutor(), + mockAxeHelpers, + ), + ); + + expect(result.isError).toBe(true); + expect(allText(result)).toContain(AXE_NOT_AVAILABLE_MESSAGE); }); it('should handle AxeError from command execution', async () => { @@ -384,35 +320,23 @@ 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( - { - simulatorId: '12345678-1234-4234-8234-123456789012', - keyCodes: [40], - }, - mockExecutor, - mockAxeHelpers, - ); - - expect(result).toEqual({ - content: [ + const result = await runLogic(() => + key_sequenceLogic( { - type: 'text' as const, - text: "Error: Failed to execute key sequence: axe command 'key-sequence' failed.\nDetails: Simulator not found", + simulatorId: '12345678-1234-4234-8234-123456789012', + keyCodes: [40], }, - ], - isError: true, - }); + mockExecutor, + mockAxeHelpers, + ), + ); + + 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,28 +347,21 @@ 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( - { - simulatorId: '12345678-1234-4234-8234-123456789012', - keyCodes: [40], - }, - mockExecutor, - mockAxeHelpers, + const result = await runLogic(() => + key_sequenceLogic( + { + simulatorId: '12345678-1234-4234-8234-123456789012', + keyCodes: [40], + }, + mockExecutor, + 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,28 +374,21 @@ 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( - { - simulatorId: '12345678-1234-4234-8234-123456789012', - keyCodes: [40], - }, - mockExecutor, - mockAxeHelpers, + const result = await runLogic(() => + key_sequenceLogic( + { + simulatorId: '12345678-1234-4234-8234-123456789012', + keyCodes: [40], + }, + mockExecutor, + 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,35 +401,23 @@ 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( - { - simulatorId: '12345678-1234-4234-8234-123456789012', - keyCodes: [40], - }, - mockExecutor, - mockAxeHelpers, - ); - - expect(result).toEqual({ - content: [ + const result = await runLogic(() => + key_sequenceLogic( { - type: 'text' as const, - text: 'Error: System error executing axe: Failed to execute axe command: String error', + simulatorId: '12345678-1234-4234-8234-123456789012', + keyCodes: [40], }, - ], - isError: true, - }); + mockExecutor, + mockAxeHelpers, + ), + ); + + 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..191bb3b6 100644 --- a/src/mcp/tools/ui-automation/__tests__/long_press.test.ts +++ b/src/mcp/tools/ui-automation/__tests__/long_press.test.ts @@ -1,13 +1,11 @@ -/** - * 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'; +import { allText, runLogic } from '../../../../test-utils/test-helpers.ts'; + describe('Long Press Plugin', () => { beforeEach(() => { @@ -112,21 +110,19 @@ 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( - { - simulatorId: '12345678-1234-4234-8234-123456789012', - x: 100, - y: 200, - duration: 1500, - }, - trackingExecutor, - mockAxeHelpers, + await runLogic(() => + long_pressLogic( + { + simulatorId: '12345678-1234-4234-8234-123456789012', + x: 100, + y: 200, + duration: 1500, + }, + trackingExecutor, + mockAxeHelpers, + ), ); expect(capturedCommand).toEqual([ @@ -160,21 +156,19 @@ 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( - { - simulatorId: '12345678-1234-4234-8234-123456789012', - x: 50, - y: 75, - duration: 2000, - }, - trackingExecutor, - mockAxeHelpers, + await runLogic(() => + long_pressLogic( + { + simulatorId: '12345678-1234-4234-8234-123456789012', + x: 50, + y: 75, + duration: 2000, + }, + trackingExecutor, + mockAxeHelpers, + ), ); expect(capturedCommand).toEqual([ @@ -208,21 +202,19 @@ 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( - { - simulatorId: '12345678-1234-4234-8234-123456789012', - x: 300, - y: 400, - duration: 500, - }, - trackingExecutor, - mockAxeHelpers, + await runLogic(() => + long_pressLogic( + { + simulatorId: '12345678-1234-4234-8234-123456789012', + x: 300, + y: 400, + duration: 500, + }, + trackingExecutor, + mockAxeHelpers, + ), ); expect(capturedCommand).toEqual([ @@ -256,21 +248,19 @@ 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( - { - simulatorId: '12345678-1234-4234-8234-123456789012', - x: 150, - y: 250, - duration: 3000, - }, - trackingExecutor, - mockAxeHelpers, + await runLogic(() => + long_pressLogic( + { + simulatorId: '12345678-1234-4234-8234-123456789012', + x: 150, + y: 250, + duration: 3000, + }, + trackingExecutor, + mockAxeHelpers, + ), ); expect(capturedCommand).toEqual([ @@ -301,32 +291,25 @@ 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( - { - simulatorId: '12345678-1234-4234-8234-123456789012', - x: 100, - y: 200, - duration: 1500, - }, - mockExecutor, - mockAxeHelpers, - ); - - expect(result).toEqual({ - content: [ + const result = await runLogic(() => + long_pressLogic( { - 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.', + simulatorId: '12345678-1234-4234-8234-123456789012', + x: 100, + y: 200, + duration: 1500, }, - ], - isError: false, - }); + mockExecutor, + mockAxeHelpers, + ), + ); + + 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,37 +323,23 @@ 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( - { - simulatorId: '12345678-1234-4234-8234-123456789012', - x: 100, - y: 200, - duration: 1500, - }, - mockExecutor, - mockAxeHelpers, - ); - - expect(result).toEqual({ - content: [ + const result = await runLogic(() => + long_pressLogic( { - type: 'text' as const, - text: AXE_NOT_AVAILABLE_MESSAGE, + simulatorId: '12345678-1234-4234-8234-123456789012', + x: 100, + y: 200, + duration: 1500, }, - ], - isError: true, - }); + mockExecutor, + mockAxeHelpers, + ), + ); + + expect(result.isError).toBe(true); + expect(allText(result)).toContain(AXE_NOT_AVAILABLE_MESSAGE); }); it('should handle AxeError from failed command execution', async () => { @@ -384,32 +353,25 @@ 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( - { - simulatorId: '12345678-1234-4234-8234-123456789012', - x: 100, - y: 200, - duration: 1500, - }, - mockExecutor, - mockAxeHelpers, - ); - - expect(result).toEqual({ - content: [ + const result = await runLogic(() => + long_pressLogic( { - type: 'text' as const, - text: "Error: Failed to simulate long press at (100, 200): axe command 'touch' failed.\nDetails: axe command failed", + simulatorId: '12345678-1234-4234-8234-123456789012', + x: 100, + y: 200, + duration: 1500, }, - ], - isError: true, - }); + mockExecutor, + mockAxeHelpers, + ), + ); + + 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,34 +382,22 @@ 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( - { - simulatorId: '12345678-1234-4234-8234-123456789012', - x: 100, - y: 200, - duration: 1500, - }, - mockExecutor, - mockAxeHelpers, - ); - - expect(result).toEqual({ - content: [ + const result = await runLogic(() => + long_pressLogic( { - type: 'text' as const, - text: expect.stringContaining( - 'Error: System error executing axe: Failed to execute axe command: ENOENT: no such file or directory', - ), + simulatorId: '12345678-1234-4234-8234-123456789012', + x: 100, + y: 200, + duration: 1500, }, - ], - isError: true, - }); + mockExecutor, + mockAxeHelpers, + ), + ); + + expect(result.isError).toBe(true); }); it('should handle unexpected Error objects', async () => { @@ -458,34 +408,22 @@ 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( - { - simulatorId: '12345678-1234-4234-8234-123456789012', - x: 100, - y: 200, - duration: 1500, - }, - mockExecutor, - mockAxeHelpers, - ); - - expect(result).toEqual({ - content: [ + const result = await runLogic(() => + long_pressLogic( { - type: 'text' as const, - text: expect.stringContaining( - 'Error: System error executing axe: Failed to execute axe command: Unexpected error', - ), + simulatorId: '12345678-1234-4234-8234-123456789012', + x: 100, + y: 200, + duration: 1500, }, - ], - isError: true, - }); + mockExecutor, + mockAxeHelpers, + ), + ); + + expect(result.isError).toBe(true); }); it('should handle unexpected string errors', async () => { @@ -496,32 +434,25 @@ 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( - { - simulatorId: '12345678-1234-4234-8234-123456789012', - x: 100, - y: 200, - duration: 1500, - }, - mockExecutor, - mockAxeHelpers, - ); - - expect(result).toEqual({ - content: [ + const result = await runLogic(() => + long_pressLogic( { - type: 'text' as const, - text: 'Error: System error executing axe: Failed to execute axe command: String error', + simulatorId: '12345678-1234-4234-8234-123456789012', + x: 100, + y: 200, + duration: 1500, }, - ], - isError: true, - }); + mockExecutor, + mockAxeHelpers, + ), + ); + + 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..ddd76860 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,6 +14,8 @@ import { detectLandscapeMode, rotateImage, } from '../screenshot.ts'; +import { allText, runLogic } from '../../../../test-utils/test-helpers.ts'; + describe('Screenshot Plugin', () => { beforeEach(() => { @@ -84,14 +82,16 @@ describe('Screenshot Plugin', () => { readFile: async () => mockImageBuffer.toString('utf8'), }); - await screenshotLogic( - { - simulatorId: '12345678-1234-4234-8234-123456789012', - }, - trackingExecutor, - mockFileSystemExecutor, - { tmpdir: () => '/tmp', join: (...paths) => paths.join('/') }, - { v4: () => 'test-uuid' }, + await runLogic(() => + screenshotLogic( + { + simulatorId: '12345678-1234-4234-8234-123456789012', + }, + trackingExecutor, + mockFileSystemExecutor, + { tmpdir: () => '/tmp', join: (...paths) => paths.join('/') }, + { v4: () => 'test-uuid' }, + ), ); // Should capture the screenshot command first @@ -122,14 +122,16 @@ describe('Screenshot Plugin', () => { readFile: async () => mockImageBuffer.toString('utf8'), }); - await screenshotLogic( - { - simulatorId: 'ABCDEF12-3456-7890-ABCD-ABCDEFABCDEF', - }, - trackingExecutor, - mockFileSystemExecutor, - { tmpdir: () => '/var/tmp', join: (...paths) => paths.join('/') }, - { v4: () => 'another-uuid' }, + await runLogic(() => + screenshotLogic( + { + simulatorId: 'ABCDEF12-3456-7890-ABCD-ABCDEFABCDEF', + }, + trackingExecutor, + mockFileSystemExecutor, + { tmpdir: () => '/var/tmp', join: (...paths) => paths.join('/') }, + { v4: () => 'another-uuid' }, + ), ); expect(capturedCommands[0]).toEqual([ @@ -159,17 +161,19 @@ describe('Screenshot Plugin', () => { readFile: async () => mockImageBuffer.toString('utf8'), }); - await screenshotLogic( - { - simulatorId: '98765432-1098-7654-3210-987654321098', - }, - trackingExecutor, - mockFileSystemExecutor, - { - tmpdir: () => '/custom/temp/dir', - join: (...paths) => paths.join('\\'), // Windows-style path joining - }, - { v4: () => 'custom-uuid' }, + await runLogic(() => + screenshotLogic( + { + simulatorId: '98765432-1098-7654-3210-987654321098', + }, + trackingExecutor, + mockFileSystemExecutor, + { + tmpdir: () => '/custom/temp/dir', + join: (...paths) => paths.join('\\'), // Windows-style path joining + }, + { v4: () => 'custom-uuid' }, + ), ); expect(capturedCommands[0]).toEqual([ @@ -199,14 +203,16 @@ describe('Screenshot Plugin', () => { readFile: async () => mockImageBuffer.toString('utf8'), }); - await screenshotLogic( - { - simulatorId: '12345678-1234-4234-8234-123456789012', - }, - trackingExecutor, - mockFileSystemExecutor, - { tmpdir: () => '/tmp', join: (...paths) => paths.join('/') }, - // No UUID deps provided - should use real uuidv4() + await runLogic(() => + screenshotLogic( + { + simulatorId: '12345678-1234-4234-8234-123456789012', + }, + trackingExecutor, + mockFileSystemExecutor, + { tmpdir: () => '/tmp', join: (...paths) => paths.join('/') }, + // No UUID deps provided - should use real uuidv4() + ), ); // Verify the command structure but not the exact UUID since it's generated @@ -222,79 +228,6 @@ describe('Screenshot Plugin', () => { }); describe('Handler Behavior (Complete Literal Returns)', () => { - it('should handle parameter validation via plugin handler (not logic function)', async () => { - // Note: With Zod validation in createTypedTool, the screenshotLogic function - // will never receive invalid parameters - validation happens at the handler level. - // This test documents that screenshotLogic assumes valid parameters. - const result = await screenshotLogic( - { - simulatorId: '12345678-1234-4234-8234-123456789012', - }, - createMockExecutor({ - success: true, - output: 'Screenshot saved', - error: undefined, - }), - createMockFileSystemExecutor({ - readFile: async () => Buffer.from('fake-image-data', 'utf8').toString('utf8'), - }), - ); - - expect(result.isError).toBe(false); - expect(result.content[0].type).toBe('image'); - }); - - it('should return success for valid screenshot capture', async () => { - const mockImageBuffer = Buffer.from('fake-image-data', 'utf8'); - - const mockExecutor = createMockExecutor({ - success: true, - output: 'Screenshot saved', - error: undefined, - }); - - const mockFileSystemExecutor = createMockFileSystemExecutor({ - readFile: async () => mockImageBuffer.toString('utf8'), - }); - - const result = await screenshotLogic( - { - simulatorId: '12345678-1234-4234-8234-123456789012', - }, - mockExecutor, - mockFileSystemExecutor, - ); - - expect(result.isError).toBe(false); - expect(result.content[0].type).toBe('image'); - }); - - it('should handle command execution failure', async () => { - const mockExecutor = createMockExecutor({ - success: false, - output: '', - error: 'Simulator not found', - }); - - const result = await screenshotLogic( - { - simulatorId: '12345678-1234-4234-8234-123456789012', - }, - mockExecutor, - createMockFileSystemExecutor(), - ); - - expect(result).toEqual({ - content: [ - { - type: 'text' as const, - text: 'Error: System error executing screenshot: Failed to capture screenshot: Simulator not found', - }, - ], - isError: true, - }); - }); - it('should handle file reading errors', async () => { const mockExecutor = createMockExecutor({ success: true, @@ -308,24 +241,21 @@ describe('Screenshot Plugin', () => { }, }); - const result = await screenshotLogic( - { - simulatorId: '12345678-1234-4234-8234-123456789012', - returnFormat: 'base64', - }, - mockExecutor, - mockFileSystemExecutor, - ); - - expect(result).toEqual({ - content: [ + const result = await runLogic(() => + screenshotLogic( { - type: 'text' as const, - text: 'Error: Screenshot captured but failed to process image file: File not found', + simulatorId: '12345678-1234-4234-8234-123456789012', + returnFormat: 'base64', }, - ], - isError: true, - }); + mockExecutor, + mockFileSystemExecutor, + ), + ); + + 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 () => { @@ -343,26 +273,19 @@ describe('Screenshot Plugin', () => { // which simulates the cleanup failure being caught and logged }); - const result = await screenshotLogic( - { - simulatorId: '12345678-1234-4234-8234-123456789012', - returnFormat: 'base64', - }, - mockExecutor, - mockFileSystemExecutor, + const result = await runLogic(() => + screenshotLogic( + { + simulatorId: '12345678-1234-4234-8234-123456789012', + returnFormat: 'base64', + }, + mockExecutor, + mockFileSystemExecutor, + ), ); // 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 () => { @@ -370,23 +293,18 @@ describe('Screenshot Plugin', () => { throw new SystemError('System error occurred'); }; - const result = await screenshotLogic( - { - simulatorId: '12345678-1234-4234-8234-123456789012', - }, - mockExecutor, - createMockFileSystemExecutor(), - ); - - expect(result).toEqual({ - content: [ + const result = await runLogic(() => + screenshotLogic( { - type: 'text' as const, - text: 'Error: System error executing screenshot: System error occurred', + simulatorId: '12345678-1234-4234-8234-123456789012', }, - ], - isError: true, - }); + mockExecutor, + createMockFileSystemExecutor(), + ), + ); + + expect(result.isError).toBe(true); + expect(allText(result)).toContain('System error executing screenshot: System error occurred'); }); it('should handle unexpected Error objects', async () => { @@ -394,20 +312,18 @@ describe('Screenshot Plugin', () => { throw new Error('Unexpected error'); }; - const result = await screenshotLogic( - { - simulatorId: '12345678-1234-4234-8234-123456789012', - }, - mockExecutor, - createMockFileSystemExecutor(), + const result = await runLogic(() => + screenshotLogic( + { + simulatorId: '12345678-1234-4234-8234-123456789012', + }, + mockExecutor, + 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 () => { @@ -415,20 +331,18 @@ describe('Screenshot Plugin', () => { throw 'String error'; }; - const result = await screenshotLogic( - { - simulatorId: '12345678-1234-4234-8234-123456789012', - }, - mockExecutor, - createMockFileSystemExecutor(), + const result = await runLogic(() => + screenshotLogic( + { + simulatorId: '12345678-1234-4234-8234-123456789012', + }, + mockExecutor, + 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'); }); }); @@ -665,12 +579,14 @@ describe('Screenshot Plugin', () => { readFile: async () => 'fake-image-data', }); - await screenshotLogic( - { simulatorId: '12345678-1234-4234-8234-123456789012' }, - trackingExecutor, - mockFileSystemExecutor, - { tmpdir: () => '/tmp', join: (...paths) => paths.join('/') }, - { v4: () => 'test-uuid' }, + await runLogic(() => + screenshotLogic( + { simulatorId: '12345678-1234-4234-8234-123456789012' }, + trackingExecutor, + mockFileSystemExecutor, + { tmpdir: () => '/tmp', join: (...paths) => paths.join('/') }, + { v4: () => 'test-uuid' }, + ), ); // Verify rotation command was called with +90 degrees (index 3) @@ -729,19 +645,24 @@ describe('Screenshot Plugin', () => { readFile: async () => 'fake-image-data', }); - await screenshotLogic( - { simulatorId: '12345678-1234-4234-8234-123456789012' }, - trackingExecutor, - mockFileSystemExecutor, - { tmpdir: () => '/tmp', join: (...paths) => paths.join('/') }, - { v4: () => 'test-uuid' }, + await runLogic(() => + screenshotLogic( + { simulatorId: '12345678-1234-4234-8234-123456789012' }, + trackingExecutor, + mockFileSystemExecutor, + { tmpdir: () => '/tmp', join: (...paths) => paths.join('/') }, + { 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 () => { @@ -791,18 +712,20 @@ describe('Screenshot Plugin', () => { readFile: async () => 'fake-image-data', }); - const result = await screenshotLogic( - { simulatorId: '12345678-1234-4234-8234-123456789012' }, - trackingExecutor, - mockFileSystemExecutor, - { tmpdir: () => '/tmp', join: (...paths) => paths.join('/') }, - { v4: () => 'test-uuid' }, + const result = await runLogic(() => + screenshotLogic( + { simulatorId: '12345678-1234-4234-8234-123456789012' }, + trackingExecutor, + mockFileSystemExecutor, + { tmpdir: () => '/tmp', join: (...paths) => paths.join('/') }, + { v4: () => 'test-uuid' }, + ), ); // Should still succeed - expect(result.isError).toBe(false); - // Should have: screenshot, list devices, failed orientation detection, optimization - expect(capturedCommands.length).toBe(4); + expect(result.isError).toBeFalsy(); + // 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 () => { @@ -861,17 +784,19 @@ describe('Screenshot Plugin', () => { readFile: async () => 'fake-image-data', }); - const result = await screenshotLogic( - { simulatorId: '12345678-1234-4234-8234-123456789012', returnFormat: 'base64' }, - trackingExecutor, - mockFileSystemExecutor, - { tmpdir: () => '/tmp', join: (...paths) => paths.join('/') }, - { v4: () => 'test-uuid' }, + const result = await runLogic(() => + screenshotLogic( + { simulatorId: '12345678-1234-4234-8234-123456789012', returnFormat: 'base64' }, + trackingExecutor, + mockFileSystemExecutor, + { tmpdir: () => '/tmp', join: (...paths) => paths.join('/') }, + { v4: () => 'test-uuid' }, + ), ); // 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..8dd6fd51 100644 --- a/src/mcp/tools/ui-automation/__tests__/snapshot_ui.test.ts +++ b/src/mcp/tools/ui-automation/__tests__/snapshot_ui.test.ts @@ -1,13 +1,11 @@ -/** - * 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'; +import { allText, runLogic } from '../../../../test-utils/test-helpers.ts'; + describe('Snapshot UI Plugin', () => { describe('Export Field Validation (Literal)', () => { @@ -33,8 +31,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 +42,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 +61,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 @@ -76,12 +70,14 @@ describe('Snapshot UI Plugin', () => { return mockExecutor(...args); }; - const result = await snapshot_uiLogic( - { - simulatorId: '12345678-1234-4234-8234-123456789012', - }, - trackingExecutor, - mockAxeHelpers, + const result = await runLogic(() => + snapshot_uiLogic( + { + simulatorId: '12345678-1234-4234-8234-123456789012', + }, + trackingExecutor, + mockAxeHelpers, + ), ); expect(executorCalls[0]).toEqual([ @@ -91,22 +87,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,34 +106,20 @@ 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( - { - simulatorId: '12345678-1234-4234-8234-123456789012', - }, - createNoopExecutor(), - mockAxeHelpers, - ); - - expect(result).toEqual({ - content: [ + const result = await runLogic(() => + snapshot_uiLogic( { - type: 'text' as const, - text: AXE_NOT_AVAILABLE_MESSAGE, + simulatorId: '12345678-1234-4234-8234-123456789012', }, - ], - isError: true, - }); + createNoopExecutor(), + mockAxeHelpers, + ), + ); + + expect(result.isError).toBe(true); + expect(allText(result)).toContain(AXE_NOT_AVAILABLE_MESSAGE); }); it('should handle AxeError from failed command execution', async () => { @@ -157,29 +134,22 @@ 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( - { - simulatorId: '12345678-1234-4234-8234-123456789012', - }, - mockExecutor, - mockAxeHelpers, - ); - - expect(result).toEqual({ - content: [ + const result = await runLogic(() => + snapshot_uiLogic( { - type: 'text' as const, - text: "Error: Failed to get accessibility hierarchy: axe command 'describe-ui' failed.\nDetails: axe command failed", + simulatorId: '12345678-1234-4234-8234-123456789012', }, - ], - isError: true, - }); + mockExecutor, + mockAxeHelpers, + ), + ); + + 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,31 +159,19 @@ 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( - { - simulatorId: '12345678-1234-4234-8234-123456789012', - }, - mockExecutor, - mockAxeHelpers, - ); - - expect(result).toEqual({ - content: [ + const result = await runLogic(() => + snapshot_uiLogic( { - type: 'text' as const, - text: expect.stringContaining( - 'Error: System error executing axe: Failed to execute axe command: ENOENT: no such file or directory', - ), + simulatorId: '12345678-1234-4234-8234-123456789012', }, - ], - isError: true, - }); + mockExecutor, + mockAxeHelpers, + ), + ); + + expect(result.isError).toBe(true); }); it('should handle unexpected Error objects', async () => { @@ -223,31 +181,19 @@ 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( - { - simulatorId: '12345678-1234-4234-8234-123456789012', - }, - mockExecutor, - mockAxeHelpers, - ); - - expect(result).toEqual({ - content: [ + const result = await runLogic(() => + snapshot_uiLogic( { - type: 'text' as const, - text: expect.stringContaining( - 'Error: System error executing axe: Failed to execute axe command: Unexpected error', - ), + simulatorId: '12345678-1234-4234-8234-123456789012', }, - ], - isError: true, - }); + mockExecutor, + mockAxeHelpers, + ), + ); + + expect(result.isError).toBe(true); }); it('should handle unexpected string errors', async () => { @@ -257,29 +203,22 @@ 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( - { - simulatorId: '12345678-1234-4234-8234-123456789012', - }, - mockExecutor, - mockAxeHelpers, - ); - - expect(result).toEqual({ - content: [ + const result = await runLogic(() => + snapshot_uiLogic( { - type: 'text' as const, - text: 'Error: System error executing axe: Failed to execute axe command: String error', + simulatorId: '12345678-1234-4234-8234-123456789012', }, - ], - isError: true, - }); + mockExecutor, + mockAxeHelpers, + ), + ); + + 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..a0d9d967 100644 --- a/src/mcp/tools/ui-automation/__tests__/swipe.test.ts +++ b/src/mcp/tools/ui-automation/__tests__/swipe.test.ts @@ -1,47 +1,25 @@ -/** - * 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, runLogic } from '../../../../test-utils/test-helpers.ts'; + -// Helper function to create mock axe helpers 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, - }), }; } -// Helper function to create mock axe helpers with null path (for dependency error tests) function createMockAxeHelpersWithNullPath(): AxeHelpers { return { getAxePath: () => null, getBundledAxeEnvironment: () => ({ SOME_ENV: 'value' }), - createAxeNotAvailableResponse: () => ({ - content: [ - { - type: 'text' as const, - text: AXE_NOT_AVAILABLE_MESSAGE, - }, - ], - isError: true, - }), }; } @@ -126,16 +104,18 @@ describe('Swipe Tool', () => { const mockAxeHelpers = createMockAxeHelpers(); - await swipeLogic( - { - simulatorId: '12345678-1234-4234-8234-123456789012', - x1: 100, - y1: 200, - x2: 300, - y2: 400, - }, - trackingExecutor, - mockAxeHelpers, + await runLogic(() => + swipeLogic( + { + simulatorId: '12345678-1234-4234-8234-123456789012', + x1: 100, + y1: 200, + x2: 300, + y2: 400, + }, + trackingExecutor, + mockAxeHelpers, + ), ); expect(capturedCommand).toEqual([ @@ -168,17 +148,19 @@ describe('Swipe Tool', () => { const mockAxeHelpers = createMockAxeHelpers(); - await swipeLogic( - { - simulatorId: '12345678-1234-4234-8234-123456789012', - x1: 50, - y1: 75, - x2: 250, - y2: 350, - duration: 1.5, - }, - trackingExecutor, - mockAxeHelpers, + await runLogic(() => + swipeLogic( + { + simulatorId: '12345678-1234-4234-8234-123456789012', + x1: 50, + y1: 75, + x2: 250, + y2: 350, + duration: 1.5, + }, + trackingExecutor, + mockAxeHelpers, + ), ); expect(capturedCommand).toEqual([ @@ -213,20 +195,22 @@ describe('Swipe Tool', () => { const mockAxeHelpers = createMockAxeHelpers(); - await swipeLogic( - { - simulatorId: '12345678-1234-4234-8234-123456789012', - x1: 0, - y1: 0, - x2: 500, - y2: 800, - duration: 2.0, - delta: 10, - preDelay: 0.5, - postDelay: 0.3, - }, - trackingExecutor, - mockAxeHelpers, + await runLogic(() => + swipeLogic( + { + simulatorId: '12345678-1234-4234-8234-123456789012', + x1: 0, + y1: 0, + x2: 500, + y2: 800, + duration: 2.0, + delta: 10, + preDelay: 0.5, + postDelay: 0.3, + }, + trackingExecutor, + mockAxeHelpers, + ), ); expect(capturedCommand).toEqual([ @@ -268,23 +252,21 @@ 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( - { - simulatorId: 'ABCDEF12-3456-7890-ABCD-ABCDEFABCDEF', - x1: 150, - y1: 250, - x2: 400, - y2: 600, - delta: 5, - }, - trackingExecutor, - mockAxeHelpers, + await runLogic(() => + swipeLogic( + { + simulatorId: 'ABCDEF12-3456-7890-ABCD-ABCDEFABCDEF', + x1: 150, + y1: 250, + x2: 400, + y2: 600, + delta: 5, + }, + trackingExecutor, + mockAxeHelpers, + ), ); expect(capturedCommand).toEqual([ @@ -312,9 +294,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 +310,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 () => { @@ -343,27 +323,24 @@ describe('Swipe Tool', () => { const mockAxeHelpers = createMockAxeHelpers(); - const result = await swipeLogic( - { - simulatorId: '12345678-1234-4234-8234-123456789012', - x1: 100, - y1: 200, - x2: 300, - y2: 400, - }, - mockExecutor, - mockAxeHelpers, - ); - - expect(result).toEqual({ - content: [ + const result = await runLogic(() => + swipeLogic( { - 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.', + simulatorId: '12345678-1234-4234-8234-123456789012', + x1: 100, + y1: 200, + x2: 300, + y2: 400, }, - ], - isError: false, - }); + mockExecutor, + mockAxeHelpers, + ), + ); + + 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 () => { @@ -375,28 +352,25 @@ describe('Swipe Tool', () => { const mockAxeHelpers = createMockAxeHelpers(); - const result = await swipeLogic( - { - simulatorId: '12345678-1234-4234-8234-123456789012', - x1: 100, - y1: 200, - x2: 300, - y2: 400, - duration: 1.5, - }, - mockExecutor, - mockAxeHelpers, - ); - - expect(result).toEqual({ - content: [ + const result = await runLogic(() => + swipeLogic( { - 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.', + simulatorId: '12345678-1234-4234-8234-123456789012', + x1: 100, + y1: 200, + x2: 300, + y2: 400, + duration: 1.5, }, - ], - isError: false, - }); + mockExecutor, + mockAxeHelpers, + ), + ); + + 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 () => { @@ -408,27 +382,22 @@ describe('Swipe Tool', () => { const mockAxeHelpers = createMockAxeHelpersWithNullPath(); - const result = await swipeLogic( - { - simulatorId: '12345678-1234-4234-8234-123456789012', - x1: 100, - y1: 200, - x2: 300, - y2: 400, - }, - mockExecutor, - mockAxeHelpers, - ); - - expect(result).toEqual({ - content: [ + const result = await runLogic(() => + swipeLogic( { - type: 'text' as const, - text: AXE_NOT_AVAILABLE_MESSAGE, + simulatorId: '12345678-1234-4234-8234-123456789012', + x1: 100, + y1: 200, + x2: 300, + y2: 400, }, - ], - isError: true, - }); + mockExecutor, + mockAxeHelpers, + ), + ); + + expect(result.isError).toBe(true); + expect(allText(result)).toContain(AXE_NOT_AVAILABLE_MESSAGE); }); it('should handle AxeError from failed command execution', async () => { @@ -440,27 +409,22 @@ describe('Swipe Tool', () => { const mockAxeHelpers = createMockAxeHelpers(); - const result = await swipeLogic( - { - simulatorId: '12345678-1234-4234-8234-123456789012', - x1: 100, - y1: 200, - x2: 300, - y2: 400, - }, - mockExecutor, - mockAxeHelpers, - ); - - expect(result).toEqual({ - content: [ + const result = await runLogic(() => + swipeLogic( { - type: 'text' as const, - text: "Error: Failed to simulate swipe: axe command 'swipe' failed.\nDetails: axe command failed", + simulatorId: '12345678-1234-4234-8234-123456789012', + x1: 100, + y1: 200, + x2: 300, + y2: 400, }, - ], - isError: true, - }); + mockExecutor, + mockAxeHelpers, + ), + ); + + 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 () => { @@ -471,24 +435,24 @@ describe('Swipe Tool', () => { const mockAxeHelpers = createMockAxeHelpers(); - const result = await swipeLogic( - { - simulatorId: '12345678-1234-4234-8234-123456789012', - x1: 100, - y1: 200, - x2: 300, - y2: 400, - }, - systemErrorExecutor, - mockAxeHelpers, + const result = await runLogic(() => + swipeLogic( + { + simulatorId: '12345678-1234-4234-8234-123456789012', + x1: 100, + y1: 200, + x2: 300, + y2: 400, + }, + systemErrorExecutor, + mockAxeHelpers, + ), ); 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 () => { @@ -499,24 +463,24 @@ describe('Swipe Tool', () => { const mockAxeHelpers = createMockAxeHelpers(); - const result = await swipeLogic( - { - simulatorId: '12345678-1234-4234-8234-123456789012', - x1: 100, - y1: 200, - x2: 300, - y2: 400, - }, - unexpectedErrorExecutor, - mockAxeHelpers, + const result = await runLogic(() => + swipeLogic( + { + simulatorId: '12345678-1234-4234-8234-123456789012', + x1: 100, + y1: 200, + x2: 300, + y2: 400, + }, + unexpectedErrorExecutor, + mockAxeHelpers, + ), ); 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 () => { @@ -527,27 +491,24 @@ describe('Swipe Tool', () => { const mockAxeHelpers = createMockAxeHelpers(); - const result = await swipeLogic( - { - simulatorId: '12345678-1234-4234-8234-123456789012', - x1: 100, - y1: 200, - x2: 300, - y2: 400, - }, - stringErrorExecutor, - mockAxeHelpers, - ); - - expect(result).toEqual({ - content: [ + const result = await runLogic(() => + swipeLogic( { - type: 'text' as const, - text: 'Error: System error executing axe: Failed to execute axe command: String error', + simulatorId: '12345678-1234-4234-8234-123456789012', + x1: 100, + y1: 200, + x2: 300, + y2: 400, }, - ], - isError: true, - }); + stringErrorExecutor, + mockAxeHelpers, + ), + ); + + 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..62935081 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,38 +5,20 @@ 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, runLogic } from '../../../../test-utils/test-helpers.ts'; + -// Helper function to create mock axe helpers function createMockAxeHelpers(): AxeHelpers { return { getAxePath: () => '/mocked/axe/path', getBundledAxeEnvironment: () => ({ SOME_ENV: 'value' }), - createAxeNotAvailableResponse: () => ({ - content: [ - { - type: 'text', - text: AXE_NOT_AVAILABLE_MESSAGE, - }, - ], - isError: true, - }), }; } -// Helper function to create mock axe helpers with null path (for dependency error tests) function createMockAxeHelpersWithNullPath(): AxeHelpers { return { getAxePath: () => null, getBundledAxeEnvironment: () => ({ SOME_ENV: 'value' }), - createAxeNotAvailableResponse: () => ({ - content: [ - { - type: 'text', - text: AXE_NOT_AVAILABLE_MESSAGE, - }, - ], - isError: true, - }), }; } @@ -148,14 +126,16 @@ describe('Tap Plugin', () => { const mockAxeHelpers = createMockAxeHelpers(); - await tapLogic( - { - simulatorId: '12345678-1234-4234-8234-123456789012', - x: 100, - y: 200, - }, - wrappedExecutor, - mockAxeHelpers, + await runLogic(() => + tapLogic( + { + simulatorId: '12345678-1234-4234-8234-123456789012', + x: 100, + y: 200, + }, + wrappedExecutor, + mockAxeHelpers, + ), ); expect(callHistory).toHaveLength(1); @@ -194,13 +174,15 @@ describe('Tap Plugin', () => { const mockAxeHelpers = createMockAxeHelpers(); - await tapLogic( - { - simulatorId: '12345678-1234-4234-8234-123456789012', - id: 'loginButton', - }, - wrappedExecutor, - mockAxeHelpers, + await runLogic(() => + tapLogic( + { + simulatorId: '12345678-1234-4234-8234-123456789012', + id: 'loginButton', + }, + wrappedExecutor, + mockAxeHelpers, + ), ); expect(callHistory).toHaveLength(1); @@ -237,13 +219,15 @@ describe('Tap Plugin', () => { const mockAxeHelpers = createMockAxeHelpers(); - await tapLogic( - { - simulatorId: '12345678-1234-4234-8234-123456789012', - label: 'Log in', - }, - wrappedExecutor, - mockAxeHelpers, + await runLogic(() => + tapLogic( + { + simulatorId: '12345678-1234-4234-8234-123456789012', + label: 'Log in', + }, + wrappedExecutor, + mockAxeHelpers, + ), ); expect(callHistory).toHaveLength(1); @@ -280,15 +264,17 @@ describe('Tap Plugin', () => { const mockAxeHelpers = createMockAxeHelpers(); - await tapLogic( - { - simulatorId: '12345678-1234-4234-8234-123456789012', - x: 120, - y: 240, - id: 'loginButton', - }, - wrappedExecutor, - mockAxeHelpers, + await runLogic(() => + tapLogic( + { + simulatorId: '12345678-1234-4234-8234-123456789012', + x: 120, + y: 240, + id: 'loginButton', + }, + wrappedExecutor, + mockAxeHelpers, + ), ); expect(callHistory).toHaveLength(1); @@ -327,15 +313,17 @@ describe('Tap Plugin', () => { const mockAxeHelpers = createMockAxeHelpers(); - await tapLogic( - { - simulatorId: '12345678-1234-4234-8234-123456789012', - x: 150, - y: 300, - preDelay: 0.5, - }, - wrappedExecutor, - mockAxeHelpers, + await runLogic(() => + tapLogic( + { + simulatorId: '12345678-1234-4234-8234-123456789012', + x: 150, + y: 300, + preDelay: 0.5, + }, + wrappedExecutor, + mockAxeHelpers, + ), ); expect(callHistory).toHaveLength(1); @@ -376,15 +364,17 @@ describe('Tap Plugin', () => { const mockAxeHelpers = createMockAxeHelpers(); - await tapLogic( - { - simulatorId: '12345678-1234-4234-8234-123456789012', - x: 250, - y: 400, - postDelay: 1.0, - }, - wrappedExecutor, - mockAxeHelpers, + await runLogic(() => + tapLogic( + { + simulatorId: '12345678-1234-4234-8234-123456789012', + x: 250, + y: 400, + postDelay: 1.0, + }, + wrappedExecutor, + mockAxeHelpers, + ), ); expect(callHistory).toHaveLength(1); @@ -425,16 +415,18 @@ describe('Tap Plugin', () => { const mockAxeHelpers = createMockAxeHelpers(); - await tapLogic( - { - simulatorId: '12345678-1234-4234-8234-123456789012', - x: 350, - y: 500, - preDelay: 0.3, - postDelay: 0.7, - }, - wrappedExecutor, - mockAxeHelpers, + await runLogic(() => + tapLogic( + { + simulatorId: '12345678-1234-4234-8234-123456789012', + x: 350, + y: 500, + preDelay: 0.3, + postDelay: 0.7, + }, + wrappedExecutor, + mockAxeHelpers, + ), ); expect(callHistory).toHaveLength(1); @@ -460,211 +452,6 @@ describe('Tap Plugin', () => { }); }); - describe('Success Response Processing', () => { - it('should return successful response for basic tap', async () => { - const mockExecutor = createMockExecutor({ - success: true, - output: 'Tap completed', - }); - - const mockAxeHelpers = createMockAxeHelpers(); - - const result = await tapLogic( - { - simulatorId: '12345678-1234-4234-8234-123456789012', - x: 100, - y: 200, - }, - mockExecutor, - 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, - }); - }); - - it('should return successful response with coordinate warning when snapshot_ui not called', async () => { - const mockExecutor = createMockExecutor({ - success: true, - output: 'Tap completed', - }); - - const mockAxeHelpers = createMockAxeHelpers(); - - const result = await tapLogic( - { - simulatorId: '87654321-4321-4321-4321-210987654321', - x: 150, - y: 300, - }, - mockExecutor, - 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, - }); - }); - - it('should return successful response with delays', async () => { - const mockExecutor = createMockExecutor({ - success: true, - output: 'Tap completed', - }); - - const mockAxeHelpers = createMockAxeHelpers(); - - const result = await tapLogic( - { - simulatorId: '12345678-1234-4234-8234-123456789012', - x: 250, - y: 400, - preDelay: 0.5, - postDelay: 1.0, - }, - mockExecutor, - 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, - }); - }); - - it('should return successful response with integer coordinates', async () => { - const mockExecutor = createMockExecutor({ - success: true, - output: 'Tap completed', - }); - - const mockAxeHelpers = createMockAxeHelpers(); - - const result = await tapLogic( - { - simulatorId: '12345678-1234-4234-8234-123456789012', - x: 0, - y: 0, - }, - mockExecutor, - 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, - }); - }); - - it('should return successful response with large coordinates', async () => { - const mockExecutor = createMockExecutor({ - success: true, - output: 'Tap completed', - }); - - const mockAxeHelpers = createMockAxeHelpers(); - - const result = await tapLogic( - { - simulatorId: '12345678-1234-4234-8234-123456789012', - x: 1920, - y: 1080, - }, - mockExecutor, - 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, - }); - }); - - it('should return successful response for element id target', async () => { - const mockExecutor = createMockExecutor({ - success: true, - output: 'Tap completed', - }); - - const mockAxeHelpers = createMockAxeHelpers(); - - const result = await tapLogic( - { - simulatorId: '12345678-1234-4234-8234-123456789012', - id: 'loginButton', - }, - mockExecutor, - mockAxeHelpers, - ); - - expect(result).toEqual({ - content: [ - { - type: 'text', - text: 'Tap on element id "loginButton" simulated successfully.', - }, - ], - isError: false, - }); - }); - - it('should return successful response for element label target', async () => { - const mockExecutor = createMockExecutor({ - success: true, - output: 'Tap completed', - }); - - const mockAxeHelpers = createMockAxeHelpers(); - - const result = await tapLogic( - { - simulatorId: '12345678-1234-4234-8234-123456789012', - label: 'Log in', - }, - mockExecutor, - mockAxeHelpers, - ); - - expect(result).toEqual({ - content: [ - { - type: 'text', - text: 'Tap on element label "Log in" simulated successfully.', - }, - ], - isError: false, - }); - }); - }); - describe('Plugin Handler Validation', () => { it('should require simulatorId session default when not provided', async () => { const result = await handler({ @@ -788,27 +575,22 @@ describe('Tap Plugin', () => { const mockAxeHelpers = createMockAxeHelpersWithNullPath(); - const result = await tapLogic( - { - simulatorId: '12345678-1234-4234-8234-123456789012', - x: 100, - y: 200, - preDelay: 0.5, - postDelay: 1.0, - }, - mockExecutor, - mockAxeHelpers, - ); - - expect(result).toEqual({ - content: [ + const result = await runLogic(() => + tapLogic( { - type: 'text', - text: AXE_NOT_AVAILABLE_MESSAGE, + simulatorId: '12345678-1234-4234-8234-123456789012', + x: 100, + y: 200, + preDelay: 0.5, + postDelay: 1.0, }, - ], - isError: true, - }); + mockExecutor, + mockAxeHelpers, + ), + ); + + 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 () => { @@ -820,25 +602,20 @@ describe('Tap Plugin', () => { const mockAxeHelpers = createMockAxeHelpersWithNullPath(); - const result = await tapLogic( - { - simulatorId: '12345678-1234-4234-8234-123456789012', - x: 100, - y: 200, - }, - mockExecutor, - mockAxeHelpers, - ); - - expect(result).toEqual({ - content: [ + const result = await runLogic(() => + tapLogic( { - type: 'text', - text: AXE_NOT_AVAILABLE_MESSAGE, + simulatorId: '12345678-1234-4234-8234-123456789012', + x: 100, + y: 200, }, - ], - isError: true, - }); + mockExecutor, + mockAxeHelpers, + ), + ); + + 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 () => { @@ -850,25 +627,20 @@ describe('Tap Plugin', () => { const mockAxeHelpers = createMockAxeHelpersWithNullPath(); - const result = await tapLogic( - { - simulatorId: '12345678-1234-4234-8234-123456789012', - x: 100, - y: 200, - }, - mockExecutor, - mockAxeHelpers, - ); - - expect(result).toEqual({ - content: [ + const result = await runLogic(() => + tapLogic( { - type: 'text', - text: AXE_NOT_AVAILABLE_MESSAGE, + simulatorId: '12345678-1234-4234-8234-123456789012', + x: 100, + y: 200, }, - ], - isError: true, - }); + mockExecutor, + mockAxeHelpers, + ), + ); + + 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 () => { @@ -878,25 +650,20 @@ describe('Tap Plugin', () => { const mockAxeHelpers = createMockAxeHelpersWithNullPath(); - const result = await tapLogic( - { - simulatorId: '12345678-1234-4234-8234-123456789012', - x: 100, - y: 200, - }, - mockExecutor, - mockAxeHelpers, - ); - - expect(result).toEqual({ - content: [ + const result = await runLogic(() => + tapLogic( { - type: 'text', - text: AXE_NOT_AVAILABLE_MESSAGE, + simulatorId: '12345678-1234-4234-8234-123456789012', + x: 100, + y: 200, }, - ], - isError: true, - }); + mockExecutor, + mockAxeHelpers, + ), + ); + + 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 () => { @@ -906,25 +673,20 @@ describe('Tap Plugin', () => { const mockAxeHelpers = createMockAxeHelpersWithNullPath(); - const result = await tapLogic( - { - simulatorId: '12345678-1234-4234-8234-123456789012', - x: 100, - y: 200, - }, - mockExecutor, - mockAxeHelpers, - ); - - expect(result).toEqual({ - content: [ + const result = await runLogic(() => + tapLogic( { - type: 'text', - text: AXE_NOT_AVAILABLE_MESSAGE, + simulatorId: '12345678-1234-4234-8234-123456789012', + x: 100, + y: 200, }, - ], - isError: true, - }); + mockExecutor, + mockAxeHelpers, + ), + ); + + 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 () => { @@ -934,25 +696,20 @@ describe('Tap Plugin', () => { const mockAxeHelpers = createMockAxeHelpersWithNullPath(); - const result = await tapLogic( - { - simulatorId: '12345678-1234-4234-8234-123456789012', - x: 100, - y: 200, - }, - mockExecutor, - mockAxeHelpers, - ); - - expect(result).toEqual({ - content: [ + const result = await runLogic(() => + tapLogic( { - type: 'text', - text: AXE_NOT_AVAILABLE_MESSAGE, + simulatorId: '12345678-1234-4234-8234-123456789012', + x: 100, + y: 200, }, - ], - isError: true, - }); + mockExecutor, + mockAxeHelpers, + ), + ); + + 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..2816ea4a 100644 --- a/src/mcp/tools/ui-automation/__tests__/touch.test.ts +++ b/src/mcp/tools/ui-automation/__tests__/touch.test.ts @@ -1,14 +1,11 @@ -/** - * 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'; +import { allText, runLogic } from '../../../../test-utils/test-helpers.ts'; + describe('Touch Plugin', () => { beforeEach(() => { @@ -121,26 +118,19 @@ describe('Touch Plugin', () => { const mockAxeHelpers = { getAxePath: () => '/usr/local/bin/axe', getBundledAxeEnvironment: () => ({}), - createAxeNotAvailableResponse: () => ({ - content: [ - { - type: 'text', - text: AXE_NOT_AVAILABLE_MESSAGE, - }, - ], - isError: true, - }), }; - await touchLogic( - { - simulatorId: '12345678-1234-4234-8234-123456789012', - x: 100, - y: 200, - down: true, - }, - trackingExecutor, - mockAxeHelpers, + await runLogic(() => + touchLogic( + { + simulatorId: '12345678-1234-4234-8234-123456789012', + x: 100, + y: 200, + down: true, + }, + trackingExecutor, + mockAxeHelpers, + ), ); expect(capturedCommand).toEqual([ @@ -171,26 +161,19 @@ describe('Touch Plugin', () => { const mockAxeHelpers = { getAxePath: () => '/usr/local/bin/axe', getBundledAxeEnvironment: () => ({}), - createAxeNotAvailableResponse: () => ({ - content: [ - { - type: 'text', - text: AXE_NOT_AVAILABLE_MESSAGE, - }, - ], - isError: true, - }), }; - await touchLogic( - { - simulatorId: '12345678-1234-4234-8234-123456789012', - x: 150, - y: 250, - up: true, - }, - trackingExecutor, - mockAxeHelpers, + await runLogic(() => + touchLogic( + { + simulatorId: '12345678-1234-4234-8234-123456789012', + x: 150, + y: 250, + up: true, + }, + trackingExecutor, + mockAxeHelpers, + ), ); expect(capturedCommand).toEqual([ @@ -221,27 +204,20 @@ describe('Touch Plugin', () => { const mockAxeHelpers = { getAxePath: () => '/usr/local/bin/axe', getBundledAxeEnvironment: () => ({}), - createAxeNotAvailableResponse: () => ({ - content: [ - { - type: 'text', - text: AXE_NOT_AVAILABLE_MESSAGE, - }, - ], - isError: true, - }), }; - await touchLogic( - { - simulatorId: '12345678-1234-4234-8234-123456789012', - x: 300, - y: 400, - down: true, - up: true, - }, - trackingExecutor, - mockAxeHelpers, + await runLogic(() => + touchLogic( + { + simulatorId: '12345678-1234-4234-8234-123456789012', + x: 300, + y: 400, + down: true, + up: true, + }, + trackingExecutor, + mockAxeHelpers, + ), ); expect(capturedCommand).toEqual([ @@ -273,28 +249,21 @@ describe('Touch Plugin', () => { const mockAxeHelpers = { getAxePath: () => '/usr/local/bin/axe', getBundledAxeEnvironment: () => ({}), - createAxeNotAvailableResponse: () => ({ - content: [ - { - type: 'text', - text: AXE_NOT_AVAILABLE_MESSAGE, - }, - ], - isError: true, - }), }; - await touchLogic( - { - simulatorId: '12345678-1234-4234-8234-123456789012', - x: 50, - y: 75, - down: true, - up: true, - delay: 1.5, - }, - trackingExecutor, - mockAxeHelpers, + await runLogic(() => + touchLogic( + { + simulatorId: '12345678-1234-4234-8234-123456789012', + x: 50, + y: 75, + down: true, + up: true, + delay: 1.5, + }, + trackingExecutor, + mockAxeHelpers, + ), ); expect(capturedCommand).toEqual([ @@ -330,16 +299,18 @@ describe('Touch Plugin', () => { getBundledAxeEnvironment: () => ({ AXE_PATH: '/some/path' }), }; - await touchLogic( - { - simulatorId: 'ABCDEF12-3456-7890-ABCD-ABCDEFABCDEF', - x: 0, - y: 0, - up: true, - delay: 0.5, - }, - trackingExecutor, - mockAxeHelpers, + await runLogic(() => + touchLogic( + { + simulatorId: 'ABCDEF12-3456-7890-ABCD-ABCDEFABCDEF', + x: 0, + y: 0, + up: true, + delay: 0.5, + }, + trackingExecutor, + mockAxeHelpers, + ), ); expect(capturedCommand).toEqual([ @@ -364,37 +335,23 @@ describe('Touch Plugin', () => { const mockAxeHelpers = { getAxePath: () => null, getBundledAxeEnvironment: () => ({}), - createAxeNotAvailableResponse: () => ({ - content: [ - { - type: 'text', - text: AXE_NOT_AVAILABLE_MESSAGE, - }, - ], - isError: true, - }), }; - const result = await touchLogic( - { - simulatorId: '12345678-1234-4234-8234-123456789012', - x: 100, - y: 200, - down: true, - }, - mockExecutor, - mockAxeHelpers, - ); - - expect(result).toEqual({ - content: [ + const result = await runLogic(() => + touchLogic( { - type: 'text', - text: AXE_NOT_AVAILABLE_MESSAGE, + simulatorId: '12345678-1234-4234-8234-123456789012', + x: 100, + y: 200, + down: true, }, - ], - isError: true, - }); + mockExecutor, + mockAxeHelpers, + ), + ); + + expect(result.isError).toBe(true); + expect(allText(result)).toContain(AXE_NOT_AVAILABLE_MESSAGE); }); it('should successfully perform touch down', async () => { @@ -402,37 +359,25 @@ 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( - { - simulatorId: '12345678-1234-4234-8234-123456789012', - x: 100, - y: 200, - down: true, - }, - mockExecutor, - mockAxeHelpers, - ); - - expect(result).toEqual({ - content: [ + const result = await runLogic(() => + touchLogic( { - 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.', + simulatorId: '12345678-1234-4234-8234-123456789012', + x: 100, + y: 200, + down: true, }, - ], - isError: false, - }); + mockExecutor, + mockAxeHelpers, + ), + ); + + 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,55 +385,43 @@ 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( - { - simulatorId: '12345678-1234-4234-8234-123456789012', - x: 100, - y: 200, - up: true, - }, - mockExecutor, - mockAxeHelpers, - ); - - expect(result).toEqual({ - content: [ + const result = await runLogic(() => + touchLogic( { - 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.', + simulatorId: '12345678-1234-4234-8234-123456789012', + x: 100, + y: 200, + up: true, }, - ], - isError: false, - }); + mockExecutor, + mockAxeHelpers, + ), + ); + + 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 () => { const mockExecutor = createMockExecutor({ success: true }); - const result = await touchLogic( - { - simulatorId: '12345678-1234-4234-8234-123456789012', - x: 100, - y: 200, - }, - mockExecutor, + const result = await runLogic(() => + touchLogic( + { + simulatorId: '12345678-1234-4234-8234-123456789012', + x: 100, + y: 200, + }, + 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,37 +434,25 @@ 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( - { - simulatorId: '12345678-1234-4234-8234-123456789012', - x: 100, - y: 200, - down: true, - }, - mockExecutor, - mockAxeHelpers, - ); - - expect(result).toEqual({ - content: [ + const result = await runLogic(() => + touchLogic( { - 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.', + simulatorId: '12345678-1234-4234-8234-123456789012', + x: 100, + y: 200, + down: true, }, - ], - isError: false, - }); + mockExecutor, + mockAxeHelpers, + ), + ); + + 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,37 +465,25 @@ 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( - { - simulatorId: '12345678-1234-4234-8234-123456789012', - x: 100, - y: 200, - up: true, - }, - mockExecutor, - mockAxeHelpers, - ); - - expect(result).toEqual({ - content: [ + const result = await runLogic(() => + touchLogic( { - 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.', + simulatorId: '12345678-1234-4234-8234-123456789012', + x: 100, + y: 200, + up: true, }, - ], - isError: false, - }); + mockExecutor, + mockAxeHelpers, + ), + ); + + 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,38 +496,26 @@ 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( - { - simulatorId: '12345678-1234-4234-8234-123456789012', - x: 100, - y: 200, - down: true, - up: true, - }, - mockExecutor, - mockAxeHelpers, - ); - - expect(result).toEqual({ - content: [ + const result = await runLogic(() => + touchLogic( { - 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.', + simulatorId: '12345678-1234-4234-8234-123456789012', + x: 100, + y: 200, + down: true, + up: true, }, - ], - isError: false, - }); + mockExecutor, + mockAxeHelpers, + ), + ); + + 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,37 +524,23 @@ describe('Touch Plugin', () => { const mockAxeHelpers = { getAxePath: () => null, getBundledAxeEnvironment: () => ({}), - createAxeNotAvailableResponse: () => ({ - content: [ - { - type: 'text', - text: AXE_NOT_AVAILABLE_MESSAGE, - }, - ], - isError: true, - }), }; - const result = await touchLogic( - { - simulatorId: '12345678-1234-4234-8234-123456789012', - x: 100, - y: 200, - down: true, - }, - mockExecutor, - mockAxeHelpers, - ); - - expect(result).toEqual({ - content: [ + const result = await runLogic(() => + touchLogic( { - type: 'text', - text: AXE_NOT_AVAILABLE_MESSAGE, + simulatorId: '12345678-1234-4234-8234-123456789012', + x: 100, + y: 200, + down: true, }, - ], - isError: true, - }); + mockExecutor, + mockAxeHelpers, + ), + ); + + expect(result.isError).toBe(true); + expect(allText(result)).toContain(AXE_NOT_AVAILABLE_MESSAGE); }); it('should handle AxeError from failed command execution', async () => { @@ -670,37 +553,25 @@ 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( - { - simulatorId: '12345678-1234-4234-8234-123456789012', - x: 100, - y: 200, - down: true, - }, - mockExecutor, - mockAxeHelpers, - ); - - expect(result).toEqual({ - content: [ + const result = await runLogic(() => + touchLogic( { - type: 'text', - text: "Error: Failed to execute touch event: axe command 'touch' failed.\nDetails: axe command failed", + simulatorId: '12345678-1234-4234-8234-123456789012', + x: 100, + y: 200, + down: true, }, - ], - isError: true, - }); + mockExecutor, + mockAxeHelpers, + ), + ); + + 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,39 +582,22 @@ 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( - { - simulatorId: '12345678-1234-4234-8234-123456789012', - x: 100, - y: 200, - down: true, - }, - mockExecutor, - mockAxeHelpers, - ); - - expect(result).toMatchObject({ - content: [ + const result = await runLogic(() => + touchLogic( { - type: 'text', - text: expect.stringContaining( - 'Error: System error executing axe: Failed to execute axe command: System error occurred', - ), + simulatorId: '12345678-1234-4234-8234-123456789012', + x: 100, + y: 200, + down: true, }, - ], - isError: true, - }); + mockExecutor, + mockAxeHelpers, + ), + ); + + expect(result.isError).toBe(true); }); it('should handle unexpected Error objects', async () => { @@ -754,39 +608,22 @@ 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( - { - simulatorId: '12345678-1234-4234-8234-123456789012', - x: 100, - y: 200, - down: true, - }, - mockExecutor, - mockAxeHelpers, - ); - - expect(result).toMatchObject({ - content: [ + const result = await runLogic(() => + touchLogic( { - type: 'text', - text: expect.stringContaining( - 'Error: System error executing axe: Failed to execute axe command: Unexpected error', - ), + simulatorId: '12345678-1234-4234-8234-123456789012', + x: 100, + y: 200, + down: true, }, - ], - isError: true, - }); + mockExecutor, + mockAxeHelpers, + ), + ); + + expect(result.isError).toBe(true); }); it('should handle unexpected string errors', async () => { @@ -797,37 +634,25 @@ 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( - { - simulatorId: '12345678-1234-4234-8234-123456789012', - x: 100, - y: 200, - down: true, - }, - mockExecutor, - mockAxeHelpers, - ); - - expect(result).toEqual({ - content: [ + const result = await runLogic(() => + touchLogic( { - type: 'text', - text: 'Error: System error executing axe: Failed to execute axe command: String error', + simulatorId: '12345678-1234-4234-8234-123456789012', + x: 100, + y: 200, + down: true, }, - ], - isError: true, - }); + mockExecutor, + mockAxeHelpers, + ), + ); + + 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..07599c2e 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,8 @@ 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, runLogic } from '../../../../test-utils/test-helpers.ts'; + // Mock axe helpers for dependency injection function createMockAxeHelpers( @@ -24,15 +22,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, - }), }; } @@ -126,13 +115,15 @@ describe('Type Text Tool', () => { getBundledAxeEnvironmentReturn: {}, }); - await type_textLogic( - { - simulatorId: '12345678-1234-4234-8234-123456789012', - text: 'Hello World', - }, - trackingExecutor, - mockAxeHelpers, + await runLogic(() => + type_textLogic( + { + simulatorId: '12345678-1234-4234-8234-123456789012', + text: 'Hello World', + }, + trackingExecutor, + mockAxeHelpers, + ), ); expect(capturedCommand).toEqual([ @@ -161,13 +152,15 @@ describe('Type Text Tool', () => { getBundledAxeEnvironmentReturn: {}, }); - await type_textLogic( - { - simulatorId: '12345678-1234-4234-8234-123456789012', - text: 'user@example.com', - }, - trackingExecutor, - mockAxeHelpers, + await runLogic(() => + type_textLogic( + { + simulatorId: '12345678-1234-4234-8234-123456789012', + text: 'user@example.com', + }, + trackingExecutor, + mockAxeHelpers, + ), ); expect(capturedCommand).toEqual([ @@ -196,13 +189,15 @@ describe('Type Text Tool', () => { getBundledAxeEnvironmentReturn: {}, }); - await type_textLogic( - { - simulatorId: '12345678-1234-4234-8234-123456789012', - text: 'Password123!@#', - }, - trackingExecutor, - mockAxeHelpers, + await runLogic(() => + type_textLogic( + { + simulatorId: '12345678-1234-4234-8234-123456789012', + text: 'Password123!@#', + }, + trackingExecutor, + mockAxeHelpers, + ), ); expect(capturedCommand).toEqual([ @@ -234,13 +229,15 @@ describe('Type Text Tool', () => { const longText = 'This is a very long text that needs to be typed into the simulator for testing purposes.'; - await type_textLogic( - { - simulatorId: '12345678-1234-4234-8234-123456789012', - text: longText, - }, - trackingExecutor, - mockAxeHelpers, + await runLogic(() => + type_textLogic( + { + simulatorId: '12345678-1234-4234-8234-123456789012', + text: longText, + }, + trackingExecutor, + mockAxeHelpers, + ), ); expect(capturedCommand).toEqual([ @@ -269,13 +266,15 @@ describe('Type Text Tool', () => { getBundledAxeEnvironmentReturn: { AXE_PATH: '/some/path' }, }); - await type_textLogic( - { - simulatorId: 'ABCDEF12-3456-7890-ABCD-ABCDEFABCDEF', - text: 'Test message', - }, - trackingExecutor, - mockAxeHelpers, + await runLogic(() => + type_textLogic( + { + simulatorId: 'ABCDEF12-3456-7890-ABCD-ABCDEFABCDEF', + text: 'Test message', + }, + trackingExecutor, + mockAxeHelpers, + ), ); expect(capturedCommand).toEqual([ @@ -294,24 +293,19 @@ describe('Type Text Tool', () => { getAxePathReturn: null, }); - const result = await type_textLogic( - { - simulatorId: '12345678-1234-4234-8234-123456789012', - text: 'Hello World', - }, - createNoopExecutor(), - mockAxeHelpers, - ); - - expect(result).toEqual({ - content: [ + const result = await runLogic(() => + type_textLogic( { - type: 'text', - text: AXE_NOT_AVAILABLE_MESSAGE, + simulatorId: '12345678-1234-4234-8234-123456789012', + text: 'Hello World', }, - ], - isError: true, - }); + createNoopExecutor(), + mockAxeHelpers, + ), + ); + + expect(result.isError).toBe(true); + expect(allText(result)).toContain(AXE_NOT_AVAILABLE_MESSAGE); }); it('should successfully type text', async () => { @@ -325,19 +319,19 @@ describe('Type Text Tool', () => { error: undefined, }); - const result = await type_textLogic( - { - simulatorId: '12345678-1234-4234-8234-123456789012', - text: 'Hello World', - }, - mockExecutor, - mockAxeHelpers, + const result = await runLogic(() => + type_textLogic( + { + simulatorId: '12345678-1234-4234-8234-123456789012', + text: 'Hello World', + }, + mockExecutor, + 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 () => { @@ -352,19 +346,19 @@ describe('Type Text Tool', () => { error: undefined, }); - const result = await type_textLogic( - { - simulatorId: '12345678-1234-4234-8234-123456789012', - text: 'Hello World', - }, - mockExecutor, - mockAxeHelpers, + const result = await runLogic(() => + type_textLogic( + { + simulatorId: '12345678-1234-4234-8234-123456789012', + text: 'Hello World', + }, + mockExecutor, + 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 () => { @@ -372,24 +366,19 @@ describe('Type Text Tool', () => { getAxePathReturn: null, }); - const result = await type_textLogic( - { - simulatorId: '12345678-1234-4234-8234-123456789012', - text: 'Hello World', - }, - createNoopExecutor(), - mockAxeHelpers, - ); - - expect(result).toEqual({ - content: [ + const result = await runLogic(() => + type_textLogic( { - type: 'text', - text: AXE_NOT_AVAILABLE_MESSAGE, + simulatorId: '12345678-1234-4234-8234-123456789012', + text: 'Hello World', }, - ], - isError: true, - }); + createNoopExecutor(), + mockAxeHelpers, + ), + ); + + expect(result.isError).toBe(true); + expect(allText(result)).toContain(AXE_NOT_AVAILABLE_MESSAGE); }); it('should handle AxeError from command execution', async () => { @@ -404,24 +393,21 @@ describe('Type Text Tool', () => { error: 'Text field not found', }); - const result = await type_textLogic( - { - simulatorId: '12345678-1234-4234-8234-123456789012', - text: 'Hello World', - }, - mockExecutor, - mockAxeHelpers, - ); - - expect(result).toEqual({ - content: [ + const result = await runLogic(() => + type_textLogic( { - type: 'text', - text: "Error: Failed to simulate text typing: axe command 'type' failed.\nDetails: Text field not found", + simulatorId: '12345678-1234-4234-8234-123456789012', + text: 'Hello World', }, - ], - isError: true, - }); + mockExecutor, + mockAxeHelpers, + ), + ); + + 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 () => { @@ -432,26 +418,18 @@ describe('Type Text Tool', () => { const mockExecutor = createRejectingExecutor(new Error('ENOENT: no such file or directory')); - const result = await type_textLogic( - { - simulatorId: '12345678-1234-4234-8234-123456789012', - text: 'Hello World', - }, - mockExecutor, - mockAxeHelpers, - ); - - expect(result).toEqual({ - content: [ + const result = await runLogic(() => + type_textLogic( { - type: 'text', - text: expect.stringContaining( - 'Error: System error executing axe: Failed to execute axe command: ENOENT: no such file or directory', - ), + simulatorId: '12345678-1234-4234-8234-123456789012', + text: 'Hello World', }, - ], - isError: true, - }); + mockExecutor, + mockAxeHelpers, + ), + ); + + expect(result.isError).toBe(true); }); it('should handle unexpected Error objects', async () => { @@ -462,26 +440,18 @@ describe('Type Text Tool', () => { const mockExecutor = createRejectingExecutor(new Error('Unexpected error')); - const result = await type_textLogic( - { - simulatorId: '12345678-1234-4234-8234-123456789012', - text: 'Hello World', - }, - mockExecutor, - mockAxeHelpers, - ); - - expect(result).toEqual({ - content: [ + const result = await runLogic(() => + type_textLogic( { - type: 'text', - text: expect.stringContaining( - 'Error: System error executing axe: Failed to execute axe command: Unexpected error', - ), + simulatorId: '12345678-1234-4234-8234-123456789012', + text: 'Hello World', }, - ], - isError: true, - }); + mockExecutor, + mockAxeHelpers, + ), + ); + + expect(result.isError).toBe(true); }); it('should handle unexpected string errors', async () => { @@ -492,24 +462,21 @@ describe('Type Text Tool', () => { const mockExecutor = createRejectingExecutor('String error'); - const result = await type_textLogic( - { - simulatorId: '12345678-1234-4234-8234-123456789012', - text: 'Hello World', - }, - mockExecutor, - mockAxeHelpers, - ); - - expect(result).toEqual({ - content: [ + const result = await runLogic(() => + type_textLogic( { - type: 'text', - text: 'Error: System error executing axe: Failed to execute axe command: String error', + simulatorId: '12345678-1234-4234-8234-123456789012', + text: 'Hello World', }, - ], - isError: true, - }); + mockExecutor, + mockAxeHelpers, + ), + ); + + 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..9be567d9 100644 --- a/src/mcp/tools/ui-automation/button.ts +++ b/src/mcp/tools/ui-automation/button.ts @@ -1,24 +1,22 @@ 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, -} 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, + getHandlerContext, } from '../../../utils/typed-tool-factory.ts'; +import { executeAxeCommand, defaultAxeHelpers } from './shared/axe-command.ts'; +import type { AxeHelpers } from './shared/axe-command.ts'; +import { withErrorHandling } from '../../../utils/tool-error-handling.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,36 +29,33 @@ 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]'; export async function buttonLogic( params: ButtonParams, executor: CommandExecutor, - axeHelpers: AxeHelpers = { - getAxePath, - getBundledAxeEnvironment, - createAxeNotAvailableResponse, - }, + axeHelpers: AxeHelpers = defaultAxeHelpers, debuggerManager: DebuggerManager = getDefaultDebuggerManager(), -): Promise { +): Promise { const toolName = 'button'; const { simulatorId, buttonType, duration } = params; + const headerEvent = header('Button', [{ label: 'Simulator', value: simulatorId }]); + + const ctx = getHandlerContext(); + const guard = await guardUiAutomationAgainstStoppedDebugger({ debugger: debuggerManager, simulatorId, toolName, }); - if (guard.blockedResponse) return guard.blockedResponse; + if (guard.blockedMessage) { + ctx.emit(headerEvent); + ctx.emit(statusLine('error', guard.blockedMessage)); + return; + } const commandArgs = ['button', buttonType]; if (duration !== undefined) { @@ -69,33 +64,42 @@ export async function buttonLogic( log('info', `${LOG_PREFIX}/${toolName}: Starting ${buttonType} button press on ${simulatorId}`); - 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); - } catch (error) { - log('error', `${LOG_PREFIX}/${toolName}: Failed - ${error}`); - if (error instanceof DependencyError) { - return axeHelpers.createAxeNotAvailableResponse(); - } else if (error instanceof AxeError) { - return createErrorResponse( - `Failed to press button '${buttonType}': ${error.message}`, - error.axeOutput, - ); - } else if (error instanceof SystemError) { - return createErrorResponse( - `System error executing axe: ${error.message}`, - error.originalError?.stack, - ); - } - return createErrorResponse( - `An unexpected error occurred: ${error instanceof Error ? error.message : String(error)}`, - ); - } + return withErrorHandling( + ctx, + async () => { + await executeAxeCommand(commandArgs, simulatorId, 'button', executor, axeHelpers); + log('info', `${LOG_PREFIX}/${toolName}: Success for ${simulatorId}`); + ctx.emit(headerEvent); + ctx.emit(statusLine('success', `Hardware button '${buttonType}' pressed successfully.`)); + if (guard.warningText) { + ctx.emit(statusLine('warning', guard.warningText)); + } + }, + { + header: headerEvent, + errorMessage: ({ message }) => `An unexpected error occurred: ${message}`, + logMessage: ({ error }) => `${LOG_PREFIX}/${toolName}: Failed - ${error}`, + mapError: ({ error, headerEvent: hdr, emit }) => { + if (error instanceof DependencyError) { + emit?.(hdr); + emit?.(statusLine('error', AXE_NOT_AVAILABLE_MESSAGE)); + return; + } else if (error instanceof AxeError) { + emit?.(hdr); + emit?.(statusLine('error', `Failed to press button '${buttonType}': ${error.message}`)); + if (error.axeOutput) emit?.(section('Details', [error.axeOutput])); + return; + } else if (error instanceof SystemError) { + emit?.(hdr); + emit?.(statusLine('error', `System error executing axe: ${error.message}`)); + if (error.originalError?.stack) + emit?.(section('Stack Trace', [error.originalError.stack])); + return; + } + return undefined; + }, + }, + ); } const publicSchemaObject = z.strictObject(buttonSchema.omit({ simulatorId: true } as const).shape); @@ -108,75 +112,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, - createAxeNotAvailableResponse, - }), + 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, createAxeNotAvailableResponse }, -): 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 2cc2c66a..4a8f7c9f 100644 --- a/src/mcp/tools/ui-automation/gesture.ts +++ b/src/mcp/tools/ui-automation/gesture.ts @@ -6,31 +6,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'; +import { AXE_NOT_AVAILABLE_MESSAGE } from '../../../utils/axe-helpers.ts'; import { createSessionAwareTool, getSessionAwareToolSchemaShape, + getHandlerContext, } from '../../../utils/typed-tool-factory.ts'; +import { executeAxeCommand, defaultAxeHelpers } from './shared/axe-command.ts'; +import type { AxeHelpers } from './shared/axe-command.ts'; +import { withErrorHandling } from '../../../utils/tool-error-handling.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,36 +78,34 @@ 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]'; export async function gestureLogic( params: GestureParams, executor: CommandExecutor, - axeHelpers: AxeHelpers = { - getAxePath, - getBundledAxeEnvironment, - createAxeNotAvailableResponse, - }, + axeHelpers: AxeHelpers = defaultAxeHelpers, debuggerManager: DebuggerManager = getDefaultDebuggerManager(), -): Promise { +): Promise { const toolName = 'gesture'; const { simulatorId, preset, screenWidth, screenHeight, duration, delta, preDelay, postDelay } = params; + + const headerEvent = header('Gesture', [{ label: 'Simulator', value: simulatorId }]); + + const ctx = getHandlerContext(); + const guard = await guardUiAutomationAgainstStoppedDebugger({ debugger: debuggerManager, simulatorId, toolName, }); - if (guard.blockedResponse) return guard.blockedResponse; + if (guard.blockedMessage) { + ctx.emit(headerEvent); + ctx.emit(statusLine('error', guard.blockedMessage)); + return; + } const commandArgs = ['gesture', preset]; if (screenWidth !== undefined) { @@ -138,33 +129,42 @@ export async function gestureLogic( log('info', `${LOG_PREFIX}/${toolName}: Starting gesture '${preset}' on ${simulatorId}`); - 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); - } catch (error) { - log('error', `${LOG_PREFIX}/${toolName}: Failed - ${error}`); - if (error instanceof DependencyError) { - return axeHelpers.createAxeNotAvailableResponse(); - } else if (error instanceof AxeError) { - return createErrorResponse( - `Failed to execute gesture '${preset}': ${error.message}`, - error.axeOutput, - ); - } else if (error instanceof SystemError) { - return createErrorResponse( - `System error executing axe: ${error.message}`, - error.originalError?.stack, - ); - } - return createErrorResponse( - `An unexpected error occurred: ${error instanceof Error ? error.message : String(error)}`, - ); - } + return withErrorHandling( + ctx, + async () => { + await executeAxeCommand(commandArgs, simulatorId, 'gesture', executor, axeHelpers); + log('info', `${LOG_PREFIX}/${toolName}: Success for ${simulatorId}`); + ctx.emit(headerEvent); + ctx.emit(statusLine('success', `Gesture '${preset}' executed successfully.`)); + if (guard.warningText) { + ctx.emit(statusLine('warning', guard.warningText)); + } + }, + { + header: headerEvent, + errorMessage: ({ message }) => `An unexpected error occurred: ${message}`, + logMessage: ({ error }) => `${LOG_PREFIX}/${toolName}: Failed - ${error}`, + mapError: ({ error, headerEvent: hdr, emit }) => { + if (error instanceof DependencyError) { + emit?.(hdr); + emit?.(statusLine('error', AXE_NOT_AVAILABLE_MESSAGE)); + return; + } else if (error instanceof AxeError) { + emit?.(hdr); + emit?.(statusLine('error', `Failed to execute gesture '${preset}': ${error.message}`)); + if (error.axeOutput) emit?.(section('Details', [error.axeOutput])); + return; + } else if (error instanceof SystemError) { + emit?.(hdr); + emit?.(statusLine('error', `System error executing axe: ${error.message}`)); + if (error.originalError?.stack) + emit?.(section('Stack Trace', [error.originalError.stack])); + return; + } + return undefined; + }, + }, + ); } const publicSchemaObject = z.strictObject(gestureSchema.omit({ simulatorId: true } as const).shape); @@ -177,75 +177,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, - createAxeNotAvailableResponse, - }), + 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, createAxeNotAvailableResponse }, -): 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 aaa048a3..d822ffab 100644 --- a/src/mcp/tools/ui-automation/key_press.ts +++ b/src/mcp/tools/ui-automation/key_press.ts @@ -1,29 +1,22 @@ 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'; +import { AXE_NOT_AVAILABLE_MESSAGE } from '../../../utils/axe-helpers.ts'; import { createSessionAwareTool, getSessionAwareToolSchemaShape, + getHandlerContext, } from '../../../utils/typed-tool-factory.ts'; +import { executeAxeCommand, defaultAxeHelpers } from './shared/axe-command.ts'; +import type { AxeHelpers } from './shared/axe-command.ts'; +import { withErrorHandling } from '../../../utils/tool-error-handling.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,36 +32,33 @@ 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]'; export async function key_pressLogic( params: KeyPressParams, executor: CommandExecutor, - axeHelpers: AxeHelpers = { - getAxePath, - getBundledAxeEnvironment, - createAxeNotAvailableResponse, - }, + axeHelpers: AxeHelpers = defaultAxeHelpers, debuggerManager: DebuggerManager = getDefaultDebuggerManager(), -): Promise { +): Promise { const toolName = 'key_press'; const { simulatorId, keyCode, duration } = params; + const headerEvent = header('Key Press', [{ label: 'Simulator', value: simulatorId }]); + + const ctx = getHandlerContext(); + const guard = await guardUiAutomationAgainstStoppedDebugger({ debugger: debuggerManager, simulatorId, toolName, }); - if (guard.blockedResponse) return guard.blockedResponse; + if (guard.blockedMessage) { + ctx.emit(headerEvent); + ctx.emit(statusLine('error', guard.blockedMessage)); + return; + } const commandArgs = ['key', String(keyCode)]; if (duration !== undefined) { @@ -77,33 +67,47 @@ export async function key_pressLogic( log('info', `${LOG_PREFIX}/${toolName}: Starting key press ${keyCode} on ${simulatorId}`); - 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); - } catch (error) { - log('error', `${LOG_PREFIX}/${toolName}: Failed - ${error}`); - if (error instanceof DependencyError) { - return axeHelpers.createAxeNotAvailableResponse(); - } else if (error instanceof AxeError) { - return createErrorResponse( - `Failed to simulate key press (code: ${keyCode}): ${error.message}`, - error.axeOutput, - ); - } else if (error instanceof SystemError) { - return createErrorResponse( - `System error executing axe: ${error.message}`, - error.originalError?.stack, - ); - } - return createErrorResponse( - `An unexpected error occurred: ${error instanceof Error ? error.message : String(error)}`, - ); - } + return withErrorHandling( + ctx, + async () => { + await executeAxeCommand(commandArgs, simulatorId, 'key', executor, axeHelpers); + log('info', `${LOG_PREFIX}/${toolName}: Success for ${simulatorId}`); + ctx.emit(headerEvent); + ctx.emit(statusLine('success', `Key press (code: ${keyCode}) simulated successfully.`)); + if (guard.warningText) { + ctx.emit(statusLine('warning', guard.warningText)); + } + }, + { + header: headerEvent, + errorMessage: ({ message }) => `An unexpected error occurred: ${message}`, + logMessage: ({ error }) => `${LOG_PREFIX}/${toolName}: Failed - ${error}`, + mapError: ({ error, headerEvent: hdr, emit }) => { + if (error instanceof DependencyError) { + emit?.(hdr); + emit?.(statusLine('error', AXE_NOT_AVAILABLE_MESSAGE)); + return; + } else if (error instanceof AxeError) { + emit?.(hdr); + emit?.( + statusLine( + 'error', + `Failed to simulate key press (code: ${keyCode}): ${error.message}`, + ), + ); + if (error.axeOutput) emit?.(section('Details', [error.axeOutput])); + return; + } else if (error instanceof SystemError) { + emit?.(hdr); + emit?.(statusLine('error', `System error executing axe: ${error.message}`)); + if (error.originalError?.stack) + emit?.(section('Stack Trace', [error.originalError.stack])); + return; + } + return undefined; + }, + }, + ); } const publicSchemaObject = z.strictObject( @@ -118,75 +122,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, - createAxeNotAvailableResponse, - }), + 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, createAxeNotAvailableResponse }, -): 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 96bc9d9d..7c32c4ac 100644 --- a/src/mcp/tools/ui-automation/key_sequence.ts +++ b/src/mcp/tools/ui-automation/key_sequence.ts @@ -5,31 +5,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'; +import { AXE_NOT_AVAILABLE_MESSAGE } from '../../../utils/axe-helpers.ts'; import { createSessionAwareTool, getSessionAwareToolSchemaShape, + getHandlerContext, } from '../../../utils/typed-tool-factory.ts'; +import { executeAxeCommand, defaultAxeHelpers } from './shared/axe-command.ts'; +import type { AxeHelpers } from './shared/axe-command.ts'; +import { withErrorHandling } from '../../../utils/tool-error-handling.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,36 +32,33 @@ 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]'; export async function key_sequenceLogic( params: KeySequenceParams, executor: CommandExecutor, - axeHelpers: AxeHelpers = { - getAxePath, - getBundledAxeEnvironment, - createAxeNotAvailableResponse, - }, + axeHelpers: AxeHelpers = defaultAxeHelpers, debuggerManager: DebuggerManager = getDefaultDebuggerManager(), -): Promise { +): Promise { const toolName = 'key_sequence'; const { simulatorId, keyCodes, delay } = params; + const headerEvent = header('Key Sequence', [{ label: 'Simulator', value: simulatorId }]); + + const ctx = getHandlerContext(); + const guard = await guardUiAutomationAgainstStoppedDebugger({ debugger: debuggerManager, simulatorId, toolName, }); - if (guard.blockedResponse) return guard.blockedResponse; + if (guard.blockedMessage) { + ctx.emit(headerEvent); + ctx.emit(statusLine('error', guard.blockedMessage)); + return; + } const commandArgs = ['key-sequence', '--keycodes', keyCodes.join(',')]; if (delay !== undefined) { @@ -80,33 +70,44 @@ export async function key_sequenceLogic( `${LOG_PREFIX}/${toolName}: Starting key sequence [${keyCodes.join(',')}] on ${simulatorId}`, ); - 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); - } catch (error) { - log('error', `${LOG_PREFIX}/${toolName}: Failed - ${error}`); - if (error instanceof DependencyError) { - return axeHelpers.createAxeNotAvailableResponse(); - } else if (error instanceof AxeError) { - return createErrorResponse( - `Failed to execute key sequence: ${error.message}`, - error.axeOutput, + return withErrorHandling( + ctx, + async () => { + await executeAxeCommand(commandArgs, simulatorId, 'key-sequence', executor, axeHelpers); + log('info', `${LOG_PREFIX}/${toolName}: Success for ${simulatorId}`); + ctx.emit(headerEvent); + ctx.emit( + statusLine('success', `Key sequence [${keyCodes.join(',')}] executed successfully.`), ); - } else if (error instanceof SystemError) { - return createErrorResponse( - `System error executing axe: ${error.message}`, - error.originalError?.stack, - ); - } - return createErrorResponse( - `An unexpected error occurred: ${error instanceof Error ? error.message : String(error)}`, - ); - } + if (guard.warningText) { + ctx.emit(statusLine('warning', guard.warningText)); + } + }, + { + header: headerEvent, + errorMessage: ({ message }) => `An unexpected error occurred: ${message}`, + logMessage: ({ error }) => `${LOG_PREFIX}/${toolName}: Failed - ${error}`, + mapError: ({ error, headerEvent: hdr, emit }) => { + if (error instanceof DependencyError) { + emit?.(hdr); + emit?.(statusLine('error', AXE_NOT_AVAILABLE_MESSAGE)); + return; + } else if (error instanceof AxeError) { + emit?.(hdr); + emit?.(statusLine('error', `Failed to execute key sequence: ${error.message}`)); + if (error.axeOutput) emit?.(section('Details', [error.axeOutput])); + return; + } else if (error instanceof SystemError) { + emit?.(hdr); + emit?.(statusLine('error', `System error executing axe: ${error.message}`)); + if (error.originalError?.stack) + emit?.(section('Stack Trace', [error.originalError.stack])); + return; + } + return undefined; + }, + }, + ); } const publicSchemaObject = z.strictObject( @@ -121,75 +122,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, - createAxeNotAvailableResponse, - }), + 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, createAxeNotAvailableResponse }, -): 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 f6429729..29b75770 100644 --- a/src/mcp/tools/ui-automation/long_press.ts +++ b/src/mcp/tools/ui-automation/long_press.ts @@ -6,32 +6,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'; +import { AXE_NOT_AVAILABLE_MESSAGE } from '../../../utils/axe-helpers.ts'; import { createSessionAwareTool, getSessionAwareToolSchemaShape, + getHandlerContext, } 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 { withErrorHandling } from '../../../utils/tool-error-handling.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,43 +35,39 @@ const longPressSchema = z.object({ .describe('milliseconds'), }); -// Use z.infer for type safety type LongPressParams = z.infer; const publicSchemaObject = z.strictObject( longPressSchema.omit({ simulatorId: true } as const).shape, ); -export interface AxeHelpers { - getAxePath: () => string | null; - getBundledAxeEnvironment: () => Record; - createAxeNotAvailableResponse: () => ToolResponse; -} - const LOG_PREFIX = '[AXe]'; export async function long_pressLogic( params: LongPressParams, executor: CommandExecutor, - axeHelpers: AxeHelpers = { - getAxePath, - getBundledAxeEnvironment, - createAxeNotAvailableResponse, - }, + axeHelpers: AxeHelpers = defaultAxeHelpers, debuggerManager: DebuggerManager = getDefaultDebuggerManager(), -): Promise { +): Promise { const toolName = 'long_press'; const { simulatorId, x, y, duration } = params; + const headerEvent = header('Long Press', [{ label: 'Simulator', value: simulatorId }]); + + const ctx = getHandlerContext(); + const guard = await guardUiAutomationAgainstStoppedDebugger({ debugger: debuggerManager, simulatorId, toolName, }); - if (guard.blockedResponse) return guard.blockedResponse; + if (guard.blockedMessage) { + ctx.emit(headerEvent); + ctx.emit(statusLine('error', guard.blockedMessage)); + return; + } - // 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', @@ -96,38 +85,54 @@ export async function long_pressLogic( `${LOG_PREFIX}/${toolName}: Starting for (${x}, ${y}), ${duration}ms on ${simulatorId}`, ); - try { - await executeAxeCommand(commandArgs, simulatorId, 'touch', executor, axeHelpers); - log('info', `${LOG_PREFIX}/${toolName}: Success for ${simulatorId}`); + return withErrorHandling( + ctx, + async () => { + await executeAxeCommand(commandArgs, simulatorId, 'touch', executor, axeHelpers); + 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); - } catch (error) { - log('error', `${LOG_PREFIX}/${toolName}: Failed - ${error}`); - if (error instanceof DependencyError) { - return axeHelpers.createAxeNotAvailableResponse(); - } else if (error instanceof AxeError) { - return createErrorResponse( - `Failed to simulate long press at (${x}, ${y}): ${error.message}`, - error.axeOutput, + const coordinateWarning = getSnapshotUiWarning(simulatorId); + const warnings = [guard.warningText, coordinateWarning].filter( + (w): w is string => typeof w === 'string' && w.length > 0, ); - } else if (error instanceof SystemError) { - return createErrorResponse( - `System error executing axe: ${error.message}`, - error.originalError?.stack, + ctx.emit(headerEvent); + ctx.emit( + statusLine( + 'success', + `Long press at (${x}, ${y}) for ${duration}ms simulated successfully.`, + ), ); - } - return createErrorResponse( - `An unexpected error occurred: ${error instanceof Error ? error.message : String(error)}`, - ); - } + for (const w of warnings) { + ctx.emit(statusLine('warning', w)); + } + }, + { + header: headerEvent, + errorMessage: ({ message }) => `An unexpected error occurred: ${message}`, + logMessage: ({ error }) => `${LOG_PREFIX}/${toolName}: Failed - ${error}`, + mapError: ({ error, headerEvent: hdr, emit }) => { + if (error instanceof DependencyError) { + emit?.(hdr); + emit?.(statusLine('error', AXE_NOT_AVAILABLE_MESSAGE)); + return; + } else if (error instanceof AxeError) { + emit?.(hdr); + emit?.( + statusLine('error', `Failed to simulate long press at (${x}, ${y}): ${error.message}`), + ); + if (error.axeOutput) emit?.(section('Details', [error.axeOutput])); + return; + } else if (error instanceof SystemError) { + emit?.(hdr); + emit?.(statusLine('error', `System error executing axe: ${error.message}`)); + if (error.originalError?.stack) + emit?.(section('Stack Trace', [error.originalError.stack])); + return; + } + return undefined; + }, + }, + ); } export const schema = getSessionAwareToolSchemaShape({ @@ -138,75 +143,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, - createAxeNotAvailableResponse, - }), + 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, createAxeNotAvailableResponse }, -): 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 5e4000cd..b7b62618 100644 --- a/src/mcp/tools/ui-automation/screenshot.ts +++ b/src/mcp/tools/ui-automation/screenshot.ts @@ -1,23 +1,9 @@ -/** - * 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'; -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, @@ -26,10 +12,34 @@ import { import { createSessionAwareTool, getSessionAwareToolSchemaShape, + getHandlerContext, } from '../../../utils/typed-tool-factory.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]}px`; + } + return null; + } catch { + return null; + } +} + /** * Type for simctl device list response */ @@ -175,7 +185,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 +193,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( @@ -197,8 +205,10 @@ export async function screenshotLogic( fileSystemExecutor: FileSystemExecutor = getDefaultFileSystemExecutor(), pathUtils: { tmpdir: () => string; join: (...paths: string[]) => string } = { ...path, tmpdir }, uuidUtils: { v4: () => string } = { v4: uuidv4 }, -): Promise { +): Promise { + const ctx = getHandlerContext(); 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 +217,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 +229,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 +238,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,36 +268,42 @@ 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) { log('warn', `${LOG_PREFIX}/screenshot: Failed to delete temp file: ${err}`); } - return { - content: [createImageContent(base64Image, 'image/png')], - isError: false, - }; + ctx.emit(headerEvent); + ctx.emit(statusLine('success', 'Screenshot captured')); + ctx.emit( + detailTree([{ label: 'Format', value: 'image/png (optimization failed)' }]), + ); + ctx.attach({ data: base64Image, mimeType: 'image/png' }); + return; } - return createTextResponse( - `Screenshot captured: ${screenshotPath} (image/png, optimization failed)`, + ctx.emit(headerEvent); + ctx.emit(statusLine('success', 'Screenshot captured')); + ctx.emit( + detailTree([ + { label: 'Screenshot', value: screenshotPath }, + { label: 'Format', value: 'image/png (optimization failed)' }, + ]), ); + return; } 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'); + const base64Dims = await getImageDimensions(optimizedPath, executor); 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,37 +311,58 @@ 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, - }; + ctx.emit(headerEvent); + ctx.emit(statusLine('success', 'Screenshot captured')); + ctx.emit( + detailTree([ + { label: 'Format', value: 'image/jpeg' }, + ...(base64Dims ? [{ label: 'Size', value: base64Dims }] : []), + ] as Array<{ label: string; value: string }>), + ); + ctx.attach({ data: base64Image, mimeType: 'image/jpeg' }); + return; } - // 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)`); + const dims = await getImageDimensions(optimizedPath, executor); + ctx.emit(headerEvent); + ctx.emit(statusLine('success', 'Screenshot captured')); + ctx.emit( + detailTree([ + { label: 'Screenshot', value: optimizedPath }, + { label: 'Format', value: 'image/jpeg' }, + ...(dims ? [{ label: 'Size', value: dims }] : []), + ] as Array<{ label: string; value: string }>), + ); + return; } 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)}`, + ctx.emit(headerEvent); + ctx.emit( + statusLine( + 'error', + `Screenshot captured but failed to process image file: ${fileError instanceof Error ? fileError.message : String(fileError)}`, + ), ); + return; } } catch (_error) { log('error', `${LOG_PREFIX}/screenshot: Failed - ${_error}`); + ctx.emit(headerEvent); if (_error instanceof SystemError) { - return createErrorResponse( - `System error executing screenshot: ${_error.message}`, - _error.originalError?.stack, - ); + ctx.emit(statusLine('error', `System error executing screenshot: ${_error.message}`)); + return; } - return createErrorResponse( - `An unexpected error occurred: ${_error instanceof Error ? _error.message : String(_error)}`, + ctx.emit( + statusLine( + 'error', + `An unexpected error occurred: ${_error instanceof Error ? _error.message : String(_error)}`, + ), ); } } @@ -343,9 +374,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 6d07efaf..5d2ff893 100644 --- a/src/mcp/tools/ui-automation/snapshot_ui.ts +++ b/src/mcp/tools/ui-automation/snapshot_ui.ts @@ -1,120 +1,116 @@ 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'; 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-helpers.ts'; +import { AXE_NOT_AVAILABLE_MESSAGE } from '../../../utils/axe-helpers.ts'; import { createSessionAwareTool, getSessionAwareToolSchemaShape, + getHandlerContext, } 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 { withErrorHandling } from '../../../utils/tool-error-handling.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]'; -/** - * Core business logic for snapshot_ui functionality - */ export async function snapshot_uiLogic( params: SnapshotUiParams, executor: CommandExecutor, - axeHelpers: AxeHelpers = { - getAxePath, - getBundledAxeEnvironment, - createAxeNotAvailableResponse, - }, + axeHelpers: AxeHelpers = defaultAxeHelpers, debuggerManager: DebuggerManager = getDefaultDebuggerManager(), -): Promise { +): Promise { const toolName = 'snapshot_ui'; const { simulatorId } = params; const commandArgs = ['describe-ui']; + const headerEvent = header('Snapshot UI', [{ label: 'Simulator', value: simulatorId }]); + + const ctx = getHandlerContext(); + const guard = await guardUiAutomationAgainstStoppedDebugger({ debugger: debuggerManager, simulatorId, toolName, }); - if (guard.blockedResponse) return guard.blockedResponse; + if (guard.blockedMessage) { + ctx.emit(headerEvent); + ctx.emit(statusLine('error', guard.blockedMessage)); + return; + } log('info', `${LOG_PREFIX}/${toolName}: Starting for ${simulatorId}`); - try { - const responseText = await executeAxeCommand( - commandArgs, - simulatorId, - 'describe-ui', - executor, - axeHelpers, - ); - - // Record the snapshot_ui call for warning system - recordSnapshotUiCall(simulatorId); + return withErrorHandling( + ctx, + async () => { + const responseText = await executeAxeCommand( + commandArgs, + simulatorId, + 'describe-ui', + executor, + axeHelpers, + ); - 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`, - }, - ], - nextStepParams: { + recordSnapshotUiCall(simulatorId); + + log('info', `${LOG_PREFIX}/${toolName}: Success for ${simulatorId}`); + ctx.emit(headerEvent); + ctx.emit(statusLine('success', 'Accessibility hierarchy retrieved successfully.')); + ctx.emit(section('Accessibility Hierarchy', ['```json', responseText, '```'])); + ctx.emit( + 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', + ]), + ); + if (guard.warningText) { + ctx.emit(statusLine('warning', guard.warningText)); + } + ctx.nextStepParams = { snapshot_ui: { simulatorId }, tap: { simulatorId, x: 0, y: 0 }, screenshot: { simulatorId }, + }; + }, + { + header: headerEvent, + errorMessage: ({ message }) => `An unexpected error occurred: ${message}`, + logMessage: ({ error }) => `${LOG_PREFIX}/${toolName}: Failed - ${error}`, + mapError: ({ error, headerEvent: hdr, emit }) => { + if (error instanceof DependencyError) { + emit?.(hdr); + emit?.(statusLine('error', AXE_NOT_AVAILABLE_MESSAGE)); + return; + } else if (error instanceof AxeError) { + emit?.(hdr); + emit?.(statusLine('error', `Failed to get accessibility hierarchy: ${error.message}`)); + if (error.axeOutput) emit?.(section('Details', [error.axeOutput])); + return; + } else if (error instanceof SystemError) { + emit?.(hdr); + emit?.(statusLine('error', `System error executing axe: ${error.message}`)); + if (error.originalError?.stack) + emit?.(section('Stack Trace', [error.originalError.stack])); + return; + } + return undefined; }, - }; - 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(); - } else if (error instanceof AxeError) { - return createErrorResponse( - `Failed to get accessibility hierarchy: ${error.message}`, - error.axeOutput, - ); - } else if (error instanceof SystemError) { - return createErrorResponse( - `System error executing axe: ${error.message}`, - error.originalError?.stack, - ); - } - return createErrorResponse( - `An unexpected error occurred: ${error instanceof Error ? error.message : String(error)}`, - ); - } + }, + ); } const publicSchemaObject = z.strictObject( @@ -129,70 +125,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, - createAxeNotAvailableResponse, - }), + 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, createAxeNotAvailableResponse }, -): 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 58672d8a..0af6c88c 100644 --- a/src/mcp/tools/ui-automation/swipe.ts +++ b/src/mcp/tools/ui-automation/swipe.ts @@ -5,27 +5,26 @@ */ 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'; 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-helpers.ts'; +import { AXE_NOT_AVAILABLE_MESSAGE } from '../../../utils/axe-helpers.ts'; import { createSessionAwareTool, getSessionAwareToolSchemaShape, + getHandlerContext, } 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 { withErrorHandling } from '../../../utils/tool-error-handling.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,41 +49,35 @@ 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); -export interface AxeHelpers { - getAxePath: () => string | null; - getBundledAxeEnvironment: () => Record; - createAxeNotAvailableResponse: () => ToolResponse; -} - const LOG_PREFIX = '[AXe]'; -/** - * Core swipe logic implementation - */ export async function swipeLogic( params: SwipeParams, executor: CommandExecutor, - axeHelpers: AxeHelpers = { - getAxePath, - getBundledAxeEnvironment, - createAxeNotAvailableResponse, - }, + axeHelpers: AxeHelpers = defaultAxeHelpers, debuggerManager: DebuggerManager = getDefaultDebuggerManager(), -): Promise { +): Promise { const toolName = 'swipe'; const { simulatorId, x1, y1, x2, y2, duration, delta, preDelay, postDelay } = params; + const headerEvent = header('Swipe', [{ label: 'Simulator', value: simulatorId }]); + + const ctx = getHandlerContext(); + const guard = await guardUiAutomationAgainstStoppedDebugger({ debugger: debuggerManager, simulatorId, toolName, }); - if (guard.blockedResponse) return guard.blockedResponse; + if (guard.blockedMessage) { + ctx.emit(headerEvent); + ctx.emit(statusLine('error', guard.blockedMessage)); + return; + } const commandArgs = [ 'swipe', @@ -116,35 +109,52 @@ export async function swipeLogic( `${LOG_PREFIX}/${toolName}: Starting swipe (${x1},${y1})->(${x2},${y2})${optionsText} on ${simulatorId}`, ); - try { - await executeAxeCommand(commandArgs, simulatorId, 'swipe', executor, axeHelpers); - 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'); + return withErrorHandling( + ctx, + async () => { + await executeAxeCommand(commandArgs, simulatorId, 'swipe', executor, axeHelpers); + log('info', `${LOG_PREFIX}/${toolName}: Success for ${simulatorId}`); - if (warnings) { - return createTextResponse(`${message}\n\n${warnings}`); - } - - return createTextResponse(message); - } catch (error) { - log('error', `${LOG_PREFIX}/${toolName}: Failed - ${error}`); - if (error instanceof DependencyError) { - return axeHelpers.createAxeNotAvailableResponse(); - } else if (error instanceof AxeError) { - return createErrorResponse(`Failed to simulate swipe: ${error.message}`, error.axeOutput); - } else if (error instanceof SystemError) { - return createErrorResponse( - `System error executing axe: ${error.message}`, - error.originalError?.stack, + const coordinateWarning = getSnapshotUiWarning(simulatorId); + const warnings = [guard.warningText, coordinateWarning].filter( + (w): w is string => typeof w === 'string' && w.length > 0, ); - } - return createErrorResponse( - `An unexpected error occurred: ${error instanceof Error ? error.message : String(error)}`, - ); - } + ctx.emit(headerEvent); + ctx.emit( + statusLine( + 'success', + `Swipe from (${x1}, ${y1}) to (${x2}, ${y2})${optionsText} simulated successfully.`, + ), + ); + for (const w of warnings) { + ctx.emit(statusLine('warning', w)); + } + }, + { + header: headerEvent, + errorMessage: ({ message }) => `An unexpected error occurred: ${message}`, + logMessage: ({ error }) => `${LOG_PREFIX}/${toolName}: Failed - ${error}`, + mapError: ({ error, headerEvent: hdr, emit }) => { + if (error instanceof DependencyError) { + emit?.(hdr); + emit?.(statusLine('error', AXE_NOT_AVAILABLE_MESSAGE)); + return; + } else if (error instanceof AxeError) { + emit?.(hdr); + emit?.(statusLine('error', `Failed to simulate swipe: ${error.message}`)); + if (error.axeOutput) emit?.(section('Details', [error.axeOutput])); + return; + } else if (error instanceof SystemError) { + emit?.(hdr); + emit?.(statusLine('error', `System error executing axe: ${error.message}`)); + if (error.originalError?.stack) + emit?.(section('Stack Trace', [error.originalError.stack])); + return; + } + return undefined; + }, + }, + ); } export const schema = getSessionAwareToolSchemaShape({ @@ -155,75 +165,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, - createAxeNotAvailableResponse, - }), + 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, createAxeNotAvailableResponse }, -): 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 f033b592..d3827b2a 100644 --- a/src/mcp/tools/ui-automation/tap.ts +++ b/src/mcp/tools/ui-automation/tap.ts @@ -1,31 +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, -} 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, + getHandlerContext, } 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 { withErrorHandling } from '../../../utils/tool-error-handling.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 +97,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); @@ -114,22 +106,26 @@ const LOG_PREFIX = '[AXe]'; export async function tapLogic( params: TapParams, executor: CommandExecutor, - axeHelpers: AxeHelpers = { - getAxePath, - getBundledAxeEnvironment, - createAxeNotAvailableResponse, - }, + axeHelpers: AxeHelpers = defaultAxeHelpers, debuggerManager: DebuggerManager = getDefaultDebuggerManager(), -): Promise { +): Promise { const toolName = 'tap'; const { simulatorId, x, y, id, label, preDelay, postDelay } = params; + const headerEvent = header('Tap', [{ label: 'Simulator', value: simulatorId }]); + + const ctx = getHandlerContext(); + const guard = await guardUiAutomationAgainstStoppedDebugger({ debugger: debuggerManager, simulatorId, toolName, }); - if (guard.blockedResponse) return guard.blockedResponse; + if (guard.blockedMessage) { + ctx.emit(headerEvent); + ctx.emit(statusLine('error', guard.blockedMessage)); + return; + } let targetDescription = ''; let actionDescription = ''; @@ -150,10 +146,9 @@ export async function tapLogic( actionDescription = `Tap on ${targetDescription}`; commandArgs.push('--label', label); } else { - return createErrorResponse( - 'Parameter validation failed', - 'Invalid parameters:\nroot: Missing tap target', - ); + ctx.emit(headerEvent); + ctx.emit(statusLine('error', 'Parameter validation failed: Missing tap target')); + return; } if (preDelay !== undefined) { @@ -165,39 +160,52 @@ export async function tapLogic( log('info', `${LOG_PREFIX}/${toolName}: Starting for ${targetDescription} on ${simulatorId}`); - try { - await executeAxeCommand(commandArgs, simulatorId, 'tap', executor, axeHelpers); - log('info', `${LOG_PREFIX}/${toolName}: Success for ${simulatorId}`); + return withErrorHandling( + ctx, + async () => { + await executeAxeCommand(commandArgs, simulatorId, 'tap', executor, axeHelpers); + 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}`); - } - - return createTextResponse(message); - } 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(); - } else if (error instanceof AxeError) { - return createErrorResponse( - `Failed to simulate ${actionDescription.toLowerCase()}: ${error.message}`, - error.axeOutput, - ); - } else if (error instanceof SystemError) { - return createErrorResponse( - `System error executing axe: ${error.message}`, - error.originalError?.stack, + const coordinateWarning = usesCoordinates ? getSnapshotUiWarning(simulatorId) : null; + const warnings = [guard.warningText, coordinateWarning].filter( + (w): w is string => typeof w === 'string' && w.length > 0, ); - } - return createErrorResponse( - `An unexpected error occurred: ${error instanceof Error ? error.message : String(error)}`, - ); - } + ctx.emit(headerEvent); + ctx.emit(statusLine('success', `${actionDescription} simulated successfully.`)); + for (const w of warnings) { + ctx.emit(statusLine('warning', w)); + } + }, + { + header: headerEvent, + errorMessage: ({ message }) => `An unexpected error occurred: ${message}`, + logMessage: ({ message }) => `${LOG_PREFIX}/${toolName}: Failed - ${message}`, + mapError: ({ error, headerEvent: hdr, emit }) => { + if (error instanceof DependencyError) { + emit?.(hdr); + emit?.(statusLine('error', AXE_NOT_AVAILABLE_MESSAGE)); + return; + } else if (error instanceof AxeError) { + emit?.(hdr); + emit?.( + statusLine( + 'error', + `Failed to simulate ${actionDescription.toLowerCase()}: ${error.message}`, + ), + ); + if (error.axeOutput) emit?.(section('Details', [error.axeOutput])); + return; + } else if (error instanceof SystemError) { + emit?.(hdr); + emit?.(statusLine('error', `System error executing axe: ${error.message}`)); + if (error.originalError?.stack) + emit?.(section('Stack Trace', [error.originalError.stack])); + return; + } + return undefined; + }, + }, + ); } export const schema = getSessionAwareToolSchemaShape({ @@ -208,75 +216,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, - createAxeNotAvailableResponse, - }), + 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, createAxeNotAvailableResponse }, -): 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 beab8c71..098de781 100644 --- a/src/mcp/tools/ui-automation/touch.ts +++ b/src/mcp/tools/ui-automation/touch.ts @@ -7,26 +7,24 @@ 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'; 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-helpers.ts'; -import type { ToolResponse } from '../../../types/common.ts'; +import { AXE_NOT_AVAILABLE_MESSAGE } from '../../../utils/axe-helpers.ts'; import { createSessionAwareTool, getSessionAwareToolSchemaShape, + getHandlerContext, } 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 { withErrorHandling } from '../../../utils/tool-error-handling.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,32 +38,29 @@ 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); -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 { +): 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 }]); + + const ctx = getHandlerContext(); - // 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'); + ctx.emit(headerEvent); + ctx.emit(statusLine('error', 'At least one of "down" or "up" must be true')); + return; } const guard = await guardUiAutomationAgainstStoppedDebugger({ @@ -73,7 +68,11 @@ export async function touchLogic( simulatorId, toolName, }); - if (guard.blockedResponse) return guard.blockedResponse; + if (guard.blockedMessage) { + ctx.emit(headerEvent); + ctx.emit(statusLine('error', guard.blockedMessage)); + return; + } const commandArgs = ['touch', '-x', String(x), '-y', String(y)]; if (down) { @@ -92,41 +91,49 @@ export async function touchLogic( `${LOG_PREFIX}/${toolName}: Starting ${actionText} at (${x}, ${y}) on ${simulatorId}`, ); - try { - await executeAxeCommand(commandArgs, simulatorId, 'touch', executor, axeHelpers); - 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); - } catch (error) { - log( - 'error', - `${LOG_PREFIX}/${toolName}: Failed - ${error instanceof Error ? error.message : String(error)}`, - ); - if (error instanceof DependencyError) { - return createAxeNotAvailableResponse(); - } else if (error instanceof AxeError) { - return createErrorResponse( - `Failed to execute touch event: ${error.message}`, - error.axeOutput, + return withErrorHandling( + ctx, + async () => { + await executeAxeCommand(commandArgs, simulatorId, 'touch', executor, axeHelpers); + log('info', `${LOG_PREFIX}/${toolName}: Success for ${simulatorId}`); + + const coordinateWarning = getSnapshotUiWarning(simulatorId); + const warnings = [guard.warningText, coordinateWarning].filter( + (w): w is string => typeof w === 'string' && w.length > 0, ); - } else if (error instanceof SystemError) { - return createErrorResponse( - `System error executing axe: ${error.message}`, - error.originalError?.stack, + ctx.emit(headerEvent); + ctx.emit( + statusLine('success', `Touch event (${actionText}) at (${x}, ${y}) executed successfully.`), ); - } - return createErrorResponse( - `An unexpected error occurred: ${error instanceof Error ? error.message : String(error)}`, - ); - } + for (const w of warnings) { + ctx.emit(statusLine('warning', w)); + } + }, + { + header: headerEvent, + errorMessage: ({ message }) => `An unexpected error occurred: ${message}`, + logMessage: ({ message }) => `${LOG_PREFIX}/${toolName}: Failed - ${message}`, + mapError: ({ error, headerEvent: hdr, emit }) => { + if (error instanceof DependencyError) { + emit?.(hdr); + emit?.(statusLine('error', AXE_NOT_AVAILABLE_MESSAGE)); + return; + } else if (error instanceof AxeError) { + emit?.(hdr); + emit?.(statusLine('error', `Failed to execute touch event: ${error.message}`)); + if (error.axeOutput) emit?.(section('Details', [error.axeOutput])); + return; + } else if (error instanceof SystemError) { + emit?.(hdr); + emit?.(statusLine('error', `System error executing axe: ${error.message}`)); + if (error.originalError?.stack) + emit?.(section('Stack Trace', [error.originalError.stack])); + return; + } + return undefined; + }, + }, + ); } export const schema = getSessionAwareToolSchemaShape({ @@ -140,70 +147,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 1a1d6e85..274bcefb 100644 --- a/src/mcp/tools/ui-automation/type_text.ts +++ b/src/mcp/tools/ui-automation/type_text.ts @@ -6,61 +6,60 @@ */ 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'; 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-helpers.ts'; +import { AXE_NOT_AVAILABLE_MESSAGE } from '../../../utils/axe-helpers.ts'; import { createSessionAwareTool, getSessionAwareToolSchemaShape, + getHandlerContext, } from '../../../utils/typed-tool-factory.ts'; +import { executeAxeCommand, defaultAxeHelpers } from './shared/axe-command.ts'; +import type { AxeHelpers } from './shared/axe-command.ts'; +import { withErrorHandling } from '../../../utils/tool-error-handling.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( 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 { +): 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 ctx = getHandlerContext(); + const guard = await guardUiAutomationAgainstStoppedDebugger({ debugger: debuggerManager, simulatorId, toolName, }); - if (guard.blockedResponse) return guard.blockedResponse; + if (guard.blockedMessage) { + ctx.emit(headerEvent); + ctx.emit(statusLine('error', guard.blockedMessage)); + return; + } const commandArgs = ['type', text]; @@ -69,36 +68,42 @@ export async function type_textLogic( `${LOG_PREFIX}/${toolName}: Starting type "${text.substring(0, 20)}..." on ${simulatorId}`, ); - 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); - } catch (error) { - log( - 'error', - `${LOG_PREFIX}/${toolName}: Failed - ${error instanceof Error ? error.message : String(error)}`, - ); - if (error instanceof DependencyError) { - return createAxeNotAvailableResponse(); - } else if (error instanceof AxeError) { - return createErrorResponse( - `Failed to simulate text typing: ${error.message}`, - error.axeOutput, - ); - } else if (error instanceof SystemError) { - return createErrorResponse( - `System error executing axe: ${error.message}`, - error.originalError?.stack, - ); - } - return createErrorResponse( - `An unexpected error occurred: ${error instanceof Error ? error.message : String(error)}`, - ); - } + return withErrorHandling( + ctx, + async () => { + await executeAxeCommand(commandArgs, simulatorId, 'type', executor, axeHelpers); + log('info', `${LOG_PREFIX}/${toolName}: Success for ${simulatorId}`); + ctx.emit(headerEvent); + ctx.emit(statusLine('success', 'Text typing simulated successfully.')); + if (guard.warningText) { + ctx.emit(statusLine('warning', guard.warningText)); + } + }, + { + header: headerEvent, + errorMessage: ({ message }) => `An unexpected error occurred: ${message}`, + logMessage: ({ message }) => `${LOG_PREFIX}/${toolName}: Failed - ${message}`, + mapError: ({ error, headerEvent: hdr, emit }) => { + if (error instanceof DependencyError) { + emit?.(hdr); + emit?.(statusLine('error', AXE_NOT_AVAILABLE_MESSAGE)); + return; + } else if (error instanceof AxeError) { + emit?.(hdr); + emit?.(statusLine('error', `Failed to simulate text typing: ${error.message}`)); + if (error.axeOutput) emit?.(section('Details', [error.axeOutput])); + return; + } else if (error instanceof SystemError) { + emit?.(hdr); + emit?.(statusLine('error', `System error executing axe: ${error.message}`)); + if (error.originalError?.stack) + emit?.(section('Stack Trace', [error.originalError.stack])); + return; + } + return undefined; + }, + }, + ); } export const schema = getSessionAwareToolSchemaShape({ @@ -113,70 +118,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/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 ee26b0c3..ccdc2f8d 100644 --- a/src/utils/axe/index.ts +++ b/src/utils/axe/index.ts @@ -1,5 +1,4 @@ export { - createAxeNotAvailableResponse, AXE_NOT_AVAILABLE_MESSAGE, getAxePath, getBundledAxeEnvironment, diff --git a/src/utils/debugger/ui-automation-guard.ts b/src/utils/debugger/ui-automation-guard.ts index f6f469af..0c005a12 100644 --- a/src/utils/debugger/ui-automation-guard.ts +++ b/src/utils/debugger/ui-automation-guard.ts @@ -7,6 +7,7 @@ import type { DebuggerManager } from './debugger-manager.ts'; type GuardResult = { blockedResponse?: ToolResponse; + blockedMessage?: string; warningText?: string; }; @@ -55,6 +56,7 @@ export async function guardUiAutomationAgainstStoppedDebugger(opts: { 'UI automation blocked: app is paused in debugger', details, ), + blockedMessage: `UI automation blocked: app is paused in debugger\n${details}`, }; }