diff --git a/CHANGELOG.md b/CHANGELOG.md index f47e609ad..52fdf5e4d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ # Changelog +## [Unreleased] + +### Added + +- Added `shutdown_sim` (CLI: `mobilebuildmcp simulator-management shutdown`) to shut down a simulator by `simulatorId` or `simulatorName`. Shutting down a simulator that is not booted reports the `simctl` error. + +### Changed + +- `sim_statusbar` now accepts `time`, `wifiMode`, `wifiBars`, `cellularMode`, `cellularBars`, `batteryState`, and `batteryLevel` overrides, and `dataNetwork` is optional. All supplied fields are applied in one `simctl status_bar override` call; at least one field is required, and `dataNetwork: "clear"` must be used alone. +- `sim_statusbar` and `shutdown_sim` return `mobilebuildmcp.output.simulator-action-result` schema version 3, which adds the `shutdown` action and the new status bar fields. Validators pinned to version 2 must accept version 3 for these tools; other simulator action tools still return version 2. + ## [2.7.1] ### Changed diff --git a/manifests/tools/shutdown_sim.yaml b/manifests/tools/shutdown_sim.yaml new file mode 100644 index 000000000..bc319357f --- /dev/null +++ b/manifests/tools/shutdown_sim.yaml @@ -0,0 +1,14 @@ +id: shutdown_sim +module: mcp/tools/simulator-management/shutdown_sim +names: + mcp: shutdown_sim + cli: shutdown +description: Shut down a booted simulator. +outputSchema: + schema: mobilebuildmcp.output.simulator-action-result + version: "3" +annotations: + title: Shut Down Simulator + readOnlyHint: false + destructiveHint: false + openWorldHint: false diff --git a/manifests/tools/sim_statusbar.yaml b/manifests/tools/sim_statusbar.yaml index 18dfa0849..79b6b3849 100644 --- a/manifests/tools/sim_statusbar.yaml +++ b/manifests/tools/sim_statusbar.yaml @@ -3,10 +3,10 @@ module: mcp/tools/simulator-management/sim_statusbar names: mcp: sim_statusbar cli: statusbar -description: Set sim status bar network. +description: Override or clear sim status bar time, network, signal, and battery. outputSchema: schema: mobilebuildmcp.output.simulator-action-result - version: "2" + version: "3" annotations: title: Simulator Statusbar readOnlyHint: true diff --git a/manifests/workflows/simulator-management.yaml b/manifests/workflows/simulator-management.yaml index c1ce1ddcc..0e62c6fe4 100644 --- a/manifests/workflows/simulator-management.yaml +++ b/manifests/workflows/simulator-management.yaml @@ -4,6 +4,7 @@ description: Tools for managing simulators from booting, opening simulators, lis targetPlatforms: [iOS, tvOS, watchOS, visionOS] tools: - boot_sim + - shutdown_sim - list_sims - open_sim - erase_sims diff --git a/schemas/structured-output/mobilebuildmcp.output.simulator-action-result/3.schema.json b/schemas/structured-output/mobilebuildmcp.output.simulator-action-result/3.schema.json new file mode 100644 index 000000000..dc320edd2 --- /dev/null +++ b/schemas/structured-output/mobilebuildmcp.output.simulator-action-result/3.schema.json @@ -0,0 +1,244 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://raw.githubusercontent.com/getsentry/MobileBuildMCP/main/schemas/structured-output/mobilebuildmcp.output.simulator-action-result/3.schema.json", + "type": "object", + "additionalProperties": false, + "allOf": [ + { + "$ref": "https://raw.githubusercontent.com/getsentry/MobileBuildMCP/main/schemas/structured-output/_defs/common.schema.json#/$defs/errorConsistency" + } + ], + "properties": { + "schema": { + "const": "mobilebuildmcp.output.simulator-action-result" + }, + "schemaVersion": { + "const": "3" + }, + "didError": { + "type": "boolean" + }, + "error": { + "type": [ + "string", + "null" + ] + }, + "data": { + "type": "object", + "additionalProperties": false, + "properties": { + "summary": { + "$ref": "https://raw.githubusercontent.com/getsentry/MobileBuildMCP/main/schemas/structured-output/_defs/common.schema.json#/$defs/statusSummary" + }, + "action": { + "oneOf": [ + { + "type": "object", + "additionalProperties": false, + "properties": { + "type": { + "const": "boot" + } + }, + "required": [ + "type" + ] + }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "type": { + "const": "erase" + } + }, + "required": [ + "type" + ] + }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "type": { + "const": "open" + } + }, + "required": [ + "type" + ] + }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "type": { + "const": "reset-location" + } + }, + "required": [ + "type" + ] + }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "type": { + "const": "set-location" + }, + "coordinates": { + "type": "object", + "additionalProperties": false, + "properties": { + "latitude": { + "type": "number", + "minimum": -90, + "maximum": 90 + }, + "longitude": { + "type": "number", + "minimum": -180, + "maximum": 180 + } + }, + "required": [ + "latitude", + "longitude" + ] + } + }, + "required": [ + "type", + "coordinates" + ] + }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "type": { + "const": "set-appearance" + }, + "appearance": { + "type": "string" + } + }, + "required": [ + "type", + "appearance" + ] + }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "type": { + "const": "shutdown" + } + }, + "required": [ + "type" + ] + }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "type": { + "const": "statusbar" + }, + "time": { + "type": "string" + }, + "dataNetwork": { + "type": "string" + }, + "wifiMode": { + "type": "string" + }, + "wifiBars": { + "type": "integer", + "minimum": 0, + "maximum": 3 + }, + "cellularMode": { + "type": "string" + }, + "cellularBars": { + "type": "integer", + "minimum": 0, + "maximum": 4 + }, + "batteryState": { + "type": "string" + }, + "batteryLevel": { + "type": "integer", + "minimum": 0, + "maximum": 100 + } + }, + "required": [ + "type" + ] + }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "type": { + "const": "toggle-software-keyboard" + } + }, + "required": [ + "type" + ] + }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "type": { + "const": "toggle-connect-hardware-keyboard" + } + }, + "required": [ + "type" + ] + } + ] + }, + "artifacts": { + "type": "object", + "additionalProperties": false, + "properties": { + "simulatorId": { + "type": "string" + } + }, + "required": [], + "minProperties": 1 + }, + "diagnostics": { + "$ref": "https://raw.githubusercontent.com/getsentry/MobileBuildMCP/main/schemas/structured-output/_defs/common.schema.json#/$defs/basicDiagnostics" + } + }, + "required": [ + "summary", + "action" + ] + }, + "nextSteps": { + "$ref": "https://raw.githubusercontent.com/getsentry/MobileBuildMCP/main/schemas/structured-output/_defs/common.schema.json#/$defs/nextSteps" + } + }, + "required": [ + "schema", + "schemaVersion", + "didError", + "error", + "data" + ] +} diff --git a/src/core/__tests__/structured-output-schema.test.ts b/src/core/__tests__/structured-output-schema.test.ts index c9936f252..2c6bf3b11 100644 --- a/src/core/__tests__/structured-output-schema.test.ts +++ b/src/core/__tests__/structured-output-schema.test.ts @@ -259,6 +259,46 @@ describe('structured output schema bundling', () => { ).toBe(true); }); + it('accepts shutdown and extended statusbar actions only in simulator action result v3', () => { + const ajv = new Ajv2020({ allErrors: true, strict: true, validateSchema: true }); + const actionV2 = ajv.compile( + getMcpOutputSchema({ schema: 'mobilebuildmcp.output.simulator-action-result', version: '2' }), + ); + const actionV3 = ajv.compile( + getMcpOutputSchema({ schema: 'mobilebuildmcp.output.simulator-action-result', version: '3' }), + ); + const envelope = (action: JsonObject): JsonObject => ({ + schema: 'mobilebuildmcp.output.simulator-action-result', + schemaVersion: '3', + didError: false, + error: null, + data: { + summary: { status: 'SUCCEEDED' }, + action, + artifacts: { simulatorId: 'SIM-1' }, + }, + }); + const statusbarEnvelope = envelope({ + type: 'statusbar', + time: '9:41', + dataNetwork: 'wifi', + wifiMode: 'active', + wifiBars: 3, + cellularMode: 'active', + cellularBars: 4, + batteryState: 'charged', + batteryLevel: 100, + }); + const shutdownEnvelope = envelope({ type: 'shutdown' }); + + expect(actionV2({ ...statusbarEnvelope, schemaVersion: '2' })).toBe(false); + expect(actionV2({ ...shutdownEnvelope, schemaVersion: '2' })).toBe(false); + expect(actionV3(statusbarEnvelope)).toBe(true); + expect(actionV3(shutdownEnvelope)).toBe(true); + expect(actionV3(envelope({ type: 'statusbar', dataNetwork: 'clear' }))).toBe(true); + expect(actionV3(envelope({ type: 'statusbar', batteryLevel: 101 }))).toBe(false); + }); + it('accepts video recording capture payloads in the bumped capture contract', () => { const schema = getMcpOutputSchema({ schema: 'mobilebuildmcp.output.capture-result', diff --git a/src/mcp/tools/simulator-management/__tests__/shutdown_sim.test.ts b/src/mcp/tools/simulator-management/__tests__/shutdown_sim.test.ts new file mode 100644 index 000000000..20f9482ca --- /dev/null +++ b/src/mcp/tools/simulator-management/__tests__/shutdown_sim.test.ts @@ -0,0 +1,194 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import * as z from 'zod'; +import { + createMockCommandResponse, + createMockExecutor, +} from '../../../../test-utils/mock-executors.ts'; +import { sessionStore } from '../../../../utils/session-store.ts'; +import { schema, handler, shutdown_simLogic } from '../shutdown_sim.ts'; +import { + allText, + runLogic, + callHandler, + createMockToolHandlerContext, +} from '../../../../test-utils/test-helpers.ts'; + +const availableSimulatorsJson = JSON.stringify({ + devices: { + 'iOS 26.0': [{ name: 'iPhone 17', udid: 'resolved-uuid', isAvailable: true }], + }, +}); + +describe('shutdown_sim tool', () => { + beforeEach(() => { + sessionStore.clear(); + }); + + describe('Export Field Validation (Literal)', () => { + it('should expose empty public schema', () => { + const schemaObj = z.object(schema); + expect(schemaObj.safeParse({}).success).toBe(true); + expect(Object.keys(schema)).toHaveLength(0); + + const withSimId = schemaObj.safeParse({ simulatorId: 'abc' }); + expect(withSimId.success).toBe(true); + expect('simulatorId' in (withSimId.data as Record)).toBe(false); + }); + }); + + describe('Handler Requirements', () => { + it('should require simulatorId when not provided', async () => { + const result = await callHandler(handler, {}); + + expect(result.isError).toBe(true); + const message = result.content[0].text; + expect(message).toContain('Missing required session defaults'); + expect(message).toContain('Provide simulatorId or simulatorName'); + expect(message).toContain('session-set-defaults'); + }); + + it('should reject simulatorId and simulatorName together', async () => { + const result = await callHandler(handler, { + simulatorId: 'test-uuid-123', + simulatorName: 'iPhone 17', + }); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain( + 'Mutually exclusive parameters provided: simulatorId, simulatorName', + ); + }); + }); + + describe('Logic Behavior (Literal Results)', () => { + it('should handle successful shutdown', async () => { + const mockExecutor = createMockExecutor({ success: true, output: '' }); + const { ctx, run } = createMockToolHandlerContext(); + + await run(() => shutdown_simLogic({ simulatorId: 'test-uuid-123' }, mockExecutor)); + + expect(ctx.structuredOutput).toEqual({ + schema: 'mobilebuildmcp.output.simulator-action-result', + schemaVersion: '3', + result: { + kind: 'simulator-action-result', + didError: false, + error: null, + summary: { status: 'SUCCEEDED' }, + action: { type: 'shutdown' }, + artifacts: { simulatorId: 'test-uuid-123' }, + }, + }); + expect(ctx.nextStepParams).toBeUndefined(); + }); + + it('should render shutdown success text', async () => { + const mockExecutor = createMockExecutor({ success: true, output: '' }); + + const result = await runLogic(() => + shutdown_simLogic({ simulatorId: 'test-uuid-123' }, mockExecutor), + ); + + const text = allText(result); + expect(text).toContain('Shut Down Simulator'); + expect(text).toContain('Simulator shut down successfully'); + expect(result.isError).toBeFalsy(); + }); + + it('should surface the simctl error for a simulator that is already shut down', async () => { + const alreadyShutdownError = + 'An error was encountered processing the command (domain=com.apple.CoreSimulator.SimError, code=405):\nUnable to shutdown device in current state: Shutdown\n'; + const mockExecutor = createMockExecutor({ success: false, error: alreadyShutdownError }); + + const result = await runLogic(() => + shutdown_simLogic({ simulatorId: 'test-uuid-123' }, mockExecutor), + ); + + const text = allText(result); + expect(text).toContain('Shut down simulator operation failed.'); + expect(text).toContain('Unable to shutdown device in current state: Shutdown'); + expect(result.isError).toBe(true); + }); + + it('should handle exception with Error object', async () => { + const mockExecutor = async () => { + throw new Error('Connection failed'); + }; + + const result = await runLogic(() => + shutdown_simLogic({ simulatorId: 'test-uuid-123' }, mockExecutor), + ); + + const text = allText(result); + expect(text).toContain('Shut down simulator operation failed.'); + expect(text).toContain('Connection failed'); + expect(result.isError).toBe(true); + }); + + it('should resolve simulatorName before shutting down', async () => { + const calls: string[][] = []; + const mockExecutor = async (command: string[]) => { + calls.push(command); + if (command.includes('list')) { + return createMockCommandResponse({ success: true, output: availableSimulatorsJson }); + } + return createMockCommandResponse({ success: true, output: '' }); + }; + + const result = await runLogic(() => + shutdown_simLogic({ simulatorName: 'iPhone 17' }, mockExecutor), + ); + + expect(result.isError).toBeFalsy(); + expect(calls).toEqual([ + ['xcrun', 'simctl', 'list', 'devices', 'available', '-j'], + ['xcrun', 'simctl', 'shutdown', 'resolved-uuid'], + ]); + }); + + it('should fail without running shutdown when simulatorName cannot be resolved', async () => { + const calls: string[][] = []; + const mockExecutor = async (command: string[]) => { + calls.push(command); + return createMockCommandResponse({ success: true, output: availableSimulatorsJson }); + }; + + const result = await runLogic(() => + shutdown_simLogic({ simulatorName: 'iPhone 1' }, mockExecutor), + ); + + expect(result.isError).toBe(true); + expect(allText(result)).toContain('Failed to resolve simulator'); + expect(calls).toEqual([['xcrun', 'simctl', 'list', 'devices', 'available', '-j']]); + }); + + it('should verify command generation with mock executor', async () => { + const calls: Array<{ + command: string[]; + description?: string; + allowStderr?: boolean; + opts?: { cwd?: string }; + }> = []; + const mockExecutor = async ( + command: string[], + description?: string, + allowStderr?: boolean, + opts?: { cwd?: string }, + ) => { + calls.push({ command, description, allowStderr, opts }); + return createMockCommandResponse({ success: true, output: '' }); + }; + + await runLogic(() => shutdown_simLogic({ simulatorId: 'test-uuid-123' }, mockExecutor)); + + expect(calls).toEqual([ + { + command: ['xcrun', 'simctl', 'shutdown', 'test-uuid-123'], + description: 'Shut Down Simulator', + allowStderr: false, + opts: undefined, + }, + ]); + }); + }); +}); diff --git a/src/mcp/tools/simulator-management/__tests__/sim_statusbar.test.ts b/src/mcp/tools/simulator-management/__tests__/sim_statusbar.test.ts index d9adf951c..74dc8bf22 100644 --- a/src/mcp/tools/simulator-management/__tests__/sim_statusbar.test.ts +++ b/src/mcp/tools/simulator-management/__tests__/sim_statusbar.test.ts @@ -5,8 +5,15 @@ import { createMockExecutor, type CommandExecutor, } from '../../../../test-utils/mock-executors.ts'; -import { schema, sim_statusbarLogic } from '../sim_statusbar.ts'; -import { allText, runLogic } from '../../../../test-utils/test-helpers.ts'; +import { schema, handler, sim_statusbarLogic } from '../sim_statusbar.ts'; +import { + allText, + callHandler, + createMockToolHandlerContext, + runLogic, +} from '../../../../test-utils/test-helpers.ts'; + +const SIMULATOR_ID = '00000000-0000-0000-0000-000000000000'; describe('sim_statusbar tool', () => { describe('Schema Validation', () => { @@ -21,6 +28,57 @@ describe('sim_statusbar tool', () => { expect(withSimId.success).toBe(true); expect('simulatorId' in (withSimId.data as object)).toBe(false); }); + + it('should accept status bar overrides without dataNetwork', () => { + const schemaObj = z.object(schema); + + expect( + schemaObj.safeParse({ + time: '9:41', + wifiMode: 'active', + wifiBars: 3, + cellularMode: 'notSupported', + cellularBars: 0, + batteryState: 'charged', + batteryLevel: 100, + }).success, + ).toBe(true); + }); + + it('should reject values simctl does not accept', () => { + const schemaObj = z.object(schema); + + expect(schemaObj.safeParse({ wifiMode: 'connected' }).success).toBe(false); + expect(schemaObj.safeParse({ wifiBars: 4 }).success).toBe(false); + expect(schemaObj.safeParse({ cellularMode: 'roaming' }).success).toBe(false); + expect(schemaObj.safeParse({ cellularBars: 5 }).success).toBe(false); + expect(schemaObj.safeParse({ batteryState: 'full' }).success).toBe(false); + expect(schemaObj.safeParse({ batteryLevel: 101 }).success).toBe(false); + expect(schemaObj.safeParse({ batteryLevel: -1 }).success).toBe(false); + expect(schemaObj.safeParse({ batteryLevel: 50.5 }).success).toBe(false); + }); + }); + + describe('Handler Validation', () => { + it('should require at least one status bar field', async () => { + const result = await callHandler(handler, { simulatorId: SIMULATOR_ID }); + + expect(result.isError).toBe(true); + expect(allText(result)).toContain('Provide at least one status bar field'); + }); + + it('should reject dataNetwork clear combined with other fields', async () => { + const result = await callHandler(handler, { + simulatorId: SIMULATOR_ID, + dataNetwork: 'clear', + batteryLevel: 100, + }); + + expect(result.isError).toBe(true); + expect(allText(result)).toContain( + 'dataNetwork "clear" removes all status bar overrides and cannot be combined with other status bar fields.', + ); + }); }); describe('Handler Behavior (Complete Literal Returns)', () => { @@ -197,6 +255,97 @@ describe('sim_statusbar tool', () => { ); expect(result.isError).toBeFalsy(); + expect(allText(result)).toContain('Status bar overrides cleared'); + }); + + it('should pass every supplied override to a single simctl override call', async () => { + const commands: string[][] = []; + const mockExecutor: CommandExecutor = async (command) => { + commands.push(command); + return createMockCommandResponse({ success: true, output: '' }); + }; + + const result = await runLogic(() => + sim_statusbarLogic( + { + simulatorId: 'test-uuid-123', + batteryLevel: 100, + batteryState: 'charged', + cellularBars: 4, + cellularMode: 'active', + wifiBars: 3, + wifiMode: 'active', + dataNetwork: '5g', + time: '9:41', + }, + mockExecutor, + ), + ); + + expect(result.isError).toBeFalsy(); + expect(allText(result)).toContain('Status bar overrides set successfully'); + expect(commands).toEqual([ + [ + 'xcrun', + 'simctl', + 'status_bar', + 'test-uuid-123', + 'override', + '--time', + '9:41', + '--dataNetwork', + '5g', + '--wifiMode', + 'active', + '--wifiBars', + '3', + '--cellularMode', + 'active', + '--cellularBars', + '4', + '--batteryState', + 'charged', + '--batteryLevel', + '100', + ], + ]); + }); + + it('should report the applied overrides in structured output', async () => { + const mockExecutor = createMockExecutor({ success: true, output: '' }); + const { ctx, run } = createMockToolHandlerContext(); + + await run(() => + sim_statusbarLogic( + { + simulatorId: 'test-uuid-123', + time: '9:41', + batteryState: 'charging', + batteryLevel: 0, + wifiBars: 0, + }, + mockExecutor, + ), + ); + + expect(ctx.structuredOutput).toEqual({ + schema: 'mobilebuildmcp.output.simulator-action-result', + schemaVersion: '3', + result: { + kind: 'simulator-action-result', + didError: false, + error: null, + summary: { status: 'SUCCEEDED' }, + action: { + type: 'statusbar', + time: '9:41', + wifiBars: 0, + batteryState: 'charging', + batteryLevel: 0, + }, + artifacts: { simulatorId: 'test-uuid-123' }, + }, + }); }); }); }); diff --git a/src/mcp/tools/simulator-management/shutdown_sim.ts b/src/mcp/tools/simulator-management/shutdown_sim.ts new file mode 100644 index 000000000..1df89a8df --- /dev/null +++ b/src/mcp/tools/simulator-management/shutdown_sim.ts @@ -0,0 +1,172 @@ +import * as z from 'zod'; +import type { ToolHandlerContext } from '../../../rendering/types.ts'; +import type { SimulatorActionResultDomainResult } from '../../../types/domain-results.ts'; +import type { NonStreamingExecutor } from '../../../types/tool-execution.ts'; +import { log } from '../../../utils/logging/index.ts'; +import type { CommandExecutor } from '../../../utils/execution/index.ts'; +import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; +import { + createSessionAwareTool, + getSessionAwareToolSchemaShape, + getHandlerContext, + toInternalSchema, +} from '../../../utils/typed-tool-factory.ts'; +import { determineSimulatorUuid } from '../../../utils/simulator-utils.ts'; +import { toErrorMessage } from '../../../utils/errors.ts'; +import { createBasicDiagnostics } from '../../../utils/diagnostics.ts'; + +const baseSchemaObject = z.object({ + simulatorId: z + .string() + .optional() + .describe( + 'UUID of the simulator to use (obtained from list_sims). Provide EITHER this OR simulatorName, not both', + ), + simulatorName: z + .string() + .optional() + .describe( + "Name of the simulator (e.g., 'iPhone 17'). Provide EITHER this OR simulatorId, not both", + ), +}); + +const internalSchemaObject = z.object({ + simulatorId: z.string().optional(), + simulatorName: z.string().optional(), +}); + +type ShutdownSimParams = z.infer; +type ResolvedShutdownSimParams = ShutdownSimParams & { simulatorId: string }; +type ShutdownSimResult = SimulatorActionResultDomainResult; + +const publicSchemaObject = z.strictObject( + baseSchemaObject.omit({ + simulatorId: true, + simulatorName: true, + } as const).shape, +); + +function createShutdownSimResult(params: { + simulatorId?: string; + didError: boolean; + error?: string; + diagnosticMessage?: string; +}): ShutdownSimResult { + return { + kind: 'simulator-action-result', + didError: params.didError, + error: params.error ?? null, + summary: { + status: params.didError ? 'FAILED' : 'SUCCEEDED', + }, + action: { + type: 'shutdown', + }, + ...(params.diagnosticMessage + ? { diagnostics: createBasicDiagnostics({ errors: [params.diagnosticMessage] }) } + : {}), + ...(params.simulatorId + ? { + artifacts: { + simulatorId: params.simulatorId, + }, + } + : {}), + }; +} + +function setStructuredOutput(ctx: ToolHandlerContext, result: ShutdownSimResult): void { + ctx.structuredOutput = { + result, + schema: 'mobilebuildmcp.output.simulator-action-result', + schemaVersion: '3', + }; +} + +export function createShutdownSimExecutor( + executor: CommandExecutor, +): NonStreamingExecutor { + return async (params) => { + try { + const result = await executor( + ['xcrun', 'simctl', 'shutdown', params.simulatorId], + 'Shut Down Simulator', + false, + ); + + if (!result.success) { + const diagnosticMessage = result.error ?? 'Unknown error'; + return createShutdownSimResult({ + simulatorId: params.simulatorId, + didError: true, + error: 'Shut down simulator operation failed.', + diagnosticMessage, + }); + } + + return createShutdownSimResult({ + simulatorId: params.simulatorId, + didError: false, + }); + } catch (error) { + const diagnosticMessage = toErrorMessage(error); + return createShutdownSimResult({ + simulatorId: params.simulatorId, + didError: true, + error: 'Shut down simulator operation failed.', + diagnosticMessage, + }); + } + }; +} + +export async function shutdown_simLogic( + params: ShutdownSimParams, + executor: CommandExecutor, +): Promise { + const ctx = getHandlerContext(); + const simulatorResult = await determineSimulatorUuid(params, executor); + if (simulatorResult.error || !simulatorResult.uuid) { + const result = createShutdownSimResult({ + didError: true, + error: 'Shut down simulator operation failed.', + diagnosticMessage: `Failed to resolve simulator: ${simulatorResult.error ?? 'No simulator UUID returned'}`, + }); + setStructuredOutput(ctx, result); + log('error', `Error during shut down simulator operation: ${result.error ?? 'Unknown error'}`); + return; + } + + if (simulatorResult.warning) { + log('warn', simulatorResult.warning); + } + + const resolvedParams: ResolvedShutdownSimParams = { + ...params, + simulatorId: simulatorResult.uuid, + }; + log('info', `Starting xcrun simctl shutdown request for simulator ${resolvedParams.simulatorId}`); + + const executeShutdownSim = createShutdownSimExecutor(executor); + const result = await executeShutdownSim(resolvedParams); + setStructuredOutput(ctx, result); + + if (result.didError) { + log('error', `Error during shut down simulator operation: ${result.error ?? 'Unknown error'}`); + } +} + +export const schema = getSessionAwareToolSchemaShape({ + sessionAware: publicSchemaObject, + legacy: baseSchemaObject, +}); + +export const handler = createSessionAwareTool({ + internalSchema: toInternalSchema(internalSchemaObject), + logicFunction: shutdown_simLogic, + getExecutor: getDefaultCommandExecutor, + requirements: [ + { oneOf: ['simulatorId', 'simulatorName'], message: 'Provide simulatorId or simulatorName' }, + ], + exclusivePairs: [['simulatorId', 'simulatorName']], +}); diff --git a/src/mcp/tools/simulator-management/sim_statusbar.ts b/src/mcp/tools/simulator-management/sim_statusbar.ts index cc3cb1a4f..5b1a992a3 100644 --- a/src/mcp/tools/simulator-management/sim_statusbar.ts +++ b/src/mcp/tools/simulator-management/sim_statusbar.ts @@ -1,6 +1,9 @@ import * as z from 'zod'; import type { ToolHandlerContext } from '../../../rendering/types.ts'; -import type { SimulatorActionResultDomainResult } from '../../../types/domain-results.ts'; +import type { + SimulatorActionResultDomainResult, + SimulatorActionStatusbar, +} from '../../../types/domain-results.ts'; import type { NonStreamingExecutor } from '../../../types/tool-execution.ts'; import { log } from '../../../utils/logging/index.ts'; import type { CommandExecutor } from '../../../utils/execution/index.ts'; @@ -14,7 +17,7 @@ import { import { toErrorMessage } from '../../../utils/errors.ts'; import { createBasicDiagnostics } from '../../../utils/diagnostics.ts'; -const simStatusbarSchema = z.object({ +const simStatusbarSchemaObject = z.object({ simulatorId: z.uuid().describe('UUID of the simulator to use (obtained from list_simulators)'), dataNetwork: z .enum([ @@ -31,15 +34,78 @@ const simStatusbarSchema = z.object({ '5g-uwb', '5g-uc', ]) - .describe('clear|hide|wifi|3g|4g|lte|lte-a|lte+|5g|5g+|5g-uwb|5g-uc'), + .optional() + .describe( + 'clear|hide|wifi|3g|4g|lte|lte-a|lte+|5g|5g+|5g-uwb|5g-uc; clear removes all overrides and must be used alone', + ), + time: z.string().optional().describe('Fixed time or ISO date string, e.g. "9:41"'), + wifiMode: z.enum(['searching', 'failed', 'active']).optional(), + wifiBars: z.number().int().min(0).max(3).optional(), + cellularMode: z.enum(['notSupported', 'searching', 'failed', 'active']).optional(), + cellularBars: z.number().int().min(0).max(4).optional(), + batteryState: z.enum(['charging', 'charged', 'discharging']).optional(), + batteryLevel: z.number().int().min(0).max(100).optional(), }); +// Ordered as `simctl status_bar override` documents its flags. +const OVERRIDE_FIELDS = [ + 'time', + 'dataNetwork', + 'wifiMode', + 'wifiBars', + 'cellularMode', + 'cellularBars', + 'batteryState', + 'batteryLevel', +] as const; + +const simStatusbarSchema = simStatusbarSchemaObject + .refine((params) => OVERRIDE_FIELDS.some((field) => params[field] !== undefined), { + message: `Provide at least one status bar field: ${OVERRIDE_FIELDS.join(', ')}.`, + }) + .refine( + (params) => + params.dataNetwork !== 'clear' || + OVERRIDE_FIELDS.every((field) => field === 'dataNetwork' || params[field] === undefined), + { + message: + 'dataNetwork "clear" removes all status bar overrides and cannot be combined with other status bar fields.', + path: ['dataNetwork'], + }, + ); + type SimStatusbarParams = z.infer; type SimStatusbarResult = SimulatorActionResultDomainResult; +function createStatusbarAction(params: SimStatusbarParams): SimulatorActionStatusbar { + return { + type: 'statusbar', + ...(params.time !== undefined ? { time: params.time } : {}), + ...(params.dataNetwork !== undefined ? { dataNetwork: params.dataNetwork } : {}), + ...(params.wifiMode !== undefined ? { wifiMode: params.wifiMode } : {}), + ...(params.wifiBars !== undefined ? { wifiBars: params.wifiBars } : {}), + ...(params.cellularMode !== undefined ? { cellularMode: params.cellularMode } : {}), + ...(params.cellularBars !== undefined ? { cellularBars: params.cellularBars } : {}), + ...(params.batteryState !== undefined ? { batteryState: params.batteryState } : {}), + ...(params.batteryLevel !== undefined ? { batteryLevel: params.batteryLevel } : {}), + }; +} + +function createStatusbarCommand(params: SimStatusbarParams): string[] { + const command = ['xcrun', 'simctl', 'status_bar', params.simulatorId]; + if (params.dataNetwork === 'clear') { + return [...command, 'clear']; + } + + const flags = OVERRIDE_FIELDS.flatMap((field) => { + const value = params[field]; + return value === undefined ? [] : [`--${field}`, String(value)]; + }); + return [...command, 'override', ...flags]; +} + function createSimStatusbarResult(params: { - simulatorId: string; - dataNetwork: SimStatusbarParams['dataNetwork']; + request: SimStatusbarParams; didError: boolean; error?: string; diagnosticMessage?: string; @@ -51,15 +117,12 @@ function createSimStatusbarResult(params: { summary: { status: params.didError ? 'FAILED' : 'SUCCEEDED', }, - action: { - type: 'statusbar', - dataNetwork: params.dataNetwork, - }, + action: createStatusbarAction(params.request), ...(params.diagnosticMessage ? { diagnostics: createBasicDiagnostics({ errors: [params.diagnosticMessage] }) } : {}), artifacts: { - simulatorId: params.simulatorId, + simulatorId: params.request.simulatorId, }, }; } @@ -68,7 +131,7 @@ function setStructuredOutput(ctx: ToolHandlerContext, result: SimStatusbarResult ctx.structuredOutput = { result, schema: 'mobilebuildmcp.output.simulator-action-result', - schemaVersion: '2', + schemaVersion: '3', }; } @@ -77,26 +140,12 @@ export function createSimStatusbarExecutor( ): NonStreamingExecutor { return async (params) => { try { - const command = - params.dataNetwork === 'clear' - ? ['xcrun', 'simctl', 'status_bar', params.simulatorId, 'clear'] - : [ - 'xcrun', - 'simctl', - 'status_bar', - params.simulatorId, - 'override', - '--dataNetwork', - params.dataNetwork, - ]; - - const result = await executor(command, 'Set Status Bar', false); + const result = await executor(createStatusbarCommand(params), 'Set Status Bar', false); if (!result.success) { const diagnosticMessage = result.error ?? 'Unknown error'; return createSimStatusbarResult({ - simulatorId: params.simulatorId, - dataNetwork: params.dataNetwork, + request: params, didError: true, error: 'Failed to set status bar.', diagnosticMessage, @@ -104,15 +153,13 @@ export function createSimStatusbarExecutor( } return createSimStatusbarResult({ - simulatorId: params.simulatorId, - dataNetwork: params.dataNetwork, + request: params, didError: false, }); } catch (error) { const diagnosticMessage = toErrorMessage(error); return createSimStatusbarResult({ - simulatorId: params.simulatorId, - dataNetwork: params.dataNetwork, + request: params, didError: true, error: 'Failed to set status bar.', diagnosticMessage, @@ -125,10 +172,10 @@ export async function sim_statusbarLogic( params: SimStatusbarParams, executor: CommandExecutor, ): Promise { - log( - 'info', - `Setting simulator ${params.simulatorId} status bar data network to ${params.dataNetwork}`, - ); + const overrides = OVERRIDE_FIELDS.filter((field) => params[field] !== undefined) + .map((field) => `${field}=${String(params[field])}`) + .join(', '); + log('info', `Setting simulator ${params.simulatorId} status bar: ${overrides}`); const ctx = getHandlerContext(); const executeSimStatusbar = createSimStatusbarExecutor(executor); @@ -145,20 +192,18 @@ export async function sim_statusbarLogic( } const successMsg = - params.dataNetwork === 'clear' - ? 'Status bar overrides cleared' - : 'Status bar data network set successfully'; + params.dataNetwork === 'clear' ? 'Status bar overrides cleared' : 'Status bar overrides set'; log('info', `${successMsg} (simulator: ${params.simulatorId})`); } const publicSchemaObject = z.strictObject( - simStatusbarSchema.omit({ simulatorId: true } as const).shape, + simStatusbarSchemaObject.omit({ simulatorId: true } as const).shape, ); export const schema = getSessionAwareToolSchemaShape({ sessionAware: publicSchemaObject, - legacy: simStatusbarSchema, + legacy: simStatusbarSchemaObject, }); export const handler = createSessionAwareTool({ diff --git a/src/snapshot-tests/__fixtures__/cli/json/simulator-management/shutdown--error-invalid-id.json b/src/snapshot-tests/__fixtures__/cli/json/simulator-management/shutdown--error-invalid-id.json new file mode 100644 index 000000000..db88e09ac --- /dev/null +++ b/src/snapshot-tests/__fixtures__/cli/json/simulator-management/shutdown--error-invalid-id.json @@ -0,0 +1,25 @@ +{ + "schema": "mobilebuildmcp.output.simulator-action-result", + "schemaVersion": "3", + "didError": true, + "error": "Shut down simulator operation failed.", + "data": { + "summary": { + "status": "FAILED" + }, + "action": { + "type": "shutdown" + }, + "diagnostics": { + "warnings": [], + "errors": [ + { + "message": "Invalid device: " + } + ] + }, + "artifacts": { + "simulatorId": "" + } + } +} diff --git a/src/snapshot-tests/__fixtures__/cli/json/simulator-management/shutdown--success.json b/src/snapshot-tests/__fixtures__/cli/json/simulator-management/shutdown--success.json new file mode 100644 index 000000000..4381b3095 --- /dev/null +++ b/src/snapshot-tests/__fixtures__/cli/json/simulator-management/shutdown--success.json @@ -0,0 +1,17 @@ +{ + "schema": "mobilebuildmcp.output.simulator-action-result", + "schemaVersion": "3", + "didError": false, + "error": null, + "data": { + "summary": { + "status": "SUCCEEDED" + }, + "action": { + "type": "shutdown" + }, + "artifacts": { + "simulatorId": "" + } + } +} diff --git a/src/snapshot-tests/__fixtures__/cli/json/simulator-management/statusbar--error-invalid-simulator.json b/src/snapshot-tests/__fixtures__/cli/json/simulator-management/statusbar--error-invalid-simulator.json index 3624ee417..b24ad114b 100644 --- a/src/snapshot-tests/__fixtures__/cli/json/simulator-management/statusbar--error-invalid-simulator.json +++ b/src/snapshot-tests/__fixtures__/cli/json/simulator-management/statusbar--error-invalid-simulator.json @@ -1,6 +1,6 @@ { "schema": "mobilebuildmcp.output.simulator-action-result", - "schemaVersion": "2", + "schemaVersion": "3", "didError": true, "error": "Failed to set status bar.", "data": { diff --git a/src/snapshot-tests/__fixtures__/cli/json/simulator-management/statusbar--success-all-overrides.json b/src/snapshot-tests/__fixtures__/cli/json/simulator-management/statusbar--success-all-overrides.json new file mode 100644 index 000000000..f9076fb72 --- /dev/null +++ b/src/snapshot-tests/__fixtures__/cli/json/simulator-management/statusbar--success-all-overrides.json @@ -0,0 +1,25 @@ +{ + "schema": "mobilebuildmcp.output.simulator-action-result", + "schemaVersion": "3", + "didError": false, + "error": null, + "data": { + "summary": { + "status": "SUCCEEDED" + }, + "action": { + "type": "statusbar", + "time": "9:41", + "dataNetwork": "wifi", + "wifiMode": "active", + "wifiBars": 3, + "cellularMode": "active", + "cellularBars": 4, + "batteryState": "charged", + "batteryLevel": 100 + }, + "artifacts": { + "simulatorId": "" + } + } +} diff --git a/src/snapshot-tests/__fixtures__/cli/json/simulator-management/statusbar--success.json b/src/snapshot-tests/__fixtures__/cli/json/simulator-management/statusbar--success.json index 4e351dcbc..847b2bea9 100644 --- a/src/snapshot-tests/__fixtures__/cli/json/simulator-management/statusbar--success.json +++ b/src/snapshot-tests/__fixtures__/cli/json/simulator-management/statusbar--success.json @@ -1,6 +1,6 @@ { "schema": "mobilebuildmcp.output.simulator-action-result", - "schemaVersion": "2", + "schemaVersion": "3", "didError": false, "error": null, "data": { diff --git a/src/snapshot-tests/__fixtures__/cli/text/simulator-management/shutdown--error-invalid-id.txt b/src/snapshot-tests/__fixtures__/cli/text/simulator-management/shutdown--error-invalid-id.txt new file mode 100644 index 000000000..cd3d55e4b --- /dev/null +++ b/src/snapshot-tests/__fixtures__/cli/text/simulator-management/shutdown--error-invalid-id.txt @@ -0,0 +1,10 @@ + +📱 Shut Down Simulator + + Simulator: + +Errors (1): + + ✗ Invalid device: + +❌ Shut down simulator operation failed. diff --git a/src/snapshot-tests/__fixtures__/cli/text/simulator-management/shutdown--success.txt b/src/snapshot-tests/__fixtures__/cli/text/simulator-management/shutdown--success.txt new file mode 100644 index 000000000..de0ccd97f --- /dev/null +++ b/src/snapshot-tests/__fixtures__/cli/text/simulator-management/shutdown--success.txt @@ -0,0 +1,6 @@ + +📱 Shut Down Simulator + + Simulator: + +✅ Simulator shut down successfully diff --git a/src/snapshot-tests/__fixtures__/cli/text/simulator-management/statusbar--success-all-overrides.txt b/src/snapshot-tests/__fixtures__/cli/text/simulator-management/statusbar--success-all-overrides.txt new file mode 100644 index 000000000..3ad195b0e --- /dev/null +++ b/src/snapshot-tests/__fixtures__/cli/text/simulator-management/statusbar--success-all-overrides.txt @@ -0,0 +1,14 @@ + +📱 Statusbar + + Simulator: + Time: 9:41 + Data Network: wifi + Wi-Fi Mode: active + Wi-Fi Bars: 3 + Cellular Mode: active + Cellular Bars: 4 + Battery State: charged + Battery Level: 100 + +✅ Status bar overrides set successfully diff --git a/src/snapshot-tests/__fixtures__/cli/text/simulator-management/statusbar--success.txt b/src/snapshot-tests/__fixtures__/cli/text/simulator-management/statusbar--success.txt index 3d93eeaff..dba05a3da 100644 --- a/src/snapshot-tests/__fixtures__/cli/text/simulator-management/statusbar--success.txt +++ b/src/snapshot-tests/__fixtures__/cli/text/simulator-management/statusbar--success.txt @@ -4,4 +4,4 @@ Simulator: Data Network: wifi -✅ Status bar data network set successfully +✅ Status bar overrides set successfully diff --git a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/shutdown_sim.json b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/shutdown_sim.json new file mode 100644 index 000000000..f6c756c1b --- /dev/null +++ b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/shutdown_sim.json @@ -0,0 +1,267 @@ +{ + "annotations": {"destructiveHint":false,"openWorldHint":false,"readOnlyHint":false,"title":"Shut Down Simulator"}, + "description": "Shut down a booted simulator.", + "execution": {"taskSupport":"forbidden"}, + "inputSchema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "simulatorId": {"description":"UUID of the simulator to use (obtained from list_sims). Provide EITHER this OR simulatorName, not both","type":"string"}, + "simulatorName": {"description":"Name of the simulator (e.g., 'iPhone 17'). Provide EITHER this OR simulatorId, not both","type":"string"} + }, + "type": "object" + }, + "name": "shutdown_sim", + "outputSchema": { + "$defs": { + "basicDiagnostics": { + "additionalProperties": false, + "properties": { + "errors": { + "items": {"$ref":"#/$defs/diagnosticEntry"}, + "type": "array" + }, + "rawOutput": { + "items": {"type":"string"}, + "type": "array" + }, + "warnings": { + "items": {"$ref":"#/$defs/diagnosticEntry"}, + "type": "array" + } + }, + "required": ["warnings","errors"], + "type": "object" + }, + "diagnosticEntry": { + "additionalProperties": false, + "properties": { + "location": {"type":"string"}, + "message": {"type":"string"} + }, + "required": ["message"], + "type": "object" + }, + "errorConsistency": { + "allOf": [ + { + "if": { + "properties": { + "didError": {"const":false} + }, + "required": ["didError"] + }, + "then": { + "properties": { + "error": {"type":"null"} + }, + "required": ["error"] + } + }, + { + "if": { + "properties": { + "didError": {"const":true} + }, + "required": ["didError"] + }, + "then": { + "properties": { + "error": {"minLength":1,"type":"string"} + }, + "required": ["error"] + } + } + ], + "properties": { + "didError": {"type":"boolean"}, + "error": { + "type": ["string","null"] + } + }, + "type": "object" + }, + "nextSteps": { + "items": {"minLength":1,"type":"string"}, + "minItems": 1, + "type": "array" + }, + "statusSummary": { + "additionalProperties": false, + "properties": { + "status": { + "enum": ["SUCCEEDED","FAILED"] + } + }, + "required": ["status"], + "type": "object" + } + }, + "$id": "https://raw.githubusercontent.com/getsentry/MobileBuildMCP/main/schemas/structured-output/mobilebuildmcp.output.simulator-action-result/3.registration.schema.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "oneOf": [ + { + "additionalProperties": false, + "allOf": [ + {"$ref":"#/$defs/errorConsistency"} + ], + "properties": { + "data": { + "additionalProperties": false, + "properties": { + "action": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "type": {"const":"boot"} + }, + "required": ["type"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "type": {"const":"erase"} + }, + "required": ["type"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "type": {"const":"open"} + }, + "required": ["type"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "type": {"const":"reset-location"} + }, + "required": ["type"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "coordinates": { + "additionalProperties": false, + "properties": { + "latitude": {"maximum":90,"minimum":-90,"type":"number"}, + "longitude": {"maximum":180,"minimum":-180,"type":"number"} + }, + "required": ["latitude","longitude"], + "type": "object" + }, + "type": {"const":"set-location"} + }, + "required": ["type","coordinates"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "appearance": {"type":"string"}, + "type": {"const":"set-appearance"} + }, + "required": ["type","appearance"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "type": {"const":"shutdown"} + }, + "required": ["type"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "batteryLevel": {"maximum":100,"minimum":0,"type":"integer"}, + "batteryState": {"type":"string"}, + "cellularBars": {"maximum":4,"minimum":0,"type":"integer"}, + "cellularMode": {"type":"string"}, + "dataNetwork": {"type":"string"}, + "time": {"type":"string"}, + "type": {"const":"statusbar"}, + "wifiBars": {"maximum":3,"minimum":0,"type":"integer"}, + "wifiMode": {"type":"string"} + }, + "required": ["type"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "type": {"const":"toggle-software-keyboard"} + }, + "required": ["type"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "type": {"const":"toggle-connect-hardware-keyboard"} + }, + "required": ["type"], + "type": "object" + } + ] + }, + "artifacts": { + "additionalProperties": false, + "minProperties": 1, + "properties": { + "simulatorId": {"type":"string"} + }, + "required": [], + "type": "object" + }, + "diagnostics": {"$ref":"#/$defs/basicDiagnostics"}, + "summary": {"$ref":"#/$defs/statusSummary"} + }, + "required": ["summary","action"], + "type": "object" + }, + "didError": {"type":"boolean"}, + "error": { + "type": ["string","null"] + }, + "nextSteps": {"$ref":"#/$defs/nextSteps"}, + "schema": {"const":"mobilebuildmcp.output.simulator-action-result"}, + "schemaVersion": {"const":"3"} + }, + "required": ["schema","schemaVersion","didError","error","data"], + "type": "object" + }, + { + "additionalProperties": false, + "allOf": [ + {"$ref":"#/$defs/errorConsistency"} + ], + "properties": { + "data": { + "additionalProperties": false, + "properties": { + "category": { + "enum": ["runtime","validation","schema"] + }, + "code": {"minLength":1,"type":"string"} + }, + "required": ["category","code"], + "type": "object" + }, + "didError": {"const":true}, + "error": {"minLength":1,"type":"string"}, + "schema": {"const":"mobilebuildmcp.output.error"}, + "schemaVersion": {"const":"1"} + }, + "required": ["schema","schemaVersion","didError","error","data"], + "type": "object" + } + ], + "type": "object" + } +} diff --git a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/sim_statusbar.json b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/sim_statusbar.json index 433b425cf..0a0a204fd 100644 --- a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/sim_statusbar.json +++ b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/sim_statusbar.json @@ -1,12 +1,22 @@ { "annotations": {"destructiveHint":false,"openWorldHint":false,"readOnlyHint":true,"title":"Simulator Statusbar"}, - "description": "Set sim status bar network.", + "description": "Override or clear sim status bar time, network, signal, and battery.", "execution": {"taskSupport":"forbidden"}, "inputSchema": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { + "batteryLevel": {"maximum":100,"minimum":0,"type":"integer"}, + "batteryState": { + "enum": ["charging","charged","discharging"], + "type": "string" + }, + "cellularBars": {"maximum":4,"minimum":0,"type":"integer"}, + "cellularMode": { + "enum": ["notSupported","searching","failed","active"], + "type": "string" + }, "dataNetwork": { - "description": "clear|hide|wifi|3g|4g|lte|lte-a|lte+|5g|5g+|5g-uwb|5g-uc", + "description": "clear|hide|wifi|3g|4g|lte|lte-a|lte+|5g|5g+|5g-uwb|5g-uc; clear removes all overrides and must be used alone", "enum": ["clear","hide","wifi","3g","4g","lte","lte-a","lte+","5g","5g+","5g-uwb","5g-uc"], "type": "string" }, @@ -15,9 +25,15 @@ "format": "uuid", "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", "type": "string" + }, + "time": {"description":"Fixed time or ISO date string, e.g. \"9:41\"","type":"string"}, + "wifiBars": {"maximum":3,"minimum":0,"type":"integer"}, + "wifiMode": { + "enum": ["searching","failed","active"], + "type": "string" } }, - "required": ["simulatorId","dataNetwork"], + "required": ["simulatorId"], "type": "object" }, "name": "sim_statusbar", @@ -106,7 +122,7 @@ "type": "object" } }, - "$id": "https://raw.githubusercontent.com/getsentry/MobileBuildMCP/main/schemas/structured-output/mobilebuildmcp.output.simulator-action-result/2.registration.schema.json", + "$id": "https://raw.githubusercontent.com/getsentry/MobileBuildMCP/main/schemas/structured-output/mobilebuildmcp.output.simulator-action-result/3.registration.schema.json", "$schema": "https://json-schema.org/draft/2020-12/schema", "oneOf": [ { @@ -181,8 +197,23 @@ { "additionalProperties": false, "properties": { + "type": {"const":"shutdown"} + }, + "required": ["type"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "batteryLevel": {"maximum":100,"minimum":0,"type":"integer"}, + "batteryState": {"type":"string"}, + "cellularBars": {"maximum":4,"minimum":0,"type":"integer"}, + "cellularMode": {"type":"string"}, "dataNetwork": {"type":"string"}, - "type": {"const":"statusbar"} + "time": {"type":"string"}, + "type": {"const":"statusbar"}, + "wifiBars": {"maximum":3,"minimum":0,"type":"integer"}, + "wifiMode": {"type":"string"} }, "required": ["type"], "type": "object" @@ -226,7 +257,7 @@ }, "nextSteps": {"$ref":"#/$defs/nextSteps"}, "schema": {"const":"mobilebuildmcp.output.simulator-action-result"}, - "schemaVersion": {"const":"2"} + "schemaVersion": {"const":"3"} }, "required": ["schema","schemaVersion","didError","error","data"], "type": "object" diff --git a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/shutdown_sim.json b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/shutdown_sim.json new file mode 100644 index 000000000..1122956f5 --- /dev/null +++ b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/shutdown_sim.json @@ -0,0 +1,264 @@ +{ + "annotations": {"destructiveHint":false,"openWorldHint":false,"readOnlyHint":false,"title":"Shut Down Simulator"}, + "description": "Shut down a booted simulator.", + "execution": {"taskSupport":"forbidden"}, + "inputSchema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": {}, + "type": "object" + }, + "name": "shutdown_sim", + "outputSchema": { + "$defs": { + "basicDiagnostics": { + "additionalProperties": false, + "properties": { + "errors": { + "items": {"$ref":"#/$defs/diagnosticEntry"}, + "type": "array" + }, + "rawOutput": { + "items": {"type":"string"}, + "type": "array" + }, + "warnings": { + "items": {"$ref":"#/$defs/diagnosticEntry"}, + "type": "array" + } + }, + "required": ["warnings","errors"], + "type": "object" + }, + "diagnosticEntry": { + "additionalProperties": false, + "properties": { + "location": {"type":"string"}, + "message": {"type":"string"} + }, + "required": ["message"], + "type": "object" + }, + "errorConsistency": { + "allOf": [ + { + "if": { + "properties": { + "didError": {"const":false} + }, + "required": ["didError"] + }, + "then": { + "properties": { + "error": {"type":"null"} + }, + "required": ["error"] + } + }, + { + "if": { + "properties": { + "didError": {"const":true} + }, + "required": ["didError"] + }, + "then": { + "properties": { + "error": {"minLength":1,"type":"string"} + }, + "required": ["error"] + } + } + ], + "properties": { + "didError": {"type":"boolean"}, + "error": { + "type": ["string","null"] + } + }, + "type": "object" + }, + "nextSteps": { + "items": {"minLength":1,"type":"string"}, + "minItems": 1, + "type": "array" + }, + "statusSummary": { + "additionalProperties": false, + "properties": { + "status": { + "enum": ["SUCCEEDED","FAILED"] + } + }, + "required": ["status"], + "type": "object" + } + }, + "$id": "https://raw.githubusercontent.com/getsentry/MobileBuildMCP/main/schemas/structured-output/mobilebuildmcp.output.simulator-action-result/3.registration.schema.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "oneOf": [ + { + "additionalProperties": false, + "allOf": [ + {"$ref":"#/$defs/errorConsistency"} + ], + "properties": { + "data": { + "additionalProperties": false, + "properties": { + "action": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "type": {"const":"boot"} + }, + "required": ["type"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "type": {"const":"erase"} + }, + "required": ["type"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "type": {"const":"open"} + }, + "required": ["type"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "type": {"const":"reset-location"} + }, + "required": ["type"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "coordinates": { + "additionalProperties": false, + "properties": { + "latitude": {"maximum":90,"minimum":-90,"type":"number"}, + "longitude": {"maximum":180,"minimum":-180,"type":"number"} + }, + "required": ["latitude","longitude"], + "type": "object" + }, + "type": {"const":"set-location"} + }, + "required": ["type","coordinates"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "appearance": {"type":"string"}, + "type": {"const":"set-appearance"} + }, + "required": ["type","appearance"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "type": {"const":"shutdown"} + }, + "required": ["type"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "batteryLevel": {"maximum":100,"minimum":0,"type":"integer"}, + "batteryState": {"type":"string"}, + "cellularBars": {"maximum":4,"minimum":0,"type":"integer"}, + "cellularMode": {"type":"string"}, + "dataNetwork": {"type":"string"}, + "time": {"type":"string"}, + "type": {"const":"statusbar"}, + "wifiBars": {"maximum":3,"minimum":0,"type":"integer"}, + "wifiMode": {"type":"string"} + }, + "required": ["type"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "type": {"const":"toggle-software-keyboard"} + }, + "required": ["type"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "type": {"const":"toggle-connect-hardware-keyboard"} + }, + "required": ["type"], + "type": "object" + } + ] + }, + "artifacts": { + "additionalProperties": false, + "minProperties": 1, + "properties": { + "simulatorId": {"type":"string"} + }, + "required": [], + "type": "object" + }, + "diagnostics": {"$ref":"#/$defs/basicDiagnostics"}, + "summary": {"$ref":"#/$defs/statusSummary"} + }, + "required": ["summary","action"], + "type": "object" + }, + "didError": {"type":"boolean"}, + "error": { + "type": ["string","null"] + }, + "nextSteps": {"$ref":"#/$defs/nextSteps"}, + "schema": {"const":"mobilebuildmcp.output.simulator-action-result"}, + "schemaVersion": {"const":"3"} + }, + "required": ["schema","schemaVersion","didError","error","data"], + "type": "object" + }, + { + "additionalProperties": false, + "allOf": [ + {"$ref":"#/$defs/errorConsistency"} + ], + "properties": { + "data": { + "additionalProperties": false, + "properties": { + "category": { + "enum": ["runtime","validation","schema"] + }, + "code": {"minLength":1,"type":"string"} + }, + "required": ["category","code"], + "type": "object" + }, + "didError": {"const":true}, + "error": {"minLength":1,"type":"string"}, + "schema": {"const":"mobilebuildmcp.output.error"}, + "schemaVersion": {"const":"1"} + }, + "required": ["schema","schemaVersion","didError","error","data"], + "type": "object" + } + ], + "type": "object" + } +} diff --git a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/sim_statusbar.json b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/sim_statusbar.json index 3f8a7b4c3..e365edd35 100644 --- a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/sim_statusbar.json +++ b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/sim_statusbar.json @@ -1,17 +1,32 @@ { "annotations": {"destructiveHint":false,"openWorldHint":false,"readOnlyHint":true,"title":"Simulator Statusbar"}, - "description": "Set sim status bar network.", + "description": "Override or clear sim status bar time, network, signal, and battery.", "execution": {"taskSupport":"forbidden"}, "inputSchema": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { + "batteryLevel": {"maximum":100,"minimum":0,"type":"integer"}, + "batteryState": { + "enum": ["charging","charged","discharging"], + "type": "string" + }, + "cellularBars": {"maximum":4,"minimum":0,"type":"integer"}, + "cellularMode": { + "enum": ["notSupported","searching","failed","active"], + "type": "string" + }, "dataNetwork": { - "description": "clear|hide|wifi|3g|4g|lte|lte-a|lte+|5g|5g+|5g-uwb|5g-uc", + "description": "clear|hide|wifi|3g|4g|lte|lte-a|lte+|5g|5g+|5g-uwb|5g-uc; clear removes all overrides and must be used alone", "enum": ["clear","hide","wifi","3g","4g","lte","lte-a","lte+","5g","5g+","5g-uwb","5g-uc"], "type": "string" + }, + "time": {"description":"Fixed time or ISO date string, e.g. \"9:41\"","type":"string"}, + "wifiBars": {"maximum":3,"minimum":0,"type":"integer"}, + "wifiMode": { + "enum": ["searching","failed","active"], + "type": "string" } }, - "required": ["dataNetwork"], "type": "object" }, "name": "sim_statusbar", @@ -100,7 +115,7 @@ "type": "object" } }, - "$id": "https://raw.githubusercontent.com/getsentry/MobileBuildMCP/main/schemas/structured-output/mobilebuildmcp.output.simulator-action-result/2.registration.schema.json", + "$id": "https://raw.githubusercontent.com/getsentry/MobileBuildMCP/main/schemas/structured-output/mobilebuildmcp.output.simulator-action-result/3.registration.schema.json", "$schema": "https://json-schema.org/draft/2020-12/schema", "oneOf": [ { @@ -175,8 +190,23 @@ { "additionalProperties": false, "properties": { + "type": {"const":"shutdown"} + }, + "required": ["type"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "batteryLevel": {"maximum":100,"minimum":0,"type":"integer"}, + "batteryState": {"type":"string"}, + "cellularBars": {"maximum":4,"minimum":0,"type":"integer"}, + "cellularMode": {"type":"string"}, "dataNetwork": {"type":"string"}, - "type": {"const":"statusbar"} + "time": {"type":"string"}, + "type": {"const":"statusbar"}, + "wifiBars": {"maximum":3,"minimum":0,"type":"integer"}, + "wifiMode": {"type":"string"} }, "required": ["type"], "type": "object" @@ -220,7 +250,7 @@ }, "nextSteps": {"$ref":"#/$defs/nextSteps"}, "schema": {"const":"mobilebuildmcp.output.simulator-action-result"}, - "schemaVersion": {"const":"2"} + "schemaVersion": {"const":"3"} }, "required": ["schema","schemaVersion","didError","error","data"], "type": "object" diff --git a/src/snapshot-tests/__fixtures__/mcp/json/simulator-management/shutdown--error-invalid-id.json b/src/snapshot-tests/__fixtures__/mcp/json/simulator-management/shutdown--error-invalid-id.json new file mode 100644 index 000000000..db88e09ac --- /dev/null +++ b/src/snapshot-tests/__fixtures__/mcp/json/simulator-management/shutdown--error-invalid-id.json @@ -0,0 +1,25 @@ +{ + "schema": "mobilebuildmcp.output.simulator-action-result", + "schemaVersion": "3", + "didError": true, + "error": "Shut down simulator operation failed.", + "data": { + "summary": { + "status": "FAILED" + }, + "action": { + "type": "shutdown" + }, + "diagnostics": { + "warnings": [], + "errors": [ + { + "message": "Invalid device: " + } + ] + }, + "artifacts": { + "simulatorId": "" + } + } +} diff --git a/src/snapshot-tests/__fixtures__/mcp/json/simulator-management/shutdown--success.json b/src/snapshot-tests/__fixtures__/mcp/json/simulator-management/shutdown--success.json new file mode 100644 index 000000000..4381b3095 --- /dev/null +++ b/src/snapshot-tests/__fixtures__/mcp/json/simulator-management/shutdown--success.json @@ -0,0 +1,17 @@ +{ + "schema": "mobilebuildmcp.output.simulator-action-result", + "schemaVersion": "3", + "didError": false, + "error": null, + "data": { + "summary": { + "status": "SUCCEEDED" + }, + "action": { + "type": "shutdown" + }, + "artifacts": { + "simulatorId": "" + } + } +} diff --git a/src/snapshot-tests/__fixtures__/mcp/json/simulator-management/statusbar--error-invalid-simulator.json b/src/snapshot-tests/__fixtures__/mcp/json/simulator-management/statusbar--error-invalid-simulator.json index 3624ee417..b24ad114b 100644 --- a/src/snapshot-tests/__fixtures__/mcp/json/simulator-management/statusbar--error-invalid-simulator.json +++ b/src/snapshot-tests/__fixtures__/mcp/json/simulator-management/statusbar--error-invalid-simulator.json @@ -1,6 +1,6 @@ { "schema": "mobilebuildmcp.output.simulator-action-result", - "schemaVersion": "2", + "schemaVersion": "3", "didError": true, "error": "Failed to set status bar.", "data": { diff --git a/src/snapshot-tests/__fixtures__/mcp/json/simulator-management/statusbar--success-all-overrides.json b/src/snapshot-tests/__fixtures__/mcp/json/simulator-management/statusbar--success-all-overrides.json new file mode 100644 index 000000000..f9076fb72 --- /dev/null +++ b/src/snapshot-tests/__fixtures__/mcp/json/simulator-management/statusbar--success-all-overrides.json @@ -0,0 +1,25 @@ +{ + "schema": "mobilebuildmcp.output.simulator-action-result", + "schemaVersion": "3", + "didError": false, + "error": null, + "data": { + "summary": { + "status": "SUCCEEDED" + }, + "action": { + "type": "statusbar", + "time": "9:41", + "dataNetwork": "wifi", + "wifiMode": "active", + "wifiBars": 3, + "cellularMode": "active", + "cellularBars": 4, + "batteryState": "charged", + "batteryLevel": 100 + }, + "artifacts": { + "simulatorId": "" + } + } +} diff --git a/src/snapshot-tests/__fixtures__/mcp/json/simulator-management/statusbar--success.json b/src/snapshot-tests/__fixtures__/mcp/json/simulator-management/statusbar--success.json index 4e351dcbc..847b2bea9 100644 --- a/src/snapshot-tests/__fixtures__/mcp/json/simulator-management/statusbar--success.json +++ b/src/snapshot-tests/__fixtures__/mcp/json/simulator-management/statusbar--success.json @@ -1,6 +1,6 @@ { "schema": "mobilebuildmcp.output.simulator-action-result", - "schemaVersion": "2", + "schemaVersion": "3", "didError": false, "error": null, "data": { diff --git a/src/snapshot-tests/__fixtures__/mcp/text/simulator-management/shutdown--error-invalid-id.txt b/src/snapshot-tests/__fixtures__/mcp/text/simulator-management/shutdown--error-invalid-id.txt new file mode 100644 index 000000000..0d30df810 --- /dev/null +++ b/src/snapshot-tests/__fixtures__/mcp/text/simulator-management/shutdown--error-invalid-id.txt @@ -0,0 +1,8 @@ + +📱 Shut Down Simulator + +Errors (1): + + ✗ Invalid device: + +❌ Shut down simulator operation failed. diff --git a/src/snapshot-tests/__fixtures__/mcp/text/simulator-management/shutdown--success.txt b/src/snapshot-tests/__fixtures__/mcp/text/simulator-management/shutdown--success.txt new file mode 100644 index 000000000..30e59e92e --- /dev/null +++ b/src/snapshot-tests/__fixtures__/mcp/text/simulator-management/shutdown--success.txt @@ -0,0 +1,4 @@ + +📱 Shut Down Simulator + +✅ Simulator shut down successfully diff --git a/src/snapshot-tests/__fixtures__/mcp/text/simulator-management/statusbar--success-all-overrides.txt b/src/snapshot-tests/__fixtures__/mcp/text/simulator-management/statusbar--success-all-overrides.txt new file mode 100644 index 000000000..7ae518753 --- /dev/null +++ b/src/snapshot-tests/__fixtures__/mcp/text/simulator-management/statusbar--success-all-overrides.txt @@ -0,0 +1,4 @@ + +📱 Statusbar + +✅ Status bar overrides set successfully diff --git a/src/snapshot-tests/__fixtures__/mcp/text/simulator-management/statusbar--success.txt b/src/snapshot-tests/__fixtures__/mcp/text/simulator-management/statusbar--success.txt index c2b9efdf8..7ae518753 100644 --- a/src/snapshot-tests/__fixtures__/mcp/text/simulator-management/statusbar--success.txt +++ b/src/snapshot-tests/__fixtures__/mcp/text/simulator-management/statusbar--success.txt @@ -1,4 +1,4 @@ 📱 Statusbar -✅ Status bar data network set successfully +✅ Status bar overrides set successfully diff --git a/src/snapshot-tests/preflight/__tests__/simulator-state.test.ts b/src/snapshot-tests/preflight/__tests__/simulator-state.test.ts index 13b3b5970..7313f79f8 100644 --- a/src/snapshot-tests/preflight/__tests__/simulator-state.test.ts +++ b/src/snapshot-tests/preflight/__tests__/simulator-state.test.ts @@ -3,6 +3,7 @@ import { CleanupStack } from '../cleanup.ts'; import type { ExternalCommandResult, ExternalCommandRunner } from '../command-runner.ts'; import { ensureSimulatorShutdown, + prepareSimulatorBooted, prepareSimulatorShutdown, readSimulatorAppearance, } from '../simulator-state.ts'; @@ -64,6 +65,42 @@ describe('simulator state preflight', () => { expect(runner).toHaveBeenCalledTimes(3); }); + it('restores a simulator that was originally shut down', async () => { + const runner = vi + .fn() + .mockResolvedValueOnce(simulatorList('Shutdown')) + .mockResolvedValueOnce(simulatorList('Shutdown')) + .mockResolvedValueOnce(result()) + .mockResolvedValueOnce(result()) + .mockResolvedValueOnce(simulatorList('Booted')) + .mockResolvedValueOnce(result()) + .mockResolvedValueOnce(simulatorList('Shutdown')); + const cleanup = new CleanupStack(); + + await prepareSimulatorBooted('SIM-1', cleanup, runner); + await cleanup.cleanup(); + + expect(runner.mock.calls.map(([, args]) => args)).toEqual([ + ['simctl', 'list', 'devices', 'SIM-1', '--json'], + ['simctl', 'list', 'devices', 'SIM-1', '--json'], + ['simctl', 'boot', 'SIM-1'], + ['simctl', 'bootstatus', 'SIM-1', '-b'], + ['simctl', 'list', 'devices', 'SIM-1', '--json'], + ['simctl', 'shutdown', 'SIM-1'], + ['simctl', 'list', 'devices', 'available', '--json'], + ]); + }); + + it('leaves an originally booted simulator booted', async () => { + const runner = vi.fn().mockResolvedValue(simulatorList('Booted')); + const cleanup = new CleanupStack(); + + await prepareSimulatorBooted('SIM-1', cleanup, runner); + await cleanup.cleanup(); + + expect(runner).toHaveBeenCalledTimes(3); + }); + it('does not issue a redundant shutdown command', async () => { const runner = vi.fn().mockResolvedValue(simulatorList('Shutdown')); diff --git a/src/snapshot-tests/preflight/simulator-state.ts b/src/snapshot-tests/preflight/simulator-state.ts index 49404dd66..033399b1b 100644 --- a/src/snapshot-tests/preflight/simulator-state.ts +++ b/src/snapshot-tests/preflight/simulator-state.ts @@ -73,6 +73,18 @@ export async function prepareSimulatorShutdown( await setSimulatorBootState(simulatorId, 'Shutdown', runner); } +export async function prepareSimulatorBooted( + simulatorId: string, + cleanup: CleanupStack, + runner: ExternalCommandRunner = runExternalCommand, +): Promise { + const originalState = await readSimulatorBootState(simulatorId, runner); + cleanup.defer(`restore simulator ${simulatorId} boot state`, async () => { + await setSimulatorBootState(simulatorId, originalState, runner); + }); + await setSimulatorBootState(simulatorId, 'Booted', runner); +} + export async function ensureSimulatorShutdown( simulatorId: string, runner: ExternalCommandRunner = runExternalCommand, diff --git a/src/snapshot-tests/suites/simulator-management-suite.ts b/src/snapshot-tests/suites/simulator-management-suite.ts index 95322c48c..4b75bd977 100644 --- a/src/snapshot-tests/suites/simulator-management-suite.ts +++ b/src/snapshot-tests/suites/simulator-management-suite.ts @@ -5,6 +5,7 @@ import { runExternalCommandChecked } from '../preflight/command-runner.ts'; import { ensureSimulatorBooted } from '../preflight/simulator.ts'; import { ensureSimulatorShutdown, + prepareSimulatorBooted, prepareSimulatorShutdown, readSimulatorAppearance, setSimulatorAppearance, @@ -84,6 +85,27 @@ export function registerSimulatorManagementSnapshotSuite(runtime: SnapshotRuntim }); }); + describe('shutdown', () => { + it.runIf(DISPOSABLE_SIMULATOR_ID !== undefined)( + 'success', + async () => { + const simulatorId = DISPOSABLE_SIMULATOR_ID!; + await prepareSimulatorBooted(simulatorId, cleanup); + + const result = await harness.invoke('simulator-management', 'shutdown', { simulatorId }); + expectFixture(result, 'shutdown--success', 'success'); + }, + 60_000, + ); + + it('error - invalid id', async () => { + const result = await harness.invoke('simulator-management', 'shutdown', { + simulatorId: INVALID_SIMULATOR_ID, + }); + expectFixture(result, 'shutdown--error-invalid-id', 'error'); + }); + }); + describe('open', () => { it.runIf(RUN_FOREGROUND_SIMULATOR_SNAPSHOTS)('success', async () => { const foregroundHarness = await createHarnessForRuntime(runtime, { @@ -252,6 +274,28 @@ export function registerSimulatorManagementSnapshotSuite(runtime: SnapshotRuntim expectFixture(result, 'statusbar--success', 'success'); }); + it.runIf(DISPOSABLE_SIMULATOR_ID !== undefined)('success - all overrides', async () => { + const simulatorId = DISPOSABLE_SIMULATOR_ID!; + await ensureSimulatorBooted(simulatorId, cleanup); + await runExternalCommandChecked('xcrun', ['simctl', 'status_bar', simulatorId, 'clear']); + cleanup.defer(`clear status bar on simulator ${simulatorId}`, async () => { + await runExternalCommandChecked('xcrun', ['simctl', 'status_bar', simulatorId, 'clear']); + }); + + const result = await harness.invoke('simulator-management', 'statusbar', { + simulatorId, + time: '9:41', + dataNetwork: 'wifi', + wifiMode: 'active', + wifiBars: 3, + cellularMode: 'active', + cellularBars: 4, + batteryState: 'charged', + batteryLevel: 100, + }); + expectFixture(result, 'statusbar--success-all-overrides', 'success'); + }); + it('error - invalid simulator', async () => { const result = await harness.invoke('simulator-management', 'statusbar', { simulatorId: INVALID_SIMULATOR_ID, diff --git a/src/types/domain-results.ts b/src/types/domain-results.ts index cc035f6ed..e6c1c14e0 100644 --- a/src/types/domain-results.ts +++ b/src/types/domain-results.ts @@ -454,9 +454,19 @@ export interface SimulatorActionSetAppearance { type: 'set-appearance'; appearance: string; } +export interface SimulatorActionShutdown { + type: 'shutdown'; +} export interface SimulatorActionStatusbar { type: 'statusbar'; + time?: string; dataNetwork?: string; + wifiMode?: string; + wifiBars?: number; + cellularMode?: string; + cellularBars?: number; + batteryState?: string; + batteryLevel?: number; } export interface SimulatorActionToggleSoftwareKeyboard { type: 'toggle-software-keyboard'; @@ -471,6 +481,7 @@ export type SimulatorAction = | SimulatorActionResetLocation | SimulatorActionSetLocation | SimulatorActionSetAppearance + | SimulatorActionShutdown | SimulatorActionStatusbar | SimulatorActionToggleSoftwareKeyboard | SimulatorActionToggleConnectHardwareKeyboard; diff --git a/src/utils/renderers/domain-result-text.ts b/src/utils/renderers/domain-result-text.ts index 6428bc479..6c00c1686 100644 --- a/src/utils/renderers/domain-result-text.ts +++ b/src/utils/renderers/domain-result-text.ts @@ -2,6 +2,7 @@ import type { NextStep } from '../../types/common.ts'; import type { BasicDiagnostics, DebugThread, + SimulatorActionStatusbar, TestDiagnostics, ToolDomainResult, } from '../../types/domain-results.ts'; @@ -1423,6 +1424,23 @@ function createUiErrorItems(uiError?: UiAutomationRecoverableError): TextRendera return [createSection('Recovery', lines)]; } +function createStatusbarParams(action: SimulatorActionStatusbar): HeaderRenderItem['params'] { + const fields: Array<[label: string, value: string | number | undefined]> = [ + ['Time', action.time], + ['Data Network', action.dataNetwork], + ['Wi-Fi Mode', action.wifiMode], + ['Wi-Fi Bars', action.wifiBars], + ['Cellular Mode', action.cellularMode], + ['Cellular Bars', action.cellularBars], + ['Battery State', action.batteryState], + ['Battery Level', action.batteryLevel], + ]; + + return fields + .filter((field): field is [string, string | number] => field[1] !== undefined) + .map(([label, value]) => ({ label, value: String(value) })); +} + function createSimulatorActionItems( result: Extract, ): TextRenderableItem[] { @@ -1433,6 +1451,7 @@ function createSimulatorActionItems( 'reset-location': 'Reset Location', 'set-location': 'Set Location', 'set-appearance': 'Set Appearance', + shutdown: 'Shut Down Simulator', statusbar: 'Statusbar', 'toggle-software-keyboard': 'Toggle Software Keyboard', 'toggle-connect-hardware-keyboard': 'Toggle Connect Hardware Keyboard', @@ -1451,8 +1470,8 @@ function createSimulatorActionItems( if (result.action.type === 'set-appearance') { params.push({ label: 'Mode', value: result.action.appearance }); } - if (result.action.type === 'statusbar' && result.action.dataNetwork) { - params.push({ label: 'Data Network', value: result.action.dataNetwork }); + if (result.action.type === 'statusbar') { + params.push(...createStatusbarParams(result.action)); } const items: TextRenderableItem[] = [createHeader(titleMap[result.action.type], params)]; @@ -1466,7 +1485,11 @@ function createSimulatorActionItems( 'reset-location': 'Location successfully reset to default', 'set-location': 'Location set successfully', 'set-appearance': `Appearance successfully set to ${result.action.type === 'set-appearance' ? result.action.appearance : 'requested'} mode`, - statusbar: 'Status bar data network set successfully', + shutdown: 'Simulator shut down successfully', + statusbar: + result.action.type === 'statusbar' && result.action.dataNetwork === 'clear' + ? 'Status bar overrides cleared' + : 'Status bar overrides set successfully', 'toggle-software-keyboard': 'Sent Toggle Software Keyboard (Cmd+K)', 'toggle-connect-hardware-keyboard': 'Sent Connect Hardware Keyboard (Cmd+Shift+K)', }; diff --git a/src/utils/renderers/event-formatting.ts b/src/utils/renderers/event-formatting.ts index 180a9bf16..42a5f3eb4 100644 --- a/src/utils/renderers/event-formatting.ts +++ b/src/utils/renderers/event-formatting.ts @@ -46,6 +46,7 @@ export const OPERATION_EMOJI: Record = { 'File Coverage': '\u{1F4CA}', 'List Simulators': '\u{1F4F1}', 'Boot Simulator': '\u{1F4F1}', + 'Shut Down Simulator': '\u{1F4F1}', 'Open Simulator': '\u{1F4F1}', 'Set Appearance': '\u{1F3A8}', 'Set Location': '\u{1F4CD}',