diff --git a/CHANGELOG.md b/CHANGELOG.md index 1130fa825..bbf2dcf49 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,10 +2,28 @@ ## [Unreleased] +### Added + +- Added experimental opt-in Simulator operation leases with isolated storage, persistent worktree bindings, MCP/CLI/daemon management, and target enforcement for boot readiness, installation, app launch, screenshots, UI snapshots, taps, and text input. Explicit provisioning creates and verifies a dedicated device once per worktree, retaining uncertain creation records instead of automatically replacing devices. Managed boot holds the operation until readiness is verified and blocks handoff on uncertain completion. Managed app launch returns its PID without background log capture and retains uncertain launch outcomes. Managed UI references expire across refreshes, handoffs, and intervening runtime calls; uncertain commands prevent follow-up actions. Unsupported tools, including batch UI actions and background capture, remain blocked in managed mode. +- Added managed compile-only build support for `build_sim` under experimental Simulator operation leases, requiring explicit source inputs, scheme, and operation credentials. Managed builds run standard supervised `xcodebuild` on the bound simulator, invalidate prior UI snapshots, require explicit release on success or compile failure, and reject extra arguments, test preparation, or incremental build overrides. +- Added managed two-phase `test_sim` support under experimental Simulator operation leases, with explicit source and operation credentials, serialized supervised test execution, asynchronous result metadata, dedicated Simulator shutdown verification, and explicit cleanup semantics. + ### Changed - Dictionary-shaped MCP inputs now use client-compatible wire representations ([#491](https://github.com/getsentry/XcodeBuildMCP/issues/491)). The `env` and `testRunnerEnv` inputs on build, launch, test, and session-default tools are arrays of `{ "key": "...", "value": "..." }` entries, while `xcode_ide_call_tool.arguments` is a JSON object string. XcodeBuildMCP converts these values to their existing internal objects only after MCP input validation. +### Fixed + +- Fixed UI automation reporting a missing Simulator when its UUID contains lowercase letters, including UUIDs returned by managed operation leases. +- Fixed xcodebuild reporting "Unable to find device" when simulatorId contains lowercase letters by normalizing the destination UUID to uppercase at the xcodebuild command boundary. +- Fixed managed test execution rejecting Xcode's invalid `-test-iterations 1` flag by preserving project and test-plan repetition settings while retaining serialized simulator destinations. +- Fixed managed Phase 1 test failures with confirmed numeric nonzero exits being misclassified as uncertain; Phase 2 continues to accept only exits 0 and 65 as known outcomes. +- Fixed managed build and test admission to require exactly one explicit nonblank project or workspace input together with a nonblank scheme and bound Simulator UUID. +- Fixed daemon socket resolution so an explicit `XCODEBUILDMCP_SOCKET` is honored before ordinary workspace discovery, while managed mode continues to validate the isolated workspace and resource namespace. +- Fixed managed activity completion to persist retry and replay receipts before compacting the operation header, and to wait through lock contention instead of abandoning finalization at the normal 10-second transaction deadline. +- Fixed `resource_operation` end and cancel calls from reporting success without changing a blocked operation; blocked leases now return an explicit no-recovery error. +- Fixed managed `test_sim` to allow safe `-only-testing` and `-skip-testing` selectors while continuing to reject destination and execution-policy overrides. + ## [2.7.0] ### New! Xcode 27 Device Hub simulator support diff --git a/README.md b/README.md index 5e111a3eb..d8c2cd567 100644 --- a/README.md +++ b/README.md @@ -65,6 +65,10 @@ For further information on installing skills, see [Agent Skills](https://xcodebu - XcodeBuildMCP requests xcodebuild to skip macro validation to avoid errors when building projects that use Swift Macros. - Device tools require code signing to be configured in Xcode. See [Device Code Signing](https://xcodebuildmcp.com/docs/device-signing). +## Experimental managed Simulator operations + +Managed Simulator provisioning and worktree-scoped operation leases are experimental and opt-in. See the [managed Simulator operations guide](src/resource-management/README.md) for configuration, lease lifecycle, tool contracts, and limitations. + ## Privacy XcodeBuildMCP uses Sentry for internal runtime error telemetry only. For details and opt-out instructions, see [Privacy & Telemetry](https://xcodebuildmcp.com/docs/privacy). diff --git a/manifests/tools/boot_sim.yaml b/manifests/tools/boot_sim.yaml index 4acd0048c..da4edc3b2 100644 --- a/manifests/tools/boot_sim.yaml +++ b/manifests/tools/boot_sim.yaml @@ -3,10 +3,10 @@ module: mcp/tools/simulator/boot_sim names: mcp: boot_sim cli: boot -description: Boot iOS simulator for manual/non-build flows. Not required before simulator build-and-run (build_run_sim). +description: Boot iOS simulator for manual/non-build flows. Not required before simulator build-and-run (build_run_sim). In managed mode, requires an operation lease and waits for boot readiness. outputSchema: schema: xcodebuildmcp.output.simulator-action-result - version: "2" + version: '2' annotations: title: Boot Simulator readOnlyHint: false diff --git a/manifests/tools/build_sim.yaml b/manifests/tools/build_sim.yaml index 43b265578..f8109925c 100644 --- a/manifests/tools/build_sim.yaml +++ b/manifests/tools/build_sim.yaml @@ -3,7 +3,7 @@ module: mcp/tools/simulator/build_sim names: mcp: build_sim cli: build -description: Build for iOS sim (compile-only, no launch). +description: Build for iOS sim (compile-only, no launch). In managed mode, executes standard supervised xcodebuild under an operation lease and requires explicit projectPath/workspacePath, scheme, simulatorId, and operation credentials. Non-empty extraArgs, buildForTesting=true, testProductsPath, and preferXcodebuild=false are rejected. Operation remains held after successful builds and confirmed compile failures until explicit end; uncertain command completion or open streams block the lease. Supervision covers direct child exit and closed streams, without process-tree guarantees for user build scripts. build_run_sim remains unsupported in managed mode. outputSchema: schema: xcodebuildmcp.output.build-result version: '3' diff --git a/manifests/tools/launch_app_sim.yaml b/manifests/tools/launch_app_sim.yaml index 5e96d2752..a5c6c0942 100644 --- a/manifests/tools/launch_app_sim.yaml +++ b/manifests/tools/launch_app_sim.yaml @@ -3,10 +3,10 @@ module: mcp/tools/simulator/launch_app_sim names: mcp: launch_app_sim cli: launch-app -description: Launch app on simulator. Runtime logs are captured automatically and the log file path is included in the response. +description: Launch app on simulator. In managed mode, requires an operation lease and explicit bundle ID, restarts the app, and returns its PID without background log capture. Otherwise, runtime logs are captured automatically and the log file path is included in the response. outputSchema: schema: xcodebuildmcp.output.launch-result - version: "2" + version: '2' annotations: title: Launch App Simulator readOnlyHint: false diff --git a/manifests/tools/resource_operation.yaml b/manifests/tools/resource_operation.yaml new file mode 100644 index 000000000..75556c029 --- /dev/null +++ b/manifests/tools/resource_operation.yaml @@ -0,0 +1,16 @@ +id: resource_operation +module: mcp/tools/resource-management/resource_operation +names: + mcp: resource_operation + cli: operation +description: Begin, inspect, poll, end, or cancel an operation on this worktree's bound Simulator. Retain the returned credentials across calls; explicitly end after all work is finished. Does not provision a Simulator. +predicates: + - managedResourcesEnabled +outputSchema: + schema: xcodebuildmcp.output.resource-operation + version: '1' +annotations: + title: Manage Simulator Operation + readOnlyHint: false + destructiveHint: false + openWorldHint: false diff --git a/manifests/tools/resource_provision.yaml b/manifests/tools/resource_provision.yaml new file mode 100644 index 000000000..9832e0ddd --- /dev/null +++ b/manifests/tools/resource_provision.yaml @@ -0,0 +1,16 @@ +id: resource_provision +module: mcp/tools/resource-management/resource_provision +names: + mcp: resource_provision + cli: provision +description: Create this worktree's dedicated Simulator on first use, or verify its existing binding. Requires explicit device type and runtime identifiers. Does not boot, replace, delete, or adopt an existing device. Begin an operation separately before using the Simulator. +predicates: + - managedResourcesEnabled +outputSchema: + schema: xcodebuildmcp.output.resource-binding + version: '1' +annotations: + title: Provision Worktree Simulator + readOnlyHint: false + destructiveHint: false + openWorldHint: false diff --git a/manifests/tools/snapshot_ui.yaml b/manifests/tools/snapshot_ui.yaml index c0746db8c..972ca98c8 100644 --- a/manifests/tools/snapshot_ui.yaml +++ b/manifests/tools/snapshot_ui.yaml @@ -3,7 +3,7 @@ module: mcp/tools/ui-automation/snapshot_ui names: mcp: snapshot_ui cli: snapshot-ui -description: Capture a semantic rs/1 runtime UI snapshot with elementRef targets. Observe once, use tap for one target or batch for multiple same-screen targets, and refresh after navigation, scrolling, sheet changes, or obvious layout changes. +description: Capture a semantic rs/1 runtime UI snapshot with elementRef targets. In managed mode, requires operation credentials and always returns a full snapshot with fresh refs; batch and wait_for_ui are not yet supported. Observe once, use tap for one target or batch for multiple same-screen targets, and refresh after navigation, scrolling, sheet changes, or obvious layout changes. outputSchema: schema: xcodebuildmcp.output.capture-result version: '2' diff --git a/manifests/tools/tap.yaml b/manifests/tools/tap.yaml index 9ffc8bb6c..147ea634e 100644 --- a/manifests/tools/tap.yaml +++ b/manifests/tools/tap.yaml @@ -4,7 +4,7 @@ names: mcp: tap cli: tap description: >- - Tap one elementRef from the latest snapshot_ui or wait_for_ui output. The elementRef must list the tap action in the snapshot targets; do not use refs from text-only rows. For multiple same-screen taps or visible switch toggles with no intermediate assertion, use batch instead of repeated tap calls. Other same-screen refs may remain usable after success; refresh after navigation, scrolling, sheet changes, or obvious layout changes. + Tap one elementRef from the latest snapshot_ui or wait_for_ui output. In managed mode, requires operation credentials and a ref from this operation and runtime; use fresh refs returned after each action, and batch is not yet supported. The elementRef must list the tap action in the snapshot targets; do not use refs from text-only rows. For multiple same-screen taps or visible switch toggles with no intermediate assertion, use batch instead of repeated tap calls. Other same-screen refs may remain usable after success; refresh after navigation, scrolling, sheet changes, or obvious layout changes. outputSchema: schema: xcodebuildmcp.output.ui-action-result version: '2' diff --git a/manifests/tools/test_sim.yaml b/manifests/tools/test_sim.yaml index 1e2e527a9..069fb0c07 100644 --- a/manifests/tools/test_sim.yaml +++ b/manifests/tools/test_sim.yaml @@ -3,10 +3,10 @@ module: mcp/tools/simulator/test_sim names: mcp: test_sim cli: test -description: Test on iOS sim. +description: Test on iOS sim. In managed mode, executes standard supervised two-phase xcodebuild under an operation lease and requires explicit projectPath or workspacePath, scheme, simulatorId, and operation credentials. Prepared test artifacts, unsafe extraArgs, simulatorName, buildForTesting, and preferXcodebuild=false are rejected; -only-testing and -skip-testing selectors are allowed. testRunnerEnv is accepted in wire format. Phase 2 disables parallel testing and limits concurrent simulator destinations to one while preserving project and test-plan repetition settings. On normal phase 2 exit 0 or 65, metadata extraction is followed by shutdown of the specified Simulator and a unique available-catalog verification of its Shutdown state, clearing running app state without erase/delete or auto-reboot (subsequent UI automation requires boot_sim). Confirmed phase 1 compile failures do not shut down the simulator. Completed runs release upon explicit end; in-flight calls hold concurrent end in closing. Signals, rejected or unknown commands, open streams, cleanup failures, or unexpected exceptions after phase 2 starts and before verified shutdown block the lease without automatic TTL or recovery. Supervision confirms CoreSimulator Shutdown state without arbitrary host script process-tree guarantees. Output uses schema version 3, UI snapshots are invalidated upon admission, and next-step templates are suppressed. Normal MCP and native session resume remain available without an external resume script. outputSchema: schema: xcodebuildmcp.output.test-result - version: "3" + version: '3' predicates: - hideWhenXcodeAgentMode annotations: diff --git a/manifests/tools/type_text.yaml b/manifests/tools/type_text.yaml index 1cc50c2db..fdf656359 100644 --- a/manifests/tools/type_text.yaml +++ b/manifests/tools/type_text.yaml @@ -4,10 +4,10 @@ names: mcp: type_text cli: type-text description: >- - Type text into a UI element by elementRef from a current rs/1 runtime snapshot, optionally replacing existing field contents. elementRef is required; do not call with only text. Example input: {"elementRef":"e8","text":"London","replaceExisting":true}. + Type text into a UI element by elementRef from a current rs/1 runtime snapshot, optionally replacing existing field contents. In managed mode, requires operation credentials and a ref from this operation and runtime; use fresh refs returned after each action. elementRef is required; do not call with only text. Example input: {"elementRef":"e8","text":"London","replaceExisting":true}. outputSchema: schema: xcodebuildmcp.output.ui-action-result - version: "2" + version: '2' routing: stateful: true annotations: diff --git a/manifests/workflows/resource-management.yaml b/manifests/workflows/resource-management.yaml new file mode 100644 index 000000000..85ba5e62f --- /dev/null +++ b/manifests/workflows/resource-management.yaml @@ -0,0 +1,12 @@ +id: resource-management +title: Simulator Resource Management +description: Explicit operation leases for a persistent worktree Simulator. +targetPlatforms: [] +predicates: + - managedResourcesEnabled +selection: + mcp: + autoInclude: true +tools: + - resource_operation + - resource_provision diff --git a/schemas/structured-output/xcodebuildmcp.output.resource-binding/1.schema.json b/schemas/structured-output/xcodebuildmcp.output.resource-binding/1.schema.json new file mode 100644 index 000000000..0f1c6fe73 --- /dev/null +++ b/schemas/structured-output/xcodebuildmcp.output.resource-binding/1.schema.json @@ -0,0 +1,87 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://xcodebuildmcp.com/schemas/structured-output/xcodebuildmcp.output.resource-binding/1.schema.json", + "type": "object", + "additionalProperties": false, + "allOf": [ + { + "$ref": "https://xcodebuildmcp.com/schemas/structured-output/_defs/common.schema.json#/$defs/errorConsistency" + }, + { + "if": { + "properties": { + "didError": { + "const": false + } + } + }, + "then": { + "properties": { + "data": { + "properties": { + "simulatorId": { + "type": "string" + } + }, + "type": "object" + } + } + } + } + ], + "properties": { + "schema": { + "const": "xcodebuildmcp.output.resource-binding" + }, + "schemaVersion": { + "const": "1" + }, + "didError": { + "type": "boolean" + }, + "error": { + "type": [ + "string", + "null" + ] + }, + "data": { + "type": "object", + "additionalProperties": false, + "properties": { + "worktreeGeneration": { + "type": "string", + "pattern": "^[a-f0-9]{64}$" + }, + "simulatorId": { + "type": [ + "string", + "null" + ] + }, + "deviceType": { + "type": "string" + }, + "runtime": { + "type": "string" + } + }, + "required": [ + "worktreeGeneration", + "simulatorId", + "deviceType", + "runtime" + ] + }, + "nextSteps": { + "$ref": "https://xcodebuildmcp.com/schemas/structured-output/_defs/common.schema.json#/$defs/nextSteps" + } + }, + "required": [ + "schema", + "schemaVersion", + "didError", + "error", + "data" + ] +} diff --git a/schemas/structured-output/xcodebuildmcp.output.resource-operation/1.schema.json b/schemas/structured-output/xcodebuildmcp.output.resource-operation/1.schema.json new file mode 100644 index 000000000..5f0a2c80b --- /dev/null +++ b/schemas/structured-output/xcodebuildmcp.output.resource-operation/1.schema.json @@ -0,0 +1,90 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://xcodebuildmcp.com/schemas/structured-output/xcodebuildmcp.output.resource-operation/1.schema.json", + "type": "object", + "additionalProperties": false, + "allOf": [ + { + "$ref": "https://xcodebuildmcp.com/schemas/structured-output/_defs/common.schema.json#/$defs/errorConsistency" + } + ], + "properties": { + "schema": { + "const": "xcodebuildmcp.output.resource-operation" + }, + "schemaVersion": { + "const": "1" + }, + "didError": { + "type": "boolean" + }, + "error": { + "type": [ + "string", + "null" + ] + }, + "data": { + "type": "object", + "additionalProperties": false, + "properties": { + "requestId": { + "type": "string" + }, + "sessionId": { + "type": "string" + }, + "simulatorId": { + "type": "string" + }, + "token": { + "type": "string" + }, + "reason": { + "type": "string" + }, + "action": { + "enum": [ + "begin", + "poll", + "status", + "end", + "cancel" + ] + }, + "state": { + "enum": [ + "waiting", + "active", + "closing", + "blocked", + "released", + "cancelled" + ] + }, + "activityCount": { + "type": "integer", + "minimum": 0 + } + }, + "required": [ + "action", + "requestId", + "sessionId", + "simulatorId", + "state", + "activityCount" + ] + }, + "nextSteps": { + "$ref": "https://xcodebuildmcp.com/schemas/structured-output/_defs/common.schema.json#/$defs/nextSteps" + } + }, + "required": [ + "schema", + "schemaVersion", + "didError", + "error", + "data" + ] +} diff --git a/src/cli/daemon-client.ts b/src/cli/daemon-client.ts index a61732380..0d8590277 100644 --- a/src/cli/daemon-client.ts +++ b/src/cli/daemon-client.ts @@ -18,7 +18,11 @@ import { type XcodeIdeInvokeParams, type XcodeIdeInvokeResult, } from '../daemon/protocol.ts'; -import { getSocketPath } from '../daemon/socket-path.ts'; +import { getSocketPath, assertManagedSocketPath } from '../daemon/socket-path.ts'; +import { + resourceEnvironment, + assertResourceNamespace, +} from '../resource-management/environment.ts'; import type { AnyFragment } from '../types/domain-fragments.ts'; export class DaemonVersionMismatchError extends Error { @@ -43,6 +47,7 @@ export class DaemonClient { constructor(opts: DaemonClientOptions = {}) { this.socketPath = opts.socketPath ?? getSocketPath(); + assertManagedSocketPath(this.socketPath); this.timeout = opts.timeout ?? 30000; } @@ -131,6 +136,7 @@ export class DaemonClient { * Stop the daemon. */ async stop(): Promise { + if (resourceEnvironment()) assertResourceNamespace((await this.status()).resourceNamespace); await this.request<{ ok: boolean }>('daemon.stop'); } @@ -149,6 +155,8 @@ export class DaemonClient { args: Record, options: InvokeToolOptions = {}, ): Promise { + const managed = resourceEnvironment(); + if (managed) assertResourceNamespace((await this.status()).resourceNamespace); const id = randomUUID(); const req: DaemonRequest = { v: DAEMON_PROTOCOL_VERSION, @@ -157,6 +165,7 @@ export class DaemonClient { params: { tool, args, + ...(managed ? { resourceNamespace: managed.namespace } : {}), }, }; diff --git a/src/cli/daemon-control.ts b/src/cli/daemon-control.ts index d6ce593a7..c0310dc30 100644 --- a/src/cli/daemon-control.ts +++ b/src/cli/daemon-control.ts @@ -11,6 +11,11 @@ import { type DaemonRegistryEntry, } from '../daemon/daemon-registry.ts'; import { isPidAlive } from '../utils/process-liveness.ts'; +import { assertManagedSocketPath } from '../daemon/socket-path.ts'; +import { + resourceEnvironment, + assertResourceNamespace, +} from '../resource-management/environment.ts'; /** * Default timeout for daemon startup in milliseconds. @@ -87,6 +92,7 @@ export function getDaemonExecutablePath(): string { * Uses registry ownership metadata to stop the process before unregistering daemon files. */ export async function forceStopDaemon(socketPath: string): Promise { + assertManagedSocketPath(socketPath); const entry = findDaemonRegistryEntryBySocketPath(socketPath); if (!entry) { throw new Error( @@ -132,6 +138,7 @@ export interface StartDaemonBackgroundOptions { * Does not wait for the daemon to be ready. */ export function startDaemonBackground(opts: StartDaemonBackgroundOptions): void { + assertManagedSocketPath(opts.socketPath, opts.workspaceRoot); const daemonPath = getDaemonExecutablePath(); const child = spawn(process.execPath, [daemonPath], { @@ -171,7 +178,8 @@ export async function waitForDaemonReady(opts: WaitForDaemonReadyOptions): Promi while (Date.now() - startTime < opts.timeoutMs) { try { // Use status() to confirm protocol handler is ready (not just connect) - await client.status(); + const status = await client.status(); + if (resourceEnvironment()) assertResourceNamespace(status.resourceNamespace); return; // Success } catch { // Not ready yet, wait and retry @@ -205,9 +213,11 @@ export async function ensureDaemonRunning(opts: EnsureDaemonRunningOptions): Pro const isRunning = await client.isRunning(); if (isRunning) { try { - await client.status(); + const status = await client.status(); + if (resourceEnvironment()) assertResourceNamespace(status.resourceNamespace); return; } catch (error) { + if (resourceEnvironment()) throw error; if (error instanceof DaemonVersionMismatchError) { await forceStopDaemon(opts.socketPath); } else { @@ -239,6 +249,7 @@ export interface StartDaemonForegroundOptions { * Used for debugging. The function returns when the daemon exits. */ export function startDaemonForeground(opts: StartDaemonForegroundOptions): Promise { + assertManagedSocketPath(opts.socketPath, opts.workspaceRoot); const daemonPath = getDaemonExecutablePath(); return new Promise((resolve) => { diff --git a/src/core/manifest/import-tool-module.ts b/src/core/manifest/import-tool-module.ts index 8c3f1f5dd..f6edbcd3c 100644 --- a/src/core/manifest/import-tool-module.ts +++ b/src/core/manifest/import-tool-module.ts @@ -8,6 +8,7 @@ import { pathToFileURL } from 'node:url'; import type { ToolSchemaShape } from '../plugin-types.ts'; import type { ToolHandlerContext } from '../../rendering/types.ts'; import { getPackageRoot } from './load-manifest.ts'; +import { managedToolSchema, wrapManagedTool } from '../../resource-management/tool-gate.ts'; export interface ImportedToolModule { schema: ToolSchemaShape; @@ -50,12 +51,15 @@ export async function importToolModule(moduleId: string): Promise, - ctx?: ToolHandlerContext, - ) => Promise, + schema: managedToolSchema(moduleId, mod.schema as ToolSchemaShape), + mcpSchema: managedToolSchema(moduleId, (mod.mcpSchema ?? mod.schema) as ToolSchemaShape), + handler: wrapManagedTool( + moduleId, + mod.handler as ( + params: Record, + ctx?: ToolHandlerContext, + ) => Promise, + ), }; moduleCache.set(moduleId, result); diff --git a/src/daemon.ts b/src/daemon.ts index efcdfea06..ef78feed4 100644 --- a/src/daemon.ts +++ b/src/daemon.ts @@ -4,6 +4,7 @@ import net from 'node:net'; import { dirname } from 'node:path'; import { existsSync, mkdirSync, renameSync, statSync } from 'node:fs'; import { bootstrapRuntime } from './runtime/bootstrap-runtime.ts'; +import { resourceEnvironment } from './resource-management/environment.ts'; import { buildDaemonToolCatalogFromManifest } from './runtime/tool-catalog.ts'; import { loadManifest } from './core/manifest/load-manifest.ts'; import { @@ -93,6 +94,8 @@ function rotateLogIfNeeded(logPath: string): void { function resolveDaemonLogPath(workspaceKey: string): string | null { const override = process.env.XCODEBUILDMCP_DAEMON_LOG_PATH?.trim(); if (override) { + if (resourceEnvironment()) + throw new Error('Managed resources do not support daemon log path overrides'); return override; } @@ -220,7 +223,7 @@ async function main(): Promise { const daemonWorkflows = allWorkflowIds.filter( (workflowId) => !excludedWorkflows.includes(workflowId), ); - const xcodeIdeWorkflowEnabled = daemonWorkflows.includes('xcode-ide'); + const xcodeIdeWorkflowEnabled = !resourceEnvironment() && daemonWorkflows.includes('xcode-ide'); const axeBinary = resolveAxeBinary(); const axeAvailable = axeBinary !== null; const axeSource: 'env' | 'source' | 'bundled' | 'path' | 'unavailable' = @@ -486,6 +489,7 @@ async function main(): Promise { // a slow sweep cannot delay request serving. Request handlers must not assume orphans // have been cleaned at startup. setImmediate(() => { + if (resourceEnvironment()) return; void enrichSentryMetadata().catch((error) => { const message = error instanceof Error ? error.message : String(error); log('warn', `[Daemon] Failed to enrich Sentry metadata: ${message}`); diff --git a/src/daemon/__tests__/socket-path.test.ts b/src/daemon/__tests__/socket-path.test.ts index fcb099890..2070c81d1 100644 --- a/src/daemon/__tests__/socket-path.test.ts +++ b/src/daemon/__tests__/socket-path.test.ts @@ -10,7 +10,14 @@ import { } from 'node:fs'; import { tmpdir } from 'node:os'; import * as path from 'node:path'; -import { ensureSocketDir } from '../socket-path.ts'; +import { + ensureSocketDir, + getSocketPath, + resolveWorkspaceRoot, + setDaemonRunDirOverrideForTests, +} from '../socket-path.ts'; +import { resourceEnvironment } from '../../resource-management/environment.ts'; +import { shortWorkspaceHash, workspaceKeyForRoot } from '../../utils/workspace-identity.ts'; let tempDir: string; @@ -59,3 +66,128 @@ describe('ensureSocketDir', () => { expect(() => ensureSocketDir(path.join(filePath, 'd.sock'))).toThrow(/not a directory/u); }); }); + +describe('getSocketPath environment propagation', () => { + const environmentKeys = [ + 'XCODEBUILDMCP_OPERATION_STATE_ROOT', + 'XCODEBUILDMCP_OPERATION_CAPACITY', + 'XCODEBUILDMCP_SOCKET', + ] as const; + const originalEnvironment = new Map(); + let daemonRoot: string; + let workspaceRoot: string; + let otherWorkspaceRoot: string; + + beforeEach(() => { + daemonRoot = mkdtempSync(path.join(tmpdir(), 'xcodebuildmcp-socket-run-')); + workspaceRoot = mkdtempSync(path.join(tmpdir(), 'xcodebuildmcp-workspace-')); + otherWorkspaceRoot = mkdtempSync(path.join(tmpdir(), 'xcodebuildmcp-workspace-')); + setDaemonRunDirOverrideForTests(daemonRoot); + for (const key of environmentKeys) { + originalEnvironment.set(key, process.env[key]); + delete process.env[key]; + } + }); + + afterEach(() => { + setDaemonRunDirOverrideForTests(null); + rmSync(daemonRoot, { recursive: true, force: true }); + rmSync(workspaceRoot, { recursive: true, force: true }); + rmSync(otherWorkspaceRoot, { recursive: true, force: true }); + for (const key of environmentKeys) { + const value = originalEnvironment.get(key); + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + originalEnvironment.clear(); + }); + + function managedEnvironment(stateRoot: string): NodeJS.ProcessEnv { + return { + XCODEBUILDMCP_OPERATION_STATE_ROOT: stateRoot, + XCODEBUILDMCP_OPERATION_CAPACITY: '2', + }; + } + + function expectedManagedSocket(root: string, env: NodeJS.ProcessEnv): string { + const managed = resourceEnvironment(env); + if (!managed) throw new Error('Expected managed environment'); + const key = workspaceKeyForRoot(resolveWorkspaceRoot({ cwd: root })); + return path.join( + daemonRoot, + `xbm-r-${shortWorkspaceHash(`${managed.namespace}:${key}`)}`, + 'd.sock', + ); + } + + function expectedOrdinarySocket(root: string): string { + const key = workspaceKeyForRoot(resolveWorkspaceRoot({ cwd: root })); + const suffix = key.match(/-([a-f0-9]{12})$/u)?.[1] ?? shortWorkspaceHash(key); + return path.join(daemonRoot, `xcodebuildmcp-${suffix}`, 'd.sock'); + } + + it('uses the explicit managed env when process.env is ordinary', () => { + const env = managedEnvironment(path.join(workspaceRoot, 'state')); + + const socket = getSocketPath({ cwd: workspaceRoot, env }); + + expect(socket).toBe(expectedManagedSocket(workspaceRoot, env)); + expect(socket).not.toBe(expectedOrdinarySocket(workspaceRoot)); + expect(getSocketPath({ cwd: otherWorkspaceRoot, env })).not.toBe(socket); + }); + + it('uses the explicit ordinary env when process.env is managed', () => { + const managed = managedEnvironment(path.join(workspaceRoot, 'state')); + for (const [key, value] of Object.entries(managed)) process.env[key] = value; + + const socket = getSocketPath({ cwd: workspaceRoot, env: {} }); + + expect(socket).toBe(expectedOrdinarySocket(workspaceRoot)); + }); + + it('returns an ordinary explicit override before resolving workspace context', () => { + const socket = path.join(daemonRoot, 'explicit.sock'); + const options = { + env: { XCODEBUILDMCP_SOCKET: socket }, + get projectConfigPath(): string { + throw new Error('workspace resolution should not run'); + }, + }; + + expect(getSocketPath(options)).toBe(socket); + }); + + it('validates explicit managed socket overrides against the same env and workspace', () => { + const env = managedEnvironment(path.join(workspaceRoot, 'state')); + const expected = expectedManagedSocket(workspaceRoot, env); + + expect( + getSocketPath({ + cwd: workspaceRoot, + env: { ...env, XCODEBUILDMCP_SOCKET: expected }, + }), + ).toBe(expected); + expect(() => + getSocketPath({ + cwd: workspaceRoot, + env: { ...env, XCODEBUILDMCP_SOCKET: path.join(daemonRoot, 'foreign.sock') }, + }), + ).toThrow('isolated'); + expect(() => + getSocketPath({ + cwd: workspaceRoot, + env: { ...env, XCODEBUILDMCP_SOCKET: expectedManagedSocket(otherWorkspaceRoot, env) }, + }), + ).toThrow('isolated'); + + const otherEnvironment = managedEnvironment(path.join(otherWorkspaceRoot, 'state')); + const otherNamespaceSocket = expectedManagedSocket(workspaceRoot, otherEnvironment); + expect(otherNamespaceSocket).not.toBe(expected); + expect(() => + getSocketPath({ + cwd: workspaceRoot, + env: { ...env, XCODEBUILDMCP_SOCKET: otherNamespaceSocket }, + }), + ).toThrow('isolated'); + }); +}); diff --git a/src/daemon/daemon-server.ts b/src/daemon/daemon-server.ts index 2a2fdf6d1..9c37fa31e 100644 --- a/src/daemon/daemon-server.ts +++ b/src/daemon/daemon-server.ts @@ -29,6 +29,10 @@ import { } from '../integrations/xcode-tools-bridge/bridge-tool-result.ts'; import { toLocalToolName } from '../integrations/xcode-tools-bridge/registry.ts'; import { toBridgeCallResultDomainResult } from '../mcp/tools/xcode-ide/shared.ts'; +import { + resourceEnvironment, + assertResourceNamespace, +} from '../resource-management/environment.ts'; export interface DaemonServerContext { socketPath: string; @@ -139,6 +143,9 @@ export function startDaemonServer(ctx: DaemonServerContext): net.Server { workspaceRoot: ctx.workspaceRoot, workspaceKey: ctx.workspaceKey, instanceId: ctx.instanceId, + ...(resourceEnvironment() + ? { resourceNamespace: resourceEnvironment()!.namespace } + : {}), }; return writeFrame(socket, { ...base, result }); } @@ -164,6 +171,7 @@ export function startDaemonServer(ctx: DaemonServerContext): net.Server { case 'tool.invoke': { const params = req.params as ToolInvokeParams; + assertResourceNamespace(params?.resourceNamespace); if (!params?.tool) { return writeFrame(socket, { ...base, @@ -230,6 +238,8 @@ export function startDaemonServer(ctx: DaemonServerContext): net.Server { } case 'xcode-ide.list': { + if (resourceEnvironment()) + throw new Error('Xcode bridge is unsupported with managed resources'); if (!ctx.xcodeIdeWorkflowEnabled) { return writeFrame(socket, { ...base, @@ -265,6 +275,8 @@ export function startDaemonServer(ctx: DaemonServerContext): net.Server { } case 'xcode-ide.invoke': { + if (resourceEnvironment()) + throw new Error('Xcode bridge is unsupported with managed resources'); if (!ctx.xcodeIdeWorkflowEnabled) { return writeFrame(socket, { ...base, diff --git a/src/daemon/protocol.ts b/src/daemon/protocol.ts index 976640504..c848bbd3c 100644 --- a/src/daemon/protocol.ts +++ b/src/daemon/protocol.ts @@ -42,6 +42,7 @@ export interface DaemonResponse { export interface ToolInvokeParams { tool: string; args: Record; + resourceNamespace?: string; } export type ToolInvokeProgressStream = { kind: 'fragment'; fragment: AnyFragment }; @@ -86,6 +87,7 @@ export interface DaemonStatusResult { workspaceKey: string; /** Opaque identity for this daemon process instance. */ instanceId?: string; + resourceNamespace?: string; } export interface ToolListItem { diff --git a/src/daemon/socket-path.ts b/src/daemon/socket-path.ts index 46bb83e16..f35d0ba48 100644 --- a/src/daemon/socket-path.ts +++ b/src/daemon/socket-path.ts @@ -8,6 +8,8 @@ import { resolveWorkspaceIdentity, } from '../utils/workspace-identity.ts'; import { getWorkspaceFilesystemLayout } from '../utils/log-paths.ts'; +import { resourceEnvironment } from '../resource-management/environment.ts'; +import { getRuntimeInstance } from '../utils/runtime-instance.ts'; export { resolveWorkspaceRoot, workspaceKeyForRoot, resolveWorkspaceIdentity }; @@ -26,13 +28,38 @@ export function setDaemonRunDirOverrideForTests(dir: string | null): void { daemonRunDirOverrideForTests = dir; } -export function daemonDirForWorkspaceKey(key: string): string { +export function daemonDirForWorkspaceKey( + key: string, + env: NodeJS.ProcessEnv = process.env, +): string { + const managed = resourceEnvironment(env); + if (managed) { + return join(daemonRunDir(), `xbm-r-${shortWorkspaceHash(`${managed.namespace}:${key}`)}`); + } return join(daemonRunDir(), `xcodebuildmcp-${compactWorkspaceKey(key)}`); } -export function socketPathForWorkspaceRoot(workspaceRoot: string): string { +export function assertManagedSocketPath( + socketPath: string, + workspaceRoot?: string, + env: NodeJS.ProcessEnv = process.env, +): void { + if (!resourceEnvironment(env)) return; + const key = + workspaceRoot === undefined + ? getRuntimeInstance().workspaceKey + : workspaceKeyForRoot(workspaceRoot); + if (socketPath !== join(daemonDirForWorkspaceKey(key, env), 'd.sock')) { + throw new Error('Managed resources require the isolated workspace daemon socket'); + } +} + +export function socketPathForWorkspaceRoot( + workspaceRoot: string, + env: NodeJS.ProcessEnv = process.env, +): string { const key = workspaceKeyForRoot(workspaceRoot); - return join(daemonDirForWorkspaceKey(key), 'd.sock'); + return join(daemonDirForWorkspaceKey(key, env), 'd.sock'); } export function registryPathForWorkspaceKey(key: string): string { @@ -51,8 +78,7 @@ export interface GetSocketPathOptions { export function getSocketPath(opts?: GetSocketPathOptions): string { const env = opts?.env ?? process.env; - - if (env.XCODEBUILDMCP_SOCKET) { + if (env.XCODEBUILDMCP_SOCKET && !resourceEnvironment(env)) { return env.XCODEBUILDMCP_SOCKET; } @@ -62,7 +88,12 @@ export function getSocketPath(opts?: GetSocketPathOptions): string { projectConfigPath: opts?.projectConfigPath, }); - return socketPathForWorkspaceRoot(workspaceRoot); + if (env.XCODEBUILDMCP_SOCKET) { + assertManagedSocketPath(env.XCODEBUILDMCP_SOCKET, workspaceRoot, env); + return env.XCODEBUILDMCP_SOCKET; + } + + return socketPathForWorkspaceRoot(workspaceRoot, env); } function validateSocketDir(dir: string): void { diff --git a/src/integrations/xcode-tools-bridge/registry.ts b/src/integrations/xcode-tools-bridge/registry.ts index 51c746f75..d241178f6 100644 --- a/src/integrations/xcode-tools-bridge/registry.ts +++ b/src/integrations/xcode-tools-bridge/registry.ts @@ -2,6 +2,7 @@ import type { McpServer, RegisteredTool } from '@modelcontextprotocol/sdk/server import type { CallToolResult, Tool, ToolAnnotations } from '@modelcontextprotocol/sdk/types.js'; import * as z from 'zod'; import { jsonSchemaToZod } from './jsonschema-to-zod.ts'; +import { resourceEnvironment } from '../../resource-management/environment.ts'; export type CallRemoteTool = ( remoteToolName: string, @@ -113,6 +114,8 @@ export class XcodeToolsProxyRegistry { }, }, async (args: unknown) => { + if (resourceEnvironment()) + throw new Error('Xcode bridge is unsupported with managed resources'); const params = (args ?? {}) as Record; return callRemoteTool(tool.name, params); }, diff --git a/src/mcp/tools/resource-management/resource_operation.ts b/src/mcp/tools/resource-management/resource_operation.ts new file mode 100644 index 000000000..94ece9be5 --- /dev/null +++ b/src/mcp/tools/resource-management/resource_operation.ts @@ -0,0 +1,82 @@ +import * as z from 'zod'; +import { resourceEnvironment } from '../../../resource-management/environment.ts'; +import { + SimulatorResourceManager, + type LeaseCredentials, +} from '../../../resource-management/manager.ts'; +import { resolveManagedWorktree } from '../../../resource-management/worktree.ts'; +import { getDefaultCommandExecutor } from '../../../utils/command.ts'; +import type { CommandExecutor } from '../../../utils/execution/index.ts'; +import { createTypedTool, getHandlerContext } from '../../../utils/typed-tool-factory.ts'; + +const schemaObject = z.object({ + action: z.enum(['begin', 'poll', 'status', 'end', 'cancel']), + requestId: z.uuid().describe('Stable UUID for this operation; reuse when retrying begin.'), + sessionId: z.uuid().optional().describe('Caller session UUID; required except for status.'), + token: z + .uuid() + .optional() + .describe('Lease token returned by begin; required for poll/end/cancel.'), +}); + +export const schema = schemaObject.shape; + +export async function resourceOperationLogic( + params: z.infer, + executor: CommandExecutor, +): Promise { + const options = resourceEnvironment(); + if (!options) throw new Error('Resource management is not enabled'); + if (params.action !== 'status' && !params.sessionId) throw new Error('sessionId is required'); + if (!['begin', 'status'].includes(params.action) && !params.token) + throw new Error('token is required'); + if (['begin', 'status'].includes(params.action) && params.token) + throw new Error('This action does not accept a token'); + const manager = await SimulatorResourceManager.open(options); + const worktree = await resolveManagedWorktree(process.cwd(), executor); + let token: string | undefined; + if (params.action === 'begin') { + const lease = await manager.requestLease({ + requestId: params.requestId, + owner: { sessionId: params.sessionId! }, + worktree, + }); + token = lease.token; + } else { + const previous = await manager.getStatus(params.requestId); + if (previous.generation !== worktree.generation) + throw new Error('Operation belongs to another worktree generation'); + const credentials: LeaseCredentials = { + requestId: params.requestId, + sessionId: params.sessionId ?? '', + token: params.token ?? '', + }; + if (params.action === 'poll') token = (await manager.poll(credentials)).token; + if (params.action === 'end') await manager.end(credentials); + if (params.action === 'cancel') await manager.cancel(credentials); + } + const status = await manager.getStatus(params.requestId); + getHandlerContext().structuredOutput = { + schema: 'xcodebuildmcp.output.resource-operation', + schemaVersion: '1', + result: { + kind: 'resource-operation', + didError: false, + error: null, + action: params.action, + requestId: status.requestId, + sessionId: status.owner.sessionId, + simulatorId: status.simulatorId, + state: status.state, + activityCount: status.activities.length, + ...(token ? { token } : {}), + ...(status.reason ? { reason: status.reason } : {}), + }, + }; +} + +export const handler = createTypedTool( + schemaObject, + resourceOperationLogic, + getDefaultCommandExecutor, +); diff --git a/src/mcp/tools/resource-management/resource_provision.ts b/src/mcp/tools/resource-management/resource_provision.ts new file mode 100644 index 000000000..27c84bbe0 --- /dev/null +++ b/src/mcp/tools/resource-management/resource_provision.ts @@ -0,0 +1,62 @@ +import * as z from 'zod'; +import { resourceEnvironment } from '../../../resource-management/environment.ts'; +import { SimulatorResourceManager } from '../../../resource-management/manager.ts'; +import { + provisionSimulator, + SimulatorProvisioningFailure, +} from '../../../resource-management/provisioner.ts'; +import { resolveManagedWorktree } from '../../../resource-management/worktree.ts'; +import { getDefaultCommandExecutor } from '../../../utils/command.ts'; +import type { CommandExecutor } from '../../../utils/CommandExecutor.ts'; +import { createTypedTool, getHandlerContext } from '../../../utils/typed-tool-factory.ts'; +import type { ResourceBindingDomainResult } from '../../../types/domain-results.ts'; + +const schemaObject = z.object({ + deviceType: z + .string() + .startsWith('com.apple.CoreSimulator.SimDeviceType.') + .describe('Explicit available Simulator device type identifier.'), + runtime: z + .string() + .startsWith('com.apple.CoreSimulator.SimRuntime.') + .describe('Explicit available Simulator runtime identifier.'), +}); +export const schema = schemaObject.shape; + +export async function resourceProvisionLogic( + params: z.infer, + executor: CommandExecutor, +): Promise { + const options = resourceEnvironment(); + if (!options) throw new Error('Resource management is not enabled'); + const worktree = await resolveManagedWorktree(process.cwd(), executor); + const manager = await SimulatorResourceManager.open(options); + const result: ResourceBindingDomainResult = { + kind: 'resource-binding', + didError: false, + error: null, + worktreeGeneration: worktree.generation, + deviceType: params.deviceType, + runtime: params.runtime, + simulatorId: null, + }; + try { + const binding = await provisionSimulator(manager, worktree, params, executor); + result.simulatorId = binding.simulatorId; + } catch (error) { + if (!(error instanceof SimulatorProvisioningFailure)) throw error; + result.didError = true; + result.error = error.message; + } + getHandlerContext().structuredOutput = { + schema: 'xcodebuildmcp.output.resource-binding', + schemaVersion: '1', + result, + }; +} + +export const handler = createTypedTool( + schemaObject, + resourceProvisionLogic, + getDefaultCommandExecutor, +); diff --git a/src/mcp/tools/simulator/boot_sim.ts b/src/mcp/tools/simulator/boot_sim.ts index d0cf77767..7236262dc 100644 --- a/src/mcp/tools/simulator/boot_sim.ts +++ b/src/mcp/tools/simulator/boot_sim.ts @@ -14,6 +14,10 @@ import { import { determineSimulatorUuid } from '../../../utils/simulator-utils.ts'; import { toErrorMessage } from '../../../utils/errors.ts'; import { createBasicDiagnostics } from '../../../utils/diagnostics.ts'; +import { + bootManagedSimulator, + SimulatorBootPreflightFailure, +} from '../../../resource-management/boot.ts'; const baseSchemaObject = z.object({ simulatorId: z @@ -125,6 +129,28 @@ export async function boot_simLogic( executor: CommandExecutor, ): Promise { const ctx = getHandlerContext(); + if (ctx.managedOperation) { + if (!params.simulatorId) throw new Error('Managed boot requires an explicit Simulator UUID'); + ctx.nextSteps = []; + try { + await bootManagedSimulator(params.simulatorId, executor); + setStructuredOutput( + ctx, + createBootSimResult({ simulatorId: params.simulatorId, didError: false }), + ); + } catch (error) { + if (!(error instanceof SimulatorBootPreflightFailure)) throw error; + setStructuredOutput( + ctx, + createBootSimResult({ + simulatorId: params.simulatorId, + didError: true, + error: error.message, + }), + ); + } + return; + } const simulatorResult = await determineSimulatorUuid(params, executor); if (simulatorResult.error || !simulatorResult.uuid) { const result = createBootSimResult({ diff --git a/src/mcp/tools/simulator/build_sim.ts b/src/mcp/tools/simulator/build_sim.ts index c89e343f4..56b77eeaa 100644 --- a/src/mcp/tools/simulator/build_sim.ts +++ b/src/mcp/tools/simulator/build_sim.ts @@ -43,6 +43,7 @@ import { markTestProductsPathCompleted, } from '../../../utils/test-products-path.ts'; import { createBuildInvocationFragment } from '../../../utils/xcodebuild-pipeline.ts'; +import { isCommandSupervised } from '../../../resource-management/execution.ts'; const baseOptions = { scheme: z.string().describe('The scheme to use (Required)'), @@ -216,15 +217,22 @@ export function createBuildSimExecutor( } const started = createDomainStreamingPipeline('build_sim', 'BUILD', ctx, 'build-result'); - const buildResult = await executeXcodeBuildCommand( - resolved.sharedBuildParams, - resolved.platformOptions, - params.preferXcodebuild ?? false, - resolved.buildAction, - executor, - undefined, - started.pipeline, - ); + let buildResult; + try { + buildResult = await executeXcodeBuildCommand( + resolved.sharedBuildParams, + resolved.platformOptions, + params.preferXcodebuild ?? false, + resolved.buildAction, + executor, + undefined, + started.pipeline, + isCommandSupervised() ? { propagateInfrastructureErrors: true } : undefined, + ); + } catch (error) { + started.pipeline.finalize(false, Date.now() - started.startedAt); + throw error; + } const succeeded = !buildResult.isError; if (resolved.isManagedTestProductsPath) { @@ -267,6 +275,13 @@ export async function build_simLogic( setXcodebuildStructuredOutput(ctx, 'build-result', result, '3'); + if (ctx.managedOperation) { + ctx.nextSteps = []; + delete ctx.nextStepParams; + delete ctx.nextStepConditionKeys; + return; + } + if (!result.didError) { if (prepared.testProductsPath) { const nextParams = { diff --git a/src/mcp/tools/simulator/launch_app_sim.ts b/src/mcp/tools/simulator/launch_app_sim.ts index b133851bb..0ee5a0124 100644 --- a/src/mcp/tools/simulator/launch_app_sim.ts +++ b/src/mcp/tools/simulator/launch_app_sim.ts @@ -1,4 +1,5 @@ import * as z from 'zod'; +import { launchManagedSimulatorApp } from '../../../resource-management/launch.ts'; import type { LaunchResultDomainResult } from '../../../types/domain-results.ts'; import type { NonStreamingExecutor } from '../../../types/tool-execution.ts'; import { log } from '../../../utils/logging/index.ts'; @@ -79,6 +80,16 @@ export async function launch_app_simLogic( launcher: SimulatorLauncher = launchSimulatorAppWithLogging, ): Promise { const ctx = getHandlerContext(); + if (ctx.managedOperation) { + if (!params.simulatorId) throw new Error('Managed launch requires an explicit Simulator UUID'); + ctx.nextSteps = []; + const result = await launchManagedSimulatorApp( + { ...params, simulatorId: params.simulatorId }, + executor, + ); + setLaunchResultStructuredOutput(ctx, result); + return; + } const simulatorResult = await determineSimulatorUuid(params, executor); if (simulatorResult.error || !simulatorResult.uuid) { const result = buildLaunchFailure( diff --git a/src/mcp/tools/simulator/test_sim.ts b/src/mcp/tools/simulator/test_sim.ts index b2fa4486b..2bd7024eb 100644 --- a/src/mcp/tools/simulator/test_sim.ts +++ b/src/mcp/tools/simulator/test_sim.ts @@ -292,6 +292,13 @@ export async function test_simLogic( const result = await executeTestSim(params, executionContext); setXcodebuildStructuredOutput(ctx, 'test-result', result, '3'); + + if (ctx.managedOperation) { + ctx.nextSteps = []; + delete ctx.nextStepParams; + delete ctx.nextStepConditionKeys; + return; + } } const publicSchemaObject = baseSchemaObject.omit({ diff --git a/src/mcp/tools/ui-automation/__tests__/axe-command.test.ts b/src/mcp/tools/ui-automation/__tests__/axe-command.test.ts new file mode 100644 index 000000000..a9a4a05c7 --- /dev/null +++ b/src/mcp/tools/ui-automation/__tests__/axe-command.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest'; +import { createMockExecutor } from '../../../../test-utils/mock-executors.ts'; +import { executeAxeCommand } from '../shared/axe-command.ts'; + +describe('AXe device identity', () => { + it.each([ + '65faa1dc-6dc9-456d-a88e-3cf5a516f95b', + '65FaA1dC-6dc9-456D-a88e-3cf5a516f95B', + '65FAA1DC-6DC9-456D-A88E-3CF5A516F95B', + ])('finds the same CoreSimulator device for %s', async (simulatorId) => { + const executor = createMockExecutor({ + output: '[]', + onExecute: (command) => { + // AXe 1.8.0 matches the uppercase CoreSimulator UDID case-sensitively. + if (command[command.indexOf('--udid') + 1] !== '65FAA1DC-6DC9-456D-A88E-3CF5A516F95B') { + throw new Error('Simulator not found'); + } + }, + }); + + await expect( + executeAxeCommand(['describe-ui'], simulatorId, 'describe-ui', executor, { + getAxePath: () => '/test/axe', + getBundledAxeEnvironment: () => ({}), + }), + ).resolves.toBe('[]'); + }); +}); diff --git a/src/mcp/tools/ui-automation/screenshot.ts b/src/mcp/tools/ui-automation/screenshot.ts index 945c269f9..d5b1da58c 100644 --- a/src/mcp/tools/ui-automation/screenshot.ts +++ b/src/mcp/tools/ui-automation/screenshot.ts @@ -73,7 +73,7 @@ async function getSimulatorDeviceForSimulatorId( const data = JSON.parse(result.output) as SimctlDeviceList; for (const devices of Object.values(data.devices)) { - const match = devices.find((device) => device.udid === simulatorId); + const match = devices.find((device) => device.udid.toLowerCase() === simulatorId.toLowerCase()); if (match) { return match; } diff --git a/src/mcp/tools/ui-automation/shared/axe-command.ts b/src/mcp/tools/ui-automation/shared/axe-command.ts index 40bb12a6a..41925976b 100644 --- a/src/mcp/tools/ui-automation/shared/axe-command.ts +++ b/src/mcp/tools/ui-automation/shared/axe-command.ts @@ -28,7 +28,8 @@ export async function executeAxeCommand( throw new DependencyError('AXe binary not found'); } - const fullArgs = [...commandArgs, '--udid', simulatorId]; + // AXe matches CoreSimulator's uppercase UDIDs case-sensitively. + const fullArgs = [...commandArgs, '--udid', simulatorId.toUpperCase()]; const fullCommand = [axeBinary, ...fullArgs]; try { diff --git a/src/mcp/tools/ui-automation/shared/domain-result.ts b/src/mcp/tools/ui-automation/shared/domain-result.ts index 9994e1a80..1c51fd4e4 100644 --- a/src/mcp/tools/ui-automation/shared/domain-result.ts +++ b/src/mcp/tools/ui-automation/shared/domain-result.ts @@ -1,4 +1,5 @@ import type { RenderHints, ToolHandlerContext } from '../../../../rendering/types.ts'; +import { isCommandSupervised } from '../../../../resource-management/execution.ts'; import type { BasicDiagnostics, CapturePayload, @@ -232,6 +233,7 @@ export function mapAxeCommandError( message: string; diagnostics?: BasicDiagnostics; } { + if (isCommandSupervised() && !(error instanceof AxeError)) throw error; if (error instanceof DependencyError) { return { message: messages.dependencyFailureMessage ?? AXE_NOT_AVAILABLE_MESSAGE }; } diff --git a/src/mcp/tools/ui-automation/shared/post-action-snapshot.ts b/src/mcp/tools/ui-automation/shared/post-action-snapshot.ts index 1ec58a107..d184809d2 100644 --- a/src/mcp/tools/ui-automation/shared/post-action-snapshot.ts +++ b/src/mcp/tools/ui-automation/shared/post-action-snapshot.ts @@ -1,4 +1,6 @@ import type { CapturePayload } from '../../../../types/domain-results.ts'; +import { isCommandSupervised } from '../../../../resource-management/execution.ts'; +import { DependencyError, SystemError } from '../../../../utils/errors.ts'; import type { RuntimeSnapshotRecord, UiAutomationRecoverableError, @@ -134,6 +136,8 @@ export async function captureRuntimeSnapshotAfterActionSafely(params: { }; } catch (error) { clearRuntimeSnapshot(params.simulatorId); + if (isCommandSupervised() && (error instanceof DependencyError || error instanceof SystemError)) + throw error; const isParseFailure = error instanceof RuntimeSnapshotParseError; const isSettleTimeout = error instanceof RuntimeSnapshotSettleTimeoutError; diff --git a/src/mcp/tools/ui-automation/shared/snapshot-ui-state.ts b/src/mcp/tools/ui-automation/shared/snapshot-ui-state.ts index 5807e6930..ed10461b3 100644 --- a/src/mcp/tools/ui-automation/shared/snapshot-ui-state.ts +++ b/src/mcp/tools/ui-automation/shared/snapshot-ui-state.ts @@ -1,4 +1,6 @@ import { COMPACT_RUNTIME_TARGET_LIMIT } from '../../../../types/ui-snapshot.ts'; +import { randomUUID } from 'node:crypto'; +import { isCommandSupervised } from '../../../../resource-management/execution.ts'; import type { RuntimeActionNameV1, RuntimeElementResolution, @@ -17,6 +19,7 @@ export async function withSimulatorUiAutomationTransaction( simulatorId: string, transaction: () => Promise, ): Promise { + simulatorId = simulatorId.toLowerCase(); const previousTransaction = simulatorUiAutomationQueues.get(simulatorId) ?? Promise.resolve(); let releaseCurrentTransaction!: () => void; const currentTransaction = new Promise((resolve) => { @@ -77,16 +80,38 @@ function isActionableCandidateForRequiredActions( } export function recordRuntimeSnapshot(snapshot: RuntimeSnapshotRecord): RuntimeSnapshotRecord { - const nextSeq = (runtimeSnapshotSeqs.get(snapshot.simulatorId) ?? 0) + 1; - runtimeSnapshotSeqs.set(snapshot.simulatorId, nextSeq); + const key = snapshot.simulatorId.toLowerCase(); + if (isCommandSupervised()) { + // Keep the published e format while making refs unique to this capture. + const prefix = BigInt(`0x${randomUUID().replaceAll('-', '')}`) + .toString() + .padStart(39, '0'); + const refs = new Map(); + for (const element of snapshot.elements) { + const oldRef = element.publicElement.ref; + const ref = `e1${prefix}${oldRef.slice(1)}`; + refs.set(oldRef, ref); + element.publicElement.ref = ref; + } + snapshot.elementsByRef = new Map( + snapshot.elements.map((element) => [element.publicElement.ref, element]), + ); + for (const action of snapshot.payload.actions) { + const ref = refs.get(action.elementRef); + if (!ref) throw new Error('Snapshot action has no matching element'); + action.elementRef = ref; + } + } + const nextSeq = (runtimeSnapshotSeqs.get(key) ?? 0) + 1; + runtimeSnapshotSeqs.set(key, nextSeq); snapshot.seq = nextSeq; snapshot.payload.seq = nextSeq; - runtimeSnapshots.set(snapshot.simulatorId, snapshot); + runtimeSnapshots.set(key, snapshot); return snapshot; } export function clearRuntimeSnapshot(simulatorId: string): void { - runtimeSnapshots.delete(simulatorId); + runtimeSnapshots.delete(simulatorId.toLowerCase()); } export function __resetRuntimeSnapshotStoreForTests(): void { @@ -99,6 +124,7 @@ export function getRuntimeSnapshotLookup( simulatorId: string, nowMs = Date.now(), ): RuntimeSnapshotLookup { + simulatorId = simulatorId.toLowerCase(); const snapshot = runtimeSnapshots.get(simulatorId) ?? null; if (!snapshot) { return { status: 'missing', snapshot: null }; diff --git a/src/mcp/tools/ui-automation/snapshot_ui.ts b/src/mcp/tools/ui-automation/snapshot_ui.ts index ba1712750..bef85c22a 100644 --- a/src/mcp/tools/ui-automation/snapshot_ui.ts +++ b/src/mcp/tools/ui-automation/snapshot_ui.ts @@ -1,4 +1,5 @@ import * as z from 'zod'; +import { isCommandSupervised } from '../../../resource-management/execution.ts'; import { log } from '../../../utils/logging/index.ts'; import type { CommandExecutor } from '../../../utils/execution/index.ts'; import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; @@ -93,7 +94,7 @@ export function createSnapshotUiExecutor( recordRuntimeSnapshot(snapshot); log('info', `${LOG_PREFIX}/${toolName}: Success for ${simulatorId}`); - if (params.sinceScreenHash === snapshot.screenHash) { + if (!isCommandSupervised() && params.sinceScreenHash === snapshot.screenHash) { return createCaptureSuccessResult(simulatorId, { capture: { type: 'runtime-snapshot-unchanged', diff --git a/src/rendering/types.ts b/src/rendering/types.ts index 07c395d3c..97f99147e 100644 --- a/src/rendering/types.ts +++ b/src/rendering/types.ts @@ -25,6 +25,7 @@ export interface RenderSession { export interface RenderHints { headerTitle?: string; runtimeSnapshot?: { + managedOperation?: boolean; suppressedTargetRefs?: readonly string[]; }; } @@ -37,6 +38,9 @@ export interface StructuredToolOutput { } export interface ToolHandlerContext { + managedOperation?: { + run(params: unknown, invoke: () => Promise): Promise; + }; emit: (fragment: AnyFragment) => void; attach: (image: ImageAttachment) => void; nextStepParams?: NextStepParamsMap; diff --git a/src/resource-management/README.md b/src/resource-management/README.md new file mode 100644 index 000000000..ddb497825 --- /dev/null +++ b/src/resource-management/README.md @@ -0,0 +1,182 @@ +# Simulator resource-management core + +This experimental implementation provides a filesystem registry and opt-in MCP, CLI, and daemon integration. Importing the core has no side effects. Explicit provisioning creates and verifies a dedicated Simulator through a supervised command adapter. Managed boot waits for readiness under an operation lease. Reset and deletion remain unsupported, and there is deliberately no public command to adopt an existing device. Dedicated-device provisioning, boot, screenshots, and live MCP session handoff have also been validated on iOS 27.0; managed test execution remains subject to the validation limits described below. + +## Runtime opt-in + +Set both `XCODEBUILDMCP_OPERATION_STATE_ROOT` (an absolute private directory outside all worktrees and installed tool storage) and `XCODEBUILDMCP_OPERATION_CAPACITY` (a positive integer) in every participating process. Use the same absolute root spelling and capacity. These settings are separate from `XCODEBUILDMCP_RESOURCE_ROOT`, which selects packaged manifests and other static resources. + +Managed runtimes put workspace artifacts and daemon registry files under `/runtime`. Their temporary Unix socket namespace includes the operation registry identity. Arbitrary socket and daemon log-path overrides are rejected. Clients verify the daemon's matching namespace before invoking tools; a managed protocol mismatch never triggers automatic termination or restart. Operation credentials travel as explicit tool arguments, including across daemon routing, and never enter session defaults. + +Configure these settings through the MCP client's normal server configuration. No session-resume script or particular client session ID is required. The caller supplies its own operation session UUID, independently of the client's conversation identifier. Keep the registry in durable storage for persistent bindings; deleting a temporary test directory does not retire its Simulator. + +`resource_provision` and `resource_operation` are automatically included in managed MCP sessions. Provisioning requires explicit `deviceType` and `runtime` identifiers. The CLI entry is: + +```sh +xcodebuildmcp resource-management provision --device-type --runtime +``` + +Choose full identifiers from the installed Simulator catalog, such as `com.apple.CoreSimulator.SimDeviceType.iPhone-16` and `com.apple.CoreSimulator.SimRuntime.iOS-18-0`, only when they are available on the host. The command verifies existing bindings without creating replacements. Provisioning does not begin an operation or boot the device; begin an operation before explicitly requesting boot readiness. + +`resource_operation` has the CLI entry `resource-management operation`. It accepts: + +| Action | Required arguments | Result | +| --- | --- | --- | +| `begin` | `requestId`, `sessionId` | Registers/retries a request and returns state, UDID, and token; may return `waiting` | +| `poll` | `requestId`, `sessionId`, `token` | Schedules eligible work and returns current lease | +| `status` | `requestId` | Returns state and activity count without the token | +| `end` | `requestId`, `sessionId`, `token` | Closes active work and releases after admitted calls drain | +| `cancel` | `requestId`, `sessionId`, `token` | Cancels waiting work or drains an active operation | + +All actions resolve the current Git worktree. Begin fails if it has no binding. Non-begin actions reject requests belonging to another worktree generation. A new session uses a new session UUID and begins a new operation after its predecessor ends; it does not inherit the predecessor's token. Keep begin's request/session identities when a response is lost and retry them. + +The currently admitted device tools are `boot_sim`, `build_sim`, `test_sim`, `install_app_sim`, `launch_app_sim`, `screenshot`, `snapshot_ui`, `tap`, and `type_text`. In managed mode their public schemas require `simulatorId`, `operationRequestId`, `operationSessionId`, and `operationToken`. Parameter validation and session-default merging happen before checking the final UUID and worktree binding. A call waits at most 30 seconds for another call on the same token, without ending the operation on timeout. + +All other manifest tools and Xcode bridge calls are rejected in managed mode, including `get_app_bundle_id`, `get_sim_app_path`, `open_sim`, `build_run_sim`, standalone `build-for-testing`, prepared-test execution, erase, stop-app, debugger, batch/wait and other UI actions, recording, and background logging. These tools either lack a bound Simulator target or need a separate supervised read-only contract, so they are not admitted by the initial lease surface. This also means session-default setters are unavailable in this initial managed mode. Startup does not perform automatic Simulator-default refresh, Xcode synchronization, or orphan cleanup. Ordinary runtimes without the opt-in keep their existing behavior; they do not participate in the lease guarantee. Normal MCP and native session resume operate without any external resume script dependency. + +The admitted tools use finite command executors. Supervision tracks every such command through async context, including errors swallowed by existing helpers. A returned domain failure with confirmed process completion remains a domain failure and allows explicit end. Unknown exit state, rejected execution, or output streams still open after process exit block the operation even if the tool returns success. No TTL, disconnect, or process death resolves this uncertainty. This supervision tracks direct child exit and closed streams; it does not support detached commands or prove termination of arbitrary process trees or user project build scripts, and tools requiring those remain disabled. + +## Managed boot readiness + +After provisioning and an active `begin` result, call `boot_sim` with the bound `simulatorId` and the three operation credentials. The CLI equivalent is: + +```sh +xcodebuildmcp simulator boot --simulator-id --operation-request-id --operation-session-id --operation-token +``` + +The call reads device availability and state, then runs `simctl bootstatus -b` to boot if needed and wait for readiness. It verifies the device is available and `Booted` after the command completes. Shutdown, booting, and already booted devices all follow this readiness check. Installation and screenshots remain explicit subsequent calls; keep the same operation until the workflow ends. + +An end/cancel request during boot closes admission but cannot release the device until readiness finishes. A confirmed missing/unavailable device before boot produces a domain failure. Uncertain command completion, failed boot status, or failed verification after boot blocks the operation and retains its activity record. A transitional or unknown preflight state also blocks rather than guessing that the service has stopped. There is no automatic timeout reclaim, shutdown, replacement, or unblock. A boot that remains pending keeps its call and operation held. + +Managed boot suppresses ordinary boot next-step templates, which do not carry operation credentials and include unsupported display tools. Ordinary mode retains its existing boot behavior. Boot readiness tests inject every command and cover explicit target admission, preflight failures, uncertain completion, and end/waiting-session races; they do not verify real Simulator startup. + +## Managed compile-only build + +After provisioning and obtaining an active operation lease, invoke `build_sim` with exactly one raw, nonblank source input (`projectPath` or `workspacePath`), a nonblank `scheme`, the bound `simulatorId`, and the three operation credentials. These required source, scheme, and UUID values must be supplied by the call; managed build does not obtain them from session or project defaults. Optional `configuration`, `derivedDataPath`, and `preferXcodebuild` (omitted or `true`) are supported. The CLI equivalent provides the same explicit flags: + +```sh +xcodebuildmcp simulator build --project-path --scheme --simulator-id --operation-request-id --operation-session-id --operation-token +``` + +Managed build supports compile-only workflows. Admission validates the exactly-one source rule and nonblank required inputs, then rejects non-empty `extraArgs` (including inherited session defaults), `buildForTesting=true`, `testProductsPath`, and explicit `preferXcodebuild=false` before executing any external build commands. Empty `extraArgs` arrays and omitted or `true` `preferXcodebuild` are allowed. `build_run_sim`, standalone `build-for-testing`, and prepared-test production remain unsupported; use the managed `test_sim` workflow for source tests. + +Managed builds always execute standard supervised `xcodebuild`. Even if incremental build support (`xcodemake`) is enabled in host or project configuration, managed mode bypasses both the `xcodemake` availability probe and its execution because it uses unsupervised child processes. Effective destinations are bound to the leased Simulator UUID, normalized to uppercase at the `xcodebuild` command boundary to match CoreSimulator/xcodebuild destination specifier requirements while registry, lease, and caller provenance retain their existing identities. + +Admission immediately invalidates any existing managed UI snapshot and suppresses all next-step metadata (`nextSteps`, `nextStepParams`, and `nextStepConditionKeys`) because follow-up templates lack credentials or reference unsupported tools. The final structured build result retains schema version 3 (`xcodebuildmcp.output.build-result`). + +The operation remains held in `active` state across both successful builds and confirmed compile failures, and is released only upon an explicit `end`. Concurrent `end` moves the lease to `closing` and waits for admitted execution to complete. Infrastructure rejections, unknown exit status, or unclosed output streams block the operation. Supervision covers direct child process termination and closed standard streams; it does not provide arbitrary process-tree guarantees for user build scripts executed by `xcodebuild`. + +## Managed simulator tests + +After provisioning and obtaining an active operation lease, invoke `test_sim` with exactly one raw, nonblank source (`projectPath` or `workspacePath`), a nonblank `scheme`, the bound `simulatorId`, and the three operation credentials. Required source, scheme, and UUID values must be supplied by the call rather than obtained from defaults. `simulatorName`, prepared test artifacts (`testProductsPath` and `xctestrunPath`), `buildForTesting`, execution and destination overrides, and `preferXcodebuild: false` are rejected before any external command. The safe `-only-testing` and `-skip-testing` selector forms are allowed in `extraArgs`, including inherited session defaults; an empty array and omitted or `true` `preferXcodebuild` are also allowed. `testRunnerEnv` accepts the MCP wire array of `{ key, value }` entries. Admission invalidates the managed UI snapshot and suppresses next-step metadata. The ordinary `test_sim` path retains its existing behavior. + +Managed source tests run one supervised `xcodebuild build-for-testing` phase followed by one supervised `xcodebuild test-without-building` phase. The test phase binds the leased Simulator UUID, uses `-parallel-testing-enabled NO` and `-maximum-concurrent-test-simulator-destinations 1`, and preserves the project's and test plan's repetition settings rather than forcing an iteration count. Every confirmed, nonsignaled, consistent numeric nonzero exit from phase 1 remains a domain failure; it does not start phase 2 or shut down the Simulator. Phase 2 accepts only exit 0 or 65 as known outcomes. A normal phase 2 exit of 0 or 65 is followed by asynchronous test failure and summary extraction, shutdown of the specified Simulator, and verification that exactly one available catalog entry for that UUID reports `Shutdown`; result and completion markers are written before the activity finishes. + +After a confirmed phase 2 outcome and verified shutdown, the operation remains active with zero activities until the caller explicitly invokes `resource_operation end`. An end request while a call is in flight moves the operation to `closing` and waits for that call to drain. Shutdown stops running Simulator apps and test runners, so running UI state and in-memory process state are lost; installed app containers and on-disk data remain. Signals, rejected or unknown commands, open streams, cleanup failures, and unexpected exceptions after phase 2 starts before shutdown verification block the operation and retain its activity. There is no automatic TTL reclaim, forced end, simulator erase/delete, or automatic reboot. The shutdown check observes the CoreSimulator catalog boundary and does not prove termination of arbitrary host process trees or user project scripts. Follow-up UI work must call `boot_sim` explicitly. + +## Managed app launch + +After boot readiness and installation, invoke `launch_app_sim` with an explicit `bundleId`, bound `simulatorId`, and the three operation credentials. Optional `launchArgs` and `env` retain their existing input formats. The call checks the installed app container, then runs `simctl launch --terminate-running-process` without a console attachment. This explicitly restarts an already-running copy of the app. Arguments are passed without a shell, and environment names use the existing `SIMCTL_CHILD_` normalization. + +Managed launch returns the reported app PID after the command exits with closed output streams. It does not prove that the app will remain healthy or that its UI is ready. An inaccessible app container produces a domain failure before launch. Rejected or unobserved commands, failed launch requests, or missing/mismatched PID output block the operation and preserve its activity record. End/cancel during launch waits for the admitted call; a waiting session is granted only after confirmed completion and release. + +The app itself may remain running when the operation ends. It is persistent device state for the next session, not a host helper holding the previous operation. The next session can inspect that state without relaunching. Managed launch creates no implicit console or OSLog helper and returns no log paths; background capture requires a later supervision integration. Ordinary launch retains automatic logging. Managed next-step templates are suppressed because they omit operation credentials and suggest unsupported display/stop tools. + +## Managed UI snapshots and actions + +Use `snapshot_ui`, then `tap` or `type_text` with a returned `elementRef` and the same operation credentials. Each managed capture assigns fresh opaque references while preserving the published `e` string format. Copy them exactly; do not convert their numeric portion to a number or construct refs. Actions return a refreshed snapshot when capture succeeds. Always use refs from that refreshed result for the next action. Managed `snapshot_ui` returns a fresh snapshot even when `sinceScreenHash` matches; normal compact MCP rendering still applies. + +Before each admitted call, the runtime compares its local snapshot provenance with the durable operation's last completed call. A new operation, an intervening call in another runtime, or boot/build/install/launch/test clears the local snapshot. Even a read-only call in another runtime conservatively requires refreshing. The call lease protects this comparison and all UI commands through post-action capture. Refs remain in memory, cannot be transferred between MCP and a separate daemon, and are invalid after capture refresh. A CLI UI sequence must use the same daemon; a session taking over must capture its own view. + +Command supervision stops follow-up work immediately after uncertain exit or open streams, including when a helper catches the initial error. A safely completed action failure remains a domain result; missing dependencies and execution infrastructure failures surface as runtime errors. Uncertain command completion blocks the operation. An observed failure to refresh the UI after an action may return a warning requiring a new snapshot; it cannot authorize reuse of stale refs. + +Existing next-step templates are suppressed in managed UI mode because they omit operation credentials and can suggest unsupported tools. Batch, wait, scrolling and other UI tools remain unsupported. Ordinary mode retains its existing short refs and unchanged-snapshot behavior. UUID keys are canonicalized consistently in the local cache, sequence counters and transaction queue. + +## Storage and identity + +`SimulatorResourceManager.open({ stateRoot, maxActiveOperations })` requires an explicit absolute, private directory outside the installed tool's storage. Binding a worktree rejects a registry located inside that checkout. All participating processes must use the same root and capacity; the persisted configuration rejects capacity mismatches. There is no global default registry. + +The registry stores one JSON record per binding and operation. Record updates use same-directory atomic rename. Every activity completion first writes a separate completion receipt, preserving the evidence needed for retries and replay checks. The operation v2 hot record keeps only the most recent completed call; this bounds the live record without deleting completion receipts or historical evidence. + +Legacy v1 operations remain v1 while activities are still present and migrate only after they drain. A strict-version older binary rejects a v2 operation, so all participants must be updated before they share v2 records. The existing filesystem lock protects short registry transactions; normal transactions wait at most 10 seconds, while `finishActivity` may wait for the lock to complete finalization and settle a waiting lease without that deadline. A live lock owner is never displaced. The transaction lock's expiry is not an operation expiry. + +Records are retained, including completed requests, so retries and orphaned worktrees remain identifiable. A runtime crash leaves durable ownership and activities in place; it does not trigger automatic recovery or unblock. Retention and recovery of temporary files after abrupt termination are not implemented. Atomic visibility across process crashes is supported; power-loss durability is not claimed. + +`resolveManagedWorktree(cwd, executor)` uses injected Git commands to locate the checkout root and its individual Git directory. A generation hashes both directories' device number, inode, and nanosecond birth time. Ordinary source edits do not change it; replacing the checkout or Git directory changes it. Missing directory identity data is an error. This is a local-filesystem identity, not a portable ID for restored backups or copied repositories. + +The binding retains the original root, Git directory and existing workspace key. Relocation of the same generation is detected and blocked until a future reconciliation implementation can update resource ownership safely. Deleted checkout records remain queryable with `getBinding(generation)`. Multiple project/workspace storage associations are not integrated yet. + +`bind(worktree, specification, simulatorId)` is an internal binding boundary. It records the supplied device; it does not discover or adopt an existing device. It rejects worktrees with a provisioning record, including uncertain creation attempts. Device type and runtime must be nonempty, and cannot change once bound. The production adapter validates catalog availability. Simulator UUIDs are normalized before uniqueness checks. A device cannot bind to two generations within one registry. + +## Dedicated-device creation coordination + +`provision(worktree, specification, create, verifyCreated?)` persists one creation reservation per worktree generation before invoking the injected creator. The creator receives a unique device name and the explicit specification; it must create a new device and return its UUID only after creation has definitively stopped. It must not discover or adopt an existing device. An optional verification callback runs after the observed UUID is saved and before the binding is committed, without holding the registry transaction lock. Verification failure retains the UUID and blocks automatic retries. + +The production adapter always supplies this verification callback. It validates explicit available runtime/device-type identifiers, executes `simctl create` without a shell, confirms process exit and closed output streams, and then verifies the returned UUID's availability and device type in its expected runtime. Existing device UUIDs cannot be adopted through the creation response. Missing devices and specification conflicts produce errors without automatic replacement. Malformed catalog output and uncertain command completion surface as runtime errors; expected unavailable-runtime/device and confirmed creation failures produce domain failures. + +Creation runs outside the short registry transaction, so a slow creator does not block unrelated operations. Another process finding an unfinished reservation receives an explicit in-progress/reconciliation error and never invokes a second creator. Once the binding commits, retrying provisioning with the same specification returns that binding, even while the original process stays alive. Conflicting specifications fail. Provisioning creates no operation lease, consumes no operation slot, and does not boot the device. + +One record under `provisioning/` retains the original worktree, specification, reservation ID, and any observed UUID. `creating` means a reservation exists but completion has not been recorded; `created` means the creator returned an observed UUID; `blocked` means creation or binding completion failed. The binding record is authoritative for whether the device is ready for lease acquisition. The observed UUID is saved before attempting the binding commit, allowing inspection even when the worktree disappears during creation. + +`getProvisioning(generation)` reads this evidence after checkout deletion. A process crash can leave `creating` or `created` without a binding, including the gap after device creation but before its UUID is saved. No PID death, age, retry, or direct binding clears that reservation. A recorded UUID also prevents another worktree generation from binding that device, including a new checkout at the same path. The unique creation name can be reconstructed from its ID for future reconciliation. There is no automatic recreation, deletion, or recovery override. Atomic visibility is supported; power-loss durability is not claimed. + +## Lease lifecycle + +The caller provides a new UUID `sessionId` for its session and a UUID `requestId` for each operation. Identity UUIDs must be lowercase. Retrying a begin uses the same request ID and session ID; it returns the same credentials instead of allocating another operation. These identities are separate from runtime processes and daemon request IDs. + +| State | Meaning | Holds device/capacity | +| --- | --- | --- | +| waiting | Queued for its bound device and an operation slot | No | +| active | Caller may register a tool call | Yes | +| closing | End/cancel requested; existing work must drain | Yes | +| blocked | Supervision lost or safe completion is uncertain | Yes | +| released | Operation completed and tracked work stopped | No | +| cancelled | Waiting request cancelled | No | + +`requestLease` and `poll` schedule eligible requests in registration order. An occupied device does not prevent an unrelated free device from using spare capacity. This capacity counts operations, including blocked/closing operations, not booted Simulators or total persistent bindings. There is no automatic preemption or idle shutdown. `end` and `cancel` reject a `blocked` operation with an explicit error; they do not claim to release it, and there is no public unblock action because supervision loss leaves completion uncertain. + +`acquire(request, { timeoutMs, signal })` waits for a grant. Cancellation and timeout cancel only a still-waiting request. A committed grant wins a concurrent cancellation/deadline and is returned to the caller, which must explicitly end it. In particular, retrying an already granted request cannot revoke it, and `timeoutMs: 0` allows an immediately available grant. Registry contention has a separate bounded transaction timeout; cancellation is observed between transactions. + +Each tool integration must: + +1. Verify its final resolved worktree generation and UDID against the lease with `startCall` before producing any device side effects. A `false` return means another call is in flight; wait or cancel without executing. +2. Keep the lease across related tool calls and analysis pauses. `finishActivity` ends one activity, not the operation. +3. Register any helper before spawning it, using `registerHelper` tied to the owning in-flight call. That call may register its helper while closing so end cannot overtake already admitted work. +4. Call `finishActivity` only after observing that the activity's side effects and child work have stopped and required evidence is saved. Recording a PID is diagnostic information, not proof of termination. +5. Explicitly call `end` after the entire operation. It rejects new calls immediately and releases only after all tracked activities finish. The session process may remain alive throughout handoff. + +Activities have runtime ownership and cannot reuse a completed activity ID. A repeated finish is idempotent and cannot erase later activity. The first implementation serializes calls; helpers can remain alongside later calls in the same operation, so integrations must classify helper interference before enabling device operations. + +`block` fences an uncertain operation. Neither PID death, file age, a disconnected caller, nor expiry of the registry transaction lock releases a held operation. Even completing all tracked activity does not automatically unblock it. There is no recovery override in this slice. Callers must retain credentials/request identity after uncertain transport failures and retry or inspect status; they must not infer that a failed response means the operation was not committed. + +The token is omitted from `getStatus`, and it must not be stored in shared session defaults. This is a coordination contract for cooperating participants under one OS user, not a security boundary against that user reading or modifying the private registry. Direct device tools and participants using another registry are outside the guarantee. + +## Validation + +Run the focused suite with: + +```sh +npx vitest run src/resource-management/__tests__/manager.test.ts +``` + +It is included by the default unit configuration. Tests use private temporary directories and injected Git discovery. IPC workers are real Node processes loading this checkout's TypeScript through `tsx`; they do not invoke Git, Xcode, Simulator tools, or installed MCP servers. Each worker receives its isolated state root explicitly, and teardown waits for worker exit before removing owned test directories. + +Tests cover cross-process arbitration, live-session handoff, shared-token call serialization, end/call races, queue order, independent devices, binding uniqueness including UUID case, retry identity, aborted waits, grant/abort ordering, delayed activity completions, capacity, corrupt records, and worktree replacement/relocation. Helper and owner-death tests validate durable ownership records with live/killed Node workers. They do not prove termination of real build, debugger, recording, or log-helper process trees. + +The additional gate tests cover effective defaults, public credential/target schemas, call serialization, end races, observed command completion, swallowed execution errors, retained open streams, and nested-workspace socket validation. Transport tests run two independent stdio MCP servers, CLI command registration, and a Unix-socket daemon with the real management handler and injected Git discovery. They use a controlled catalog rather than built-module discovery. Built manifest/importer and actual device-handler integration require a separate injected validation after building; the unit transport fixture alone does not prove that wiring. + +Provisioning tests cover persisted retries, specification conflicts, invalid/duplicate UUIDs, worktree replacement during creation, independent processes contending for creation, unrelated lease progress while creation waits, and a killed creator with an aged reservation. Creators are injected; no test creates a real device. + +Adapter tests cover explicit command arguments, unavailable specifications, rejected/uncertain/invalid creation results, pre-existing UUID rejection, missing or changed bound devices, verification before binding publication, and schema-valid public success/failure output. All commands are injected. Built entry-point checks separately validate actual manifest imports, MCP registration, CLI argument parsing, and daemon invocation. + +Next slices must add background process supervision/reconciliation and retirement dry-run. A live MCP validation on 2026-09-17 provisioned and booted a dedicated iPhone 18 Pro running iOS 27.0, ran CalculatorApp tests with 54 passed and 3 intentional fixture failures out of 57 executed, verified managed shutdown through the CoreSimulator catalog, and released the operation. A subsequent MCP session acquired and released the same device, confirming session handoff. Real process-tree cleanup has not been validated. + +Live UI validation found that AXe 1.8.0 matches uppercase CoreSimulator UDIDs case-sensitively. The AXe command boundary now converts the UUID to uppercase; the registry and runtime cache retain their existing normalized identities. Local source runs need the pinned AXe binary and its frameworks, just as packaged installations do. + +Managed launch tests cover explicit credentials and bundle IDs, literal launch arguments, environment normalization, preflight failures, process/response uncertainty, shared-token serialization, and successful/failed launch during closing. The ordinary launch tests remain part of the affected suite. All commands are injected. + +Managed UI tests cover fresh-ref remapping, hash-matched captures, operation handoff, intervening runtime activity, relaunch and UUID casing, input sequencing, uncertainty during focus/post-action capture, safe domain errors, missing dependencies and closing. A separate built-entry validation uses an independent Node process to verify reference isolation and durable invalidation, alongside MCP, daemon and CLI calls. All device commands are injected. + +Managed build tests cover explicit source and scheme inputs, required operation credentials, rejection of non-empty extraArgs (including inherited session defaults), buildForTesting, testProductsPath, and preferXcodebuild=false, exact destination binding with bound uppercase-normalized UDIDs against uppercase catalogs and case-sensitive executors, physical device ID case preservation, xcodemake probe and execution bypass under supervision, lease retention across successful builds and confirmed compile failures, blocking on uncertain process termination or open streams, pipeline log descriptor closure upon executor rejection, closing operations held until execution drains, and admission-time UI snapshot invalidation. All commands are injected. + +Managed `test_sim` tests cover explicit source and scheme inputs, required operation credentials, wire-format `testRunnerEnv`, safe test selectors, rejection of simulator names, prepared artifacts, unsafe extraArgs (including inherited session defaults), `buildForTesting`, and `preferXcodebuild=false`, the two-phase command sequence, phase 2 serialization flags, preservation of configured test-plan repetitions, uppercase UUID destination binding, asynchronous metadata extraction, shutdown and unique-catalog verification, compile-failure behavior without shutdown, lease retention across confirmed phase 2 failures, and cleanup markers. All commands are injected. Live managed test execution and real Simulator shutdown verification are validated above; the live CalculatorApp run intentionally produced 3 fixture failures. Real process-tree cleanup has not been validated. diff --git a/src/resource-management/__tests__/admission.test.ts b/src/resource-management/__tests__/admission.test.ts new file mode 100644 index 000000000..18c886135 --- /dev/null +++ b/src/resource-management/__tests__/admission.test.ts @@ -0,0 +1,49 @@ +import { randomUUID } from 'node:crypto'; +import { mkdir, mkdtemp, rename, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { expect, it } from 'vitest'; +import { createMockExecutor } from '../../test-utils/mock-executors.ts'; +import { SimulatorResourceManager } from '../manager.ts'; +import { resolveManagedWorktree } from '../worktree.ts'; + +it('rejects an admission whose worktree was replaced after target resolution', async () => { + const directory = await mkdtemp(join(tmpdir(), 'resource-admission-')); + try { + const root = join(directory, 'checkout'); + await mkdir(join(root, '.git'), { recursive: true }); + const executor = createMockExecutor({}); + const worktree = await resolveManagedWorktree(root, async (...args) => ({ + ...(await executor(...args)), + output: `${args[0].at(-1) === '--show-toplevel' ? root : join(root, '.git')}\n`, + })); + const manager = await SimulatorResourceManager.open({ + stateRoot: join(directory, 'state'), + maxActiveOperations: 1, + }); + const simulatorId = randomUUID(); + await manager.bind(worktree, { deviceType: 'fixture', runtime: 'fixture' }, simulatorId); + const lease = await manager.requestLease({ + requestId: randomUUID(), + owner: { sessionId: randomUUID() }, + worktree, + }); + await rename(root, join(directory, 'retired')); + await mkdir(join(root, '.git'), { recursive: true }); + await expect( + manager.startCall( + lease, + { generation: worktree.generation, simulatorId }, + { + id: randomUUID(), + kind: 'call', + runtimeId: randomUUID(), + pid: process.pid, + }, + ), + ).rejects.toThrow('generation changed'); + expect((await manager.end(lease)).state).toBe('released'); + } finally { + await rm(directory, { recursive: true, force: true }); + } +}); diff --git a/src/resource-management/__tests__/boot.test.ts b/src/resource-management/__tests__/boot.test.ts new file mode 100644 index 000000000..e1725a827 --- /dev/null +++ b/src/resource-management/__tests__/boot.test.ts @@ -0,0 +1,269 @@ +import { randomUUID } from 'node:crypto'; +import { PassThrough } from 'node:stream'; +import { mkdtemp, mkdir, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, expect, it, vi } from 'vitest'; +import { createMockExecutor } from '../../test-utils/mock-executors.ts'; +import { + __setTestCommandExecutorOverride, + __clearTestExecutorOverrides, +} from '../../utils/command.ts'; +import type { ToolHandlerContext } from '../../rendering/types.ts'; +import { resourceEnvironment } from '../environment.ts'; +import { managedToolSchema, wrapManagedTool } from '../tool-gate.ts'; +import { SimulatorResourceManager, type Lease } from '../manager.ts'; +import { resolveManagedWorktree } from '../worktree.ts'; +import { handler } from '../../mcp/tools/simulator/boot_sim.ts'; + +let directory: string; +let manager: SimulatorResourceManager; +let lease: Lease; +const moduleId = 'mcp/tools/simulator/boot_sim'; +const context = (): ToolHandlerContext => ({ emit: () => {}, attach: () => {} }); +const credentials = () => ({ + simulatorId: lease.simulatorId, + operationRequestId: lease.requestId, + operationToken: lease.token, + operationSessionId: lease.sessionId, +}); +const boot = wrapManagedTool(moduleId, (args, ctx) => { + if (!ctx) throw new Error('Missing fixture context'); + return handler(args, ctx); +}); +beforeEach(async () => { + directory = await mkdtemp(join(tmpdir(), 'resource-boot-')); + const root = join(directory, 'checkout'); + await mkdir(join(root, '.git'), { recursive: true }); + vi.stubEnv('XCODEBUILDMCP_OPERATION_STATE_ROOT', join(directory, 'state')); + vi.stubEnv('XCODEBUILDMCP_OPERATION_CAPACITY', '1'); + const executor = createMockExecutor({}); + __setTestCommandExecutorOverride(async (...args) => { + if (args[0][0] !== 'git') throw new Error('Unexpected external command'); + return { + ...(await executor(...args)), + output: `${args[0].at(-1) === '--show-toplevel' ? root : join(root, '.git')}\n`, + }; + }); + const worktree = await resolveManagedWorktree(root, async (...args) => { + return { + ...(await executor(...args)), + output: `${args[0].at(-1) === '--show-toplevel' ? root : join(root, '.git')}\n`, + }; + }); + manager = await SimulatorResourceManager.open(resourceEnvironment()!); + await manager.bind(worktree, { deviceType: 'fixture', runtime: 'fixture' }, randomUUID()); + lease = await manager.requestLease({ + requestId: randomUUID(), + owner: { sessionId: randomUUID() }, + worktree, + }); +}); + +afterEach(async () => { + __clearTestExecutorOverrides(); + + vi.unstubAllEnvs(); + + await rm(directory, { recursive: true, force: true }); +}); + +function commands( + options: { + before?: string; + available?: boolean; + missing?: boolean; + postState?: string; + failure?: + | 'exit' + | 'reject' + | 'open-stream' + | 'unknown-exit' + | 'post-json' + | 'pre-json' + | 'pre-exit' + | 'pre-reject' + | 'pre-open'; + onBoot?: () => Promise; + } = {}, +) { + const calls: string[][] = []; + const stream = new PassThrough(); + let booted = false; + const mock = createMockExecutor({}); + const root = join(directory, 'checkout'); + __setTestCommandExecutorOverride(async (...args) => { + const cmd = args[0]; + const response = await mock(...args); + if (cmd[0] === 'git') + return { + ...response, + output: `${cmd.at(-1) === '--show-toplevel' ? root : join(root, '.git')}\n`, + }; + calls.push(cmd); + expect(args[2]).toBe(false); + if (cmd[2] === 'bootstatus') { + expect(cmd).toEqual(['xcrun', 'simctl', 'bootstatus', lease.simulatorId, '-b']); + await options.onBoot?.(); + if (options.failure === 'reject') throw new Error('lost executor'); + if (options.failure === 'exit') return createMockExecutor({ success: false })(...args); + if (options.failure === 'open-stream') response.process.stdout = stream; + if (options.failure === 'unknown-exit') Object.assign(response.process, { exitCode: null }); + booted = true; + return response; + } + expect(cmd).toEqual(['xcrun', 'simctl', 'list', 'devices', '--json']); + if (options.failure === 'pre-reject' && !booted) { + throw new Error('catalog preflight executor rejected'); + } + if (options.failure === 'pre-exit' && !booted) { + return createMockExecutor({ + success: false, + exitCode: 1, + error: 'catalog preflight failed', + })(...args); + } + if (options.failure === 'pre-open' && !booted) response.process.stdout = stream; + if ((options.failure === 'post-json' && booted) || options.failure === 'pre-json') + return { ...response, output: 'invalid' }; + return { + ...response, + output: JSON.stringify({ + devices: { + runtime: options.missing + ? [] + : [ + { + udid: lease.simulatorId.toUpperCase(), + isAvailable: options.available ?? true, + state: booted ? (options.postState ?? 'Booted') : (options.before ?? 'Shutdown'), + }, + ], + }, + }), + }; + }); + return { calls, stream }; +} + +it.each(['Shutdown', 'Booting', 'Booted'])( + 'waits for readiness from %s under the existing lease', + async (before) => { + const { calls } = commands({ before }); + const ctx = context(); + await boot(credentials(), ctx); + expect(calls).toHaveLength(3); + expect(ctx.structuredOutput?.result).toMatchObject({ + didError: false, + action: { type: 'boot' }, + artifacts: { simulatorId: lease.simulatorId }, + }); + expect(ctx.nextSteps).toEqual([]); + expect((await manager.getStatus(lease.requestId)).state).toBe('active'); + expect((await manager.end(lease)).state).toBe('released'); + }, +); + +it('rejects missing credentials, wrong token and foreign UUID before device commands', async () => { + const { calls } = commands(); + expect(managedToolSchema(moduleId, {})).toHaveProperty('operationToken'); + for (const args of [ + { simulatorId: lease.simulatorId }, + { ...credentials(), operationToken: randomUUID() }, + { ...credentials(), simulatorId: randomUUID() }, + ]) { + await expect(boot(args, context())).rejects.toThrow(); + } + expect(calls).toEqual([]); +}); + +it.each([{ missing: true }, { available: false }])( + 'returns a safe preflight domain failure: %j', + async (options) => { + const { calls } = commands(options); + const ctx = context(); + await boot(credentials(), ctx); + expect(ctx.structuredOutput?.result.didError).toBe(true); + expect(calls).toHaveLength(1); + expect((await manager.end(lease)).state).toBe('released'); + }, +); + +it.each([ + 'exit', + 'reject', + 'open-stream', + 'unknown-exit', + 'post-json', + 'pre-reject', + 'pre-open', +] as const)('blocks handoff on %s', async (failure) => { + const { calls, stream } = commands({ failure }); + try { + await expect(boot(credentials(), context())).rejects.toThrow( + 'completion could not be confirmed', + ); + await expect(manager.end(lease)).rejects.toThrow( + 'Operation is blocked; end/cancel cannot recover it', + ); + expect((await manager.getStatus(lease.requestId)).activities).toHaveLength(1); + expect(calls).toHaveLength( + failure === 'pre-open' || failure === 'pre-reject' ? 1 : failure === 'post-json' ? 3 : 2, + ); + } finally { + stream.destroy(); + } +}); + +it('releases a malformed read-only preflight but blocks unverified post-boot state', async () => { + commands({ failure: 'pre-json' }); + await expect(boot(credentials(), context())).rejects.toThrow(); + expect((await manager.getStatus(lease.requestId)).activities).toHaveLength(0); + commands({ postState: 'Booting' }); + await expect(boot(credentials(), context())).rejects.toThrow(); + await expect(manager.end(lease)).rejects.toThrow( + 'Operation is blocked; end/cancel cannot recover it', + ); +}); + +it('releases a confirmed nonzero read-only preflight failure', async () => { + const { calls } = commands({ failure: 'pre-exit' }); + + await expect(boot(credentials(), context())).rejects.toThrow('Simulator command failed'); + expect(calls).toEqual([['xcrun', 'simctl', 'list', 'devices', '--json']]); + expect((await manager.getStatus(lease.requestId)).state).toBe('active'); + expect((await manager.getStatus(lease.requestId)).activities).toHaveLength(0); + expect((await manager.end(lease)).state).toBe('released'); +}); + +it('keeps a closing operation held until readiness finishes before granting a new session', async () => { + let release!: () => void; + let entered!: () => void; + const started = new Promise((resolve) => { + entered = resolve; + }); + const finish = new Promise((resolve) => { + release = resolve; + }); + commands({ + onBoot: async () => { + entered(); + await finish; + }, + }); + const binding = await manager.getBinding((await manager.getStatus(lease.requestId)).generation); + if (!binding) throw new Error('Missing fixture binding'); + const waiting = await manager.requestLease({ + requestId: randomUUID(), + owner: { sessionId: randomUUID() }, + worktree: binding.worktree, + }); + const run = boot(credentials(), context()); + await started; + expect((await manager.end(lease)).state).toBe('closing'); + expect((await manager.poll(waiting)).state).toBe('waiting'); + release(); + await run; + expect((await manager.getStatus(lease.requestId)).state).toBe('released'); + expect((await manager.poll(waiting)).state).toBe('active'); +}); diff --git a/src/resource-management/__tests__/build.test.ts b/src/resource-management/__tests__/build.test.ts new file mode 100644 index 000000000..2401498c3 --- /dev/null +++ b/src/resource-management/__tests__/build.test.ts @@ -0,0 +1,639 @@ +import { randomUUID } from 'node:crypto'; +import { mkdtemp, mkdir, rm, readFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { PassThrough } from 'node:stream'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { createMockExecutor } from '../../test-utils/mock-executors.ts'; +import { + __setTestCommandExecutorOverride, + __clearTestExecutorOverrides, +} from '../../utils/command.ts'; +import type { ToolHandlerContext } from '../../rendering/types.ts'; +import { sessionStore } from '../../utils/session-store.ts'; +import { resourceEnvironment } from '../environment.ts'; +import { managedToolSchema, wrapManagedTool } from '../tool-gate.ts'; +import { SimulatorResourceManager, type Lease } from '../manager.ts'; +import { resolveManagedWorktree } from '../worktree.ts'; +import { + handler as buildHandler, + schema as buildSchema, +} from '../../mcp/tools/simulator/build_sim.ts'; +import { handler as snapshotHandler } from '../../mcp/tools/ui-automation/snapshot_ui.ts'; +import { defaultAxeHelpers } from '../../mcp/tools/ui-automation/shared/axe-command.ts'; +import * as xcodemakeModule from '../../utils/xcodemake.ts'; +import * as logCaptureModule from '../../utils/xcodebuild-log-capture.ts'; +import { + getRuntimeSnapshotLookup, + __resetRuntimeSnapshotStoreForTests, +} from '../../mcp/tools/ui-automation/shared/snapshot-ui-state.ts'; + +let directory: string; +let manager: SimulatorResourceManager; +let lease: Lease; +let logsDir: string; +const moduleId = 'mcp/tools/simulator/build_sim'; +const context = (): ToolHandlerContext => ({ emit: () => {}, attach: () => {} }); + +const credentials = () => ({ + simulatorId: lease.simulatorId, + operationRequestId: lease.requestId, + operationToken: lease.token, + operationSessionId: lease.sessionId, +}); + +const defaultBuildParams = (root: string) => ({ + ...credentials(), + projectPath: join(root, 'TestApp.xcodeproj'), + scheme: 'TestApp', +}); + +const build = wrapManagedTool(moduleId, (args, ctx) => { + if (!ctx) throw new Error('Missing fixture context'); + return buildHandler(args, ctx); +}); + +beforeEach(async () => { + __resetRuntimeSnapshotStoreForTests(); + sessionStore.clear(); + directory = await mkdtemp(join(tmpdir(), 'resource-build-')); + const root = join(directory, 'checkout'); + await mkdir(join(root, '.git'), { recursive: true }); + logsDir = join(directory, 'logs'); + await mkdir(logsDir, { recursive: true }); + logCaptureModule.setXcodebuildLogDirOverrideForTests(logsDir); + + vi.stubEnv('XCODEBUILDMCP_OPERATION_STATE_ROOT', join(directory, 'state')); + vi.stubEnv('XCODEBUILDMCP_OPERATION_CAPACITY', '1'); + + const executor = createMockExecutor({}); + __setTestCommandExecutorOverride(async (...args) => { + if (args[0][0] !== 'git') throw new Error('Unexpected external command'); + return { + ...(await executor(...args)), + output: `${args[0].at(-1) === '--show-toplevel' ? root : join(root, '.git')}\n`, + }; + }); + + const worktree = await resolveManagedWorktree(root, async (...args) => { + return { + ...(await executor(...args)), + output: `${args[0].at(-1) === '--show-toplevel' ? root : join(root, '.git')}\n`, + }; + }); + + manager = await SimulatorResourceManager.open(resourceEnvironment()!); + await manager.bind(worktree, { deviceType: 'fixture', runtime: 'fixture' }, randomUUID()); + lease = await manager.requestLease({ + requestId: randomUUID(), + owner: { sessionId: randomUUID() }, + worktree, + }); +}); + +afterEach(async () => { + __clearTestExecutorOverrides(); + logCaptureModule.setXcodebuildLogDirOverrideForTests(null); + sessionStore.clear(); + vi.unstubAllEnvs(); + vi.restoreAllMocks(); + await rm(directory, { recursive: true, force: true }); +}); + +function commands( + options: { + failure?: 'exit' | 'reject' | 'open-stream' | 'unknown-exit'; + exitCode?: number; + error?: string; + emitOutput?: boolean; + onBuild?: () => Promise; + } = {}, +) { + const calls: string[][] = []; + const stream = new PassThrough(); + const mock = createMockExecutor({}); + const root = join(directory, 'checkout'); + + __setTestCommandExecutorOverride(async (...args) => { + const cmd = args[0]; + const execOpts = args[3]; + + if (cmd[0] === 'git') { + return { + ...(await mock(...args)), + output: `${cmd.at(-1) === '--show-toplevel' ? root : join(root, '.git')}\n`, + }; + } + + if (cmd[0] === 'xcrun' && cmd[1] === 'simctl') { + calls.push(cmd); + return { + ...(await mock(...args)), + output: JSON.stringify({ + devices: { + 'com.apple.CoreSimulator.SimRuntime.iOS-18-0': [ + { + udid: lease.simulatorId.toUpperCase(), + name: 'iPhone 16 Pro', + isAvailable: true, + state: 'Booted', + }, + ], + }, + }), + }; + } + + if (cmd[0] === 'xcodebuild') { + calls.push(cmd); + + const destIndex = cmd.indexOf('-destination'); + if (destIndex !== -1) { + const dest = cmd[destIndex + 1] ?? ''; + const idMatch = dest.match(/id=([^,]+)/); + if (idMatch && idMatch[1] !== lease.simulatorId.toUpperCase()) { + return createMockExecutor({ + success: false, + exitCode: 70, + error: `xcodebuild: error: Unable to find a device matching the provided destination specifier: { ${dest} }`, + })(...args); + } + } + + if (options.emitOutput && execOpts?.onStdout) { + execOpts.onStdout( + '=== BUILD TARGET TestApp OF PROJECT TestApp ===\nCompileSwift normal ...\n', + ); + } + + await options.onBuild?.(); + + if (options.failure === 'reject') { + throw new Error('lost executor'); + } + + if (options.failure === 'exit') { + return createMockExecutor({ + success: false, + exitCode: options.exitCode ?? 65, + error: options.error ?? 'Build failed', + })(...args); + } + + const response = await mock(...args); + if (options.failure === 'open-stream') { + response.process.stdout = stream; + } + if (options.failure === 'unknown-exit') { + Object.assign(response.process, { exitCode: null, signalCode: null }); + } + + return response; + } + + if (cmd[0] === '/fixture/axe') { + calls.push(cmd); + return { + ...(await mock(...args)), + output: + cmd[1] === 'describe-ui' + ? JSON.stringify({ + elements: [ + { + type: 'TextField', + role: 'AXTextField', + AXLabel: 'Email', + AXUniqueId: 'email-field', + frame: { x: 10, y: 20, width: 100, height: 40 }, + enabled: true, + children: [], + }, + ], + }) + : 'ok', + }; + } + + throw new Error(`Unexpected external command in test mock: ${cmd.join(' ')}`); + }); + + return { calls, stream, root }; +} + +describe('Managed build_sim', () => { + it('exposes explicit project, workspace, scheme, and credentials on public managed schema', () => { + const schema = managedToolSchema(moduleId, buildSchema); + expect(schema).toHaveProperty('projectPath'); + expect(schema).toHaveProperty('workspacePath'); + expect(schema).toHaveProperty('scheme'); + expect(schema).toHaveProperty('configuration'); + expect(schema).toHaveProperty('derivedDataPath'); + expect(schema).toHaveProperty('preferXcodebuild'); + expect(schema).toHaveProperty('simulatorId'); + expect(schema).toHaveProperty('operationRequestId'); + expect(schema).toHaveProperty('operationToken'); + expect(schema).toHaveProperty('operationSessionId'); + }); + + it('rejects raw source defaults and invalid source fields before commands or activities', async () => { + const { calls, root } = commands(); + const base = { + ...credentials(), + scheme: 'TestApp', + }; + + sessionStore.setDefaults({ + projectPath: join(root, 'TestApp.xcodeproj'), + scheme: 'TestApp', + }); + await expect(build(base, context())).rejects.toThrow( + 'Managed builds require exactly one of projectPath or workspacePath', + ); + sessionStore.clear(); + + await expect( + build( + { + ...base, + projectPath: join(root, 'TestApp.xcodeproj'), + workspacePath: ' ', + }, + context(), + ), + ).rejects.toThrow('Managed builds do not allow blank workspacePath'); + await expect( + build( + { + ...base, + projectPath: '\t', + workspacePath: join(root, 'TestApp.xcworkspace'), + }, + context(), + ), + ).rejects.toThrow('Managed builds do not allow blank projectPath'); + + sessionStore.setDefaults({ scheme: 'DefaultScheme' }); + await expect( + build( + { + ...credentials(), + projectPath: join(root, 'TestApp.xcodeproj'), + }, + context(), + ), + ).rejects.toThrow('Managed builds require an explicit non-empty scheme'); + sessionStore.clear(); + + sessionStore.setDefaults({ simulatorId: lease.simulatorId }); + await expect( + build( + { + operationRequestId: lease.requestId, + operationToken: lease.token, + operationSessionId: lease.sessionId, + projectPath: join(root, 'TestApp.xcodeproj'), + scheme: 'TestApp', + }, + context(), + ), + ).rejects.toThrow('Managed builds require an explicit simulatorId'); + sessionStore.clear(); + + await expect(build({ ...base, projectPath: ' ' }, context())).rejects.toThrow( + 'Managed builds do not allow blank projectPath', + ); + await expect(build({ ...base, workspacePath: '\t' }, context())).rejects.toThrow( + 'Managed builds do not allow blank workspacePath', + ); + await expect( + build( + { + ...base, + projectPath: join(root, 'TestApp.xcodeproj'), + workspacePath: join(root, 'TestApp.xcworkspace'), + }, + context(), + ), + ).rejects.toThrow('Managed builds require exactly one of projectPath or workspacePath'); + await expect( + build({ ...base, projectPath: join(root, 'TestApp.xcodeproj'), scheme: ' ' }, context()), + ).rejects.toThrow('Managed builds require an explicit non-empty scheme'); + await expect( + build( + { ...base, projectPath: join(root, 'TestApp.xcodeproj'), simulatorId: 'invalid' }, + context(), + ), + ).rejects.toThrow('Managed calls require a resolved Simulator UUID without a name'); + await expect( + build( + { + ...base, + projectPath: join(root, 'TestApp.xcodeproj'), + simulatorName: 'iPhone 16 Pro', + }, + context(), + ), + ).rejects.toThrow('Managed calls require a resolved Simulator UUID without a name'); + + expect(calls).toHaveLength(0); + expect((await manager.getStatus(lease.requestId)).activities).toHaveLength(0); + }); + + it('rejects missing credentials, wrong token, wrong session, foreign UUID, and simulatorName before build', async () => { + const { calls, root } = commands(); + const baseParams = { + projectPath: join(root, 'TestApp.xcodeproj'), + scheme: 'TestApp', + }; + + // Missing credentials + await expect( + build({ ...baseParams, simulatorId: lease.simulatorId }, context()), + ).rejects.toThrow('credentials'); + + // Wrong token + await expect( + build({ ...baseParams, ...credentials(), operationToken: randomUUID() }, context()), + ).rejects.toThrow(); + + // Wrong session + await expect( + build({ ...baseParams, ...credentials(), operationSessionId: randomUUID() }, context()), + ).rejects.toThrow(); + + // Foreign UUID + await expect( + build({ ...baseParams, ...credentials(), simulatorId: randomUUID() }, context()), + ).rejects.toThrow(); + + // simulatorName provided without simulatorId + const credsWithoutSimId = { + operationRequestId: lease.requestId, + operationToken: lease.token, + operationSessionId: lease.sessionId, + }; + await expect( + build({ ...baseParams, ...credsWithoutSimId, simulatorName: 'iPhone 16 Pro' }, context()), + ).rejects.toThrow('without a name'); + + // simulatorName provided alongside simulatorId is rejected before admission + await expect( + build({ ...baseParams, ...credentials(), simulatorName: 'iPhone 16 Pro' }, context()), + ).rejects.toThrow('without a name'); + + expect(calls).toHaveLength(0); + }); + + it('rejects nonempty extraArgs (including session defaults), buildForTesting, testProductsPath, and preferXcodebuild=false', async () => { + const { calls, root } = commands(); + const base = defaultBuildParams(root); + + // Direct non-empty extraArgs + await expect(build({ ...base, extraArgs: ['-verbose'] }, context())).rejects.toThrow( + 'extraArgs', + ); + + // Inherited non-empty extraArgs from session defaults + sessionStore.setDefaults({ extraArgs: ['-inherited'] }); + await expect(build(base, context())).rejects.toThrow('extraArgs'); + sessionStore.clear(); + + // testProductsPath provided (with buildForTesting=true to pass schema validation) + await expect( + build( + { ...base, testProductsPath: '/tmp/products.xctestproducts', buildForTesting: true }, + context(), + ), + ).rejects.toThrow(); + + // buildForTesting = true without testProductsPath + await expect(build({ ...base, buildForTesting: true }, context())).rejects.toThrow( + 'buildForTesting', + ); + + // Direct preferXcodebuild = false + await expect(build({ ...base, preferXcodebuild: false }, context())).rejects.toThrow( + 'preferXcodebuild=false', + ); + + // Inherited preferXcodebuild = false from session defaults + sessionStore.setDefaults({ preferXcodebuild: false }); + await expect(build(base, context())).rejects.toThrow('preferXcodebuild=false'); + sessionStore.clear(); + + // No build commands executed during rejected admissions + expect(calls).toHaveLength(0); + }); + + it('allows empty extraArgs and omitted/true preferXcodebuild with explicit workspace', async () => { + const { calls, root } = commands(); + const ctx = context(); + + await build( + { + ...credentials(), + workspacePath: join(root, 'TestApp.xcworkspace'), + scheme: 'TestApp', + extraArgs: [], + preferXcodebuild: true, + }, + ctx, + ); + + expect(ctx.structuredOutput?.result.didError).toBe(false); + const xcodebuildCall = calls.find((c) => c[0] === 'xcodebuild'); + expect(xcodebuildCall).toBeDefined(); + expect(xcodebuildCall).toContain('-workspace'); + expect(xcodebuildCall).toContain(join(root, 'TestApp.xcworkspace')); + expect(xcodebuildCall).toContain('-scheme'); + expect(xcodebuildCall).toContain('TestApp'); + }); + + it('binds uppercase destination matching catalog despite lowercase lease ID', async () => { + const { calls, root } = commands(); + const ctx = context(); + + await build(defaultBuildParams(root), ctx); + + const xcodebuildCall = calls.find((c) => c[0] === 'xcodebuild'); + expect(xcodebuildCall).toBeDefined(); + const destIndex = xcodebuildCall!.indexOf('-destination'); + expect(destIndex).toBeGreaterThan(-1); + expect(xcodebuildCall![destIndex + 1]).toBe( + `platform=iOS Simulator,id=${lease.simulatorId.toUpperCase()}`, + ); + }); + + it('bypasses xcodemake probe and execution even when xcodemake is enabled', async () => { + const enabledSpy = vi.spyOn(xcodemakeModule, 'isXcodemakeEnabled').mockReturnValue(true); + expect(xcodemakeModule.isXcodemakeEnabled()).toBe(true); + const availableSpy = vi.spyOn(xcodemakeModule, 'isXcodemakeAvailable'); + const executeSpy = vi.spyOn(xcodemakeModule, 'executeXcodemakeCommand'); + + const { calls, root } = commands(); + const ctx = context(); + + await build(defaultBuildParams(root), ctx); + + expect(availableSpy).not.toHaveBeenCalled(); + expect(executeSpy).not.toHaveBeenCalled(); + + const xcodebuildCall = calls.find((c) => c[0] === 'xcodebuild'); + expect(xcodebuildCall).toBeDefined(); + expect(xcodebuildCall![0]).toBe('xcodebuild'); + enabledSpy.mockRestore(); + }); + + it('keeps lease active on successful build and confirmed compile failure, releasing upon explicit end', async () => { + const { root } = commands(); + const ctxSuccess = context(); + + await build(defaultBuildParams(root), ctxSuccess); + expect(ctxSuccess.structuredOutput?.result.didError).toBe(false); + expect((await manager.getStatus(lease.requestId)).state).toBe('active'); + expect((await manager.end(lease)).state).toBe('released'); + + // Acquire new lease for compile failure test + const binding = await manager.getBinding((await manager.getStatus(lease.requestId)).generation); + if (!binding) throw new Error('Missing fixture binding'); + lease = await manager.requestLease({ + requestId: randomUUID(), + owner: { sessionId: randomUUID() }, + worktree: binding.worktree, + }); + + commands({ failure: 'exit', exitCode: 65, error: 'Compile error' }); + const ctxFailure = context(); + await build(defaultBuildParams(root), ctxFailure); + expect(ctxFailure.structuredOutput?.result.didError).toBe(true); + expect(ctxFailure.structuredOutput?.result.error).toBe('Build failed'); + expect((await manager.getStatus(lease.requestId)).state).toBe('active'); + expect((await manager.end(lease)).state).toBe('released'); + }); + + it.each(['reject', 'open-stream', 'unknown-exit'] as const)( + 'blocks lease on %s failure', + async (failure) => { + const { stream, root } = commands({ failure }); + try { + await expect(build(defaultBuildParams(root), context())).rejects.toThrow(); + expect((await manager.getStatus(lease.requestId)).state).toBe('blocked'); + await expect(manager.end(lease)).rejects.toThrow( + 'Operation is blocked; end/cancel cannot recover it', + ); + } finally { + stream.destroy(); + } + }, + ); + + it('closes log file descriptor and preserves emitted output when executor rejects', async () => { + let captureClosed = false; + let capturedLogPath: string | null = null; + const origCreate = logCaptureModule.createLogCapture; + vi.spyOn(logCaptureModule, 'createLogCapture').mockImplementation((toolName) => { + const cap = origCreate(toolName); + capturedLogPath = cap.path; + const origClose = cap.close.bind(cap); + cap.close = () => { + captureClosed = true; + origClose(); + }; + return cap; + }); + + const { root } = commands({ failure: 'reject', emitOutput: true }); + + await expect(build(defaultBuildParams(root), context())).rejects.toThrow( + 'completion could not be confirmed', + ); + + // Evidence of pipeline finalization and log closure + expect(captureClosed).toBe(true); + + // Verify log file was written and closed + expect(capturedLogPath).not.toBeNull(); + const content = await readFile(capturedLogPath!, 'utf8'); + expect(content).toContain('=== BUILD TARGET TestApp OF PROJECT TestApp ==='); + + expect((await manager.getStatus(lease.requestId)).state).toBe('blocked'); + }); + + it('keeps closing operation held until build completes before granting waiting session', async () => { + let release!: () => void; + let entered!: () => void; + const started = new Promise((resolve) => { + entered = resolve; + }); + const finish = new Promise((resolve) => { + release = resolve; + }); + + const { root } = commands({ + onBuild: async () => { + entered(); + await finish; + }, + }); + + const binding = await manager.getBinding((await manager.getStatus(lease.requestId)).generation); + if (!binding) throw new Error('Missing fixture binding'); + const waiting = await manager.requestLease({ + requestId: randomUUID(), + owner: { sessionId: randomUUID() }, + worktree: binding.worktree, + }); + + const run = build(defaultBuildParams(root), context()); + await started; + + expect((await manager.end(lease)).state).toBe('closing'); + expect((await manager.poll(waiting)).state).toBe('waiting'); + + release(); + await run; + + expect((await manager.getStatus(lease.requestId)).state).toBe('released'); + expect((await manager.poll(waiting)).state).toBe('active'); + }); + + it('invalidates previous UI snapshot on admission and suppresses next-step metadata', async () => { + vi.spyOn(defaultAxeHelpers, 'getAxePath').mockReturnValue('/fixture/axe'); + vi.spyOn(defaultAxeHelpers, 'getBundledAxeEnvironment').mockReturnValue({}); + + let snapshotStatusDuringBuild: string | undefined; + + const { root } = commands({ + onBuild: async () => { + // Assert snapshot is already missing during build execution (invalidated upon admission) + snapshotStatusDuringBuild = getRuntimeSnapshotLookup(lease.simulatorId).status; + }, + }); + + // Take a valid snapshot via real managed snapshot_ui handler first to establish provenance + const snapshotCtx = context(); + const snapshotTool = wrapManagedTool('mcp/tools/ui-automation/snapshot_ui', (args, ctx) => { + if (!ctx) throw new Error('Missing fixture context'); + return snapshotHandler(args, ctx); + }); + await snapshotTool(credentials(), snapshotCtx); + + expect(getRuntimeSnapshotLookup(lease.simulatorId).status).toBe('available'); + + const ctx = context(); + await build(defaultBuildParams(root), ctx); + + // Snapshot must be invalidated upon build admission (both during and after execution) + expect(snapshotStatusDuringBuild).toBe('missing'); + expect(getRuntimeSnapshotLookup(lease.simulatorId).status).toBe('missing'); + + // Structured result retains schema version 3 + expect(ctx.structuredOutput?.schemaVersion).toBe('3'); + expect(ctx.structuredOutput?.result.kind).toBe('build-result'); + + // All next-step metadata suppressed + expect(ctx.nextSteps).toEqual([]); + expect(ctx.nextStepParams).toBeUndefined(); + expect(ctx.nextStepConditionKeys).toBeUndefined(); + }); +}); diff --git a/src/resource-management/__tests__/fixtures/lease-worker.ts b/src/resource-management/__tests__/fixtures/lease-worker.ts new file mode 100644 index 000000000..b2433a912 --- /dev/null +++ b/src/resource-management/__tests__/fixtures/lease-worker.ts @@ -0,0 +1,93 @@ +import { + SimulatorResourceManager, + type LeaseCredentials, + type LeaseRequest, + type OperationActivity, + type SimulatorSpecification, +} from '../../manager.ts'; +import type { ManagedWorktree } from '../../worktree.ts'; + +export type WorkerCommand = + | { + action: 'bind'; + worktree: ManagedWorktree; + specification: SimulatorSpecification; + simulatorId: string; + } + | { action: 'request'; request: LeaseRequest } + | { + action: 'provision'; + worktree: ManagedWorktree; + specification: SimulatorSpecification; + simulatorId: string; + } + | { action: 'complete-creation' } + | { action: 'poll' | 'end' | 'cancel'; credentials: LeaseCredentials } + | { action: 'status'; requestId: string } + | { + action: 'start'; + credentials: LeaseCredentials; + target: { generation: string; simulatorId: string }; + activity: OperationActivity; + } + | { action: 'finish'; credentials: LeaseCredentials; activityId: string; runtimeId: string } + | { action: 'helper'; credentials: LeaseCredentials; callId: string; activity: OperationActivity } + | { action: 'block'; credentials: LeaseCredentials; reason: string }; + +const manager = await SimulatorResourceManager.open({ + stateRoot: process.argv[2], + maxActiveOperations: Number(process.argv[3]), +}); +let completeCreation: (() => void) | undefined; + +async function run(command: WorkerCommand): Promise { + switch (command.action) { + case 'provision': + return manager.provision(command.worktree, command.specification, async () => { + const completed = new Promise((resolve) => { + completeCreation = resolve; + }); + process.send?.({ creating: true }); + await completed; + return command.simulatorId; + }); + case 'complete-creation': + if (!completeCreation) throw new Error('No creation is pending'); + completeCreation(); + completeCreation = undefined; + return null; + case 'bind': + return manager.bind(command.worktree, command.specification, command.simulatorId); + case 'request': + return manager.requestLease(command.request); + case 'poll': + return manager.poll(command.credentials); + case 'end': + return manager.end(command.credentials); + case 'cancel': + return manager.cancel(command.credentials); + case 'status': + return manager.getStatus(command.requestId); + case 'start': + return manager.startCall(command.credentials, command.target, command.activity); + case 'finish': + return manager.finishActivity(command.credentials, command.activityId, command.runtimeId); + case 'helper': + return manager.registerHelper(command.credentials, command.callId, command.activity); + case 'block': + return manager.block(command.credentials, command.reason); + } +} + +process.on('message', (message: { id: number; command: WorkerCommand }) => { + void run(message.command).then( + (result) => process.send?.({ id: message.id, result }), + (error: unknown) => + process.send?.({ + id: message.id, + error: error instanceof Error ? error.message : String(error), + }), + ); +}); +process.on('disconnect', () => process.exit(0)); +process.send?.({ ready: true }); diff --git a/src/resource-management/__tests__/fixtures/transport-worker.ts b/src/resource-management/__tests__/fixtures/transport-worker.ts new file mode 100644 index 000000000..788776a9a --- /dev/null +++ b/src/resource-management/__tests__/fixtures/transport-worker.ts @@ -0,0 +1,88 @@ +import { join } from 'node:path'; +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; +import yargs from 'yargs'; +import { handler, schema } from '../../../mcp/tools/resource-management/resource_operation.ts'; +import { createMockExecutor } from '../../../test-utils/mock-executors.ts'; +import { __setTestCommandExecutorOverride } from '../../../utils/command.ts'; +import { getConfig } from '../../../utils/config-store.ts'; +import { createToolCatalog } from '../../../runtime/tool-catalog.ts'; +import { registerToolCommands } from '../../../cli/register-tool-commands.ts'; +import { startDaemonServer } from '../../../daemon/daemon-server.ts'; +import { getSocketPath, ensureSocketDir } from '../../../daemon/socket-path.ts'; +import { createRenderSession } from '../../../rendering/render.ts'; +import { toStructuredEnvelope } from '../../../utils/structured-output-envelope.ts'; +import type { ToolHandlerContext } from '../../../rendering/types.ts'; +import { configureRuntimeWorkspaceKey } from '../../../utils/runtime-instance.ts'; +import { workspaceKeyForRoot } from '../../../utils/workspace-identity.ts'; + +// Every command is injected. This worker never starts Git or any device executable. +const root = process.cwd(); +configureRuntimeWorkspaceKey(workspaceKeyForRoot(root)); +const executor = createMockExecutor({}); +__setTestCommandExecutorOverride(async (...args) => { + if (args[0][0] !== 'git') throw new Error('Unexpected fixture command'); + return { + ...(await executor(...args)), + output: `${args[0].at(-1) === '--show-toplevel' ? root : join(root, '.git')}\n`, + }; +}); +const mode = process.argv[2]; +const catalog = createToolCatalog([ + { + id: 'resource_operation', + mcpName: 'resource_operation', + cliName: 'operation', + workflow: 'resource-management', + cliSchema: schema, + mcpSchema: schema, + stateful: mode === 'cli-daemon', + handler, + }, +]); + +if (mode === 'mcp') { + const server = new McpServer({ name: 'resource-transport-fixture', version: '1' }); + server.registerTool('resource_operation', { inputSchema: schema }, async (args) => { + const render = createRenderSession('text'); + const ctx: ToolHandlerContext = { emit: render.emit, attach: render.attach }; + await handler(args, ctx); + const output = ctx.structuredOutput; + if (!output) throw new Error('Missing result'); + render.setStructuredOutput?.(output); + return { + content: [{ type: 'text', text: render.finalize() }], + structuredContent: { + ...toStructuredEnvelope(output.result, output.schema, output.schemaVersion), + }, + }; + }); + await server.connect(new StdioServerTransport()); +} else if (mode === 'daemon') { + const socketPath = getSocketPath(); + ensureSocketDir(socketPath); + const server = startDaemonServer({ + socketPath, + startedAt: new Date().toISOString(), + enabledWorkflows: ['resource-management'], + catalog, + workspaceRoot: root, + workspaceKey: 'fixture', + xcodeIdeWorkflowEnabled: false, + requestShutdown: () => server.close(), + }); + server.listen(socketPath, () => process.send?.({ ready: true, socketPath })); + server.once('error', (error) => { + throw error; + }); + process.on('disconnect', () => server.close(() => process.exit(0))); +} else if (mode === 'cli' || mode === 'cli-daemon') { + const app = yargs() + .exitProcess(false) + .strict() + .option('socket', { type: 'string', default: getSocketPath() }); + registerToolCommands(app, catalog, { workspaceRoot: root, runtimeConfig: getConfig() }); + await app.parseAsync(process.argv.slice(3)); +} else { + throw new Error('Unknown transport fixture mode'); +} diff --git a/src/resource-management/__tests__/launch.test.ts b/src/resource-management/__tests__/launch.test.ts new file mode 100644 index 000000000..f43e29f37 --- /dev/null +++ b/src/resource-management/__tests__/launch.test.ts @@ -0,0 +1,278 @@ +import { randomUUID } from 'node:crypto'; +import { PassThrough } from 'node:stream'; +import { mkdtemp, mkdir, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, expect, it, vi } from 'vitest'; +import { createMockExecutor } from '../../test-utils/mock-executors.ts'; +import { + __setTestCommandExecutorOverride, + __clearTestExecutorOverrides, +} from '../../utils/command.ts'; +import type { ToolHandlerContext } from '../../rendering/types.ts'; +import { resourceEnvironment } from '../environment.ts'; +import { managedToolSchema, wrapManagedTool } from '../tool-gate.ts'; +import { SimulatorResourceManager, type Lease } from '../manager.ts'; +import { resolveManagedWorktree } from '../worktree.ts'; +import { launchSimulatorAppWithLogging } from '../../utils/simulator-steps.ts'; +import { sessionStore } from '../../utils/session-store.ts'; +import { handler } from '../../mcp/tools/simulator/launch_app_sim.ts'; + +vi.mock('../../utils/simulator-steps.ts', () => ({ + launchSimulatorAppWithLogging: vi.fn(() => { + throw new Error('Detached launch is forbidden in this fixture'); + }), +})); +const bundleId = 'com.example.LeaseFixture'; +let directory: string; +let manager: SimulatorResourceManager; +let lease: Lease; +const moduleId = 'mcp/tools/simulator/launch_app_sim'; +const context = (): ToolHandlerContext => ({ emit: () => {}, attach: () => {} }); +const credentials = () => ({ + bundleId, + simulatorId: lease.simulatorId, + operationRequestId: lease.requestId, + operationToken: lease.token, + operationSessionId: lease.sessionId, +}); +const launch = wrapManagedTool(moduleId, (args, ctx) => { + if (!ctx) throw new Error('Missing fixture context'); + return handler(args, ctx); +}); +beforeEach(async () => { + directory = await mkdtemp(join(tmpdir(), 'resource-launch-')); + const root = join(directory, 'checkout'); + await mkdir(join(root, '.git'), { recursive: true }); + vi.stubEnv('XCODEBUILDMCP_OPERATION_STATE_ROOT', join(directory, 'state')); + vi.stubEnv('XCODEBUILDMCP_OPERATION_CAPACITY', '1'); + const executor = createMockExecutor({}); + __setTestCommandExecutorOverride(async (...args) => { + if (args[0][0] !== 'git') throw new Error('Unexpected external command'); + return { + ...(await executor(...args)), + output: `${args[0].at(-1) === '--show-toplevel' ? root : join(root, '.git')}\n`, + }; + }); + const worktree = await resolveManagedWorktree(root, async (...args) => { + return { + ...(await executor(...args)), + output: `${args[0].at(-1) === '--show-toplevel' ? root : join(root, '.git')}\n`, + }; + }); + manager = await SimulatorResourceManager.open(resourceEnvironment()!); + await manager.bind(worktree, { deviceType: 'fixture', runtime: 'fixture' }, randomUUID()); + lease = await manager.requestLease({ + requestId: randomUUID(), + owner: { sessionId: randomUUID() }, + worktree, + }); +}); + +afterEach(async () => { + __clearTestExecutorOverrides(); + sessionStore.clear(); + expect(launchSimulatorAppWithLogging).not.toHaveBeenCalled(); + + vi.unstubAllEnvs(); + + await rm(directory, { recursive: true, force: true }); +}); + +function commands( + options: { + failure?: 'pre-exit' | 'pre-reject' | 'pre-open' | 'exit' | 'reject' | 'open' | 'unknown-exit'; + output?: string; + onLaunch?: () => Promise; + } = {}, +) { + const calls: string[][] = []; + const stream = new PassThrough(); + const root = join(directory, 'checkout'); + const mock = createMockExecutor({}); + __setTestCommandExecutorOverride(async (...args) => { + const response = await mock(...args); + const cmd = args[0]; + if (cmd[0] === 'git') + return { + ...response, + output: `${cmd.at(-1) === '--show-toplevel' ? root : join(root, '.git')}\n`, + }; + calls.push(cmd); + expect(args[2]).toBe(false); + expect(args[4]).toBeUndefined(); + if (cmd[2] === 'get_app_container') { + expect(cmd).toEqual([ + 'xcrun', + 'simctl', + 'get_app_container', + lease.simulatorId, + bundleId, + 'app', + ]); + if (options.failure === 'pre-reject') throw new Error('executor lost'); + if (options.failure === 'pre-open') response.process.stdout = stream; + return { ...response, success: options.failure !== 'pre-exit', output: '/fixture/App.app' }; + } + expect(cmd.slice(0, 6)).toEqual([ + 'xcrun', + 'simctl', + 'launch', + '--terminate-running-process', + lease.simulatorId, + bundleId, + ]); + expect(cmd).not.toContain('--console-pty'); + if (args[3]?.env) expect(args[3].env).toEqual({ SIMCTL_CHILD_TEST_FLAG: 'fixture value' }); + await options.onLaunch?.(); + if (options.failure === 'reject') throw new Error('executor lost'); + if (options.failure === 'open') response.process.stdout = stream; + if (options.failure === 'unknown-exit') Object.assign(response.process, { exitCode: null }); + return { + ...response, + success: options.failure !== 'exit', + output: options.output ?? `${bundleId}: 1234\n`, + }; + }); + return { calls, stream }; +} + +it.each([undefined, [], ['--fixture', 'two words', '$(literal)']])( + 'launches with literal arguments %j and no background helper', + async (launchArgs) => { + const { calls } = commands(); + const ctx = context(); + await launch( + { ...credentials(), launchArgs, env: [{ key: 'TEST_FLAG', value: 'fixture value' }] }, + ctx, + ); + expect(calls).toHaveLength(2); + expect(calls[1].slice(6)).toEqual(launchArgs ?? []); + expect(ctx.structuredOutput?.result).toMatchObject({ + didError: false, + artifacts: { simulatorId: lease.simulatorId, bundleId, processId: 1234 }, + }); + expect(ctx.structuredOutput?.result).not.toHaveProperty('artifacts.runtimeLogPath'); + expect(ctx.nextSteps).toEqual([]); + expect((await manager.getStatus(lease.requestId)).state).toBe('active'); + expect((await manager.end(lease)).state).toBe('released'); + }, +); + +it('requires explicit public bundle/lease/target arguments and blocks foreign credentials', async () => { + const { calls } = commands(); + const schema = managedToolSchema(moduleId, {}); + expect(schema.bundleId.safeParse(undefined).success).toBe(false); + expect(schema.bundleId.safeParse('').success).toBe(false); + expect(schema.simulatorId.safeParse(undefined).success).toBe(false); + for (const args of [ + { simulatorId: lease.simulatorId, bundleId }, + { ...credentials(), operationToken: randomUUID() }, + { ...credentials(), simulatorId: randomUUID() }, + ]) { + await expect(launch(args, context())).rejects.toThrow(); + } + for (const invalid of ['', '--console']) { + const ctx = context(); + await launch({ ...credentials(), bundleId: invalid }, ctx); + expect(ctx.structuredOutput?.result.didError).toBe(true); + } + expect(calls).toEqual([]); +}); + +it('returns a safe domain failure when installed-app preflight fails', async () => { + const { calls } = commands({ failure: 'pre-exit' }); + const ctx = context(); + await launch(credentials(), ctx); + expect(calls).toHaveLength(1); + expect(ctx.structuredOutput?.result.didError).toBe(true); + expect((await manager.end(lease)).state).toBe('released'); +}); + +it.each(['pre-reject', 'pre-open', 'exit', 'reject', 'open', 'unknown-exit'] as const)( + 'retains activity and blocks handoff on %s', + async (failure) => { + const { calls, stream } = commands({ failure }); + try { + await expect(launch(credentials(), context())).rejects.toThrow( + 'completion could not be confirmed', + ); + expect(calls).toHaveLength(failure.startsWith('pre-') ? 1 : 2); + await expect(manager.end(lease)).rejects.toThrow( + 'Operation is blocked; end/cancel cannot recover it', + ); + expect((await manager.getStatus(lease.requestId)).activities).toHaveLength(1); + } finally { + stream.destroy(); + } + }, +); + +it.each([ + '', + 'foreign.bundle: 1234', + `${bundleId}: 0`, + `${bundleId}: 9007199254740992`, + `${bundleId}: 12garbage`, +])('blocks ambiguous successful launch output %j', async (output) => { + commands({ output }); + await expect(launch(credentials(), context())).rejects.toThrow( + 'completion could not be confirmed', + ); + await expect(manager.end(lease)).rejects.toThrow( + 'Operation is blocked; end/cancel cannot recover it', + ); +}); + +it.each(['success', 'failure'])('drains closing launch before handoff: %s', async (outcome) => { + let release!: () => void; + let entered!: () => void; + const started = new Promise((resolve) => { + entered = resolve; + }); + const finish = new Promise((resolve) => { + release = resolve; + }); + commands({ + failure: outcome === 'failure' ? 'exit' : undefined, + onLaunch: async () => { + entered(); + await finish; + }, + }); + const binding = await manager.getBinding((await manager.getStatus(lease.requestId)).generation); + if (!binding) throw new Error('Missing fixture binding'); + const waiting = await manager.requestLease({ + requestId: randomUUID(), + owner: { sessionId: randomUUID() }, + worktree: binding.worktree, + }); + const call = launch(credentials(), context()).then( + () => 'success', + () => 'failure', + ); + await started; + expect((await manager.end(lease)).state).toBe('closing'); + expect((await manager.poll(waiting)).state).toBe('waiting'); + release(); + expect(await call).toBe(outcome); + expect((await manager.getStatus(lease.requestId)).state).toBe( + outcome === 'success' ? 'released' : 'blocked', + ); + expect((await manager.poll(waiting)).state).toBe(outcome === 'success' ? 'active' : 'waiting'); +}); + +it('serializes launches sharing one token', async () => { + let active = 0; + let maximum = 0; + commands({ + onLaunch: async () => { + maximum = Math.max(maximum, ++active); + await new Promise((resolve) => setTimeout(resolve, 20)); + active--; + }, + }); + await Promise.all([launch(credentials(), context()), launch(credentials(), context())]); + expect(maximum).toBe(1); + expect((await manager.getStatus(lease.requestId)).activities).toHaveLength(0); +}); diff --git a/src/resource-management/__tests__/manager-completion-lock.test.ts b/src/resource-management/__tests__/manager-completion-lock.test.ts new file mode 100644 index 000000000..1c176ab5d --- /dev/null +++ b/src/resource-management/__tests__/manager-completion-lock.test.ts @@ -0,0 +1,148 @@ +import { randomUUID } from 'node:crypto'; +import { mkdir, mkdtemp, readFile, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { createMockExecutor } from '../../test-utils/mock-executors.ts'; +import { FS_LOCK_OWNER_FILE, tryAcquireFsLock, type AcquiredFsLock } from '../../utils/fs-lock.ts'; +import { SimulatorResourceManager, type LeaseRequest, type OperationActivity } from '../manager.ts'; +import { resolveManagedWorktree, type ManagedWorktree } from '../worktree.ts'; + +const specification = { deviceType: 'test-device-type', runtime: 'test-runtime' }; +let directory: string; +let stateRoot: string; +let manager: SimulatorResourceManager; +let firstWorktree: ManagedWorktree; +let secondWorktree: ManagedWorktree; +let firstSimulator: string; +let secondSimulator: string; + +async function identify(root: string): Promise { + const executor = createMockExecutor({}); + return resolveManagedWorktree(root, async (...args) => { + const result = await executor(...args); + const option = args[0].at(-1); + if (option !== '--show-toplevel' && option !== '--absolute-git-dir') { + throw new Error('Unexpected Git discovery command'); + } + return { ...result, output: `${option === '--show-toplevel' ? root : join(root, '.git')}\n` }; + }); +} + +async function makeWorktree(name: string): Promise { + const root = join(directory, name); + await mkdir(join(root, '.git'), { recursive: true }); + return identify(root); +} + +function request(worktree: ManagedWorktree): LeaseRequest { + return { requestId: randomUUID(), owner: { sessionId: randomUUID() }, worktree }; +} + +function activity(): OperationActivity { + return { id: randomUUID(), kind: 'call', runtimeId: randomUUID(), pid: process.pid }; +} + +async function holdRegistryLock(): Promise { + const lock = await tryAcquireFsLock({ + lockDir: join(stateRoot, 'registry.lock'), + purpose: 'resource-registry', + leaseMs: 30_000, + }); + if (!lock) throw new Error('Unable to hold the registry lock for the fixture'); + return lock; +} + +async function waitForDateCalls(calls: () => number, minimum: number): Promise { + await new Promise((resolve, reject) => { + let interval: ReturnType | undefined; + const timeout = setTimeout(() => { + if (interval) clearInterval(interval); + reject(new Error(`Expected repeated lock attempts, saw ${calls()} Date.now calls`)); + }, 1_000); + interval = setInterval(() => { + if (calls() >= minimum) { + clearTimeout(timeout); + if (interval) clearInterval(interval); + resolve(); + } + }, 1); + }); +} + +beforeEach(async () => { + directory = await mkdtemp(join(tmpdir(), 'resource-manager-completion-lock-')); + stateRoot = join(directory, 'state'); + firstWorktree = await makeWorktree('first'); + secondWorktree = await makeWorktree('second'); + firstSimulator = randomUUID(); + secondSimulator = randomUUID(); + manager = await SimulatorResourceManager.open({ stateRoot, maxActiveOperations: 1 }); + await manager.bind(firstWorktree, specification, firstSimulator); + await manager.bind(secondWorktree, specification, secondSimulator); +}); + +afterEach(async () => { + vi.restoreAllMocks(); + await rm(directory, { recursive: true, force: true }); +}); + +describe('SimulatorResourceManager completion lock waiting', () => { + it('keeps a closing activity until the live lock is released after its lease deadline', async () => { + const current = await manager.requestLease(request(firstWorktree)); + const waiting = await manager.requestLease(request(secondWorktree)); + expect(waiting.state).toBe('waiting'); + + const call = activity(); + expect( + await manager.startCall( + current, + { generation: firstWorktree.generation, simulatorId: firstSimulator }, + call, + ), + ).toBe(true); + expect((await manager.end(current)).state).toBe('closing'); + + const held = await holdRegistryLock(); + const realNow = Date.now(); + const now = vi.spyOn(Date, 'now'); + let calls = 0; + now.mockImplementation(() => realNow + (calls++ === 0 ? 0 : 30_001)); + let settled = false; + const pending = manager.finishActivity(current, call.id, call.runtimeId).then( + (result) => { + settled = true; + return result; + }, + (error: unknown) => { + settled = true; + throw error; + }, + ); + + try { + await waitForDateCalls(() => calls, 2); + expect(settled).toBe(false); + const owner = JSON.parse( + await readFile(join(stateRoot, 'registry.lock', FS_LOCK_OWNER_FILE), 'utf8'), + ) as { token: string; pid: number }; + expect(owner).toMatchObject({ token: held.owner.token, pid: process.pid }); + const operation = JSON.parse( + await readFile(join(stateRoot, 'operations', `${current.requestId}.json`), 'utf8'), + ) as { state: string; activities: OperationActivity[] }; + expect(operation).toMatchObject({ state: 'closing', activities: [call] }); + + await held.release(); + await expect(pending).resolves.toMatchObject({ state: 'released' }); + expect((await manager.poll(waiting)).state).toBe('active'); + const completed = JSON.parse( + await readFile(join(stateRoot, 'operations', `${current.requestId}.json`), 'utf8'), + ) as { state: string; activities: OperationActivity[] }; + expect(completed).toMatchObject({ state: 'released', activities: [] }); + } finally { + now.mockRestore(); + await held.release(); + await pending.catch(() => {}); + } + }); +}); diff --git a/src/resource-management/__tests__/manager.test.ts b/src/resource-management/__tests__/manager.test.ts new file mode 100644 index 000000000..a842d110a --- /dev/null +++ b/src/resource-management/__tests__/manager.test.ts @@ -0,0 +1,1125 @@ +import { randomUUID } from 'node:crypto'; +import { fork, type ChildProcess } from 'node:child_process'; +import { once } from 'node:events'; +import { mkdtemp, mkdir, readFile, readdir, rename, rm, utimes, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { createMockExecutor } from '../../test-utils/mock-executors.ts'; +import { + SimulatorResourceManager, + type Lease, + type LeaseRequest, + type OperationActivity, + type OperationStatus, + type SimulatorBinding, +} from '../manager.ts'; +import { resolveManagedWorktree, type ManagedWorktree } from '../worktree.ts'; +import type { WorkerCommand } from './fixtures/lease-worker.ts'; +import { ResourceStore } from '../store.ts'; + +const specification = { deviceType: 'test-device-type', runtime: 'test-runtime' }; +const workers: Worker[] = []; +let directory: string; +let stateRoot: string; +let worktree: ManagedWorktree; +let manager: SimulatorResourceManager; +let simulatorId: string; + +class Worker { + private sequence = 0; + private pending = new Map< + number, + { resolve: (value: unknown) => void; reject: (error: Error) => void } + >(); + private stderr = ''; + readonly child: ChildProcess; + readonly ready: Promise; + readonly exited: Promise; + readonly creationStarted: Promise; + + constructor() { + // Real Node children intentionally exercise the filesystem protocol, never external device tools. + this.child = fork( + fileURLToPath(new URL('./fixtures/lease-worker.ts', import.meta.url)), + [stateRoot, '2'], + { + execArgv: ['--import', 'tsx'], + stdio: ['ignore', 'ignore', 'pipe', 'ipc'], + }, + ); + this.child.stderr?.on('data', (chunk: Buffer) => { + this.stderr += chunk.toString(); + }); + this.exited = once(this.child, 'exit').then(() => undefined); + this.creationStarted = new Promise((resolve) => { + this.child.on('message', (message: { creating?: boolean }) => { + if (message.creating) resolve(); + }); + }); + this.ready = new Promise((resolve, reject) => { + this.child.once('error', reject); + this.child.on( + 'message', + (message: { ready?: boolean; id?: number; result?: unknown; error?: string }) => { + if (message.ready) { + resolve(); + return; + } + if (message.id === undefined) return; + const pending = this.pending.get(message.id); + if (!pending) return; + this.pending.delete(message.id); + if (message.error) pending.reject(new Error(message.error)); + else pending.resolve(message.result); + }, + ); + this.child.once('exit', () => { + const error = new Error(`Lease worker exited: ${this.stderr}`); + reject(error); + for (const pending of this.pending.values()) pending.reject(error); + this.pending.clear(); + }); + }); + workers.push(this); + } + + async send(command: WorkerCommand): Promise { + await this.ready; + return new Promise((resolve, reject) => { + const id = ++this.sequence; + this.pending.set(id, { resolve: (value) => resolve(value as T), reject }); + this.child.send({ id, command }, (error) => { + if (error) { + this.pending.delete(id); + reject(error); + } + }); + }); + } + + async stop(): Promise { + if (this.child.exitCode === null && this.child.signalCode === null) this.child.kill('SIGKILL'); + await this.exited; + } +} + +async function identify(root: string): Promise { + const executor = createMockExecutor({}); + // Fixtures use a separate Git directory but inject Git discovery; no git executable is launched. + return resolveManagedWorktree(root, async (...args) => { + const result = await executor(...args); + const option = args[0].at(-1); + if (option !== '--show-toplevel' && option !== '--absolute-git-dir') { + throw new Error('Unexpected Git discovery command'); + } + return { ...result, output: `${option === '--show-toplevel' ? root : join(root, '.git')}\n` }; + }); +} + +async function makeWorktree(name: string): Promise { + const root = join(directory, name); + await mkdir(join(root, '.git'), { recursive: true }); + return identify(root); +} + +function request(target = worktree): LeaseRequest { + return { requestId: randomUUID(), owner: { sessionId: randomUUID() }, worktree: target }; +} + +function activity( + kind: 'call' | 'helper' = 'call', + runtimeId: string = randomUUID(), +): OperationActivity { + return { id: randomUUID(), kind, runtimeId, pid: process.pid }; +} + +function target( + tree = worktree, + device = simulatorId, +): { generation: string; simulatorId: string } { + return { generation: tree.generation, simulatorId: device }; +} + +beforeEach(async () => { + directory = await mkdtemp(join(tmpdir(), 'resource-manager-')); + stateRoot = join(directory, 'state'); + worktree = await makeWorktree('checkout'); + simulatorId = randomUUID(); + manager = await SimulatorResourceManager.open({ stateRoot, maxActiveOperations: 2 }); + await manager.bind(worktree, specification, simulatorId); +}); + +afterEach(async () => { + await Promise.all(workers.splice(0).map((worker) => worker.stop())); + await rm(directory, { recursive: true, force: true }); +}); + +describe('persistent identity and isolation', () => { + it('resolves Git paths with injected commands and preserves binding after ordinary file edits', async () => { + await writeFile(join(worktree.root, 'source.txt'), 'changed'); + const again = await identify(worktree.root); + expect(again.generation).toBe(worktree.generation); + expect(await manager.bind(again, specification, simulatorId)).toMatchObject({ simulatorId }); + }); + + it('distinguishes same-path replacement while retaining the removed generation record', async () => { + await rename(worktree.root, join(directory, 'old-checkout')); + const replacement = await makeWorktree('checkout'); + expect(replacement.workspaceKey).toBe(worktree.workspaceKey); + expect(replacement.generation).not.toBe(worktree.generation); + await expect(manager.requestLease(request())).rejects.toThrow('generation changed'); + await expect(manager.bind(replacement, specification, simulatorId)).rejects.toThrow( + 'another worktree', + ); + await manager.bind(replacement, specification, randomUUID()); + await rm(join(directory, 'old-checkout'), { recursive: true }); + expect(await manager.getBinding(worktree.generation)).toMatchObject({ simulatorId }); + }); + + it('does not silently replace a bound specification or device', async () => { + await expect( + manager.bind(worktree, { ...specification, runtime: 'different' }, simulatorId), + ).rejects.toThrow('conflicts'); + await expect(manager.bind(worktree, specification, randomUUID())).rejects.toThrow('conflicts'); + }); + + it('requires reconciliation when a bound worktree moves', async () => { + const movedRoot = join(directory, 'moved'); + await rename(worktree.root, movedRoot); + const moved = await identify(movedRoot); + expect(moved.generation).toBe(worktree.generation); + await expect(manager.bind(moved, specification, simulatorId)).rejects.toThrow('relocation'); + await expect(manager.requestLease(request(moved))).rejects.toThrow('relocation'); + }); + + it('treats UUID case variants as the same Simulator across independent binders', async () => { + const firstTree = await makeWorktree('case-a'); + const secondTree = await makeWorktree('case-b'); + const device = 'aaaaaaaa-aaaa-4aaa-aaaa-aaaaaaaaaaaa'; + const outcomes = await Promise.allSettled( + [new Worker(), new Worker()].map((worker, index) => + worker.send({ + action: 'bind', + worktree: [firstTree, secondTree][index], + specification, + simulatorId: index === 0 ? device : device.toUpperCase(), + }), + ), + ); + expect(outcomes.filter((outcome) => outcome.status === 'fulfilled')).toHaveLength(1); + const rejected = outcomes.filter((outcome) => outcome.status === 'rejected'); + expect(rejected).toHaveLength(1); + expect(rejected[0].reason).toEqual( + new Error('Simulator is bound to another worktree generation'), + ); + }); + + it('requires explicit consistent private storage outside the checkout', async () => { + await expect( + SimulatorResourceManager.open({ stateRoot: 'relative', maxActiveOperations: 2 }), + ).rejects.toThrow('absolute'); + await expect( + SimulatorResourceManager.open({ stateRoot, maxActiveOperations: 1 }), + ).rejects.toThrow('same operation capacity'); + const nested = await SimulatorResourceManager.open({ + stateRoot: join(worktree.root, 'state'), + maxActiveOperations: 2, + }); + await expect(nested.bind(worktree, specification, simulatorId)).rejects.toThrow( + 'inside a worktree', + ); + }); + + it('fails on corrupt records rather than treating them as available', async () => { + await writeFile(join(stateRoot, 'operations', `${randomUUID()}.json`), '{broken'); + await expect(manager.requestLease(request())).rejects.toThrow(SyntaxError); + }); +}); + +describe('dedicated-device provisioning', () => { + it('creates once and reuses the persisted binding across manager instances', async () => { + const tree = await makeWorktree('provision'); + const device = randomUUID(); + const create = vi.fn(async () => device.toUpperCase()); + const binding = await manager.provision(tree, specification, create); + const reopened = await SimulatorResourceManager.open({ stateRoot, maxActiveOperations: 2 }); + expect(await reopened.provision(tree, specification, create)).toEqual(binding); + expect(binding.simulatorId).toBe(device); + expect(create).toHaveBeenCalledExactlyOnceWith({ + name: expect.stringMatching(/^XcodeBuildMCP-[a-f0-9-]+$/), + specification, + }); + expect(await manager.getProvisioning(tree.generation)).toMatchObject({ + state: 'created', + simulatorId: device, + }); + await expect( + reopened.provision(tree, { ...specification, runtime: 'different' }, create), + ).rejects.toThrow('conflicts'); + expect(create).toHaveBeenCalledTimes(1); + const first = await manager.requestLease(request(tree)); + await manager.end(first); + expect(await reopened.requestLease(request(tree))).toMatchObject({ + state: 'active', + simulatorId: device, + }); + }); + + it('retains uncertain creation and rejects retries and direct binding', async () => { + const tree = await makeWorktree('uncertain'); + const create = vi.fn(async () => { + throw new Error('Creation response lost'); + }); + await expect(manager.provision(tree, specification, create)).rejects.toThrow('response lost'); + expect(await manager.getProvisioning(tree.generation)).toMatchObject({ state: 'blocked' }); + await expect(manager.provision(tree, specification, create)).rejects.toThrow( + 'requires reconciliation', + ); + await expect(manager.bind(tree, specification, randomUUID())).rejects.toThrow( + 'Provisioning record exists', + ); + expect(create).toHaveBeenCalledTimes(1); + expect(await manager.getBinding(tree.generation)).toBeNull(); + }); + + it('retains the created UDID if the worktree disappears before commit', async () => { + const tree = await makeWorktree('removed'); + const device = randomUUID(); + await expect( + manager.provision(tree, specification, async () => { + await rename(tree.root, join(directory, 'removed-checkout')); + await makeWorktree('removed'); + return device; + }), + ).rejects.toThrow('generation changed'); + expect(await manager.getProvisioning(tree.generation)).toMatchObject({ + state: 'blocked', + simulatorId: device, + }); + expect(await manager.getBinding(tree.generation)).toBeNull(); + const replacement = await identify(tree.root); + await expect(manager.bind(replacement, specification, device)).rejects.toThrow( + 'reserved by another provisioning attempt', + ); + }); + + it.each(['provisioning', 'bindings'])( + 'handles a lost acknowledgement after a %s write', + async (recordType) => { + const tree = await makeWorktree('lost-ack'); + const device = randomUUID(); + const create = vi.fn(async () => device); + const write = ResourceStore.prototype.write; + let injected = false; + const spy = vi.spyOn(ResourceStore.prototype, 'write').mockImplementation(async function ( + this: ResourceStore, + name, + record, + ) { + await write.call(this, name, record); + if ( + !injected && + name.startsWith(`${recordType}/`) && + (record as { simulatorId?: string }).simulatorId === device + ) { + injected = true; + throw new Error('Write acknowledgement lost'); + } + }); + try { + if (recordType === 'bindings') { + expect(await manager.provision(tree, specification, create)).toMatchObject({ + simulatorId: device, + }); + expect(await manager.provision(tree, specification, create)).toMatchObject({ + simulatorId: device, + }); + } else { + await expect(manager.provision(tree, specification, create)).rejects.toThrow( + 'acknowledgement lost', + ); + expect(await manager.getProvisioning(tree.generation)).toMatchObject({ + state: 'blocked', + simulatorId: device, + }); + expect(await manager.getBinding(tree.generation)).toBeNull(); + await expect(manager.provision(tree, specification, create)).rejects.toThrow( + 'requires reconciliation', + ); + } + expect(injected).toBe(true); + expect(create).toHaveBeenCalledTimes(1); + } finally { + spy.mockRestore(); + } + }, + ); + + it('does not publish invalid or already bound device identities', async () => { + for (const [device, message] of [ + ['invalid-uuid', 'Invalid UUID'], + [simulatorId, 'Simulator is bound to another worktree generation'], + ]) { + const tree = await makeWorktree(randomUUID()); + await expect(manager.provision(tree, specification, async () => device)).rejects.toThrow( + message, + ); + expect(await manager.getBinding(tree.generation)).toBeNull(); + expect(await manager.getProvisioning(tree.generation)).toMatchObject({ state: 'blocked' }); + } + }); + + it('does not let the creator mutate the persisted specification', async () => { + const tree = await makeWorktree('mutable'); + const result = await manager.provision(tree, specification, async (input) => { + input.specification.runtime = 'different'; + return randomUUID(); + }); + expect(result.specification).toEqual(specification); + }); + + it('permits only one creator across processes and reuses its result while it stays alive', async () => { + const tree = await makeWorktree('concurrent-create'); + const a = new Worker(); + const b = new Worker(); + const contenders = [a, b]; + await Promise.all(contenders.map((worker) => worker.ready)); + const creating = contenders.map((worker) => + worker.send({ + action: 'provision', + worktree: tree, + specification, + simulatorId: randomUUID(), + }), + ); + const settled = Promise.allSettled(creating); + const winner = await Promise.race( + contenders.map(async (worker, index) => { + await worker.creationStarted; + return index; + }), + ); + await expect(creating[1 - winner]).rejects.toThrow('in progress'); + expect(await manager.getBinding(tree.generation)).toBeNull(); + // Unrelated lease work must not wait for an external creator's completion. + expect((await manager.requestLease(request())).state).toBe('active'); + await contenders[winner].send({ action: 'complete-creation' }); + const binding = await creating[winner]; + expect((await settled).filter((result) => result.status === 'fulfilled')).toHaveLength(1); + expect( + await contenders[1 - winner].send({ + action: 'provision', + worktree: tree, + specification, + simulatorId: randomUUID(), + }), + ).toEqual(binding); + expect(contenders[winner].child.exitCode).toBeNull(); + }); + + it('does not reclaim a creation reservation after creator death or aging', async () => { + const tree = await makeWorktree('dead-creator'); + const worker = new Worker(); + const creating = worker.send({ + action: 'provision', + worktree: tree, + specification, + simulatorId: randomUUID(), + }); + const rejected = expect(creating).rejects.toThrow('Lease worker exited'); + await worker.creationStarted; + await worker.stop(); + await rejected; + const oldTime = new Date('2000-01-01T00:00:00Z'); + await utimes(join(stateRoot, 'provisioning', `${tree.generation}.json`), oldTime, oldTime); + const reopened = await SimulatorResourceManager.open({ stateRoot, maxActiveOperations: 2 }); + const create = vi.fn(async () => randomUUID()); + await expect(reopened.provision(tree, specification, create)).rejects.toThrow( + 'requires reconciliation', + ); + expect(create).not.toHaveBeenCalled(); + expect(await reopened.getProvisioning(tree.generation)).toMatchObject({ state: 'creating' }); + }); +}); + +describe('lease lifecycle', () => { + it('deduplicates begin retries and redacts the token from status', async () => { + const begin = request(); + const first = await manager.requestLease(begin); + expect(await manager.requestLease(begin)).toEqual(first); + expect(await manager.getStatus(first.requestId)).not.toHaveProperty('token'); + await expect( + manager.requestLease({ ...begin, owner: { sessionId: randomUUID() } }), + ).rejects.toThrow('conflicts'); + await manager.end(first); + expect((await manager.requestLease(begin)).state).toBe('released'); + }); + + it('keeps v2 completion history bounded while rejecting replayed activity IDs', async () => { + const current = await manager.requestLease(request()); + const first = activity(); + const second = activity(); + + await manager.startCall(current, target(), first); + await manager.finishActivity(current, first.id, first.runtimeId); + await manager.startCall(current, target(), second); + await manager.finishActivity(current, second.id, second.runtimeId); + await expect(manager.startCall(current, target(), first)).rejects.toThrow('already exists'); + + const record = JSON.parse( + await readFile(join(stateRoot, 'operations', `${current.requestId}.json`), 'utf8'), + ) as { + version: number; + activities: OperationActivity[]; + completedActivities: OperationActivity[]; + }; + expect(record.version).toBe(2); + expect(record.activities).toEqual([]); + expect(record.completedActivities).toEqual([second]); + + const completionFiles = (await readdir(join(stateRoot, 'completions'))).filter((name) => + name.endsWith('.json'), + ); + expect(completionFiles).toHaveLength(2); + const firstReceipt = JSON.parse( + await readFile( + join(stateRoot, 'completions', `${current.requestId}.${first.id}.json`), + 'utf8', + ), + ) as { requestId: string; owner: { sessionId: string }; activity: OperationActivity }; + expect(firstReceipt).toEqual({ + requestId: current.requestId, + owner: { sessionId: current.sessionId }, + activity: first, + }); + expect(firstReceipt).not.toHaveProperty('token'); + expect((await manager.end(current)).state).toBe('released'); + }); + + it('keeps the latest completed call when a helper finishes', async () => { + const current = await manager.requestLease(request()); + const first = activity(); + const second = activity(); + const helper = activity('helper', second.runtimeId); + + await manager.startCall(current, target(), first); + await manager.finishActivity(current, first.id, first.runtimeId); + await manager.startCall(current, target(), second); + await manager.registerHelper(current, second.id, helper); + await manager.finishActivity(current, helper.id, helper.runtimeId); + + expect((await manager.getStatus(current.requestId)).completedActivities).toEqual([first]); + expect((await manager.getStatus(current.requestId)).activities).toEqual([second]); + + await manager.finishActivity(current, second.id, second.runtimeId); + expect((await manager.getStatus(current.requestId)).completedActivities).toEqual([second]); + expect((await manager.end(current)).state).toBe('released'); + }); + + it('fails closed on a conflicting historical receipt without changing a new in-flight activity', async () => { + const current = await manager.requestLease(request()); + const first = activity(); + const second = activity(); + const third = activity(); + + await manager.startCall(current, target(), first); + await manager.finishActivity(current, first.id, first.runtimeId); + await manager.startCall(current, target(), second); + await manager.finishActivity(current, second.id, second.runtimeId); + await manager.startCall(current, target(), third); + + await expect(manager.finishActivity(current, first.id, randomUUID())).rejects.toThrow( + 'Activity ownership does not match', + ); + expect((await manager.getStatus(current.requestId)).activities).toEqual([third]); + expect((await manager.finishActivity(current, first.id, first.runtimeId)).activities).toEqual([ + third, + ]); + + await writeFile( + join(stateRoot, 'completions', `${current.requestId}.${first.id}.json`), + `${JSON.stringify({ + requestId: current.requestId, + owner: { sessionId: randomUUID() }, + activity: first, + })}\n`, + ); + await expect(manager.finishActivity(current, first.id, first.runtimeId)).rejects.toThrow( + 'Completion receipt conflicts', + ); + expect((await manager.getStatus(current.requestId)).activities).toEqual([third]); + + await manager.finishActivity(current, third.id, third.runtimeId); + expect((await manager.end(current)).state).toBe('released'); + }); + + it('leaves the header active when a receipt write fails before commit', async () => { + const current = await manager.requestLease(request()); + const call = activity(); + await manager.startCall(current, target(), call); + + const write = ResourceStore.prototype.write; + const spy = vi.spyOn(ResourceStore.prototype, 'write').mockImplementation(async function ( + this: ResourceStore, + name, + value, + ) { + if (name.startsWith('completions/')) throw new Error('Receipt write rejected'); + await write.call(this, name, value); + }); + try { + await expect(manager.finishActivity(current, call.id, call.runtimeId)).rejects.toThrow( + 'Receipt write rejected', + ); + } finally { + spy.mockRestore(); + } + + expect( + ( + JSON.parse( + await readFile(join(stateRoot, 'operations', `${current.requestId}.json`), 'utf8'), + ) as { activities: OperationActivity[] } + ).activities, + ).toEqual([call]); + await expect( + readFile(join(stateRoot, 'completions', `${current.requestId}.${call.id}.json`), 'utf8'), + ).rejects.toMatchObject({ code: 'ENOENT' }); + + await manager.finishActivity(current, call.id, call.runtimeId); + expect((await manager.end(current)).state).toBe('released'); + }); + + it('writes completion receipts before headers and resumes after a precommit header write failure', async () => { + const current = await manager.requestLease(request()); + const call = activity(); + await manager.startCall(current, target(), call); + + const operationFile = `operations/${current.requestId}.json`; + const write = ResourceStore.prototype.write; + let failed = false; + const spy = vi.spyOn(ResourceStore.prototype, 'write').mockImplementation(async function ( + this: ResourceStore, + name, + record, + ) { + if ( + !failed && + name === operationFile && + (record as { activities: unknown[] }).activities.length === 0 + ) { + failed = true; + throw new Error('Operation header write rejected'); + } + await write.call(this, name, record); + }); + try { + await expect(manager.finishActivity(current, call.id, call.runtimeId)).rejects.toThrow( + 'header write rejected', + ); + } finally { + spy.mockRestore(); + } + + const unfinished = JSON.parse( + await readFile(join(stateRoot, 'operations', `${current.requestId}.json`), 'utf8'), + ) as { activities: OperationActivity[] }; + expect(unfinished.activities).toEqual([call]); + await expect( + readFile(join(stateRoot, 'completions', `${current.requestId}.${call.id}.json`), 'utf8'), + ).resolves.toBeDefined(); + + expect((await manager.finishActivity(current, call.id, call.runtimeId)).state).toBe('active'); + expect((await manager.getStatus(current.requestId)).activities).toEqual([]); + expect((await manager.end(current)).state).toBe('released'); + }); + + it('retries after a lost header acknowledgement without dropping closing work or waiters', async () => { + const current = await manager.requestLease(request()); + const waiter = await manager.requestLease(request()); + const call = activity(); + const helper = activity('helper', call.runtimeId); + await manager.startCall(current, target(), call); + await manager.registerHelper(current, call.id, helper); + await manager.end(current); + + const operationFile = `operations/${current.requestId}.json`; + const write = ResourceStore.prototype.write; + let lostAcknowledgement = false; + const spy = vi.spyOn(ResourceStore.prototype, 'write').mockImplementation(async function ( + this: ResourceStore, + name, + record, + ) { + if ( + !lostAcknowledgement && + name === operationFile && + (record as { state: string; activities: OperationActivity[] }).state === 'closing' && + (record as { activities: OperationActivity[] }).activities.length === 1 + ) { + await write.call(this, name, record); + lostAcknowledgement = true; + throw new Error('Operation header acknowledgement lost'); + } + await write.call(this, name, record); + }); + try { + await expect(manager.finishActivity(current, call.id, call.runtimeId)).rejects.toThrow( + 'header acknowledgement lost', + ); + } finally { + spy.mockRestore(); + } + + expect((await manager.getStatus(current.requestId)).state).toBe('closing'); + expect((await manager.getStatus(current.requestId)).activities).toEqual([helper]); + expect((await manager.finishActivity(current, call.id, call.runtimeId)).state).toBe('closing'); + await manager.finishActivity(current, helper.id, helper.runtimeId); + expect((await manager.poll(waiter)).state).toBe('active'); + expect((await manager.end(waiter)).state).toBe('released'); + }); + + it('keeps legacy active records at v1 until activities drain, then migrates them', async () => { + const current = await manager.requestLease(request()); + const call = activity(); + const helper = activity('helper', call.runtimeId); + await manager.startCall(current, target(), call); + await manager.registerHelper(current, call.id, helper); + + const operationPath = join(stateRoot, 'operations', `${current.requestId}.json`); + const record = JSON.parse(await readFile(operationPath, 'utf8')) as Record; + await writeFile( + operationPath, + `${JSON.stringify({ ...record, version: 1, completedActivities: [] })}\n`, + ); + + await expect(manager.poll(current)).resolves.toMatchObject({ state: 'active' }); + expect((JSON.parse(await readFile(operationPath, 'utf8')) as { version: number }).version).toBe( + 1, + ); + + await manager.finishActivity(current, call.id, call.runtimeId); + const drainedCall = JSON.parse(await readFile(operationPath, 'utf8')) as { + version: number; + activities: OperationActivity[]; + completedActivities: OperationActivity[]; + }; + expect(drainedCall.version).toBe(1); + expect(drainedCall.activities).toEqual([helper]); + expect(drainedCall.completedActivities).toEqual([call]); + + await manager.finishActivity(current, helper.id, helper.runtimeId); + const migrated = JSON.parse(await readFile(operationPath, 'utf8')) as { + version: number; + activities: OperationActivity[]; + completedActivities: OperationActivity[]; + }; + expect(migrated.version).toBe(2); + expect(migrated.activities).toEqual([]); + expect(migrated.completedActivities).toEqual([call]); + expect( + (await readdir(join(stateRoot, 'completions'))).filter((name) => name.endsWith('.json')), + ).toHaveLength(2); + expect((await manager.end(current)).state).toBe('released'); + }); + + it('retries a partial legacy migration without rewriting committed receipts', async () => { + const current = await manager.requestLease(request()); + const first = activity(); + const second = activity(); + const operationPath = join(stateRoot, 'operations', `${current.requestId}.json`); + const record = JSON.parse(await readFile(operationPath, 'utf8')) as Record; + await writeFile( + operationPath, + `${JSON.stringify({ ...record, version: 1, activities: [], completedActivities: [first, second] })}\n`, + ); + + const write = ResourceStore.prototype.write; + let failed = false; + let firstReceiptWrites = 0; + const firstReceipt = `completions/${current.requestId}.${first.id}.json`; + const spy = vi.spyOn(ResourceStore.prototype, 'write').mockImplementation(async function ( + this: ResourceStore, + name, + value, + ) { + if (name === firstReceipt) firstReceiptWrites += 1; + await write.call(this, name, value); + if (!failed && name === firstReceipt) { + failed = true; + throw new Error('Completion receipt acknowledgement lost'); + } + }); + try { + await expect(manager.poll(current)).rejects.toThrow('receipt acknowledgement lost'); + expect( + (JSON.parse(await readFile(operationPath, 'utf8')) as { version: number }).version, + ).toBe(1); + expect(firstReceiptWrites).toBe(1); + expect( + (await readdir(join(stateRoot, 'completions'))).filter((name) => name.endsWith('.json')), + ).toHaveLength(1); + await expect(manager.poll(current)).resolves.toMatchObject({ state: 'active' }); + const migrated = JSON.parse(await readFile(operationPath, 'utf8')) as { + version: number; + completedActivities: OperationActivity[]; + }; + expect(migrated.version).toBe(2); + expect(migrated.completedActivities).toEqual([second]); + expect(firstReceiptWrites).toBe(1); + expect( + (await readdir(join(stateRoot, 'completions'))).filter((name) => name.endsWith('.json')), + ).toHaveLength(2); + } finally { + spy.mockRestore(); + } + expect((await manager.end(current)).state).toBe('released'); + }); + + it('fails closed when a legacy completion receipt has a conflicting owner', async () => { + const current = await manager.requestLease(request()); + const completed = activity(); + const operationPath = join(stateRoot, 'operations', `${current.requestId}.json`); + const record = JSON.parse(await readFile(operationPath, 'utf8')) as Record; + await writeFile( + operationPath, + `${JSON.stringify({ ...record, version: 1, activities: [], completedActivities: [completed] })}\n`, + ); + await writeFile( + join(stateRoot, 'completions', `${current.requestId}.${completed.id}.json`), + `${JSON.stringify({ + requestId: current.requestId, + owner: { sessionId: randomUUID() }, + activity: completed, + })}\n`, + ); + + await expect(manager.poll(current)).rejects.toThrow('Completion receipt conflicts'); + expect((JSON.parse(await readFile(operationPath, 'utf8')) as { version: number }).version).toBe( + 1, + ); + }); + + it('persists completion receipts across manager processes and rejects cross-process replay', async () => { + const current = await manager.requestLease(request()); + const worker = new Worker(); + const call = { ...activity(), pid: worker.child.pid! }; + const laterCall = { ...activity(), pid: worker.child.pid! }; + expect( + await worker.send({ + action: 'start', + credentials: current, + target: target(), + activity: call, + }), + ).toBe(true); + await worker.send({ + action: 'finish', + credentials: current, + activityId: call.id, + runtimeId: call.runtimeId, + }); + expect((await manager.getStatus(current.requestId)).completedActivities).toEqual([call]); + await worker.send({ + action: 'start', + credentials: current, + target: target(), + activity: laterCall, + }); + await worker.send({ + action: 'finish', + credentials: current, + activityId: laterCall.id, + runtimeId: laterCall.runtimeId, + }); + expect((await manager.getStatus(current.requestId)).completedActivities).toEqual([laterCall]); + await expect(manager.startCall(current, target(), call)).rejects.toThrow('already exists'); + expect((await manager.getStatus(current.requestId)).activities).toEqual([]); + expect((await manager.end(current)).state).toBe('released'); + }); + + it('allows zero-wait acquisition and never cancels a previously granted begin retry', async () => { + const begin = request(); + const current = await manager.acquire(begin, { timeoutMs: 0 }); + const call = activity(); + await manager.startCall(current, target(), call); + expect(await manager.acquire(begin, { timeoutMs: 0 })).toEqual(current); + expect((await manager.getStatus(current.requestId)).state).toBe('active'); + }); + + it('returns committed credentials if abort races with delivery of a grant', async () => { + const controller = new AbortController(); + const original = manager.requestLease.bind(manager); + vi.spyOn(manager, 'requestLease').mockImplementationOnce(async (begin) => { + const granted = await original(begin); + controller.abort(); + return granted; + }); + const current = await manager.acquire(request(), { timeoutMs: 0, signal: controller.signal }); + expect(current.state).toBe('active'); + expect((await manager.getStatus(current.requestId)).state).toBe('active'); + await manager.end(current); + }); + + it('retries the same begin after its committed result is lost', async () => { + const begin = request(); + const original = manager.requestLease.bind(manager); + vi.spyOn(manager, 'requestLease').mockImplementationOnce(async (input) => { + await original(input); + throw new Error('Result delivery lost'); + }); + await expect(manager.requestLease(begin)).rejects.toThrow('delivery lost'); + const recovered = await manager.requestLease(begin); + expect(recovered.state).toBe('active'); + expect( + (await readdir(join(stateRoot, 'operations'))).filter((name) => name.endsWith('.json')), + ).toHaveLength(1); + }); + + it('drains calls and helpers before transfer and fences new calls during end', async () => { + const first = await manager.requestLease(request()); + const waiting = await manager.requestLease(request()); + const call = activity(); + const helper = activity('helper', call.runtimeId); + expect(await manager.startCall(first, target(), call)).toBe(true); + expect((await manager.end(first)).state).toBe('closing'); + await manager.registerHelper(first, call.id, helper); + await expect(manager.startCall(first, target(), activity())).rejects.toThrow('closing'); + await manager.finishActivity(first, call.id, call.runtimeId); + expect((await manager.poll(waiting)).state).toBe('waiting'); + expect((await manager.finishActivity(first, helper.id, helper.runtimeId)).state).toBe( + 'released', + ); + expect((await manager.poll(waiting)).state).toBe('active'); + expect((await manager.end(first)).state).toBe('released'); + expect((await manager.poll(waiting)).state).toBe('active'); + }); + + it('rejects wrong credentials, effective targets, and activity owners', async () => { + const current = await manager.requestLease(request()); + await expect( + manager.startCall({ ...current, sessionId: randomUUID() }, target(), activity()), + ).rejects.toThrow('credentials'); + await expect( + manager.startCall({ ...current, token: randomUUID() }, target(), activity()), + ).rejects.toThrow('credentials'); + await expect( + manager.startCall(current, target(worktree, randomUUID()), activity()), + ).rejects.toThrow('target'); + const call = activity(); + await manager.startCall(current, target(), call); + await expect(manager.finishActivity(current, call.id, randomUUID())).rejects.toThrow( + 'ownership', + ); + await manager.finishActivity(current, call.id, call.runtimeId); + await manager.end(current); + await expect(manager.startCall(current, target(), activity())).rejects.toThrow('released'); + }); + + it('cancels queued requests, observes timeout, and does not seize an active lease', async () => { + const current = await manager.requestLease(request()); + const queued = await manager.requestLease(request()); + expect((await manager.cancel(queued)).state).toBe('cancelled'); + const timed = request(); + await expect(manager.acquire(timed, { timeoutMs: 25 })).rejects.toThrow('timed out'); + expect((await manager.getStatus(timed.requestId)).state).toBe('cancelled'); + const controller = new AbortController(); + controller.abort(); + await expect( + manager.acquire(request(), { timeoutMs: 100, signal: controller.signal }), + ).rejects.toThrow(); + expect((await manager.poll(current)).state).toBe('active'); + }); + + it('retains blocked ownership even after all tracked activities finish', async () => { + const current = await manager.requestLease(request()); + const call = activity(); + await manager.startCall(current, target(), call); + await manager.block(current, 'Lost supervision of child process'); + await manager.finishActivity(current, call.id, call.runtimeId); + await expect(manager.end(current)).rejects.toThrow( + 'Operation is blocked; end/cancel cannot recover it', + ); + await expect(manager.cancel(current)).rejects.toThrow( + 'Operation is blocked; end/cancel cannot recover it', + ); + expect((await manager.getStatus(current.requestId)).state).toBe('blocked'); + expect((await manager.requestLease(request())).state).toBe('waiting'); + }); + + it('does not let a delayed finish retry release a later activity', async () => { + const current = await manager.requestLease(request()); + const first = activity(); + await manager.startCall(current, target(), first); + await manager.finishActivity(current, first.id, first.runtimeId); + await expect(manager.startCall(current, target(), first)).rejects.toThrow('already exists'); + const second = activity(); + await manager.startCall(current, target(), second); + await manager.end(current); + expect((await manager.finishActivity(current, first.id, first.runtimeId)).state).toBe( + 'closing', + ); + expect((await manager.getStatus(current.requestId)).activities).toEqual([second]); + }); + + it('cancels an acquire that is already waiting without leaking a queue entry', async () => { + await manager.requestLease(request()); + const next = request(); + const controller = new AbortController(); + const acquiring = manager.acquire(next, { timeoutMs: 5_000, signal: controller.signal }); + const assertion = expect(acquiring).rejects.toThrow('cancelled'); + await expect + .poll(async () => + (await readdir(join(stateRoot, 'operations'))).includes(`${next.requestId}.json`), + ) + .toBe(true); + controller.abort(); + await assertion; + expect((await manager.getStatus(next.requestId)).state).toBe('cancelled'); + }); + + it('enforces host capacity independently of binding count', async () => { + const secondTree = await makeWorktree('second'); + const thirdTree = await makeWorktree('third'); + await manager.bind(secondTree, specification, randomUUID()); + await manager.bind(thirdTree, specification, randomUUID()); + const first = await manager.requestLease(request()); + expect((await manager.requestLease(request(secondTree))).state).toBe('active'); + const third = await manager.requestLease(request(thirdTree)); + expect(third.state).toBe('waiting'); + await manager.end(first); + expect((await manager.poll(third)).state).toBe('active'); + }); +}); + +describe('independent processes', () => { + it('arbitrates simultaneous begin and hands off while the previous process stays alive', async () => { + const a = new Worker(); + const b = new Worker(); + const leases = await Promise.all([ + a.send({ action: 'request', request: request() }), + b.send({ action: 'request', request: request() }), + ]); + expect(leases.map((entry) => entry.state).sort()).toEqual(['active', 'waiting']); + const index = leases.findIndex((entry) => entry.state === 'active'); + const holder = [a, b][index]; + const next = [a, b][1 - index]; + await holder.send({ action: 'end', credentials: leases[index] }); + expect(holder.child.exitCode).toBeNull(); + expect( + await next.send({ action: 'poll', credentials: leases[1 - index] }), + ).toMatchObject({ state: 'active', simulatorId }); + }); + + it('serializes concurrent calls carrying the same token across processes', async () => { + const current = await manager.requestLease(request()); + const a = new Worker(); + const b = new Worker(); + const calls = [activity(), activity()]; + const entered = await Promise.all( + [a, b].map((worker, index) => + worker.send({ + action: 'start', + credentials: current, + target: target(), + activity: calls[index], + }), + ), + ); + expect(entered.filter(Boolean)).toHaveLength(1); + const winner = calls[entered.indexOf(true)]; + await manager.finishActivity(current, winner.id, winner.runtimeId); + const loser = calls[entered.indexOf(false)]; + expect(await manager.startCall(current, target(), loser)).toBe(true); + }); + + it('orders end versus call entry without releasing in-flight activity', async () => { + const current = await manager.requestLease(request()); + const waiting = await manager.requestLease(request()); + const a = new Worker(); + const b = new Worker(); + const call = activity(); + const [entry, ending] = await Promise.allSettled([ + a.send({ action: 'start', credentials: current, target: target(), activity: call }), + b.send({ action: 'end', credentials: current }), + ]); + expect(ending.status).toBe('fulfilled'); + if (entry.status === 'fulfilled') { + expect(entry.value).toBe(true); + expect((await manager.poll(waiting)).state).toBe('waiting'); + await manager.finishActivity(current, call.id, call.runtimeId); + } else { + expect(String(entry.reason)).toContain('released'); + } + expect((await manager.poll(waiting)).state).toBe('active'); + }); + + it('does not reclaim on owner process death or aged records, including surviving helper records', async () => { + const a = new Worker(); + const b = new Worker(); + const current = await a.send({ action: 'request', request: request() }); + const call = { ...activity(), pid: a.child.pid! }; + await a.send({ action: 'start', credentials: current, target: target(), activity: call }); + const helper = { ...activity('helper', call.runtimeId), pid: b.child.pid! }; + await a.send({ action: 'helper', credentials: current, callId: call.id, activity: helper }); + await a.stop(); + const oldTime = new Date('2000-01-01T00:00:00Z'); + await utimes(join(stateRoot, 'operations', `${current.requestId}.json`), oldTime, oldTime); + const waiting = await b.send({ action: 'request', request: request() }); + expect(waiting.state).toBe('waiting'); + const record = JSON.parse( + await readFile(join(stateRoot, 'operations', `${current.requestId}.json`), 'utf8'), + ) as { activities: OperationActivity[] }; + expect(record.activities).toHaveLength(2); + // No clock or expiry participates in scheduling; persisting/reopening never infers quiescence. + const reopened = await SimulatorResourceManager.open({ stateRoot, maxActiveOperations: 2 }); + expect((await reopened.poll(waiting)).state).toBe('waiting'); + expect(b.child.exitCode).toBeNull(); + }); + + it('prevents concurrent binding of one Simulator to two worktrees', async () => { + const firstTree = await makeWorktree('binding-a'); + const secondTree = await makeWorktree('binding-b'); + const device = randomUUID(); + const outcomes = await Promise.allSettled( + [new Worker(), new Worker()].map((worker, index) => + worker.send({ + action: 'bind', + worktree: [firstTree, secondTree][index], + specification, + simulatorId: device, + }), + ), + ); + expect(outcomes.filter((entry) => entry.status === 'fulfilled')).toHaveLength(1); + expect(outcomes.filter((entry) => entry.status === 'rejected')).toHaveLength(1); + const rejected = outcomes.find((entry) => entry.status === 'rejected'); + expect(rejected?.reason).toEqual( + new Error('Simulator is bound to another worktree generation'), + ); + expect( + (await readdir(join(stateRoot, 'bindings'))).filter((entry) => entry.endsWith('.json')), + ).toHaveLength(2); + }); + + it('lets different worktrees operate concurrently while preserving FIFO on a busy device', async () => { + const other = await makeWorktree('parallel'); + const otherDevice = randomUUID(); + await manager.bind(other, specification, otherDevice); + const a = new Worker(); + const b = new Worker(); + const [first, second] = await Promise.all([ + a.send({ action: 'request', request: request() }), + b.send({ action: 'request', request: request(other) }), + ]); + expect([first.state, second.state]).toEqual(['active', 'active']); + expect(first.simulatorId).not.toBe(second.simulatorId); + const earlier = await b.send({ action: 'request', request: request() }); + const later = await b.send({ action: 'request', request: request() }); + await a.send({ action: 'end', credentials: first }); + expect((await manager.poll(earlier)).state).toBe('active'); + expect((await manager.poll(later)).state).toBe('waiting'); + }); +}); diff --git a/src/resource-management/__tests__/provisioner.test.ts b/src/resource-management/__tests__/provisioner.test.ts new file mode 100644 index 000000000..d537f3150 --- /dev/null +++ b/src/resource-management/__tests__/provisioner.test.ts @@ -0,0 +1,279 @@ +import { randomUUID } from 'node:crypto'; +import { mkdir, mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, expect, it, vi } from 'vitest'; +import { Ajv2020 } from 'ajv/dist/2020.js'; +import { createMockExecutor } from '../../test-utils/mock-executors.ts'; +import type { CommandExecutor } from '../../utils/CommandExecutor.ts'; +import { + __setTestCommandExecutorOverride, + __clearTestExecutorOverrides, +} from '../../utils/command.ts'; +import type { ToolHandlerContext } from '../../rendering/types.ts'; +import { handler } from '../../mcp/tools/resource-management/resource_provision.ts'; +import { getMcpOutputSchema } from '../../core/structured-output-schema.ts'; +import { toStructuredEnvelope } from '../../utils/structured-output-envelope.ts'; +import { renderCliTextTranscript } from '../../utils/renderers/cli-text-renderer.ts'; +import { SimulatorResourceManager } from '../manager.ts'; +import { provisionSimulator, SimulatorProvisioningFailure } from '../provisioner.ts'; +import { resolveManagedWorktree, type ManagedWorktree } from '../worktree.ts'; +import { wrapManagedTool } from '../tool-gate.ts'; + +const specification = { + deviceType: 'com.apple.CoreSimulator.SimDeviceType.iPhone-16', + runtime: 'com.apple.CoreSimulator.SimRuntime.iOS-18-0', +}; +type Device = { udid: string; isAvailable: boolean; deviceTypeIdentifier?: string }; +let directory: string; +let worktree: ManagedWorktree; +let manager: SimulatorResourceManager; +let executor: CommandExecutor; +let commands: string[][]; +let deviceId: string; +let data: { + devicetypes: { identifier: string }[]; + runtimes: { identifier: string; isAvailable: boolean }[]; + devices: Record; +}; +let createMode: 'normal' | 'failed' | 'unknown' | 'rejected' | 'invalid'; +let malformedCatalog: boolean; +const ctx = (): ToolHandlerContext => ({ emit: () => {}, attach: () => {} }); + +beforeEach(async () => { + directory = await mkdtemp(join(tmpdir(), 'resource-provisioner-')); + const root = join(directory, 'checkout'); + await mkdir(join(root, '.git'), { recursive: true }); + const stateRoot = join(directory, 'state'); + vi.stubEnv('XCODEBUILDMCP_OPERATION_STATE_ROOT', stateRoot); + vi.stubEnv('XCODEBUILDMCP_OPERATION_CAPACITY', '1'); + commands = []; + deviceId = randomUUID(); + createMode = 'normal'; + malformedCatalog = false; + data = { + devicetypes: [{ identifier: specification.deviceType }], + runtimes: [{ identifier: specification.runtime, isAvailable: true }], + devices: { [specification.runtime]: [] }, + }; + executor = async (...args) => { + const command = args[0]; + commands.push([...command]); + if ( + command[0] === 'git' && + command[1] === '-C' && + command[3] === 'rev-parse' && + ['--show-toplevel', '--absolute-git-dir'].includes(command[4]) + ) { + return createMockExecutor({ + output: `${command[4] === '--show-toplevel' ? root : join(root, '.git')}\n`, + })(...args); + } + if (command.join(' ') === 'xcrun simctl list --json') { + return createMockExecutor({ output: malformedCatalog ? '{broken' : JSON.stringify(data) })( + ...args, + ); + } + if (command.slice(0, 3).join(' ') === 'xcrun simctl create') { + expect(command).toHaveLength(6); + expect(command[3]).toMatch(/^XcodeBuildMCP-[a-f0-9-]+$/); + expect(command.slice(4)).toEqual([specification.deviceType, specification.runtime]); + expect(args[2]).toBe(false); + expect(args[4]).toBeUndefined(); + if (createMode === 'rejected') throw new Error('Executor disconnected'); + if (createMode === 'failed') return createMockExecutor({ success: false })(...args); + data.devices[specification.runtime].push({ + udid: deviceId.toUpperCase(), + isAvailable: true, + deviceTypeIdentifier: specification.deviceType, + }); + return createMockExecutor({ + output: createMode === 'invalid' ? 'not-a-uuid' : `${deviceId.toUpperCase()}\n`, + ...(createMode === 'unknown' + ? { process: { exitCode: null, signalCode: null, stdout: null, stderr: null } } + : {}), + })(...args); + } + throw new Error(`Unexpected fixture command: ${command.join(' ')}`); + }; + __setTestCommandExecutorOverride(executor); + manager = await SimulatorResourceManager.open({ stateRoot, maxActiveOperations: 1 }); + worktree = await resolveManagedWorktree(root, executor); + commands.length = 0; +}); + +afterEach(async () => { + __clearTestExecutorOverrides(); + vi.unstubAllEnvs(); + await rm(directory, { recursive: true, force: true }); +}); + +it('creates once using explicit identifiers and reuses the binding without more mutations', async () => { + const binding = await provisionSimulator(manager, worktree, specification, executor); + expect(binding.simulatorId).toBe(deviceId); + expect(await provisionSimulator(manager, worktree, specification, executor)).toEqual(binding); + expect(commands.filter((command) => command[2] === 'create')).toHaveLength(1); + expect(commands.every((command) => ['list', 'create'].includes(command[2]))).toBe(true); +}); + +it.each(['runtime', 'deviceType'])( + 'rejects unavailable %s before reserving or creating', + async (missing) => { + if (missing === 'runtime') data.runtimes[0].isAvailable = false; + else data.devicetypes = []; + await expect( + provisionSimulator(manager, worktree, specification, executor), + ).rejects.toBeInstanceOf(SimulatorProvisioningFailure); + expect(await manager.getProvisioning(worktree.generation)).toBeNull(); + expect(commands).toEqual([['xcrun', 'simctl', 'list', '--json']]); + }, +); + +it.each(['failed', 'unknown', 'rejected', 'invalid'] as const)( + 'fences %s creation without publishing a binding or retrying', + async (mode) => { + createMode = mode; + const expected = { + failed: 'Simulator creation failed', + unknown: 'completion is uncertain', + rejected: 'Executor disconnected', + invalid: 'Invalid UUID', + }[mode]; + await expect(provisionSimulator(manager, worktree, specification, executor)).rejects.toThrow( + expected, + ); + expect(await manager.getBinding(worktree.generation)).toBeNull(); + expect(await manager.getProvisioning(worktree.generation)).toMatchObject({ state: 'blocked' }); + await expect(provisionSimulator(manager, worktree, specification, executor)).rejects.toThrow( + 'requires reconciliation', + ); + expect(commands.filter((command) => command[2] === 'create')).toHaveLength(1); + }, +); + +it('rejects a create response that points to a pre-existing device', async () => { + data.devices[specification.runtime].push({ + udid: deviceId, + isAvailable: true, + deviceTypeIdentifier: specification.deviceType, + }); + await expect(provisionSimulator(manager, worktree, specification, executor)).rejects.toThrow( + 'pre-existing device', + ); + expect(await manager.getBinding(worktree.generation)).toBeNull(); +}); + +it('does not publish a binding while verifying the newly created device', async () => { + let checking!: () => void; + let release!: () => void; + const entered = new Promise((resolve) => { + checking = resolve; + }); + const resume = new Promise((resolve) => { + release = resolve; + }); + let reads = 0; + const gated: CommandExecutor = async (...args) => { + if (args[0][2] === 'list' && ++reads === 2) { + checking(); + await resume; + data.devices[specification.runtime] = []; + } + return executor(...args); + }; + const provisioning = provisionSimulator(manager, worktree, specification, gated); + const rejected = expect(provisioning).rejects.toThrow('missing or unavailable'); + await entered; + const bindingDuringCheck = await manager.getBinding(worktree.generation); + const [leaseDuringCheck] = await Promise.allSettled([ + manager.requestLease({ + requestId: randomUUID(), + owner: { sessionId: randomUUID() }, + worktree, + }), + ]); + release(); + await rejected; + expect(bindingDuringCheck).toBeNull(); + expect(leaseDuringCheck).toMatchObject({ + status: 'rejected', + reason: new Error('Worktree has no Simulator binding'), + }); + expect(await manager.getProvisioning(worktree.generation)).toMatchObject({ + state: 'blocked', + simulatorId: deviceId, + }); +}); + +it.each(['missing', 'unavailable', 'wrong-type', 'missing-metadata'])( + 'never replaces an existing binding when its device is %s', + async (state) => { + await provisionSimulator(manager, worktree, specification, executor); + if (state === 'missing') data.devices[specification.runtime] = []; + if (state === 'unavailable') data.devices[specification.runtime][0].isAvailable = false; + if (state === 'wrong-type') + data.devices[specification.runtime][0].deviceTypeIdentifier = 'different'; + if (state === 'missing-metadata') + delete data.devices[specification.runtime][0].deviceTypeIdentifier; + const expected = + state === 'missing-metadata' + ? 'metadata is missing' + : state === 'wrong-type' + ? 'does not match' + : 'missing or unavailable'; + await expect(provisionSimulator(manager, worktree, specification, executor)).rejects.toThrow( + expected, + ); + expect(await manager.getBinding(worktree.generation)).toMatchObject({ simulatorId: deviceId }); + expect(commands.filter((command) => command[2] === 'create')).toHaveLength(1); + }, +); + +it('produces schema-valid final output through the managed public handler without fragments', async () => { + const context = ctx(); + await wrapManagedTool( + 'mcp/tools/resource-management/resource_provision', + (args, activeContext) => { + if (!activeContext) throw new Error('Missing fixture context'); + return handler(args, activeContext); + }, + )(specification, context); + const output = context.structuredOutput!; + const validate = new Ajv2020({ strict: true }).compile( + getMcpOutputSchema({ schema: output.schema, version: output.schemaVersion }), + ); + expect( + validate(toStructuredEnvelope(output.result, output.schema, output.schemaVersion)), + JSON.stringify(validate.errors), + ).toBe(true); + expect(output.result).toMatchObject({ didError: false, simulatorId: deviceId }); + expect(renderCliTextTranscript({ structuredOutput: output })).toContain(`Simulator: ${deviceId}`); + data.runtimes[0].isAvailable = false; + await handler(specification, context); + const failure = context.structuredOutput!; + expect(failure.result).toMatchObject({ + didError: true, + error: 'Requested Simulator runtime is unavailable', + }); + expect( + validate(toStructuredEnvelope(failure.result, failure.schema, failure.schemaVersion)), + ).toBe(true); + expect(renderCliTextTranscript({ structuredOutput: failure })).toContain( + 'Simulator provisioning failed', + ); +}); + +it('lets catalog infrastructure errors surface as runtime errors', async () => { + malformedCatalog = true; + const context = ctx(); + await expect(handler(specification, context)).rejects.toThrow(SyntaxError); + expect(context.structuredOutput).toBeUndefined(); + expect(await manager.getProvisioning(worktree.generation)).toBeNull(); +}); + +it('rejects the public tool when management is disabled before issuing commands', async () => { + vi.stubEnv('XCODEBUILDMCP_OPERATION_STATE_ROOT', undefined); + vi.stubEnv('XCODEBUILDMCP_OPERATION_CAPACITY', undefined); + await expect(handler(specification, ctx())).rejects.toThrow('not enabled'); + expect(commands).toEqual([]); +}); diff --git a/src/resource-management/__tests__/screenshot-id.test.ts b/src/resource-management/__tests__/screenshot-id.test.ts new file mode 100644 index 000000000..acd139502 --- /dev/null +++ b/src/resource-management/__tests__/screenshot-id.test.ts @@ -0,0 +1,42 @@ +import { expect, it } from 'vitest'; +import { createScreenshotExecutor } from '../../mcp/tools/ui-automation/screenshot.ts'; +import { + createMockExecutor, + createMockFileSystemExecutor, +} from '../../test-utils/mock-executors.ts'; + +it('captures using a lowercase lease UDID when simctl lists the uppercase UUID', async () => { + const simulatorId = 'aaaaaaaa-aaaa-4aaa-aaaa-aaaaaaaaaaaa'; + const calls: string[][] = []; + const unexpectedCommands: string[][] = []; + const executor = createMockExecutor({}); + const capture = createScreenshotExecutor({ + executor: async (...args) => { + calls.push(args[0]); + const result = await executor(...args); + if (args[0].join(' ') === 'xcrun simctl list devices -j') + return { + ...result, + output: JSON.stringify({ + devices: { + runtime: [{ udid: simulatorId.toUpperCase(), name: 'Fixture', state: 'Booted' }], + }, + }), + }; + if (args[0][0] === 'swift') return { ...result, output: '400,800' }; + if (args[0][0] === 'sips' && args[0].includes('pixelWidth')) + return { ...result, output: 'pixelWidth: 400\npixelHeight: 800' }; + if ( + args[0].slice(0, 5).join(' ') === `xcrun simctl io ${simulatorId} screenshot` || + args[0].slice(0, 3).join(' ') === 'sips -Z 800' + ) + return result; + unexpectedCommands.push(args[0]); + throw new Error('Unexpected screenshot command'); + }, + fileSystemExecutor: createMockFileSystemExecutor(), + }); + expect(await capture({ simulatorId, returnFormat: 'path' })).toMatchObject({ didError: false }); + expect(calls.some((command) => command[2] === 'io' && command[3] === simulatorId)).toBe(true); + expect(unexpectedCommands).toEqual([]); +}); diff --git a/src/resource-management/__tests__/store-transaction-lock.test.ts b/src/resource-management/__tests__/store-transaction-lock.test.ts new file mode 100644 index 000000000..58796e327 --- /dev/null +++ b/src/resource-management/__tests__/store-transaction-lock.test.ts @@ -0,0 +1,101 @@ +import { mkdtemp, readFile, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { FS_LOCK_OWNER_FILE, tryAcquireFsLock, type AcquiredFsLock } from '../../utils/fs-lock.ts'; +import { ResourceStore } from '../store.ts'; + +let directory: string; +let store: ResourceStore; + +beforeEach(async () => { + directory = await mkdtemp(join(tmpdir(), 'resource-store-lock-')); + store = await ResourceStore.open({ + stateRoot: join(directory, 'state'), + maxActiveOperations: 1, + }); +}); + +afterEach(async () => { + await rm(directory, { recursive: true, force: true }); +}); + +async function holdRegistryLock(): Promise { + const lock = await tryAcquireFsLock({ + lockDir: join(store.root, 'registry.lock'), + purpose: 'resource-registry', + leaseMs: 30_000, + }); + if (!lock) throw new Error('Unable to hold the registry lock for the fixture'); + return lock; +} + +describe('ResourceStore transaction lock waiting', () => { + it('keeps the default transaction deadline without invoking the callback', async () => { + const held = await holdRegistryLock(); + const realNow = Date.now(); + const now = vi.spyOn(Date, 'now'); + let calls = 0; + now.mockImplementation(() => realNow + (calls++ === 0 ? 0 : 10_001)); + const callback = vi.fn(async () => 'finished'); + + try { + await expect(store.transaction(callback)).rejects.toThrow('timed out'); + expect(callback).not.toHaveBeenCalled(); + } finally { + now.mockRestore(); + await held.release(); + } + }); + + it('waits for a live lock without stealing it, then completes the pending activity', async () => { + const held = await holdRegistryLock(); + const activityPath = 'operations/activity.json'; + await store.write(activityPath, { state: 'active' }); + const callback = vi.fn(async () => { + await store.write(activityPath, { state: 'finished' }); + return 'finished'; + }); + + const realNow = Date.now(); + const now = vi.spyOn(Date, 'now'); + let calls = 0; + now.mockImplementation(() => realNow + (calls++ === 0 ? 0 : 30_001)); + const pending = store.transaction(callback, { waitForLock: true }); + + try { + await new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + clearInterval(interval); + reject(new Error(`Expected repeated lock attempts, saw ${calls} Date.now calls`)); + }, 1_000); + const interval = setInterval(() => { + if (calls >= 3) { + clearTimeout(timeout); + clearInterval(interval); + resolve(); + } + }, 1); + }); + expect(callback).not.toHaveBeenCalled(); + const owner = JSON.parse( + await readFile(join(store.root, 'registry.lock', FS_LOCK_OWNER_FILE), 'utf8'), + ) as { token: string }; + expect(owner.token).toBe(held.owner.token); + expect(JSON.parse(await readFile(join(store.root, activityPath), 'utf8'))).toEqual({ + state: 'active', + }); + + await held.release(); + await expect(pending).resolves.toBe('finished'); + expect(callback).toHaveBeenCalledOnce(); + expect(JSON.parse(await readFile(join(store.root, activityPath), 'utf8'))).toEqual({ + state: 'finished', + }); + } finally { + now.mockRestore(); + await held.release(); + await pending.catch(() => {}); + } + }); +}); diff --git a/src/resource-management/__tests__/test-execution-barrier.test.ts b/src/resource-management/__tests__/test-execution-barrier.test.ts new file mode 100644 index 000000000..1c54df4f7 --- /dev/null +++ b/src/resource-management/__tests__/test-execution-barrier.test.ts @@ -0,0 +1,524 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { mkdtemp, rm, mkdir, access } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { createTestExecutor, type SharedTestExecutorParams } from '../../utils/test-common.ts'; +import { XcodePlatform } from '../../utils/xcode.ts'; +import { CommandSupervision, supervisedExecutor } from '../execution.ts'; +import { createMockExecutor, type CommandExecutor } from '../../test-utils/mock-executors.ts'; +import type { CommandResponse } from '../../utils/CommandExecutor.ts'; +import * as logCaptureModule from '../../utils/xcodebuild-log-capture.ts'; +import * as logPathsModule from '../../utils/log-paths.ts'; +import * as testProductsModule from '../../utils/test-products-path.ts'; +import * as resultBundleModule from '../../utils/result-bundle-path.ts'; +import * as xcresultModule from '../../utils/xcresult-test-failures.ts'; +import type { ToolHandlerContext } from '../../rendering/types.ts'; +import { createStreamingExecutionContext } from '../../utils/xcodebuild-domain-results.ts'; + +interface Deferred { + promise: Promise; + resolve: (value: T | PromiseLike) => void; + reject: (reason?: unknown) => void; +} + +function createDeferred(): Deferred { + let resolve!: (value: T | PromiseLike) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +function trackPromiseSettled(promise: Promise) { + let settled = false; + let settledResult: { resolved?: T; rejected?: unknown } | undefined; + promise.then( + (value) => { + settled = true; + settledResult = { resolved: value }; + }, + (reason) => { + settled = true; + settledResult = { rejected: reason }; + }, + ); + return { + isSettled: () => settled, + getSettledResult: () => settledResult, + }; +} + +async function flushMicrotasks(): Promise { + for (let i = 0; i < 5; i++) { + await Promise.resolve(); + } +} + +describe('Stage T4b: Deferred async barrier verification for simulator shutdown', () => { + let tempDir: string; + let logsDir: string; + + beforeEach(async () => { + tempDir = await mkdtemp(join(tmpdir(), 'test-execution-barrier-')); + logsDir = join(tempDir, 'logs'); + logPathsModule.setXcodeBuildMCPAppDirOverrideForTests(tempDir); + logCaptureModule.setXcodebuildLogDirOverrideForTests(logsDir); + + vi.spyOn(xcresultModule, 'extractTestFailuresFromXcresult').mockReturnValue([]); + vi.spyOn(xcresultModule, 'extractTestSummaryCountsFromXcresult').mockReturnValue({ + passed: 1, + failed: 0, + skipped: 0, + }); + }); + + afterEach(async () => { + logPathsModule.setXcodeBuildMCPAppDirOverrideForTests(null); + logCaptureModule.setXcodebuildLogDirOverrideForTests(null); + vi.restoreAllMocks(); + await rm(tempDir, { recursive: true, force: true }); + }); + + const createContext = () => { + const rawCtx: ToolHandlerContext = { + emit: () => {}, + attach: () => {}, + }; + const streamingCtx = createStreamingExecutionContext(rawCtx); + return { rawCtx, streamingCtx }; + }; + + const defaultParams: SharedTestExecutorParams = { + projectPath: '/tmp/test/App.xcodeproj', + scheme: 'AppScheme', + simulatorId: '65FAA1DC-6DC9-456D-A88E-3CF5A516F95B', + platform: XcodePlatform.iOSSimulator, + }; + + function setupLogCaptureSpy() { + let captureClosed = false; + let closeCount = 0; + let capturedLogPath: string | null = null; + const origCreate = logCaptureModule.createLogCapture; + vi.spyOn(logCaptureModule, 'createLogCapture').mockImplementation((toolName) => { + const cap = origCreate(toolName); + capturedLogPath = cap.path; + const origClose = cap.close.bind(cap); + cap.close = () => { + closeCount++; + captureClosed = true; + origClose(); + }; + return cap; + }); + return { + isClosed: () => captureClosed, + get closeCount() { + return closeCount; + }, + getLogPath: () => capturedLogPath, + }; + } + + function setupSyncExtractSentinels() { + const summarySpy = vi + .spyOn(xcresultModule, 'extractTestSummaryCountsFromXcresult') + .mockImplementation(() => { + throw new Error( + 'SENTINEL: extractTestSummaryCountsFromXcresult sync function must never be called in managed mode', + ); + }); + const failuresSpy = vi + .spyOn(xcresultModule, 'extractTestFailuresFromXcresult') + .mockImplementation(() => { + throw new Error( + 'SENTINEL: extractTestFailuresFromXcresult sync function must never be called in managed mode', + ); + }); + return { summarySpy, failuresSpy }; + } + + async function mockCommandResponse( + options: { + success?: boolean; + exitCode?: number; + output?: string; + error?: string; + } = {}, + ): Promise { + return createMockExecutor(options)(['mock']); + } + + function makeCatalogJson( + devices: + | Array<{ udid: string; isAvailable: boolean; state: string }> + | Record>, + ): string { + if (Array.isArray(devices)) { + return JSON.stringify({ + devices: { + 'com.apple.CoreSimulator.SimRuntime.iOS-18-0': devices, + }, + }); + } + return JSON.stringify({ devices }); + } + + it('pauses at shutdown command and post-shutdown list: asserts un-settled, 0 markers, closeCount 0 until released', async () => { + const logSpy = setupLogCaptureSpy(); + setupSyncExtractSentinels(); + const markProductsSpy = vi.spyOn(testProductsModule, 'markTestProductsPathCompleted'); + const markResultSpy = vi.spyOn(resultBundleModule, 'markResultBundlePathCompleted'); + + const enteredShutdown = createDeferred(); + const releaseShutdown = createDeferred(); + const enteredPostList = createDeferred(); + const releasePostList = createDeferred(); + + const capturedCommands: string[][] = []; + let capturedProductsPath: string | undefined; + let capturedResultBundlePath: string | undefined; + let simctlListCount = 0; + + const mockExecutor: CommandExecutor = vi.fn(async (command, _prefix, _shell, opts) => { + capturedCommands.push([...command]); + if (command[0] === 'xcrun') { + if (command[1] === 'simctl') { + if (command[2] === 'list') { + simctlListCount++; + if (simctlListCount === 1) { + // Initial catalog: Simulator is Booted + return mockCommandResponse({ + success: true, + exitCode: 0, + output: makeCatalogJson([ + { udid: defaultParams.simulatorId!, isAvailable: true, state: 'Booted' }, + ]), + }); + } + if (simctlListCount === 2) { + // Post-shutdown catalog verification barrier: + enteredPostList.resolve(); + await releasePostList.promise; + return mockCommandResponse({ + success: true, + exitCode: 0, + output: makeCatalogJson([ + { udid: defaultParams.simulatorId!, isAvailable: true, state: 'Shutdown' }, + ]), + }); + } + throw new Error(`Unexpected simctl list invocation count: ${simctlListCount}`); + } + if (command[2] === 'shutdown') { + // Shutdown command barrier: + enteredShutdown.resolve(); + await releaseShutdown.promise; + return mockCommandResponse({ + success: true, + exitCode: 0, + output: '', + }); + } + } + if (command.includes('tests')) { + return mockCommandResponse({ + success: true, + exitCode: 0, + output: JSON.stringify({ testNodes: [] }), + }); + } + if (command.includes('summary')) { + return mockCommandResponse({ + success: true, + exitCode: 0, + output: JSON.stringify({ + totalTestCount: 1, + passedTests: 1, + failedTests: 0, + skippedTests: 0, + }), + }); + } + throw new Error(`Unexpected xcrun: ${command.join(' ')}`); + } + if (command[0] !== 'xcodebuild') { + throw new Error(`Unexpected command: ${command.join(' ')}`); + } + const prodIdx = command.indexOf('-testProductsPath'); + const prodPath = command[prodIdx + 1]; + if (prodIdx !== -1 && prodPath) { + capturedProductsPath = prodPath; + await mkdir(prodPath, { recursive: true }); + } + const bundleIdx = command.indexOf('-resultBundlePath'); + const bundlePath = command[bundleIdx + 1]; + if (bundleIdx !== -1 && bundlePath) { + capturedResultBundlePath = bundlePath; + await mkdir(bundlePath, { recursive: true }); + } + + if (command.includes('build-for-testing')) { + opts?.onStdout?.('Build ok\n'); + return mockCommandResponse({ success: true, exitCode: 0, output: 'Build ok' }); + } + if (command.includes('test-without-building')) { + opts?.onStdout?.('Test ok\n'); + return mockCommandResponse({ success: true, exitCode: 0, output: 'Test ok' }); + } + throw new Error(`Unexpected command phase: ${command.join(' ')}`); + }); + + const supervision = new CommandSupervision(); + const supervised = supervisedExecutor(mockExecutor); + const execute = createTestExecutor(supervised, { + toolName: 'test_sim', + target: 'simulator', + request: { scheme: 'AppScheme' }, + }); + + const { streamingCtx } = createContext(); + const executePromise = supervision.run(() => execute(defaultParams, streamingCtx)); + const executeTracker = trackPromiseSettled(executePromise); + + try { + // 1. Wait until shutdown command mock is entered + await enteredShutdown.promise; + await flushMicrotasks(); + + // Assertions while paused inside shutdown command: + expect(executeTracker.isSettled()).toBe(false); + expect(markProductsSpy).not.toHaveBeenCalled(); + expect(markResultSpy).not.toHaveBeenCalled(); + expect(logSpy.closeCount).toBe(0); + + // Release shutdown command + releaseShutdown.resolve(); + + // 2. Wait until post-shutdown list verification mock is entered + await enteredPostList.promise; + await flushMicrotasks(); + + // Assertions while paused inside post-shutdown list command: + expect(executeTracker.isSettled()).toBe(false); + expect(markProductsSpy).not.toHaveBeenCalled(); + expect(markResultSpy).not.toHaveBeenCalled(); + expect(logSpy.closeCount).toBe(0); + + // Release post-shutdown list command (returns state 'Shutdown') + releasePostList.resolve(); + + // 3. Await final execution promise to settle + const result = await executePromise; + await flushMicrotasks(); + + // Assertions after full completion: + expect(executeTracker.isSettled()).toBe(true); + expect(result.didError).toBe(false); + expect(result.summary.status).toBe('SUCCEEDED'); + expect(logSpy.closeCount).toBe(1); + expect(markProductsSpy).toHaveBeenCalledTimes(1); + expect(markResultSpy).toHaveBeenCalledTimes(1); + } finally { + // Guarantee barriers are released so tests do not hang if assertions fail + releaseShutdown.resolve(); + releasePostList.resolve(); + } + + // Captured command assertions outside await: + expect(capturedCommands).toHaveLength(7); + expect(capturedCommands[0]).toContain('build-for-testing'); + expect(capturedCommands[1]).toContain('test-without-building'); + expect(capturedCommands[2]).toContain('tests'); + expect(capturedCommands[3]).toContain('summary'); + expect(capturedCommands[4]).toEqual(['xcrun', 'simctl', 'list', 'devices', '--json']); + expect(capturedCommands[5]).toEqual([ + 'xcrun', + 'simctl', + 'shutdown', + defaultParams.simulatorId!, + ]); + expect(capturedCommands[6]).toEqual(['xcrun', 'simctl', 'list', 'devices', '--json']); + + // Completion marker file existence on disk + await expect( + access(testProductsModule.getTestProductsCompletionMarkerPath(capturedProductsPath!)), + ).resolves.toBeUndefined(); + await expect( + access(resultBundleModule.getResultBundleCompletionMarkerPath(capturedResultBundlePath!)), + ).resolves.toBeUndefined(); + }); + + it('test failure (exit 65): pauses at shutdown command and post-shutdown list: asserts un-settled, 0 markers, closeCount 0 until released', async () => { + const logSpy = setupLogCaptureSpy(); + setupSyncExtractSentinels(); + const markProductsSpy = vi.spyOn(testProductsModule, 'markTestProductsPathCompleted'); + const markResultSpy = vi.spyOn(resultBundleModule, 'markResultBundlePathCompleted'); + + const enteredShutdown = createDeferred(); + const releaseShutdown = createDeferred(); + const enteredPostList = createDeferred(); + const releasePostList = createDeferred(); + + const capturedCommands: string[][] = []; + let capturedProductsPath: string | undefined; + let capturedResultBundlePath: string | undefined; + let simctlListCount = 0; + + const mockExecutor: CommandExecutor = vi.fn(async (command, _prefix, _shell, opts) => { + capturedCommands.push([...command]); + if (command[0] === 'xcrun') { + if (command[1] === 'simctl') { + if (command[2] === 'list') { + simctlListCount++; + if (simctlListCount === 1) { + return mockCommandResponse({ + success: true, + exitCode: 0, + output: makeCatalogJson([ + { udid: defaultParams.simulatorId!, isAvailable: true, state: 'Booted' }, + ]), + }); + } + if (simctlListCount === 2) { + enteredPostList.resolve(); + await releasePostList.promise; + return mockCommandResponse({ + success: true, + exitCode: 0, + output: makeCatalogJson([ + { udid: defaultParams.simulatorId!, isAvailable: true, state: 'Shutdown' }, + ]), + }); + } + throw new Error(`Unexpected simctl list invocation count: ${simctlListCount}`); + } + if (command[2] === 'shutdown') { + enteredShutdown.resolve(); + await releaseShutdown.promise; + return mockCommandResponse({ + success: true, + exitCode: 0, + output: '', + }); + } + } + if (command.includes('tests')) { + return mockCommandResponse({ + success: true, + exitCode: 0, + output: JSON.stringify({ testNodes: [] }), + }); + } + if (command.includes('summary')) { + return mockCommandResponse({ + success: true, + exitCode: 0, + output: JSON.stringify({ + totalTestCount: 5, + passedTests: 4, + failedTests: 1, + skippedTests: 0, + }), + }); + } + throw new Error(`Unexpected xcrun: ${command.join(' ')}`); + } + if (command[0] !== 'xcodebuild') { + throw new Error(`Unexpected command: ${command.join(' ')}`); + } + const prodIdx = command.indexOf('-testProductsPath'); + const prodPath = command[prodIdx + 1]; + if (prodIdx !== -1 && prodPath) { + capturedProductsPath = prodPath; + await mkdir(prodPath, { recursive: true }); + } + const bundleIdx = command.indexOf('-resultBundlePath'); + const bundlePath = command[bundleIdx + 1]; + if (bundleIdx !== -1 && bundlePath) { + capturedResultBundlePath = bundlePath; + await mkdir(bundlePath, { recursive: true }); + } + + if (command.includes('build-for-testing')) { + opts?.onStdout?.('Build ok\n'); + return mockCommandResponse({ success: true, exitCode: 0, output: 'Build ok' }); + } + if (command.includes('test-without-building')) { + opts?.onStdout?.('Test failed\n'); + return mockCommandResponse({ success: false, exitCode: 65, output: 'Test failed' }); + } + throw new Error(`Unexpected command phase: ${command.join(' ')}`); + }); + + const supervision = new CommandSupervision(); + const supervised = supervisedExecutor(mockExecutor); + const execute = createTestExecutor(supervised, { + toolName: 'test_sim', + target: 'simulator', + request: { scheme: 'AppScheme' }, + }); + + const { streamingCtx } = createContext(); + const executePromise = supervision.run(() => execute(defaultParams, streamingCtx)); + const executeTracker = trackPromiseSettled(executePromise); + + try { + await enteredShutdown.promise; + await flushMicrotasks(); + + expect(executeTracker.isSettled()).toBe(false); + expect(markProductsSpy).not.toHaveBeenCalled(); + expect(markResultSpy).not.toHaveBeenCalled(); + expect(logSpy.closeCount).toBe(0); + + releaseShutdown.resolve(); + + await enteredPostList.promise; + await flushMicrotasks(); + + expect(executeTracker.isSettled()).toBe(false); + expect(markProductsSpy).not.toHaveBeenCalled(); + expect(markResultSpy).not.toHaveBeenCalled(); + expect(logSpy.closeCount).toBe(0); + + releasePostList.resolve(); + + const result = await executePromise; + await flushMicrotasks(); + + expect(executeTracker.isSettled()).toBe(true); + expect(result.didError).toBe(true); + expect(result.summary.status).toBe('FAILED'); + expect(logSpy.closeCount).toBe(1); + expect(markProductsSpy).toHaveBeenCalledTimes(1); + expect(markResultSpy).toHaveBeenCalledTimes(1); + } finally { + releaseShutdown.resolve(); + releasePostList.resolve(); + } + + expect(capturedCommands).toHaveLength(7); + expect(capturedCommands[0]).toContain('build-for-testing'); + expect(capturedCommands[1]).toContain('test-without-building'); + expect(capturedCommands[2]).toContain('tests'); + expect(capturedCommands[3]).toContain('summary'); + expect(capturedCommands[4]).toEqual(['xcrun', 'simctl', 'list', 'devices', '--json']); + expect(capturedCommands[5]).toEqual([ + 'xcrun', + 'simctl', + 'shutdown', + defaultParams.simulatorId!, + ]); + expect(capturedCommands[6]).toEqual(['xcrun', 'simctl', 'list', 'devices', '--json']); + + await expect( + access(testProductsModule.getTestProductsCompletionMarkerPath(capturedProductsPath!)), + ).resolves.toBeUndefined(); + await expect( + access(resultBundleModule.getResultBundleCompletionMarkerPath(capturedResultBundlePath!)), + ).resolves.toBeUndefined(); + }); +}); diff --git a/src/resource-management/__tests__/test-execution.test.ts b/src/resource-management/__tests__/test-execution.test.ts new file mode 100644 index 000000000..e4d3d8bb1 --- /dev/null +++ b/src/resource-management/__tests__/test-execution.test.ts @@ -0,0 +1,3799 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { mkdtemp, rm, mkdir, stat, access } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { PassThrough } from 'node:stream'; +import { createTestExecutor, type SharedTestExecutorParams } from '../../utils/test-common.ts'; +import { XcodePlatform } from '../../utils/xcode.ts'; +import { CommandSupervision, supervisedExecutor } from '../execution.ts'; +import { SimulatorTestUncertainError } from '../test-execution.ts'; +import { createMockExecutor, type CommandExecutor } from '../../test-utils/mock-executors.ts'; +import type { CommandResponse } from '../../utils/CommandExecutor.ts'; +import * as logCaptureModule from '../../utils/xcodebuild-log-capture.ts'; +import * as logPathsModule from '../../utils/log-paths.ts'; +import * as testProductsModule from '../../utils/test-products-path.ts'; +import * as resultBundleModule from '../../utils/result-bundle-path.ts'; +import * as xcresultModule from '../../utils/xcresult-test-failures.ts'; +import type { ToolHandlerContext } from '../../rendering/types.ts'; +import { createStreamingExecutionContext } from '../../utils/xcodebuild-domain-results.ts'; + +describe('createTestExecutor lifecycle and supervision contract', () => { + let tempDir: string; + let logsDir: string; + + beforeEach(async () => { + tempDir = await mkdtemp(join(tmpdir(), 'test-execution-lifecycle-')); + logsDir = join(tempDir, 'logs'); + logPathsModule.setXcodeBuildMCPAppDirOverrideForTests(tempDir); + logCaptureModule.setXcodebuildLogDirOverrideForTests(logsDir); + + // Mock xcresult extraction to avoid real xcresulttool execFileSync + vi.spyOn(xcresultModule, 'extractTestFailuresFromXcresult').mockReturnValue([]); + vi.spyOn(xcresultModule, 'extractTestSummaryCountsFromXcresult').mockReturnValue({ + passed: 1, + failed: 0, + skipped: 0, + }); + }); + + afterEach(async () => { + logPathsModule.setXcodeBuildMCPAppDirOverrideForTests(null); + logCaptureModule.setXcodebuildLogDirOverrideForTests(null); + vi.restoreAllMocks(); + await rm(tempDir, { recursive: true, force: true }); + }); + + const createContext = () => { + const rawCtx: ToolHandlerContext = { + emit: () => {}, + attach: () => {}, + }; + const streamingCtx = createStreamingExecutionContext(rawCtx); + return { rawCtx, streamingCtx }; + }; + + const defaultParams: SharedTestExecutorParams = { + projectPath: '/tmp/test/App.xcodeproj', + scheme: 'AppScheme', + simulatorId: '65FAA1DC-6DC9-456D-A88E-3CF5A516F95B', + platform: XcodePlatform.iOSSimulator, + }; + + function setupLogCaptureSpy() { + let captureClosed = false; + let closeCount = 0; + let capturedLogPath: string | null = null; + const origCreate = logCaptureModule.createLogCapture; + vi.spyOn(logCaptureModule, 'createLogCapture').mockImplementation((toolName) => { + const cap = origCreate(toolName); + capturedLogPath = cap.path; + const origClose = cap.close.bind(cap); + cap.close = () => { + closeCount++; + captureClosed = true; + origClose(); + }; + return cap; + }); + return { + isClosed: () => captureClosed, + get closeCount() { + return closeCount; + }, + getLogPath: () => capturedLogPath, + }; + } + + function setupSyncExtractSentinels() { + const summarySpy = vi + .spyOn(xcresultModule, 'extractTestSummaryCountsFromXcresult') + .mockImplementation(() => { + throw new Error( + 'SENTINEL: extractTestSummaryCountsFromXcresult sync function must never be called in managed mode', + ); + }); + const failuresSpy = vi + .spyOn(xcresultModule, 'extractTestFailuresFromXcresult') + .mockImplementation(() => { + throw new Error( + 'SENTINEL: extractTestFailuresFromXcresult sync function must never be called in managed mode', + ); + }); + return { summarySpy, failuresSpy }; + } + + async function mockCommandResponse( + options: { + success?: boolean; + exitCode?: number; + output?: string; + error?: string; + } = {}, + ): Promise { + return createMockExecutor(options)(['mock']); + } + + function makeCatalogJson( + devices: + | Array<{ udid: string; isAvailable: boolean; state: string }> + | Record>, + ): string { + if (Array.isArray(devices)) { + return JSON.stringify({ + devices: { + 'com.apple.CoreSimulator.SimRuntime.iOS-18-0': devices, + }, + }); + } + return JSON.stringify({ devices }); + } + + function createSimctlLifecycleHandler(simulatorId: string) { + let simState = 'Booted'; + return async (command: readonly string[]): Promise => { + if (command[0] !== 'xcrun' || command[1] !== 'simctl') { + return null; + } + if ( + command[2] === 'list' && + command[3] === 'devices' && + command[4] === '--json' && + command.length === 5 + ) { + return mockCommandResponse({ + success: true, + exitCode: 0, + output: makeCatalogJson([ + { + udid: simulatorId, + isAvailable: true, + state: simState, + }, + ]), + }); + } + if (command[2] === 'shutdown' && command[3] === simulatorId && command.length === 4) { + simState = 'Shutdown'; + return mockCommandResponse({ + success: true, + exitCode: 0, + output: '', + }); + } + throw new Error(`Unexpected simctl command: ${command.join(' ')}`); + }; + } + + it('Phase 1 reject: closes pipeline log file descriptor and suppresses completion marker in supervised mode', async () => { + const logSpy = setupLogCaptureSpy(); + const markProductsSpy = vi.spyOn(testProductsModule, 'markTestProductsPathCompleted'); + let capturedProductsPath: string | undefined; + + const mockExecutor: CommandExecutor = vi.fn(async (command, _prefix, _shell, opts) => { + if (command[0] !== 'xcodebuild') { + throw new Error(`Unexpected command: ${command.join(' ')}`); + } + const prodIdx = command.indexOf('-testProductsPath'); + const prodPath = command[prodIdx + 1]; + if (prodIdx !== -1 && prodPath) { + capturedProductsPath = prodPath; + await mkdir(prodPath, { recursive: true }); + } + + if (command.includes('build-for-testing')) { + opts?.onStdout?.('Phase 1 building...\n'); + throw new Error('Phase 1 executor rejected'); + } + throw new Error(`Unexpected command phase: ${command.join(' ')}`); + }); + + const supervision = new CommandSupervision(); + const supervised = supervisedExecutor(mockExecutor); + const execute = createTestExecutor(supervised, { + toolName: 'test_sim', + target: 'simulator', + request: { scheme: 'AppScheme' }, + }); + + const { streamingCtx } = createContext(); + await expect(supervision.run(() => execute(defaultParams, streamingCtx))).rejects.toThrow( + 'Phase 1 executor rejected', + ); + + expect(logSpy.closeCount).toBe(1); + expect(markProductsSpy).not.toHaveBeenCalled(); + + // Actual filesystem assertion: directory exists, marker does not exist + expect(capturedProductsPath).toBeDefined(); + await expect(stat(capturedProductsPath!)).resolves.toBeDefined(); + await expect( + access(testProductsModule.getTestProductsCompletionMarkerPath(capturedProductsPath!)), + ).rejects.toMatchObject({ code: 'ENOENT' }); + }); + + it('Phase 1 unknown exit (null/null): closes pipeline log descriptor and does not mark products completed', async () => { + const logSpy = setupLogCaptureSpy(); + const markProductsSpy = vi.spyOn(testProductsModule, 'markTestProductsPathCompleted'); + let capturedProductsPath: string | undefined; + + const mockExecutor: CommandExecutor = vi.fn(async (command, _prefix, _shell, opts) => { + if (command[0] !== 'xcodebuild') { + throw new Error(`Unexpected command: ${command.join(' ')}`); + } + const prodIdx = command.indexOf('-testProductsPath'); + const prodPath = command[prodIdx + 1]; + if (prodIdx !== -1 && prodPath) { + capturedProductsPath = prodPath; + await mkdir(prodPath, { recursive: true }); + } + + if (command.includes('build-for-testing')) { + opts?.onStdout?.('Phase 1 building...\n'); + const response = await mockCommandResponse({ success: true, output: 'ok' }); + Object.assign(response.process, { exitCode: null, signalCode: null }); + return response; + } + throw new Error(`Unexpected command phase: ${command.join(' ')}`); + }); + + const supervision = new CommandSupervision(); + const supervised = supervisedExecutor(mockExecutor); + const execute = createTestExecutor(supervised, { + toolName: 'test_sim', + target: 'simulator', + request: { scheme: 'AppScheme' }, + }); + + const { streamingCtx } = createContext(); + await expect(supervision.run(() => execute(defaultParams, streamingCtx))).rejects.toThrow( + 'Managed command completion is uncertain', + ); + + expect(logSpy.closeCount).toBe(1); + expect(markProductsSpy).not.toHaveBeenCalled(); + + expect(capturedProductsPath).toBeDefined(); + await expect(stat(capturedProductsPath!)).resolves.toBeDefined(); + await expect( + access(testProductsModule.getTestProductsCompletionMarkerPath(capturedProductsPath!)), + ).rejects.toMatchObject({ code: 'ENOENT' }); + }); + + it('Phase 1 unknown exit (undefined/undefined): low-level wrapper rejects nonnumeric completion', async () => { + const logSpy = setupLogCaptureSpy(); + const markProductsSpy = vi.spyOn(testProductsModule, 'markTestProductsPathCompleted'); + const capturedCommands: string[][] = []; + + const mockExecutor: CommandExecutor = vi.fn(async (command, _prefix, _shell, opts) => { + capturedCommands.push([...command]); + if (command.includes('build-for-testing')) { + opts?.onStdout?.('Unknown completion\n'); + const response = await mockCommandResponse({ + success: false, + output: 'Unknown completion', + }); + Object.assign(response.process, { exitCode: undefined, signalCode: null }); + response.exitCode = undefined; + return response; + } + throw new Error(`Unexpected command: ${command.join(' ')}`); + }); + + const supervision = new CommandSupervision(); + const execute = createTestExecutor(mockExecutor, { + toolName: 'test_sim', + target: 'simulator', + request: { scheme: 'AppScheme' }, + }); + + const { streamingCtx } = createContext(); + let caughtError: unknown; + try { + await supervision.run(() => execute(defaultParams, streamingCtx)); + } catch (error) { + caughtError = error; + } + + expect(caughtError).toBeInstanceOf(SimulatorTestUncertainError); + expect((caughtError as Error).message).toContain( + 'Unexpected xcodebuild test exit code: undefined', + ); + expect(logSpy.closeCount).toBe(1); + expect(markProductsSpy).not.toHaveBeenCalled(); + expect(capturedCommands).toHaveLength(1); + }); + + it('Phase 1 open stream: closes pipeline log descriptor and does not mark products completed', async () => { + const logSpy = setupLogCaptureSpy(); + const markProductsSpy = vi.spyOn(testProductsModule, 'markTestProductsPathCompleted'); + const stream = new PassThrough(); + let capturedProductsPath: string | undefined; + + const mockExecutor: CommandExecutor = vi.fn(async (command, _prefix, _shell, opts) => { + if (command[0] !== 'xcodebuild') { + throw new Error(`Unexpected command: ${command.join(' ')}`); + } + const prodIdx = command.indexOf('-testProductsPath'); + const prodPath = command[prodIdx + 1]; + if (prodIdx !== -1 && prodPath) { + capturedProductsPath = prodPath; + await mkdir(prodPath, { recursive: true }); + } + + if (command.includes('build-for-testing')) { + opts?.onStdout?.('Phase 1 building...\n'); + const response = await mockCommandResponse({ success: true, exitCode: 0, output: 'ok' }); + Object.assign(response.process, { stdout: stream }); + return response; + } + throw new Error(`Unexpected command phase: ${command.join(' ')}`); + }); + + try { + const supervision = new CommandSupervision(); + const supervised = supervisedExecutor(mockExecutor); + const execute = createTestExecutor(supervised, { + toolName: 'test_sim', + target: 'simulator', + request: { scheme: 'AppScheme' }, + }); + + const { streamingCtx } = createContext(); + await expect(supervision.run(() => execute(defaultParams, streamingCtx))).rejects.toThrow( + 'Managed command completion is uncertain', + ); + + expect(logSpy.closeCount).toBe(1); + expect(markProductsSpy).not.toHaveBeenCalled(); + + expect(capturedProductsPath).toBeDefined(); + await expect(stat(capturedProductsPath!)).resolves.toBeDefined(); + await expect( + access(testProductsModule.getTestProductsCompletionMarkerPath(capturedProductsPath!)), + ).rejects.toMatchObject({ code: 'ENOENT' }); + } finally { + stream.destroy(); + } + }); + + it('Phase 1 build failure (exit 65): does not proceed to Phase 2, returns domain failure, marks products completed, closes log', async () => { + const logSpy = setupLogCaptureSpy(); + const markProductsSpy = vi.spyOn(testProductsModule, 'markTestProductsPathCompleted'); + let phase2Invoked = false; + let capturedProductsPath: string | undefined; + + const mockExecutor: CommandExecutor = vi.fn(async (command, _prefix, _shell, opts) => { + if (command[0] !== 'xcodebuild') { + throw new Error(`Unexpected command: ${command.join(' ')}`); + } + const prodIdx = command.indexOf('-testProductsPath'); + const prodPath = command[prodIdx + 1]; + if (prodIdx !== -1 && prodPath) { + capturedProductsPath = prodPath; + await mkdir(prodPath, { recursive: true }); + } + + if (command.includes('build-for-testing')) { + opts?.onStdout?.('Phase 1 build failed...\n'); + return mockCommandResponse({ + success: false, + exitCode: 65, + error: 'Build failed', + output: 'Compile error', + }); + } + if (command.includes('test-without-building')) { + phase2Invoked = true; + } + throw new Error(`Unexpected command phase: ${command.join(' ')}`); + }); + + const supervision = new CommandSupervision(); + const supervised = supervisedExecutor(mockExecutor); + const execute = createTestExecutor(supervised, { + toolName: 'test_sim', + target: 'simulator', + request: { scheme: 'AppScheme' }, + }); + + const { streamingCtx } = createContext(); + const result = await supervision.run(() => execute(defaultParams, streamingCtx)); + + expect(phase2Invoked).toBe(false); + expect(result.didError).toBe(true); + expect(result.summary.status).toBe('FAILED'); + expect(markProductsSpy).toHaveBeenCalled(); + expect(logSpy.closeCount).toBe(1); + + expect(capturedProductsPath).toBeDefined(); + await expect(stat(capturedProductsPath!)).resolves.toBeDefined(); + await expect( + access(testProductsModule.getTestProductsCompletionMarkerPath(capturedProductsPath!)), + ).resolves.toBeUndefined(); + }); + + it('Phase 2 reject: closes pipeline log descriptor and does not mark products or resultBundle completed in supervised mode', async () => { + const logSpy = setupLogCaptureSpy(); + const markProductsSpy = vi.spyOn(testProductsModule, 'markTestProductsPathCompleted'); + const markResultSpy = vi.spyOn(resultBundleModule, 'markResultBundlePathCompleted'); + let capturedProductsPath: string | undefined; + let capturedResultBundlePath: string | undefined; + + const mockExecutor: CommandExecutor = vi.fn(async (command, _prefix, _shell, opts) => { + if (command[0] !== 'xcodebuild') { + throw new Error(`Unexpected command: ${command.join(' ')}`); + } + const prodIdx = command.indexOf('-testProductsPath'); + const prodPath = command[prodIdx + 1]; + if (prodIdx !== -1 && prodPath) { + capturedProductsPath = prodPath; + await mkdir(prodPath, { recursive: true }); + } + const bundleIdx = command.indexOf('-resultBundlePath'); + const bundlePath = command[bundleIdx + 1]; + if (bundleIdx !== -1 && bundlePath) { + capturedResultBundlePath = bundlePath; + await mkdir(bundlePath, { recursive: true }); + } + + if (command.includes('build-for-testing')) { + opts?.onStdout?.('Phase 1 succeeded\n'); + return mockCommandResponse({ + success: true, + exitCode: 0, + output: 'Phase 1 succeeded', + }); + } + if (command.includes('test-without-building')) { + opts?.onStdout?.('Phase 2 running...\n'); + throw new Error('Phase 2 executor rejected'); + } + throw new Error(`Unexpected command phase: ${command.join(' ')}`); + }); + + const supervision = new CommandSupervision(); + const supervised = supervisedExecutor(mockExecutor); + const execute = createTestExecutor(supervised, { + toolName: 'test_sim', + target: 'simulator', + request: { scheme: 'AppScheme' }, + }); + + const { streamingCtx } = createContext(); + await expect(supervision.run(() => execute(defaultParams, streamingCtx))).rejects.toThrow( + 'Phase 2 executor rejected', + ); + + expect(logSpy.closeCount).toBe(1); + expect(markProductsSpy).not.toHaveBeenCalled(); + expect(markResultSpy).not.toHaveBeenCalled(); + + expect(capturedProductsPath).toBeDefined(); + expect(capturedResultBundlePath).toBeDefined(); + await expect(stat(capturedProductsPath!)).resolves.toBeDefined(); + await expect(stat(capturedResultBundlePath!)).resolves.toBeDefined(); + await expect( + access(testProductsModule.getTestProductsCompletionMarkerPath(capturedProductsPath!)), + ).rejects.toMatchObject({ code: 'ENOENT' }); + await expect( + access(resultBundleModule.getResultBundleCompletionMarkerPath(capturedResultBundlePath!)), + ).rejects.toMatchObject({ code: 'ENOENT' }); + }); + + it('Phase 2 unknown exit (null/null): closes pipeline log descriptor and suppresses completion markers in supervised mode', async () => { + const logSpy = setupLogCaptureSpy(); + const markProductsSpy = vi.spyOn(testProductsModule, 'markTestProductsPathCompleted'); + const markResultSpy = vi.spyOn(resultBundleModule, 'markResultBundlePathCompleted'); + let capturedProductsPath: string | undefined; + let capturedResultBundlePath: string | undefined; + + const mockExecutor: CommandExecutor = vi.fn(async (command, _prefix, _shell, opts) => { + if (command[0] !== 'xcodebuild') { + throw new Error(`Unexpected command: ${command.join(' ')}`); + } + const prodIdx = command.indexOf('-testProductsPath'); + const prodPath = command[prodIdx + 1]; + if (prodIdx !== -1 && prodPath) { + capturedProductsPath = prodPath; + await mkdir(prodPath, { recursive: true }); + } + const bundleIdx = command.indexOf('-resultBundlePath'); + const bundlePath = command[bundleIdx + 1]; + if (bundleIdx !== -1 && bundlePath) { + capturedResultBundlePath = bundlePath; + await mkdir(bundlePath, { recursive: true }); + } + + if (command.includes('build-for-testing')) { + opts?.onStdout?.('Phase 1 succeeded\n'); + return mockCommandResponse({ + success: true, + exitCode: 0, + output: 'Phase 1 succeeded', + }); + } + if (command.includes('test-without-building')) { + opts?.onStdout?.('Phase 2 running...\n'); + const response = await mockCommandResponse({ success: true, output: 'Test succeeded' }); + Object.assign(response.process, { exitCode: null, signalCode: null }); + return response; + } + throw new Error(`Unexpected command phase: ${command.join(' ')}`); + }); + + const supervision = new CommandSupervision(); + const supervised = supervisedExecutor(mockExecutor); + const execute = createTestExecutor(supervised, { + toolName: 'test_sim', + target: 'simulator', + request: { scheme: 'AppScheme' }, + }); + + const { streamingCtx } = createContext(); + await expect(supervision.run(() => execute(defaultParams, streamingCtx))).rejects.toThrow( + 'Managed command completion is uncertain', + ); + + expect(logSpy.closeCount).toBe(1); + expect(markProductsSpy).not.toHaveBeenCalled(); + expect(markResultSpy).not.toHaveBeenCalled(); + + expect(capturedProductsPath).toBeDefined(); + expect(capturedResultBundlePath).toBeDefined(); + await expect( + access(testProductsModule.getTestProductsCompletionMarkerPath(capturedProductsPath!)), + ).rejects.toMatchObject({ code: 'ENOENT' }); + await expect( + access(resultBundleModule.getResultBundleCompletionMarkerPath(capturedResultBundlePath!)), + ).rejects.toMatchObject({ code: 'ENOENT' }); + }); + + it('Phase 2 open stream: closes pipeline log descriptor and suppresses completion markers in supervised mode', async () => { + const logSpy = setupLogCaptureSpy(); + const markProductsSpy = vi.spyOn(testProductsModule, 'markTestProductsPathCompleted'); + const markResultSpy = vi.spyOn(resultBundleModule, 'markResultBundlePathCompleted'); + const stream = new PassThrough(); + let capturedProductsPath: string | undefined; + let capturedResultBundlePath: string | undefined; + + const mockExecutor: CommandExecutor = vi.fn(async (command, _prefix, _shell, opts) => { + if (command[0] !== 'xcodebuild') { + throw new Error(`Unexpected command: ${command.join(' ')}`); + } + const prodIdx = command.indexOf('-testProductsPath'); + const prodPath = command[prodIdx + 1]; + if (prodIdx !== -1 && prodPath) { + capturedProductsPath = prodPath; + await mkdir(prodPath, { recursive: true }); + } + const bundleIdx = command.indexOf('-resultBundlePath'); + const bundlePath = command[bundleIdx + 1]; + if (bundleIdx !== -1 && bundlePath) { + capturedResultBundlePath = bundlePath; + await mkdir(bundlePath, { recursive: true }); + } + + if (command.includes('build-for-testing')) { + opts?.onStdout?.('Phase 1 succeeded\n'); + return mockCommandResponse({ + success: true, + exitCode: 0, + output: 'Phase 1 succeeded', + }); + } + if (command.includes('test-without-building')) { + opts?.onStdout?.('Phase 2 running...\n'); + const response = await mockCommandResponse({ + success: true, + exitCode: 0, + output: 'Test succeeded', + }); + Object.assign(response.process, { stdout: stream }); + return response; + } + throw new Error(`Unexpected command phase: ${command.join(' ')}`); + }); + + try { + const supervision = new CommandSupervision(); + const supervised = supervisedExecutor(mockExecutor); + const execute = createTestExecutor(supervised, { + toolName: 'test_sim', + target: 'simulator', + request: { scheme: 'AppScheme' }, + }); + + const { streamingCtx } = createContext(); + await expect(supervision.run(() => execute(defaultParams, streamingCtx))).rejects.toThrow( + 'Managed command completion is uncertain', + ); + + expect(logSpy.closeCount).toBe(1); + expect(markProductsSpy).not.toHaveBeenCalled(); + expect(markResultSpy).not.toHaveBeenCalled(); + + expect(capturedProductsPath).toBeDefined(); + expect(capturedResultBundlePath).toBeDefined(); + await expect( + access(testProductsModule.getTestProductsCompletionMarkerPath(capturedProductsPath!)), + ).rejects.toMatchObject({ code: 'ENOENT' }); + await expect( + access(resultBundleModule.getResultBundleCompletionMarkerPath(capturedResultBundlePath!)), + ).rejects.toMatchObject({ code: 'ENOENT' }); + } finally { + stream.destroy(); + } + }); + + it('Phase 2 finalize emit throw integration: propagates original error object and guarantees closeCount is 1 despite re-entrant catch finalize', async () => { + const logSpy = setupLogCaptureSpy(); + const explosiveError = new Error('Explosive emit in finalize flush'); + + let phase2Finished = false; + const simctlHandler = createSimctlLifecycleHandler(defaultParams.simulatorId!); + + const mockExecutor: CommandExecutor = vi.fn(async (command, _prefix, _shell, opts) => { + const simctlRes = await simctlHandler(command); + if (simctlRes) { + return simctlRes; + } + if (command[0] === 'xcrun') { + if (command.includes('tests')) { + return mockCommandResponse({ + success: true, + exitCode: 0, + output: JSON.stringify({ testNodes: [] }), + }); + } + if (command.includes('summary')) { + return mockCommandResponse({ + success: true, + exitCode: 0, + output: JSON.stringify({ + totalTestCount: 1, + passedTests: 1, + failedTests: 0, + skippedTests: 0, + }), + }); + } + throw new Error(`Unexpected xcrun: ${command.join(' ')}`); + } + if (command[0] !== 'xcodebuild') { + throw new Error(`Unexpected command: ${command.join(' ')}`); + } + if (command.includes('build-for-testing')) { + opts?.onStdout?.('Phase 1 succeeded\n'); + return mockCommandResponse({ success: true, exitCode: 0, output: 'ok' }); + } + if (command.includes('test-without-building')) { + // Feed line without newline so it remains buffered until parser.flush() in finalize + opts?.onStdout?.("Test Case '-[Suite testA]' passed (0.001 seconds)"); + const res = await mockCommandResponse({ success: true, exitCode: 0, output: 'ok' }); + phase2Finished = true; + return res; + } + throw new Error(`Unexpected command phase: ${command.join(' ')}`); + }); + + const supervision = new CommandSupervision(); + const supervised = supervisedExecutor(mockExecutor); + const execute = createTestExecutor(supervised, { + toolName: 'test_sim', + target: 'simulator', + request: { scheme: 'AppScheme' }, + }); + + const rawCtx: ToolHandlerContext = { + emit: () => { + if (phase2Finished) { + throw explosiveError; + } + }, + attach: () => {}, + }; + const streamingCtx = createStreamingExecutionContext(rawCtx); + + let caughtError: unknown; + try { + await supervision.run(() => execute(defaultParams, streamingCtx)); + } catch (err) { + caughtError = err; + } + + expect(caughtError).toBe(explosiveError); + expect(logSpy.closeCount).toBe(1); + }); + + it('Prepared Phase 2 normal execution: marks resultBundle completed, closes log, finalize count is 1', async () => { + const logSpy = setupLogCaptureSpy(); + setupSyncExtractSentinels(); + const markResultSpy = vi.spyOn(resultBundleModule, 'markResultBundlePathCompleted'); + let capturedResultBundlePath: string | undefined; + + const mockExecutor: CommandExecutor = vi.fn(async (command, _prefix, _shell, opts) => { + if (command[0] === 'xcrun') { + if (command.includes('tests')) { + return mockCommandResponse({ + success: true, + exitCode: 0, + output: JSON.stringify({ testNodes: [] }), + }); + } + if (command.includes('summary')) { + return mockCommandResponse({ + success: true, + exitCode: 0, + output: JSON.stringify({ + totalTestCount: 1, + passedTests: 1, + failedTests: 0, + skippedTests: 0, + }), + }); + } + throw new Error(`Unexpected xcrun command: ${command.join(' ')}`); + } + if (command[0] !== 'xcodebuild') { + throw new Error(`Unexpected command: ${command.join(' ')}`); + } + const bundleIdx = command.indexOf('-resultBundlePath'); + const bundlePath = command[bundleIdx + 1]; + if (bundleIdx !== -1 && bundlePath) { + capturedResultBundlePath = bundlePath; + await mkdir(bundlePath, { recursive: true }); + } + + if (command.includes('test-without-building')) { + opts?.onStdout?.('Prepared test running...\n'); + return mockCommandResponse({ + success: true, + exitCode: 0, + output: 'Prepared test ok', + }); + } + throw new Error(`Unexpected command phase: ${command.join(' ')}`); + }); + + const supervision = new CommandSupervision(); + const supervised = supervisedExecutor(mockExecutor); + const execute = createTestExecutor(supervised, { + toolName: 'test_sim', + target: 'simulator', + request: { scheme: 'AppScheme' }, + }); + + const { streamingCtx } = createContext(); + const result = await supervision.run(() => + execute( + { + ...defaultParams, + testProductsPath: join(tempDir, 'fixture.xctestproducts'), + }, + streamingCtx, + ), + ); + + expect(result.didError).toBe(false); + expect(result.summary.status).toBe('SUCCEEDED'); + expect(markResultSpy).toHaveBeenCalled(); + expect(logSpy.closeCount).toBe(1); + + expect(capturedResultBundlePath).toBeDefined(); + await expect( + access(resultBundleModule.getResultBundleCompletionMarkerPath(capturedResultBundlePath!)), + ).resolves.toBeUndefined(); + }); + + it('Prepared Phase 2 reject: closes pipeline log descriptor and suppresses resultBundle completion marker in supervised mode', async () => { + const logSpy = setupLogCaptureSpy(); + const markResultSpy = vi.spyOn(resultBundleModule, 'markResultBundlePathCompleted'); + let capturedResultBundlePath: string | undefined; + + const mockExecutor: CommandExecutor = vi.fn(async (command, _prefix, _shell, opts) => { + if (command[0] !== 'xcodebuild') { + throw new Error(`Unexpected command: ${command.join(' ')}`); + } + const bundleIdx = command.indexOf('-resultBundlePath'); + const bundlePath = command[bundleIdx + 1]; + if (bundleIdx !== -1 && bundlePath) { + capturedResultBundlePath = bundlePath; + await mkdir(bundlePath, { recursive: true }); + } + + if (command.includes('test-without-building')) { + opts?.onStdout?.('Prepared test running...\n'); + throw new Error('Prepared test rejected'); + } + throw new Error(`Unexpected command phase: ${command.join(' ')}`); + }); + + const supervision = new CommandSupervision(); + const supervised = supervisedExecutor(mockExecutor); + const execute = createTestExecutor(supervised, { + toolName: 'test_sim', + target: 'simulator', + request: { scheme: 'AppScheme' }, + }); + + const { streamingCtx } = createContext(); + await expect( + supervision.run(() => + execute( + { + ...defaultParams, + testProductsPath: join(tempDir, 'fixture.xctestproducts'), + }, + streamingCtx, + ), + ), + ).rejects.toThrow('Prepared test rejected'); + + expect(logSpy.closeCount).toBe(1); + expect(markResultSpy).not.toHaveBeenCalled(); + + expect(capturedResultBundlePath).toBeDefined(); + await expect(stat(capturedResultBundlePath!)).resolves.toBeDefined(); + await expect( + access(resultBundleModule.getResultBundleCompletionMarkerPath(capturedResultBundlePath!)), + ).rejects.toMatchObject({ code: 'ENOENT' }); + }); + + it('Prepared Phase 2 unknown exit (null/null): closes pipeline log descriptor and suppresses resultBundle completion marker', async () => { + const logSpy = setupLogCaptureSpy(); + const markResultSpy = vi.spyOn(resultBundleModule, 'markResultBundlePathCompleted'); + let capturedResultBundlePath: string | undefined; + + const mockExecutor: CommandExecutor = vi.fn(async (command, _prefix, _shell, opts) => { + if (command[0] !== 'xcodebuild') { + throw new Error(`Unexpected command: ${command.join(' ')}`); + } + const bundleIdx = command.indexOf('-resultBundlePath'); + const bundlePath = command[bundleIdx + 1]; + if (bundleIdx !== -1 && bundlePath) { + capturedResultBundlePath = bundlePath; + await mkdir(bundlePath, { recursive: true }); + } + + if (command.includes('test-without-building')) { + opts?.onStdout?.('Prepared test running...\n'); + const response = await mockCommandResponse({ + success: true, + output: 'Prepared test ok', + }); + Object.assign(response.process, { exitCode: null, signalCode: null }); + return response; + } + throw new Error(`Unexpected command phase: ${command.join(' ')}`); + }); + + const supervision = new CommandSupervision(); + const supervised = supervisedExecutor(mockExecutor); + const execute = createTestExecutor(supervised, { + toolName: 'test_sim', + target: 'simulator', + request: { scheme: 'AppScheme' }, + }); + + const { streamingCtx } = createContext(); + await expect( + supervision.run(() => + execute( + { + ...defaultParams, + testProductsPath: join(tempDir, 'fixture.xctestproducts'), + }, + streamingCtx, + ), + ), + ).rejects.toThrow('Managed command completion is uncertain'); + + expect(logSpy.closeCount).toBe(1); + expect(markResultSpy).not.toHaveBeenCalled(); + + expect(capturedResultBundlePath).toBeDefined(); + await expect(stat(capturedResultBundlePath!)).resolves.toBeDefined(); + await expect( + access(resultBundleModule.getResultBundleCompletionMarkerPath(capturedResultBundlePath!)), + ).rejects.toMatchObject({ code: 'ENOENT' }); + }); + + it('Prepared Phase 2 open stream: closes pipeline log descriptor and suppresses resultBundle completion marker', async () => { + const logSpy = setupLogCaptureSpy(); + const markResultSpy = vi.spyOn(resultBundleModule, 'markResultBundlePathCompleted'); + const stream = new PassThrough(); + let capturedResultBundlePath: string | undefined; + + const mockExecutor: CommandExecutor = vi.fn(async (command, _prefix, _shell, opts) => { + if (command[0] !== 'xcodebuild') { + throw new Error(`Unexpected command: ${command.join(' ')}`); + } + const bundleIdx = command.indexOf('-resultBundlePath'); + const bundlePath = command[bundleIdx + 1]; + if (bundleIdx !== -1 && bundlePath) { + capturedResultBundlePath = bundlePath; + await mkdir(bundlePath, { recursive: true }); + } + + if (command.includes('test-without-building')) { + opts?.onStdout?.('Prepared test running...\n'); + const response = await mockCommandResponse({ + success: true, + exitCode: 0, + output: 'Prepared test ok', + }); + Object.assign(response.process, { stdout: stream }); + return response; + } + throw new Error(`Unexpected command phase: ${command.join(' ')}`); + }); + + try { + const supervision = new CommandSupervision(); + const supervised = supervisedExecutor(mockExecutor); + const execute = createTestExecutor(supervised, { + toolName: 'test_sim', + target: 'simulator', + request: { scheme: 'AppScheme' }, + }); + + const { streamingCtx } = createContext(); + await expect( + supervision.run(() => + execute( + { + ...defaultParams, + testProductsPath: join(tempDir, 'fixture.xctestproducts'), + }, + streamingCtx, + ), + ), + ).rejects.toThrow('Managed command completion is uncertain'); + + expect(logSpy.closeCount).toBe(1); + expect(markResultSpy).not.toHaveBeenCalled(); + + expect(capturedResultBundlePath).toBeDefined(); + await expect(stat(capturedResultBundlePath!)).resolves.toBeDefined(); + await expect( + access(resultBundleModule.getResultBundleCompletionMarkerPath(capturedResultBundlePath!)), + ).rejects.toMatchObject({ code: 'ENOENT' }); + } finally { + stream.destroy(); + } + }); + + it('Normal successful execution: marks artifacts completed, closes log, finalize count is 1', async () => { + const logSpy = setupLogCaptureSpy(); + setupSyncExtractSentinels(); + const markProductsSpy = vi.spyOn(testProductsModule, 'markTestProductsPathCompleted'); + const markResultSpy = vi.spyOn(resultBundleModule, 'markResultBundlePathCompleted'); + let capturedProductsPath: string | undefined; + let capturedResultBundlePath: string | undefined; + const simctlHandler = createSimctlLifecycleHandler(defaultParams.simulatorId!); + + const mockExecutor: CommandExecutor = vi.fn(async (command, _prefix, _shell, opts) => { + const simctlRes = await simctlHandler(command); + if (simctlRes) { + return simctlRes; + } + if (command[0] === 'xcrun') { + if (command.includes('tests')) { + return mockCommandResponse({ + success: true, + exitCode: 0, + output: JSON.stringify({ testNodes: [] }), + }); + } + if (command.includes('summary')) { + return mockCommandResponse({ + success: true, + exitCode: 0, + output: JSON.stringify({ + totalTestCount: 1, + passedTests: 1, + failedTests: 0, + skippedTests: 0, + }), + }); + } + throw new Error(`Unexpected xcrun command: ${command.join(' ')}`); + } + if (command[0] !== 'xcodebuild') { + throw new Error(`Unexpected command: ${command.join(' ')}`); + } + const prodIdx = command.indexOf('-testProductsPath'); + const prodPath = command[prodIdx + 1]; + if (prodIdx !== -1 && prodPath) { + capturedProductsPath = prodPath; + await mkdir(prodPath, { recursive: true }); + } + const bundleIdx = command.indexOf('-resultBundlePath'); + const bundlePath = command[bundleIdx + 1]; + if (bundleIdx !== -1 && bundlePath) { + capturedResultBundlePath = bundlePath; + await mkdir(bundlePath, { recursive: true }); + } + + if (command.includes('build-for-testing')) { + opts?.onStdout?.('Build succeeded\n'); + return mockCommandResponse({ + success: true, + exitCode: 0, + output: 'Build succeeded', + }); + } + if (command.includes('test-without-building')) { + opts?.onStdout?.('Test succeeded\n'); + return mockCommandResponse({ + success: true, + exitCode: 0, + output: 'Test succeeded', + }); + } + throw new Error(`Unexpected command phase: ${command.join(' ')}`); + }); + + const supervision = new CommandSupervision(); + const supervised = supervisedExecutor(mockExecutor); + const execute = createTestExecutor(supervised, { + toolName: 'test_sim', + target: 'simulator', + request: { scheme: 'AppScheme' }, + }); + + const { streamingCtx } = createContext(); + const result = await supervision.run(() => execute(defaultParams, streamingCtx)); + + expect(result.didError).toBe(false); + expect(result.summary.status).toBe('SUCCEEDED'); + expect(markProductsSpy).toHaveBeenCalled(); + expect(markResultSpy).toHaveBeenCalled(); + expect(logSpy.closeCount).toBe(1); + + expect(capturedProductsPath).toBeDefined(); + expect(capturedResultBundlePath).toBeDefined(); + await expect( + access(testProductsModule.getTestProductsCompletionMarkerPath(capturedProductsPath!)), + ).resolves.toBeUndefined(); + await expect( + access(resultBundleModule.getResultBundleCompletionMarkerPath(capturedResultBundlePath!)), + ).resolves.toBeUndefined(); + }); + + it('Pre-execution artifact path creation error: closes log file descriptor and propagates original error', async () => { + const logSpy = setupLogCaptureSpy(); + vi.spyOn(testProductsModule, 'createDefaultTestProductsPath').mockImplementation(() => { + throw new Error('Disk failure creating test products path'); + }); + + const mockExecutor: CommandExecutor = vi.fn(async () => { + throw new Error('Should not execute any command'); + }); + + const execute = createTestExecutor(mockExecutor, { + toolName: 'test_sim', + target: 'simulator', + request: { scheme: 'AppScheme' }, + }); + + const { streamingCtx } = createContext(); + await expect(execute(defaultParams, streamingCtx)).rejects.toThrow( + 'Disk failure creating test products path', + ); + + expect(logSpy.closeCount).toBe(1); + }); + + it('Ordinary Phase 1 regression: when not supervised, error still executes ordinary catch completion marking', async () => { + const logSpy = setupLogCaptureSpy(); + const markProductsSpy = vi.spyOn(testProductsModule, 'markTestProductsPathCompleted'); + let capturedProductsPath: string | undefined; + + const mockExecutor: CommandExecutor = vi.fn(async (command, _prefix, _shell, opts) => { + if (command[0] !== 'xcodebuild') { + throw new Error(`Unexpected command: ${command.join(' ')}`); + } + const prodIdx = command.indexOf('-testProductsPath'); + const prodPath = command[prodIdx + 1]; + if (prodIdx !== -1 && prodPath) { + capturedProductsPath = prodPath; + await mkdir(prodPath, { recursive: true }); + } + + if (command.includes('build-for-testing')) { + opts?.onStdout?.('Phase 1 building...\n'); + throw new Error('Phase 1 ordinary error'); + } + throw new Error(`Unexpected command phase: ${command.join(' ')}`); + }); + + const execute = createTestExecutor(mockExecutor, { + toolName: 'test_sim', + target: 'simulator', + request: { scheme: 'AppScheme' }, + }); + + const { streamingCtx } = createContext(); + await expect(execute(defaultParams, streamingCtx)).rejects.toThrow('Phase 1 ordinary error'); + + // In ordinary mode, catch block marks completion according to existing behavior + expect(markProductsSpy).toHaveBeenCalled(); + expect(logSpy.closeCount).toBe(1); + + expect(capturedProductsPath).toBeDefined(); + await expect( + access(testProductsModule.getTestProductsCompletionMarkerPath(capturedProductsPath!)), + ).resolves.toBeUndefined(); + }); + + it('Ordinary Phase 2 regression: when not supervised, error still executes ordinary finally completion marking', async () => { + const logSpy = setupLogCaptureSpy(); + const markProductsSpy = vi.spyOn(testProductsModule, 'markTestProductsPathCompleted'); + const markResultSpy = vi.spyOn(resultBundleModule, 'markResultBundlePathCompleted'); + let capturedProductsPath: string | undefined; + let capturedResultBundlePath: string | undefined; + + const mockExecutor: CommandExecutor = vi.fn(async (command, _prefix, _shell, opts) => { + if (command[0] !== 'xcodebuild') { + throw new Error(`Unexpected command: ${command.join(' ')}`); + } + const prodIdx = command.indexOf('-testProductsPath'); + const prodPath = command[prodIdx + 1]; + if (prodIdx !== -1 && prodPath) { + capturedProductsPath = prodPath; + await mkdir(prodPath, { recursive: true }); + } + const bundleIdx = command.indexOf('-resultBundlePath'); + const bundlePath = command[bundleIdx + 1]; + if (bundleIdx !== -1 && bundlePath) { + capturedResultBundlePath = bundlePath; + await mkdir(bundlePath, { recursive: true }); + } + + if (command.includes('build-for-testing')) { + opts?.onStdout?.('Phase 1 succeeded\n'); + return mockCommandResponse({ + success: true, + exitCode: 0, + output: 'Phase 1 succeeded', + }); + } + if (command.includes('test-without-building')) { + opts?.onStdout?.('Phase 2 running...\n'); + throw new Error('Phase 2 ordinary error'); + } + throw new Error(`Unexpected command phase: ${command.join(' ')}`); + }); + + const execute = createTestExecutor(mockExecutor, { + toolName: 'test_sim', + target: 'simulator', + request: { scheme: 'AppScheme' }, + }); + + const { streamingCtx } = createContext(); + await expect(execute(defaultParams, streamingCtx)).rejects.toThrow('Phase 2 ordinary error'); + + // In ordinary mode, finally block marks completion of both artifacts + expect(markProductsSpy).toHaveBeenCalled(); + expect(markResultSpy).toHaveBeenCalled(); + expect(logSpy.closeCount).toBe(1); + + expect(capturedProductsPath).toBeDefined(); + expect(capturedResultBundlePath).toBeDefined(); + await expect( + access(testProductsModule.getTestProductsCompletionMarkerPath(capturedProductsPath!)), + ).resolves.toBeUndefined(); + await expect( + access(resultBundleModule.getResultBundleCompletionMarkerPath(capturedResultBundlePath!)), + ).resolves.toBeUndefined(); + }); + + it('Metadata reject: closes pipeline log descriptor, suppresses completion markers, stops subsequent commands', async () => { + const logSpy = setupLogCaptureSpy(); + setupSyncExtractSentinels(); + const markProductsSpy = vi.spyOn(testProductsModule, 'markTestProductsPathCompleted'); + const markResultSpy = vi.spyOn(resultBundleModule, 'markResultBundlePathCompleted'); + let capturedProductsPath: string | undefined; + let capturedResultBundlePath: string | undefined; + let summaryCalled = false; + + const mockExecutor: CommandExecutor = vi.fn(async (command, _prefix, _shell, opts) => { + if (command[0] === 'xcrun') { + if (command.includes('tests')) { + throw new Error('Metadata xcrun tests rejected'); + } + if (command.includes('summary')) { + summaryCalled = true; + return mockCommandResponse({ success: true, exitCode: 0, output: '{}' }); + } + throw new Error(`Unexpected xcrun: ${command.join(' ')}`); + } + if (command[0] !== 'xcodebuild') { + throw new Error(`Unexpected command: ${command.join(' ')}`); + } + const prodIdx = command.indexOf('-testProductsPath'); + const prodPath = command[prodIdx + 1]; + if (prodIdx !== -1 && prodPath) { + capturedProductsPath = prodPath; + await mkdir(prodPath, { recursive: true }); + } + const bundleIdx = command.indexOf('-resultBundlePath'); + const bundlePath = command[bundleIdx + 1]; + if (bundleIdx !== -1 && bundlePath) { + capturedResultBundlePath = bundlePath; + await mkdir(bundlePath, { recursive: true }); + } + + if (command.includes('build-for-testing')) { + opts?.onStdout?.('Build ok\n'); + return mockCommandResponse({ success: true, exitCode: 0, output: 'Build ok' }); + } + if (command.includes('test-without-building')) { + opts?.onStdout?.('Test ok\n'); + return mockCommandResponse({ success: true, exitCode: 0, output: 'Test ok' }); + } + throw new Error(`Unexpected command phase: ${command.join(' ')}`); + }); + + const supervision = new CommandSupervision(); + const supervised = supervisedExecutor(mockExecutor); + const execute = createTestExecutor(supervised, { + toolName: 'test_sim', + target: 'simulator', + request: { scheme: 'AppScheme' }, + }); + + const { streamingCtx } = createContext(); + await expect(supervision.run(() => execute(defaultParams, streamingCtx))).rejects.toThrow( + 'Metadata xcrun tests rejected', + ); + + expect(summaryCalled).toBe(false); + expect(logSpy.closeCount).toBe(1); + expect(markProductsSpy).not.toHaveBeenCalled(); + expect(markResultSpy).not.toHaveBeenCalled(); + + expect(capturedProductsPath).toBeDefined(); + expect(capturedResultBundlePath).toBeDefined(); + await expect( + access(testProductsModule.getTestProductsCompletionMarkerPath(capturedProductsPath!)), + ).rejects.toMatchObject({ code: 'ENOENT' }); + await expect( + access(resultBundleModule.getResultBundleCompletionMarkerPath(capturedResultBundlePath!)), + ).rejects.toMatchObject({ code: 'ENOENT' }); + }); + + it('Metadata unknown exit (null/null): closes pipeline log descriptor, suppresses completion markers, stops subsequent commands', async () => { + const logSpy = setupLogCaptureSpy(); + setupSyncExtractSentinels(); + const markProductsSpy = vi.spyOn(testProductsModule, 'markTestProductsPathCompleted'); + const markResultSpy = vi.spyOn(resultBundleModule, 'markResultBundlePathCompleted'); + let capturedProductsPath: string | undefined; + let capturedResultBundlePath: string | undefined; + let summaryCalled = false; + + const mockExecutor: CommandExecutor = vi.fn(async (command, _prefix, _shell, opts) => { + if (command[0] === 'xcrun') { + if (command.includes('tests')) { + const res = await mockCommandResponse({ success: true, output: 'tests ok' }); + Object.assign(res.process, { exitCode: null, signalCode: null }); + return res; + } + if (command.includes('summary')) { + summaryCalled = true; + return mockCommandResponse({ success: true, exitCode: 0, output: '{}' }); + } + throw new Error(`Unexpected xcrun: ${command.join(' ')}`); + } + if (command[0] !== 'xcodebuild') { + throw new Error(`Unexpected command: ${command.join(' ')}`); + } + const prodIdx = command.indexOf('-testProductsPath'); + const prodPath = command[prodIdx + 1]; + if (prodIdx !== -1 && prodPath) { + capturedProductsPath = prodPath; + await mkdir(prodPath, { recursive: true }); + } + const bundleIdx = command.indexOf('-resultBundlePath'); + const bundlePath = command[bundleIdx + 1]; + if (bundleIdx !== -1 && bundlePath) { + capturedResultBundlePath = bundlePath; + await mkdir(bundlePath, { recursive: true }); + } + + if (command.includes('build-for-testing')) { + opts?.onStdout?.('Build ok\n'); + return mockCommandResponse({ success: true, exitCode: 0, output: 'Build ok' }); + } + if (command.includes('test-without-building')) { + opts?.onStdout?.('Test ok\n'); + return mockCommandResponse({ success: true, exitCode: 0, output: 'Test ok' }); + } + throw new Error(`Unexpected command phase: ${command.join(' ')}`); + }); + + const supervision = new CommandSupervision(); + const supervised = supervisedExecutor(mockExecutor); + const execute = createTestExecutor(supervised, { + toolName: 'test_sim', + target: 'simulator', + request: { scheme: 'AppScheme' }, + }); + + const { streamingCtx } = createContext(); + await expect(supervision.run(() => execute(defaultParams, streamingCtx))).rejects.toThrow( + 'Managed command completion is uncertain', + ); + + expect(summaryCalled).toBe(false); + expect(logSpy.closeCount).toBe(1); + expect(markProductsSpy).not.toHaveBeenCalled(); + expect(markResultSpy).not.toHaveBeenCalled(); + + expect(capturedProductsPath).toBeDefined(); + expect(capturedResultBundlePath).toBeDefined(); + await expect( + access(testProductsModule.getTestProductsCompletionMarkerPath(capturedProductsPath!)), + ).rejects.toMatchObject({ code: 'ENOENT' }); + await expect( + access(resultBundleModule.getResultBundleCompletionMarkerPath(capturedResultBundlePath!)), + ).rejects.toMatchObject({ code: 'ENOENT' }); + }); + + it('Metadata open stream: closes pipeline log descriptor, suppresses completion markers, stops subsequent commands', async () => { + const logSpy = setupLogCaptureSpy(); + setupSyncExtractSentinels(); + const markProductsSpy = vi.spyOn(testProductsModule, 'markTestProductsPathCompleted'); + const markResultSpy = vi.spyOn(resultBundleModule, 'markResultBundlePathCompleted'); + const stream = new PassThrough(); + let capturedProductsPath: string | undefined; + let capturedResultBundlePath: string | undefined; + let summaryCalled = false; + + const mockExecutor: CommandExecutor = vi.fn(async (command, _prefix, _shell, opts) => { + if (command[0] === 'xcrun') { + if (command.includes('tests')) { + const res = await mockCommandResponse({ success: true, exitCode: 0, output: 'tests ok' }); + Object.assign(res.process, { stdout: stream }); + return res; + } + if (command.includes('summary')) { + summaryCalled = true; + return mockCommandResponse({ success: true, exitCode: 0, output: '{}' }); + } + throw new Error(`Unexpected xcrun: ${command.join(' ')}`); + } + if (command[0] !== 'xcodebuild') { + throw new Error(`Unexpected command: ${command.join(' ')}`); + } + const prodIdx = command.indexOf('-testProductsPath'); + const prodPath = command[prodIdx + 1]; + if (prodIdx !== -1 && prodPath) { + capturedProductsPath = prodPath; + await mkdir(prodPath, { recursive: true }); + } + const bundleIdx = command.indexOf('-resultBundlePath'); + const bundlePath = command[bundleIdx + 1]; + if (bundleIdx !== -1 && bundlePath) { + capturedResultBundlePath = bundlePath; + await mkdir(bundlePath, { recursive: true }); + } + + if (command.includes('build-for-testing')) { + opts?.onStdout?.('Build ok\n'); + return mockCommandResponse({ success: true, exitCode: 0, output: 'Build ok' }); + } + if (command.includes('test-without-building')) { + opts?.onStdout?.('Test ok\n'); + return mockCommandResponse({ success: true, exitCode: 0, output: 'Test ok' }); + } + throw new Error(`Unexpected command phase: ${command.join(' ')}`); + }); + + try { + const supervision = new CommandSupervision(); + const supervised = supervisedExecutor(mockExecutor); + const execute = createTestExecutor(supervised, { + toolName: 'test_sim', + target: 'simulator', + request: { scheme: 'AppScheme' }, + }); + + const { streamingCtx } = createContext(); + await expect(supervision.run(() => execute(defaultParams, streamingCtx))).rejects.toThrow( + 'Managed command completion is uncertain', + ); + + expect(summaryCalled).toBe(false); + expect(logSpy.closeCount).toBe(1); + expect(markProductsSpy).not.toHaveBeenCalled(); + expect(markResultSpy).not.toHaveBeenCalled(); + + expect(capturedProductsPath).toBeDefined(); + expect(capturedResultBundlePath).toBeDefined(); + await expect( + access(testProductsModule.getTestProductsCompletionMarkerPath(capturedProductsPath!)), + ).rejects.toMatchObject({ code: 'ENOENT' }); + await expect( + access(resultBundleModule.getResultBundleCompletionMarkerPath(capturedResultBundlePath!)), + ).rejects.toMatchObject({ code: 'ENOENT' }); + } finally { + stream.destroy(); + } + }); + + it('Confirmed non-zero exit code (exit 1) on metadata: falls back to state counts, writes markers, closes log', async () => { + const logSpy = setupLogCaptureSpy(); + setupSyncExtractSentinels(); + const markProductsSpy = vi.spyOn(testProductsModule, 'markTestProductsPathCompleted'); + const markResultSpy = vi.spyOn(resultBundleModule, 'markResultBundlePathCompleted'); + let capturedProductsPath: string | undefined; + let capturedResultBundlePath: string | undefined; + const simctlHandler = createSimctlLifecycleHandler(defaultParams.simulatorId!); + + const mockExecutor: CommandExecutor = vi.fn(async (command, _prefix, _shell, opts) => { + const simctlRes = await simctlHandler(command); + if (simctlRes) { + return simctlRes; + } + if (command[0] === 'xcrun') { + return mockCommandResponse({ + success: false, + exitCode: 1, + output: 'xcresulttool failed with error', + }); + } + if (command[0] !== 'xcodebuild') { + throw new Error(`Unexpected command: ${command.join(' ')}`); + } + const prodIdx = command.indexOf('-testProductsPath'); + const prodPath = command[prodIdx + 1]; + if (prodIdx !== -1 && prodPath) { + capturedProductsPath = prodPath; + await mkdir(prodPath, { recursive: true }); + } + const bundleIdx = command.indexOf('-resultBundlePath'); + const bundlePath = command[bundleIdx + 1]; + if (bundleIdx !== -1 && bundlePath) { + capturedResultBundlePath = bundlePath; + await mkdir(bundlePath, { recursive: true }); + } + + if (command.includes('build-for-testing')) { + opts?.onStdout?.('Build ok\n'); + return mockCommandResponse({ success: true, exitCode: 0, output: 'Build ok' }); + } + if (command.includes('test-without-building')) { + opts?.onStdout?.('Test ok\n'); + return mockCommandResponse({ success: true, exitCode: 0, output: 'Test ok' }); + } + throw new Error(`Unexpected command phase: ${command.join(' ')}`); + }); + + const supervision = new CommandSupervision(); + const supervised = supervisedExecutor(mockExecutor); + const execute = createTestExecutor(supervised, { + toolName: 'test_sim', + target: 'simulator', + request: { scheme: 'AppScheme' }, + }); + + const { streamingCtx } = createContext(); + const result = await supervision.run(() => execute(defaultParams, streamingCtx)); + + expect(result.didError).toBe(false); + expect(result.summary.status).toBe('SUCCEEDED'); + expect(markProductsSpy).toHaveBeenCalled(); + expect(markResultSpy).toHaveBeenCalled(); + expect(logSpy.closeCount).toBe(1); + + expect(capturedProductsPath).toBeDefined(); + expect(capturedResultBundlePath).toBeDefined(); + await expect( + access(testProductsModule.getTestProductsCompletionMarkerPath(capturedProductsPath!)), + ).resolves.toBeUndefined(); + await expect( + access(resultBundleModule.getResultBundleCompletionMarkerPath(capturedResultBundlePath!)), + ).resolves.toBeUndefined(); + }); + + it('Invalid JSON on metadata: falls back to state counts, writes markers, closes log', async () => { + const logSpy = setupLogCaptureSpy(); + setupSyncExtractSentinels(); + const markProductsSpy = vi.spyOn(testProductsModule, 'markTestProductsPathCompleted'); + const markResultSpy = vi.spyOn(resultBundleModule, 'markResultBundlePathCompleted'); + let capturedProductsPath: string | undefined; + let capturedResultBundlePath: string | undefined; + const simctlHandler = createSimctlLifecycleHandler(defaultParams.simulatorId!); + + const mockExecutor: CommandExecutor = vi.fn(async (command, _prefix, _shell, opts) => { + const simctlRes = await simctlHandler(command); + if (simctlRes) { + return simctlRes; + } + if (command[0] === 'xcrun') { + return mockCommandResponse({ + success: true, + exitCode: 0, + output: 'malformed json { not valid', + }); + } + if (command[0] !== 'xcodebuild') { + throw new Error(`Unexpected command: ${command.join(' ')}`); + } + const prodIdx = command.indexOf('-testProductsPath'); + const prodPath = command[prodIdx + 1]; + if (prodIdx !== -1 && prodPath) { + capturedProductsPath = prodPath; + await mkdir(prodPath, { recursive: true }); + } + const bundleIdx = command.indexOf('-resultBundlePath'); + const bundlePath = command[bundleIdx + 1]; + if (bundleIdx !== -1 && bundlePath) { + capturedResultBundlePath = bundlePath; + await mkdir(bundlePath, { recursive: true }); + } + + if (command.includes('build-for-testing')) { + opts?.onStdout?.('Build ok\n'); + return mockCommandResponse({ success: true, exitCode: 0, output: 'Build ok' }); + } + if (command.includes('test-without-building')) { + opts?.onStdout?.('Test ok\n'); + return mockCommandResponse({ success: true, exitCode: 0, output: 'Test ok' }); + } + throw new Error(`Unexpected command phase: ${command.join(' ')}`); + }); + + const supervision = new CommandSupervision(); + const supervised = supervisedExecutor(mockExecutor); + const execute = createTestExecutor(supervised, { + toolName: 'test_sim', + target: 'simulator', + request: { scheme: 'AppScheme' }, + }); + + const { streamingCtx } = createContext(); + const result = await supervision.run(() => execute(defaultParams, streamingCtx)); + + expect(result.didError).toBe(false); + expect(result.summary.status).toBe('SUCCEEDED'); + expect(markProductsSpy).toHaveBeenCalled(); + expect(markResultSpy).toHaveBeenCalled(); + expect(logSpy.closeCount).toBe(1); + + expect(capturedProductsPath).toBeDefined(); + expect(capturedResultBundlePath).toBeDefined(); + await expect( + access(testProductsModule.getTestProductsCompletionMarkerPath(capturedProductsPath!)), + ).resolves.toBeUndefined(); + await expect( + access(resultBundleModule.getResultBundleCompletionMarkerPath(capturedResultBundlePath!)), + ).resolves.toBeUndefined(); + }); + + it('Valid metadata summary and failures: flows into final domain result, writes markers, closes log', async () => { + const logSpy = setupLogCaptureSpy(); + setupSyncExtractSentinels(); + const markProductsSpy = vi.spyOn(testProductsModule, 'markTestProductsPathCompleted'); + const markResultSpy = vi.spyOn(resultBundleModule, 'markResultBundlePathCompleted'); + let capturedProductsPath: string | undefined; + let capturedResultBundlePath: string | undefined; + const simctlHandler = createSimctlLifecycleHandler(defaultParams.simulatorId!); + + const mockExecutor: CommandExecutor = vi.fn(async (command, _prefix, _shell, opts) => { + const simctlRes = await simctlHandler(command); + if (simctlRes) { + return simctlRes; + } + if (command[0] === 'xcrun') { + if (command.includes('tests')) { + return mockCommandResponse({ + success: true, + exitCode: 0, + output: JSON.stringify({ + testNodes: [ + { + name: 'AppTests', + nodeType: 'Test Suite', + children: [ + { + name: '-[AppTests testAssertionFailure]', + nodeType: 'Test Case', + result: 'Failed', + children: [ + { + name: 'AppTests.swift:42: failed - assertion failed: x == y', + nodeType: 'Failure Message', + }, + ], + }, + ], + }, + ], + }), + }); + } + if (command.includes('summary')) { + return mockCommandResponse({ + success: true, + exitCode: 0, + output: JSON.stringify({ + totalTestCount: 10, + passedTests: 9, + failedTests: 1, + skippedTests: 0, + }), + }); + } + throw new Error(`Unexpected xcrun: ${command.join(' ')}`); + } + if (command[0] !== 'xcodebuild') { + throw new Error(`Unexpected command: ${command.join(' ')}`); + } + const prodIdx = command.indexOf('-testProductsPath'); + const prodPath = command[prodIdx + 1]; + if (prodIdx !== -1 && prodPath) { + capturedProductsPath = prodPath; + await mkdir(prodPath, { recursive: true }); + } + const bundleIdx = command.indexOf('-resultBundlePath'); + const bundlePath = command[bundleIdx + 1]; + if (bundleIdx !== -1 && bundlePath) { + capturedResultBundlePath = bundlePath; + await mkdir(bundlePath, { recursive: true }); + } + + if (command.includes('build-for-testing')) { + opts?.onStdout?.('Build ok\n'); + return mockCommandResponse({ success: true, exitCode: 0, output: 'Build ok' }); + } + if (command.includes('test-without-building')) { + opts?.onStdout?.('Test completed with failure\n'); + // tests failed exit code 65 + return mockCommandResponse({ success: false, exitCode: 65, output: 'Tests failed' }); + } + throw new Error(`Unexpected command phase: ${command.join(' ')}`); + }); + + const supervision = new CommandSupervision(); + const supervised = supervisedExecutor(mockExecutor); + const execute = createTestExecutor(supervised, { + toolName: 'test_sim', + target: 'simulator', + request: { scheme: 'AppScheme' }, + }); + + const { streamingCtx } = createContext(); + const result = await supervision.run(() => execute(defaultParams, streamingCtx)); + + expect(result.didError).toBe(true); + expect(result.summary.status).toBe('FAILED'); + expect(result.summary.counts).toEqual({ + passed: 9, + failed: 1, + skipped: 0, + }); + expect(result.diagnostics.testFailures).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + message: 'assertion failed: x == y', + location: 'AppTests.swift:42', + }), + ]), + ); + + expect(markProductsSpy).toHaveBeenCalled(); + expect(markResultSpy).toHaveBeenCalled(); + expect(logSpy.closeCount).toBe(1); + + expect(capturedProductsPath).toBeDefined(); + expect(capturedResultBundlePath).toBeDefined(); + await expect( + access(testProductsModule.getTestProductsCompletionMarkerPath(capturedProductsPath!)), + ).resolves.toBeUndefined(); + await expect( + access(resultBundleModule.getResultBundleCompletionMarkerPath(capturedResultBundlePath!)), + ).resolves.toBeUndefined(); + }); + + it('Phase 1 compile failure in managed mode does not read xcresult', async () => { + const logSpy = setupLogCaptureSpy(); + setupSyncExtractSentinels(); + const markProductsSpy = vi.spyOn(testProductsModule, 'markTestProductsPathCompleted'); + let capturedProductsPath: string | undefined; + let xcrunCalled = false; + + const mockExecutor: CommandExecutor = vi.fn(async (command, _prefix, _shell, opts) => { + if (command[0] === 'xcrun') { + xcrunCalled = true; + return mockCommandResponse({ success: true, exitCode: 0, output: '{}' }); + } + if (command[0] !== 'xcodebuild') { + throw new Error(`Unexpected command: ${command.join(' ')}`); + } + const prodIdx = command.indexOf('-testProductsPath'); + const prodPath = command[prodIdx + 1]; + if (prodIdx !== -1 && prodPath) { + capturedProductsPath = prodPath; + await mkdir(prodPath, { recursive: true }); + } + + if (command.includes('build-for-testing')) { + opts?.onStdout?.('Compile error\n'); + return mockCommandResponse({ + success: false, + exitCode: 65, + output: 'Compile error', + }); + } + throw new Error(`Unexpected command phase: ${command.join(' ')}`); + }); + + const supervision = new CommandSupervision(); + const supervised = supervisedExecutor(mockExecutor); + const execute = createTestExecutor(supervised, { + toolName: 'test_sim', + target: 'simulator', + request: { scheme: 'AppScheme' }, + }); + + const { streamingCtx } = createContext(); + const result = await supervision.run(() => execute(defaultParams, streamingCtx)); + + expect(result.didError).toBe(true); + expect(xcrunCalled).toBe(false); + expect(markProductsSpy).toHaveBeenCalled(); + expect(logSpy.closeCount).toBe(1); + + expect(capturedProductsPath).toBeDefined(); + await expect( + access(testProductsModule.getTestProductsCompletionMarkerPath(capturedProductsPath!)), + ).resolves.toBeUndefined(); + }); + + it('Metadata summary reject: closes pipeline log descriptor, suppresses completion markers, propagates error without fallback to sync', async () => { + const logSpy = setupLogCaptureSpy(); + setupSyncExtractSentinels(); + const markProductsSpy = vi.spyOn(testProductsModule, 'markTestProductsPathCompleted'); + const markResultSpy = vi.spyOn(resultBundleModule, 'markResultBundlePathCompleted'); + let capturedProductsPath: string | undefined; + let capturedResultBundlePath: string | undefined; + + const mockExecutor: CommandExecutor = vi.fn(async (command, _prefix, _shell, opts) => { + if (command[0] === 'xcrun') { + if (command.includes('tests')) { + return mockCommandResponse({ + success: true, + exitCode: 0, + output: JSON.stringify({ testNodes: [] }), + }); + } + if (command.includes('summary')) { + throw new Error('Summary command rejected'); + } + throw new Error(`Unexpected xcrun: ${command.join(' ')}`); + } + if (command[0] !== 'xcodebuild') { + throw new Error(`Unexpected command: ${command.join(' ')}`); + } + const prodIdx = command.indexOf('-testProductsPath'); + const prodPath = command[prodIdx + 1]; + if (prodIdx !== -1 && prodPath) { + capturedProductsPath = prodPath; + await mkdir(prodPath, { recursive: true }); + } + const bundleIdx = command.indexOf('-resultBundlePath'); + const bundlePath = command[bundleIdx + 1]; + if (bundleIdx !== -1 && bundlePath) { + capturedResultBundlePath = bundlePath; + await mkdir(bundlePath, { recursive: true }); + } + + if (command.includes('build-for-testing')) { + opts?.onStdout?.('Build ok\n'); + return mockCommandResponse({ success: true, exitCode: 0, output: 'Build ok' }); + } + if (command.includes('test-without-building')) { + opts?.onStdout?.('Test ok\n'); + return mockCommandResponse({ success: true, exitCode: 0, output: 'Test ok' }); + } + throw new Error(`Unexpected command phase: ${command.join(' ')}`); + }); + + const supervision = new CommandSupervision(); + const supervised = supervisedExecutor(mockExecutor); + const execute = createTestExecutor(supervised, { + toolName: 'test_sim', + target: 'simulator', + request: { scheme: 'AppScheme' }, + }); + + const { streamingCtx } = createContext(); + await expect(supervision.run(() => execute(defaultParams, streamingCtx))).rejects.toThrow( + 'Summary command rejected', + ); + + expect(logSpy.closeCount).toBe(1); + expect(markProductsSpy).not.toHaveBeenCalled(); + expect(markResultSpy).not.toHaveBeenCalled(); + + expect(capturedProductsPath).toBeDefined(); + expect(capturedResultBundlePath).toBeDefined(); + await expect( + access(testProductsModule.getTestProductsCompletionMarkerPath(capturedProductsPath!)), + ).rejects.toMatchObject({ code: 'ENOENT' }); + await expect( + access(resultBundleModule.getResultBundleCompletionMarkerPath(capturedResultBundlePath!)), + ).rejects.toMatchObject({ code: 'ENOENT' }); + }); + + it('Metadata summary unknown exit (null/null): closes pipeline log descriptor, suppresses completion markers', async () => { + const logSpy = setupLogCaptureSpy(); + setupSyncExtractSentinels(); + const markProductsSpy = vi.spyOn(testProductsModule, 'markTestProductsPathCompleted'); + const markResultSpy = vi.spyOn(resultBundleModule, 'markResultBundlePathCompleted'); + let capturedProductsPath: string | undefined; + let capturedResultBundlePath: string | undefined; + + const mockExecutor: CommandExecutor = vi.fn(async (command, _prefix, _shell, opts) => { + if (command[0] === 'xcrun') { + if (command.includes('tests')) { + return mockCommandResponse({ + success: true, + exitCode: 0, + output: JSON.stringify({ testNodes: [] }), + }); + } + if (command.includes('summary')) { + const res = await mockCommandResponse({ success: true, output: '{}' }); + Object.assign(res.process, { exitCode: null, signalCode: null }); + return res; + } + throw new Error(`Unexpected xcrun: ${command.join(' ')}`); + } + if (command[0] !== 'xcodebuild') { + throw new Error(`Unexpected command: ${command.join(' ')}`); + } + const prodIdx = command.indexOf('-testProductsPath'); + const prodPath = command[prodIdx + 1]; + if (prodIdx !== -1 && prodPath) { + capturedProductsPath = prodPath; + await mkdir(prodPath, { recursive: true }); + } + const bundleIdx = command.indexOf('-resultBundlePath'); + const bundlePath = command[bundleIdx + 1]; + if (bundleIdx !== -1 && bundlePath) { + capturedResultBundlePath = bundlePath; + await mkdir(bundlePath, { recursive: true }); + } + + if (command.includes('build-for-testing')) { + opts?.onStdout?.('Build ok\n'); + return mockCommandResponse({ success: true, exitCode: 0, output: 'Build ok' }); + } + if (command.includes('test-without-building')) { + opts?.onStdout?.('Test ok\n'); + return mockCommandResponse({ success: true, exitCode: 0, output: 'Test ok' }); + } + throw new Error(`Unexpected command phase: ${command.join(' ')}`); + }); + + const supervision = new CommandSupervision(); + const supervised = supervisedExecutor(mockExecutor); + const execute = createTestExecutor(supervised, { + toolName: 'test_sim', + target: 'simulator', + request: { scheme: 'AppScheme' }, + }); + + const { streamingCtx } = createContext(); + await expect(supervision.run(() => execute(defaultParams, streamingCtx))).rejects.toThrow( + 'Managed command completion is uncertain', + ); + + expect(logSpy.closeCount).toBe(1); + expect(markProductsSpy).not.toHaveBeenCalled(); + expect(markResultSpy).not.toHaveBeenCalled(); + + expect(capturedProductsPath).toBeDefined(); + expect(capturedResultBundlePath).toBeDefined(); + await expect( + access(testProductsModule.getTestProductsCompletionMarkerPath(capturedProductsPath!)), + ).rejects.toMatchObject({ code: 'ENOENT' }); + await expect( + access(resultBundleModule.getResultBundleCompletionMarkerPath(capturedResultBundlePath!)), + ).rejects.toMatchObject({ code: 'ENOENT' }); + }); + + it('Prepared branch metadata summary failure: suppresses resultBundle completion marker and closes log once', async () => { + const logSpy = setupLogCaptureSpy(); + setupSyncExtractSentinels(); + const markResultSpy = vi.spyOn(resultBundleModule, 'markResultBundlePathCompleted'); + let capturedResultBundlePath: string | undefined; + + const mockExecutor: CommandExecutor = vi.fn(async (command, _prefix, _shell, opts) => { + if (command[0] === 'xcrun') { + if (command.includes('tests')) { + return mockCommandResponse({ + success: true, + exitCode: 0, + output: JSON.stringify({ testNodes: [] }), + }); + } + if (command.includes('summary')) { + throw new Error('Prepared summary command rejected'); + } + throw new Error(`Unexpected xcrun: ${command.join(' ')}`); + } + if (command[0] !== 'xcodebuild') { + throw new Error(`Unexpected command: ${command.join(' ')}`); + } + const bundleIdx = command.indexOf('-resultBundlePath'); + const bundlePath = command[bundleIdx + 1]; + if (bundleIdx !== -1 && bundlePath) { + capturedResultBundlePath = bundlePath; + await mkdir(bundlePath, { recursive: true }); + } + + if (command.includes('test-without-building')) { + opts?.onStdout?.('Prepared test running...\n'); + return mockCommandResponse({ success: true, exitCode: 0, output: 'Prepared test ok' }); + } + throw new Error(`Unexpected command phase: ${command.join(' ')}`); + }); + + const supervision = new CommandSupervision(); + const supervised = supervisedExecutor(mockExecutor); + const execute = createTestExecutor(supervised, { + toolName: 'test_sim', + target: 'simulator', + request: { scheme: 'AppScheme' }, + }); + + const { streamingCtx } = createContext(); + await expect( + supervision.run(() => + execute( + { + ...defaultParams, + testProductsPath: join(tempDir, 'fixture.xctestproducts'), + }, + streamingCtx, + ), + ), + ).rejects.toThrow('Prepared summary command rejected'); + + expect(logSpy.closeCount).toBe(1); + expect(markResultSpy).not.toHaveBeenCalled(); + + expect(capturedResultBundlePath).toBeDefined(); + await expect( + access(resultBundleModule.getResultBundleCompletionMarkerPath(capturedResultBundlePath!)), + ).rejects.toMatchObject({ code: 'ENOENT' }); + }); + + describe('Stage T3: Simulator command targeting and flag injection contract', () => { + it('Supervised source-mode: captures real argv in both phases, Phase 1 has no runtime flags, Phase 2 has serialized destination flags exactly once without override, and both have uppercase UUID', async () => { + const logSpy = setupLogCaptureSpy(); + setupSyncExtractSentinels(); + const markProductsSpy = vi.spyOn(testProductsModule, 'markTestProductsPathCompleted'); + const markResultSpy = vi.spyOn(resultBundleModule, 'markResultBundlePathCompleted'); + + const capturedCommands: string[][] = []; + let capturedProductsPath: string | undefined; + let capturedResultBundlePath: string | undefined; + const simctlHandler = createSimctlLifecycleHandler('65faa1dc-6dc9-456d-a88e-3cf5a516f95b'); + + const mockExecutor: CommandExecutor = vi.fn(async (command, _prefix, _shell, opts) => { + capturedCommands.push([...command]); + const simctlRes = await simctlHandler(command); + if (simctlRes) { + return simctlRes; + } + if (command[0] === 'xcrun') { + if (command.includes('tests')) { + return mockCommandResponse({ + success: true, + exitCode: 0, + output: JSON.stringify({ testNodes: [] }), + }); + } + if (command.includes('summary')) { + return mockCommandResponse({ + success: true, + exitCode: 0, + output: JSON.stringify({ + totalTestCount: 1, + passedTests: 1, + failedTests: 0, + skippedTests: 0, + }), + }); + } + throw new Error(`Unexpected xcrun: ${command.join(' ')}`); + } + if (command[0] !== 'xcodebuild') { + throw new Error(`Unexpected command: ${command.join(' ')}`); + } + const prodIdx = command.indexOf('-testProductsPath'); + const prodPath = command[prodIdx + 1]; + if (prodIdx !== -1 && prodPath) { + capturedProductsPath = prodPath; + await mkdir(prodPath, { recursive: true }); + } + const bundleIdx = command.indexOf('-resultBundlePath'); + const bundlePath = command[bundleIdx + 1]; + if (bundleIdx !== -1 && bundlePath) { + capturedResultBundlePath = bundlePath; + await mkdir(bundlePath, { recursive: true }); + } + + if (command.includes('build-for-testing')) { + opts?.onStdout?.('Build ok\n'); + return mockCommandResponse({ success: true, exitCode: 0, output: 'Build ok' }); + } + if (command.includes('test-without-building')) { + opts?.onStdout?.('Test ok\n'); + return mockCommandResponse({ success: true, exitCode: 0, output: 'Test ok' }); + } + throw new Error(`Unexpected command phase: ${command.join(' ')}`); + }); + + const supervision = new CommandSupervision(); + const supervised = supervisedExecutor(mockExecutor); + const execute = createTestExecutor(supervised, { + toolName: 'test_sim', + target: 'simulator', + request: { scheme: 'AppScheme' }, + }); + + const lowercaseIdParams: SharedTestExecutorParams = { + ...defaultParams, + simulatorId: '65faa1dc-6dc9-456d-a88e-3cf5a516f95b', + }; + + const { streamingCtx } = createContext(); + const result = await supervision.run(() => execute(lowercaseIdParams, streamingCtx)); + + expect(result.didError).toBe(false); + expect(result.summary.status).toBe('SUCCEEDED'); + expect(logSpy.closeCount).toBe(1); + expect(markProductsSpy).toHaveBeenCalled(); + expect(markResultSpy).toHaveBeenCalled(); + + // Find Phase 1 and Phase 2 commands + const phase1Cmd = capturedCommands.find((c) => c.includes('build-for-testing')); + const phase2Cmd = capturedCommands.find((c) => c.includes('test-without-building')); + expect(phase1Cmd).toBeDefined(); + expect(phase2Cmd).toBeDefined(); + + // Phase 1 assertions: + // Destination must have uppercase UUID + const p1DestIdx = phase1Cmd!.indexOf('-destination'); + expect(p1DestIdx).not.toBe(-1); + expect(phase1Cmd![p1DestIdx + 1]).toBe( + 'platform=iOS Simulator,id=65FAA1DC-6DC9-456D-A88E-3CF5A516F95B', + ); + // Phase 1 must NOT have any of the three runtime flags + expect(phase1Cmd).not.toContain('-parallel-testing-enabled'); + expect(phase1Cmd).not.toContain('-maximum-concurrent-test-simulator-destinations'); + expect(phase1Cmd).not.toContain('-test-iterations'); + + // Phase 2 assertions: + // Destination must have uppercase UUID + const p2DestIdx = phase2Cmd!.indexOf('-destination'); + expect(p2DestIdx).not.toBe(-1); + expect(phase2Cmd![p2DestIdx + 1]).toBe( + 'platform=iOS Simulator,id=65FAA1DC-6DC9-456D-A88E-3CF5A516F95B', + ); + + // Phase 2 must have -parallel-testing-enabled NO exactly once + const parallelOccurrences = phase2Cmd!.filter((arg) => arg === '-parallel-testing-enabled'); + expect(parallelOccurrences).toHaveLength(1); + const parallelIdx = phase2Cmd!.indexOf('-parallel-testing-enabled'); + expect(phase2Cmd![parallelIdx + 1]).toBe('NO'); + + // Phase 2 must have -maximum-concurrent-test-simulator-destinations 1 exactly once + const maxDestOccurrences = phase2Cmd!.filter( + (arg) => arg === '-maximum-concurrent-test-simulator-destinations', + ); + expect(maxDestOccurrences).toHaveLength(1); + const maxDestIdx = phase2Cmd!.indexOf('-maximum-concurrent-test-simulator-destinations'); + expect(phase2Cmd![maxDestIdx + 1]).toBe('1'); + + // Phase 2 preserves project/test plan repetitions instead of injecting an invalid single iteration. + expect(phase2Cmd).not.toContain('-test-iterations'); + expect(phase2Cmd).not.toContain('-retry-tests-on-failure'); + expect(phase2Cmd).not.toContain('-run-tests-until-failure'); + + // Flags must not be overridden later in the command + const trailingAfterMaxDest = phase2Cmd!.slice(maxDestIdx + 2); + expect(trailingAfterMaxDest).not.toContain('-parallel-testing-enabled'); + expect(trailingAfterMaxDest).not.toContain('-maximum-concurrent-test-simulator-destinations'); + expect(trailingAfterMaxDest).not.toContain('-destination'); + }); + + it('Supervised source-mode: rejects non-empty caller extraArgs before any side effects', async () => { + const createLogSpy = vi.spyOn(logCaptureModule, 'createLogCapture'); + const createProductsPathSpy = vi.spyOn(testProductsModule, 'createDefaultTestProductsPath'); + const createResultBundlePathSpy = vi.spyOn( + resultBundleModule, + 'createDefaultResultBundlePath', + ); + const mockExecutor: CommandExecutor = vi.fn(); + + const supervision = new CommandSupervision(); + const supervised = supervisedExecutor(mockExecutor); + const execute = createTestExecutor(supervised, { + toolName: 'test_sim', + target: 'simulator', + request: { scheme: 'AppScheme' }, + }); + + const { streamingCtx } = createContext(); + await expect( + supervision.run(() => + execute( + { + ...defaultParams, + extraArgs: ['-quiet'], + }, + streamingCtx, + ), + ), + ).rejects.toThrow( + 'Managed simulator tests only allow -only-testing and -skip-testing selectors in extraArgs', + ); + + expect(mockExecutor).not.toHaveBeenCalled(); + expect(createLogSpy).not.toHaveBeenCalled(); + expect(createProductsPathSpy).not.toHaveBeenCalled(); + expect(createResultBundlePathSpy).not.toHaveBeenCalled(); + }); + + it('Supervised source-mode: rejects destination override in caller extraArgs before any side effects', async () => { + const createLogSpy = vi.spyOn(logCaptureModule, 'createLogCapture'); + const createProductsPathSpy = vi.spyOn(testProductsModule, 'createDefaultTestProductsPath'); + const createResultBundlePathSpy = vi.spyOn( + resultBundleModule, + 'createDefaultResultBundlePath', + ); + const mockExecutor: CommandExecutor = vi.fn(); + + const supervision = new CommandSupervision(); + const supervised = supervisedExecutor(mockExecutor); + const execute = createTestExecutor(supervised, { + toolName: 'test_sim', + target: 'simulator', + request: { scheme: 'AppScheme' }, + }); + + const { streamingCtx } = createContext(); + await expect( + supervision.run(() => + execute( + { + ...defaultParams, + extraArgs: ['-destination', 'platform=macOS'], + }, + streamingCtx, + ), + ), + ).rejects.toThrow( + 'Managed simulator tests only allow -only-testing and -skip-testing selectors in extraArgs', + ); + + expect(mockExecutor).not.toHaveBeenCalled(); + expect(createLogSpy).not.toHaveBeenCalled(); + expect(createProductsPathSpy).not.toHaveBeenCalled(); + expect(createResultBundlePathSpy).not.toHaveBeenCalled(); + }); + + it('Supervised source-mode: rejects non-simulator platform before any side effects', async () => { + const createLogSpy = vi.spyOn(logCaptureModule, 'createLogCapture'); + const createProductsPathSpy = vi.spyOn(testProductsModule, 'createDefaultTestProductsPath'); + const createResultBundlePathSpy = vi.spyOn( + resultBundleModule, + 'createDefaultResultBundlePath', + ); + const mockExecutor: CommandExecutor = vi.fn(); + + const supervision = new CommandSupervision(); + const supervised = supervisedExecutor(mockExecutor); + const execute = createTestExecutor(supervised, { + toolName: 'test_sim', + target: 'simulator', + request: { scheme: 'AppScheme' }, + }); + + const { streamingCtx } = createContext(); + await expect( + supervision.run(() => + execute( + { + ...defaultParams, + platform: XcodePlatform.macOS, + }, + streamingCtx, + ), + ), + ).rejects.toThrow('Supervised simulator test runs require a simulator platform'); + + expect(mockExecutor).not.toHaveBeenCalled(); + expect(createLogSpy).not.toHaveBeenCalled(); + expect(createProductsPathSpy).not.toHaveBeenCalled(); + expect(createResultBundlePathSpy).not.toHaveBeenCalled(); + }); + + it('Supervised source-mode: rejects missing or empty simulatorId before any side effects', async () => { + const createLogSpy = vi.spyOn(logCaptureModule, 'createLogCapture'); + const createProductsPathSpy = vi.spyOn(testProductsModule, 'createDefaultTestProductsPath'); + const createResultBundlePathSpy = vi.spyOn( + resultBundleModule, + 'createDefaultResultBundlePath', + ); + const mockExecutor: CommandExecutor = vi.fn(); + + const supervision = new CommandSupervision(); + const supervised = supervisedExecutor(mockExecutor); + const execute = createTestExecutor(supervised, { + toolName: 'test_sim', + target: 'simulator', + request: { scheme: 'AppScheme' }, + }); + + const { streamingCtx } = createContext(); + await expect( + supervision.run(() => + execute( + { + ...defaultParams, + simulatorId: '', + }, + streamingCtx, + ), + ), + ).rejects.toThrow('Supervised simulator test runs require an explicit simulatorId'); + + expect(mockExecutor).not.toHaveBeenCalled(); + expect(createLogSpy).not.toHaveBeenCalled(); + expect(createProductsPathSpy).not.toHaveBeenCalled(); + expect(createResultBundlePathSpy).not.toHaveBeenCalled(); + }); + + it('Supervised source-mode: rejects mixing simulatorName or deviceId before any side effects', async () => { + const createLogSpy = vi.spyOn(logCaptureModule, 'createLogCapture'); + const createProductsPathSpy = vi.spyOn(testProductsModule, 'createDefaultTestProductsPath'); + const createResultBundlePathSpy = vi.spyOn( + resultBundleModule, + 'createDefaultResultBundlePath', + ); + const mockExecutor: CommandExecutor = vi.fn(); + + const supervision = new CommandSupervision(); + const supervised = supervisedExecutor(mockExecutor); + const execute = createTestExecutor(supervised, { + toolName: 'test_sim', + target: 'simulator', + request: { scheme: 'AppScheme' }, + }); + + const { streamingCtx } = createContext(); + await expect( + supervision.run(() => + execute( + { + ...defaultParams, + simulatorName: 'iPhone 17 Pro', + }, + streamingCtx, + ), + ), + ).rejects.toThrow('Supervised simulator test runs do not allow simulatorName'); + + await expect( + supervision.run(() => + execute( + { + ...defaultParams, + deviceId: '00008110-001234567890abcdef', + }, + streamingCtx, + ), + ), + ).rejects.toThrow('Supervised simulator test runs do not allow deviceId'); + + expect(mockExecutor).not.toHaveBeenCalled(); + expect(createLogSpy).not.toHaveBeenCalled(); + expect(createProductsPathSpy).not.toHaveBeenCalled(); + expect(createResultBundlePathSpy).not.toHaveBeenCalled(); + }); + + it('Supervised source-mode: rejects blank/empty projectPath or workspacePath and enforces exactly one valid source before any side effects', async () => { + const createLogSpy = vi.spyOn(logCaptureModule, 'createLogCapture'); + const createProductsPathSpy = vi.spyOn(testProductsModule, 'createDefaultTestProductsPath'); + const createResultBundlePathSpy = vi.spyOn( + resultBundleModule, + 'createDefaultResultBundlePath', + ); + const mockExecutor: CommandExecutor = vi.fn(); + + const supervision = new CommandSupervision(); + const supervised = supervisedExecutor(mockExecutor); + const execute = createTestExecutor(supervised, { + toolName: 'test_sim', + target: 'simulator', + request: { scheme: 'AppScheme' }, + }); + + const { streamingCtx } = createContext(); + + // Case 1: valid project + blank workspace + await expect( + supervision.run(() => + execute( + { + ...defaultParams, + projectPath: '/tmp/test/App.xcodeproj', + workspacePath: ' ', + }, + streamingCtx, + ), + ), + ).rejects.toThrow('Supervised simulator test runs do not allow blank workspacePath'); + + // Case 2: valid workspace + blank project + await expect( + supervision.run(() => + execute( + { + ...defaultParams, + projectPath: ' ', + workspacePath: '/tmp/test/App.xcworkspace', + }, + streamingCtx, + ), + ), + ).rejects.toThrow('Supervised simulator test runs do not allow blank projectPath'); + + // Case 3: standalone blank project + await expect( + supervision.run(() => + execute( + { + ...defaultParams, + projectPath: ' ', + workspacePath: undefined, + }, + streamingCtx, + ), + ), + ).rejects.toThrow('Supervised simulator test runs do not allow blank projectPath'); + + // Case 4: standalone blank workspace + await expect( + supervision.run(() => + execute( + { + ...defaultParams, + projectPath: undefined, + workspacePath: ' ', + }, + streamingCtx, + ), + ), + ).rejects.toThrow('Supervised simulator test runs do not allow blank workspacePath'); + + // Case 5: neither projectPath nor workspacePath + await expect( + supervision.run(() => + execute( + { + ...defaultParams, + projectPath: undefined, + workspacePath: undefined, + }, + streamingCtx, + ), + ), + ).rejects.toThrow( + 'Supervised simulator test runs require exactly one of projectPath or workspacePath', + ); + + // Case 6: both projectPath and workspacePath (conflict) + await expect( + supervision.run(() => + execute( + { + ...defaultParams, + projectPath: '/tmp/test/App.xcodeproj', + workspacePath: '/tmp/test/App.xcworkspace', + }, + streamingCtx, + ), + ), + ).rejects.toThrow( + 'Supervised simulator test runs require exactly one of projectPath or workspacePath', + ); + + expect(mockExecutor).not.toHaveBeenCalled(); + expect(createLogSpy).not.toHaveBeenCalled(); + expect(createProductsPathSpy).not.toHaveBeenCalled(); + expect(createResultBundlePathSpy).not.toHaveBeenCalled(); + }); + + it('Supervised source-mode: rejects missing scheme before any side effects', async () => { + const createLogSpy = vi.spyOn(logCaptureModule, 'createLogCapture'); + const createProductsPathSpy = vi.spyOn(testProductsModule, 'createDefaultTestProductsPath'); + const createResultBundlePathSpy = vi.spyOn( + resultBundleModule, + 'createDefaultResultBundlePath', + ); + const mockExecutor: CommandExecutor = vi.fn(); + + const supervision = new CommandSupervision(); + const supervised = supervisedExecutor(mockExecutor); + const execute = createTestExecutor(supervised, { + toolName: 'test_sim', + target: 'simulator', + request: { scheme: 'AppScheme' }, + }); + + const { streamingCtx } = createContext(); + await expect( + supervision.run(() => + execute( + { + ...defaultParams, + scheme: '', + }, + streamingCtx, + ), + ), + ).rejects.toThrow('Supervised simulator test runs require a non-empty scheme'); + + expect(mockExecutor).not.toHaveBeenCalled(); + expect(createLogSpy).not.toHaveBeenCalled(); + expect(createProductsPathSpy).not.toHaveBeenCalled(); + expect(createResultBundlePathSpy).not.toHaveBeenCalled(); + }); + + it('Supervised source-mode: supports workspacePath correctly with uppercase UUID and Phase 2 flags', async () => { + const logSpy = setupLogCaptureSpy(); + setupSyncExtractSentinels(); + + const capturedCommands: string[][] = []; + const simctlHandler = createSimctlLifecycleHandler('65faa1dc-6dc9-456d-a88e-3cf5a516f95b'); + const mockExecutor: CommandExecutor = vi.fn(async (command, _prefix, _shell, opts) => { + capturedCommands.push([...command]); + const simctlRes = await simctlHandler(command); + if (simctlRes) { + return simctlRes; + } + if (command[0] === 'xcrun') { + if (command.includes('tests')) { + return mockCommandResponse({ + success: true, + exitCode: 0, + output: JSON.stringify({ testNodes: [] }), + }); + } + if (command.includes('summary')) { + return mockCommandResponse({ + success: true, + exitCode: 0, + output: JSON.stringify({ + totalTestCount: 1, + passedTests: 1, + failedTests: 0, + skippedTests: 0, + }), + }); + } + } + if (command.includes('build-for-testing')) { + opts?.onStdout?.('Build ok\n'); + return mockCommandResponse({ success: true, exitCode: 0, output: 'Build ok' }); + } + if (command.includes('test-without-building')) { + opts?.onStdout?.('Test ok\n'); + return mockCommandResponse({ success: true, exitCode: 0, output: 'Test ok' }); + } + throw new Error(`Unexpected command: ${command.join(' ')}`); + }); + + const supervision = new CommandSupervision(); + const supervised = supervisedExecutor(mockExecutor); + const execute = createTestExecutor(supervised, { + toolName: 'test_sim', + target: 'simulator', + request: { scheme: 'AppScheme' }, + }); + + const { streamingCtx } = createContext(); + const result = await supervision.run(() => + execute( + { + platform: XcodePlatform.iOSSimulator, + workspacePath: '/tmp/test/App.xcworkspace', + scheme: 'AppScheme', + simulatorId: '65faa1dc-6dc9-456d-a88e-3cf5a516f95b', + extraArgs: [], + }, + streamingCtx, + ), + ); + + expect(result.didError).toBe(false); + expect(result.summary.status).toBe('SUCCEEDED'); + expect(logSpy.closeCount).toBe(1); + + const p1 = capturedCommands.find((c) => c.includes('build-for-testing'))!; + expect(p1).toContain('-workspace'); + expect(p1).toContain('/tmp/test/App.xcworkspace'); + expect(p1).not.toContain('-parallel-testing-enabled'); + + const p2 = capturedCommands.find((c) => c.includes('test-without-building'))!; + expect(p2).toContain('-parallel-testing-enabled'); + expect(p2[p2.indexOf('-parallel-testing-enabled') + 1]).toBe('NO'); + expect(p2[p2.indexOf('-destination') + 1]).toBe( + 'platform=iOS Simulator,id=65FAA1DC-6DC9-456D-A88E-3CF5A516F95B', + ); + }); + + it('Ordinary mode: preserves caller extraArgs, does not inject concurrency flags into Phase 2, and uppercases simulator UUID', async () => { + const capturedCommands: string[][] = []; + const mockExecutor: CommandExecutor = vi.fn(async (command, _prefix, _shell, opts) => { + capturedCommands.push([...command]); + if (command.includes('build-for-testing')) { + opts?.onStdout?.('Build ok\n'); + return mockCommandResponse({ success: true, exitCode: 0, output: 'Build ok' }); + } + if (command.includes('test-without-building')) { + opts?.onStdout?.('Test ok\n'); + return mockCommandResponse({ success: true, exitCode: 0, output: 'Test ok' }); + } + throw new Error(`Unexpected command: ${command.join(' ')}`); + }); + + // Ordinary mode (mockExecutor directly, no CommandSupervision) + const execute = createTestExecutor(mockExecutor, { + toolName: 'test_sim', + target: 'simulator', + request: { scheme: 'AppScheme' }, + }); + + const { streamingCtx } = createContext(); + const result = await execute( + { + ...defaultParams, + simulatorId: '65faa1dc-6dc9-456d-a88e-3cf5a516f95b', + extraArgs: ['-parallel-testing-enabled', 'YES'], + }, + streamingCtx, + ); + + expect(result.didError).toBe(false); + const p1 = capturedCommands.find((c) => c.includes('build-for-testing'))!; + const p2 = capturedCommands.find((c) => c.includes('test-without-building'))!; + + // Both phases uppercase simulator UUID + expect(p1[p1.indexOf('-destination') + 1]).toBe( + 'platform=iOS Simulator,id=65FAA1DC-6DC9-456D-A88E-3CF5A516F95B', + ); + expect(p2[p2.indexOf('-destination') + 1]).toBe( + 'platform=iOS Simulator,id=65FAA1DC-6DC9-456D-A88E-3CF5A516F95B', + ); + + // Caller extraArgs preserved, NO forced flags + expect(p2).toContain('-parallel-testing-enabled'); + expect(p2[p2.indexOf('-parallel-testing-enabled') + 1]).toBe('YES'); + expect(p2).not.toContain('-maximum-concurrent-test-simulator-destinations'); + expect(p2).not.toContain('-test-iterations'); + }); + + it('Prepared mode: preserves low-level execution without applying source-only validation, keeping physical device ID unaltered', async () => { + const capturedCommands: string[][] = []; + const mockExecutor: CommandExecutor = vi.fn(async (command, _prefix, _shell, opts) => { + capturedCommands.push([...command]); + if (command[0] === 'xcrun') { + return mockCommandResponse({ success: true, exitCode: 0, output: '{}' }); + } + if (command.includes('test-without-building')) { + opts?.onStdout?.('Prepared test ok\n'); + return mockCommandResponse({ success: true, exitCode: 0, output: 'Prepared test ok' }); + } + throw new Error(`Unexpected command: ${command.join(' ')}`); + }); + + const supervision = new CommandSupervision(); + const supervised = supervisedExecutor(mockExecutor); + const execute = createTestExecutor(supervised, { + toolName: 'test_sim', + target: 'device', + request: { scheme: 'AppScheme' }, + }); + + const { streamingCtx } = createContext(); + // Low-level prepared execution: has testProductsPath, physical deviceId, non-simulator platform, no projectPath/workspacePath/scheme + const result = await supervision.run(() => + execute( + { + platform: XcodePlatform.iOS, + deviceId: '00008110-001234567890abcdef', + testProductsPath: join(tempDir, 'products.xctestproducts'), + }, + streamingCtx, + ), + ); + + expect(result.didError).toBe(false); + const p2 = capturedCommands.find((c) => c.includes('test-without-building'))!; + expect(p2).toBeDefined(); + // Physical deviceId remains lowercase / unaltered + }); + }); + + describe('Stage T4b: Simulator test execution lifecycle and shutdown verification contract', () => { + it('Normal test exit 0: executes Phase 1 -> Phase 2 -> simctl list -> shutdown -> list, marks artifacts, closes log once', async () => { + const logSpy = setupLogCaptureSpy(); + setupSyncExtractSentinels(); + const markProductsSpy = vi.spyOn(testProductsModule, 'markTestProductsPathCompleted'); + const markResultSpy = vi.spyOn(resultBundleModule, 'markResultBundlePathCompleted'); + + const capturedCommands: string[][] = []; + let capturedProductsPath: string | undefined; + let capturedResultBundlePath: string | undefined; + const simctlHandler = createSimctlLifecycleHandler(defaultParams.simulatorId!); + + const mockExecutor: CommandExecutor = vi.fn(async (command, _prefix, _shell, opts) => { + capturedCommands.push([...command]); + const simctlRes = await simctlHandler(command); + if (simctlRes) { + return simctlRes; + } + if (command[0] === 'xcrun') { + if (command.includes('tests')) { + return mockCommandResponse({ + success: true, + exitCode: 0, + output: JSON.stringify({ testNodes: [] }), + }); + } + if (command.includes('summary')) { + return mockCommandResponse({ + success: true, + exitCode: 0, + output: JSON.stringify({ + totalTestCount: 1, + passedTests: 1, + failedTests: 0, + skippedTests: 0, + }), + }); + } + throw new Error(`Unexpected xcrun: ${command.join(' ')}`); + } + if (command[0] !== 'xcodebuild') { + throw new Error(`Unexpected command: ${command.join(' ')}`); + } + const prodIdx = command.indexOf('-testProductsPath'); + const prodPath = command[prodIdx + 1]; + if (prodIdx !== -1 && prodPath) { + capturedProductsPath = prodPath; + await mkdir(prodPath, { recursive: true }); + } + const bundleIdx = command.indexOf('-resultBundlePath'); + const bundlePath = command[bundleIdx + 1]; + if (bundleIdx !== -1 && bundlePath) { + capturedResultBundlePath = bundlePath; + await mkdir(bundlePath, { recursive: true }); + } + + if (command.includes('build-for-testing')) { + opts?.onStdout?.('Build ok\n'); + return mockCommandResponse({ success: true, exitCode: 0, output: 'Build ok' }); + } + if (command.includes('test-without-building')) { + opts?.onStdout?.('Test ok\n'); + return mockCommandResponse({ success: true, exitCode: 0, output: 'Test ok' }); + } + throw new Error(`Unexpected command phase: ${command.join(' ')}`); + }); + + const supervision = new CommandSupervision(); + const supervised = supervisedExecutor(mockExecutor); + const execute = createTestExecutor(supervised, { + toolName: 'test_sim', + target: 'simulator', + request: { scheme: 'AppScheme' }, + }); + + const { streamingCtx } = createContext(); + const result = await supervision.run(() => execute(defaultParams, streamingCtx)); + + // Assertions outside await: + expect(result.didError).toBe(false); + expect(result.summary.status).toBe('SUCCEEDED'); + expect(logSpy.closeCount).toBe(1); + expect(markProductsSpy).toHaveBeenCalled(); + expect(markResultSpy).toHaveBeenCalled(); + + // Check captured commands full sequence: 2 xcodebuild + 2 xcresulttool + 3 simctl = 7 + expect(capturedCommands).toHaveLength(7); + expect(capturedCommands[0]).toContain('build-for-testing'); + expect(capturedCommands[1]).toContain('test-without-building'); + expect(capturedCommands[2]).toContain('tests'); + expect(capturedCommands[3]).toContain('summary'); + expect(capturedCommands[4]).toEqual(['xcrun', 'simctl', 'list', 'devices', '--json']); + expect(capturedCommands[5]).toEqual([ + 'xcrun', + 'simctl', + 'shutdown', + defaultParams.simulatorId!, + ]); + expect(capturedCommands[6]).toEqual(['xcrun', 'simctl', 'list', 'devices', '--json']); + + // Filesystem completion markers + await expect( + access(testProductsModule.getTestProductsCompletionMarkerPath(capturedProductsPath!)), + ).resolves.toBeUndefined(); + await expect( + access(resultBundleModule.getResultBundleCompletionMarkerPath(capturedResultBundlePath!)), + ).resolves.toBeUndefined(); + }); + + it('Test failure exit 65: executes Phase 1 -> Phase 2 (exit 65) -> xcresult metadata -> simctl list -> shutdown -> list, returns domain failure, marks artifacts', async () => { + const logSpy = setupLogCaptureSpy(); + setupSyncExtractSentinels(); + const markProductsSpy = vi.spyOn(testProductsModule, 'markTestProductsPathCompleted'); + const markResultSpy = vi.spyOn(resultBundleModule, 'markResultBundlePathCompleted'); + + const capturedCommands: string[][] = []; + let capturedProductsPath: string | undefined; + let capturedResultBundlePath: string | undefined; + const simctlHandler = createSimctlLifecycleHandler(defaultParams.simulatorId!); + + const mockExecutor: CommandExecutor = vi.fn(async (command, _prefix, _shell, opts) => { + capturedCommands.push([...command]); + const simctlRes = await simctlHandler(command); + if (simctlRes) { + return simctlRes; + } + if (command[0] === 'xcrun') { + if (command.includes('tests')) { + return mockCommandResponse({ + success: true, + exitCode: 0, + output: JSON.stringify({ testNodes: [] }), + }); + } + if (command.includes('summary')) { + return mockCommandResponse({ + success: true, + exitCode: 0, + output: JSON.stringify({ + totalTestCount: 5, + passedTests: 4, + failedTests: 1, + skippedTests: 0, + }), + }); + } + throw new Error(`Unexpected xcrun: ${command.join(' ')}`); + } + if (command[0] !== 'xcodebuild') { + throw new Error(`Unexpected command: ${command.join(' ')}`); + } + const prodIdx = command.indexOf('-testProductsPath'); + const prodPath = command[prodIdx + 1]; + if (prodIdx !== -1 && prodPath) { + capturedProductsPath = prodPath; + await mkdir(prodPath, { recursive: true }); + } + const bundleIdx = command.indexOf('-resultBundlePath'); + const bundlePath = command[bundleIdx + 1]; + if (bundleIdx !== -1 && bundlePath) { + capturedResultBundlePath = bundlePath; + await mkdir(bundlePath, { recursive: true }); + } + + if (command.includes('build-for-testing')) { + opts?.onStdout?.('Build ok\n'); + return mockCommandResponse({ success: true, exitCode: 0, output: 'Build ok' }); + } + if (command.includes('test-without-building')) { + opts?.onStdout?.('Tests failed\n'); + return mockCommandResponse({ success: false, exitCode: 65, output: 'Tests failed' }); + } + throw new Error(`Unexpected command phase: ${command.join(' ')}`); + }); + + const supervision = new CommandSupervision(); + const supervised = supervisedExecutor(mockExecutor); + const execute = createTestExecutor(supervised, { + toolName: 'test_sim', + target: 'simulator', + request: { scheme: 'AppScheme' }, + }); + + const { streamingCtx } = createContext(); + const result = await supervision.run(() => execute(defaultParams, streamingCtx)); + + // Assertions outside await: + expect(result.didError).toBe(true); + expect(result.summary.status).toBe('FAILED'); + expect(logSpy.closeCount).toBe(1); + expect(markProductsSpy).toHaveBeenCalled(); + expect(markResultSpy).toHaveBeenCalled(); + + // Check captured commands full sequence: 2 xcodebuild + 2 xcresulttool + 3 simctl = 7 + expect(capturedCommands).toHaveLength(7); + expect(capturedCommands[0]).toContain('build-for-testing'); + expect(capturedCommands[1]).toContain('test-without-building'); + expect(capturedCommands[2]).toContain('tests'); + expect(capturedCommands[3]).toContain('summary'); + expect(capturedCommands[4]).toEqual(['xcrun', 'simctl', 'list', 'devices', '--json']); + expect(capturedCommands[5]).toEqual([ + 'xcrun', + 'simctl', + 'shutdown', + defaultParams.simulatorId!, + ]); + expect(capturedCommands[6]).toEqual(['xcrun', 'simctl', 'list', 'devices', '--json']); + + // Filesystem completion markers + await expect( + access(testProductsModule.getTestProductsCompletionMarkerPath(capturedProductsPath!)), + ).resolves.toBeUndefined(); + await expect( + access(resultBundleModule.getResultBundleCompletionMarkerPath(capturedResultBundlePath!)), + ).resolves.toBeUndefined(); + }); + + it('Phase 1 signal termination (SIGTERM): immediately throws SimulatorTestUncertainError, closeCount is 1, 0 markers, Phase 2 not called', async () => { + const logSpy = setupLogCaptureSpy(); + const markProductsSpy = vi.spyOn(testProductsModule, 'markTestProductsPathCompleted'); + + const capturedCommands: string[][] = []; + let capturedProductsPath: string | undefined; + + const mockExecutor: CommandExecutor = vi.fn(async (command, _prefix, _shell, opts) => { + capturedCommands.push([...command]); + if (command[0] !== 'xcodebuild') { + throw new Error(`Unexpected command: ${command.join(' ')}`); + } + const prodIdx = command.indexOf('-testProductsPath'); + const prodPath = command[prodIdx + 1]; + if (prodIdx !== -1 && prodPath) { + capturedProductsPath = prodPath; + await mkdir(prodPath, { recursive: true }); + } + + if (command.includes('build-for-testing')) { + opts?.onStdout?.('Signal interrupted\n'); + const res = await mockCommandResponse({ + success: false, + exitCode: 1, + output: 'Interrupted', + }); + Object.assign(res.process, { exitCode: null, signalCode: 'SIGTERM' }); + return res; + } + throw new Error(`Unexpected command phase: ${command.join(' ')}`); + }); + + const supervision = new CommandSupervision(); + const supervised = supervisedExecutor(mockExecutor); + const execute = createTestExecutor(supervised, { + toolName: 'test_sim', + target: 'simulator', + request: { scheme: 'AppScheme' }, + }); + + const { streamingCtx } = createContext(); + let caughtError: unknown; + try { + await supervision.run(() => execute(defaultParams, streamingCtx)); + } catch (err) { + caughtError = err; + } + + // Assertions outside await: + expect(caughtError).toBeInstanceOf(SimulatorTestUncertainError); + expect((caughtError as Error).message).toContain( + 'Managed test execution interrupted by signal: SIGTERM', + ); + expect(logSpy.closeCount).toBe(1); + expect(markProductsSpy).not.toHaveBeenCalled(); + expect(capturedCommands).toHaveLength(1); + expect(capturedCommands[0]).toContain('build-for-testing'); + await expect( + access(testProductsModule.getTestProductsCompletionMarkerPath(capturedProductsPath!)), + ).rejects.toMatchObject({ code: 'ENOENT' }); + }); + + it('Phase 1 signal adversarial (signalCode SIGTERM with success true, exitCode 0): immediately throws SimulatorTestUncertainError, closeCount is 1, 0 markers', async () => { + const logSpy = setupLogCaptureSpy(); + const markProductsSpy = vi.spyOn(testProductsModule, 'markTestProductsPathCompleted'); + + const capturedCommands: string[][] = []; + let capturedProductsPath: string | undefined; + + const mockExecutor: CommandExecutor = vi.fn(async (command, _prefix, _shell, opts) => { + capturedCommands.push([...command]); + if (command[0] !== 'xcodebuild') { + throw new Error(`Unexpected command: ${command.join(' ')}`); + } + const prodIdx = command.indexOf('-testProductsPath'); + const prodPath = command[prodIdx + 1]; + if (prodIdx !== -1 && prodPath) { + capturedProductsPath = prodPath; + await mkdir(prodPath, { recursive: true }); + } + + if (command.includes('build-for-testing')) { + opts?.onStdout?.('Adversarial signal\n'); + const res = await mockCommandResponse({ success: true, exitCode: 0, output: 'ok' }); + Object.assign(res.process, { exitCode: 0, signalCode: 'SIGTERM' }); + return res; + } + throw new Error(`Unexpected command phase: ${command.join(' ')}`); + }); + + const supervision = new CommandSupervision(); + const supervised = supervisedExecutor(mockExecutor); + const execute = createTestExecutor(supervised, { + toolName: 'test_sim', + target: 'simulator', + request: { scheme: 'AppScheme' }, + }); + + const { streamingCtx } = createContext(); + let caughtError: unknown; + try { + await supervision.run(() => execute(defaultParams, streamingCtx)); + } catch (err) { + caughtError = err; + } + + // Assertions outside await: + expect(caughtError).toBeInstanceOf(SimulatorTestUncertainError); + expect((caughtError as Error).message).toContain( + 'Managed test execution interrupted by signal: SIGTERM', + ); + expect(logSpy.closeCount).toBe(1); + expect(markProductsSpy).not.toHaveBeenCalled(); + expect(capturedCommands).toHaveLength(1); + expect(capturedCommands[0]).toContain('build-for-testing'); + await expect( + access(testProductsModule.getTestProductsCompletionMarkerPath(capturedProductsPath!)), + ).rejects.toMatchObject({ code: 'ENOENT' }); + }); + + it('Phase 2 signal termination (SIGKILL): immediately throws SimulatorTestUncertainError, closeCount is 1, 0 markers, metadata and shutdown not called', async () => { + const logSpy = setupLogCaptureSpy(); + const markProductsSpy = vi.spyOn(testProductsModule, 'markTestProductsPathCompleted'); + const markResultSpy = vi.spyOn(resultBundleModule, 'markResultBundlePathCompleted'); + + const capturedCommands: string[][] = []; + let capturedProductsPath: string | undefined; + let capturedResultBundlePath: string | undefined; + + const mockExecutor: CommandExecutor = vi.fn(async (command, _prefix, _shell, opts) => { + capturedCommands.push([...command]); + if (command[0] !== 'xcodebuild') { + throw new Error(`Unexpected command: ${command.join(' ')}`); + } + const prodIdx = command.indexOf('-testProductsPath'); + const prodPath = command[prodIdx + 1]; + if (prodIdx !== -1 && prodPath) { + capturedProductsPath = prodPath; + await mkdir(prodPath, { recursive: true }); + } + const bundleIdx = command.indexOf('-resultBundlePath'); + const bundlePath = command[bundleIdx + 1]; + if (bundleIdx !== -1 && bundlePath) { + capturedResultBundlePath = bundlePath; + await mkdir(bundlePath, { recursive: true }); + } + + if (command.includes('build-for-testing')) { + opts?.onStdout?.('Build ok\n'); + return mockCommandResponse({ success: true, exitCode: 0, output: 'Build ok' }); + } + if (command.includes('test-without-building')) { + opts?.onStdout?.('Phase 2 killed\n'); + const res = await mockCommandResponse({ success: false, exitCode: 1, output: 'Killed' }); + Object.assign(res.process, { exitCode: null, signalCode: 'SIGKILL' }); + return res; + } + throw new Error(`Unexpected command phase: ${command.join(' ')}`); + }); + + const supervision = new CommandSupervision(); + const supervised = supervisedExecutor(mockExecutor); + const execute = createTestExecutor(supervised, { + toolName: 'test_sim', + target: 'simulator', + request: { scheme: 'AppScheme' }, + }); + + const { streamingCtx } = createContext(); + let caughtError: unknown; + try { + await supervision.run(() => execute(defaultParams, streamingCtx)); + } catch (err) { + caughtError = err; + } + + // Assertions outside await: + expect(caughtError).toBeInstanceOf(SimulatorTestUncertainError); + expect((caughtError as Error).message).toContain( + 'Managed test execution interrupted by signal: SIGKILL', + ); + expect(logSpy.closeCount).toBe(1); + expect(markProductsSpy).not.toHaveBeenCalled(); + expect(markResultSpy).not.toHaveBeenCalled(); + expect(capturedCommands).toHaveLength(2); + expect(capturedCommands[0]).toContain('build-for-testing'); + expect(capturedCommands[1]).toContain('test-without-building'); + await expect( + access(testProductsModule.getTestProductsCompletionMarkerPath(capturedProductsPath!)), + ).rejects.toMatchObject({ code: 'ENOENT' }); + await expect( + access(resultBundleModule.getResultBundleCompletionMarkerPath(capturedResultBundlePath!)), + ).rejects.toMatchObject({ code: 'ENOENT' }); + }); + + it('Phase 2 signal adversarial (signalCode SIGKILL with success true, exitCode 0): immediately throws SimulatorTestUncertainError, closeCount is 1, 0 markers', async () => { + const logSpy = setupLogCaptureSpy(); + const markProductsSpy = vi.spyOn(testProductsModule, 'markTestProductsPathCompleted'); + const markResultSpy = vi.spyOn(resultBundleModule, 'markResultBundlePathCompleted'); + + const capturedCommands: string[][] = []; + let capturedProductsPath: string | undefined; + let capturedResultBundlePath: string | undefined; + + const mockExecutor: CommandExecutor = vi.fn(async (command, _prefix, _shell, opts) => { + capturedCommands.push([...command]); + if (command[0] !== 'xcodebuild') { + throw new Error(`Unexpected command: ${command.join(' ')}`); + } + const prodIdx = command.indexOf('-testProductsPath'); + const prodPath = command[prodIdx + 1]; + if (prodIdx !== -1 && prodPath) { + capturedProductsPath = prodPath; + await mkdir(prodPath, { recursive: true }); + } + const bundleIdx = command.indexOf('-resultBundlePath'); + const bundlePath = command[bundleIdx + 1]; + if (bundleIdx !== -1 && bundlePath) { + capturedResultBundlePath = bundlePath; + await mkdir(bundlePath, { recursive: true }); + } + + if (command.includes('build-for-testing')) { + opts?.onStdout?.('Build ok\n'); + return mockCommandResponse({ success: true, exitCode: 0, output: 'Build ok' }); + } + if (command.includes('test-without-building')) { + opts?.onStdout?.('Adversarial ok\n'); + const res = await mockCommandResponse({ success: true, exitCode: 0, output: 'ok' }); + Object.assign(res.process, { exitCode: 0, signalCode: 'SIGKILL' }); + return res; + } + throw new Error(`Unexpected command phase: ${command.join(' ')}`); + }); + + const supervision = new CommandSupervision(); + const supervised = supervisedExecutor(mockExecutor); + const execute = createTestExecutor(supervised, { + toolName: 'test_sim', + target: 'simulator', + request: { scheme: 'AppScheme' }, + }); + + const { streamingCtx } = createContext(); + let caughtError: unknown; + try { + await supervision.run(() => execute(defaultParams, streamingCtx)); + } catch (err) { + caughtError = err; + } + + // Assertions outside await: + expect(caughtError).toBeInstanceOf(SimulatorTestUncertainError); + expect((caughtError as Error).message).toContain( + 'Managed test execution interrupted by signal: SIGKILL', + ); + expect(logSpy.closeCount).toBe(1); + expect(markProductsSpy).not.toHaveBeenCalled(); + expect(markResultSpy).not.toHaveBeenCalled(); + expect(capturedCommands).toHaveLength(2); + await expect( + access(testProductsModule.getTestProductsCompletionMarkerPath(capturedProductsPath!)), + ).rejects.toMatchObject({ code: 'ENOENT' }); + await expect( + access(resultBundleModule.getResultBundleCompletionMarkerPath(capturedResultBundlePath!)), + ).rejects.toMatchObject({ code: 'ENOENT' }); + }); + + it('Inconsistent exit codes (response.exitCode !== process.exitCode): throws SimulatorTestUncertainError', async () => { + const logSpy = setupLogCaptureSpy(); + const capturedCommands: string[][] = []; + + const mockExecutor: CommandExecutor = vi.fn(async (command, _prefix, _shell, opts) => { + capturedCommands.push([...command]); + if (command.includes('build-for-testing')) { + opts?.onStdout?.('Build ok\n'); + const res = await mockCommandResponse({ success: true, exitCode: 0, output: 'Build ok' }); + Object.assign(res.process, { exitCode: 1 }); + return res; + } + throw new Error(`Unexpected command: ${command.join(' ')}`); + }); + + const supervision = new CommandSupervision(); + const supervised = supervisedExecutor(mockExecutor); + const execute = createTestExecutor(supervised, { + toolName: 'test_sim', + target: 'simulator', + request: { scheme: 'AppScheme' }, + }); + + const { streamingCtx } = createContext(); + let caughtError: unknown; + try { + await supervision.run(() => execute(defaultParams, streamingCtx)); + } catch (err) { + caughtError = err; + } + + // Assertions outside await: + expect(caughtError).toBeInstanceOf(SimulatorTestUncertainError); + expect((caughtError as Error).message).toContain( + 'Inconsistent exit codes: response.exitCode=0, process.exitCode=1', + ); + expect(logSpy.closeCount).toBe(1); + expect(capturedCommands).toHaveLength(1); + }); + + it('Inconsistent success state (response.success true but exitCode 65): throws SimulatorTestUncertainError', async () => { + const logSpy = setupLogCaptureSpy(); + const capturedCommands: string[][] = []; + + const mockExecutor: CommandExecutor = vi.fn(async (command, _prefix, _shell, opts) => { + capturedCommands.push([...command]); + if (command.includes('build-for-testing')) { + opts?.onStdout?.('Build failed\n'); + const res = await mockCommandResponse({ + success: true, + exitCode: 65, + output: 'Build failed', + }); + Object.assign(res.process, { exitCode: 65 }); + return res; + } + throw new Error(`Unexpected command: ${command.join(' ')}`); + }); + + const supervision = new CommandSupervision(); + const supervised = supervisedExecutor(mockExecutor); + const execute = createTestExecutor(supervised, { + toolName: 'test_sim', + target: 'simulator', + request: { scheme: 'AppScheme' }, + }); + + const { streamingCtx } = createContext(); + let caughtError: unknown; + try { + await supervision.run(() => execute(defaultParams, streamingCtx)); + } catch (err) { + caughtError = err; + } + + // Assertions outside await: + expect(caughtError).toBeInstanceOf(SimulatorTestUncertainError); + expect((caughtError as Error).message).toContain( + 'Inconsistent success state: response.success=true, exitCode=65', + ); + expect(logSpy.closeCount).toBe(1); + expect(capturedCommands).toHaveLength(1); + }); + + it.each([1, 64, 66] as const)( + 'Confirmed numeric nonzero exit (exit %i on xcodebuild Phase 1): returns domain failure', + async (exitCode) => { + const logSpy = setupLogCaptureSpy(); + const markProductsSpy = vi.spyOn(testProductsModule, 'markTestProductsPathCompleted'); + const capturedCommands: string[][] = []; + + const mockExecutor: CommandExecutor = vi.fn(async (command, _prefix, _shell, opts) => { + capturedCommands.push([...command]); + if (command.includes('build-for-testing')) { + opts?.onStdout?.('Known user failure\n'); + return mockCommandResponse({ + success: false, + exitCode, + output: 'Known user failure', + }); + } + throw new Error(`Unexpected command: ${command.join(' ')}`); + }); + + const supervision = new CommandSupervision(); + const supervised = supervisedExecutor(mockExecutor); + const execute = createTestExecutor(supervised, { + toolName: 'test_sim', + target: 'simulator', + request: { scheme: 'AppScheme' }, + }); + + const { streamingCtx } = createContext(); + const result = await supervision.run(() => execute(defaultParams, streamingCtx)); + + expect(result.didError).toBe(true); + expect(result.summary.status).toBe('FAILED'); + expect(markProductsSpy).toHaveBeenCalled(); + expect(logSpy.closeCount).toBe(1); + expect(capturedCommands).toHaveLength(1); + }, + ); + + it('Unexpected exit code (exit 1 on xcodebuild Phase 2): throws SimulatorTestUncertainError, does not call shutdown', async () => { + const logSpy = setupLogCaptureSpy(); + const capturedCommands: string[][] = []; + + const mockExecutor: CommandExecutor = vi.fn(async (command, _prefix, _shell, opts) => { + capturedCommands.push([...command]); + if (command.includes('build-for-testing')) { + opts?.onStdout?.('Build ok\n'); + return mockCommandResponse({ success: true, exitCode: 0, output: 'Build ok' }); + } + if (command.includes('test-without-building')) { + opts?.onStdout?.('Unknown test runner crash\n'); + return mockCommandResponse({ success: false, exitCode: 1, output: 'Crash' }); + } + throw new Error(`Unexpected command: ${command.join(' ')}`); + }); + + const supervision = new CommandSupervision(); + const supervised = supervisedExecutor(mockExecutor); + const execute = createTestExecutor(supervised, { + toolName: 'test_sim', + target: 'simulator', + request: { scheme: 'AppScheme' }, + }); + + const { streamingCtx } = createContext(); + let caughtError: unknown; + try { + await supervision.run(() => execute(defaultParams, streamingCtx)); + } catch (err) { + caughtError = err; + } + + // Assertions outside await: + expect(caughtError).toBeInstanceOf(SimulatorTestUncertainError); + expect((caughtError as Error).message).toContain('Unexpected xcodebuild test exit code: 1'); + expect(logSpy.closeCount).toBe(1); + expect(capturedCommands).toHaveLength(2); + expect(capturedCommands.some((c) => c.includes('simctl'))).toBe(false); + }); + + it('Shutdown initial catalog failure: missing simulator UUID throws SimulatorTestUncertainError, 0 markers', async () => { + const logSpy = setupLogCaptureSpy(); + setupSyncExtractSentinels(); + const markProductsSpy = vi.spyOn(testProductsModule, 'markTestProductsPathCompleted'); + const markResultSpy = vi.spyOn(resultBundleModule, 'markResultBundlePathCompleted'); + + const capturedCommands: string[][] = []; + let capturedProductsPath: string | undefined; + let capturedResultBundlePath: string | undefined; + + const mockExecutor: CommandExecutor = vi.fn(async (command, _prefix, _shell, opts) => { + capturedCommands.push([...command]); + if (command[0] === 'xcrun') { + if (command[1] === 'simctl') { + // Return empty catalog missing the simulator UUID + return mockCommandResponse({ + success: true, + exitCode: 0, + output: makeCatalogJson([]), + }); + } + if (command.includes('tests')) { + return mockCommandResponse({ + success: true, + exitCode: 0, + output: JSON.stringify({ testNodes: [] }), + }); + } + if (command.includes('summary')) { + return mockCommandResponse({ + success: true, + exitCode: 0, + output: JSON.stringify({ + totalTestCount: 1, + passedTests: 1, + failedTests: 0, + skippedTests: 0, + }), + }); + } + throw new Error(`Unexpected xcrun: ${command.join(' ')}`); + } + if (command[0] !== 'xcodebuild') { + throw new Error(`Unexpected command: ${command.join(' ')}`); + } + const prodIdx = command.indexOf('-testProductsPath'); + const prodPath = command[prodIdx + 1]; + if (prodIdx !== -1 && prodPath) { + capturedProductsPath = prodPath; + await mkdir(prodPath, { recursive: true }); + } + const bundleIdx = command.indexOf('-resultBundlePath'); + const bundlePath = command[bundleIdx + 1]; + if (bundleIdx !== -1 && bundlePath) { + capturedResultBundlePath = bundlePath; + await mkdir(bundlePath, { recursive: true }); + } + + if (command.includes('build-for-testing')) { + opts?.onStdout?.('Build ok\n'); + return mockCommandResponse({ success: true, exitCode: 0, output: 'Build ok' }); + } + if (command.includes('test-without-building')) { + opts?.onStdout?.('Test ok\n'); + return mockCommandResponse({ success: true, exitCode: 0, output: 'Test ok' }); + } + throw new Error(`Unexpected command phase: ${command.join(' ')}`); + }); + + const supervision = new CommandSupervision(); + const supervised = supervisedExecutor(mockExecutor); + const execute = createTestExecutor(supervised, { + toolName: 'test_sim', + target: 'simulator', + request: { scheme: 'AppScheme' }, + }); + + const { streamingCtx } = createContext(); + let caughtError: unknown; + try { + await supervision.run(() => execute(defaultParams, streamingCtx)); + } catch (err) { + caughtError = err; + } + + // Assertions outside await: + expect(caughtError).toBeInstanceOf(SimulatorTestUncertainError); + expect((caughtError as Error).message).toContain('missing from device catalog'); + expect(logSpy.closeCount).toBe(1); + expect(markProductsSpy).not.toHaveBeenCalled(); + expect(markResultSpy).not.toHaveBeenCalled(); + // Should stop after initial simctl list: 2 xcodebuild + 2 xcresult + 1 simctl list = 5 + expect(capturedCommands).toHaveLength(5); + expect(capturedCommands[4]).toEqual(['xcrun', 'simctl', 'list', 'devices', '--json']); + await expect( + access(testProductsModule.getTestProductsCompletionMarkerPath(capturedProductsPath!)), + ).rejects.toMatchObject({ code: 'ENOENT' }); + await expect( + access(resultBundleModule.getResultBundleCompletionMarkerPath(capturedResultBundlePath!)), + ).rejects.toMatchObject({ code: 'ENOENT' }); + }); + + it('Shutdown command failure (exit 1 on simctl shutdown): throws SimulatorTestUncertainError, 0 markers', async () => { + const logSpy = setupLogCaptureSpy(); + setupSyncExtractSentinels(); + const markProductsSpy = vi.spyOn(testProductsModule, 'markTestProductsPathCompleted'); + const markResultSpy = vi.spyOn(resultBundleModule, 'markResultBundlePathCompleted'); + + const capturedCommands: string[][] = []; + let capturedProductsPath: string | undefined; + let capturedResultBundlePath: string | undefined; + + const mockExecutor: CommandExecutor = vi.fn(async (command, _prefix, _shell, opts) => { + capturedCommands.push([...command]); + if (command[0] === 'xcrun') { + if (command[1] === 'simctl') { + if (command[2] === 'list') { + return mockCommandResponse({ + success: true, + exitCode: 0, + output: makeCatalogJson([ + { udid: defaultParams.simulatorId!, isAvailable: true, state: 'Booted' }, + ]), + }); + } + if (command[2] === 'shutdown') { + return mockCommandResponse({ + success: false, + exitCode: 1, + output: 'Shutdown failed: timeout', + }); + } + } + if (command.includes('tests')) { + return mockCommandResponse({ + success: true, + exitCode: 0, + output: JSON.stringify({ testNodes: [] }), + }); + } + if (command.includes('summary')) { + return mockCommandResponse({ + success: true, + exitCode: 0, + output: JSON.stringify({ + totalTestCount: 1, + passedTests: 1, + failedTests: 0, + skippedTests: 0, + }), + }); + } + throw new Error(`Unexpected xcrun: ${command.join(' ')}`); + } + if (command[0] !== 'xcodebuild') { + throw new Error(`Unexpected command: ${command.join(' ')}`); + } + const prodIdx = command.indexOf('-testProductsPath'); + const prodPath = command[prodIdx + 1]; + if (prodIdx !== -1 && prodPath) { + capturedProductsPath = prodPath; + await mkdir(prodPath, { recursive: true }); + } + const bundleIdx = command.indexOf('-resultBundlePath'); + const bundlePath = command[bundleIdx + 1]; + if (bundleIdx !== -1 && bundlePath) { + capturedResultBundlePath = bundlePath; + await mkdir(bundlePath, { recursive: true }); + } + + if (command.includes('build-for-testing')) { + opts?.onStdout?.('Build ok\n'); + return mockCommandResponse({ success: true, exitCode: 0, output: 'Build ok' }); + } + if (command.includes('test-without-building')) { + opts?.onStdout?.('Test ok\n'); + return mockCommandResponse({ success: true, exitCode: 0, output: 'Test ok' }); + } + throw new Error(`Unexpected command phase: ${command.join(' ')}`); + }); + + const supervision = new CommandSupervision(); + const supervised = supervisedExecutor(mockExecutor); + const execute = createTestExecutor(supervised, { + toolName: 'test_sim', + target: 'simulator', + request: { scheme: 'AppScheme' }, + }); + + const { streamingCtx } = createContext(); + let caughtError: unknown; + try { + await supervision.run(() => execute(defaultParams, streamingCtx)); + } catch (err) { + caughtError = err; + } + + // Assertions outside await: + expect(caughtError).toBeInstanceOf(SimulatorTestUncertainError); + expect((caughtError as Error).message).toContain( + 'Managed simulator command execution failed', + ); + expect(logSpy.closeCount).toBe(1); + expect(markProductsSpy).not.toHaveBeenCalled(); + expect(markResultSpy).not.toHaveBeenCalled(); + // Stops after shutdown, no second list: 2 xcodebuild + 2 xcresult + list + shutdown = 6 + expect(capturedCommands).toHaveLength(6); + expect(capturedCommands[4]).toEqual(['xcrun', 'simctl', 'list', 'devices', '--json']); + expect(capturedCommands[5]).toEqual([ + 'xcrun', + 'simctl', + 'shutdown', + defaultParams.simulatorId!, + ]); + await expect( + access(testProductsModule.getTestProductsCompletionMarkerPath(capturedProductsPath!)), + ).rejects.toMatchObject({ code: 'ENOENT' }); + await expect( + access(resultBundleModule.getResultBundleCompletionMarkerPath(capturedResultBundlePath!)), + ).rejects.toMatchObject({ code: 'ENOENT' }); + }); + + it('Shutdown post-catalog verification failure (device remains Booted): throws SimulatorTestUncertainError, 0 markers', async () => { + const logSpy = setupLogCaptureSpy(); + setupSyncExtractSentinels(); + const markProductsSpy = vi.spyOn(testProductsModule, 'markTestProductsPathCompleted'); + const markResultSpy = vi.spyOn(resultBundleModule, 'markResultBundlePathCompleted'); + + const capturedCommands: string[][] = []; + let capturedProductsPath: string | undefined; + let capturedResultBundlePath: string | undefined; + + const mockExecutor: CommandExecutor = vi.fn(async (command, _prefix, _shell, opts) => { + capturedCommands.push([...command]); + if (command[0] === 'xcrun') { + if (command[1] === 'simctl') { + if (command[2] === 'list') { + // Always return Booted, meaning shutdown did not change state + return mockCommandResponse({ + success: true, + exitCode: 0, + output: makeCatalogJson([ + { udid: defaultParams.simulatorId!, isAvailable: true, state: 'Booted' }, + ]), + }); + } + if (command[2] === 'shutdown') { + return mockCommandResponse({ success: true, exitCode: 0, output: '' }); + } + } + if (command.includes('tests')) { + return mockCommandResponse({ + success: true, + exitCode: 0, + output: JSON.stringify({ testNodes: [] }), + }); + } + if (command.includes('summary')) { + return mockCommandResponse({ + success: true, + exitCode: 0, + output: JSON.stringify({ + totalTestCount: 1, + passedTests: 1, + failedTests: 0, + skippedTests: 0, + }), + }); + } + throw new Error(`Unexpected xcrun: ${command.join(' ')}`); + } + if (command[0] !== 'xcodebuild') { + throw new Error(`Unexpected command: ${command.join(' ')}`); + } + const prodIdx = command.indexOf('-testProductsPath'); + const prodPath = command[prodIdx + 1]; + if (prodIdx !== -1 && prodPath) { + capturedProductsPath = prodPath; + await mkdir(prodPath, { recursive: true }); + } + const bundleIdx = command.indexOf('-resultBundlePath'); + const bundlePath = command[bundleIdx + 1]; + if (bundleIdx !== -1 && bundlePath) { + capturedResultBundlePath = bundlePath; + await mkdir(bundlePath, { recursive: true }); + } + + if (command.includes('build-for-testing')) { + opts?.onStdout?.('Build ok\n'); + return mockCommandResponse({ success: true, exitCode: 0, output: 'Build ok' }); + } + if (command.includes('test-without-building')) { + opts?.onStdout?.('Test ok\n'); + return mockCommandResponse({ success: true, exitCode: 0, output: 'Test ok' }); + } + throw new Error(`Unexpected command phase: ${command.join(' ')}`); + }); + + const supervision = new CommandSupervision(); + const supervised = supervisedExecutor(mockExecutor); + const execute = createTestExecutor(supervised, { + toolName: 'test_sim', + target: 'simulator', + request: { scheme: 'AppScheme' }, + }); + + const { streamingCtx } = createContext(); + let caughtError: unknown; + try { + await supervision.run(() => execute(defaultParams, streamingCtx)); + } catch (err) { + caughtError = err; + } + + // Assertions outside await: + expect(caughtError).toBeInstanceOf(SimulatorTestUncertainError); + expect((caughtError as Error).message).toContain( + 'Simulator failed to reach Shutdown state: Booted', + ); + expect(logSpy.closeCount).toBe(1); + expect(markProductsSpy).not.toHaveBeenCalled(); + expect(markResultSpy).not.toHaveBeenCalled(); + // Runs list -> shutdown -> list: 2 xcodebuild + 2 xcresult + 3 simctl = 7 + expect(capturedCommands).toHaveLength(7); + await expect( + access(testProductsModule.getTestProductsCompletionMarkerPath(capturedProductsPath!)), + ).rejects.toMatchObject({ code: 'ENOENT' }); + await expect( + access(resultBundleModule.getResultBundleCompletionMarkerPath(capturedResultBundlePath!)), + ).rejects.toMatchObject({ code: 'ENOENT' }); + }); + + it('Shutdown already in Shutdown state: verifies Shutdown and returns without issuing shutdown command', async () => { + const logSpy = setupLogCaptureSpy(); + setupSyncExtractSentinels(); + const markProductsSpy = vi.spyOn(testProductsModule, 'markTestProductsPathCompleted'); + const markResultSpy = vi.spyOn(resultBundleModule, 'markResultBundlePathCompleted'); + + const capturedCommands: string[][] = []; + let capturedProductsPath: string | undefined; + let capturedResultBundlePath: string | undefined; + + const mockExecutor: CommandExecutor = vi.fn(async (command, _prefix, _shell, opts) => { + capturedCommands.push([...command]); + if (command[0] === 'xcrun') { + if (command[1] === 'simctl') { + if (command[2] === 'list') { + // Initially already Shutdown + return mockCommandResponse({ + success: true, + exitCode: 0, + output: makeCatalogJson([ + { udid: defaultParams.simulatorId!, isAvailable: true, state: 'Shutdown' }, + ]), + }); + } + throw new Error(`Unexpected simctl command: ${command.join(' ')}`); + } + if (command.includes('tests')) { + return mockCommandResponse({ + success: true, + exitCode: 0, + output: JSON.stringify({ testNodes: [] }), + }); + } + if (command.includes('summary')) { + return mockCommandResponse({ + success: true, + exitCode: 0, + output: JSON.stringify({ + totalTestCount: 1, + passedTests: 1, + failedTests: 0, + skippedTests: 0, + }), + }); + } + throw new Error(`Unexpected xcrun: ${command.join(' ')}`); + } + if (command[0] !== 'xcodebuild') { + throw new Error(`Unexpected command: ${command.join(' ')}`); + } + const prodIdx = command.indexOf('-testProductsPath'); + const prodPath = command[prodIdx + 1]; + if (prodIdx !== -1 && prodPath) { + capturedProductsPath = prodPath; + await mkdir(prodPath, { recursive: true }); + } + const bundleIdx = command.indexOf('-resultBundlePath'); + const bundlePath = command[bundleIdx + 1]; + if (bundleIdx !== -1 && bundlePath) { + capturedResultBundlePath = bundlePath; + await mkdir(bundlePath, { recursive: true }); + } + + if (command.includes('build-for-testing')) { + opts?.onStdout?.('Build ok\n'); + return mockCommandResponse({ success: true, exitCode: 0, output: 'Build ok' }); + } + if (command.includes('test-without-building')) { + opts?.onStdout?.('Test ok\n'); + return mockCommandResponse({ success: true, exitCode: 0, output: 'Test ok' }); + } + throw new Error(`Unexpected command phase: ${command.join(' ')}`); + }); + + const supervision = new CommandSupervision(); + const supervised = supervisedExecutor(mockExecutor); + const execute = createTestExecutor(supervised, { + toolName: 'test_sim', + target: 'simulator', + request: { scheme: 'AppScheme' }, + }); + + const { streamingCtx } = createContext(); + const result = await supervision.run(() => execute(defaultParams, streamingCtx)); + + // Assertions outside await: + expect(result.didError).toBe(false); + expect(logSpy.closeCount).toBe(1); + expect(markProductsSpy).toHaveBeenCalled(); + expect(markResultSpy).toHaveBeenCalled(); + // Only one simctl list command, NO shutdown command: 2 xcodebuild + 2 xcresult + 1 simctl list = 5 + expect(capturedCommands).toHaveLength(5); + expect(capturedCommands[4]).toEqual(['xcrun', 'simctl', 'list', 'devices', '--json']); + expect(capturedCommands.some((c) => c.includes('shutdown'))).toBe(false); + await expect( + access(testProductsModule.getTestProductsCompletionMarkerPath(capturedProductsPath!)), + ).resolves.toBeUndefined(); + await expect( + access(resultBundleModule.getResultBundleCompletionMarkerPath(capturedResultBundlePath!)), + ).resolves.toBeUndefined(); + }); + + it('Metadata uncertain error (reject) stops execution before shutdown: 0 simctl commands, 0 markers', async () => { + const logSpy = setupLogCaptureSpy(); + setupSyncExtractSentinels(); + const markProductsSpy = vi.spyOn(testProductsModule, 'markTestProductsPathCompleted'); + const markResultSpy = vi.spyOn(resultBundleModule, 'markResultBundlePathCompleted'); + + const capturedCommands: string[][] = []; + let capturedProductsPath: string | undefined; + let capturedResultBundlePath: string | undefined; + + const mockExecutor: CommandExecutor = vi.fn(async (command, _prefix, _shell, opts) => { + capturedCommands.push([...command]); + if (command[0] === 'xcrun') { + if (command.includes('tests')) { + throw new Error('Metadata query rejected'); + } + throw new Error(`Unexpected xcrun: ${command.join(' ')}`); + } + if (command[0] !== 'xcodebuild') { + throw new Error(`Unexpected command: ${command.join(' ')}`); + } + const prodIdx = command.indexOf('-testProductsPath'); + const prodPath = command[prodIdx + 1]; + if (prodIdx !== -1 && prodPath) { + capturedProductsPath = prodPath; + await mkdir(prodPath, { recursive: true }); + } + const bundleIdx = command.indexOf('-resultBundlePath'); + const bundlePath = command[bundleIdx + 1]; + if (bundleIdx !== -1 && bundlePath) { + capturedResultBundlePath = bundlePath; + await mkdir(bundlePath, { recursive: true }); + } + + if (command.includes('build-for-testing')) { + opts?.onStdout?.('Build ok\n'); + return mockCommandResponse({ success: true, exitCode: 0, output: 'Build ok' }); + } + if (command.includes('test-without-building')) { + opts?.onStdout?.('Test ok\n'); + return mockCommandResponse({ success: true, exitCode: 0, output: 'Test ok' }); + } + throw new Error(`Unexpected command phase: ${command.join(' ')}`); + }); + + const supervision = new CommandSupervision(); + const supervised = supervisedExecutor(mockExecutor); + const execute = createTestExecutor(supervised, { + toolName: 'test_sim', + target: 'simulator', + request: { scheme: 'AppScheme' }, + }); + + const { streamingCtx } = createContext(); + let caughtError: unknown; + try { + await supervision.run(() => execute(defaultParams, streamingCtx)); + } catch (err) { + caughtError = err; + } + + // Assertions outside await: + expect((caughtError as Error).message).toContain('Metadata query rejected'); + expect(logSpy.closeCount).toBe(1); + expect(markProductsSpy).not.toHaveBeenCalled(); + expect(markResultSpy).not.toHaveBeenCalled(); + // NO simctl commands executed at all + expect(capturedCommands.some((c) => c.includes('simctl'))).toBe(false); + await expect( + access(testProductsModule.getTestProductsCompletionMarkerPath(capturedProductsPath!)), + ).rejects.toMatchObject({ code: 'ENOENT' }); + await expect( + access(resultBundleModule.getResultBundleCompletionMarkerPath(capturedResultBundlePath!)), + ).rejects.toMatchObject({ code: 'ENOENT' }); + }); + }); +}); diff --git a/src/resource-management/__tests__/test-shutdown.test.ts b/src/resource-management/__tests__/test-shutdown.test.ts new file mode 100644 index 000000000..77c164ac5 --- /dev/null +++ b/src/resource-management/__tests__/test-shutdown.test.ts @@ -0,0 +1,994 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import { PassThrough } from 'node:stream'; +import { CommandSupervision, supervisedExecutor } from '../execution.ts'; +import { + shutdownAndVerifyManagedSimulator, + SimulatorTestUncertainError, +} from '../test-execution.ts'; +import { createMockExecutor, type CommandExecutor } from '../../test-utils/mock-executors.ts'; + +const VALID_SIMULATOR_ID = '65faa1dc-6dc9-456d-a88e-3cf5a516f95b'; + +const activeStreams: PassThrough[] = []; + +function createTrackedPassThrough(): PassThrough { + const stream = new PassThrough(); + activeStreams.push(stream); + return stream; +} + +afterEach(() => { + for (const stream of activeStreams) { + if (!stream.destroyed) { + stream.destroy(); + } + } + activeStreams.length = 0; +}); + +function makeCatalogJson( + devices: + | Array<{ udid: string; isAvailable: boolean; state: string }> + | Record>, +): string { + if (Array.isArray(devices)) { + return JSON.stringify({ + devices: { + 'com.apple.CoreSimulator.SimRuntime.iOS-18-0': devices, + }, + }); + } + return JSON.stringify({ devices }); +} + +interface MockStep { + expectedArgs: string[]; + mockResult?: { + success?: boolean; + output?: string; + error?: string; + exitCode?: number; + shouldThrow?: Error; + }; + override?: { + exitCode?: number; + processExitCode?: number | null; + signalCode?: NodeJS.Signals | null; + stdout?: PassThrough | null; + stderr?: PassThrough | null; + }; +} + +function createStrictQueueExecutor(steps: MockStep[]): { + executor: CommandExecutor; + executedCommands: string[][]; +} { + const executedCommands: string[][] = []; + const queue = [...steps]; + + const executor: CommandExecutor = async (command, logPrefix, useShell, opts, detached) => { + executedCommands.push([...command]); + const step = queue.shift(); + if (!step) { + throw new Error(`Unexpected command call (queue empty): ${command.join(' ')}`); + } + + if ( + command.length !== step.expectedArgs.length || + command.some((val, idx) => val !== step.expectedArgs[idx]) + ) { + throw new Error( + `Command mismatch: expected [${step.expectedArgs.join(' ')}] but got [${command.join(' ')}]`, + ); + } + + const delegate = createMockExecutor(step.mockResult ?? { success: true }); + const response = await delegate(command, logPrefix, useShell, opts, detached); + + if (step.override?.exitCode !== undefined) { + response.exitCode = step.override.exitCode; + } + if (step.override?.processExitCode !== undefined) { + (response.process as { exitCode: number | null }).exitCode = step.override.processExitCode; + } + if (step.override?.signalCode !== undefined) { + (response.process as { signalCode: NodeJS.Signals | null }).signalCode = + step.override.signalCode; + } + if (step.override?.stdout !== undefined) { + (response.process as { stdout: unknown }).stdout = step.override.stdout; + } + if (step.override?.stderr !== undefined) { + (response.process as { stderr: unknown }).stderr = step.override.stderr; + } + + return response; + }; + + return { executor, executedCommands }; +} + +describe('shutdownAndVerifyManagedSimulator', () => { + describe('pre-condition checks', () => { + it('fails loudly when isCommandSupervised() is false, executing 0 commands', async () => { + const { executor, executedCommands } = createStrictQueueExecutor([]); + + await expect(shutdownAndVerifyManagedSimulator(VALID_SIMULATOR_ID, executor)).rejects.toThrow( + SimulatorTestUncertainError, + ); + + await expect(shutdownAndVerifyManagedSimulator(VALID_SIMULATOR_ID, executor)).rejects.toThrow( + /supervised context/i, + ); + + expect(executedCommands).toEqual([]); + }); + + it('fails loudly on invalid simulatorId UUID without calling executor', async () => { + const supervision = new CommandSupervision(); + const { executor, executedCommands } = createStrictQueueExecutor([]); + const supervised = supervisedExecutor(executor); + + await supervision.run(async () => { + await expect( + shutdownAndVerifyManagedSimulator('not-a-valid-uuid', supervised), + ).rejects.toThrow(SimulatorTestUncertainError); + }); + + expect(executedCommands).toEqual([]); + }); + }); + + describe('nominal lifecycle flows', () => { + it('when simulator is already Shutdown: executes exactly 1 list command and does not call shutdown', async () => { + const supervision = new CommandSupervision(); + const { executor, executedCommands } = createStrictQueueExecutor([ + { + expectedArgs: ['xcrun', 'simctl', 'list', 'devices', '--json'], + mockResult: { + output: makeCatalogJson([ + { + udid: VALID_SIMULATOR_ID, + isAvailable: true, + state: 'Shutdown', + }, + ]), + }, + }, + ]); + const supervised = supervisedExecutor(executor); + + await supervision.run(async () => { + await shutdownAndVerifyManagedSimulator(VALID_SIMULATOR_ID, supervised); + }); + + expect(executedCommands).toEqual([['xcrun', 'simctl', 'list', 'devices', '--json']]); + expect(supervision.quiescent).toBe(true); + }); + + it('when simulator is Booted: executes list, shutdown with caller UUID, and list verification', async () => { + const supervision = new CommandSupervision(); + const { executor, executedCommands } = createStrictQueueExecutor([ + { + expectedArgs: ['xcrun', 'simctl', 'list', 'devices', '--json'], + mockResult: { + output: makeCatalogJson([ + { udid: VALID_SIMULATOR_ID, isAvailable: true, state: 'Booted' }, + ]), + }, + }, + { + expectedArgs: ['xcrun', 'simctl', 'shutdown', VALID_SIMULATOR_ID], + mockResult: { + output: '', + }, + }, + { + expectedArgs: ['xcrun', 'simctl', 'list', 'devices', '--json'], + mockResult: { + output: makeCatalogJson([ + { + udid: VALID_SIMULATOR_ID, + isAvailable: true, + state: 'Shutdown', + }, + ]), + }, + }, + ]); + const supervised = supervisedExecutor(executor); + + await supervision.run(async () => { + await shutdownAndVerifyManagedSimulator(VALID_SIMULATOR_ID, supervised); + }); + + expect(executedCommands).toEqual([ + ['xcrun', 'simctl', 'list', 'devices', '--json'], + ['xcrun', 'simctl', 'shutdown', VALID_SIMULATOR_ID], + ['xcrun', 'simctl', 'list', 'devices', '--json'], + ]); + expect(supervision.quiescent).toBe(true); + }); + + it('matches UUID case-insensitively when initial catalog has uppercase UUID and state is Shutdown', async () => { + const supervision = new CommandSupervision(); + const { executor, executedCommands } = createStrictQueueExecutor([ + { + expectedArgs: ['xcrun', 'simctl', 'list', 'devices', '--json'], + mockResult: { + output: makeCatalogJson([ + { + udid: VALID_SIMULATOR_ID.toUpperCase(), + isAvailable: true, + state: 'Shutdown', + }, + ]), + }, + }, + ]); + const supervised = supervisedExecutor(executor); + + await supervision.run(async () => { + await shutdownAndVerifyManagedSimulator(VALID_SIMULATOR_ID.toLowerCase(), supervised); + }); + + expect(executedCommands).toEqual([['xcrun', 'simctl', 'list', 'devices', '--json']]); + }); + + it('matches UUID case-insensitively when post-shutdown catalog has uppercase UUID', async () => { + const supervision = new CommandSupervision(); + const { executor, executedCommands } = createStrictQueueExecutor([ + { + expectedArgs: ['xcrun', 'simctl', 'list', 'devices', '--json'], + mockResult: { + output: makeCatalogJson([ + { + udid: VALID_SIMULATOR_ID.toLowerCase(), + isAvailable: true, + state: 'Booted', + }, + ]), + }, + }, + { + expectedArgs: ['xcrun', 'simctl', 'shutdown', VALID_SIMULATOR_ID.toLowerCase()], + mockResult: { output: '' }, + }, + { + expectedArgs: ['xcrun', 'simctl', 'list', 'devices', '--json'], + mockResult: { + output: makeCatalogJson([ + { + udid: VALID_SIMULATOR_ID.toUpperCase(), + isAvailable: true, + state: 'Shutdown', + }, + ]), + }, + }, + ]); + const supervised = supervisedExecutor(executor); + + await supervision.run(async () => { + await shutdownAndVerifyManagedSimulator(VALID_SIMULATOR_ID.toLowerCase(), supervised); + }); + + expect(executedCommands).toEqual([ + ['xcrun', 'simctl', 'list', 'devices', '--json'], + ['xcrun', 'simctl', 'shutdown', VALID_SIMULATOR_ID.toLowerCase()], + ['xcrun', 'simctl', 'list', 'devices', '--json'], + ]); + }); + }); + + describe('unexpected initial states', () => { + it.each(['Booting', 'Shutting Down', 'Unknown', 'Creating'])( + 'when initial state is %s: throws SimulatorTestUncertainError and executes 0 shutdown calls', + async (initialState) => { + const supervision = new CommandSupervision(); + const { executor, executedCommands } = createStrictQueueExecutor([ + { + expectedArgs: ['xcrun', 'simctl', 'list', 'devices', '--json'], + mockResult: { + output: makeCatalogJson([ + { + udid: VALID_SIMULATOR_ID, + isAvailable: true, + state: initialState, + }, + ]), + }, + }, + ]); + const supervised = supervisedExecutor(executor); + + await supervision.run(async () => { + await expect( + shutdownAndVerifyManagedSimulator(VALID_SIMULATOR_ID, supervised), + ).rejects.toThrow(SimulatorTestUncertainError); + }); + + expect(executedCommands).toEqual([['xcrun', 'simctl', 'list', 'devices', '--json']]); + }, + ); + }); + + describe('initial catalog validation failures', () => { + it('fails when catalog returns invalid JSON', async () => { + const supervision = new CommandSupervision(); + const { executor, executedCommands } = createStrictQueueExecutor([ + { + expectedArgs: ['xcrun', 'simctl', 'list', 'devices', '--json'], + mockResult: { output: 'this is not valid json' }, + }, + ]); + const supervised = supervisedExecutor(executor); + + await supervision.run(async () => { + await expect( + shutdownAndVerifyManagedSimulator(VALID_SIMULATOR_ID, supervised), + ).rejects.toThrow(SimulatorTestUncertainError); + }); + + expect(executedCommands).toEqual([['xcrun', 'simctl', 'list', 'devices', '--json']]); + }); + + it('fails when catalog returns invalid schema', async () => { + const supervision = new CommandSupervision(); + const { executor, executedCommands } = createStrictQueueExecutor([ + { + expectedArgs: ['xcrun', 'simctl', 'list', 'devices', '--json'], + mockResult: { + output: JSON.stringify({ devices: 'not-a-record' }), + }, + }, + ]); + const supervised = supervisedExecutor(executor); + + await supervision.run(async () => { + await expect( + shutdownAndVerifyManagedSimulator(VALID_SIMULATOR_ID, supervised), + ).rejects.toThrow(SimulatorTestUncertainError); + }); + + expect(executedCommands).toEqual([['xcrun', 'simctl', 'list', 'devices', '--json']]); + }); + + it('fails when UUID is missing (0 matches) - never treats missing as success', async () => { + const supervision = new CommandSupervision(); + const { executor, executedCommands } = createStrictQueueExecutor([ + { + expectedArgs: ['xcrun', 'simctl', 'list', 'devices', '--json'], + mockResult: { + output: makeCatalogJson([ + { + udid: '11111111-1111-4111-a111-111111111111', + isAvailable: true, + state: 'Booted', + }, + ]), + }, + }, + ]); + const supervised = supervisedExecutor(executor); + + await supervision.run(async () => { + await expect( + shutdownAndVerifyManagedSimulator(VALID_SIMULATOR_ID, supervised), + ).rejects.toThrow(/missing/i); + }); + + expect(executedCommands).toEqual([['xcrun', 'simctl', 'list', 'devices', '--json']]); + }); + + it('fails when UUID has duplicate entries in same runtime', async () => { + const supervision = new CommandSupervision(); + const { executor, executedCommands } = createStrictQueueExecutor([ + { + expectedArgs: ['xcrun', 'simctl', 'list', 'devices', '--json'], + mockResult: { + output: makeCatalogJson([ + { udid: VALID_SIMULATOR_ID, isAvailable: true, state: 'Booted' }, + { + udid: VALID_SIMULATOR_ID, + isAvailable: true, + state: 'Shutdown', + }, + ]), + }, + }, + ]); + const supervised = supervisedExecutor(executor); + + await supervision.run(async () => { + await expect( + shutdownAndVerifyManagedSimulator(VALID_SIMULATOR_ID, supervised), + ).rejects.toThrow(/duplicate/i); + }); + + expect(executedCommands).toEqual([['xcrun', 'simctl', 'list', 'devices', '--json']]); + }); + + it('fails when duplicate UUID appears across different runtime keys', async () => { + const supervision = new CommandSupervision(); + const { executor, executedCommands } = createStrictQueueExecutor([ + { + expectedArgs: ['xcrun', 'simctl', 'list', 'devices', '--json'], + mockResult: { + output: makeCatalogJson({ + 'com.apple.CoreSimulator.SimRuntime.iOS-18-0': [ + { + udid: VALID_SIMULATOR_ID, + isAvailable: true, + state: 'Booted', + }, + ], + 'com.apple.CoreSimulator.SimRuntime.iOS-17-0': [ + { + udid: VALID_SIMULATOR_ID, + isAvailable: true, + state: 'Shutdown', + }, + ], + }), + }, + }, + ]); + const supervised = supervisedExecutor(executor); + + await supervision.run(async () => { + await expect( + shutdownAndVerifyManagedSimulator(VALID_SIMULATOR_ID, supervised), + ).rejects.toThrow(/duplicate/i); + }); + + expect(executedCommands).toEqual([['xcrun', 'simctl', 'list', 'devices', '--json']]); + }); + + it('fails when UUID has isAvailable: false', async () => { + const supervision = new CommandSupervision(); + const { executor, executedCommands } = createStrictQueueExecutor([ + { + expectedArgs: ['xcrun', 'simctl', 'list', 'devices', '--json'], + mockResult: { + output: makeCatalogJson([ + { udid: VALID_SIMULATOR_ID, isAvailable: false, state: 'Booted' }, + ]), + }, + }, + ]); + const supervised = supervisedExecutor(executor); + + await supervision.run(async () => { + await expect( + shutdownAndVerifyManagedSimulator(VALID_SIMULATOR_ID, supervised), + ).rejects.toThrow(/unavailable/i); + }); + + expect(executedCommands).toEqual([['xcrun', 'simctl', 'list', 'devices', '--json']]); + }); + }); + + describe('post-shutdown catalog validation failures', () => { + it('fails when post-shutdown catalog returns invalid JSON', async () => { + const supervision = new CommandSupervision(); + const { executor, executedCommands } = createStrictQueueExecutor([ + { + expectedArgs: ['xcrun', 'simctl', 'list', 'devices', '--json'], + mockResult: { + output: makeCatalogJson([ + { udid: VALID_SIMULATOR_ID, isAvailable: true, state: 'Booted' }, + ]), + }, + }, + { + expectedArgs: ['xcrun', 'simctl', 'shutdown', VALID_SIMULATOR_ID], + mockResult: { output: '' }, + }, + { + expectedArgs: ['xcrun', 'simctl', 'list', 'devices', '--json'], + mockResult: { output: 'corrupt post json' }, + }, + ]); + const supervised = supervisedExecutor(executor); + + await supervision.run(async () => { + await expect( + shutdownAndVerifyManagedSimulator(VALID_SIMULATOR_ID, supervised), + ).rejects.toThrow(SimulatorTestUncertainError); + }); + + expect(executedCommands).toEqual([ + ['xcrun', 'simctl', 'list', 'devices', '--json'], + ['xcrun', 'simctl', 'shutdown', VALID_SIMULATOR_ID], + ['xcrun', 'simctl', 'list', 'devices', '--json'], + ]); + }); + + it('fails when post-shutdown catalog returns invalid schema', async () => { + const supervision = new CommandSupervision(); + const { executor, executedCommands } = createStrictQueueExecutor([ + { + expectedArgs: ['xcrun', 'simctl', 'list', 'devices', '--json'], + mockResult: { + output: makeCatalogJson([ + { udid: VALID_SIMULATOR_ID, isAvailable: true, state: 'Booted' }, + ]), + }, + }, + { + expectedArgs: ['xcrun', 'simctl', 'shutdown', VALID_SIMULATOR_ID], + mockResult: { output: '' }, + }, + { + expectedArgs: ['xcrun', 'simctl', 'list', 'devices', '--json'], + mockResult: { + output: JSON.stringify({ devices: 'not-an-object' }), + }, + }, + ]); + const supervised = supervisedExecutor(executor); + + await supervision.run(async () => { + await expect( + shutdownAndVerifyManagedSimulator(VALID_SIMULATOR_ID, supervised), + ).rejects.toThrow(SimulatorTestUncertainError); + }); + + expect(executedCommands).toEqual([ + ['xcrun', 'simctl', 'list', 'devices', '--json'], + ['xcrun', 'simctl', 'shutdown', VALID_SIMULATOR_ID], + ['xcrun', 'simctl', 'list', 'devices', '--json'], + ]); + }); + + it('fails when post-shutdown catalog state is still Booted', async () => { + const supervision = new CommandSupervision(); + const { executor, executedCommands } = createStrictQueueExecutor([ + { + expectedArgs: ['xcrun', 'simctl', 'list', 'devices', '--json'], + mockResult: { + output: makeCatalogJson([ + { udid: VALID_SIMULATOR_ID, isAvailable: true, state: 'Booted' }, + ]), + }, + }, + { + expectedArgs: ['xcrun', 'simctl', 'shutdown', VALID_SIMULATOR_ID], + mockResult: { output: '' }, + }, + { + expectedArgs: ['xcrun', 'simctl', 'list', 'devices', '--json'], + mockResult: { + output: makeCatalogJson([ + { udid: VALID_SIMULATOR_ID, isAvailable: true, state: 'Booted' }, + ]), + }, + }, + ]); + const supervised = supervisedExecutor(executor); + + await supervision.run(async () => { + await expect( + shutdownAndVerifyManagedSimulator(VALID_SIMULATOR_ID, supervised), + ).rejects.toThrow(/Shutdown state/i); + }); + + expect(executedCommands).toEqual([ + ['xcrun', 'simctl', 'list', 'devices', '--json'], + ['xcrun', 'simctl', 'shutdown', VALID_SIMULATOR_ID], + ['xcrun', 'simctl', 'list', 'devices', '--json'], + ]); + }); + + it('fails when post-shutdown catalog state is Shutting Down', async () => { + const supervision = new CommandSupervision(); + const { executor, executedCommands } = createStrictQueueExecutor([ + { + expectedArgs: ['xcrun', 'simctl', 'list', 'devices', '--json'], + mockResult: { + output: makeCatalogJson([ + { udid: VALID_SIMULATOR_ID, isAvailable: true, state: 'Booted' }, + ]), + }, + }, + { + expectedArgs: ['xcrun', 'simctl', 'shutdown', VALID_SIMULATOR_ID], + mockResult: { output: '' }, + }, + { + expectedArgs: ['xcrun', 'simctl', 'list', 'devices', '--json'], + mockResult: { + output: makeCatalogJson([ + { + udid: VALID_SIMULATOR_ID, + isAvailable: true, + state: 'Shutting Down', + }, + ]), + }, + }, + ]); + const supervised = supervisedExecutor(executor); + + await supervision.run(async () => { + await expect( + shutdownAndVerifyManagedSimulator(VALID_SIMULATOR_ID, supervised), + ).rejects.toThrow(/Shutdown state/i); + }); + + expect(executedCommands).toEqual([ + ['xcrun', 'simctl', 'list', 'devices', '--json'], + ['xcrun', 'simctl', 'shutdown', VALID_SIMULATOR_ID], + ['xcrun', 'simctl', 'list', 'devices', '--json'], + ]); + }); + + it('fails when post-shutdown catalog has missing UUID', async () => { + const supervision = new CommandSupervision(); + const { executor, executedCommands } = createStrictQueueExecutor([ + { + expectedArgs: ['xcrun', 'simctl', 'list', 'devices', '--json'], + mockResult: { + output: makeCatalogJson([ + { udid: VALID_SIMULATOR_ID, isAvailable: true, state: 'Booted' }, + ]), + }, + }, + { + expectedArgs: ['xcrun', 'simctl', 'shutdown', VALID_SIMULATOR_ID], + mockResult: { output: '' }, + }, + { + expectedArgs: ['xcrun', 'simctl', 'list', 'devices', '--json'], + mockResult: { output: makeCatalogJson([]) }, + }, + ]); + const supervised = supervisedExecutor(executor); + + await supervision.run(async () => { + await expect( + shutdownAndVerifyManagedSimulator(VALID_SIMULATOR_ID, supervised), + ).rejects.toThrow(/missing/i); + }); + + expect(executedCommands).toEqual([ + ['xcrun', 'simctl', 'list', 'devices', '--json'], + ['xcrun', 'simctl', 'shutdown', VALID_SIMULATOR_ID], + ['xcrun', 'simctl', 'list', 'devices', '--json'], + ]); + }); + + it('fails when post-shutdown catalog has duplicate UUID across runtimes', async () => { + const supervision = new CommandSupervision(); + const { executor, executedCommands } = createStrictQueueExecutor([ + { + expectedArgs: ['xcrun', 'simctl', 'list', 'devices', '--json'], + mockResult: { + output: makeCatalogJson([ + { udid: VALID_SIMULATOR_ID, isAvailable: true, state: 'Booted' }, + ]), + }, + }, + { + expectedArgs: ['xcrun', 'simctl', 'shutdown', VALID_SIMULATOR_ID], + mockResult: { output: '' }, + }, + { + expectedArgs: ['xcrun', 'simctl', 'list', 'devices', '--json'], + mockResult: { + output: makeCatalogJson({ + 'com.apple.CoreSimulator.SimRuntime.iOS-18-0': [ + { + udid: VALID_SIMULATOR_ID, + isAvailable: true, + state: 'Shutdown', + }, + ], + 'com.apple.CoreSimulator.SimRuntime.iOS-17-0': [ + { + udid: VALID_SIMULATOR_ID, + isAvailable: true, + state: 'Shutdown', + }, + ], + }), + }, + }, + ]); + const supervised = supervisedExecutor(executor); + + await supervision.run(async () => { + await expect( + shutdownAndVerifyManagedSimulator(VALID_SIMULATOR_ID, supervised), + ).rejects.toThrow(/duplicate/i); + }); + + expect(executedCommands).toEqual([ + ['xcrun', 'simctl', 'list', 'devices', '--json'], + ['xcrun', 'simctl', 'shutdown', VALID_SIMULATOR_ID], + ['xcrun', 'simctl', 'list', 'devices', '--json'], + ]); + }); + + it('fails when post-shutdown catalog has isAvailable: false', async () => { + const supervision = new CommandSupervision(); + const { executor, executedCommands } = createStrictQueueExecutor([ + { + expectedArgs: ['xcrun', 'simctl', 'list', 'devices', '--json'], + mockResult: { + output: makeCatalogJson([ + { udid: VALID_SIMULATOR_ID, isAvailable: true, state: 'Booted' }, + ]), + }, + }, + { + expectedArgs: ['xcrun', 'simctl', 'shutdown', VALID_SIMULATOR_ID], + mockResult: { output: '' }, + }, + { + expectedArgs: ['xcrun', 'simctl', 'list', 'devices', '--json'], + mockResult: { + output: makeCatalogJson([ + { + udid: VALID_SIMULATOR_ID, + isAvailable: false, + state: 'Shutdown', + }, + ]), + }, + }, + ]); + const supervised = supervisedExecutor(executor); + + await supervision.run(async () => { + await expect( + shutdownAndVerifyManagedSimulator(VALID_SIMULATOR_ID, supervised), + ).rejects.toThrow(/unavailable/i); + }); + + expect(executedCommands).toEqual([ + ['xcrun', 'simctl', 'list', 'devices', '--json'], + ['xcrun', 'simctl', 'shutdown', VALID_SIMULATOR_ID], + ['xcrun', 'simctl', 'list', 'devices', '--json'], + ]); + }); + }); + + describe('comprehensive failure matrix across command positions (position × failure mode)', () => { + type FailureScenario = { + name: string; + stepConfig: { + mockResult?: MockStep['mockResult']; + override?: (streamTracker: () => PassThrough) => MockStep['override']; + }; + }; + + const failureScenarios: FailureScenario[] = [ + { + name: 'nonzero exitCode (success false)', + stepConfig: { + mockResult: { success: false, exitCode: 1, error: 'Command failed' }, + }, + }, + { + name: 'signal termination with success true and exitCode 0 (adversarial)', + stepConfig: { + mockResult: { success: true, exitCode: 0 }, + override: () => ({ signalCode: 'SIGTERM' }), + }, + }, + { + name: 'signal termination normal', + stepConfig: { + mockResult: { success: false, exitCode: 1 }, + override: () => ({ signalCode: 'SIGKILL' }), + }, + }, + { + name: 'rejects with error', + stepConfig: { + mockResult: { shouldThrow: new Error('Low level transport reject') }, + }, + }, + { + name: 'unknown exitCode (null processExitCode and null signalCode)', + stepConfig: { + mockResult: { success: true, exitCode: 0 }, + override: () => ({ processExitCode: null, signalCode: null }), + }, + }, + { + name: 'open stdout stream (stream not ended/destroyed)', + stepConfig: { + mockResult: { success: true, exitCode: 0 }, + override: (tracker) => ({ stdout: tracker() }), + }, + }, + { + name: 'open stderr stream (stream not ended/destroyed)', + stepConfig: { + mockResult: { success: true, exitCode: 0 }, + override: (tracker) => ({ stderr: tracker() }), + }, + }, + { + name: 'inconsistent exit codes: response.exitCode 0 but process.exitCode 1', + stepConfig: { + mockResult: { success: true, exitCode: 0 }, + override: () => ({ processExitCode: 1 }), + }, + }, + { + name: 'inconsistent exit codes: response.exitCode 1 but process.exitCode 0', + stepConfig: { + mockResult: { success: true, exitCode: 1 }, + override: () => ({ processExitCode: 0 }), + }, + }, + ]; + + describe('position 1: initial list command failure stops subsequent commands', () => { + it.each(failureScenarios)('fails on initial list with $name', async ({ stepConfig }) => { + const supervision = new CommandSupervision(); + const stepOverride = stepConfig.override + ? stepConfig.override(createTrackedPassThrough) + : undefined; + const { executor, executedCommands } = createStrictQueueExecutor([ + { + expectedArgs: ['xcrun', 'simctl', 'list', 'devices', '--json'], + mockResult: { + output: makeCatalogJson([ + { + udid: VALID_SIMULATOR_ID, + isAvailable: true, + state: 'Booted', + }, + ]), + ...stepConfig.mockResult, + }, + override: stepOverride, + }, + ]); + const supervised = supervisedExecutor(executor); + + await supervision.run(async () => { + await expect( + shutdownAndVerifyManagedSimulator(VALID_SIMULATOR_ID, supervised), + ).rejects.toThrow(SimulatorTestUncertainError); + }); + + expect(executedCommands).toEqual([['xcrun', 'simctl', 'list', 'devices', '--json']]); + }); + }); + + describe('position 2: shutdown command failure stops post-shutdown list', () => { + it.each(failureScenarios)('fails on shutdown command with $name', async ({ stepConfig }) => { + const supervision = new CommandSupervision(); + const stepOverride = stepConfig.override + ? stepConfig.override(createTrackedPassThrough) + : undefined; + const { executor, executedCommands } = createStrictQueueExecutor([ + { + expectedArgs: ['xcrun', 'simctl', 'list', 'devices', '--json'], + mockResult: { + output: makeCatalogJson([ + { + udid: VALID_SIMULATOR_ID, + isAvailable: true, + state: 'Booted', + }, + ]), + }, + }, + { + expectedArgs: ['xcrun', 'simctl', 'shutdown', VALID_SIMULATOR_ID], + mockResult: stepConfig.mockResult ?? { output: '' }, + override: stepOverride, + }, + ]); + const supervised = supervisedExecutor(executor); + + await supervision.run(async () => { + await expect( + shutdownAndVerifyManagedSimulator(VALID_SIMULATOR_ID, supervised), + ).rejects.toThrow(SimulatorTestUncertainError); + }); + + expect(executedCommands).toEqual([ + ['xcrun', 'simctl', 'list', 'devices', '--json'], + ['xcrun', 'simctl', 'shutdown', VALID_SIMULATOR_ID], + ]); + }); + }); + + describe('position 3: post-shutdown list command failure', () => { + it.each(failureScenarios)( + 'fails on post-shutdown list with $name', + async ({ stepConfig }) => { + const supervision = new CommandSupervision(); + const stepOverride = stepConfig.override + ? stepConfig.override(createTrackedPassThrough) + : undefined; + const { executor, executedCommands } = createStrictQueueExecutor([ + { + expectedArgs: ['xcrun', 'simctl', 'list', 'devices', '--json'], + mockResult: { + output: makeCatalogJson([ + { + udid: VALID_SIMULATOR_ID, + isAvailable: true, + state: 'Booted', + }, + ]), + }, + }, + { + expectedArgs: ['xcrun', 'simctl', 'shutdown', VALID_SIMULATOR_ID], + mockResult: { output: '' }, + }, + { + expectedArgs: ['xcrun', 'simctl', 'list', 'devices', '--json'], + mockResult: { + output: makeCatalogJson([ + { + udid: VALID_SIMULATOR_ID, + isAvailable: true, + state: 'Shutdown', + }, + ]), + ...stepConfig.mockResult, + }, + override: stepOverride, + }, + ]); + const supervised = supervisedExecutor(executor); + + await supervision.run(async () => { + await expect( + shutdownAndVerifyManagedSimulator(VALID_SIMULATOR_ID, supervised), + ).rejects.toThrow(SimulatorTestUncertainError); + }); + + expect(executedCommands).toEqual([ + ['xcrun', 'simctl', 'list', 'devices', '--json'], + ['xcrun', 'simctl', 'shutdown', VALID_SIMULATOR_ID], + ['xcrun', 'simctl', 'list', 'devices', '--json'], + ]); + }, + ); + }); + }); + + describe('supervision context uncertainty persistence', () => { + it('confirms uncertainty in the same context blocks subsequent commands from executing and does not invoke raw executor', async () => { + const supervision = new CommandSupervision(); + const { executor, executedCommands } = createStrictQueueExecutor([ + { + expectedArgs: ['xcrun', 'simctl', 'list', 'devices', '--json'], + mockResult: { shouldThrow: new Error('low-level crash') }, + }, + ]); + const supervised = supervisedExecutor(executor); + + await supervision.run(async () => { + // First call fails and marks supervision uncertain + await expect( + shutdownAndVerifyManagedSimulator(VALID_SIMULATOR_ID, supervised), + ).rejects.toThrow(SimulatorTestUncertainError); + + expect(executedCommands).toEqual([['xcrun', 'simctl', 'list', 'devices', '--json']]); + expect(executedCommands).toHaveLength(1); + + // Second call within the same context is rejected by CommandSupervision itself + await expect(supervised(['echo', 'should-not-run'])).rejects.toThrow( + /Previous managed command completion is uncertain/, + ); + + // Explicitly assert that raw executor was not invoked a second time + expect(executedCommands).toEqual([['xcrun', 'simctl', 'list', 'devices', '--json']]); + expect(executedCommands).toHaveLength(1); + }); + }); + }); +}); diff --git a/src/resource-management/__tests__/test.test.ts b/src/resource-management/__tests__/test.test.ts new file mode 100644 index 000000000..7b8383072 --- /dev/null +++ b/src/resource-management/__tests__/test.test.ts @@ -0,0 +1,1166 @@ +import { randomUUID } from 'node:crypto'; +import { mkdtemp, mkdir, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { PassThrough } from 'node:stream'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { z } from 'zod'; +import { createMockExecutor } from '../../test-utils/mock-executors.ts'; +import { + __setTestCommandExecutorOverride, + __clearTestExecutorOverrides, +} from '../../utils/command.ts'; +import type { ToolHandlerContext } from '../../rendering/types.ts'; +import { sessionStore } from '../../utils/session-store.ts'; +import { resourceEnvironment } from '../environment.ts'; +import { managedToolSchema, wrapManagedTool } from '../tool-gate.ts'; +import { SimulatorResourceManager, type Lease } from '../manager.ts'; +import { resolveManagedWorktree } from '../worktree.ts'; +import { + handler as testSimHandler, + schema as testSimSchema, + mcpSchema as testSimMcpSchema, +} from '../../mcp/tools/simulator/test_sim.ts'; +import { handler as snapshotHandler } from '../../mcp/tools/ui-automation/snapshot_ui.ts'; +import { defaultAxeHelpers } from '../../mcp/tools/ui-automation/shared/axe-command.ts'; +import * as logCaptureModule from '../../utils/xcodebuild-log-capture.ts'; +import * as xcresultModule from '../../utils/xcresult-test-failures.ts'; +import * as resultBundleModule from '../../utils/result-bundle-path.ts'; +import * as testProductsModule from '../../utils/test-products-path.ts'; +import { + getRuntimeSnapshotLookup, + __resetRuntimeSnapshotStoreForTests, +} from '../../mcp/tools/ui-automation/shared/snapshot-ui-state.ts'; +import { SimulatorTestUncertainError } from '../test-execution.ts'; + +let directory: string; +let manager: SimulatorResourceManager; +let lease: Lease; +let logsDir: string; +let activeStreams: PassThrough[] = []; +const moduleId = 'mcp/tools/simulator/test_sim'; +const context = (): ToolHandlerContext => ({ emit: () => {}, attach: () => {} }); + +const credentials = () => ({ + simulatorId: lease.simulatorId, + operationRequestId: lease.requestId, + operationToken: lease.token, + operationSessionId: lease.sessionId, +}); + +const defaultTestParams = (root: string) => ({ + ...credentials(), + projectPath: join(root, 'TestApp.xcodeproj'), + scheme: 'TestApp', +}); + +const testTool = wrapManagedTool(moduleId, (args, ctx) => { + if (!ctx) throw new Error('Missing fixture context'); + return testSimHandler(args, ctx); +}); + +interface Deferred { + promise: Promise; + resolve: (value: T | PromiseLike) => void; + reject: (reason?: unknown) => void; +} + +function createDeferred(): Deferred { + let resolve!: (value: T | PromiseLike) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +beforeEach(async () => { + __resetRuntimeSnapshotStoreForTests(); + sessionStore.clear(); + directory = await mkdtemp(join(tmpdir(), 'resource-test-')); + const root = join(directory, 'checkout'); + await mkdir(join(root, '.git'), { recursive: true }); + logsDir = join(directory, 'logs'); + await mkdir(logsDir, { recursive: true }); + logCaptureModule.setXcodebuildLogDirOverrideForTests(logsDir); + + vi.stubEnv('XCODEBUILDMCP_OPERATION_STATE_ROOT', join(directory, 'state')); + vi.stubEnv('XCODEBUILDMCP_OPERATION_CAPACITY', '1'); + + const executor = createMockExecutor({}); + __setTestCommandExecutorOverride(async (...args) => { + if (args[0][0] !== 'git') throw new Error('Unexpected external command'); + return { + ...(await executor(...args)), + output: `${args[0].at(-1) === '--show-toplevel' ? root : join(root, '.git')}\n`, + }; + }); + + const worktree = await resolveManagedWorktree(root, async (...args) => { + return { + ...(await executor(...args)), + output: `${args[0].at(-1) === '--show-toplevel' ? root : join(root, '.git')}\n`, + }; + }); + + manager = await SimulatorResourceManager.open(resourceEnvironment()!); + await manager.bind(worktree, { deviceType: 'fixture', runtime: 'fixture' }, randomUUID()); + lease = await manager.requestLease({ + requestId: randomUUID(), + owner: { sessionId: randomUUID() }, + worktree, + }); +}); + +afterEach(async () => { + for (const s of activeStreams) { + s.destroy(); + } + activeStreams = []; + __clearTestExecutorOverrides(); + logCaptureModule.setXcodebuildLogDirOverrideForTests(null); + sessionStore.clear(); + vi.unstubAllEnvs(); + vi.restoreAllMocks(); + await rm(directory, { recursive: true, force: true }); +}); + +function setupSyncExtractSentinels() { + const summarySpy = vi + .spyOn(xcresultModule, 'extractTestSummaryCountsFromXcresult') + .mockImplementation(() => { + throw new Error( + 'SENTINEL: extractTestSummaryCountsFromXcresult sync function must never be called in managed mode', + ); + }); + const failuresSpy = vi + .spyOn(xcresultModule, 'extractTestFailuresFromXcresult') + .mockImplementation(() => { + throw new Error( + 'SENTINEL: extractTestFailuresFromXcresult sync function must never be called in managed mode', + ); + }); + return { summarySpy, failuresSpy }; +} + +function commands( + options: { + phase1Failure?: 'exit' | 'reject' | 'signal'; + phase2Failure?: 'exit' | 'reject' | 'open-stream' | 'unknown-exit' | 'signal'; + phase1ExitCode?: number; + phase2ExitCode?: number; + shutdownFailure?: 'exit' | 'reject'; + postCatalogState?: string; + metadataFailure?: 'reject' | 'signal-tests' | 'signal-summary'; + onShutdown?: () => Promise; + onPostList?: () => Promise; + capturedEnvs?: Record[]; + xcresultFailures?: boolean; + } = {}, +) { + const calls: string[][] = []; + const stream = new PassThrough(); + activeStreams.push(stream); + const mock = createMockExecutor({}); + const root = join(directory, 'checkout'); + let simState = 'Booted'; + let hasShutDown = false; + let observingTestAdmission = false; + let firstDeviceCallSnapshotStatus: string | undefined; + + const recordDeviceCall = () => { + if (observingTestAdmission && firstDeviceCallSnapshotStatus === undefined) { + firstDeviceCallSnapshotStatus = getRuntimeSnapshotLookup(lease.simulatorId).status; + } + }; + + __setTestCommandExecutorOverride(async (...args) => { + const cmd = args[0]; + const execOpts = args[3]; + + if (execOpts?.env) { + options.capturedEnvs?.push(execOpts.env); + } + + if (cmd[0] === 'git') { + return { + ...(await mock(...args)), + output: `${cmd.at(-1) === '--show-toplevel' ? root : join(root, '.git')}\n`, + }; + } + + if (cmd[0] === 'xcrun' && cmd[1] === 'simctl') { + recordDeviceCall(); + calls.push(cmd); + if (cmd[2] === 'list') { + if (hasShutDown && options.onPostList) { + await options.onPostList(); + } + const stateToReport = hasShutDown ? (options.postCatalogState ?? simState) : simState; + return { + ...(await mock(...args)), + output: JSON.stringify({ + devices: { + 'com.apple.CoreSimulator.SimRuntime.iOS-18-0': [ + { + udid: lease.simulatorId.toUpperCase(), + name: 'iPhone 16 Pro', + isAvailable: true, + state: stateToReport, + }, + ], + }, + }), + }; + } + if (cmd[2] === 'shutdown') { + if (options.onShutdown) { + await options.onShutdown(); + } + if (options.shutdownFailure === 'reject') { + throw new Error('simctl shutdown failed unexpectedly'); + } + if (options.shutdownFailure === 'exit') { + return createMockExecutor({ + success: false, + exitCode: 1, + error: 'simctl shutdown timed out', + })(...args); + } + simState = 'Shutdown'; + hasShutDown = true; + return { + ...(await mock(...args)), + output: '', + }; + } + throw new Error(`Unexpected simctl command in mock: ${cmd.join(' ')}`); + } + + if (cmd[0] === 'xcrun') { + calls.push(cmd); + if (cmd.includes('tests')) { + if (options.metadataFailure === 'signal-tests') { + const response = await createMockExecutor({ + success: false, + exitCode: 1, + output: 'Interrupted', + })(...args); + Object.assign(response.process, { exitCode: null, signalCode: 'SIGTERM' }); + return response; + } + if (options.metadataFailure === 'reject') { + throw new Error('xcresult tests extraction rejected'); + } + if (options.xcresultFailures) { + return { + ...(await mock(...args)), + output: JSON.stringify({ + testNodes: [ + { + name: 'TestAppTests', + nodeType: 'Test Suite', + children: [ + { + name: 'testFailure()', + nodeType: 'Test Case', + result: 'Failed', + children: [ + { + name: 'XCTAssertTrue failed - Expected true got false', + nodeType: 'Failure Message', + }, + ], + }, + ], + }, + ], + }), + }; + } + return { + ...(await mock(...args)), + output: JSON.stringify({ testNodes: [] }), + }; + } + if (cmd.includes('summary')) { + if (options.metadataFailure === 'signal-summary') { + const response = await createMockExecutor({ + success: false, + exitCode: 1, + output: 'Interrupted', + })(...args); + Object.assign(response.process, { exitCode: null, signalCode: 'SIGTERM' }); + return response; + } + if (options.metadataFailure === 'reject') { + throw new Error('xcresult summary extraction rejected'); + } + return { + ...(await mock(...args)), + output: JSON.stringify({ + totalTestCount: 5, + passedTests: options.phase2ExitCode === 65 ? 4 : 5, + failedTests: options.phase2ExitCode === 65 ? 1 : 0, + skippedTests: 0, + }), + }; + } + throw new Error(`Unexpected xcrun command in mock: ${cmd.join(' ')}`); + } + + if (cmd[0] === 'xcodebuild') { + recordDeviceCall(); + calls.push(cmd); + + const destIndex = cmd.indexOf('-destination'); + if (destIndex !== -1) { + const dest = cmd[destIndex + 1] ?? ''; + const idMatch = dest.match(/id=([^,]+)/); + if (idMatch && idMatch[1] !== lease.simulatorId.toUpperCase()) { + return createMockExecutor({ + success: false, + exitCode: 70, + error: `xcodebuild: error: Unable to find a device matching the provided destination specifier: { ${dest} }`, + })(...args); + } + } + + if (cmd.includes('build-for-testing')) { + if (options.phase1Failure === 'reject') { + throw new Error('build-for-testing executor rejected'); + } + if (options.phase1Failure === 'signal') { + const res = await createMockExecutor({ + success: false, + exitCode: 1, + output: 'Interrupted', + })(...args); + Object.assign(res.process, { exitCode: null, signalCode: 'SIGTERM' }); + return res; + } + if (options.phase1Failure === 'exit') { + return createMockExecutor({ + success: false, + exitCode: options.phase1ExitCode ?? 65, + error: 'Compilation failed', + })(...args); + } + return mock(...args); + } + + if (cmd.includes('test-without-building')) { + if (options.phase2Failure === 'reject') { + throw new Error('test-without-building executor rejected'); + } + if (options.phase2Failure === 'signal') { + const res = await createMockExecutor({ + success: false, + exitCode: 1, + output: 'Killed', + })(...args); + Object.assign(res.process, { exitCode: null, signalCode: 'SIGKILL' }); + return res; + } + if (options.phase2Failure === 'exit') { + return createMockExecutor({ + success: false, + exitCode: options.phase2ExitCode ?? 65, + error: 'Tests failed', + })(...args); + } + const response = await mock(...args); + if (options.phase2Failure === 'open-stream') { + response.process.stdout = stream; + } + if (options.phase2Failure === 'unknown-exit') { + Object.assign(response.process, { exitCode: null, signalCode: null }); + } + return response; + } + + throw new Error(`Unexpected xcodebuild action: ${cmd.join(' ')}`); + } + + if (cmd[0] === '/fixture/axe') { + calls.push(cmd); + return { + ...(await mock(...args)), + output: + cmd[1] === 'describe-ui' + ? JSON.stringify({ + elements: [ + { + type: 'TextField', + role: 'AXTextField', + AXLabel: 'Email', + AXUniqueId: 'email-field', + frame: { x: 10, y: 20, width: 100, height: 40 }, + enabled: true, + children: [], + }, + ], + }) + : 'ok', + }; + } + + throw new Error(`Unexpected external command in test mock: ${cmd.join(' ')}`); + }); + + return { + calls, + stream, + root, + startObservingDeviceCalls: () => { + observingTestAdmission = true; + }, + getFirstDeviceCallSnapshotStatus: () => firstDeviceCallSnapshotStatus, + }; +} + +describe('Managed test_sim integration and lifecycle gate', () => { + it('exposes explicit project, workspace, scheme, configuration, DD, prefer, UUID, credentials, testRunnerEnv, progress on schema', () => { + const schema = managedToolSchema(moduleId, testSimSchema); + expect(schema).toHaveProperty('projectPath'); + expect(schema).toHaveProperty('workspacePath'); + expect(schema).toHaveProperty('scheme'); + expect(schema).toHaveProperty('configuration'); + expect(schema).toHaveProperty('derivedDataPath'); + expect(schema).toHaveProperty('preferXcodebuild'); + expect(schema).toHaveProperty('simulatorId'); + expect(schema).toHaveProperty('operationRequestId'); + expect(schema).toHaveProperty('operationToken'); + expect(schema).toHaveProperty('operationSessionId'); + expect(schema).toHaveProperty('testRunnerEnv'); + expect(schema).toHaveProperty('progress'); + + const mcpSchema = managedToolSchema(moduleId, testSimMcpSchema); + expect(mcpSchema).toHaveProperty('testRunnerEnv'); + expect(mcpSchema).toHaveProperty('progress'); + }); + + it('parses wire array testRunnerEnv in managedToolSchema(mcpSchema), requires credentials and scheme/UUID, and forwards to both phases', async () => { + setupSyncExtractSentinels(); + const capturedEnvs: Record[] = []; + const { calls, root } = commands({ capturedEnvs }); + const ctx = context(); + + const managedMcpSchema = z.object(managedToolSchema(moduleId, testSimMcpSchema)); + + // 1. Successful parse of wire array format with workspacePath, extraArgs: [], preferXcodebuild: true + const parsed = managedMcpSchema.parse({ + ...credentials(), + workspacePath: join(root, 'TestApp.xcworkspace'), + scheme: 'TestApp', + extraArgs: [], + preferXcodebuild: true, + testRunnerEnv: [{ key: 'CUSTOM_VAR', value: 'custom_val' }], + }); + + // 2. Schema enforces required credentials and scheme/simulatorId + expect(() => + managedMcpSchema.parse({ + operationToken: lease.token, + operationSessionId: lease.sessionId, + workspacePath: join(root, 'TestApp.xcworkspace'), + scheme: 'TestApp', + }), + ).toThrow(); // missing operationRequestId, simulatorId + + expect(() => + managedMcpSchema.parse({ + ...credentials(), + workspacePath: join(root, 'TestApp.xcworkspace'), + // missing scheme + }), + ).toThrow(); + + expect(() => + managedMcpSchema.parse({ + ...credentials(), + workspacePath: join(root, 'TestApp.xcworkspace'), + scheme: 'TestApp', + simulatorId: 'not-a-uuid', + }), + ).toThrow(); + + // 3. Pass wire array input directly to tool handler + await testTool(parsed, ctx); + + expect(ctx.structuredOutput?.result.didError).toBe(false); + expect(ctx.structuredOutput?.result).toMatchObject({ + summary: { status: 'SUCCEEDED' }, + }); + + // 4. Verify workspace and destination args on xcodebuild calls + const buildCall = calls.find((c) => c[0] === 'xcodebuild' && c.includes('build-for-testing')); + expect(buildCall).toBeDefined(); + expect(buildCall).toContain('-workspace'); + expect(buildCall).toContain(join(root, 'TestApp.xcworkspace')); + expect(buildCall).toContain('-destination'); + expect(buildCall).toContain(`platform=iOS Simulator,id=${lease.simulatorId.toUpperCase()}`); + + const testCall = calls.find( + (c) => c[0] === 'xcodebuild' && c.includes('test-without-building'), + ); + expect(testCall).toBeDefined(); + expect(testCall).toContain('-destination'); + expect(testCall).toContain(`platform=iOS Simulator,id=${lease.simulatorId.toUpperCase()}`); + + // 5. Verify TEST_RUNNER_ env forwarded in both Phase 1 and Phase 2 commands + expect(capturedEnvs.length).toBeGreaterThanOrEqual(2); + expect(capturedEnvs[0]?.TEST_RUNNER_CUSTOM_VAR).toBe('custom_val'); + expect(capturedEnvs[1]?.TEST_RUNNER_CUSTOM_VAR).toBe('custom_val'); + }); + + it('rejects credentials missing, wrong token, wrong session, foreign UUID, and simulatorName before device executor', async () => { + const { calls, root } = commands(); + const baseParams = { + projectPath: join(root, 'TestApp.xcodeproj'), + scheme: 'TestApp', + }; + + // Missing credentials + await expect( + testTool({ ...baseParams, simulatorId: lease.simulatorId }, context()), + ).rejects.toThrow('credentials'); + + // Wrong token + await expect( + testTool({ ...baseParams, ...credentials(), operationToken: randomUUID() }, context()), + ).rejects.toThrow(); + + // Wrong session + await expect( + testTool({ ...baseParams, ...credentials(), operationSessionId: randomUUID() }, context()), + ).rejects.toThrow(); + + // Foreign UUID + await expect( + testTool({ ...baseParams, ...credentials(), simulatorId: randomUUID() }, context()), + ).rejects.toThrow(); + + // simulatorName provided + await expect( + testTool( + { + ...baseParams, + operationRequestId: lease.requestId, + operationToken: lease.token, + operationSessionId: lease.sessionId, + simulatorName: 'iPhone 16 Pro', + }, + context(), + ), + ).rejects.toThrow('without a name'); + + expect(calls).toHaveLength(0); + expect((await manager.getStatus(lease.requestId)).activities).toHaveLength(0); + }); + + it('rejects source missing, conflicting, valid+blank, session defaults when raw missing, and prepared paths with 0 commands', async () => { + const { calls, root } = commands(); + const creds = credentials(); + + // 1. Missing source (neither projectPath nor workspacePath) + await expect(testTool({ ...creds, scheme: 'TestApp' }, context())).rejects.toThrow( + 'exactly one of projectPath or workspacePath', + ); + + // 2. Conflicting sources (both projectPath and workspacePath) + await expect( + testTool( + { + ...creds, + projectPath: join(root, 'TestApp.xcodeproj'), + workspacePath: join(root, 'TestApp.xcworkspace'), + scheme: 'TestApp', + }, + context(), + ), + ).rejects.toThrow('exactly one of projectPath or workspacePath'); + + // 3. Valid project + blank workspace + await expect( + testTool( + { + ...creds, + projectPath: join(root, 'TestApp.xcodeproj'), + workspacePath: ' ', + scheme: 'TestApp', + }, + context(), + ), + ).rejects.toThrow('blank workspacePath'); + + // 4. Valid workspace + blank project + await expect( + testTool( + { + ...creds, + workspacePath: join(root, 'TestApp.xcworkspace'), + projectPath: ' ', + scheme: 'TestApp', + }, + context(), + ), + ).rejects.toThrow('blank projectPath'); + + // 5. Blank scheme + await expect( + testTool( + { ...creds, projectPath: join(root, 'TestApp.xcodeproj'), scheme: ' ' }, + context(), + ), + ).rejects.toThrow('non-empty scheme'); + + // 6. Blank simulatorId + await expect( + testTool( + { + ...creds, + projectPath: join(root, 'TestApp.xcodeproj'), + scheme: 'TestApp', + simulatorId: ' ', + }, + context(), + ), + ).rejects.toThrow('explicit simulatorId'); + + // 7. Session defaults configured with source, scheme, simulatorId, but raw args omitted + sessionStore.setDefaults({ + scheme: 'DefaultScheme', + projectPath: join(root, 'TestApp.xcodeproj'), + simulatorId: lease.simulatorId, + }); + + // Raw missing source even when in session defaults + await expect(testTool({ ...creds, scheme: 'TestApp' }, context())).rejects.toThrow( + 'exactly one of projectPath or workspacePath', + ); + + // Raw missing scheme even when in session defaults + await expect( + testTool({ ...creds, projectPath: join(root, 'TestApp.xcodeproj') }, context()), + ).rejects.toThrow('non-empty scheme'); + + // Raw missing simulatorId even when in session defaults + await expect( + testTool( + { + projectPath: join(root, 'TestApp.xcodeproj'), + scheme: 'TestApp', + operationRequestId: lease.requestId, + operationToken: lease.token, + operationSessionId: lease.sessionId, + }, + context(), + ), + ).rejects.toThrow(); + + sessionStore.clear(); + + // 8. Prepared test paths rejected on raw args + const base = defaultTestParams(root); + await expect( + testTool({ ...base, testProductsPath: '/tmp/fixture.xctestproducts' }, context()), + ).rejects.toThrow('prepared test artifacts'); + await expect( + testTool({ ...base, xctestrunPath: '/tmp/fixture.xctestrun' }, context()), + ).rejects.toThrow('prepared test artifacts'); + + // 9. Forbidden buildForTesting + await expect(testTool({ ...base, buildForTesting: true }, context())).rejects.toThrow( + 'buildForTesting', + ); + + expect(calls).toHaveLength(0); + expect((await manager.getStatus(lease.requestId)).activities).toHaveLength(0); + }); + + it('allows test selectors while keeping the managed destination and execution policy', async () => { + setupSyncExtractSentinels(); + const { calls, root } = commands(); + const selector = '-only-testing:TestAppTests/TestAppTests/testExample'; + + await testTool({ ...defaultTestParams(root), extraArgs: [selector] }, context()); + + const xcodebuildCalls = calls.filter((command) => command[0] === 'xcodebuild'); + expect(xcodebuildCalls).toHaveLength(2); + expect(xcodebuildCalls[0]).toContain(selector); + expect(xcodebuildCalls[1]).toContain(selector); + expect(xcodebuildCalls[1]).toContain('-parallel-testing-enabled'); + expect(xcodebuildCalls[1]).toContain('-maximum-concurrent-test-simulator-destinations'); + expect((await manager.end(lease)).state).toBe('released'); + }); + + it('rejects unsafe extraArgs and preferXcodebuild=false (explicit and session defaults) with 0 commands', async () => { + const { calls, root } = commands(); + const base = defaultTestParams(root); + + // Direct non-empty extraArgs + await expect(testTool({ ...base, extraArgs: ['-verbose'] }, context())).rejects.toThrow( + 'extraArgs', + ); + + // Inherited non-empty extraArgs from session defaults + sessionStore.setDefaults({ extraArgs: ['-inherited'] }); + await expect(testTool(base, context())).rejects.toThrow('extraArgs'); + sessionStore.clear(); + + // Direct preferXcodebuild = false + await expect(testTool({ ...base, preferXcodebuild: false }, context())).rejects.toThrow( + 'preferXcodebuild=false', + ); + + // Inherited preferXcodebuild = false from session defaults + sessionStore.setDefaults({ preferXcodebuild: false }); + await expect(testTool(base, context())).rejects.toThrow('preferXcodebuild=false'); + sessionStore.clear(); + + expect(calls).toHaveLength(0); + expect((await manager.getStatus(lease.requestId)).activities).toHaveLength(0); + }); + + it.each([64, 65, 66] as const)( + 'Phase 1 confirmed exit %i (compile failure): does not invoke shutdown or xcresult, stays active+activity0, released on explicit end', + async (phase1ExitCode) => { + setupSyncExtractSentinels(); + const { calls, root } = commands({ + phase1Failure: 'exit', + phase1ExitCode, + }); + const ctx = context(); + + await testTool(defaultTestParams(root), ctx); + + expect(ctx.structuredOutput?.result.didError).toBe(true); + expect(ctx.structuredOutput?.result).toMatchObject({ + summary: { status: 'FAILED' }, + }); + + // Phase 1 failed: No Phase 2, no xcresulttool, no simctl shutdown + expect(calls.some((c) => c.includes('test-without-building'))).toBe(false); + expect(calls.some((c) => c[0] === 'xcrun' && c.includes('tests'))).toBe(false); + expect(calls.some((c) => c[0] === 'xcrun' && c.includes('shutdown'))).toBe(false); + + // Active state with 0 remaining activities + const status = await manager.getStatus(lease.requestId); + expect(status.state).toBe('active'); + expect(status.activities).toHaveLength(0); + + // Explicit end releases lease cleanly + expect((await manager.end(lease)).state).toBe('released'); + }, + ); + + it.each([ + [0, false, 'SUCCEEDED'], + [65, true, 'FAILED'], + ])( + 'Phase 2 exit %i: executes shutdown and post-list, staying active+activity0, released on end', + async (exitCode, didError, statusString) => { + setupSyncExtractSentinels(); + const { calls, root } = commands({ + phase2Failure: exitCode === 0 ? undefined : 'exit', + phase2ExitCode: exitCode, + }); + const ctx = context(); + + await testTool(defaultTestParams(root), ctx); + + expect(ctx.structuredOutput?.result.didError).toBe(didError); + expect(ctx.structuredOutput?.result).toMatchObject({ + summary: { status: statusString }, + }); + + // Both execute shutdown and post-list commands with complete argv + const shutdownCall = calls.find( + (c) => c[0] === 'xcrun' && c[1] === 'simctl' && c[2] === 'shutdown', + ); + expect(shutdownCall).toEqual(['xcrun', 'simctl', 'shutdown', lease.simulatorId]); + + const shutdownIdx = calls.indexOf(shutdownCall!); + expect(shutdownIdx).toBeGreaterThan(-1); + + const postListCall = calls + .slice(shutdownIdx + 1) + .find( + (c) => + c[0] === 'xcrun' && + c[1] === 'simctl' && + c[2] === 'list' && + c[3] === 'devices' && + c[4] === '--json', + ); + expect(postListCall).toEqual(['xcrun', 'simctl', 'list', 'devices', '--json']); + + const status = await manager.getStatus(lease.requestId); + expect(status.state).toBe('active'); + expect(status.activities).toHaveLength(0); + + expect((await manager.end(lease)).state).toBe('released'); + }, + ); + + it.each([ + ['phase 1 signal', { phase1Failure: 'signal' as const }], + ['phase 2 confirmed nonzero exit', { phase2Failure: 'exit' as const, phase2ExitCode: 1 }], + ['phase 2 signal', { phase2Failure: 'signal' as const }], + ['phase 2 open stream', { phase2Failure: 'open-stream' as const }], + ['phase 2 unknown exit', { phase2Failure: 'unknown-exit' as const }], + ['shutdown nonzero exit', { shutdownFailure: 'exit' as const }], + ['shutdown reject', { shutdownFailure: 'reject' as const }], + ['post-catalog non-Shutdown', { postCatalogState: 'Booted' }], + ['metadata reject', { metadataFailure: 'reject' as const }], + ])('blocks lease and preserves unfinished activity on %s failure', async (_name, opts) => { + setupSyncExtractSentinels(); + const { root } = commands(opts); + + const binding = await manager.getBinding((await manager.getStatus(lease.requestId)).generation); + if (!binding) throw new Error('Missing fixture binding'); + const waiting = await manager.requestLease({ + requestId: randomUUID(), + owner: { sessionId: randomUUID() }, + worktree: binding.worktree, + }); + + let thrownError: unknown; + try { + await testTool(defaultTestParams(root), context()); + } catch (error) { + thrownError = error; + } + expect(thrownError).toBeDefined(); + expect((thrownError as Error).message).toContain('completion could not be confirmed'); + + const status = await manager.getStatus(lease.requestId); + expect(status.state).toBe('blocked'); + expect(status.activities).toHaveLength(1); + await expect(manager.end(lease)).rejects.toThrow( + 'Operation is blocked; end/cancel cannot recover it', + ); + expect((await manager.poll(waiting)).state).toBe('waiting'); + }); + + it.each([ + ['first metadata signal', 'signal-tests' as const, 1], + ['second metadata signal', 'signal-summary' as const, 2], + ])( + 'blocks lease and preserves unfinished activity on %s', + async (_name, metadataFailure, expectedMetadataCalls) => { + setupSyncExtractSentinels(); + const { calls, root } = commands({ metadataFailure }); + const markProductsSpy = vi.spyOn(testProductsModule, 'markTestProductsPathCompleted'); + const markResultSpy = vi.spyOn(resultBundleModule, 'markResultBundlePathCompleted'); + + const binding = await manager.getBinding( + (await manager.getStatus(lease.requestId)).generation, + ); + if (!binding) throw new Error('Missing fixture binding'); + const waiting = await manager.requestLease({ + requestId: randomUUID(), + owner: { sessionId: randomUUID() }, + worktree: binding.worktree, + }); + + let thrownError: unknown; + try { + await testTool(defaultTestParams(root), context()); + } catch (error) { + thrownError = error; + } + + expect(thrownError).toBeDefined(); + expect((thrownError as Error).message).toContain('completion could not be confirmed'); + const gateError = thrownError as Error & { cause?: unknown }; + expect(gateError.cause).toBeInstanceOf(SimulatorTestUncertainError); + const uncertainError = gateError.cause as Error & { cause?: unknown }; + expect(uncertainError.cause).toMatchObject({ + message: + metadataFailure === 'signal-tests' + ? 'xcresult test failure extraction interrupted by signal: SIGTERM' + : 'xcresult test summary extraction interrupted by signal: SIGTERM', + }); + + const metadataCalls = calls.filter( + (command) => command[0] === 'xcrun' && command[1] === 'xcresulttool', + ); + expect(metadataCalls).toHaveLength(expectedMetadataCalls); + expect(metadataCalls[0]).toEqual([ + 'xcrun', + 'xcresulttool', + 'get', + 'test-results', + 'tests', + '--path', + expect.any(String), + ]); + if (metadataFailure === 'signal-summary') { + expect(metadataCalls[1]).toEqual([ + 'xcrun', + 'xcresulttool', + 'get', + 'test-results', + 'summary', + '--path', + expect.any(String), + '--compact', + ]); + } + expect( + calls.some( + (command) => + command[0] === 'xcrun' && command[1] === 'simctl' && command[2] === 'shutdown', + ), + ).toBe(false); + expect(markProductsSpy).not.toHaveBeenCalled(); + expect(markResultSpy).not.toHaveBeenCalled(); + + const status = await manager.getStatus(lease.requestId); + expect(status.state).toBe('blocked'); + expect(status.activities).toHaveLength(1); + await expect(manager.end(lease)).rejects.toThrow( + 'Operation is blocked; end/cancel cannot recover it', + ); + expect((await manager.poll(waiting)).state).toBe('waiting'); + }, + ); + + it('blocks lease and preserves cause when non-executor exception (emit callback throw) occurs during failure fragments without mocking async extractor', async () => { + setupSyncExtractSentinels(); + const { calls, root } = commands({ phase2ExitCode: 65, xcresultFailures: true }); + const emitExplosion = new Error('Failure fragment emit explosion'); + + const binding = await manager.getBinding((await manager.getStatus(lease.requestId)).generation); + if (!binding) throw new Error('Missing fixture binding'); + const waiting = await manager.requestLease({ + requestId: randomUUID(), + owner: { sessionId: randomUUID() }, + worktree: binding.worktree, + }); + + const ctx: ToolHandlerContext = { + emit: (fragment) => { + if ( + fragment.kind === 'test-result' && + (fragment as unknown as { fragment?: string }).fragment === 'test-failure' + ) { + throw emitExplosion; + } + }, + attach: () => {}, + }; + + let capturedGateError: unknown; + try { + await testTool(defaultTestParams(root), ctx); + } catch (error) { + capturedGateError = error; + } + + expect(capturedGateError).toBeDefined(); + const gateError = capturedGateError as Error & { cause?: unknown }; + expect(gateError.cause).toBeInstanceOf(SimulatorTestUncertainError); + expect((gateError.cause as Error & { cause?: unknown }).cause).toBe(emitExplosion); + + const status = await manager.getStatus(lease.requestId); + expect(status.state).toBe('blocked'); + expect(status.activities).toHaveLength(1); + await expect(manager.end(lease)).rejects.toThrow( + 'Operation is blocked; end/cancel cannot recover it', + ); + expect((await manager.poll(waiting)).state).toBe('waiting'); + + // Subsequent shutdown was not called + expect(calls.some((c) => c[0] === 'xcrun' && c[1] === 'simctl' && c[2] === 'shutdown')).toBe( + false, + ); + }); + + it('generic phase 1 emit error before phase 2 finishes activity without blocking lease, allowing waiting session to be granted', async () => { + setupSyncExtractSentinels(); + const { calls, root } = commands(); + const phase1Explosion = new Error('Phase 1 discovery emit explosion'); + + const binding = await manager.getBinding((await manager.getStatus(lease.requestId)).generation); + if (!binding) throw new Error('Missing fixture binding'); + const waiting = await manager.requestLease({ + requestId: randomUUID(), + owner: { sessionId: randomUUID() }, + worktree: binding.worktree, + }); + + const ctx: ToolHandlerContext = { + emit: (fragment) => { + if ( + fragment.kind === 'test-result' && + (fragment as unknown as { fragment?: string }).fragment === 'build-stage' + ) { + throw phase1Explosion; + } + }, + attach: () => {}, + }; + + let capturedPhase1Error: unknown; + try { + await testTool(defaultTestParams(root), ctx); + } catch (error) { + capturedPhase1Error = error; + } + + expect(capturedPhase1Error).toBe(phase1Explosion); + + // Active state with 0 remaining activities (finishActivity called) + const status = await manager.getStatus(lease.requestId); + expect(status.state).toBe('active'); + expect(status.activities).toHaveLength(0); + + // Explicit end releases lease and grants waiting session + expect((await manager.end(lease)).state).toBe('released'); + expect((await manager.poll(waiting)).state).toBe('active'); + + expect(calls.some((c) => c[0] === 'xcrun' && c[1] === 'simctl' && c[2] === 'shutdown')).toBe( + false, + ); + }); + + it('race: holds closing during shutdown and post-list, waiting session granted upon verified shutdown', async () => { + setupSyncExtractSentinels(); + const enteredShutdown = createDeferred(); + const releaseShutdown = createDeferred(); + const enteredPostList = createDeferred(); + const releasePostList = createDeferred(); + + const { root } = commands({ + onShutdown: async () => { + enteredShutdown.resolve(); + await releaseShutdown.promise; + }, + onPostList: async () => { + enteredPostList.resolve(); + await releasePostList.promise; + }, + }); + + const binding = await manager.getBinding((await manager.getStatus(lease.requestId)).generation); + if (!binding) throw new Error('Missing fixture binding'); + const waiting = await manager.requestLease({ + requestId: randomUUID(), + owner: { sessionId: randomUUID() }, + worktree: binding.worktree, + }); + + const run = testTool(defaultTestParams(root), context()); + + try { + // 1. Pause inside shutdown + await enteredShutdown.promise; + expect((await manager.end(lease)).state).toBe('closing'); + expect((await manager.poll(waiting)).state).toBe('waiting'); + + // A new call to the closing lease is rejected + await expect(testTool(defaultTestParams(root), context())).rejects.toThrow(); + + releaseShutdown.resolve(); + + // 2. Pause inside post-list verification + await enteredPostList.promise; + expect((await manager.getStatus(lease.requestId)).state).toBe('closing'); + expect((await manager.poll(waiting)).state).toBe('waiting'); + + releasePostList.resolve(); + + // 3. Complete run + await run; + + expect((await manager.getStatus(lease.requestId)).state).toBe('released'); + expect((await manager.poll(waiting)).state).toBe('active'); + } finally { + releaseShutdown.resolve(); + releasePostList.resolve(); + await run.catch(() => {}); + } + }); + + it('race: when post-list verification fails, lease becomes blocked and waiting session is not granted', async () => { + setupSyncExtractSentinels(); + const enteredPostList = createDeferred(); + const releasePostList = createDeferred(); + + const { root } = commands({ + postCatalogState: 'Booted', // verification failure + onPostList: async () => { + enteredPostList.resolve(); + await releasePostList.promise; + }, + }); + + const binding = await manager.getBinding((await manager.getStatus(lease.requestId)).generation); + if (!binding) throw new Error('Missing fixture binding'); + const waiting = await manager.requestLease({ + requestId: randomUUID(), + owner: { sessionId: randomUUID() }, + worktree: binding.worktree, + }); + + const run = testTool(defaultTestParams(root), context()); + + try { + await enteredPostList.promise; + expect((await manager.end(lease)).state).toBe('closing'); + expect((await manager.poll(waiting)).state).toBe('waiting'); + + releasePostList.resolve(); + + await expect(run).rejects.toThrow('completion could not be confirmed'); + + expect((await manager.getStatus(lease.requestId)).state).toBe('blocked'); + expect((await manager.poll(waiting)).state).toBe('waiting'); + } finally { + releasePostList.resolve(); + await run.catch(() => {}); + } + }); + + it('invalidates previous UI snapshot on admission (verified at first device call) and suppresses next-step metadata', async () => { + vi.spyOn(defaultAxeHelpers, 'getAxePath').mockReturnValue('/fixture/axe'); + vi.spyOn(defaultAxeHelpers, 'getBundledAxeEnvironment').mockReturnValue({}); + setupSyncExtractSentinels(); + + const { root, startObservingDeviceCalls, getFirstDeviceCallSnapshotStatus } = commands(); + + // 1. Establish snapshot provenance via real managed snapshot_ui + const snapshotCtx = context(); + const snapshotTool = wrapManagedTool('mcp/tools/ui-automation/snapshot_ui', (args, ctx) => { + if (!ctx) throw new Error('Missing fixture context'); + return snapshotHandler(args, ctx); + }); + await snapshotTool(credentials(), snapshotCtx); + + expect(getRuntimeSnapshotLookup(lease.simulatorId).status).toBe('available'); + + // 2. Start observing device calls specifically for test_sim admission + startObservingDeviceCalls(); + + // 3. Invoke test_sim + const ctx = context(); + await testTool(defaultTestParams(root), ctx); + + // Snapshot was invalidated upon admission (observed at first device executor call) + expect(getFirstDeviceCallSnapshotStatus()).toBe('missing'); + expect(getRuntimeSnapshotLookup(lease.simulatorId).status).toBe('missing'); + + // Schema version 3 preserved + expect(ctx.structuredOutput?.schemaVersion).toBe('3'); + expect(ctx.structuredOutput?.result.kind).toBe('test-result'); + + // Next-step metadata suppressed + expect(ctx.nextSteps).toEqual([]); + expect(ctx.nextStepParams).toBeUndefined(); + expect(ctx.nextStepConditionKeys).toBeUndefined(); + }); +}); diff --git a/src/resource-management/__tests__/tool-gate.test.ts b/src/resource-management/__tests__/tool-gate.test.ts new file mode 100644 index 000000000..48f08d3b5 --- /dev/null +++ b/src/resource-management/__tests__/tool-gate.test.ts @@ -0,0 +1,265 @@ +import { randomUUID } from 'node:crypto'; +import { PassThrough } from 'node:stream'; +import type { ChildProcess } from 'node:child_process'; +import { mkdtemp, mkdir, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import * as z from 'zod'; +import { afterEach, beforeEach, expect, it, vi } from 'vitest'; +import { createMockExecutor } from '../../test-utils/mock-executors.ts'; +import { + __setTestCommandExecutorOverride, + __clearTestExecutorOverrides, +} from '../../utils/command.ts'; +import { + createSessionAwareToolWithContext, + getHandlerContext, +} from '../../utils/typed-tool-factory.ts'; +import { sessionStore } from '../../utils/session-store.ts'; +import type { ToolHandlerContext } from '../../rendering/types.ts'; +import { resourceEnvironment, assertResourceNamespace } from '../environment.ts'; +import { managedToolSchema, wrapManagedTool } from '../tool-gate.ts'; +import { SimulatorResourceManager, type Lease } from '../manager.ts'; +import { resolveManagedWorktree } from '../worktree.ts'; +import { handler as operation } from '../../mcp/tools/resource-management/resource_operation.ts'; +import { renderCliTextTranscript } from '../../utils/renderers/cli-text-renderer.ts'; +import { supervisedExecutor } from '../execution.ts'; +import { assertManagedSocketPath, getSocketPath } from '../../daemon/socket-path.ts'; +import { + configureRuntimeWorkspaceKey, + setRuntimeInstanceForTests, +} from '../../utils/runtime-instance.ts'; +import { workspaceKeyForRoot } from '../../utils/workspace-identity.ts'; + +let directory: string; +let manager: SimulatorResourceManager; +let lease: Lease; +const moduleId = 'mcp/tools/ui-automation/screenshot'; +const schema = z.object({ simulatorId: z.uuid() }); +const context = (): ToolHandlerContext => ({ emit: () => {}, attach: () => {} }); +const credentials = () => ({ + operationRequestId: lease.requestId, + operationToken: lease.token, + operationSessionId: lease.sessionId, +}); + +beforeEach(async () => { + directory = await mkdtemp(join(tmpdir(), 'resource-gate-')); + const root = join(directory, 'checkout'); + await mkdir(join(root, '.git'), { recursive: true }); + vi.stubEnv('XCODEBUILDMCP_OPERATION_STATE_ROOT', join(directory, 'state')); + vi.stubEnv('XCODEBUILDMCP_OPERATION_CAPACITY', '1'); + const executor = createMockExecutor({}); + __setTestCommandExecutorOverride(async (...args) => { + if (args[0][0] !== 'git') throw new Error('Unexpected external command'); + return { + ...(await executor(...args)), + output: `${args[0].at(-1) === '--show-toplevel' ? root : join(root, '.git')}\n`, + }; + }); + const worktree = await resolveManagedWorktree(root, async (...args) => { + return { + ...(await executor(...args)), + output: `${args[0].at(-1) === '--show-toplevel' ? root : join(root, '.git')}\n`, + }; + }); + manager = await SimulatorResourceManager.open(resourceEnvironment()!); + await manager.bind(worktree, { deviceType: 'fixture', runtime: 'fixture' }, randomUUID()); + lease = await manager.requestLease({ + requestId: randomUUID(), + owner: { sessionId: randomUUID() }, + worktree, + }); + sessionStore.clear(); +}); + +afterEach(async () => { + __clearTestExecutorOverrides(); + sessionStore.clear(); + vi.unstubAllEnvs(); + setRuntimeInstanceForTests(null); + await rm(directory, { recursive: true, force: true }); +}); + +function tool(logic: () => Promise) { + const handler = createSessionAwareToolWithContext({ + internalSchema: schema, + logicFunction: logic, + getContext: () => undefined, + }); + return wrapManagedTool(moduleId, (args, ctx) => { + if (!ctx) throw new Error('Missing fixture context'); + return handler(args, ctx); + }); +} + +function succeed(): void { + getHandlerContext().structuredOutput = { + schema: 'xcodebuildmcp.output.capture-result', + schemaVersion: '2', + result: { + kind: 'capture-result', + didError: false, + error: null, + summary: { status: 'SUCCEEDED' }, + artifacts: { simulatorId: lease.simulatorId }, + }, + }; +} + +it('checks the final default target before invoking any device work', async () => { + const logic = vi.fn(async () => succeed()); + sessionStore.setDefaults({ simulatorId: randomUUID() }); + await expect(tool(logic)(credentials(), context())).rejects.toThrow('Effective target'); + expect(logic).not.toHaveBeenCalled(); + sessionStore.setDefaults({ simulatorId: lease.simulatorId.toUpperCase() }); + await tool(logic)(credentials(), context()); + expect(logic).toHaveBeenCalledOnce(); + expect((await manager.getStatus(lease.requestId)).activities).toHaveLength(0); +}); + +it('keeps the operation held after a call and drains end before handing off', async () => { + let release!: () => void; + let admitted!: () => void; + const started = new Promise((resolve) => { + admitted = resolve; + }); + const finish = new Promise((resolve) => { + release = resolve; + }); + const call = tool(async () => { + admitted(); + await finish; + succeed(); + })({ ...credentials(), simulatorId: lease.simulatorId }, context()); + await started; + expect((await manager.end(lease)).state).toBe('closing'); + release(); + await call; + expect((await manager.getStatus(lease.requestId)).state).toBe('released'); + await expect( + tool(async () => succeed())({ ...credentials(), simulatorId: lease.simulatorId }, context()), + ).rejects.toThrow('released'); +}); + +it('serializes concurrent tool calls using the same token', async () => { + let inFlight = 0; + let maximum = 0; + const run = tool(async () => { + maximum = Math.max(maximum, ++inFlight); + await new Promise((resolve) => setTimeout(resolve, 30)); + inFlight--; + succeed(); + }); + await Promise.all([ + run({ ...credentials(), simulatorId: lease.simulatorId }, context()), + run({ ...credentials(), simulatorId: lease.simulatorId }, context()), + ]); + expect(maximum).toBe(1); + expect((await manager.getStatus(lease.requestId)).state).toBe('active'); +}); + +it.each(['rejection', 'open-stream'])( + 'blocks uncertain command completion even when logic swallows it: %s', + async (failure) => { + const stream = new PassThrough(); + const execute = supervisedExecutor( + createMockExecutor( + failure === 'rejection' + ? { shouldThrow: new Error('lost supervision') } + : { + process: { + exitCode: 0, + signalCode: null, + stdout: stream, + stderr: null, + } as unknown as ChildProcess, + }, + ), + ); + const run = tool(async () => { + try { + await execute(['fixture-command']); + } catch { + /* Existing helpers may swallow failures. */ + } + succeed(); + }); + await expect( + run({ ...credentials(), simulatorId: lease.simulatorId }, context()), + ).rejects.toThrow(); + await expect(manager.end(lease)).rejects.toThrow( + 'Operation is blocked; end/cancel cannot recover it', + ); + expect((await manager.getStatus(lease.requestId)).activities).toHaveLength(1); + stream.destroy(); + }, +); + +it('preserves domain failures after observed completion and permits explicit end', async () => { + const execute = supervisedExecutor(createMockExecutor({ success: false, exitCode: 1 })); + const ctx = context(); + await tool(async () => { + await execute(['fixture-command']); + succeed(); + getHandlerContext().structuredOutput!.result.didError = true; + })({ ...credentials(), simulatorId: lease.simulatorId }, ctx); + expect(ctx.structuredOutput?.result.didError).toBe(true); + expect((await manager.end(lease)).state).toBe('released'); +}); + +it('does not permanently occupy a device for a preflight failure without commands', async () => { + await tool(async () => { + succeed(); + getHandlerContext().structuredOutput!.result.didError = true; + })({ ...credentials(), simulatorId: lease.simulatorId }, context()); + expect((await manager.end(lease)).state).toBe('released'); +}); + +it('rejects missing credentials, unsupported tools and disabled recipients', async () => { + const handler = vi.fn(async () => {}); + await expect(wrapManagedTool(moduleId, handler)({}, context())).rejects.toThrow('credentials'); + await expect( + wrapManagedTool('mcp/tools/simulator/clean_sim', handler)(credentials(), context()), + ).rejects.toThrow('not yet supported'); + expect(managedToolSchema(moduleId, schema.shape)).toHaveProperty('operationToken'); + expect(managedToolSchema(moduleId, {})).toHaveProperty('simulatorId'); + vi.stubEnv('XCODEBUILDMCP_OPERATION_STATE_ROOT', undefined); + vi.stubEnv('XCODEBUILDMCP_OPERATION_CAPACITY', undefined); + await expect(wrapManagedTool(moduleId, handler)(credentials(), context())).rejects.toThrow( + 'require managed', + ); + expect(handler).not.toHaveBeenCalled(); +}); + +it('returns durable management results, redacts status, and renders final state without fragments', async () => { + const ctx = context(); + await operation({ action: 'status', requestId: lease.requestId }, ctx); + expect(ctx.structuredOutput?.result).not.toHaveProperty('token'); + expect(renderCliTextTranscript({ structuredOutput: ctx.structuredOutput })).toContain( + 'State: active', + ); + await operation( + { action: 'end', requestId: lease.requestId, sessionId: lease.sessionId, token: lease.token }, + ctx, + ); + expect(ctx.structuredOutput?.result).toMatchObject({ state: 'released', activityCount: 0 }); +}); + +it('requires a complete opt-in and matching daemon capability', () => { + expect(() => assertResourceNamespace(undefined)).toThrow('namespace mismatch'); + assertResourceNamespace(resourceEnvironment()!.namespace); + vi.stubEnv('XCODEBUILDMCP_OPERATION_CAPACITY', undefined); + expect(() => resourceEnvironment()).toThrow('require'); +}); + +it('validates sockets against the bootstrapped workspace when invoked from a subdirectory', () => { + const root = join(directory, 'checkout'); + configureRuntimeWorkspaceKey(workspaceKeyForRoot(root)); + const socket = getSocketPath({ + cwd: join(root, 'src'), + projectConfigPath: join(root, '.xcodebuildmcp', 'config.yaml'), + }); + expect(() => assertManagedSocketPath(socket)).not.toThrow(); + expect(() => assertManagedSocketPath('/tmp/foreign.sock')).toThrow('isolated'); +}); diff --git a/src/resource-management/__tests__/transports.test.ts b/src/resource-management/__tests__/transports.test.ts new file mode 100644 index 000000000..ad933d333 --- /dev/null +++ b/src/resource-management/__tests__/transports.test.ts @@ -0,0 +1,185 @@ +import { randomUUID } from 'node:crypto'; +import { fork, type ChildProcess } from 'node:child_process'; +import { once } from 'node:events'; +import { mkdtemp, mkdir, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; +import { afterEach, expect, it, vi } from 'vitest'; +import { createMockExecutor } from '../../test-utils/mock-executors.ts'; +import { DaemonClient } from '../../cli/daemon-client.ts'; +import { SimulatorResourceManager } from '../manager.ts'; +import { resolveManagedWorktree } from '../worktree.ts'; +import { resourceEnvironment } from '../environment.ts'; +import type { ResourceOperationDomainResult } from '../../types/domain-results.ts'; + +const fixture = fileURLToPath(new URL('./fixtures/transport-worker.ts', import.meta.url)); +const loader = import.meta.resolve('tsx'); +const clients: Client[] = []; +const children: { child: ChildProcess; exited: Promise }[] = []; +let directory: string | undefined; +let socketDirectory: string | undefined; + +afterEach(async () => { + await Promise.all(clients.splice(0).map((client) => client.close())); + for (const { child, exited } of children.splice(0)) { + if (child.exitCode === null && child.signalCode === null) child.kill('SIGKILL'); + await exited; + } + if (socketDirectory) await rm(socketDirectory, { recursive: true, force: true }); + if (directory) await rm(directory, { recursive: true, force: true }); + vi.unstubAllEnvs(); +}); + +it('hands off a bound Simulator between live MCP sessions, direct CLI, and the daemon', async () => { + directory = await mkdtemp(join(tmpdir(), 'resource-transports-')); + const root = join(directory, 'checkout'); + await mkdir(join(root, '.git'), { recursive: true }); + vi.stubEnv('XCODEBUILDMCP_OPERATION_STATE_ROOT', join(directory, 'state')); + vi.stubEnv('XCODEBUILDMCP_OPERATION_CAPACITY', '1'); + const env = Object.fromEntries( + Object.entries(process.env).filter( + (entry): entry is [string, string] => entry[1] !== undefined, + ), + ); + const executor = createMockExecutor({}); + const tree = await resolveManagedWorktree(root, async (...args) => ({ + ...(await executor(...args)), + output: `${args[0].at(-1) === '--show-toplevel' ? root : join(root, '.git')}\n`, + })); + const manager = await SimulatorResourceManager.open(resourceEnvironment()!); + const simulatorId = randomUUID(); + await manager.bind(tree, { deviceType: 'fixture', runtime: 'fixture' }, simulatorId); + + async function mcp(): Promise { + const client = new Client({ name: 'lease-test', version: '1' }); + clients.push(client); + const transport = new StdioClientTransport({ + command: process.execPath, + args: ['--import', loader, fixture, 'mcp'], + cwd: root, + env, + stderr: 'pipe', + }); + let errors = ''; + transport.stderr?.on('data', (chunk: Buffer) => { + errors += chunk.toString(); + }); + try { + await client.connect(transport); + } catch (error) { + throw new Error(errors, { cause: error }); + } + return client; + } + async function call( + client: Client, + args: Record, + ): Promise { + const response = await client.callTool({ name: 'resource_operation', arguments: args }); + if (response.isError) throw new Error(JSON.stringify(response.content)); + return (response.structuredContent as { data: ResourceOperationDomainResult }).data; + } + const a = await mcp(); + const b = await mcp(); + const requestA = { requestId: randomUUID(), sessionId: randomUUID() }; + const requestB = { requestId: randomUUID(), sessionId: randomUUID() }; + const first = await call(a, { action: 'begin', ...requestA }); + expect(first).toMatchObject({ state: 'active', simulatorId }); + expect(await call(a, { action: 'begin', ...requestA })).toMatchObject({ token: first.token }); + expect(await call(b, { action: 'begin', ...requestB })).toMatchObject({ state: 'waiting' }); + await call(a, { action: 'end', ...requestA, token: first.token }); + const second = await call(b, { action: 'begin', ...requestB }); + expect(second).toMatchObject({ state: 'active', simulatorId }); + // A remains connected and can inspect its completed operation after B takes over. + expect(await call(a, { action: 'status', requestId: requestA.requestId })).toMatchObject({ + state: 'released', + }); + + function spawn(mode: string, args: string[] = []): ChildProcess { + const child = fork(fixture, [mode, ...args], { + cwd: root, + env, + execArgv: ['--import', loader], + stdio: ['ignore', 'pipe', 'pipe', 'ipc'], + }); + children.push({ child, exited: once(child, 'exit') }); + return child; + } + const cli = spawn('cli', [ + 'resource-management', + 'operation', + '--action', + 'end', + '--request-id', + requestB.requestId, + '--session-id', + requestB.sessionId, + '--token', + second.token!, + '--output', + 'json', + ]); + let stdout = ''; + let stderr = ''; + cli.stdout?.on('data', (chunk: Buffer) => { + stdout += chunk.toString(); + }); + cli.stderr?.on('data', (chunk: Buffer) => { + stderr += chunk.toString(); + }); + const [code] = await once(cli, 'exit'); + expect(code, stderr).toBe(0); + expect(JSON.parse(stdout).data).toMatchObject({ state: 'released' }); + + const daemon = spawn('daemon'); + let daemonError = ''; + daemon.stderr?.on('data', (chunk: Buffer) => { + daemonError += chunk.toString(); + }); + const [ready] = (await Promise.race([ + once(daemon, 'message'), + once(daemon, 'exit').then(() => { + throw new Error(`Daemon fixture exited: ${daemonError}`); + }), + ])) as [{ ready: boolean; socketPath: string }]; + socketDirectory = dirname(ready.socketPath); + // Namespace checks are independent of the caller cwd; supply this fixture's target explicitly. + vi.stubEnv('XCODEBUILDMCP_OPERATION_STATE_ROOT', undefined); + vi.stubEnv('XCODEBUILDMCP_OPERATION_CAPACITY', undefined); + const client = new DaemonClient({ socketPath: ready.socketPath }); + expect((await client.status()).resourceNamespace).toBe(resourceEnvironment(env)!.namespace); + const requestC = { requestId: randomUUID(), sessionId: randomUUID() }; + const routed = spawn('cli-daemon', [ + 'resource-management', + 'operation', + '--action', + 'begin', + '--request-id', + requestC.requestId, + '--session-id', + requestC.sessionId, + '--output', + 'json', + ]); + let routedOutput = ''; + let routedError = ''; + routed.stdout?.on('data', (chunk: Buffer) => { + routedOutput += chunk.toString(); + }); + routed.stderr?.on('data', (chunk: Buffer) => { + routedError += chunk.toString(); + }); + expect((await once(routed, 'exit'))[0], routedError).toBe(0); + const result = JSON.parse(routedOutput).data as ResourceOperationDomainResult; + expect(result).toMatchObject({ state: 'active', simulatorId }); + await expect( + client.invokeTool('resource_operation', { action: 'status', requestId: requestC.requestId }), + ).rejects.toThrow('namespace mismatch'); + expect(await call(a, { action: 'status', requestId: requestC.requestId })).not.toHaveProperty( + 'token', + ); + await call(a, { action: 'end', ...requestC, token: result.token }); +}, 20_000); diff --git a/src/resource-management/__tests__/ui.test.ts b/src/resource-management/__tests__/ui.test.ts new file mode 100644 index 000000000..5347dc2a1 --- /dev/null +++ b/src/resource-management/__tests__/ui.test.ts @@ -0,0 +1,401 @@ +import { randomUUID } from 'node:crypto'; +import { renderCliTextTranscript } from '../../utils/renderers/cli-text-renderer.ts'; +import { PassThrough } from 'node:stream'; +import { mkdtemp, mkdir, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, expect, it, vi } from 'vitest'; +import { createMockExecutor } from '../../test-utils/mock-executors.ts'; +import { + __setTestCommandExecutorOverride, + __clearTestExecutorOverrides, +} from '../../utils/command.ts'; +import type { ToolHandlerContext } from '../../rendering/types.ts'; +import type { RuntimeSnapshotV1 } from '../../types/ui-snapshot.ts'; +import { resourceEnvironment } from '../environment.ts'; +import { managedToolSchema, wrapManagedTool } from '../tool-gate.ts'; +import { SimulatorResourceManager, type Lease } from '../manager.ts'; +import { resolveManagedWorktree } from '../worktree.ts'; +import { handler as snapshotHandler } from '../../mcp/tools/ui-automation/snapshot_ui.ts'; +import { handler as tapHandler } from '../../mcp/tools/ui-automation/tap.ts'; +import { handler as typeHandler } from '../../mcp/tools/ui-automation/type_text.ts'; +import { handler as launchHandler } from '../../mcp/tools/simulator/launch_app_sim.ts'; +import { defaultAxeHelpers } from '../../mcp/tools/ui-automation/shared/axe-command.ts'; +import { + __resetRuntimeSnapshotStoreForTests, + withSimulatorUiAutomationTransaction, +} from '../../mcp/tools/ui-automation/shared/snapshot-ui-state.ts'; + +let directory: string; +let manager: SimulatorResourceManager; +let lease: Lease; +const credentials = () => ({ + simulatorId: lease.simulatorId, + operationRequestId: lease.requestId, + operationToken: lease.token, + operationSessionId: lease.sessionId, +}); +const handlers = { + snapshot_ui: snapshotHandler, + tap: tapHandler, + type_text: typeHandler, + launch_app_sim: launchHandler, +}; +async function call( + name: keyof typeof handlers, + params: Record = {}, +): Promise { + const ctx: ToolHandlerContext = { emit: () => {}, attach: () => {} }; + const moduleId = `mcp/tools/${name === 'launch_app_sim' ? 'simulator' : 'ui-automation'}/${name}`; + await wrapManagedTool(moduleId, (args, context) => { + if (!context) throw new Error('Missing fixture context'); + return handlers[name](args, context); + })({ ...credentials(), ...params }, ctx); + return ctx; +} +function capture(ctx: ToolHandlerContext): RuntimeSnapshotV1 { + const result = ctx.structuredOutput?.result; + if ( + !result || + !('capture' in result) || + !result.capture || + !('type' in result.capture) || + result.capture.type !== 'runtime-snapshot' + ) + throw new Error('Expected full snapshot'); + return result.capture; +} +beforeEach(async () => { + __resetRuntimeSnapshotStoreForTests(); + vi.spyOn(defaultAxeHelpers, 'getAxePath').mockReturnValue('/fixture/axe'); + vi.spyOn(defaultAxeHelpers, 'getBundledAxeEnvironment').mockReturnValue({}); + directory = await mkdtemp(join(tmpdir(), 'resource-ui-')); + const root = join(directory, 'checkout'); + await mkdir(join(root, '.git'), { recursive: true }); + vi.stubEnv('XCODEBUILDMCP_OPERATION_STATE_ROOT', join(directory, 'state')); + vi.stubEnv('XCODEBUILDMCP_OPERATION_CAPACITY', '1'); + const executor = createMockExecutor({}); + __setTestCommandExecutorOverride(async (...args) => { + if (args[0][0] !== 'git') throw new Error('Unexpected external command'); + return { + ...(await executor(...args)), + output: `${args[0].at(-1) === '--show-toplevel' ? root : join(root, '.git')}\n`, + }; + }); + const worktree = await resolveManagedWorktree(root, async (...args) => { + return { + ...(await executor(...args)), + output: `${args[0].at(-1) === '--show-toplevel' ? root : join(root, '.git')}\n`, + }; + }); + manager = await SimulatorResourceManager.open(resourceEnvironment()!); + await manager.bind(worktree, { deviceType: 'fixture', runtime: 'fixture' }, randomUUID()); + lease = await manager.requestLease({ + requestId: randomUUID(), + owner: { sessionId: randomUUID() }, + worktree, + }); +}); + +afterEach(async () => { + __clearTestExecutorOverrides(); + vi.restoreAllMocks(); + __resetRuntimeSnapshotStoreForTests(); + + vi.unstubAllEnvs(); + + await rm(directory, { recursive: true, force: true }); +}); + +function commands( + options: { + failure?: 'open' | 'reject' | 'exit'; + failCommand?: string; + onCommand?: (command: string[]) => Promise; + } = {}, +) { + const calls: string[][] = []; + const stream = new PassThrough(); + const root = join(directory, 'checkout'); + const mock = createMockExecutor({}); + __setTestCommandExecutorOverride(async (...args) => { + const result = await mock(...args); + const cmd = args[0]; + if (cmd[0] === 'git') + return { + ...result, + output: `${cmd.at(-1) === '--show-toplevel' ? root : join(root, '.git')}\n`, + }; + calls.push(cmd); + expect(args[2]).toBe(false); + await options.onCommand?.(cmd); + if (cmd[0] === 'xcrun') + return { + ...result, + output: cmd[2] === 'launch' ? 'com.example.App: 1234' : '/fixture/App.app', + }; + expect(cmd[0]).toBe('/fixture/axe'); + expect(cmd.at(-2)).toBe('--udid'); + expect(cmd.at(-1)?.toLowerCase()).toBe(lease.simulatorId); + if (cmd[1] === options.failCommand) { + if (options.failure === 'reject') throw new Error('lost execution'); + if (options.failure === 'open') result.process.stdout = stream; + if (options.failure === 'exit') return { ...result, success: false, error: 'action failed' }; + } + return { + ...result, + output: + cmd[1] === 'describe-ui' + ? JSON.stringify({ + elements: [ + { + type: 'TextField', + role: 'AXTextField', + AXLabel: 'Email', + AXUniqueId: 'email-field', + frame: { x: 10, y: 20, width: 100, height: 40 }, + enabled: true, + children: [], + }, + ], + }) + : 'ok', + }; + }); + return { calls, stream }; +} + +it('captures unique refs, taps and replaces text within one operation', async () => { + const { calls } = commands(); + const initial = capture(await call('snapshot_ui')); + expect(initial.elements[0].ref).toMatch(/^e[1-9][0-9]*$/); + expect(initial.actions.every((action) => action.elementRef === initial.elements[0].ref)).toBe( + true, + ); + const tapped = await call('tap', { elementRef: initial.elements[0].ref }); + expect(tapped.structuredOutput?.result.didError).toBe(false); + const refreshed = capture(tapped); + expect(refreshed.elements[0].ref).not.toBe(initial.elements[0].ref); + const typed = await call('type_text', { + elementRef: refreshed.elements[0].ref, + text: 'hello world', + replaceExisting: true, + }); + expect(typed.structuredOutput?.result.didError).toBe(false); + expect(calls.filter((cmd) => cmd[1] !== 'describe-ui').map((cmd) => cmd[1])).toEqual([ + 'tap', + 'tap', + 'key-combo', + 'type', + ]); + expect(typed.nextSteps).toEqual([]); + expect((await manager.end(lease)).state).toBe('released'); +}); + +it('always returns full managed captures and rejects refs from previous captures', async () => { + const { calls } = commands(); + const first = capture(await call('snapshot_ui')); + const second = capture(await call('snapshot_ui', { sinceScreenHash: first.screenHash })); + expect(second.screenHash).toBe(first.screenHash); + expect(second.elements[0].ref).not.toBe(first.elements[0].ref); + const before = calls.length; + const stale = await call('tap', { elementRef: first.elements[0].ref }); + expect(stale.structuredOutput?.result.didError).toBe(true); + expect(calls).toHaveLength(before); +}); + +it('invalidates refs on a new operation even while the previous session stays alive', async () => { + const { calls } = commands(); + const initial = capture(await call('snapshot_ui')); + const binding = await manager.getBinding((await manager.getStatus(lease.requestId)).generation); + if (!binding) throw new Error('Missing binding'); + await manager.end(lease); + lease = await manager.requestLease({ + requestId: randomUUID(), + owner: { sessionId: randomUUID() }, + worktree: binding.worktree, + }); + const before = calls.length; + expect( + (await call('tap', { elementRef: initial.elements[0].ref })).structuredOutput?.result.didError, + ).toBe(true); + expect(calls).toHaveLength(before); + const fresh = capture(await call('snapshot_ui')); + expect( + (await call('tap', { elementRef: fresh.elements[0].ref })).structuredOutput?.result.didError, + ).toBe(false); +}); + +it('invalidates after another runtime completes a call using the same operation', async () => { + const { calls } = commands(); + const initial = capture(await call('snapshot_ui')); + const status = await manager.getStatus(lease.requestId); + const activity = { + id: randomUUID(), + runtimeId: randomUUID(), + kind: 'call' as const, + pid: process.pid, + }; + expect( + await manager.startCall( + lease, + { generation: status.generation, simulatorId: lease.simulatorId }, + activity, + ), + ).toBe(true); + await manager.finishActivity(lease, activity.id, activity.runtimeId); + const before = calls.length; + expect( + (await call('tap', { elementRef: initial.elements[0].ref })).structuredOutput?.result.didError, + ).toBe(true); + expect(calls).toHaveLength(before); +}); + +it('invalidates across relaunch including mixed UUID casing', async () => { + const { calls } = commands(); + const initial = capture( + await call('snapshot_ui', { simulatorId: lease.simulatorId.toUpperCase() }), + ); + await call('launch_app_sim', { bundleId: 'com.example.App' }); + const before = calls.length; + expect( + ( + await call('tap', { + elementRef: initial.elements[0].ref, + simulatorId: lease.simulatorId.toUpperCase(), + }) + ).structuredOutput?.result.didError, + ).toBe(true); + expect(calls).toHaveLength(before); +}); + +it.each(['open', 'reject'] as const)( + 'blocks and stops follow-up typing after uncertain focus: %s', + async (failure) => { + commands(); + const initial = capture(await call('snapshot_ui')); + const { calls, stream } = commands({ failure, failCommand: 'tap' }); + try { + await expect( + call('type_text', { + elementRef: initial.elements[0].ref, + text: 'hello', + replaceExisting: true, + }), + ).rejects.toThrow('completion could not be confirmed'); + expect(calls.map((cmd) => cmd[1])).toEqual(['tap']); + await expect(manager.end(lease)).rejects.toThrow( + 'Operation is blocked; end/cancel cannot recover it', + ); + } finally { + stream.destroy(); + } + }, +); + +it('blocks when refreshed capture completion is uncertain after a successful tap', async () => { + commands(); + const initial = capture(await call('snapshot_ui')); + const { calls, stream } = commands({ failure: 'open', failCommand: 'describe-ui' }); + try { + await expect(call('tap', { elementRef: initial.elements[0].ref })).rejects.toThrow( + 'completion could not be confirmed', + ); + expect(calls.map((cmd) => cmd[1])).toEqual(['tap', 'describe-ui']); + await expect(manager.end(lease)).rejects.toThrow( + 'Operation is blocked; end/cancel cannot recover it', + ); + } finally { + stream.destroy(); + } +}); + +it('keeps observed command failure as a domain failure permitting end', async () => { + commands(); + const initial = capture(await call('snapshot_ui')); + commands({ failure: 'exit', failCommand: 'tap' }); + expect( + (await call('tap', { elementRef: initial.elements[0].ref })).structuredOutput?.result.didError, + ).toBe(true); + expect((await manager.end(lease)).state).toBe('released'); +}); + +it('surfaces missing AXe as infrastructure failure without occupying the device indefinitely', async () => { + commands(); + vi.mocked(defaultAxeHelpers.getAxePath).mockReturnValue(null); + await expect(call('snapshot_ui')).rejects.toThrow('AXe binary not found'); + expect((await manager.end(lease)).state).toBe('released'); +}); + +it('holds closing through post-action capture before handing off', async () => { + commands(); + const initial = capture(await call('snapshot_ui')); + let release!: () => void; + let entered!: () => void; + const started = new Promise((resolve) => { + entered = resolve; + }); + const finish = new Promise((resolve) => { + release = resolve; + }); + commands({ + onCommand: async (cmd) => { + if (cmd[1] === 'describe-ui') { + entered(); + await finish; + } + }, + }); + const callPromise = call('tap', { elementRef: initial.elements[0].ref }); + await started; + expect((await manager.end(lease)).state).toBe('closing'); + release(); + await callPromise; + expect((await manager.getStatus(lease.requestId)).state).toBe('released'); +}); + +it('checks credentials and target before any UI command', async () => { + const { calls } = commands(); + for (const name of ['snapshot_ui', 'tap', 'type_text'] as const) { + expect(managedToolSchema(`mcp/tools/ui-automation/${name}`, {})).toHaveProperty( + 'operationToken', + ); + await expect( + call(name, { elementRef: 'e1', text: 'hello', operationToken: randomUUID() }), + ).rejects.toThrow(); + await expect( + call(name, { elementRef: 'e1', text: 'hello', simulatorId: randomUUID() }), + ).rejects.toThrow(); + } + expect(calls).toEqual([]); +}); + +it('serializes ordinary UI transactions for differently cased UUIDs sharing one cache', async () => { + let active = 0; + let maximum = 0; + const transaction = async () => { + maximum = Math.max(maximum, ++active); + await new Promise((resolve) => setTimeout(resolve, 20)); + active--; + }; + await Promise.all([ + withSimulatorUiAutomationTransaction(lease.simulatorId.toLowerCase(), transaction), + withSimulatorUiAutomationTransaction(lease.simulatorId.toUpperCase(), transaction), + ]); + expect(maximum).toBe(1); +}); + +it('renders managed guidance from final metadata without changing ordinary guidance', async () => { + commands(); + const ctx = await call('snapshot_ui'); + const output = ctx.structuredOutput; + if (!output) throw new Error('Missing result'); + const managed = renderCliTextTranscript({ structuredOutput: output }); + expect(managed).toContain('same operation credentials'); + expect(managed).not.toContain('wait_for_ui'); + expect(managed).not.toContain('long_press'); + const ordinary = renderCliTextTranscript({ + structuredOutput: { ...output, renderHints: undefined }, + }); + expect(ordinary).toContain('wait_for_ui'); +}); diff --git a/src/resource-management/boot.ts b/src/resource-management/boot.ts new file mode 100644 index 000000000..2b51d678e --- /dev/null +++ b/src/resource-management/boot.ts @@ -0,0 +1,63 @@ +import * as z from 'zod'; +import type { CommandExecutor, CommandResponse } from '../utils/CommandExecutor.ts'; +import { CommandSupervision } from './execution.ts'; + +const devicesSchema = z.object({ + devices: z.record( + z.string(), + z.array(z.object({ udid: z.uuid(), isAvailable: z.boolean(), state: z.string() })), + ), +}); + +export class SimulatorBootPreflightFailure extends Error {} + +/** The service may still be mutating the device even after the command exits. */ +export class SimulatorReadinessUncertainError extends Error {} + +export async function bootManagedSimulator( + simulatorId: string, + executor: CommandExecutor, +): Promise { + const supervision = new CommandSupervision(); + async function execute(command: string[]): Promise { + let result; + try { + result = await supervision.execute(executor, [command, 'Managed Simulator boot', false]); + } catch (cause) { + throw new SimulatorReadinessUncertainError('Simulator command completion is uncertain', { + cause, + }); + } + if (!supervision.quiescent) + throw new SimulatorReadinessUncertainError('Simulator command completion is uncertain'); + if (!result.success) throw new Error('Simulator command failed', { cause: result.error }); + return result; + } + async function device(): Promise< + z.infer['devices'][string][number] | undefined + > { + const result = await execute(['xcrun', 'simctl', 'list', 'devices', '--json']); + const catalog = devicesSchema.parse(JSON.parse(result.output)); + return Object.values(catalog.devices) + .flat() + .find((entry) => entry.udid.toLowerCase() === simulatorId.toLowerCase()); + } + + const before = await device(); + if (!before?.isAvailable) + throw new SimulatorBootPreflightFailure('Bound Simulator is missing or unavailable'); + if (!['Shutdown', 'Booting', 'Booted'].includes(before.state)) + throw new SimulatorReadinessUncertainError(`Simulator state is uncertain: ${before.state}`); + + // Once boot is requested, every failure retains ownership until reconciliation. + try { + await execute(['xcrun', 'simctl', 'bootstatus', simulatorId, '-b']); + const after = await device(); + if (!after?.isAvailable || after.state !== 'Booted') + throw new Error('Bound Simulator did not reach the Booted state'); + } catch (cause) { + throw new SimulatorReadinessUncertainError('Simulator boot readiness could not be confirmed', { + cause, + }); + } +} diff --git a/src/resource-management/environment.ts b/src/resource-management/environment.ts new file mode 100644 index 000000000..7fc183415 --- /dev/null +++ b/src/resource-management/environment.ts @@ -0,0 +1,31 @@ +import { createHash } from 'node:crypto'; +import { isAbsolute, resolve } from 'node:path'; +import type { ResourceManagerOptions } from './store.ts'; + +export interface ResourceEnvironment extends ResourceManagerOptions { + namespace: string; +} + +/** Explicit opt-in only. Both values must be identical in every participating process. */ +export function resourceEnvironment( + env: NodeJS.ProcessEnv = process.env, +): ResourceEnvironment | undefined { + const root = env.XCODEBUILDMCP_OPERATION_STATE_ROOT; + const capacity = env.XCODEBUILDMCP_OPERATION_CAPACITY; + if (root === undefined && capacity === undefined) return undefined; + if (!root || !isAbsolute(root) || !capacity || !/^[1-9][0-9]*$/.test(capacity)) { + throw new Error( + 'Managed resources require an absolute OPERATION_STATE_ROOT and positive OPERATION_CAPACITY', + ); + } + const maxActiveOperations = Number(capacity); + if (!Number.isSafeInteger(maxActiveOperations)) throw new Error('Invalid resource capacity'); + const stateRoot = resolve(root); + const namespace = createHash('sha256').update(`${stateRoot}\n${capacity}`).digest('hex'); + return { stateRoot, maxActiveOperations, namespace }; +} + +export function assertResourceNamespace(namespace: unknown): void { + const expected = resourceEnvironment()?.namespace; + if (namespace !== expected) throw new Error('Resource-management namespace mismatch'); +} diff --git a/src/resource-management/execution.ts b/src/resource-management/execution.ts new file mode 100644 index 000000000..0f17e2547 --- /dev/null +++ b/src/resource-management/execution.ts @@ -0,0 +1,61 @@ +import { AsyncLocalStorage } from 'node:async_hooks'; +import type { CommandExecutor } from '../utils/CommandExecutor.ts'; + +const supervisionStorage = new AsyncLocalStorage(); + +/** Completion evidence for the finite command executors used by admitted tools. */ +export class CommandSupervision { + private pending = 0; + private uncertain = false; + + get quiescent(): boolean { + return this.pending === 0 && !this.uncertain; + } + + run(invoke: () => Promise): Promise { + return supervisionStorage.run(this, invoke); + } + + async execute( + executor: CommandExecutor, + args: Parameters, + ): ReturnType { + if (this.uncertain) throw new Error('Previous managed command completion is uncertain'); + if (args[4]) throw new Error('Detached commands are unsupported with managed resources'); + this.pending++; + try { + const result = await executor(...args); + const child = result.process; + // The shared executor can settle 100ms after exit while descendants still hold stdio. + // Exit alone is insufficient evidence; swallowed errors must remain uncertain too. + const exited = + child && (typeof child.exitCode === 'number' || typeof child.signalCode === 'string'); + const streamsClosed = + child && + [child.stdout, child.stderr].every( + (stream) => !stream || stream.destroyed || stream.readableEnded, + ); + if (!exited || !streamsClosed) { + this.uncertain = true; + throw new Error('Managed command completion is uncertain'); + } + return result; + } catch (error) { + this.uncertain = true; + throw error; + } finally { + this.pending--; + } + } +} + +export function isCommandSupervised(): boolean { + return supervisionStorage.getStore() !== undefined; +} + +export function supervisedExecutor(executor: CommandExecutor): CommandExecutor { + return (...args) => { + const supervision = supervisionStorage.getStore(); + return supervision ? supervision.execute(executor, args) : executor(...args); + }; +} diff --git a/src/resource-management/launch.ts b/src/resource-management/launch.ts new file mode 100644 index 000000000..deb6ce0b7 --- /dev/null +++ b/src/resource-management/launch.ts @@ -0,0 +1,73 @@ +import * as z from 'zod'; +import type { CommandExecutor, CommandResponse } from '../utils/CommandExecutor.ts'; +import type { LaunchResultDomainResult } from '../types/domain-results.ts'; +import { buildLaunchFailure, buildLaunchSuccess } from '../utils/app-lifecycle-results.ts'; +import { normalizeSimctlChildEnv } from '../utils/environment.ts'; +import { CommandSupervision } from './execution.ts'; + +export class SimulatorLaunchUncertainError extends Error {} + +const bundleIdSchema = z.string().regex(/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/); + +/** Finite launch only: the app persists, but no host console or log helper is started. */ +export async function launchManagedSimulatorApp( + params: { + simulatorId: string; + bundleId: string; + launchArgs?: string[]; + env?: Record; + }, + executor: CommandExecutor, +): Promise { + bundleIdSchema.parse(params.bundleId); + const artifacts = { simulatorId: params.simulatorId, bundleId: params.bundleId }; + const supervision = new CommandSupervision(); + async function execute(args: Parameters): Promise { + let result; + try { + result = await supervision.execute(executor, args); + } catch (cause) { + throw new SimulatorLaunchUncertainError('Simulator launch command completion is uncertain', { + cause, + }); + } + if (!supervision.quiescent) + throw new SimulatorLaunchUncertainError('Simulator launch command completion is uncertain'); + return result; + } + + const installed = await execute([ + ['xcrun', 'simctl', 'get_app_container', params.simulatorId, params.bundleId, 'app'], + 'Check App Installed', + false, + ]); + if (!installed.success) { + return buildLaunchFailure( + artifacts, + 'Cannot access the installed app. Boot the bound Simulator and install the app before launching.', + ); + } + + const result = await execute([ + [ + 'xcrun', + 'simctl', + 'launch', + '--terminate-running-process', + params.simulatorId, + params.bundleId, + ...(params.launchArgs ?? []), + ], + 'Launch Managed App', + false, + params.env ? { env: normalizeSimctlChildEnv(params.env) } : undefined, + ]); + // An exited command alone does not prove whether a failed service request launched the app. + if (!result.success) + throw new SimulatorLaunchUncertainError('Simulator app launch outcome is uncertain'); + const banner = result.output.trim().match(/^(.+):\s+([1-9]\d*)$/); + const processId = banner ? Number(banner[2]) : undefined; + if (banner?.[1] !== params.bundleId || !Number.isSafeInteger(processId)) + throw new SimulatorLaunchUncertainError('Simulator launch did not return a valid app PID'); + return buildLaunchSuccess({ ...artifacts, processId }); +} diff --git a/src/resource-management/manager.ts b/src/resource-management/manager.ts new file mode 100644 index 000000000..a67b90ca4 --- /dev/null +++ b/src/resource-management/manager.ts @@ -0,0 +1,664 @@ +import { randomUUID } from 'node:crypto'; +import { setTimeout as delay } from 'node:timers/promises'; +import * as z from 'zod'; +import { containsPath, ResourceStore, type ResourceManagerOptions } from './store.ts'; +import { worktreeGeneration, type ManagedWorktree } from './worktree.ts'; + +const id = z + .uuid() + .refine((value) => value === value.toLowerCase(), 'Identity UUID must be lowercase'); +const simulatorIdSchema = z.uuid().transform((value) => value.toLowerCase()); +const generation = z.string().regex(/^[a-f0-9]{64}$/); +const worktreeSchema = z + .object({ generation, root: z.string(), gitDir: z.string(), workspaceKey: z.string() }) + .strict(); +const specificationSchema = z + .object({ deviceType: z.string().min(1), runtime: z.string().min(1) }) + .strict(); +const bindingSchema = z + .object({ + version: z.literal(1), + worktree: worktreeSchema, + specification: specificationSchema, + simulatorId: simulatorIdSchema, + }) + .strict(); +const provisioningSchema = z + .object({ + version: z.literal(1), + id, + worktree: worktreeSchema, + specification: specificationSchema, + state: z.enum(['creating', 'created', 'blocked']), + simulatorId: simulatorIdSchema.optional(), + }) + .strict(); +const ownerSchema = z.object({ sessionId: id }).strict(); +const activitySchema = z + .object({ id, kind: z.enum(['call', 'helper']), runtimeId: id, pid: z.number().int().positive() }) + .strict(); +const legacyOperationSchema = z + .object({ + version: z.literal(1), + requestId: id, + token: id, + owner: ownerSchema, + generation, + simulatorId: simulatorIdSchema, + sequence: z.number().int().positive(), + state: z.enum(['waiting', 'active', 'closing', 'blocked', 'released', 'cancelled']), + activities: z.array(activitySchema), + completedActivities: z.array(activitySchema), + reason: z.string().optional(), + }) + .strict(); +const currentOperationSchema = z + .object({ + version: z.literal(2), + requestId: id, + token: id, + owner: ownerSchema, + generation, + simulatorId: simulatorIdSchema, + sequence: z.number().int().positive(), + state: z.enum(['waiting', 'active', 'closing', 'blocked', 'released', 'cancelled']), + activities: z.array(activitySchema), + completedActivities: z + .array(activitySchema) + .max(1) + .refine((entries) => entries.every((entry) => entry.kind === 'call')), + reason: z.string().optional(), + }) + .strict(); +const operationSchema = z.union([currentOperationSchema, legacyOperationSchema]); +const completionSchema = z + .object({ + requestId: id, + owner: ownerSchema, + activity: activitySchema, + }) + .strict(); + +export type SimulatorSpecification = z.infer; +export type SimulatorBinding = z.infer; +export type SimulatorProvisioning = z.infer; +/** Must create a new device and resolve only after creation has definitively stopped. */ +export type SimulatorCreator = (request: { + name: string; + specification: SimulatorSpecification; +}) => Promise; +export type OperationOwner = z.infer; +export type OperationActivity = z.infer; +type LegacyOperation = z.infer; +type CurrentOperation = z.infer; +type Operation = LegacyOperation | CurrentOperation; +type CompletionReceipt = z.infer; +export type OperationStatus = Omit; +export interface LeaseCredentials { + requestId: string; + token: string; + sessionId: string; +} +export interface LeaseRequest { + requestId: string; + owner: OperationOwner; + worktree: ManagedWorktree; +} +export interface Lease extends LeaseCredentials { + state: Operation['state']; + simulatorId: string; +} + +function operationPath(requestId: string): string { + return `operations/${id.parse(requestId)}.json`; +} +function completionPath(requestId: string, activityId: string): string { + return `completions/${id.parse(requestId)}.${id.parse(activityId)}.json`; +} +function bindingPath(value: string): string { + return `bindings/${generation.parse(value)}.json`; +} +function provisioningPath(value: string): string { + return `provisioning/${generation.parse(value)}.json`; +} +function holdsDevice(operation: Operation): boolean { + return ['active', 'closing', 'blocked'].includes(operation.state); +} +function status(operation: Operation): OperationStatus { + const { token: _token, ...result } = operation; + return result; +} +function lease(operation: Operation): Lease { + return { + requestId: operation.requestId, + token: operation.token, + sessionId: operation.owner.sessionId, + state: operation.state, + simulatorId: operation.simulatorId, + }; +} + +function activitiesMatch(left: OperationActivity, right: OperationActivity): boolean { + return ( + left.id === right.id && + left.kind === right.kind && + left.runtimeId === right.runtimeId && + left.pid === right.pid + ); +} + +/** Internal opt-in core; it does not provision devices or intercept existing tools. */ +export class SimulatorResourceManager { + private constructor(private readonly store: ResourceStore) {} + + static async open(options: ResourceManagerOptions): Promise { + return new SimulatorResourceManager(await ResourceStore.open(options)); + } + + private async validateWorktree(worktree: ManagedWorktree): Promise { + worktreeSchema.parse(worktree); + if (containsPath(worktree.root, this.store.root)) + throw new Error('Resource state cannot live inside a worktree'); + if ((await worktreeGeneration(worktree.root, worktree.gitDir)) !== worktree.generation) { + throw new Error('Worktree generation changed'); + } + } + + /** Internal binding boundary. Cannot override a pending or uncertain creation attempt. */ + async bind( + worktree: ManagedWorktree, + specification: SimulatorSpecification, + simulatorId: string, + ): Promise { + const candidate = bindingSchema.parse({ version: 1, worktree, specification, simulatorId }); + return this.store.transaction(async () => { + if (await this.store.read(provisioningPath(worktree.generation), provisioningSchema)) { + throw new Error('Provisioning record exists; use provision or reconcile instead'); + } + return this.bindInTransaction(candidate); + }); + } + + private async bindInTransaction(candidate: SimulatorBinding): Promise { + const { worktree, specification } = candidate; + await this.validateWorktree(worktree); + const bindings = await this.store.list('bindings', bindingSchema); + const existing = bindings.find((entry) => entry.worktree.generation === worktree.generation); + if (existing) { + this.validateBindingLocation(existing, worktree); + if ( + existing.simulatorId !== candidate.simulatorId || + existing.specification.deviceType !== specification.deviceType || + existing.specification.runtime !== specification.runtime + ) { + throw new Error( + 'Persistent Simulator binding conflicts with requested device or specification', + ); + } + return existing; + } + if (bindings.some((entry) => entry.simulatorId === candidate.simulatorId)) + throw new Error('Simulator is bound to another worktree generation'); + const provisioning = await this.store.list('provisioning', provisioningSchema); + if ( + provisioning.some( + (entry) => + entry.simulatorId === candidate.simulatorId && + entry.worktree.generation !== worktree.generation, + ) + ) + throw new Error('Simulator is reserved by another provisioning attempt'); + await this.store.write(bindingPath(worktree.generation), candidate); + return candidate; + } + + /** Reserve durably before invoking an external creator, without holding the transaction lock. */ + async provision( + worktree: ManagedWorktree, + specification: SimulatorSpecification, + create: SimulatorCreator, + verifyCreated?: (simulatorId: string) => Promise, + ): Promise { + const candidate = provisioningSchema.parse({ + version: 1, + id: randomUUID(), + worktree, + specification, + state: 'creating', + }); + const path = provisioningPath(candidate.worktree.generation); + const existing = await this.store.transaction(async () => { + await this.validateWorktree(candidate.worktree); + const binding = await this.store.read( + bindingPath(candidate.worktree.generation), + bindingSchema, + ); + if (binding) { + this.validateBindingLocation(binding, candidate.worktree); + if ( + binding.specification.deviceType !== candidate.specification.deviceType || + binding.specification.runtime !== candidate.specification.runtime + ) { + throw new Error('Persistent Simulator binding conflicts with requested specification'); + } + return binding; + } + if (await this.store.read(path, provisioningSchema)) { + throw new Error('Simulator provisioning is in progress or requires reconciliation'); + } + await this.store.write(path, candidate); + return null; + }); + if (existing) return existing; + + try { + candidate.simulatorId = simulatorIdSchema.parse( + await create({ + name: `XcodeBuildMCP-${candidate.id}`, + specification: { ...candidate.specification }, + }), + ); + await this.store.transaction(async () => { + const current = await this.store.read(path, provisioningSchema); + if (current?.id !== candidate.id || current.state !== 'creating') { + throw new Error('Simulator provisioning ownership changed'); + } + // Retain the observed UDID even if the worktree disappeared before binding commits. + candidate.state = 'created'; + await this.store.write(path, candidate); + }); + await verifyCreated?.(candidate.simulatorId); + return await this.store.transaction(async () => { + const current = await this.store.read(path, provisioningSchema); + if (current?.id !== candidate.id || current.state !== 'created') { + throw new Error('Simulator provisioning ownership changed'); + } + return this.bindInTransaction( + bindingSchema.parse({ + version: 1, + worktree: candidate.worktree, + specification: candidate.specification, + simulatorId: candidate.simulatorId, + }), + ); + }); + } catch (error) { + const committed = await this.store.transaction(async () => { + const binding = await this.store.read( + bindingPath(candidate.worktree.generation), + bindingSchema, + ); + if (candidate.simulatorId && binding?.simulatorId === candidate.simulatorId) return binding; + const current = await this.store.read(path, provisioningSchema); + if (current?.id === candidate.id) { + candidate.state = 'blocked'; + await this.store.write(path, candidate); + } + return null; + }); + // A committed binding wins a lost write acknowledgement; never create a replacement. + if (committed) return committed; + throw error; + } + } + + /** Retained creation evidence remains queryable even after the checkout is removed. */ + async getProvisioning(worktreeGenerationId: string): Promise { + return this.store.transaction(() => + this.store.read(provisioningPath(worktreeGenerationId), provisioningSchema), + ); + } + + async getBinding(worktreeGenerationId: string): Promise { + return this.store.transaction(() => + this.store.read(bindingPath(worktreeGenerationId), bindingSchema), + ); + } + + private validateBindingLocation(binding: SimulatorBinding, worktree: ManagedWorktree): void { + if (binding.worktree.root !== worktree.root || binding.worktree.gitDir !== worktree.gitDir) { + throw new Error('Worktree relocation requires reconciliation'); + } + } + + private async schedule(): Promise { + const operations = await this.normalizeForScheduling( + await this.store.list('operations', operationSchema), + ); + const held = operations.filter(holdsDevice); + const busyDevices = new Set(held.map((entry) => entry.simulatorId)); + let remaining = this.store.capacity - held.length; + for (const candidate of operations + .filter((entry) => entry.state === 'waiting') + .sort((a, b) => a.sequence - b.sequence)) { + if (remaining <= 0) break; + if (busyDevices.has(candidate.simulatorId)) continue; + candidate.state = 'active'; + await this.store.write(operationPath(candidate.requestId), candidate); + busyDevices.add(candidate.simulatorId); + remaining -= 1; + } + } + + /** requestId identifies one begin request across retries; sessionId identifies its caller. */ + async requestLease(request: LeaseRequest): Promise { + id.parse(request.requestId); + ownerSchema.parse(request.owner); + return this.store.transaction(async () => { + await this.validateWorktree(request.worktree); + const binding = await this.store.read( + bindingPath(request.worktree.generation), + bindingSchema, + ); + if (!binding) throw new Error('Worktree has no Simulator binding'); + this.validateBindingLocation(binding, request.worktree); + const previous = await this.store.read(operationPath(request.requestId), operationSchema); + if (previous) { + if ( + previous.owner.sessionId !== request.owner.sessionId || + previous.generation !== request.worktree.generation + ) + throw new Error('Begin request identity conflicts'); + } else { + const operations = await this.store.list('operations', operationSchema); + const operation: Operation = { + version: 2, + requestId: request.requestId, + token: randomUUID(), + owner: request.owner, + generation: request.worktree.generation, + simulatorId: binding.simulatorId, + sequence: operations.reduce((maximum, entry) => Math.max(maximum, entry.sequence), 0) + 1, + state: 'waiting', + activities: [], + completedActivities: [], + }; + await this.store.write(operationPath(operation.requestId), operation); + } + await this.schedule(); + return lease(await this.readOperation(request.requestId)); + }); + } + + async acquire( + request: LeaseRequest, + options: { timeoutMs: number; signal?: AbortSignal }, + ): Promise { + if (!Number.isFinite(options.timeoutMs) || options.timeoutMs < 0) + throw new Error('timeoutMs must be nonnegative and finite'); + options.signal?.throwIfAborted(); + const deadline = Date.now() + options.timeoutMs; + let current = await this.requestLease(request); + for (;;) { + // A committed grant wins over a waiting deadline, including retries of an existing grant. + if (current.state === 'active') return current; + if (options.signal?.aborted || Date.now() > deadline) { + current = await this.store.transaction(async () => { + const operation = await this.authorize(current); + if (operation.state === 'waiting') { + operation.state = 'cancelled'; + await this.store.write(operationPath(operation.requestId), operation); + await this.schedule(); + } + return lease(operation); + }); + if (current.state === 'active') return current; + throw new Error( + options.signal?.aborted ? 'Lease request cancelled' : 'Lease request timed out', + ); + } + if (current.state !== 'waiting') throw new Error(`Lease request is ${current.state}`); + await delay(10); + current = await this.poll(current); + } + } + + private async readOperation(requestId: string): Promise { + const operation = await this.store.read(operationPath(requestId), operationSchema); + if (!operation) throw new Error('Unknown operation'); + return operation; + } + + private async readCompletion( + operation: Operation, + activityId: string, + ): Promise { + const receipt = await this.store.read( + completionPath(operation.requestId, activityId), + completionSchema, + ); + if (!receipt) return null; + if ( + receipt.requestId !== operation.requestId || + receipt.owner.sessionId !== operation.owner.sessionId || + receipt.activity.id !== activityId + ) { + throw new Error('Completion receipt conflicts with operation identity'); + } + return receipt; + } + + private async ensureCompletion(operation: Operation, activity: OperationActivity): Promise { + const path = completionPath(operation.requestId, activity.id); + const existing = await this.store.read(path, completionSchema); + const expected: CompletionReceipt = { + requestId: operation.requestId, + owner: operation.owner, + activity, + }; + if (existing) { + if ( + existing.requestId !== expected.requestId || + existing.owner.sessionId !== expected.owner.sessionId || + !activitiesMatch(existing.activity, expected.activity) + ) { + throw new Error('Completion receipt conflicts with operation identity'); + } + return; + } + await this.store.write(path, expected); + } + + private async migrateLegacyOperation(operation: LegacyOperation): Promise { + if (operation.activities.length > 0) + throw new Error('Cannot migrate a legacy operation with active activities'); + for (const activity of operation.completedActivities) { + await this.ensureCompletion(operation, activity); + } + const lastCall = operation.completedActivities.filter((entry) => entry.kind === 'call').at(-1); + const migrated = currentOperationSchema.parse({ + ...operation, + version: 2, + completedActivities: lastCall ? [lastCall] : [], + }); + await this.store.write(operationPath(operation.requestId), migrated); + return migrated; + } + + private async normalizeForScheduling(operations: Operation[]): Promise { + const normalized: Operation[] = []; + for (const operation of operations) { + if (operation.version === 1 && operation.activities.length === 0) { + normalized.push(await this.migrateLegacyOperation(operation)); + } else { + normalized.push(operation); + } + } + return normalized; + } + + private async authorize(credentials: LeaseCredentials): Promise { + const operation = await this.readOperation(credentials.requestId); + if ( + operation.token !== credentials.token || + operation.owner.sessionId !== credentials.sessionId + ) + throw new Error('Operation credentials do not match'); + return operation; + } + + async poll(credentials: LeaseCredentials): Promise { + return this.store.transaction(async () => { + await this.authorize(credentials); + await this.schedule(); + return lease(await this.authorize(credentials)); + }); + } + + async getStatus(requestId: string): Promise { + return this.store.transaction(async () => status(await this.readOperation(requestId))); + } + + /** Reserve before starting work. A busy call returns false; the caller may wait or cancel. */ + async startCall( + credentials: LeaseCredentials, + target: { generation: string; simulatorId: string }, + activity: OperationActivity, + ): Promise { + activitySchema.parse(activity); + if (activity.kind !== 'call') throw new Error('Expected a call activity'); + return this.store.transaction(async () => { + const operation = await this.authorize(credentials); + if (operation.state !== 'active') throw new Error(`Operation is ${operation.state}`); + const binding = await this.store.read(bindingPath(operation.generation), bindingSchema); + if (!binding) throw new Error('Operation binding is missing'); + await this.validateWorktree(binding.worktree); + if ( + target.generation !== operation.generation || + simulatorIdSchema.parse(target.simulatorId) !== operation.simulatorId + ) + throw new Error('Effective target does not match the lease'); + if (await this.readCompletion(operation, activity.id)) + throw new Error('Activity identity already exists'); + if ( + [...operation.activities, ...operation.completedActivities].some( + (entry) => entry.id === activity.id, + ) + ) + throw new Error('Activity identity already exists'); + if (operation.activities.some((entry) => entry.kind === 'call')) return false; + operation.activities.push(activity); + await this.store.write(operationPath(operation.requestId), operation); + return true; + }); + } + + /** Register before spawning. The originating in-flight call may be draining after end. */ + async registerHelper( + credentials: LeaseCredentials, + callId: string, + helper: OperationActivity, + ): Promise { + activitySchema.parse(helper); + if (helper.kind !== 'helper') throw new Error('Expected a helper activity'); + await this.store.transaction(async () => { + const operation = await this.authorize(credentials); + if (!['active', 'closing'].includes(operation.state)) + throw new Error(`Operation is ${operation.state}`); + if ( + !operation.activities.some( + (entry) => + entry.kind === 'call' && entry.id === callId && entry.runtimeId === helper.runtimeId, + ) + ) + throw new Error('Helper requires its owning in-flight call'); + if ( + [...operation.activities, ...operation.completedActivities].some( + (entry) => entry.id === helper.id, + ) + ) + throw new Error('Activity identity already exists'); + if (await this.readCompletion(operation, helper.id)) + throw new Error('Activity identity already exists'); + operation.activities.push(helper); + await this.store.write(operationPath(operation.requestId), operation); + }); + } + + /** Only call after this runtime has observed all activity side effects and child work stopped. */ + async finishActivity( + credentials: LeaseCredentials, + activityId: string, + runtimeId: string, + ): Promise { + return this.store.transaction( + async () => { + const operation = await this.authorize(credentials); + const completed = operation.completedActivities.find((entry) => entry.id === activityId); + if (completed) { + if (completed.runtimeId !== runtimeId) + throw new Error('Activity ownership does not match'); + const receipt = await this.readCompletion(operation, activityId); + if (receipt && !activitiesMatch(receipt.activity, completed)) { + throw new Error('Completion receipt conflicts with operation activity'); + } + return status(operation); + } + const activity = operation.activities.find((entry) => entry.id === activityId); + if (activity && activity.runtimeId !== runtimeId) + throw new Error('Activity ownership does not match'); + const receipt = await this.readCompletion(operation, activityId); + if (!activity) { + if (receipt) { + if (receipt.activity.runtimeId !== runtimeId) + throw new Error('Activity ownership does not match'); + return status(operation); + } + throw new Error('Activity is not active'); + } + if (receipt && !activitiesMatch(receipt.activity, activity)) { + throw new Error('Completion receipt conflicts with operation activity'); + } + await this.ensureCompletion(operation, activity); + operation.activities = operation.activities.filter((entry) => entry.id !== activityId); + if (operation.version === 1) operation.completedActivities.push(activity); + else if (activity.kind === 'call') operation.completedActivities = [activity]; + if (operation.state === 'closing' && operation.activities.length === 0) + operation.state = 'released'; + await this.store.write(operationPath(operation.requestId), operation); + await this.schedule(); + return status(await this.readOperation(credentials.requestId)); + }, + { waitForLock: true }, + ); + } + + async end(credentials: LeaseCredentials): Promise { + return this.close(credentials, false); + } + + async cancel(credentials: LeaseCredentials): Promise { + return this.close(credentials, true); + } + + private async close(credentials: LeaseCredentials, cancel: boolean): Promise { + return this.store.transaction(async () => { + const operation = await this.authorize(credentials); + if (operation.state === 'waiting') { + if (!cancel) throw new Error('Cancel a waiting request instead of ending it'); + operation.state = 'cancelled'; + } else if (operation.state === 'blocked') { + throw new Error( + 'Operation is blocked; end/cancel cannot recover it and no managed unblock action is available', + ); + } else if (operation.state === 'active' || operation.state === 'closing') { + operation.state = operation.activities.length ? 'closing' : 'released'; + } + await this.store.write(operationPath(operation.requestId), operation); + await this.schedule(); + return status(await this.readOperation(credentials.requestId)); + }); + } + + /** Loss of runtime supervision fences new calls. No TTL/PID-based recovery is permitted. */ + async block(credentials: LeaseCredentials, reason: string): Promise { + if (!reason.trim()) throw new Error('A blocking reason is required'); + await this.store.transaction(async () => { + const operation = await this.authorize(credentials); + if (!holdsDevice(operation)) throw new Error('Only a held operation can be blocked'); + operation.state = 'blocked'; + operation.reason = reason; + await this.store.write(operationPath(operation.requestId), operation); + }); + } +} diff --git a/src/resource-management/provisioner.ts b/src/resource-management/provisioner.ts new file mode 100644 index 000000000..01373f8cd --- /dev/null +++ b/src/resource-management/provisioner.ts @@ -0,0 +1,111 @@ +import * as z from 'zod'; +import type { CommandExecutor } from '../utils/CommandExecutor.ts'; +import { CommandSupervision } from './execution.ts'; +import type { + SimulatorResourceManager, + SimulatorBinding, + SimulatorSpecification, +} from './manager.ts'; +import type { ManagedWorktree } from './worktree.ts'; + +const catalogSchema = z.object({ + devicetypes: z.array(z.object({ identifier: z.string() })), + runtimes: z.array(z.object({ identifier: z.string(), isAvailable: z.boolean() })), + devices: z.record( + z.string(), + z.array( + z.object({ + udid: z.uuid(), + isAvailable: z.boolean(), + deviceTypeIdentifier: z.string().optional(), + }), + ), + ), +}); + +/** Expected failures in the user's Simulator workflow, not registry or executor failures. */ +export class SimulatorProvisioningFailure extends Error {} + +async function catalog(executor: CommandExecutor): Promise> { + const result = await executor(['xcrun', 'simctl', 'list', '--json'], 'Simulator catalog', false); + if (!result.success) throw new Error('Cannot read Simulator catalog', { cause: result.error }); + return catalogSchema.parse(JSON.parse(result.output)); +} + +/** Creates only through the durable reservation; never boots, deletes, or adopts a device. */ +export async function provisionSimulator( + manager: SimulatorResourceManager, + worktree: ManagedWorktree, + specification: SimulatorSpecification, + executor: CommandExecutor, +): Promise { + const before = await catalog(executor); + if (!before.devicetypes.some((type) => type.identifier === specification.deviceType)) { + throw new SimulatorProvisioningFailure('Requested Simulator device type is unavailable'); + } + if ( + !before.runtimes.some( + (runtime) => runtime.identifier === specification.runtime && runtime.isAvailable, + ) + ) { + throw new SimulatorProvisioningFailure('Requested Simulator runtime is unavailable'); + } + let verifiedCreation = false; + async function verify(simulatorId: string): Promise { + const after = await catalog(executor); + const device = after.devices[specification.runtime]?.find( + (entry) => entry.udid.toLowerCase() === simulatorId, + ); + if (!device?.isAvailable) { + throw new SimulatorProvisioningFailure( + 'Bound Simulator is missing or unavailable; automatic replacement is disabled', + ); + } + if (!device.deviceTypeIdentifier) + throw new Error('Bound Simulator device type metadata is missing'); + if (device.deviceTypeIdentifier !== specification.deviceType) { + throw new SimulatorProvisioningFailure( + 'Bound Simulator does not match its persisted device type', + ); + } + } + const binding = await manager.provision( + worktree, + specification, + async (request) => { + const supervision = new CommandSupervision(); + const result = await supervision.execute(executor, [ + [ + 'xcrun', + 'simctl', + 'create', + request.name, + request.specification.deviceType, + request.specification.runtime, + ], + 'Create dedicated Simulator', + false, + ]); + if (!supervision.quiescent) throw new Error('Simulator creation completion is uncertain'); + if (!result.success) + throw new SimulatorProvisioningFailure( + 'Simulator creation failed; inspect provisioning before retrying', + ); + const simulatorId = z.uuid().parse(result.output.trim()).toLowerCase(); + if ( + Object.values(before.devices) + .flat() + .some((device) => device.udid.toLowerCase() === simulatorId) + ) { + throw new Error('Simulator creation returned a pre-existing device'); + } + return simulatorId; + }, + async (simulatorId) => { + await verify(simulatorId); + verifiedCreation = true; + }, + ); + if (!verifiedCreation) await verify(binding.simulatorId); + return binding; +} diff --git a/src/resource-management/store.ts b/src/resource-management/store.ts new file mode 100644 index 000000000..34c7da4d2 --- /dev/null +++ b/src/resource-management/store.ts @@ -0,0 +1,133 @@ +import { randomUUID } from 'node:crypto'; +import { lstat, mkdir, readFile, readdir, realpath, rename, rm, writeFile } from 'node:fs/promises'; +import { homedir } from 'node:os'; +import { isAbsolute, join, relative, sep } from 'node:path'; +import { setTimeout as delay } from 'node:timers/promises'; +import * as z from 'zod'; +import { tryAcquireFsLock } from '../utils/fs-lock.ts'; + +export function containsPath(parent: string, child: string): boolean { + const value = relative(parent, child); + return value === '' || (!value.startsWith(`..${sep}`) && value !== '..' && !isAbsolute(value)); +} + +export interface ResourceManagerOptions { + /** Explicit private directory outside checkouts and the installed tool's storage. */ + stateRoot: string; + maxActiveOperations: number; +} + +const configurationSchema = z + .object({ version: z.literal(1), maxActiveOperations: z.number().int().positive() }) + .strict(); + +/** Registry transactions are bounded by default. Operation ownership lives in durable records. */ +export class ResourceStore { + private constructor( + readonly root: string, + readonly capacity: number, + ) {} + + static async open(options: ResourceManagerOptions): Promise { + if (!isAbsolute(options.stateRoot)) throw new Error('stateRoot must be absolute'); + const configuration = configurationSchema.parse({ + version: 1, + maxActiveOperations: options.maxActiveOperations, + }); + const installedRoot = join(homedir(), 'Library', 'Developer', 'XcodeBuildMCP'); + if ( + containsPath(options.stateRoot, installedRoot) || + containsPath(installedRoot, options.stateRoot) + ) { + throw new Error('Resource state must be isolated from installed tool storage'); + } + await mkdir(options.stateRoot, { recursive: true, mode: 0o700 }); + const root = await realpath(options.stateRoot); + const info = await lstat(options.stateRoot); + if ( + info.isSymbolicLink() || + !info.isDirectory() || + (info.mode & 0o077) !== 0 || + info.uid !== process.getuid?.() + ) { + throw new Error('Resource state must be a private directory owned by the current user'); + } + if (containsPath(root, installedRoot) || containsPath(installedRoot, root)) { + throw new Error('Resource state resolves to installed tool storage'); + } + const store = new ResourceStore(root, configuration.maxActiveOperations); + await store.transaction(async () => { + const previous = await store.read('configuration.json', configurationSchema); + if (previous && previous.maxActiveOperations !== configuration.maxActiveOperations) { + throw new Error('All participants must use the same operation capacity'); + } + if (!previous) await store.write('configuration.json', configuration); + for (const directory of ['bindings', 'operations', 'provisioning', 'completions']) { + await mkdir(join(root, directory), { recursive: true, mode: 0o700 }); + } + }); + return store; + } + + async transaction( + callback: () => Promise, + options: { waitForLock?: boolean } = {}, + ): Promise { + const deadline = options.waitForLock ? Number.POSITIVE_INFINITY : Date.now() + 10_000; + do { + const lock = await tryAcquireFsLock({ + lockDir: join(this.root, 'registry.lock'), + purpose: 'resource-registry', + leaseMs: 30_000, + }); + if (lock) { + try { + return await callback(); + } finally { + await lock.release(); + } + } + await delay(10); + } while (Date.now() < deadline); + throw new Error('Resource registry transaction timed out'); + } + + async read(name: string, schema: z.ZodType): Promise { + let content: string; + try { + content = await readFile(join(this.root, name), 'utf8'); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null; + throw error; + } + return schema.parse(JSON.parse(content)); + } + + async list(directory: string, schema: z.ZodType): Promise { + const entries = await readdir(join(this.root, directory), { withFileTypes: true }); + const records: T[] = []; + for (const entry of entries) { + if (!entry.name.endsWith('.json')) continue; + if (!entry.isFile()) throw new Error('Resource record is not a regular file'); + const record = await this.read(`${directory}/${entry.name}`, schema); + if (!record) throw new Error('Resource record disappeared during transaction'); + records.push(record); + } + return records; + } + + async write(name: string, record: unknown): Promise { + const destination = join(this.root, name); + const temporary = `${destination}.${randomUUID()}.tmp`; + try { + await writeFile(temporary, `${JSON.stringify(record)}\n`, { + encoding: 'utf8', + mode: 0o600, + flag: 'wx', + }); + await rename(temporary, destination); + } finally { + await rm(temporary, { force: true }); + } + } +} diff --git a/src/resource-management/test-execution.ts b/src/resource-management/test-execution.ts new file mode 100644 index 000000000..169a3e1f0 --- /dev/null +++ b/src/resource-management/test-execution.ts @@ -0,0 +1,145 @@ +import * as z from 'zod'; +import type { CommandExecutor, CommandResponse } from '../utils/CommandExecutor.ts'; +import { isCommandSupervised } from './execution.ts'; + +export class SimulatorTestUncertainError extends Error { + override readonly name = 'SimulatorTestUncertainError'; +} + +const deviceEntrySchema = z.object({ + udid: z.string().uuid(), + isAvailable: z.boolean(), + state: z.string(), +}); + +const devicesCatalogSchema = z.object({ + devices: z.record(z.string(), z.array(deviceEntrySchema)), +}); + +function parseUniqueDeviceFromCatalog( + rawJson: string, + targetSimulatorId: string, +): z.infer { + let parsed: unknown; + try { + parsed = JSON.parse(rawJson); + } catch (cause) { + throw new SimulatorTestUncertainError('Failed to parse simulator catalog JSON', { cause }); + } + + const catalogResult = devicesCatalogSchema.safeParse(parsed); + if (!catalogResult.success) { + throw new SimulatorTestUncertainError('Simulator catalog schema validation failed', { + cause: catalogResult.error, + }); + } + + const matches = Object.values(catalogResult.data.devices) + .flat() + .filter((entry) => entry.udid.toLowerCase() === targetSimulatorId.toLowerCase()); + + if (matches.length === 0) { + throw new SimulatorTestUncertainError( + `Bound Simulator UUID ${targetSimulatorId} missing from device catalog`, + ); + } + + if (matches.length > 1) { + throw new SimulatorTestUncertainError( + `Duplicate entries found for Simulator UUID ${targetSimulatorId} in device catalog`, + ); + } + + const device = matches[0]!; + if (!device.isAvailable) { + throw new SimulatorTestUncertainError(`Bound Simulator ${targetSimulatorId} is unavailable`); + } + + return device; +} + +/** + * Shut down a dedicated Simulator after testing and verify its Shutdown state. + * Only operates on the specified dedicated Simulator UUID. + * Fails loudly if supervised context is missing or if any step is uncertain. + */ +export async function shutdownAndVerifyManagedSimulator( + simulatorId: string, + executor: CommandExecutor, +): Promise { + if (!isCommandSupervised()) { + throw new SimulatorTestUncertainError( + 'Managed simulator shutdown requires an active supervised context', + ); + } + + const parsedUuid = z.string().uuid().safeParse(simulatorId); + if (!parsedUuid.success) { + throw new SimulatorTestUncertainError( + `Invalid simulatorId UUID for managed shutdown: ${simulatorId}`, + ); + } + + async function execute(command: string[], logPrefix: string): Promise { + let response: CommandResponse; + try { + response = await executor(command, logPrefix, false); + } catch (cause) { + if (cause instanceof SimulatorTestUncertainError) { + throw cause; + } + throw new SimulatorTestUncertainError( + `Simulator command execution failed or was uncertain: ${command.join(' ')}`, + { cause }, + ); + } + + if ( + response.success !== true || + response.exitCode !== 0 || + response.process?.exitCode !== 0 || + response.process?.signalCode !== null + ) { + throw new SimulatorTestUncertainError( + `Managed simulator command execution failed or was interrupted: ${command.join(' ')} (success=${response.success}, exitCode=${response.exitCode}, processExitCode=${response.process?.exitCode}, signalCode=${response.process?.signalCode})`, + ); + } + + return response; + } + + // 1. Initial list devices query + const initialCatalogResponse = await execute( + ['xcrun', 'simctl', 'list', 'devices', '--json'], + 'Query Simulator State Before Shutdown', + ); + const initialDevice = parseUniqueDeviceFromCatalog(initialCatalogResponse.output, simulatorId); + + // If already Shutdown, verify it and return immediately + if (initialDevice.state === 'Shutdown') { + return; + } + + // If not Booted (e.g. Booting, Shutting Down, or other unknown state), conservatively throw + if (initialDevice.state !== 'Booted') { + throw new SimulatorTestUncertainError( + `Simulator initial state is unexpected for shutdown: ${initialDevice.state}`, + ); + } + + // 2. Execute shutdown on target UUID, preserving the caller's casing + await execute(['xcrun', 'simctl', 'shutdown', simulatorId], 'Shutdown Managed Simulator'); + + // 3. Post-shutdown list devices query + const postCatalogResponse = await execute( + ['xcrun', 'simctl', 'list', 'devices', '--json'], + 'Verify Simulator Shutdown State', + ); + const postDevice = parseUniqueDeviceFromCatalog(postCatalogResponse.output, simulatorId); + + if (postDevice.state !== 'Shutdown') { + throw new SimulatorTestUncertainError( + `Simulator failed to reach Shutdown state: ${postDevice.state}`, + ); + } +} diff --git a/src/resource-management/tool-gate.ts b/src/resource-management/tool-gate.ts new file mode 100644 index 000000000..3a7d6e6e6 --- /dev/null +++ b/src/resource-management/tool-gate.ts @@ -0,0 +1,290 @@ +import { randomUUID } from 'node:crypto'; +import { setTimeout as delay } from 'node:timers/promises'; +import * as z from 'zod'; +import type { ToolSchemaShape } from '../core/plugin-types.ts'; +import type { ToolHandlerContext } from '../rendering/types.ts'; +import { getDefaultCommandExecutor } from '../utils/command.ts'; +import { resourceEnvironment } from './environment.ts'; +import { SimulatorResourceManager, type LeaseCredentials } from './manager.ts'; +import { resolveManagedWorktree } from './worktree.ts'; +import { CommandSupervision } from './execution.ts'; +import { SimulatorReadinessUncertainError } from './boot.ts'; +import { SimulatorLaunchUncertainError } from './launch.ts'; +import { SimulatorTestUncertainError } from './test-execution.ts'; +import { clearRuntimeSnapshot } from '../mcp/tools/ui-automation/shared/snapshot-ui-state.ts'; +import { validateManagedTestExtraArgs } from '../utils/simulator-test-execution.ts'; + +const runtimeId = randomUUID(); +const snapshotProvenance = new Map< + string, + { namespace: string; requestId: string; callId: string } +>(); +const uiModules = new Set([ + 'mcp/tools/ui-automation/snapshot_ui', + 'mcp/tools/ui-automation/tap', + 'mcp/tools/ui-automation/type_text', +]); +const snapshotInvalidatingModules = new Set([ + 'mcp/tools/simulator/boot_sim', + 'mcp/tools/simulator/build_sim', + 'mcp/tools/simulator/install_app_sim', + 'mcp/tools/simulator/launch_app_sim', + 'mcp/tools/simulator/test_sim', +]); +const credentialSchema = z.object({ + operationRequestId: z.uuid(), + operationToken: z.uuid(), + operationSessionId: z.uuid(), +}); +const managementModules = new Set([ + 'mcp/tools/resource-management/resource_operation', + 'mcp/tools/resource-management/resource_provision', +]); +const supportedModules = new Set([ + ...uiModules, + 'mcp/tools/simulator/boot_sim', + 'mcp/tools/simulator/build_sim', + 'mcp/tools/simulator/launch_app_sim', + 'mcp/tools/simulator/install_app_sim', + 'mcp/tools/simulator/test_sim', + 'mcp/tools/ui-automation/screenshot', +]); + +const managedSourceInputs: ToolSchemaShape = { + scheme: z.string().describe('The scheme to use (Required)'), + projectPath: z + .string() + .optional() + .describe('Path to .xcodeproj file. Provide EITHER this OR workspacePath, not both'), + workspacePath: z + .string() + .optional() + .describe('Path to .xcworkspace file. Provide EITHER this OR projectPath, not both'), + configuration: z.string().optional().describe('Build configuration (Debug, Release, etc.)'), + derivedDataPath: z.string().optional(), + preferXcodebuild: z.boolean().optional(), +}; + +function validateManagedSourceInputs(moduleId: string, args: Record): void { + const subject = + moduleId === 'mcp/tools/simulator/build_sim' + ? 'Managed builds' + : moduleId === 'mcp/tools/simulator/test_sim' + ? 'Managed simulator tests' + : undefined; + if (!subject) return; + + if ('simulatorName' in args) { + throw new Error('Managed calls require a resolved Simulator UUID without a name'); + } + + const hasProject = typeof args.projectPath === 'string' && args.projectPath.trim().length > 0; + const hasWorkspace = + typeof args.workspacePath === 'string' && args.workspacePath.trim().length > 0; + if (typeof args.projectPath === 'string' && args.projectPath.trim().length === 0) { + throw new Error(`${subject} do not allow blank projectPath`); + } + if (typeof args.workspacePath === 'string' && args.workspacePath.trim().length === 0) { + throw new Error(`${subject} do not allow blank workspacePath`); + } + if ((hasProject && hasWorkspace) || (!hasProject && !hasWorkspace)) { + throw new Error(`${subject} require exactly one of projectPath or workspacePath`); + } + if (typeof args.scheme !== 'string' || args.scheme.trim().length === 0) { + throw new Error(`${subject} require an explicit non-empty scheme`); + } + if (typeof args.simulatorId !== 'string' || args.simulatorId.trim().length === 0) { + throw new Error(`${subject} require an explicit simulatorId`); + } + if (!z.uuid().safeParse(args.simulatorId).success) { + throw new Error('Managed calls require a resolved Simulator UUID without a name'); + } +} + +export function managedToolSchema(moduleId: string, schema: ToolSchemaShape): ToolSchemaShape { + if (!resourceEnvironment() || !supportedModules.has(moduleId)) return schema; + return { + ...schema, + ...(moduleId === 'mcp/tools/simulator/launch_app_sim' ? { bundleId: z.string().min(1) } : {}), + ...(moduleId === 'mcp/tools/simulator/build_sim' || moduleId === 'mcp/tools/simulator/test_sim' + ? managedSourceInputs + : {}), + simulatorId: z.uuid(), + ...credentialSchema.shape, + }; +} + +export function wrapManagedTool( + moduleId: string, + handler: (args: Record, ctx?: ToolHandlerContext) => Promise, +): typeof handler { + return async (args, ctx) => { + const environment = resourceEnvironment(); + if (!environment) { + if (Object.keys(credentialSchema.shape).some((key) => key in args)) { + throw new Error('Operation credentials require managed resources'); + } + return handler(args, ctx); + } + if (managementModules.has(moduleId)) return handler(args, ctx); + if (!supportedModules.has(moduleId)) { + throw new Error('This tool is not yet supported with managed resources'); + } + if (!ctx) throw new Error('Managed invocation requires a handler context'); + const parsed = credentialSchema.safeParse(args); + if (!parsed.success) throw new Error('Explicit operation credentials are required'); + const credentials: LeaseCredentials = { + requestId: parsed.data.operationRequestId, + token: parsed.data.operationToken, + sessionId: parsed.data.operationSessionId, + }; + const toolArgs = { ...args }; + for (const key of Object.keys(credentialSchema.shape)) delete toolArgs[key]; + if (moduleId === 'mcp/tools/simulator/test_sim') { + if ('buildForTesting' in args) { + throw new Error('Managed simulator tests do not support buildForTesting'); + } + if ('testProductsPath' in args || 'xctestrunPath' in args) { + throw new Error('Managed simulator tests do not support prepared test artifacts'); + } + } + validateManagedSourceInputs(moduleId, args); + let entered = false; + ctx.managedOperation = { + async run(params, invoke): Promise { + if (entered) throw new Error('Managed tool attempted repeated execution'); + entered = true; + const target = z + .object({ simulatorId: z.uuid(), simulatorName: z.undefined() }) + .safeParse(params); + if (!target.success) + throw new Error('Managed calls require a resolved Simulator UUID without a name'); + if (moduleId === 'mcp/tools/simulator/build_sim') { + const buildParams = params as Record; + if ( + buildParams.extraArgs !== undefined && + (!Array.isArray(buildParams.extraArgs) || buildParams.extraArgs.length > 0) + ) { + throw new Error('Managed builds do not support extraArgs'); + } + if (buildParams.buildForTesting === true) { + throw new Error('Managed builds do not support buildForTesting'); + } + if (buildParams.testProductsPath !== undefined) { + throw new Error('Managed builds do not support testProductsPath'); + } + if (buildParams.preferXcodebuild === false) { + throw new Error('Managed builds do not support preferXcodebuild=false'); + } + } + if (moduleId === 'mcp/tools/simulator/test_sim') { + const testParams = params as Record; + if (testParams.extraArgs !== undefined) { + if (!Array.isArray(testParams.extraArgs)) { + throw new Error( + 'Managed simulator tests only allow -only-testing and -skip-testing selectors in extraArgs', + ); + } + validateManagedTestExtraArgs(testParams.extraArgs); + } + if (testParams.testProductsPath !== undefined || testParams.xctestrunPath !== undefined) { + throw new Error('Managed simulator tests do not support prepared test artifacts'); + } + if (testParams.preferXcodebuild === false) { + throw new Error('Managed simulator tests do not support preferXcodebuild=false'); + } + } + const manager = await SimulatorResourceManager.open(environment); + const worktree = await resolveManagedWorktree(process.cwd(), getDefaultCommandExecutor()); + const binding = await manager.getBinding(worktree.generation); + if ( + binding?.worktree.root !== worktree.root || + binding.worktree.gitDir !== worktree.gitDir + ) { + throw new Error('Worktree binding requires reconciliation'); + } + const activity = { id: randomUUID(), kind: 'call' as const, runtimeId, pid: process.pid }; + const deadline = Date.now() + 30_000; + while ( + !(await manager.startCall( + credentials, + { generation: worktree.generation, simulatorId: target.data.simulatorId }, + activity, + )) + ) { + if (Date.now() >= deadline) throw new Error('Another call still holds this operation'); + await delay(10); + } + const supervision = new CommandSupervision(); + let failure: unknown; + let failed = false; + const simulatorId = target.data.simulatorId.toLowerCase(); + try { + const status = await manager.getStatus(credentials.requestId); + const previousCall = status.completedActivities + .filter((entry) => entry.kind === 'call') + .at(-1); + const provenance = snapshotProvenance.get(simulatorId); + if ( + provenance?.namespace !== environment.namespace || + provenance?.requestId !== credentials.requestId || + provenance?.callId !== previousCall?.id || + snapshotInvalidatingModules.has(moduleId) + ) + clearRuntimeSnapshot(simulatorId); + await supervision.run(invoke); + } catch (error) { + failed = true; + failure = error; + } + if ( + !supervision.quiescent || + failure instanceof SimulatorReadinessUncertainError || + failure instanceof SimulatorLaunchUncertainError || + failure instanceof SimulatorTestUncertainError + ) { + clearRuntimeSnapshot(simulatorId); + await manager.block( + credentials, + 'Tool completion or child-process quiescence is uncertain', + ); + throw new Error('Managed command completion could not be confirmed', { cause: failure }); + } + if (failed || ctx.structuredOutput?.result.didError) clearRuntimeSnapshot(simulatorId); + snapshotProvenance.set(simulatorId, { + namespace: environment.namespace, + requestId: credentials.requestId, + callId: activity.id, + }); + if (uiModules.has(moduleId)) { + if (ctx.structuredOutput) { + const hints = ctx.structuredOutput.renderHints; + ctx.structuredOutput.renderHints = { + ...hints, + runtimeSnapshot: { ...hints?.runtimeSnapshot, managedOperation: true }, + }; + } + ctx.nextSteps = []; + delete ctx.nextStepParams; + delete ctx.nextStepConditionKeys; + } + if ( + moduleId === 'mcp/tools/simulator/build_sim' || + moduleId === 'mcp/tools/simulator/test_sim' + ) { + ctx.nextSteps = []; + delete ctx.nextStepParams; + delete ctx.nextStepConditionKeys; + } + await manager.finishActivity(credentials, activity.id, runtimeId); + if (failed) throw failure; + if (!ctx.structuredOutput) throw new Error('Managed tool did not produce a final result'); + }, + }; + try { + return await handler(toolArgs, ctx); + } finally { + delete ctx.managedOperation; + } + }; +} diff --git a/src/resource-management/worktree.ts b/src/resource-management/worktree.ts new file mode 100644 index 000000000..a082cbc59 --- /dev/null +++ b/src/resource-management/worktree.ts @@ -0,0 +1,51 @@ +import { createHash } from 'node:crypto'; +import { realpath, stat } from 'node:fs/promises'; +import type { CommandExecutor } from '../utils/execution/index.ts'; +import { workspaceKeyForRoot } from '../utils/workspace-identity.ts'; + +export interface ManagedWorktree { + generation: string; + root: string; + gitDir: string; + workspaceKey: string; +} + +async function directoryIdentity(directory: string): Promise { + const info = await stat(directory, { bigint: true }); + if (!info.isDirectory() || info.birthtimeNs <= 0n || info.ino <= 0n) { + throw new Error('Worktree identity requires directory inode and birth time'); + } + return `${info.dev}:${info.ino}:${info.birthtimeNs}`; +} + +export async function worktreeGeneration(root: string, gitDir: string): Promise { + const identities = await Promise.all([directoryIdentity(root), directoryIdentity(gitDir)]); + return createHash('sha256').update(identities.join('/')).digest('hex'); +} + +/** Git resolves nested working directories and linked worktrees; no marker is written to Git. */ +export async function resolveManagedWorktree( + cwd: string, + executor: CommandExecutor, +): Promise { + const paths: string[] = []; + for (const option of ['--show-toplevel', '--absolute-git-dir']) { + const response = await executor( + ['git', '-C', cwd, 'rev-parse', option], + 'Worktree identity', + false, + ); + if (!response.success) + throw new Error(`Cannot resolve worktree: ${response.error ?? response.output}`); + const value = response.output.replace(/\r?\n$/, ''); + if (!value || /[\r\n]/.test(value)) throw new Error('Expected one Git directory path'); + paths.push(await realpath(value)); + } + const [root, gitDir] = paths; + return { + root, + gitDir, + generation: await worktreeGeneration(root, gitDir), + workspaceKey: workspaceKeyForRoot(root), + }; +} diff --git a/src/runtime/bootstrap-runtime.ts b/src/runtime/bootstrap-runtime.ts index 3f16c1556..e3ab96273 100644 --- a/src/runtime/bootstrap-runtime.ts +++ b/src/runtime/bootstrap-runtime.ts @@ -13,6 +13,8 @@ import { scheduleSimulatorDefaultsRefresh } from '../utils/simulator-defaults-re import { expandHomePrefix } from '../utils/path.ts'; import { resolveWorkspaceIdentity } from '../utils/workspace-identity.ts'; import { configureRuntimeWorkspaceKey } from '../utils/runtime-instance.ts'; +import { resourceEnvironment } from '../resource-management/environment.ts'; +import { SimulatorResourceManager } from '../resource-management/manager.ts'; export type RuntimeKind = 'cli' | 'daemon' | 'mcp'; @@ -73,6 +75,7 @@ function hydrateSessionDefaultsForMcp( } const activeDefaults = sessionStore.getAll(); + if (resourceEnvironment()) return { hydrated: true, refreshScheduled: false }; const revision = sessionStore.getRevision(); const refreshScheduled = scheduleSimulatorDefaultsRefresh({ expectedRevision: revision, @@ -116,6 +119,7 @@ export async function bootstrapRuntime( try { process.chdir(cwdOverride); } catch (error) { + if (resourceEnvironment()) throw error; log( 'warn', `XCODEBUILDMCP_CWD points at "${cwdOverride}" but chdir failed: ${ @@ -125,6 +129,8 @@ export async function bootstrapRuntime( } } const cwd = opts.cwd ?? process.cwd(); + const managed = resourceEnvironment(); + if (managed) await SimulatorResourceManager.open(managed); const fs = opts.fs ?? getDefaultFileSystemExecutor(); const configResult = await initConfigStore({ diff --git a/src/runtime/tool-invoker.ts b/src/runtime/tool-invoker.ts index 2c0024c43..adf7faeba 100644 --- a/src/runtime/tool-invoker.ts +++ b/src/runtime/tool-invoker.ts @@ -19,6 +19,7 @@ import { import type { RenderSession, ToolHandlerContext } from '../rendering/types.ts'; import { createRenderSession } from '../rendering/render.ts'; import { createStructuredErrorOutput } from '../utils/structured-error.ts'; +import { resourceEnvironment } from '../resource-management/environment.ts'; type BuiltTemplateNextStep = { step: NextStep; @@ -400,7 +401,7 @@ export class DefaultToolInvoker implements ToolInvoker { context.captureInvocationMetric('completed'); context.consumeResult(daemonResult); } catch (error) { - if (error instanceof DaemonVersionMismatchError) { + if (error instanceof DaemonVersionMismatchError && !resourceEnvironment()) { log('info', `[infra/tool-invoker] ${context.label} daemon protocol mismatch, restarting`); try { await forceStopDaemon(socketPath); @@ -483,6 +484,10 @@ export class DefaultToolInvoker implements ToolInvoker { const isDynamicXcodeIdeTool = tool.workflow === 'xcode-ide' && typeof xcodeIdeRemoteToolName === 'string'; + if (resourceEnvironment() && isDynamicXcodeIdeTool) { + throw new Error('Xcode bridge is unsupported with managed resources'); + } + if (opts.runtime === 'cli' && isDynamicXcodeIdeTool) { transport = 'xcode-ide-daemon'; return this.invokeViaDaemon( diff --git a/src/server/bootstrap.ts b/src/server/bootstrap.ts index d4b54ea88..1bbb986f0 100644 --- a/src/server/bootstrap.ts +++ b/src/server/bootstrap.ts @@ -15,6 +15,7 @@ import { getDefaultCommandExecutor } from '../utils/command.ts'; import type { PredicateContext } from '../visibility/predicate-types.ts'; import { createStartupProfiler, getStartupProfileNowMs } from './startup-profiler.ts'; import { runWorkspaceFilesystemLifecycleSweep } from '../utils/workspace-filesystem-lifecycle.ts'; +import { resourceEnvironment } from '../resource-management/environment.ts'; export interface BootstrapOptions { enabledWorkflows?: string[]; @@ -113,7 +114,7 @@ export async function bootstrapServer( profiler.mark('registerWorkflowsFromManifest', stageStartMs); const resolvedWorkflows = getRegisteredWorkflows(); - const xcodeIdeEnabled = resolvedWorkflows.includes('xcode-ide'); + const xcodeIdeEnabled = !resourceEnvironment() && resolvedWorkflows.includes('xcode-ide'); const xcodeToolsBridge = xcodeIdeEnabled ? getXcodeToolsBridgeManager(server) : null; xcodeToolsBridge?.setWorkflowEnabled(xcodeIdeEnabled); @@ -123,6 +124,7 @@ export async function bootstrapServer( return { runDeferredInitialization: async (options = {}): Promise => { + if (resourceEnvironment()) return; const deferredProfiler = createStartupProfiler('bootstrap-deferred'); const isShutdownRequested = options.isShutdownRequested; diff --git a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/boot_sim.json b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/boot_sim.json index 1ce899027..9f2aaa77d 100644 --- a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/boot_sim.json +++ b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/boot_sim.json @@ -1,6 +1,6 @@ { "annotations": {"destructiveHint":false,"openWorldHint":false,"readOnlyHint":false,"title":"Boot Simulator"}, - "description": "Boot iOS simulator for manual/non-build flows. Not required before simulator build-and-run (build_run_sim).", + "description": "Boot iOS simulator for manual/non-build flows. Not required before simulator build-and-run (build_run_sim). In managed mode, requires an operation lease and waits for boot readiness.", "execution": {"taskSupport":"forbidden"}, "inputSchema": { "$schema": "http://json-schema.org/draft-07/schema#", diff --git a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/build_sim.json b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/build_sim.json index 08765c5ce..4be3e78d5 100644 --- a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/build_sim.json +++ b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/build_sim.json @@ -1,6 +1,6 @@ { "annotations": {"destructiveHint":false,"openWorldHint":false,"readOnlyHint":false,"title":"Build Simulator"}, - "description": "Build for iOS sim (compile-only, no launch).", + "description": "Build for iOS sim (compile-only, no launch). In managed mode, executes standard supervised xcodebuild under an operation lease and requires explicit projectPath/workspacePath, scheme, simulatorId, and operation credentials. Non-empty extraArgs, buildForTesting=true, testProductsPath, and preferXcodebuild=false are rejected. Operation remains held after successful builds and confirmed compile failures until explicit end; uncertain command completion or open streams block the lease. Supervision covers direct child exit and closed streams, without process-tree guarantees for user build scripts. build_run_sim remains unsupported in managed mode.", "execution": {"taskSupport":"forbidden"}, "inputSchema": { "$schema": "http://json-schema.org/draft-07/schema#", diff --git a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/launch_app_sim.json b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/launch_app_sim.json index 70fff21b3..f23676e09 100644 --- a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/launch_app_sim.json +++ b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/launch_app_sim.json @@ -1,6 +1,6 @@ { "annotations": {"destructiveHint":false,"openWorldHint":false,"readOnlyHint":false,"title":"Launch App Simulator"}, - "description": "Launch app on simulator. Runtime logs are captured automatically and the log file path is included in the response.", + "description": "Launch app on simulator. In managed mode, requires an operation lease and explicit bundle ID, restarts the app, and returns its PID without background log capture. Otherwise, runtime logs are captured automatically and the log file path is included in the response.", "execution": {"taskSupport":"forbidden"}, "inputSchema": { "$schema": "http://json-schema.org/draft-07/schema#", diff --git a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/snapshot_ui.json b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/snapshot_ui.json index 71c3ab739..d06114fc1 100644 --- a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/snapshot_ui.json +++ b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/snapshot_ui.json @@ -1,6 +1,6 @@ { "annotations": {"destructiveHint":false,"openWorldHint":false,"readOnlyHint":true,"title":"Snapshot UI"}, - "description": "Capture a semantic rs/1 runtime UI snapshot with elementRef targets. Observe once, use tap for one target or batch for multiple same-screen targets, and refresh after navigation, scrolling, sheet changes, or obvious layout changes.", + "description": "Capture a semantic rs/1 runtime UI snapshot with elementRef targets. In managed mode, requires operation credentials and always returns a full snapshot with fresh refs; batch and wait_for_ui are not yet supported. Observe once, use tap for one target or batch for multiple same-screen targets, and refresh after navigation, scrolling, sheet changes, or obvious layout changes.", "execution": {"taskSupport":"forbidden"}, "inputSchema": { "$schema": "http://json-schema.org/draft-07/schema#", diff --git a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/tap.json b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/tap.json index 3c94e495d..c8b671acd 100644 --- a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/tap.json +++ b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/tap.json @@ -1,6 +1,6 @@ { "annotations": {"destructiveHint":false,"openWorldHint":false,"readOnlyHint":true,"title":"Tap"}, - "description": "Tap one elementRef from the latest snapshot_ui or wait_for_ui output. The elementRef must list the tap action in the snapshot targets; do not use refs from text-only rows. For multiple same-screen taps or visible switch toggles with no intermediate assertion, use batch instead of repeated tap calls. Other same-screen refs may remain usable after success; refresh after navigation, scrolling, sheet changes, or obvious layout changes.", + "description": "Tap one elementRef from the latest snapshot_ui or wait_for_ui output. In managed mode, requires operation credentials and a ref from this operation and runtime; use fresh refs returned after each action, and batch is not yet supported. The elementRef must list the tap action in the snapshot targets; do not use refs from text-only rows. For multiple same-screen taps or visible switch toggles with no intermediate assertion, use batch instead of repeated tap calls. Other same-screen refs may remain usable after success; refresh after navigation, scrolling, sheet changes, or obvious layout changes.", "execution": {"taskSupport":"forbidden"}, "inputSchema": { "$schema": "http://json-schema.org/draft-07/schema#", diff --git a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/test_sim.json b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/test_sim.json index a2ed4881f..c0df2c926 100644 --- a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/test_sim.json +++ b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/test_sim.json @@ -1,6 +1,6 @@ { "annotations": {"destructiveHint":false,"openWorldHint":false,"readOnlyHint":false,"title":"Test Simulator"}, - "description": "Test on iOS sim.", + "description": "Test on iOS sim. In managed mode, executes standard supervised two-phase xcodebuild under an operation lease and requires explicit projectPath or workspacePath, scheme, simulatorId, and operation credentials. Prepared test artifacts, unsafe extraArgs, simulatorName, buildForTesting, and preferXcodebuild=false are rejected; -only-testing and -skip-testing selectors are allowed. testRunnerEnv is accepted in wire format. Phase 2 disables parallel testing and limits concurrent simulator destinations to one while preserving project and test-plan repetition settings. On normal phase 2 exit 0 or 65, metadata extraction is followed by shutdown of the specified Simulator and a unique available-catalog verification of its Shutdown state, clearing running app state without erase/delete or auto-reboot (subsequent UI automation requires boot_sim). Confirmed phase 1 compile failures do not shut down the simulator. Completed runs release upon explicit end; in-flight calls hold concurrent end in closing. Signals, rejected or unknown commands, open streams, cleanup failures, or unexpected exceptions after phase 2 starts and before verified shutdown block the lease without automatic TTL or recovery. Supervision confirms CoreSimulator Shutdown state without arbitrary host script process-tree guarantees. Output uses schema version 3, UI snapshots are invalidated upon admission, and next-step templates are suppressed. Normal MCP and native session resume remain available without an external resume script.", "execution": {"taskSupport":"forbidden"}, "inputSchema": { "$schema": "http://json-schema.org/draft-07/schema#", diff --git a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/type_text.json b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/type_text.json index 19bc18bc3..e4ca65c53 100644 --- a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/type_text.json +++ b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/type_text.json @@ -1,6 +1,6 @@ { "annotations": {"destructiveHint":false,"openWorldHint":false,"readOnlyHint":true,"title":"Type Text"}, - "description": "Type text into a UI element by elementRef from a current rs/1 runtime snapshot, optionally replacing existing field contents. elementRef is required; do not call with only text. Example input: {\"elementRef\":\"e8\",\"text\":\"London\",\"replaceExisting\":true}.", + "description": "Type text into a UI element by elementRef from a current rs/1 runtime snapshot, optionally replacing existing field contents. In managed mode, requires operation credentials and a ref from this operation and runtime; use fresh refs returned after each action. elementRef is required; do not call with only text. Example input: {\"elementRef\":\"e8\",\"text\":\"London\",\"replaceExisting\":true}.", "execution": {"taskSupport":"forbidden"}, "inputSchema": { "$schema": "http://json-schema.org/draft-07/schema#", diff --git a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/boot_sim.json b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/boot_sim.json index 97241b432..0ea6da03a 100644 --- a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/boot_sim.json +++ b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/boot_sim.json @@ -1,6 +1,6 @@ { "annotations": {"destructiveHint":false,"openWorldHint":false,"readOnlyHint":false,"title":"Boot Simulator"}, - "description": "Boot iOS simulator for manual/non-build flows. Not required before simulator build-and-run (build_run_sim).", + "description": "Boot iOS simulator for manual/non-build flows. Not required before simulator build-and-run (build_run_sim). In managed mode, requires an operation lease and waits for boot readiness.", "execution": {"taskSupport":"forbidden"}, "inputSchema": { "$schema": "http://json-schema.org/draft-07/schema#", diff --git a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/build_sim.json b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/build_sim.json index 0e7aa8f76..a8d58d964 100644 --- a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/build_sim.json +++ b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/build_sim.json @@ -1,6 +1,6 @@ { "annotations": {"destructiveHint":false,"openWorldHint":false,"readOnlyHint":false,"title":"Build Simulator"}, - "description": "Build for iOS sim (compile-only, no launch).", + "description": "Build for iOS sim (compile-only, no launch). In managed mode, executes standard supervised xcodebuild under an operation lease and requires explicit projectPath/workspacePath, scheme, simulatorId, and operation credentials. Non-empty extraArgs, buildForTesting=true, testProductsPath, and preferXcodebuild=false are rejected. Operation remains held after successful builds and confirmed compile failures until explicit end; uncertain command completion or open streams block the lease. Supervision covers direct child exit and closed streams, without process-tree guarantees for user build scripts. build_run_sim remains unsupported in managed mode.", "execution": {"taskSupport":"forbidden"}, "inputSchema": { "$schema": "http://json-schema.org/draft-07/schema#", diff --git a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/launch_app_sim.json b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/launch_app_sim.json index 7f6690f3e..e596d205a 100644 --- a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/launch_app_sim.json +++ b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/launch_app_sim.json @@ -1,6 +1,6 @@ { "annotations": {"destructiveHint":false,"openWorldHint":false,"readOnlyHint":false,"title":"Launch App Simulator"}, - "description": "Launch app on simulator. Runtime logs are captured automatically and the log file path is included in the response.", + "description": "Launch app on simulator. In managed mode, requires an operation lease and explicit bundle ID, restarts the app, and returns its PID without background log capture. Otherwise, runtime logs are captured automatically and the log file path is included in the response.", "execution": {"taskSupport":"forbidden"}, "inputSchema": { "$schema": "http://json-schema.org/draft-07/schema#", diff --git a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/snapshot_ui.json b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/snapshot_ui.json index 1f57986c3..c1cf157a8 100644 --- a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/snapshot_ui.json +++ b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/snapshot_ui.json @@ -1,6 +1,6 @@ { "annotations": {"destructiveHint":false,"openWorldHint":false,"readOnlyHint":true,"title":"Snapshot UI"}, - "description": "Capture a semantic rs/1 runtime UI snapshot with elementRef targets. Observe once, use tap for one target or batch for multiple same-screen targets, and refresh after navigation, scrolling, sheet changes, or obvious layout changes.", + "description": "Capture a semantic rs/1 runtime UI snapshot with elementRef targets. In managed mode, requires operation credentials and always returns a full snapshot with fresh refs; batch and wait_for_ui are not yet supported. Observe once, use tap for one target or batch for multiple same-screen targets, and refresh after navigation, scrolling, sheet changes, or obvious layout changes.", "execution": {"taskSupport":"forbidden"}, "inputSchema": { "$schema": "http://json-schema.org/draft-07/schema#", diff --git a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/tap.json b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/tap.json index b426e8d7e..5d8f9f05b 100644 --- a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/tap.json +++ b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/tap.json @@ -1,6 +1,6 @@ { "annotations": {"destructiveHint":false,"openWorldHint":false,"readOnlyHint":true,"title":"Tap"}, - "description": "Tap one elementRef from the latest snapshot_ui or wait_for_ui output. The elementRef must list the tap action in the snapshot targets; do not use refs from text-only rows. For multiple same-screen taps or visible switch toggles with no intermediate assertion, use batch instead of repeated tap calls. Other same-screen refs may remain usable after success; refresh after navigation, scrolling, sheet changes, or obvious layout changes.", + "description": "Tap one elementRef from the latest snapshot_ui or wait_for_ui output. In managed mode, requires operation credentials and a ref from this operation and runtime; use fresh refs returned after each action, and batch is not yet supported. The elementRef must list the tap action in the snapshot targets; do not use refs from text-only rows. For multiple same-screen taps or visible switch toggles with no intermediate assertion, use batch instead of repeated tap calls. Other same-screen refs may remain usable after success; refresh after navigation, scrolling, sheet changes, or obvious layout changes.", "execution": {"taskSupport":"forbidden"}, "inputSchema": { "$schema": "http://json-schema.org/draft-07/schema#", diff --git a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/test_sim.json b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/test_sim.json index a9c02ce97..b3f3470e8 100644 --- a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/test_sim.json +++ b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/test_sim.json @@ -1,6 +1,6 @@ { "annotations": {"destructiveHint":false,"openWorldHint":false,"readOnlyHint":false,"title":"Test Simulator"}, - "description": "Test on iOS sim.", + "description": "Test on iOS sim. In managed mode, executes standard supervised two-phase xcodebuild under an operation lease and requires explicit projectPath or workspacePath, scheme, simulatorId, and operation credentials. Prepared test artifacts, unsafe extraArgs, simulatorName, buildForTesting, and preferXcodebuild=false are rejected; -only-testing and -skip-testing selectors are allowed. testRunnerEnv is accepted in wire format. Phase 2 disables parallel testing and limits concurrent simulator destinations to one while preserving project and test-plan repetition settings. On normal phase 2 exit 0 or 65, metadata extraction is followed by shutdown of the specified Simulator and a unique available-catalog verification of its Shutdown state, clearing running app state without erase/delete or auto-reboot (subsequent UI automation requires boot_sim). Confirmed phase 1 compile failures do not shut down the simulator. Completed runs release upon explicit end; in-flight calls hold concurrent end in closing. Signals, rejected or unknown commands, open streams, cleanup failures, or unexpected exceptions after phase 2 starts and before verified shutdown block the lease without automatic TTL or recovery. Supervision confirms CoreSimulator Shutdown state without arbitrary host script process-tree guarantees. Output uses schema version 3, UI snapshots are invalidated upon admission, and next-step templates are suppressed. Normal MCP and native session resume remain available without an external resume script.", "execution": {"taskSupport":"forbidden"}, "inputSchema": { "$schema": "http://json-schema.org/draft-07/schema#", diff --git a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/type_text.json b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/type_text.json index c46ef873a..124a7bb7e 100644 --- a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/type_text.json +++ b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/type_text.json @@ -1,6 +1,6 @@ { "annotations": {"destructiveHint":false,"openWorldHint":false,"readOnlyHint":true,"title":"Type Text"}, - "description": "Type text into a UI element by elementRef from a current rs/1 runtime snapshot, optionally replacing existing field contents. elementRef is required; do not call with only text. Example input: {\"elementRef\":\"e8\",\"text\":\"London\",\"replaceExisting\":true}.", + "description": "Type text into a UI element by elementRef from a current rs/1 runtime snapshot, optionally replacing existing field contents. In managed mode, requires operation credentials and a ref from this operation and runtime; use fresh refs returned after each action. elementRef is required; do not call with only text. Example input: {\"elementRef\":\"e8\",\"text\":\"London\",\"replaceExisting\":true}.", "execution": {"taskSupport":"forbidden"}, "inputSchema": { "$schema": "http://json-schema.org/draft-07/schema#", diff --git a/src/types/domain-results.ts b/src/types/domain-results.ts index cc035f6ed..9149e4340 100644 --- a/src/types/domain-results.ts +++ b/src/types/domain-results.ts @@ -1,4 +1,6 @@ export type ToolDomainResultKind = + | 'resource-binding' + | 'resource-operation' | 'error' | 'app-path' | 'build-result' @@ -696,6 +698,8 @@ export type WorkflowSelectionDomainResult = ToolDomainResultBase & { registeredToolCount: number; }; export type ToolDomainResult = + | ResourceBindingDomainResult + | ResourceOperationDomainResult | ErrorDomainResult | AppPathDomainResult | BuildResultDomainResult @@ -729,3 +733,23 @@ export type ToolDomainResult = | XcodeBridgeStatusDomainResult | XcodeBridgeSyncDomainResult | XcodeBridgeToolListDomainResult; + +export interface ResourceOperationDomainResult extends ToolDomainResultBase { + kind: 'resource-operation'; + action: 'begin' | 'poll' | 'status' | 'end' | 'cancel'; + requestId: string; + sessionId: string; + simulatorId: string; + state: 'waiting' | 'active' | 'closing' | 'blocked' | 'released' | 'cancelled'; + activityCount: number; + token?: string; + reason?: string; +} + +export interface ResourceBindingDomainResult extends ToolDomainResultBase { + kind: 'resource-binding'; + worktreeGeneration: string; + simulatorId: string | null; + deviceType: string; + runtime: string; +} diff --git a/src/utils/__tests__/build-utils.test.ts b/src/utils/__tests__/build-utils.test.ts index 42d808727..1e55f6c46 100644 --- a/src/utils/__tests__/build-utils.test.ts +++ b/src/utils/__tests__/build-utils.test.ts @@ -5,7 +5,11 @@ import { describe, it, expect, vi, afterEach } from 'vitest'; import path from 'node:path'; import { homedir } from 'node:os'; -import { createMockExecutor } from '../../test-utils/mock-executors.ts'; +import { + createMockExecutor, + createMockCommandResponse, + type CommandExecutor, +} from '../../test-utils/mock-executors.ts'; import { executeXcodeBuildCommand } from '../build-utils.ts'; import { XcodePlatform } from '../xcode.ts'; import type { XcodebuildPipeline } from '../xcodebuild-pipeline.ts'; @@ -581,4 +585,98 @@ describe('build-utils Sentry Classification', () => { expect(capturedCommand).toContain(expectedDerivedDataPath); }); }); + + describe('Simulator Destination UUID Casing and Matching', () => { + const expectedUpper = '65FAA1DC-6DC9-456D-A88E-3CF5A516F95B'; + const testCases = [ + { type: 'lowercase', input: '65faa1dc-6dc9-456d-a88e-3cf5a516f95b' }, + { type: 'mixed-case', input: '65FaA1Dc-6Dc9-456D-a88e-3Cf5A516F95b' }, + { type: 'uppercase', input: '65FAA1DC-6DC9-456D-A88E-3CF5A516F95B' }, + ]; + + testCases.forEach(({ type, input }) => { + it(`normalizes ${type} simulatorId to uppercase at xcodebuild command boundary and satisfies case-sensitive executor`, async () => { + let capturedCommand: string[] | undefined; + // Mock executor simulates real xcodebuild / CoreSimulator case-sensitive matching + const mockExecutor: CommandExecutor = async (command) => { + capturedCommand = command; + const destIdx = command.indexOf('-destination'); + const dest = destIdx !== -1 ? command[destIdx + 1] : ''; + const match = dest.match(/id=([^,]+)/); + if (match && match[1] !== expectedUpper) { + return createMockCommandResponse({ + success: false, + exitCode: 70, + error: `xcodebuild: error: Unable to find a device matching the provided destination specifier: { ${dest} }`, + output: '', + }); + } + return createMockCommandResponse({ + success: true, + exitCode: 0, + output: '** BUILD SUCCEEDED **', + }); + }; + + const result = await executeXcodeBuildCommand( + { + scheme: 'TestApp', + projectPath: '/path/to/TestApp.xcodeproj', + }, + { + platform: XcodePlatform.iOSSimulator, + simulatorId: input, + logPrefix: 'iOS Simulator Build', + }, + false, + 'build', + mockExecutor, + undefined, + createMockPipeline(), + ); + + expect(result.isError).toBeFalsy(); + expect(capturedCommand).toBeDefined(); + const destIdx = capturedCommand!.indexOf('-destination'); + expect(destIdx).toBeGreaterThan(-1); + expect(capturedCommand![destIdx + 1]).toBe(`platform=iOS Simulator,id=${expectedUpper}`); + }); + }); + + it('preserves physical deviceId casing without uppercase normalization', async () => { + let capturedCommand: string[] | undefined; + const mockExecutor: CommandExecutor = createMockExecutor({ + success: true, + exitCode: 0, + output: '** BUILD SUCCEEDED **', + onExecute: (command) => { + capturedCommand = command; + }, + }); + + const physicalId = '00008101-001234567890abcdef'; + const result = await executeXcodeBuildCommand( + { + scheme: 'TestApp', + projectPath: '/path/to/TestApp.xcodeproj', + }, + { + platform: XcodePlatform.iOS, + deviceId: physicalId, + logPrefix: 'iOS Device Build', + }, + false, + 'build', + mockExecutor, + undefined, + createMockPipeline(), + ); + + expect(result.isError).toBeFalsy(); + expect(capturedCommand).toBeDefined(); + const destIdx = capturedCommand!.indexOf('-destination'); + expect(destIdx).toBeGreaterThan(-1); + expect(capturedCommand![destIdx + 1]).toBe(`platform=iOS,id=${physicalId}`); + }); + }); }); diff --git a/src/utils/__tests__/infer-platform.test.ts b/src/utils/__tests__/infer-platform.test.ts index b9647da55..3d88b423c 100644 --- a/src/utils/__tests__/infer-platform.test.ts +++ b/src/utils/__tests__/infer-platform.test.ts @@ -206,4 +206,85 @@ describe('inferPlatform', () => { ), ).rejects.toThrow(/Unable to determine the simulator platform/); }); + + it('matches cached simulatorPlatform when selector UUID differs by case', async () => { + sessionStore.setDefaults({ + simulatorId: '65faa1dc-6dc9-456d-a88e-3cf5a516f95b', + simulatorPlatform: XcodePlatform.iOSSimulator, + }); + + const executor = createMockExecutor(new Error('Executor should not be called')); + const result = await inferPlatform( + { simulatorId: '65FAA1DC-6DC9-456D-A88E-3CF5A516F95B' }, + executor, + ); + + expect(result.platform).toBe(XcodePlatform.iOSSimulator); + expect(result.source).toBe('simulator-platform-cache'); + }); + + it('matches simctl catalog UDID case-insensitively when catalog emits uppercase UDID', async () => { + const mockExecutor: CommandExecutor = async () => + createMockCommandResponse({ + success: true, + output: JSON.stringify({ + devices: { + 'com.apple.CoreSimulator.SimRuntime.iOS-18-0': [ + { + udid: '65FAA1DC-6DC9-456D-A88E-3CF5A516F95B', + name: 'iPhone 16 Pro', + isAvailable: true, + }, + ], + }, + }), + }); + + const result = await inferPlatform( + { simulatorId: '65faa1dc-6dc9-456d-a88e-3cf5a516f95b' }, + mockExecutor, + ); + + expect(result.platform).toBe(XcodePlatform.iOSSimulator); + expect(result.source).toBe('simulator-runtime'); + }); + + it('preserves case-sensitivity for simulatorName in cache and catalog lookup', async () => { + sessionStore.setDefaults({ + simulatorName: 'iPhone 17 Pro', + simulatorPlatform: XcodePlatform.iOSSimulator, + }); + + // Cache should not match because simulatorName is case-sensitive + const mockExecutor: CommandExecutor = async () => + createMockCommandResponse({ + success: true, + output: JSON.stringify({ + devices: { + 'com.apple.CoreSimulator.SimRuntime.iOS-18-0': [ + { + udid: 'SIM-UUID', + name: 'iPhone 17 Pro', + isAvailable: true, + }, + ], + }, + }), + }); + + // simulatorName 'iphone 17 pro' does not match cache 'iPhone 17 Pro' + // but simctl matching also requires exact case for name, falling back to name heuristic + const result = await inferPlatform({ simulatorName: 'iPhone 17 Pro' }, mockExecutor); + expect(result.source).toBe('simulator-platform-cache'); + + const resultDifferentCase = await inferPlatform( + { simulatorName: 'iphone 17 pro' }, + mockExecutor, + ); + // Did not match cache, falls through to simctl which requires exact case for name, + // and therefore falls back to simulator-name heuristic (NOT simulator-runtime) + expect(resultDifferentCase.source).toBe('simulator-name'); + expect(resultDifferentCase.source).not.toBe('simulator-runtime'); + expect(resultDifferentCase.source).not.toBe('simulator-platform-cache'); + }); }); diff --git a/src/utils/__tests__/simulator-test-execution.test.ts b/src/utils/__tests__/simulator-test-execution.test.ts index aeb351906..5af16df38 100644 --- a/src/utils/__tests__/simulator-test-execution.test.ts +++ b/src/utils/__tests__/simulator-test-execution.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from 'vitest'; -import { createSimulatorTwoPhaseExecutionPlan } from '../simulator-test-execution.ts'; +import { + createSimulatorTwoPhaseExecutionPlan, + validateManagedTestExtraArgs, +} from '../simulator-test-execution.ts'; import type { TestPreflightResult } from '../test-preflight.ts'; function createPreflight(): TestPreflightResult { @@ -120,3 +123,31 @@ describe('createSimulatorTwoPhaseExecutionPlan', () => { expect(plan.resultBundlePath).toBe('/tmp/Last.xcresult'); }); }); + +describe('validateManagedTestExtraArgs', () => { + it('allows only-testing and skip-testing selector forms', () => { + expect(() => + validateManagedTestExtraArgs([ + '-only-testing:CalculatorAppTests/CalculatorAppTests/testAddition', + '-skip-testing', + 'CalculatorAppTests/ExpressionSuite/evaluatesExpression', + ]), + ).not.toThrow(); + }); + + it.each([ + ['-destination', 'platform=iOS Simulator,id=UUID'], + ['-parallel-testing-enabled', 'YES'], + ['-resultBundlePath', '/tmp/result.xcresult'], + ])('rejects execution or destination override %s', (...args) => { + expect(() => validateManagedTestExtraArgs(args)).toThrow( + 'only allow -only-testing and -skip-testing selectors', + ); + }); + + it('rejects a selector without a value', () => { + expect(() => validateManagedTestExtraArgs(['-only-testing'])).toThrow( + 'require a selector value', + ); + }); +}); diff --git a/src/utils/__tests__/xcodebuild-pipeline.test.ts b/src/utils/__tests__/xcodebuild-pipeline.test.ts index c4bc290f1..775cddae2 100644 --- a/src/utils/__tests__/xcodebuild-pipeline.test.ts +++ b/src/utils/__tests__/xcodebuild-pipeline.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it, beforeEach, afterEach, vi } from 'vitest'; +import * as logCaptureModule from '../xcodebuild-log-capture.ts'; import { createXcodebuildPipeline, invocationRequestToHeaderParams, @@ -369,4 +370,54 @@ describe('xcodebuild-pipeline', () => { expect(parsed).toHaveProperty('fragment'); } }); + + it('guarantees logCapture is closed and propagates original error when emit throws during finalize flush, and re-throws the same error on subsequent finalize without double-close', () => { + let closedCount = 0; + const origCreate = logCaptureModule.createLogCapture; + const logSpy = vi.spyOn(logCaptureModule, 'createLogCapture').mockImplementation((toolName) => { + const cap = origCreate(toolName); + const origClose = cap.close.bind(cap); + cap.close = () => { + closedCount++; + origClose(); + }; + return cap; + }); + + try { + const explosiveError = new Error('Explosive emit callback'); + const pipeline = createXcodebuildPipeline({ + operation: 'TEST', + toolName: 'test_sim', + params: { scheme: 'MyApp' }, + emit: () => { + throw explosiveError; + }, + }); + + pipeline.onStdout("Test Case '-[Suite testA]' passed (0.001 seconds)"); + + // First finalize: throws explosiveError, closes once + let firstError: unknown; + try { + pipeline.finalize(false, 1000); + } catch (err) { + firstError = err; + } + expect(firstError).toBe(explosiveError); + expect(closedCount).toBe(1); + + // Second finalize: must rethrow the same failure without flushing or closing again + let secondError: unknown; + try { + pipeline.finalize(false, 1000); + } catch (err) { + secondError = err; + } + expect(secondError).toBe(explosiveError); + expect(closedCount).toBe(1); + } finally { + logSpy.mockRestore(); + } + }); }); diff --git a/src/utils/__tests__/xcresult-test-failures.test.ts b/src/utils/__tests__/xcresult-test-failures.test.ts index 363ca1dc1..8b018c231 100644 --- a/src/utils/__tests__/xcresult-test-failures.test.ts +++ b/src/utils/__tests__/xcresult-test-failures.test.ts @@ -1,8 +1,22 @@ -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { parseXcresultFailureMessage, parseXcresultTestSummaryCounts, + parseXcresultTestFailures, + extractTestSummaryCountsFromXcresultAsync, + extractTestFailuresFromXcresultAsync, } from '../xcresult-test-failures.ts'; +import { createMockExecutor, createMockCommandResponse } from '../../test-utils/mock-executors.ts'; +import type { CommandExecutor } from '../command.ts'; + +async function createSignaledResponse(success: boolean) { + const response = await createMockExecutor({ + success, + exitCode: success ? 0 : 1, + })(['mock']); + Object.assign(response.process, { exitCode: null, signalCode: 'SIGTERM' }); + return response; +} describe('parseXcresultTestSummaryCounts', () => { it('uses top-level declaration counts instead of device run counts', () => { @@ -56,3 +70,297 @@ describe('parseXcresultFailureMessage', () => { }); }); }); + +describe('parseXcresultTestFailures', () => { + it('parses nested failure nodes and preserves suite context and location', () => { + const raw = JSON.stringify({ + testNodes: [ + { + name: 'CalculatorPackageTests', + nodeType: 'Test Plan', + children: [ + { + name: 'CalculatorServiceTests', + nodeType: 'Test Suite', + children: [ + { + name: '-[CalculatorServiceTests testAddition]', + nodeType: 'Test Case', + result: 'Failed', + children: [ + { + name: 'CalculatorServiceTests.swift:42: failed - 1 + 1 != 3', + nodeType: 'Failure Message', + }, + ], + }, + ], + }, + ], + }, + ], + }); + + const fragments = parseXcresultTestFailures(raw); + expect(fragments).toEqual([ + { + kind: 'test-result', + fragment: 'test-failure', + operation: 'TEST', + suite: 'CalculatorServiceTests', + test: 'testAddition', + message: '1 + 1 != 3', + location: 'CalculatorServiceTests.swift:42', + }, + ]); + }); + + it('returns empty array on malformed or empty output', () => { + expect(parseXcresultTestFailures('')).toEqual([]); + expect(parseXcresultTestFailures('not json')).toEqual([]); + expect(parseXcresultTestFailures(JSON.stringify({ notTestNodes: true }))).toEqual([]); + }); + + it('returns empty array when encountering null node, wrong-type name, or non-array children without throwing', () => { + expect(parseXcresultTestFailures(JSON.stringify({ testNodes: [null] }))).toEqual([]); + expect( + parseXcresultTestFailures( + JSON.stringify({ testNodes: [{ name: 'Suite', nodeType: 'Test Suite', children: {} }] }), + ), + ).toEqual([]); + expect( + parseXcresultTestFailures( + JSON.stringify({ testNodes: [{ name: 123, nodeType: 'Test Suite' }] }), + ), + ).toEqual([]); + expect( + parseXcresultTestFailures(JSON.stringify({ testNodes: [{ name: 'Suite', nodeType: 456 }] })), + ).toEqual([]); + expect( + parseXcresultTestFailures( + JSON.stringify({ + testNodes: [ + { + name: 'Suite', + nodeType: 'Test Case', + result: 'Failed', + children: [null], + }, + ], + }), + ), + ).toEqual([]); + expect( + parseXcresultTestFailures( + JSON.stringify({ + testNodes: [ + { + name: 'Suite', + nodeType: 'Test Case', + result: 'Failed', + children: [{ nodeType: 'Failure Message', name: 789 }], + }, + ], + }), + ), + ).toEqual([]); + }); + + it('discards partial fragments and returns empty array if a corrupted node is encountered after valid ones', () => { + const raw = JSON.stringify({ + testNodes: [ + { + name: 'CalculatorPackageTests', + nodeType: 'Test Plan', + children: [ + { + name: 'CalculatorServiceTests', + nodeType: 'Test Suite', + children: [ + { + name: '-[CalculatorServiceTests testAddition]', + nodeType: 'Test Case', + result: 'Failed', + children: [ + { + name: 'CalculatorServiceTests.swift:42: failed - 1 + 1 != 3', + nodeType: 'Failure Message', + }, + ], + }, + ], + }, + ], + }, + null, + ], + }); + + expect(parseXcresultTestFailures(raw)).toEqual([]); + }); +}); + +describe('extractTestSummaryCountsFromXcresultAsync', () => { + it('executes xcresulttool and parses valid summary', async () => { + const mockExecutor: CommandExecutor = vi.fn(async (command) => { + expect(command).toEqual([ + 'xcrun', + 'xcresulttool', + 'get', + 'test-results', + 'summary', + '--path', + '/tmp/test.xcresult', + '--compact', + ]); + return createMockCommandResponse({ + success: true, + exitCode: 0, + output: JSON.stringify({ + totalTestCount: 5, + passedTests: 4, + failedTests: 1, + skippedTests: 0, + }), + }); + }); + + const counts = await extractTestSummaryCountsFromXcresultAsync( + mockExecutor, + '/tmp/test.xcresult', + ); + expect(counts).toEqual({ + passed: 4, + failed: 1, + skipped: 0, + }); + }); + + it('returns null on confirmed non-zero exit code', async () => { + const mockExecutor: CommandExecutor = vi.fn(async () => + createMockCommandResponse({ + success: false, + exitCode: 1, + output: 'Failed to read xcresult', + }), + ); + + const counts = await extractTestSummaryCountsFromXcresultAsync( + mockExecutor, + '/tmp/test.xcresult', + ); + expect(counts).toBeNull(); + }); + + it.each([ + ['normal', false], + ['adversarial success', true], + ])( + 'throws when xcresult summary process is terminated by signal (%s)', + async (_name, success) => { + const mockExecutor: CommandExecutor = vi.fn(async () => createSignaledResponse(success)); + + await expect( + extractTestSummaryCountsFromXcresultAsync(mockExecutor, '/tmp/test.xcresult'), + ).rejects.toThrow('xcresult test summary extraction interrupted by signal: SIGTERM'); + }, + ); + + it('propagates executor errors without swallowing', async () => { + const mockExecutor: CommandExecutor = vi.fn(async () => { + throw new Error('Supervisor uncertain completion'); + }); + + await expect( + extractTestSummaryCountsFromXcresultAsync(mockExecutor, '/tmp/test.xcresult'), + ).rejects.toThrow('Supervisor uncertain completion'); + }); +}); + +describe('extractTestFailuresFromXcresultAsync', () => { + it('executes xcresulttool and parses test failures', async () => { + const mockExecutor: CommandExecutor = vi.fn(async (command) => { + expect(command).toEqual([ + 'xcrun', + 'xcresulttool', + 'get', + 'test-results', + 'tests', + '--path', + '/tmp/test.xcresult', + ]); + return createMockCommandResponse({ + success: true, + exitCode: 0, + output: JSON.stringify({ + testNodes: [ + { + name: 'AppTests', + nodeType: 'Test Suite', + children: [ + { + name: '-[AppTests testFail]', + nodeType: 'Test Case', + result: 'Failed', + children: [ + { + name: 'AppTests.swift:10: failed - Expected true', + nodeType: 'Failure Message', + }, + ], + }, + ], + }, + ], + }), + }); + }); + + const failures = await extractTestFailuresFromXcresultAsync(mockExecutor, '/tmp/test.xcresult'); + expect(failures).toEqual([ + { + kind: 'test-result', + fragment: 'test-failure', + operation: 'TEST', + suite: 'AppTests', + test: 'testFail', + message: 'Expected true', + location: 'AppTests.swift:10', + }, + ]); + }); + + it('returns empty array on confirmed non-zero exit code', async () => { + const mockExecutor: CommandExecutor = vi.fn(async () => + createMockCommandResponse({ + success: false, + exitCode: 1, + output: 'xcresulttool failed', + }), + ); + + const failures = await extractTestFailuresFromXcresultAsync(mockExecutor, '/tmp/test.xcresult'); + expect(failures).toEqual([]); + }); + + it.each([ + ['normal', false], + ['adversarial success', true], + ])('throws when xcresult tests process is terminated by signal (%s)', async (_name, success) => { + const mockExecutor: CommandExecutor = vi.fn(async () => createSignaledResponse(success)); + + await expect( + extractTestFailuresFromXcresultAsync(mockExecutor, '/tmp/test.xcresult'), + ).rejects.toThrow('xcresult test failure extraction interrupted by signal: SIGTERM'); + }); + + it('propagates executor errors without swallowing', async () => { + const mockExecutor: CommandExecutor = vi.fn(async () => { + throw new Error('Command failed catastrophically'); + }); + + await expect( + extractTestFailuresFromXcresultAsync(mockExecutor, '/tmp/test.xcresult'), + ).rejects.toThrow('Command failed catastrophically'); + }); +}); diff --git a/src/utils/build-utils.ts b/src/utils/build-utils.ts index 3618a19a0..1f9d8b774 100644 --- a/src/utils/build-utils.ts +++ b/src/utils/build-utils.ts @@ -9,6 +9,7 @@ import { resolveEffectiveDerivedDataPath } from './derived-data-path.ts'; import { resolvePathFromCwd } from './path.ts'; import type { XcodebuildPipeline } from './xcodebuild-pipeline.ts'; import { createNoticeFragment } from './xcodebuild-output.ts'; +import { isCommandSupervised } from '../resource-management/execution.ts'; export interface BuildCommandResult { content: Array<{ type: 'text'; text: string }>; @@ -41,7 +42,7 @@ export async function executeXcodeBuildCommand( log('info', `Starting ${platformOptions.logPrefix} ${buildAction} for scheme ${params.scheme}`); - const isXcodemakeEnabledFlag = isXcodemakeEnabled(); + const isXcodemakeEnabledFlag = !isCommandSupervised() && isXcodemakeEnabled(); let xcodemakeAvailableFlag = false; if (isXcodemakeEnabledFlag && buildAction === 'build') { @@ -110,7 +111,7 @@ export async function executeXcodeBuildCommand( destinationString = constructDestinationString( platformOptions.platform, undefined, - platformOptions.simulatorId, + platformOptions.simulatorId.toUpperCase(), ); } else if (platformOptions.simulatorName) { destinationString = constructDestinationString( @@ -236,7 +237,7 @@ export async function executeXcodeBuildCommand( return successResponse; } catch (error) { - if (executionOptions?.propagateInfrastructureErrors) { + if (executionOptions?.propagateInfrastructureErrors || isCommandSupervised()) { throw error; } diff --git a/src/utils/command.ts b/src/utils/command.ts index 54f889f2a..dd2747336 100644 --- a/src/utils/command.ts +++ b/src/utils/command.ts @@ -7,6 +7,8 @@ import { transcriptEmitterStorage } from './transcript-context.ts'; import { shellEscapeArg } from './shell-escape.ts'; import type { FileSystemExecutor } from './FileSystemExecutor.ts'; import type { CommandExecutor, CommandResponse, CommandExecOptions } from './CommandExecutor.ts'; +import { supervisedExecutor } from '../resource-management/execution.ts'; +import { resourceEnvironment } from '../resource-management/environment.ts'; export type { CommandExecutor, CommandResponse, CommandExecOptions } from './CommandExecutor.ts'; export type { FileSystemExecutor } from './FileSystemExecutor.ts'; @@ -320,7 +322,8 @@ export function __getRealFileSystemExecutor(): FileSystemExecutor { } export function getDefaultCommandExecutor(): CommandExecutor { - return _testCommandExecutorOverride ?? defaultExecutor; + const executor = _testCommandExecutorOverride ?? defaultExecutor; + return resourceEnvironment() ? supervisedExecutor(executor) : executor; } export function getDefaultFileSystemExecutor(): FileSystemExecutor { diff --git a/src/utils/infer-platform.ts b/src/utils/infer-platform.ts index f3f3d8f68..d972069b9 100644 --- a/src/utils/infer-platform.ts +++ b/src/utils/infer-platform.ts @@ -118,7 +118,11 @@ function resolveCachedPlatform(params: InferPlatformParams): SimulatorPlatform | return defaults.simulatorPlatform; } - if (hasExplicitId && defaults.simulatorId && params.simulatorId === defaults.simulatorId) { + if ( + hasExplicitId && + defaults.simulatorId && + params.simulatorId?.toLowerCase() === defaults.simulatorId.toLowerCase() + ) { return defaults.simulatorPlatform; } @@ -178,7 +182,9 @@ async function inferPlatformFromSimctl( }; if (simulatorId) { - const matchesId = typeof current.udid === 'string' && current.udid === simulatorId; + const matchesId = + typeof current.udid === 'string' && + current.udid.toLowerCase() === simulatorId.toLowerCase(); if (!matchesId) continue; } else { const matchesName = typeof current.name === 'string' && current.name === simulatorName; diff --git a/src/utils/log-paths.ts b/src/utils/log-paths.ts index 911b723b6..0994e3d7e 100644 --- a/src/utils/log-paths.ts +++ b/src/utils/log-paths.ts @@ -1,5 +1,6 @@ import * as path from 'node:path'; import * as os from 'node:os'; +import { resourceEnvironment } from '../resource-management/environment.ts'; export const APP_DIR = path.join(os.homedir(), 'Library', 'Developer', 'XcodeBuildMCP'); @@ -30,7 +31,8 @@ export interface WorkspaceFilesystemLayout { } export function getXcodeBuildMCPAppDir(): string { - return appDirOverrideForTests ?? APP_DIR; + const managed = resourceEnvironment(); + return appDirOverrideForTests ?? (managed ? path.join(managed.stateRoot, 'runtime') : APP_DIR); } export function getWorkspacesDir(): string { diff --git a/src/utils/renderers/cli-text-renderer.ts b/src/utils/renderers/cli-text-renderer.ts index fa37dae1a..c3cb7763f 100644 --- a/src/utils/renderers/cli-text-renderer.ts +++ b/src/utils/renderers/cli-text-renderer.ts @@ -440,7 +440,34 @@ function createCliTextProcessor(options: CliTextProcessorOptions): TranscriptRen finalize(): void { flushParserStates(); if (structuredOutput) { - if (!sawIncomingNonHeaderEvent) { + if (structuredOutput.result.kind === 'resource-binding') { + const result = structuredOutput.result; + processItem({ + type: 'text-block', + text: result.didError + ? `Simulator provisioning failed: ${result.error}` + : [ + `Simulator: ${result.simulatorId}`, + `Device type: ${result.deviceType}`, + `Runtime: ${result.runtime}`, + 'Binding ready. Begin an operation before using the Simulator.', + ].join('\n'), + }); + } else if (structuredOutput.result.kind === 'resource-operation') { + const result = structuredOutput.result; + processItem({ + type: 'text-block', + text: [ + `Operation: ${result.requestId}`, + `Session: ${result.sessionId}`, + `Simulator: ${result.simulatorId}`, + `State: ${result.state}`, + `Active work: ${result.activityCount}`, + ...(result.token ? [`Token: ${result.token}`] : []), + ...(result.reason ? [`Reason: ${result.reason}`] : []), + ].join('\n'), + }); + } else if (!sawIncomingNonHeaderEvent) { const structuredItems = renderDomainResultTextItems( structuredOutput.result, structuredOutput.renderHints, diff --git a/src/utils/renderers/domain-result-text.ts b/src/utils/renderers/domain-result-text.ts index b325d4cb5..4064de861 100644 --- a/src/utils/renderers/domain-result-text.ts +++ b/src/utils/renderers/domain-result-text.ts @@ -1585,12 +1585,21 @@ function createCaptureResultItems( items.push(scrollAreasSection); } items.push( - createSection('Tips', [ - '- Use target refs with tap, type_text, long_press, and touch.', - ...(scrollAreaCount > 0 ? ['- Use scroll refs with swipe.'] : []), - '- Refs are snapshot-specific; after snapshot_ui or wait_for_ui, use refs from the latest output.', - '- Use wait_for_ui for text/assertions or changing UI.', - ]), + createSection( + 'Tips', + hints?.runtimeSnapshot?.managedOperation + ? [ + '- Use target refs with tap or type_text and the same operation credentials.', + '- Use fresh refs from the latest action result or snapshot_ui output.', + '- After handoff or switching runtimes, run snapshot_ui again.', + ] + : [ + '- Use target refs with tap, type_text, long_press, and touch.', + ...(scrollAreaCount > 0 ? ['- Use scroll refs with swipe.'] : []), + '- Refs are snapshot-specific; after snapshot_ui or wait_for_ui, use refs from the latest output.', + '- Use wait_for_ui for text/assertions or changing UI.', + ], + ), ); items.push( ...createStandardDiagnosticSections(result.diagnostics), diff --git a/src/utils/simulator-test-execution.ts b/src/utils/simulator-test-execution.ts index 4fbbfcb98..81e44d720 100644 --- a/src/utils/simulator-test-execution.ts +++ b/src/utils/simulator-test-execution.ts @@ -49,10 +49,51 @@ function parseTestSelectorArgs(extraArgs: string[] | undefined): { }; } +/** + * Managed tests may narrow the test set, but may not override destinations or execution policy. + * Keep this validation next to the parser so admission and the low-level executor share one + * allowlist. Ordinary test execution does not call this helper. + */ +export function validateManagedTestExtraArgs(extraArgs: readonly unknown[] | undefined): void { + if (extraArgs === undefined || extraArgs.length === 0) return; + if (!extraArgs.every((argument): argument is string => typeof argument === 'string')) { + throw new Error( + 'Managed simulator tests only allow -only-testing and -skip-testing selectors in extraArgs', + ); + } + + for (let index = 0; index < extraArgs.length; index += 1) { + const argument = extraArgs[index]!; + if (argument === '-only-testing' || argument === '-skip-testing') { + const value = extraArgs[index + 1]; + if (!value || value.startsWith('-')) { + throw new Error( + 'Managed simulator tests require a selector value after -only-testing or -skip-testing', + ); + } + index += 1; + continue; + } + + if (argument.startsWith('-only-testing:') || argument.startsWith('-skip-testing:')) { + const separator = argument.indexOf(':'); + if (separator === argument.length - 1) { + throw new Error('Managed simulator tests require a non-empty test selector'); + } + continue; + } + + throw new Error( + 'Managed simulator tests only allow -only-testing and -skip-testing selectors in extraArgs', + ); + } +} + export function createSimulatorTwoPhaseExecutionPlan(params: { extraArgs?: string[]; preflight?: TestPreflightResult; resultBundlePath?: string; + supervised?: boolean; }): { buildArgs: string[]; testArgs: string[]; @@ -65,9 +106,18 @@ export function createSimulatorTwoPhaseExecutionPlan(params: { const resultBundlePath = params.resultBundlePath ?? parsedArgs.resultBundlePath; const resultBundleArgs = resultBundlePath ? ['-resultBundlePath', resultBundlePath] : []; + const supervisedTestFlags = params.supervised + ? ['-parallel-testing-enabled', 'NO', '-maximum-concurrent-test-simulator-destinations', '1'] + : []; + return { buildArgs: [...parsedArgs.remainingArgs, ...selectedTestArgs], - testArgs: [...parsedArgs.remainingArgs, ...selectedTestArgs, ...resultBundleArgs], + testArgs: [ + ...parsedArgs.remainingArgs, + ...selectedTestArgs, + ...supervisedTestFlags, + ...resultBundleArgs, + ], usesExactSelectors, ...(resultBundlePath ? { resultBundlePath } : {}), }; diff --git a/src/utils/test-common.ts b/src/utils/test-common.ts index 8dab52859..9efa4a340 100644 --- a/src/utils/test-common.ts +++ b/src/utils/test-common.ts @@ -8,14 +8,21 @@ import * as path from 'node:path'; import { log } from './logger.ts'; import { constructDestinationString, XcodePlatform } from './xcode.ts'; import { executeXcodeBuildCommand } from './build/index.ts'; -import { extractTestFailuresFromXcresult } from './xcresult-test-failures.ts'; +import { + extractTestFailuresFromXcresult, + extractTestFailuresFromXcresultAsync, + extractTestSummaryCountsFromXcresultAsync, +} from './xcresult-test-failures.ts'; import { normalizeTestRunnerEnv } from './environment.ts'; -import type { CommandExecutor, CommandExecOptions } from './command.ts'; +import type { CommandExecutor, CommandResponse, CommandExecOptions } from './command.ts'; import { getDefaultCommandExecutor } from './command.ts'; import { type TestPreflightResult } from './test-preflight.ts'; -import { createSimulatorTwoPhaseExecutionPlan } from './simulator-test-execution.ts'; +import { + createSimulatorTwoPhaseExecutionPlan, + validateManagedTestExtraArgs, +} from './simulator-test-execution.ts'; import { parseResultBundlePathArgs } from './result-bundle-args.ts'; import { createDefaultResultBundlePath, @@ -32,6 +39,11 @@ import { filterTestProductsPathArgs, getPreparedTestDestinationArgs, } from './test-source.ts'; +import { isCommandSupervised } from '../resource-management/execution.ts'; +import { + shutdownAndVerifyManagedSimulator, + SimulatorTestUncertainError, +} from '../resource-management/test-execution.ts'; import type { BuildTarget, @@ -146,7 +158,7 @@ function createPreparedTestDestination(params: SharedTestExecutorParams): string return constructDestinationString( params.platform, params.simulatorName, - params.simulatorId, + params.simulatorId ? params.simulatorId.toUpperCase() : undefined, params.useLatestOS, ); } @@ -223,11 +235,117 @@ async function executePreparedTestCommand( type PreparedTestCommandResult = Awaited>; +function createSupervisedXcodeBuildExecutor( + executor: CommandExecutor, + options: { allowKnownNonZeroExitCodes: boolean }, +): CommandExecutor { + return async (command, logPrefix, useShell, commandOpts, detached): Promise => { + const response = await executor(command, logPrefix, useShell, commandOpts, detached); + + if (response.process?.signalCode !== null && response.process?.signalCode !== undefined) { + throw new SimulatorTestUncertainError( + `Managed test execution interrupted by signal: ${response.process.signalCode}`, + ); + } + + const processCode = response.process?.exitCode; + const responseCode = response.exitCode; + + if (processCode !== responseCode) { + throw new SimulatorTestUncertainError( + `Inconsistent exit codes: response.exitCode=${responseCode}, process.exitCode=${processCode}`, + ); + } + + const expectedSuccess = responseCode === 0; + if (response.success !== expectedSuccess) { + throw new SimulatorTestUncertainError( + `Inconsistent success state: response.success=${response.success}, exitCode=${responseCode}`, + ); + } + + const hasKnownNumericExitCode = + typeof responseCode === 'number' && Number.isInteger(responseCode) && responseCode >= 0; + if ( + !hasKnownNumericExitCode || + (!options.allowKnownNonZeroExitCodes && responseCode !== 0 && responseCode !== 65) + ) { + throw new SimulatorTestUncertainError( + `Unexpected xcodebuild test exit code: ${responseCode}`, + ); + } + + return response; + }; +} + +function validateSupervisedSourceTestParams(params: SharedTestExecutorParams): void { + validateManagedTestExtraArgs(params.extraArgs); + + const isSimulator = [ + XcodePlatform.iOSSimulator, + XcodePlatform.watchOSSimulator, + XcodePlatform.tvOSSimulator, + XcodePlatform.visionOSSimulator, + ].includes(params.platform); + if (!isSimulator) { + throw new Error( + `Supervised simulator test runs require a simulator platform; received ${params.platform}`, + ); + } + + if (!params.simulatorId || params.simulatorId.trim().length === 0) { + throw new Error('Supervised simulator test runs require an explicit simulatorId'); + } + + if (params.simulatorName) { + throw new Error('Supervised simulator test runs do not allow simulatorName'); + } + if (params.deviceId) { + throw new Error('Supervised simulator test runs do not allow deviceId'); + } + + if (params.projectPath?.trim().length === 0) { + throw new Error('Supervised simulator test runs do not allow blank projectPath'); + } + if (params.workspacePath?.trim().length === 0) { + throw new Error('Supervised simulator test runs do not allow blank workspacePath'); + } + + const hasProject = Boolean(params.projectPath); + const hasWorkspace = Boolean(params.workspacePath); + if ((hasProject && hasWorkspace) || (!hasProject && !hasWorkspace)) { + throw new Error( + 'Supervised simulator test runs require exactly one of projectPath or workspacePath', + ); + } + + if (!params.scheme || params.scheme.trim().length === 0) { + throw new Error('Supervised simulator test runs require a non-empty scheme'); + } +} + export function createTestExecutor( executor: CommandExecutor = getDefaultCommandExecutor(), options: SharedTestExecutorOptions, ): StreamingExecutor { return async (params, ctx) => { + const hasPreparedTestSource = Boolean(params.testProductsPath ?? params.xctestrunPath); + const isSupervisedSource = isCommandSupervised() && !hasPreparedTestSource; + let phase2Started = false; + let shutdownVerified = false; + + if (isSupervisedSource) { + validateSupervisedSourceTestParams(params); + } + + const supervisedBuildForTestingExecutor = isSupervisedSource + ? createSupervisedXcodeBuildExecutor(executor, { allowKnownNonZeroExitCodes: true }) + : executor; + const supervisedTestWithoutBuildingExecutor = isSupervisedSource + ? createSupervisedXcodeBuildExecutor(executor, { allowKnownNonZeroExitCodes: false }) + : executor; + log( 'info', `Starting test run for ${params.scheme ? `scheme ${params.scheme}` : 'prepared tests'} on platform ${params.platform} (executor)`, @@ -236,74 +354,161 @@ export function createTestExecutor( const execOpts: CommandExecOptions | undefined = params.testRunnerEnv ? { env: normalizeTestRunnerEnv(params.testRunnerEnv) } : undefined; - const hasPreparedTestSource = Boolean(params.testProductsPath ?? params.xctestrunPath); const toolName = options.toolName ?? 'test_sim'; const target = options.target ?? getBuildTarget(params.platform); const started = createDomainStreamingPipeline(toolName, 'TEST', ctx, 'test-result'); - const platformOptions = { - platform: params.platform, - simulatorName: params.simulatorName, - simulatorId: params.simulatorId, - deviceId: params.deviceId, - useLatestOS: params.useLatestOS, - packageCachePath: params.packageCachePath, - logPrefix: 'Test Run', - }; - const discoveryEvent = createTestDiscoveryFragment(options.preflight); - if (discoveryEvent) { - started.pipeline.emitFragment(discoveryEvent); - } + try { + const platformOptions = { + platform: params.platform, + simulatorName: params.simulatorName, + simulatorId: params.simulatorId, + deviceId: params.deviceId, + useLatestOS: params.useLatestOS, + packageCachePath: params.packageCachePath, + logPrefix: 'Test Run', + }; + const discoveryEvent = createTestDiscoveryFragment(options.preflight); - const parsedResultBundleArgs = parseResultBundlePathArgs(params.extraArgs); - const shouldUseDefaultResultBundlePath = !parsedResultBundleArgs.resultBundlePath; - const resultBundlePath = - parsedResultBundleArgs.resultBundlePath ?? createDefaultResultBundlePath(toolName); + if (discoveryEvent) { + started.pipeline.emitFragment(discoveryEvent); + } - if (!hasPreparedTestSource) { - const testProductsPath = createDefaultTestProductsPath(toolName); - const executionPlan = createSimulatorTwoPhaseExecutionPlan({ - extraArgs: parsedResultBundleArgs.remainingArgs, - preflight: options.preflight, - }); + const parsedResultBundleArgs = parseResultBundlePathArgs(params.extraArgs); + const shouldUseDefaultResultBundlePath = !parsedResultBundleArgs.resultBundlePath; + const resultBundlePath = + parsedResultBundleArgs.resultBundlePath ?? createDefaultResultBundlePath(toolName); - let buildForTestingResult: Awaited>; - try { - buildForTestingResult = await executeXcodeBuildCommand( - { - ...params, - scheme: params.scheme!, - extraArgs: [ - ...filterTestProductsPathArgs(executionPlan.buildArgs), - '-testProductsPath', + if (!hasPreparedTestSource) { + const testProductsPath = createDefaultTestProductsPath(toolName); + const executionPlan = createSimulatorTwoPhaseExecutionPlan({ + extraArgs: parsedResultBundleArgs.remainingArgs, + preflight: options.preflight, + supervised: isCommandSupervised(), + }); + + let buildForTestingResult: Awaited>; + try { + buildForTestingResult = await executeXcodeBuildCommand( + { + ...params, + scheme: params.scheme!, + extraArgs: [ + ...filterTestProductsPathArgs(executionPlan.buildArgs), + '-testProductsPath', + testProductsPath, + ], + }, + platformOptions, + params.preferXcodebuild, + 'build-for-testing', + supervisedBuildForTestingExecutor, + execOpts, + started.pipeline, + { propagateInfrastructureErrors: true }, + ); + } catch (error) { + if (!isCommandSupervised()) { + markTestProductsPathCompleted(testProductsPath); + } + throw error; + } + + if (buildForTestingResult.isError) { + markTestProductsPathCompleted(testProductsPath); + return createDisplayedTestDomainResult({ + started, + succeeded: false, + target, + artifacts: createXcodebuildTestArtifacts(params, started), + fallbackErrorMessages: getFallbackErrorMessages( + started.stderrLines, + buildForTestingResult.content, + ), + includeDetectedXcresult: false, + preflight: options.preflight, + request: options.request, + summaryCounts: null, + }); + } + + started.pipeline.emitFragment({ + kind: 'test-result', + fragment: 'build-stage', + operation: 'TEST', + stage: 'RUN_TESTS', + message: 'Running tests', + }); + + phase2Started = true; + let testWithoutBuildingResult: PreparedTestCommandResult; + try { + testWithoutBuildingResult = await executePreparedTestCommand( + { ...params, testProductsPath }, + filterPreparedTestExtraArgs(executionPlan.testArgs), + resultBundlePath, + supervisedTestWithoutBuildingExecutor, + execOpts, + started.pipeline, + getPreparedTestDestinationArgs(executionPlan.testArgs), + ); + } finally { + if (!isCommandSupervised()) { + markTestProductsPathCompleted(testProductsPath); + if (shouldUseDefaultResultBundlePath) { + markResultBundlePathCompleted(resultBundlePath); + } + } + } + + if (isCommandSupervised()) { + const failures = await extractTestFailuresFromXcresultAsync(executor, resultBundlePath); + for (const event of failures) { + started.pipeline.emitFragment(event); + } + const summaryCounts = await extractTestSummaryCountsFromXcresultAsync( + executor, + resultBundlePath, + ); + + await shutdownAndVerifyManagedSimulator(params.simulatorId!, executor); + shutdownVerified = true; + + const domainResult = createDisplayedTestDomainResult({ + started, + succeeded: !testWithoutBuildingResult.isError, + target, + artifacts: createXcodebuildTestArtifacts(params, started, resultBundlePath, { testProductsPath, - ], - }, - platformOptions, - params.preferXcodebuild, - 'build-for-testing', - executor, - execOpts, - started.pipeline, - { propagateInfrastructureErrors: true }, - ); - } catch (error) { - markTestProductsPathCompleted(testProductsPath); - throw error; - } + }), + fallbackErrorMessages: getFallbackErrorMessages( + started.stderrLines, + testWithoutBuildingResult.content, + ), + preflight: options.preflight, + request: options.request, + summaryCounts, + }); + markTestProductsPathCompleted(testProductsPath); + if (shouldUseDefaultResultBundlePath) { + markResultBundlePathCompleted(resultBundlePath); + } + return domainResult; + } + + emitXcresultFailures(started.pipeline, resultBundlePath); - if (buildForTestingResult.isError) { - markTestProductsPathCompleted(testProductsPath); return createDisplayedTestDomainResult({ started, - succeeded: false, + succeeded: !testWithoutBuildingResult.isError, target, - artifacts: createXcodebuildTestArtifacts(params, started), + artifacts: createXcodebuildTestArtifacts(params, started, resultBundlePath, { + testProductsPath, + }), fallbackErrorMessages: getFallbackErrorMessages( started.stderrLines, - buildForTestingResult.content, + testWithoutBuildingResult.content, ), - includeDetectedXcresult: false, preflight: options.preflight, request: options.request, }); @@ -317,77 +522,82 @@ export function createTestExecutor( message: 'Running tests', }); - let testWithoutBuildingResult: PreparedTestCommandResult; + let preparedTestResult: PreparedTestCommandResult; try { - testWithoutBuildingResult = await executePreparedTestCommand( - { ...params, testProductsPath }, - filterPreparedTestExtraArgs(executionPlan.testArgs), + preparedTestResult = await executePreparedTestCommand( + params, + parsedResultBundleArgs.remainingArgs, resultBundlePath, executor, execOpts, started.pipeline, - getPreparedTestDestinationArgs(executionPlan.testArgs), ); } finally { - markTestProductsPathCompleted(testProductsPath); + if (!isCommandSupervised() && shouldUseDefaultResultBundlePath) { + markResultBundlePathCompleted(resultBundlePath); + } + } + + if (isCommandSupervised()) { + const failures = await extractTestFailuresFromXcresultAsync(executor, resultBundlePath); + for (const event of failures) { + started.pipeline.emitFragment(event); + } + const summaryCounts = await extractTestSummaryCountsFromXcresultAsync( + executor, + resultBundlePath, + ); + const domainResult = createDisplayedTestDomainResult({ + started, + succeeded: !preparedTestResult.isError, + target, + artifacts: createXcodebuildTestArtifacts(params, started, resultBundlePath, params), + fallbackErrorMessages: getFallbackErrorMessages( + started.stderrLines, + preparedTestResult.content, + ), + preflight: options.preflight, + request: options.request, + summaryCounts, + }); if (shouldUseDefaultResultBundlePath) { markResultBundlePathCompleted(resultBundlePath); } + return domainResult; } + emitXcresultFailures(started.pipeline, resultBundlePath); return createDisplayedTestDomainResult({ started, - succeeded: !testWithoutBuildingResult.isError, + succeeded: !preparedTestResult.isError, target, - artifacts: createXcodebuildTestArtifacts(params, started, resultBundlePath, { - testProductsPath, - }), + artifacts: createXcodebuildTestArtifacts(params, started, resultBundlePath, params), fallbackErrorMessages: getFallbackErrorMessages( started.stderrLines, - testWithoutBuildingResult.content, + preparedTestResult.content, ), preflight: options.preflight, request: options.request, }); - } - - started.pipeline.emitFragment({ - kind: 'test-result', - fragment: 'build-stage', - operation: 'TEST', - stage: 'RUN_TESTS', - message: 'Running tests', - }); - - let preparedTestResult: PreparedTestCommandResult; - try { - preparedTestResult = await executePreparedTestCommand( - params, - parsedResultBundleArgs.remainingArgs, - resultBundlePath, - executor, - execOpts, - started.pipeline, - ); - } finally { - if (shouldUseDefaultResultBundlePath) { - markResultBundlePathCompleted(resultBundlePath); + } catch (error) { + try { + started.pipeline.finalize(false, Date.now() - started.startedAt); + } catch { + // preserve original error + } + if ( + isSupervisedSource && + phase2Started && + !shutdownVerified && + !(error instanceof SimulatorTestUncertainError) + ) { + throw new SimulatorTestUncertainError( + `Managed test execution interrupted unexpectedly before shutdown verification: ${error instanceof Error ? error.message : String(error)}`, + { cause: error }, + ); } + throw error; } - emitXcresultFailures(started.pipeline, resultBundlePath); - - return createDisplayedTestDomainResult({ - started, - succeeded: !preparedTestResult.isError, - target, - artifacts: createXcodebuildTestArtifacts(params, started, resultBundlePath, params), - fallbackErrorMessages: getFallbackErrorMessages( - started.stderrLines, - preparedTestResult.content, - ), - preflight: options.preflight, - request: options.request, - }); }; } diff --git a/src/utils/typed-tool-factory.ts b/src/utils/typed-tool-factory.ts index 34feebd36..50661285a 100644 --- a/src/utils/typed-tool-factory.ts +++ b/src/utils/typed-tool-factory.ts @@ -57,7 +57,10 @@ function createValidatedHandler( try { const validatedParams = schema.parse(args); - await handlerContextStorage.run(ctx, () => logicFunction(validatedParams, context)); + const invoke = (): Promise => + handlerContextStorage.run(ctx, () => logicFunction(validatedParams, context)); + if (ctx.managedOperation) await ctx.managedOperation.run(validatedParams, invoke); + else await invoke(); } catch (error) { if (error instanceof z.ZodError) { const details = `Invalid parameters:\n${formatZodIssues(error)}`; @@ -315,7 +318,10 @@ function createSessionAwareHandler(opts: { } const validated = internalSchema.parse(merged); - await handlerContextStorage.run(ctx, () => logicFunction(validated, context)); + const invoke = (): Promise => + handlerContextStorage.run(ctx, () => logicFunction(validated, context)); + if (ctx.managedOperation) await ctx.managedOperation.run(validated, invoke); + else await invoke(); } catch (error) { if (error instanceof z.ZodError) { const details = `Invalid parameters:\n${formatZodIssues(error)}`; diff --git a/src/utils/xcodebuild-domain-results.ts b/src/utils/xcodebuild-domain-results.ts index 0f8a7bfb0..1ea185544 100644 --- a/src/utils/xcodebuild-domain-results.ts +++ b/src/utils/xcodebuild-domain-results.ts @@ -394,6 +394,7 @@ export function createTestDomainResult(options: { includeDetectedXcresult?: boolean; preflight?: TestPreflightResult; request: BuildInvocationRequest; + summaryCounts?: Counts | null; }): TestResultDomainResult { const { durationMs, pipelineResult } = finalizePipelineResult(options); const state = pipelineResult.state; @@ -416,8 +417,11 @@ export function createTestDomainResult(options: { ...(xcresultPath ? { xcresultPath } : {}), }; const counts = - (xcresultPath ? extractTestSummaryCountsFromXcresult(xcresultPath) : null) ?? - createStateTestCounts(state); + (options.summaryCounts !== undefined + ? options.summaryCounts + : xcresultPath + ? extractTestSummaryCountsFromXcresult(xcresultPath) + : null) ?? createStateTestCounts(state); const result: TestResultDomainResult = { kind: 'test-result', request: options.request, diff --git a/src/utils/xcodebuild-pipeline.ts b/src/utils/xcodebuild-pipeline.ts index 166c2093d..4469cf36b 100644 --- a/src/utils/xcodebuild-pipeline.ts +++ b/src/utils/xcodebuild-pipeline.ts @@ -259,6 +259,11 @@ export function createXcodebuildPipeline(options: PipelineOptions): XcodebuildPi }, }); + type FinalizeOutcome = + | { kind: 'success'; result: PipelineResult } + | { kind: 'failure'; error: unknown }; + let finalizeOutcome: FinalizeOutcome | null = null; + return { onStdout(chunk: string): void { logCapture.write(chunk); @@ -284,22 +289,39 @@ export function createXcodebuildPipeline(options: PipelineOptions): XcodebuildPi durationMs?: number, _finalizeOptions?: PipelineFinalizeOptions, ): PipelineResult { - parser.flush(); - logCapture.close(); - - const debugPath = debugCapture.flush(); - if (debugPath) { - appLog( - 'info', - `[Pipeline] ${debugCapture.count} unrecognized parser lines written to ${debugPath}`, - ); + if (finalizeOutcome) { + if (finalizeOutcome.kind === 'success') { + return finalizeOutcome.result; + } + throw finalizeOutcome.error; } - const finalState = runState.finalize(succeeded, durationMs); - - return { - state: finalState, - }; + try { + try { + parser.flush(); + } finally { + logCapture.close(); + } + + const debugPath = debugCapture.flush(); + if (debugPath) { + appLog( + 'info', + `[Pipeline] ${debugCapture.count} unrecognized parser lines written to ${debugPath}`, + ); + } + + const finalState = runState.finalize(succeeded, durationMs); + + const result: PipelineResult = { + state: finalState, + }; + finalizeOutcome = { kind: 'success', result }; + return result; + } catch (error) { + finalizeOutcome = { kind: 'failure', error }; + throw error; + } }, highestStageRank(): number { diff --git a/src/utils/xcresult-test-failures.ts b/src/utils/xcresult-test-failures.ts index 1e8abfa39..d8653da9c 100644 --- a/src/utils/xcresult-test-failures.ts +++ b/src/utils/xcresult-test-failures.ts @@ -2,6 +2,7 @@ import { execFileSync } from 'node:child_process'; import { log } from './logger.ts'; import type { TestFailureFragment } from '../types/domain-fragments.ts'; import type { Counts } from '../types/domain-results.ts'; +import type { CommandExecutor, CommandResponse } from './command.ts'; import { parseRawTestName } from './xcodebuild-line-parsers.ts'; interface XcresultTestNode { @@ -26,6 +27,13 @@ function isSummaryCount(value: unknown): value is number { return typeof value === 'number' && Number.isInteger(value) && value >= 0; } +function throwIfXcresultProcessWasSignaled(response: CommandResponse, operation: string): void { + const signalCode = response.process?.signalCode; + if (signalCode !== null && signalCode !== undefined) { + throw new Error(`${operation} interrupted by signal: ${signalCode}`); + } +} + export function parseXcresultTestSummaryCounts(raw: string): Counts | null { let summary: XcresultTestSummary; try { @@ -71,28 +79,50 @@ export function extractTestSummaryCountsFromXcresult(xcresultPath: string): Coun } } -export function extractTestFailuresFromXcresult(xcresultPath: string): TestFailureFragment[] { +export function parseXcresultTestFailures(raw: string): TestFailureFragment[] { try { - const output = execFileSync( - 'xcrun', - ['xcresulttool', 'get', 'test-results', 'tests', '--path', xcresultPath], - { encoding: 'utf8', timeout: 10_000, stdio: ['ignore', 'pipe', 'pipe'] }, - ); - - const results = JSON.parse(output) as XcresultTestResults; + const parsed = JSON.parse(raw) as unknown; + if ( + !parsed || + typeof parsed !== 'object' || + !('testNodes' in parsed) || + !Array.isArray((parsed as XcresultTestResults).testNodes) + ) { + return []; + } + const results = parsed as XcresultTestResults; const fragments: TestFailureFragment[] = []; - function walk(node: XcresultTestNode, suiteContext?: string): void { - const parsedNodeName = parseRawTestName(node.name); + function walk(node: unknown, suiteContext?: string): void { + if (!node || typeof node !== 'object') { + throw new Error('Invalid test node'); + } + const testNode = node as XcresultTestNode; + if (typeof testNode.name !== 'string' || typeof testNode.nodeType !== 'string') { + throw new Error('Invalid test node name or nodeType'); + } + if (testNode.children !== undefined && !Array.isArray(testNode.children)) { + throw new Error('Invalid test node children'); + } + + const parsedNodeName = parseRawTestName(testNode.name); const nextSuiteContext = - node.nodeType === 'Test Case' + testNode.nodeType === 'Test Case' ? suiteContext : (parsedNodeName.suiteName ?? - (node.nodeType === 'Test Suite' ? node.name.replaceAll('_', ' ') : suiteContext)); - - if (node.nodeType === 'Test Case' && node.result === 'Failed' && node.children) { - for (const child of node.children) { + (testNode.nodeType === 'Test Suite' + ? testNode.name.replaceAll('_', ' ') + : suiteContext)); + + if (testNode.nodeType === 'Test Case' && testNode.result === 'Failed' && testNode.children) { + for (const child of testNode.children) { + if (!child || typeof child !== 'object') { + throw new Error('Invalid child failure node'); + } if (child.nodeType === 'Failure Message') { + if (typeof child.name !== 'string') { + throw new Error('Invalid failure message node name'); + } const parsed = parseXcresultFailureMessage(child.name); const { suiteName, testName } = parsedNodeName; fragments.push({ @@ -107,8 +137,8 @@ export function extractTestFailuresFromXcresult(xcresultPath: string): TestFailu } } } - if (node.children) { - for (const child of node.children) { + if (testNode.children) { + for (const child of testNode.children) { walk(child, nextSuiteContext); } } @@ -119,6 +149,20 @@ export function extractTestFailuresFromXcresult(xcresultPath: string): TestFailu } return fragments; + } catch { + return []; + } +} + +export function extractTestFailuresFromXcresult(xcresultPath: string): TestFailureFragment[] { + try { + const output = execFileSync( + 'xcrun', + ['xcresulttool', 'get', 'test-results', 'tests', '--path', xcresultPath], + { encoding: 'utf8', timeout: 10_000, stdio: ['ignore', 'pipe', 'pipe'] }, + ); + + return parseXcresultTestFailures(output); } catch (error) { const message = error instanceof Error ? error.message : String(error); log('debug', `Failed to extract test failures from xcresult: ${message}`); @@ -126,6 +170,43 @@ export function extractTestFailuresFromXcresult(xcresultPath: string): TestFailu } } +export async function extractTestSummaryCountsFromXcresultAsync( + executor: CommandExecutor, + xcresultPath: string, +): Promise { + const command = [ + 'xcrun', + 'xcresulttool', + 'get', + 'test-results', + 'summary', + '--path', + xcresultPath, + '--compact', + ]; + const response = await executor(command, 'Extract Test Summary', false); + throwIfXcresultProcessWasSignaled(response, 'xcresult test summary extraction'); + if (!response.success) { + log('debug', `Failed to extract test summary from xcresult: exit ${response.exitCode}`); + return null; + } + return parseXcresultTestSummaryCounts(response.output); +} + +export async function extractTestFailuresFromXcresultAsync( + executor: CommandExecutor, + xcresultPath: string, +): Promise { + const command = ['xcrun', 'xcresulttool', 'get', 'test-results', 'tests', '--path', xcresultPath]; + const response = await executor(command, 'Extract Test Failures', false); + throwIfXcresultProcessWasSignaled(response, 'xcresult test failure extraction'); + if (!response.success) { + log('debug', `Failed to extract test failures from xcresult: exit ${response.exitCode}`); + return []; + } + return parseXcresultTestFailures(response.output); +} + export function parseXcresultFailureMessage(raw: string): { message: string; location?: string } { const [firstLine = '', ...continuationLines] = raw.split(/\r?\n/u); const match = firstLine.match(/^(.+?):(\d+):\s*(.*)$/u); diff --git a/src/visibility/predicate-registry.ts b/src/visibility/predicate-registry.ts index f9ba366fc..2c78ff70f 100644 --- a/src/visibility/predicate-registry.ts +++ b/src/visibility/predicate-registry.ts @@ -4,12 +4,14 @@ */ import type { PredicateFn, PredicateContext } from './predicate-types.ts'; +import { resourceEnvironment } from '../resource-management/environment.ts'; /** * Registry of named predicate functions. * All predicates return true to show the tool/workflow, false to hide. */ export const PREDICATES: Record = { + managedResourcesEnabled: (): boolean => resourceEnvironment() !== undefined, /** * Show only when debug mode is enabled in config. */