From 99ea931a671dc149d9bfe6fce532d3bc0c07e3ac Mon Sep 17 00:00:00 2001 From: Cameron Cooke Date: Sun, 21 Sep 2025 22:37:06 +0100 Subject: [PATCH 1/3] Update .gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index a537ab33..ce7a55a3 100644 --- a/.gitignore +++ b/.gitignore @@ -107,3 +107,4 @@ bundled/ /.mcpregistry_github_token /.mcpregistry_registry_token /key.pem +.mcpli From 10dc680e95890a1da41e431831dc304503b0b974 Mon Sep 17 00:00:00 2001 From: Cameron Cooke Date: Mon, 22 Sep 2025 18:24:19 +0100 Subject: [PATCH 2/3] Add support for video capture! --- .axe-version | 1 + README.md | 5 +- docs/TOOLS.md | 11 +- scripts/bundle-axe.sh | 63 +++-- .../__tests__/record_sim_video.test.ts | 189 +++++++++++++++ src/mcp/tools/simulator/record_sim_video.ts | 227 ++++++++++++++++++ src/utils/axe-helpers.ts | 47 ++++ src/utils/axe/index.ts | 1 + src/utils/video-capture/index.ts | 5 + src/utils/video_capture.ts | 214 +++++++++++++++++ 10 files changed, 733 insertions(+), 30 deletions(-) create mode 100644 .axe-version create mode 100644 src/mcp/tools/simulator/__tests__/record_sim_video.test.ts create mode 100644 src/mcp/tools/simulator/record_sim_video.ts create mode 100644 src/utils/video-capture/index.ts create mode 100644 src/utils/video_capture.ts diff --git a/.axe-version b/.axe-version new file mode 100644 index 00000000..524cb552 --- /dev/null +++ b/.axe-version @@ -0,0 +1 @@ +1.1.1 diff --git a/README.md b/README.md index 4020edc6..c6feab46 100644 --- a/README.md +++ b/README.md @@ -86,6 +86,7 @@ The XcodeBuildMCP server provides the following tool capabilities: - **Log Capture**: Capture run-time logs from a simulator - **UI Automation**: Interact with simulator UI elements - **Screenshot**: Capture screenshots from a simulator +- **Video Capture**: Start/stop simulator video capture to MP4 (AXe v1.1.0+) ### Device management - **Device Discovery**: List connected physical Apple devices over USB or Wi-Fi @@ -117,7 +118,9 @@ For clients that support MCP resources XcodeBuildMCP provides efficient URI-base - Xcode 16.x or later - Node 18.x or later -### Configure your MCP client +> Video capture requires the bundled AXe binary (v1.1.0+). Run `npm run bundle:axe` once locally before using `record_sim_video`. This is not required for unit tests. + +Configure your MCP client #### One click install diff --git a/docs/TOOLS.md b/docs/TOOLS.md index 98ed6d5c..4c402dde 100644 --- a/docs/TOOLS.md +++ b/docs/TOOLS.md @@ -1,6 +1,6 @@ # XcodeBuildMCP Tools Reference -XcodeBuildMCP provides 60 tools organized into 12 workflow groups for comprehensive Apple development workflows. +XcodeBuildMCP provides 61 tools organized into 12 workflow groups for comprehensive Apple development workflows. ## Workflow Groups @@ -19,7 +19,7 @@ XcodeBuildMCP provides 60 tools organized into 12 workflow groups for comprehens - `stop_app_device` - Stops an app running on a physical Apple device (iPhone, iPad, Apple Watch, Apple TV, Apple Vision Pro). Requires deviceId and processId. - `test_device` - Runs tests for an Apple project or workspace on a physical device (iPhone, iPad, Apple Watch, Apple TV, Apple Vision Pro) using xcodebuild test and parses xcresult output. Provide exactly one of projectPath or workspacePath. ### iOS Simulator Development (`simulator`) -**Purpose**: Complete iOS development workflow for both .xcodeproj and .xcworkspace files targeting simulators. Build, test, deploy, and interact with iOS apps on simulators. (11 tools) +**Purpose**: Complete iOS development workflow for both .xcodeproj and .xcworkspace files targeting simulators. Build, test, deploy, and interact with iOS apps on simulators. (12 tools) - `boot_sim` - Boots an iOS simulator. After booting, use open_sim() to make the simulator visible. - `build_run_sim` - Builds and runs an app from a project or workspace on a specific simulator by UUID or name. Provide exactly one of projectPath or workspacePath, and exactly one of simulatorId or simulatorName. @@ -30,6 +30,7 @@ XcodeBuildMCP provides 60 tools organized into 12 workflow groups for comprehens - `launch_app_sim` - Launches an app in an iOS simulator by UUID or name. If simulator window isn't visible, use open_sim() first. or launch_app_sim({ simulatorName: 'iPhone 16', bundleId: 'com.example.MyApp' }) - `list_sims` - Lists available iOS simulators with their UUIDs. - `open_sim` - Opens the iOS Simulator app. +- `record_sim_video` - Starts or stops video capture for an iOS simulator using AXe. Provide exactly one of start=true or stop=true. On stop, outputFile is required. fps defaults to 30. - `stop_app_sim` - Stops an app running in an iOS simulator by UUID or name. or stop_app_sim({ simulatorName: "iPhone 16", bundleId: "com.example.MyApp" }) - `test_sim` - Runs tests on a simulator by UUID or name using xcodebuild test and parses xcresult output. Works with both Xcode projects (.xcodeproj) and workspaces (.xcworkspace). ### Log Capture & Management (`logging`) @@ -68,7 +69,7 @@ XcodeBuildMCP provides 60 tools organized into 12 workflow groups for comprehens ### Simulator Management (`simulator-management`) **Purpose**: Tools for managing simulators from booting, opening simulators, listing simulators, stopping simulators, erasing simulator content and settings, and setting simulator environment options like location, network, statusbar and appearance. (5 tools) -- `erase_sims` - Erases simulator content and settings. Provide exactly one of: simulatorUuid or all=true. Optional: shutdownFirst to shut down before erasing. +- `erase_sims` - Erases simulator content and settings. Provide exactly one of: simulatorUdid or all=true. Optional: shutdownFirst to shut down before erasing. - `reset_sim_location` - Resets the simulator's location to default. - `set_sim_appearance` - Sets the appearance mode (dark/light) of an iOS simulator. - `set_sim_location` - Sets a custom GPS location for the simulator. @@ -103,9 +104,9 @@ XcodeBuildMCP provides 60 tools organized into 12 workflow groups for comprehens ## Summary Statistics -- **Total Tools**: 60 canonical tools + 22 re-exports = 82 total +- **Total Tools**: 61 canonical tools + 22 re-exports = 83 total - **Workflow Groups**: 12 --- -*This documentation is automatically generated by `scripts/update-tools-docs.ts` using static analysis. Last updated: 2025-09-21* +*This documentation is automatically generated by `scripts/update-tools-docs.ts` using static analysis. Last updated: 2025-09-22* diff --git a/scripts/bundle-axe.sh b/scripts/bundle-axe.sh index 5d6328ba..82e739d8 100755 --- a/scripts/bundle-axe.sh +++ b/scripts/bundle-axe.sh @@ -13,6 +13,21 @@ AXE_TEMP_DIR="/tmp/axe-download-$$" echo "๐Ÿ”จ Preparing AXe artifacts for bundling..." +# Single source of truth for AXe version (overridable) +# 1) Use $AXE_VERSION if provided in env +# 2) Else, use repo-level pin from .axe-version if present +# 3) Else, fall back to default below +DEFAULT_AXE_VERSION="1.1.1" +VERSION_FILE="$PROJECT_ROOT/.axe-version" +if [ -n "${AXE_VERSION}" ]; then + PINNED_AXE_VERSION="${AXE_VERSION}" +elif [ -f "$VERSION_FILE" ]; then + PINNED_AXE_VERSION="$(cat "$VERSION_FILE" | tr -d ' \n\r')" +else + PINNED_AXE_VERSION="$DEFAULT_AXE_VERSION" +fi +echo "๐Ÿ“Œ Using AXe version: $PINNED_AXE_VERSION" + # Clean up any existing bundled directory if [ -d "$BUNDLED_DIR" ]; then echo "๐Ÿงน Cleaning existing bundled directory..." @@ -22,41 +37,41 @@ fi # Create bundled directory mkdir -p "$BUNDLED_DIR" -# Use local AXe build if available, otherwise download from GitHub releases -if [ -d "$AXE_LOCAL_DIR" ] && [ -f "$AXE_LOCAL_DIR/Package.swift" ]; then +# Use local AXe build if available (unless AXE_FORCE_REMOTE=1), otherwise download from GitHub releases +if [ -z "${AXE_FORCE_REMOTE}" ] && [ -d "$AXE_LOCAL_DIR" ] && [ -f "$AXE_LOCAL_DIR/Package.swift" ]; then echo "๐Ÿ  Using local AXe source at $AXE_LOCAL_DIR" cd "$AXE_LOCAL_DIR" - + # Build AXe in release configuration echo "๐Ÿ”จ Building AXe in release configuration..." swift build --configuration release - + # Check if build succeeded if [ ! -f ".build/release/axe" ]; then echo "โŒ AXe build failed - binary not found" exit 1 fi - + echo "โœ… AXe build completed successfully" - + # Copy binary to bundled directory echo "๐Ÿ“ฆ Copying AXe binary..." cp ".build/release/axe" "$BUNDLED_DIR/" - + # Fix rpath to find frameworks in Frameworks/ subdirectory echo "๐Ÿ”ง Configuring AXe binary rpath for bundled frameworks..." install_name_tool -add_rpath "@executable_path/Frameworks" "$BUNDLED_DIR/axe" - + # Create Frameworks directory and copy frameworks echo "๐Ÿ“ฆ Copying frameworks..." mkdir -p "$BUNDLED_DIR/Frameworks" - + # Copy frameworks with better error handling for framework in .build/release/*.framework; do if [ -d "$framework" ]; then echo "๐Ÿ“ฆ Copying framework: $(basename "$framework")" cp -r "$framework" "$BUNDLED_DIR/Frameworks/" - + # Only copy nested frameworks if they exist if [ -d "$framework/Frameworks" ]; then echo "๐Ÿ“ฆ Found nested frameworks in $(basename "$framework")" @@ -66,30 +81,30 @@ if [ -d "$AXE_LOCAL_DIR" ] && [ -f "$AXE_LOCAL_DIR/Package.swift" ]; then done else echo "๐Ÿ“ฅ Downloading latest AXe release from GitHub..." - - # Get latest release download URL - LATEST_RELEASE_URL="https://github.com/cameroncooke/AXe/releases/download/v1.0.0/AXe-macOS-v1.0.0.tar.gz" - + + # Construct release download URL from pinned version + AXE_RELEASE_URL="https://github.com/cameroncooke/AXe/releases/download/v${PINNED_AXE_VERSION}/AXe-macOS-v${PINNED_AXE_VERSION}.tar.gz" + # Create temp directory mkdir -p "$AXE_TEMP_DIR" cd "$AXE_TEMP_DIR" - + # Download and extract the release - echo "๐Ÿ“ฅ Downloading AXe release archive..." - curl -L -o "axe-release.tar.gz" "$LATEST_RELEASE_URL" - + echo "๐Ÿ“ฅ Downloading AXe release archive ($AXE_RELEASE_URL)..." + curl -L -o "axe-release.tar.gz" "$AXE_RELEASE_URL" + echo "๐Ÿ“ฆ Extracting AXe release archive..." tar -xzf "axe-release.tar.gz" - + # Find the extracted directory (might be named differently) EXTRACTED_DIR=$(find . -type d -name "*AXe*" -o -name "*axe*" | head -1) if [ -z "$EXTRACTED_DIR" ]; then # If no AXe directory found, assume files are in current directory EXTRACTED_DIR="." fi - + cd "$EXTRACTED_DIR" - + # Copy binary if [ -f "axe" ]; then echo "๐Ÿ“ฆ Copying AXe binary..." @@ -104,11 +119,11 @@ else ls -la exit 1 fi - + # Copy frameworks if they exist echo "๐Ÿ“ฆ Copying frameworks..." mkdir -p "$BUNDLED_DIR/Frameworks" - + if [ -d "Frameworks" ]; then cp -r Frameworks/* "$BUNDLED_DIR/Frameworks/" elif [ -d "lib" ]; then @@ -153,4 +168,4 @@ BUNDLE_SIZE=$(du -sh "$BUNDLED_DIR" | cut -f1) echo "๐Ÿ“Š Final bundle size: $BUNDLE_SIZE" echo "๐ŸŽ‰ AXe bundling completed successfully!" -echo "๐Ÿ“ Bundled artifacts location: $BUNDLED_DIR" \ No newline at end of file +echo "๐Ÿ“ Bundled artifacts location: $BUNDLED_DIR" diff --git a/src/mcp/tools/simulator/__tests__/record_sim_video.test.ts b/src/mcp/tools/simulator/__tests__/record_sim_video.test.ts new file mode 100644 index 00000000..467ebc3f --- /dev/null +++ b/src/mcp/tools/simulator/__tests__/record_sim_video.test.ts @@ -0,0 +1,189 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { z } from 'zod'; + +// Import the tool and logic +import tool, { record_sim_videoLogic } from '../record_sim_video.ts'; +import { createMockFileSystemExecutor } from '../../../../test-utils/mock-executors.ts'; + +const DUMMY_EXECUTOR: any = (async () => ({ success: true })) as any; // CommandExecutor stub +const VALID_UUID = '00000000-0000-0000-0000-000000000000'; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe('record_sim_video tool - validation', () => { + it('errors when start and stop are both true (mutually exclusive)', async () => { + const res = await tool.handler({ + simulatorUuid: VALID_UUID, + start: true, + stop: true, + } as any); + + expect(res.isError).toBe(true); + const text = (res.content?.[0] as any)?.text ?? ''; + expect(text.toLowerCase()).toContain('mutually exclusive'); + }); + + it('errors when stop=true but outputFile is missing', async () => { + const res = await tool.handler({ + simulatorUuid: VALID_UUID, + stop: true, + } as any); + + expect(res.isError).toBe(true); + const text = (res.content?.[0] as any)?.text ?? ''; + expect(text.toLowerCase()).toContain('outputfile is required'); + }); +}); + +describe('record_sim_video logic - start behavior', () => { + it('starts with default fps (30) and warns when outputFile is provided on start (ignored)', async () => { + const video: any = { + startSimulatorVideoCapture: async () => ({ + started: true, + sessionId: 'sess-123', + }), + stopSimulatorVideoCapture: async () => ({ + stopped: false, + }), + }; + + // DI for AXe helpers: available and version OK + const axe = { + areAxeToolsAvailable: () => true, + isAxeAtLeastVersion: async () => true, + createAxeNotAvailableResponse: () => ({ + content: [{ type: 'text', text: 'AXe not available' }], + isError: true, + }), + }; + + const fs = createMockFileSystemExecutor(); + + const res = await record_sim_videoLogic( + { + simulatorUuid: VALID_UUID, + start: true, + // fps omitted to hit default 30 + outputFile: '/tmp/ignored.mp4', // should be ignored with a note + } as any, + DUMMY_EXECUTOR, + axe, + video, + fs, + ); + + expect(res.isError).toBe(false); + const texts = (res.content ?? []).map((c: any) => c.text).join('\n'); + + expect(texts).toContain('๐ŸŽฅ'); + expect(texts).toMatch(/30\s*fps/i); + expect(texts.toLowerCase()).toContain('outputfile is ignored'); + expect(texts).toContain('Next Steps'); + expect(texts).toContain('stop: true'); + expect(texts).toContain('outputFile'); + }); +}); + +describe('record_sim_video logic - end-to-end stop with rename', () => { + it('stops, parses stdout path, and renames to outputFile', async () => { + const video: any = { + startSimulatorVideoCapture: async () => ({ + started: true, + sessionId: 'sess-abc', + }), + stopSimulatorVideoCapture: async () => ({ + stopped: true, + parsedPath: '/tmp/recorded.mp4', + stdout: 'Saved to /tmp/recorded.mp4', + }), + }; + + const fs = createMockFileSystemExecutor(); + + const axe = { + areAxeToolsAvailable: () => true, + isAxeAtLeastVersion: async () => true, + createAxeNotAvailableResponse: () => ({ + content: [{ type: 'text', text: 'AXe not available' }], + isError: true, + }), + }; + + // Start (not strictly required for stop path, but included to mimic flow) + const startRes = await record_sim_videoLogic( + { + simulatorUuid: VALID_UUID, + start: true, + } as any, + DUMMY_EXECUTOR, + axe, + video, + fs, + ); + expect(startRes.isError).toBe(false); + + // Stop and rename + const outputFile = '/var/videos/final.mp4'; + const stopRes = await record_sim_videoLogic( + { + simulatorUuid: VALID_UUID, + stop: true, + outputFile, + } as any, + DUMMY_EXECUTOR, + axe, + video, + fs, + ); + + expect(stopRes.isError).toBe(false); + const texts = (stopRes.content ?? []).map((c: any) => c.text).join('\n'); + expect(texts).toContain('Original file: /tmp/recorded.mp4'); + expect(texts).toContain(`Saved to: ${outputFile}`); + + // _meta should include final saved path + expect((stopRes as any)._meta?.outputFile).toBe(outputFile); + }); +}); + +describe('record_sim_video logic - version gate', () => { + it('errors when AXe version is below 1.1.0', async () => { + const axe = { + areAxeToolsAvailable: () => true, + isAxeAtLeastVersion: async () => false, + createAxeNotAvailableResponse: () => ({ + content: [{ type: 'text', text: 'AXe not available' }], + isError: true, + }), + }; + + const video: any = { + startSimulatorVideoCapture: async () => ({ + started: true, + sessionId: 'sess-xyz', + }), + stopSimulatorVideoCapture: async () => ({ + stopped: true, + }), + }; + + const fs = createMockFileSystemExecutor(); + + const res = await record_sim_videoLogic( + { + simulatorUuid: VALID_UUID, + start: true, + } as any, + DUMMY_EXECUTOR, + axe, + video, + fs, + ); + + expect(res.isError).toBe(true); + const text = (res.content?.[0] as any)?.text ?? ''; + expect(text).toContain('AXe v1.1.0'); + }); +}); diff --git a/src/mcp/tools/simulator/record_sim_video.ts b/src/mcp/tools/simulator/record_sim_video.ts new file mode 100644 index 00000000..2db256db --- /dev/null +++ b/src/mcp/tools/simulator/record_sim_video.ts @@ -0,0 +1,227 @@ +import { z } from 'zod'; +import type { ToolResponse } from '../../../types/common.ts'; +import { createTextResponse } from '../../../utils/responses/index.ts'; +import { + getDefaultCommandExecutor, + getDefaultFileSystemExecutor, +} from '../../../utils/execution/index.ts'; +import type { CommandExecutor, FileSystemExecutor } from '../../../utils/execution/index.ts'; +import { + areAxeToolsAvailable, + isAxeAtLeastVersion, + createAxeNotAvailableResponse, +} from '../../../utils/axe/index.ts'; +import { + startSimulatorVideoCapture, + stopSimulatorVideoCapture, +} from '../../../utils/video-capture/index.ts'; +import { createTypedTool } from '../../../utils/typed-tool-factory.ts'; +import { dirname } from 'path'; + +// Base schema object (used for MCP schema exposure) +const recordSimVideoSchemaObject = z.object({ + simulatorUuid: z + .string() + .uuid('Invalid Simulator UUID format') + .describe('UUID of the simulator to record'), + start: z.boolean().optional().describe('Start recording if true'), + stop: z.boolean().optional().describe('Stop recording if true'), + fps: z.number().int().min(1).max(120).optional().describe('Frames per second (default 30)'), + outputFile: z + .string() + .optional() + .describe('Destination MP4 path to move the recorded video to on stop'), +}); + +// Schema enforcing mutually exclusive start/stop and requiring outputFile on stop +const recordSimVideoSchema = recordSimVideoSchemaObject + .refine( + (v) => { + const s = v.start === true ? 1 : 0; + const t = v.stop === true ? 1 : 0; + return s + t === 1; + }, + { + message: + 'Provide exactly one of start=true or stop=true; these options are mutually exclusive', + path: ['start'], + }, + ) + .refine((v) => (v.stop ? typeof v.outputFile === 'string' && v.outputFile.length > 0 : true), { + message: 'outputFile is required when stop=true', + path: ['outputFile'], + }); + +type RecordSimVideoParams = z.infer; + +export async function record_sim_videoLogic( + params: RecordSimVideoParams, + executor: CommandExecutor, + axe: { + areAxeToolsAvailable(): boolean; + isAxeAtLeastVersion(v: string, e: CommandExecutor): Promise; + createAxeNotAvailableResponse(): ToolResponse; + } = { + areAxeToolsAvailable, + isAxeAtLeastVersion, + createAxeNotAvailableResponse, + }, + video: { + startSimulatorVideoCapture: typeof startSimulatorVideoCapture; + stopSimulatorVideoCapture: typeof stopSimulatorVideoCapture; + } = { + startSimulatorVideoCapture, + stopSimulatorVideoCapture, + }, + fs: FileSystemExecutor = getDefaultFileSystemExecutor(), +): Promise { + // Preflight checks for AXe availability and version + if (!axe.areAxeToolsAvailable()) { + return axe.createAxeNotAvailableResponse(); + } + const hasVersion = await axe.isAxeAtLeastVersion('1.1.0', executor); + if (!hasVersion) { + return createTextResponse( + 'AXe v1.1.0 or newer is required for simulator video capture. Please update bundled AXe artifacts.', + true, + ); + } + + // using injected fs executor + + if (params.start) { + const fpsUsed = Number.isFinite(params.fps as number) ? Number(params.fps) : 30; + const startRes = await video.startSimulatorVideoCapture( + { simulatorUuid: params.simulatorUuid, fps: fpsUsed }, + executor, + ); + + if (!startRes.started) { + return createTextResponse( + `Failed to start video recording: ${startRes.error ?? 'Unknown error'}`, + true, + ); + } + + const notes: string[] = []; + if (typeof params.outputFile === 'string' && params.outputFile.length > 0) { + notes.push( + 'Note: outputFile is ignored when start=true; provide it when stopping to move/rename the recorded file.', + ); + } + if (startRes.warning) { + notes.push(startRes.warning); + } + + const nextSteps = `Next Steps: +Stop and save the recording: +record_sim_video({ simulatorUuid: "${params.simulatorUuid}", stop: true, outputFile: "/path/to/output.mp4" })`; + + return { + content: [ + { + type: 'text', + text: `๐ŸŽฅ Video recording started for simulator ${params.simulatorUuid} at ${fpsUsed} fps.\nSession: ${startRes.sessionId}`, + }, + ...(notes.length > 0 + ? [ + { + type: 'text' as const, + text: notes.join('\n'), + }, + ] + : []), + { + type: 'text', + text: nextSteps, + }, + ], + isError: false, + }; + } + + // params.stop must be true here per schema + const stopRes = await video.stopSimulatorVideoCapture( + { simulatorUuid: params.simulatorUuid }, + executor, + ); + + if (!stopRes.stopped) { + return createTextResponse( + `Failed to stop video recording: ${stopRes.error ?? 'Unknown error'}`, + true, + ); + } + + // Attempt to move/rename the recording if we parsed a source path and an outputFile was given + const outputs: string[] = []; + let finalSavedPath = params.outputFile ?? stopRes.parsedPath ?? ''; + try { + if (params.outputFile) { + if (!stopRes.parsedPath) { + return createTextResponse( + `Recording stopped but could not determine the recorded file path from AXe output.\nRaw output:\n${stopRes.stdout ?? '(no output captured)'}`, + true, + ); + } + + const src = stopRes.parsedPath; + const dest = params.outputFile; + await fs.mkdir(dirname(dest), { recursive: true }); + await fs.cp(src, dest); + try { + await fs.rm(src, { recursive: false }); + } catch { + // Ignore cleanup failure + } + finalSavedPath = dest; + + outputs.push(`Original file: ${src}`); + outputs.push(`Saved to: ${dest}`); + } else if (stopRes.parsedPath) { + outputs.push(`Saved to: ${stopRes.parsedPath}`); + finalSavedPath = stopRes.parsedPath; + } + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + return createTextResponse( + `Recording stopped but failed to save/move the video file: ${msg}`, + true, + ); + } + + return { + content: [ + { + type: 'text', + text: `โœ… Video recording stopped for simulator ${params.simulatorUuid}.`, + }, + ...(outputs.length > 0 + ? [ + { + type: 'text' as const, + text: outputs.join('\n'), + }, + ] + : []), + ...(!outputs.length && stopRes.stdout + ? [ + { + type: 'text' as const, + text: `AXe output:\n${stopRes.stdout}`, + }, + ] + : []), + ], + isError: false, + _meta: finalSavedPath ? { outputFile: finalSavedPath } : undefined, + }; +} + +export default { + name: 'record_sim_video', + description: + 'Starts or stops video capture for an iOS simulator using AXe. Provide exactly one of start=true or stop=true. On stop, outputFile is required. fps defaults to 30.', + schema: recordSimVideoSchemaObject.shape, + handler: createTypedTool(recordSimVideoSchema, record_sim_videoLogic, getDefaultCommandExecutor), +}; diff --git a/src/utils/axe-helpers.ts b/src/utils/axe-helpers.ts index 5326e2b5..30b1fe47 100644 --- a/src/utils/axe-helpers.ts +++ b/src/utils/axe-helpers.ts @@ -10,6 +10,8 @@ import { dirname, join } from 'path'; import { fileURLToPath } from 'url'; import { createTextResponse } from './validation.ts'; import { ToolResponse } from '../types/common.ts'; +import type { CommandExecutor } from './execution/index.ts'; +import { getDefaultCommandExecutor } from './execution/index.ts'; // Get bundled AXe path - always use the bundled version for consistency const __filename = fileURLToPath(import.meta.url); @@ -53,3 +55,48 @@ export function createAxeNotAvailableResponse(): ToolResponse { true, ); } + +/** + * Compare two semver strings a and b. + * Returns 1 if a > b, -1 if a < b, 0 if equal. + */ +function compareSemver(a: string, b: string): number { + const pa = a.split('.').map((n) => parseInt(n, 10)); + const pb = b.split('.').map((n) => parseInt(n, 10)); + const len = Math.max(pa.length, pb.length); + for (let i = 0; i < len; i++) { + const da = Number.isFinite(pa[i]) ? pa[i] : 0; + const db = Number.isFinite(pb[i]) ? pb[i] : 0; + if (da > db) return 1; + if (da < db) return -1; + } + return 0; +} + +/** + * Determine whether the bundled AXe meets a minimum version requirement. + * Runs `axe --version` and parses a semantic version (e.g., "1.1.0"). + * If AXe is missing or the version cannot be parsed, returns false. + */ +export async function isAxeAtLeastVersion( + required: string, + executor?: CommandExecutor, +): Promise { + const axePath = getAxePath(); + if (!axePath) return false; + + const exec = executor ?? getDefaultCommandExecutor(); + try { + const res = await exec([axePath, '--version'], 'AXe Version', true); + if (!res.success) return false; + + const output = res.output ?? ''; + const versionMatch = output.match(/(\d+\.\d+\.\d+)/); + if (!versionMatch) return false; + + const current = versionMatch[1]; + return compareSemver(current, required) >= 0; + } catch { + return false; + } +} diff --git a/src/utils/axe/index.ts b/src/utils/axe/index.ts index b3e39cc0..0ab22ebc 100644 --- a/src/utils/axe/index.ts +++ b/src/utils/axe/index.ts @@ -3,4 +3,5 @@ export { getAxePath, getBundledAxeEnvironment, areAxeToolsAvailable, + isAxeAtLeastVersion, } from '../axe-helpers.ts'; diff --git a/src/utils/video-capture/index.ts b/src/utils/video-capture/index.ts new file mode 100644 index 00000000..1d3f5f2f --- /dev/null +++ b/src/utils/video-capture/index.ts @@ -0,0 +1,5 @@ +export { + startSimulatorVideoCapture, + stopSimulatorVideoCapture, + type AxeHelpers, +} from '../video_capture.ts'; diff --git a/src/utils/video_capture.ts b/src/utils/video_capture.ts new file mode 100644 index 00000000..53ab03e4 --- /dev/null +++ b/src/utils/video_capture.ts @@ -0,0 +1,214 @@ +/** + * Video capture utility for simulator recordings using AXe. + * + * Manages long-running AXe "record-video" processes keyed by simulator UUID. + * It aggregates stdout/stderr to parse the generated MP4 path on stop. + */ + +import type { ChildProcess } from 'child_process'; +import { log } from './logging/index.ts'; +import { getAxePath, getBundledAxeEnvironment } from './axe-helpers.ts'; +import type { CommandExecutor } from './execution/index.ts'; + +type Session = { + process: unknown; + sessionId: string; + startedAt: number; + buffer: string; +}; + +const sessions = new Map(); +let signalHandlersAttached = false; + +export interface AxeHelpers { + getAxePath: () => string | null; + getBundledAxeEnvironment: () => Record; +} + +function ensureSignalHandlersAttached(): void { + if (signalHandlersAttached) return; + signalHandlersAttached = true; + + const stopAll = () => { + for (const [simulatorUuid, sess] of sessions) { + try { + const child = sess.process as ChildProcess | undefined; + child?.kill?.('SIGINT'); + } catch { + // ignore + } finally { + sessions.delete(simulatorUuid); + } + } + }; + + try { + process.on('SIGINT', stopAll); + process.on('SIGTERM', stopAll); + process.on('exit', stopAll); + } catch { + // Non-Node environments may not support process signals; ignore + } +} + +function parseLastAbsoluteMp4Path(buffer: string | undefined): string | null { + if (!buffer) return null; + const matches = [...buffer.matchAll(/(\s|^)(\/[^\s'"]+\.mp4)\b/gi)]; + if (matches.length === 0) return null; + const last = matches[matches.length - 1]; + return last?.[2] ?? null; +} + +function createSessionId(simulatorUuid: string): string { + return `${simulatorUuid}:${Date.now()}`; +} + +/** + * Start recording video for a simulator using AXe. + */ +export async function startSimulatorVideoCapture( + params: { simulatorUuid: string; fps?: number }, + executor: CommandExecutor, + axeHelpers?: AxeHelpers, +): Promise<{ started: boolean; sessionId?: string; warning?: string; error?: string }> { + const simulatorUuid = params.simulatorUuid; + if (!simulatorUuid) { + return { started: false, error: 'simulatorUuid is required' }; + } + + if (sessions.has(simulatorUuid)) { + return { + started: false, + error: 'A video recording session is already active for this simulator. Stop it first.', + }; + } + + const helpers = axeHelpers ?? { + getAxePath, + getBundledAxeEnvironment, + }; + + const axeBinary = helpers.getAxePath(); + if (!axeBinary) { + return { started: false, error: 'Bundled AXe binary not found' }; + } + + const fps = Number.isFinite(params.fps as number) ? Number(params.fps) : 30; + const command = [axeBinary, 'record-video', '--udid', simulatorUuid, '--fps', String(fps)]; + const env = helpers.getBundledAxeEnvironment?.() ?? {}; + + log('info', `Starting AXe video recording for simulator ${simulatorUuid} at ${fps} fps`); + + const result = await executor(command, 'Start Simulator Video Capture', true, { env }, true); + + if (!result.success || !result.process) { + return { + started: false, + error: result.error ?? 'Failed to start video capture process', + }; + } + + const child = result.process as ChildProcess; + const session: Session = { + process: child, + sessionId: createSessionId(simulatorUuid), + startedAt: Date.now(), + buffer: '', + }; + + try { + child.stdout?.on('data', (d: unknown) => { + try { + session.buffer += String(d ?? ''); + } catch { + // ignore + } + }); + child.stderr?.on('data', (d: unknown) => { + try { + session.buffer += String(d ?? ''); + } catch { + // ignore + } + }); + } catch { + // ignore stream listener setup failures + } + + sessions.set(simulatorUuid, session); + ensureSignalHandlersAttached(); + + return { + started: true, + sessionId: session.sessionId, + warning: fps !== (params.fps ?? 30) ? `FPS coerced to ${fps}` : undefined, + }; +} + +/** + * Stop recording video for a simulator. Returns aggregated output and parsed MP4 path if found. + */ +export async function stopSimulatorVideoCapture( + params: { simulatorUuid: string }, + executor: CommandExecutor, +): Promise<{ + stopped: boolean; + sessionId?: string; + stdout?: string; + parsedPath?: string; + error?: string; +}> { + // Mark executor as used to satisfy lint rule + void executor; + + const simulatorUuid = params.simulatorUuid; + if (!simulatorUuid) { + return { stopped: false, error: 'simulatorUuid is required' }; + } + + const session = sessions.get(simulatorUuid); + if (!session) { + return { stopped: false, error: 'No active video recording session for this simulator' }; + } + + const child = session.process as ChildProcess | undefined; + + // Attempt graceful shutdown + try { + child?.kill?.('SIGINT'); + } catch { + try { + child?.kill?.(); + } catch { + // ignore + } + } + + // Wait for process to close + await new Promise((resolve) => { + if (!child) return resolve(); + try { + child.once('close', () => resolve()); + child.once('exit', () => resolve()); + } catch { + resolve(); + } + }); + + const combinedOutput = session.buffer; + const parsedPath = parseLastAbsoluteMp4Path(combinedOutput) ?? undefined; + + sessions.delete(simulatorUuid); + + log( + 'info', + `Stopped AXe video recording for simulator ${simulatorUuid}. ${parsedPath ? `Detected file: ${parsedPath}` : 'No file detected in output.'}`, + ); + + return { + stopped: true, + sessionId: session.sessionId, + stdout: combinedOutput, + parsedPath, + }; +} From 57c84e42b62604f8fa28c47c09f1a8131be27776 Mon Sep 17 00:00:00 2001 From: Cameron Cooke Date: Mon, 22 Sep 2025 20:33:53 +0100 Subject: [PATCH 3/3] fix(simulator): background video capture and avoid hangs when AXe already exited\n\n- Spawn AXe with detached exec and pass env correctly\n- Track session.ended on child exit/close\n- Fast-path resolve in stop when process already ended\n- Add 5s safety timeout to prevent indefinite waits --- src/utils/video_capture.ts | 44 ++++++++++++++++++++++++++++++++------ 1 file changed, 38 insertions(+), 6 deletions(-) diff --git a/src/utils/video_capture.ts b/src/utils/video_capture.ts index 53ab03e4..ba6063b4 100644 --- a/src/utils/video_capture.ts +++ b/src/utils/video_capture.ts @@ -15,6 +15,7 @@ type Session = { sessionId: string; startedAt: number; buffer: string; + ended: boolean; }; const sessions = new Map(); @@ -29,7 +30,7 @@ function ensureSignalHandlersAttached(): void { if (signalHandlersAttached) return; signalHandlersAttached = true; - const stopAll = () => { + const stopAll = (): void => { for (const [simulatorUuid, sess] of sessions) { try { const child = sess.process as ChildProcess | undefined; @@ -114,6 +115,7 @@ export async function startSimulatorVideoCapture( sessionId: createSessionId(simulatorUuid), startedAt: Date.now(), buffer: '', + ended: false, }; try { @@ -135,6 +137,18 @@ export async function startSimulatorVideoCapture( // ignore stream listener setup failures } + // Track when the child process naturally ends, so stop can short-circuit + try { + child.once?.('exit', () => { + session.ended = true; + }); + child.once?.('close', () => { + session.ended = true; + }); + } catch { + // ignore + } + sessions.set(simulatorUuid, session); ensureSignalHandlersAttached(); @@ -184,15 +198,33 @@ export async function stopSimulatorVideoCapture( } } - // Wait for process to close - await new Promise((resolve) => { + // Wait for process to close (avoid hanging if it already exited) + await new Promise((resolve): void => { if (!child) return resolve(); + + // If process has already ended, resolve immediately + const alreadyEnded = (session as Session).ended === true; + const hasExitCode = (child as ChildProcess).exitCode !== null; + const hasSignal = (child as unknown as { signalCode?: string | null }).signalCode != null; + if (alreadyEnded || hasExitCode || hasSignal) { + return resolve(); + } + + let resolved = false; + const finish = (): void => { + if (!resolved) { + resolved = true; + resolve(); + } + }; try { - child.once('close', () => resolve()); - child.once('exit', () => resolve()); + child.once('close', finish); + child.once('exit', finish); } catch { - resolve(); + return finish(); } + // Safety timeout to prevent indefinite hangs + setTimeout(finish, 5000); }); const combinedOutput = session.buffer;