diff --git a/src/security/audit-logger.ts b/src/security/audit-logger.ts index 606c03c2..22b87d75 100644 --- a/src/security/audit-logger.ts +++ b/src/security/audit-logger.ts @@ -50,10 +50,9 @@ const SENSITIVE_KEYS = [ 'privatekey', 'private_key', 'session', - // Free-form user input that flows through MCP tool calls (e.g. - // `type.text`, `select_option.value`) can carry passwords or OTPs, so - // redact these keys defensively even though they are not credentials - // by name. + // Free-form user input that flows through MCP tool calls (for example + // `type.text` and `select_option.value`) can carry passwords, OTPs, or + // other secrets even when the key is not credential-named. 'text', 'value', ]; @@ -230,7 +229,13 @@ function rotateLogIfNeeded(logPath: string, bytesToAppend: number): void { fs.chmodSync(rotatedPath, AUDIT_FILE_MODE); } -export function logAuditEntry(tool: string, sessionId: string, args: Record, pageUrl?: string): void { +export function logAuditEntry( + tool: string, + sessionId: string, + args: Record, + pageUrl?: string, + status?: AuditEntry['status'], +): void { const entry: AuditEntry = { timestamp: new Date().toISOString(), tool, @@ -250,4 +255,4 @@ export function logAuditEntry(tool: string, sessionId: string, args: Record }): Promise; +} + +/** Minimal device-lookup interface the app-manager functions depend on. */ +export interface AppManagerDeviceLookup { + getDevice(deviceId: string): Promise; +} + +/** + * Launch an app on a booted simulator. + * Returns the pid parsed from simctl output. + * Throws DeviceNotBootedError if the device is not booted. + * Throws AppNotInstalledError if the bundle is not installed. + * Throws AppLaunchError for any other launch failure. + */ +export async function launchApp( + deviceId: string, + bundleId: string, + options: { args?: string[]; env?: Record } | undefined, + deps: { simctl: AppManagerSimctl; lookup: AppManagerDeviceLookup }, +): Promise<{ pid: number; bundleId: string; deviceId: string }> { + const device = await deps.lookup.getDevice(deviceId); + if (!device || device.state !== 'Booted') { + throw new DeviceNotBootedError(deviceId); + } + + const cmdArgs = ['launch', deviceId, bundleId]; + if (options?.args) { + cmdArgs.push(...options.args); + } + + // simctl passes SIMCTL_CHILD_* env vars to the launched app + const childEnv: Record = {}; + if (options?.env) { + for (const [key, value] of Object.entries(options.env)) { + childEnv[`SIMCTL_CHILD_${key}`] = value; + } + } + + try { + const output = await deps.simctl.exec(cmdArgs, { env: childEnv }); + const pidMatch = output.match(/:\s*(\d+)/); + const pid = pidMatch ? parseInt(pidMatch[1], 10) : -1; + return { pid, bundleId, deviceId }; + } catch (err) { + if (err instanceof SimctlError) { + if (isAppNotInstalledError(err)) { + throw new AppNotInstalledError(bundleId, deviceId); + } + } + throw new AppLaunchError(bundleId, deviceId, err instanceof Error ? err.message : String(err)); + } +} + +/** + * Terminate a running app on a booted simulator. + * Returns terminated:false (not an error) when the app is not running. + * Throws DeviceNotBootedError if the device is not booted. + * Throws AppNotInstalledError if the bundle is not installed. + */ +export async function terminateApp( + deviceId: string, + bundleId: string, + deps: { simctl: AppManagerSimctl; lookup: AppManagerDeviceLookup }, +): Promise<{ terminated: boolean; bundleId: string; deviceId: string }> { + const device = await deps.lookup.getDevice(deviceId); + if (!device || device.state !== 'Booted') { + throw new DeviceNotBootedError(deviceId); + } + + try { + await deps.simctl.exec(['terminate', deviceId, bundleId]); + return { terminated: true, bundleId, deviceId }; + } catch (err) { + if (err instanceof SimctlError) { + if (err.message.includes('not running') || err.message.includes('Failed to terminate')) { + return { terminated: false, bundleId, deviceId }; + } + if (isAppNotInstalledError(err)) { + throw new AppNotInstalledError(bundleId, deviceId); + } + } + throw err; + } +} + +/** + * Activate (bring to foreground) an app on a booted simulator. + * simctl launch brings an already-running app to the foreground; + * if the app is not running it starts it. + * Throws DeviceNotBootedError if the device is not booted. + * Throws AppNotInstalledError if the bundle is not installed. + */ +export async function activateApp( + deviceId: string, + bundleId: string, + deps: { simctl: AppManagerSimctl; lookup: AppManagerDeviceLookup }, +): Promise<{ activated: boolean; bundleId: string; deviceId: string; pid: number }> { + const device = await deps.lookup.getDevice(deviceId); + if (!device || device.state !== 'Booted') { + throw new DeviceNotBootedError(deviceId); + } + + try { + const output = await deps.simctl.exec(['launch', deviceId, bundleId]); + const pidMatch = output.match(/:\s*(\d+)/); + const pid = pidMatch ? parseInt(pidMatch[1], 10) : -1; + return { activated: true, bundleId, deviceId, pid }; + } catch (err) { + if (err instanceof SimctlError) { + if (isAppNotInstalledError(err)) { + throw new AppNotInstalledError(bundleId, deviceId); + } + } + throw err; + } +} + +/** + * List running foreground apps by parsing launchctl list UIKitApplication entries. + * Throws DeviceNotBootedError if the device is not booted. + */ +export async function listRunningApps( + deviceId: string, + deps: { simctl: AppManagerSimctl; lookup: AppManagerDeviceLookup }, +): Promise> { + const device = await deps.lookup.getDevice(deviceId); + if (!device || device.state !== 'Booted') { + throw new DeviceNotBootedError(deviceId); + } + + const output = await deps.simctl.exec(['spawn', deviceId, 'launchctl', 'list']); + const lines = output.split('\n'); + const apps: Array<{ label: string; pid: number }> = []; + + for (const line of lines) { + const parts = line.split('\t'); + if (parts.length < 3) continue; + const pid = parseInt(parts[0], 10); + const label = parts[2]; + // Filter for UIKitApplication entries (running foreground apps) + if (!isNaN(pid) && pid > 0 && label.startsWith('UIKitApplication:')) { + const bundleId = label.replace('UIKitApplication:', '').replace(/(\[[^\]]*\])+$/, ''); + apps.push({ label: bundleId, pid }); + } + } + + return apps; +} + +/** + * Reset app state on a simulator. + * Strategy: terminate app, reset privacy permissions, clear app data container. + * Note: simctl has no "clear data" command; the documented strategy is + * uninstall + reinstall. We uninstall here; the caller can reinstall. + * Throws DeviceNotBootedError if the device is not booted. + * Throws AppNotInstalledError if uninstall confirms the bundle is not installed. + */ +export async function resetApp( + deviceId: string, + bundleId: string, + deps: { simctl: AppManagerSimctl; lookup: AppManagerDeviceLookup }, +): Promise<{ reset: boolean; bundleId: string; deviceId: string; steps: string[] }> { + const device = await deps.lookup.getDevice(deviceId); + if (!device || device.state !== 'Booted') { + throw new DeviceNotBootedError(deviceId); + } + + const steps: string[] = []; + + // Step 1: Terminate the app if running + try { + await deps.simctl.exec(['terminate', deviceId, bundleId]); + steps.push('terminated'); + } catch { + steps.push('terminate_skipped'); + } + + // Step 2: Reset privacy permissions + try { + await deps.simctl.exec(['privacy', deviceId, 'reset', 'all', bundleId]); + steps.push('privacy_reset'); + } catch { + steps.push('privacy_reset_skipped'); + } + + // Step 3: Uninstall and note (cannot clear data container directly) + try { + await deps.simctl.exec(['uninstall', deviceId, bundleId]); + steps.push('uninstalled'); + } catch (err) { + if (err instanceof SimctlError) { + if (isAppNotInstalledError(err)) { + throw new AppNotInstalledError(bundleId, deviceId); + } + } + steps.push('uninstall_failed'); + } + + return { reset: true, bundleId, deviceId, steps }; +} diff --git a/src/simulator/device-catalog.ts b/src/simulator/device-catalog.ts new file mode 100644 index 00000000..b7ef9e04 --- /dev/null +++ b/src/simulator/device-catalog.ts @@ -0,0 +1,94 @@ +import { SimctlExecutor } from './simctl'; +import { SimulatorDevice, SimulatorRuntime } from './types'; +import { DEVICE_PRESETS } from './presets'; +import { DeviceNotFoundError } from './errors'; + +interface SimctlListResult { + // `simctl list devices -j` returns only `devices`; `simctl list runtimes -j` + // returns only `runtimes`. Treat both as optional so a missing key is not a crash. + devices?: Record>; + runtimes?: Array<{ + identifier: string; + version: string; + isAvailable: boolean; + platform: string; + }>; +} + +export async function listDevices(simctl: SimctlExecutor): Promise { + const result = await simctl.execJson(['list', 'devices']); + const devices: SimulatorDevice[] = []; + + for (const [runtimeId, deviceList] of Object.entries(result.devices ?? {})) { + const version = runtimeId.match(/iOS-(\d+)-(\d+)/); + const runtimeVersion = version ? `${version[1]}.${version[2]}` : 'unknown'; + + for (const device of deviceList) { + if (device.isAvailable) { + devices.push({ + udid: device.udid, + name: device.name, + state: device.state as SimulatorDevice['state'], + isAvailable: device.isAvailable, + runtime: runtimeId, + runtimeVersion, + }); + } + } + } + + return devices; +} + +export async function listRuntimes(simctl: SimctlExecutor): Promise { + const result = await simctl.execJson(['list', 'runtimes']); + return (result.runtimes ?? []).filter(r => r.isAvailable); +} + +export async function getDevice(simctl: SimctlExecutor, deviceId: string): Promise { + const devices = await listDevices(simctl); + return devices.find(d => d.udid === deviceId) ?? null; +} + +/** + * Resolve a preset key or device name to an actual device. + * Tries: exact UDID match → preset name match → fuzzy name match + */ +export async function resolveDevice(simctl: SimctlExecutor, presetKey: string): Promise { + const devices = await listDevices(simctl); + + // 1. Exact UDID match + const byUdid = devices.find(d => d.udid === presetKey); + if (byUdid) return byUdid; + + // 2. Preset name match + const preset = DEVICE_PRESETS[presetKey]; + if (preset) { + const exact = devices.find(d => d.name === preset.name); + if (exact) return exact; + } + + // 3. Case-insensitive substring match + const lower = presetKey.toLowerCase(); + const substring = devices.find(d => d.name.toLowerCase().includes(lower)); + if (substring) return substring; + + // 4. Fuzzy: split words, match all keywords. Filter empty tokens so that + // whitespace/separator-only input doesn't reduce to `every(kw => …includes(""))`, + // which would silently match the first available device. + const keywords = lower.split(/[\s-]+/).filter(Boolean); + if (keywords.length > 0) { + const fuzzy = devices.find(d => { + const dLower = d.name.toLowerCase(); + return keywords.every(kw => dLower.includes(kw)); + }); + if (fuzzy) return fuzzy; + } + + throw new DeviceNotFoundError(presetKey, devices.map(d => d.name)); +} diff --git a/src/simulator/errors.ts b/src/simulator/errors.ts new file mode 100644 index 00000000..312075dd --- /dev/null +++ b/src/simulator/errors.ts @@ -0,0 +1,65 @@ +export class BootTimeoutError extends Error { + constructor( + public readonly deviceId: string, + public readonly deviceName: string, + public readonly timeoutMs: number, + ) { + super(`Simulator boot timeout: "${deviceName}" (${deviceId}) did not boot within ${timeoutMs}ms`); + this.name = 'BootTimeoutError'; + } +} + +export class ShutdownTimeoutError extends Error { + constructor( + public readonly deviceId: string, + public readonly timeoutMs: number, + ) { + super(`Simulator shutdown timeout: ${deviceId} did not shutdown within ${timeoutMs}ms`); + this.name = 'ShutdownTimeoutError'; + } +} + +export class DeviceNotFoundError extends Error { + constructor( + public readonly requested: string, + public readonly available: string[], + ) { + super(`Device not found: "${requested}". Available: ${available.slice(0, 5).join(', ')}${available.length > 5 ? '...' : ''}`); + this.name = 'DeviceNotFoundError'; + } +} + +export class DeviceNotBootedError extends Error { + constructor(public readonly deviceId: string) { + super(`Device ${deviceId} is not booted. Call boot() first.`); + this.name = 'DeviceNotBootedError'; + } +} + +export class ScreenshotTimeoutError extends Error { + constructor(public readonly deviceId: string) { + super(`Screenshot capture timed out for device ${deviceId}`); + this.name = 'ScreenshotTimeoutError'; + } +} + +export class AppNotInstalledError extends Error { + constructor( + public readonly bundleId: string, + public readonly deviceId: string, + ) { + super(`App "${bundleId}" is not installed on device ${deviceId}`); + this.name = 'AppNotInstalledError'; + } +} + +export class AppLaunchError extends Error { + constructor( + public readonly bundleId: string, + public readonly deviceId: string, + public readonly reason: string, + ) { + super(`Failed to launch "${bundleId}" on device ${deviceId}: ${reason}`); + this.name = 'AppLaunchError'; + } +} diff --git a/src/simulator/lifecycle.ts b/src/simulator/lifecycle.ts new file mode 100644 index 00000000..3c018a08 --- /dev/null +++ b/src/simulator/lifecycle.ts @@ -0,0 +1,245 @@ +/** + * Simulator lifecycle operations — boot, shutdown, erase, delete, clone. + * + * Extracted from SimulatorManager as part of #708 (step 3). + * All functions take injected dependencies for testability. + * + * Behavior is strictly preserved from the original manager: + * - Boot: poll every 1 s until device.state === 'Booted' or timeout + * (throws BootTimeoutError on timeout) + * - Shutdown: bounded poll, retry once, then erase (nuclear) as a + * best-effort cleanup. Returns on success; propagates SimctlError if + * erase itself fails. Does NOT throw on the timeout-then-erased path. + * - Erase/delete/clone: pass-through to simctl; explicit caller intent required + */ + +import { SimulatorDevice } from './types'; +import { BootTimeoutError, DeviceNotFoundError } from './errors'; +import { + DEFAULT_SIMULATOR_BOOT_TIMEOUT_MS, + DEFAULT_SIMULATOR_SHUTDOWN_TIMEOUT_MS, +} from '../config/defaults'; + +/** Minimal simctl interface the lifecycle functions depend on. */ +export interface LifecycleSimctl { + exec(args: string[], options?: { timeout?: number }): Promise; +} + +/** Minimal device-lookup interface the lifecycle functions depend on. */ +export interface LifecycleDeviceLookup { + getDevice(deviceId: string): Promise; + resolveDevice(presetOrId: string): Promise; + /** + * Optional fast state-only read used by polling loops when available. + * Falls back to {@link getDevice} when absent. When present, callers may + * pass `bypassCache: true` to force a live read (e.g. to observe the + * transient `ShuttingDown` state during graceful shutdown polling). + */ + getDeviceState?( + deviceId: string, + opts?: { bypassCache?: boolean }, + ): Promise; +} + +/** + * Boot a simulator identified by preset key or UDID. + * Returns immediately if already booted. + * Throws BootTimeoutError if the device does not reach state 'Booted' within + * the configured timeout. + */ +export async function boot( + presetOrId: string, + deps: { + simctl: LifecycleSimctl; + lookup: LifecycleDeviceLookup; + sleep?: (ms: number) => Promise; + bootTimeoutMs?: number; + pollIntervalMs?: number; + /** + * Optional callback invoked after every state-mutating simctl call + * (boot) so the caller can flush any short-lived state cache. + */ + invalidateCache?: (udid: string) => void; + }, +): Promise { + const { + simctl, + lookup, + sleep = (ms: number) => new Promise(r => setTimeout(r, ms)), + bootTimeoutMs = DEFAULT_SIMULATOR_BOOT_TIMEOUT_MS, + pollIntervalMs = 1000, + invalidateCache, + } = deps; + + const device = await lookup.resolveDevice(presetOrId); + + // Already booted — return immediately + if (device.state === 'Booted') { + return device; + } + + await simctl.exec(['boot', device.udid]); + // Boot mutates state — flush any cached `Shutdown` reading so the polling + // loop below observes fresh data on the very first tick. + invalidateCache?.(device.udid); + + const start = Date.now(); + while (Date.now() - start < bootTimeoutMs) { + // Prefer the narrow state-only read when the lookup exposes it; fall back + // to a full getDevice call otherwise (preserves the legacy single-method + // contract used by older lifecycle tests). + let state: SimulatorDevice['state'] | null; + if (lookup.getDeviceState) { + state = await lookup.getDeviceState(device.udid); + } else { + const current = await lookup.getDevice(device.udid); + state = current?.state ?? null; + } + + if (state === 'Booted') { + // Fetch full metadata once for the caller. If the lookup returns null + // the device was removed between the state read and this fetch (race + // condition). Returning a stale snapshot here would be silently wrong, + // so throw a clear error instead. + const current = await lookup.getDevice(device.udid); + if (!current) { + throw new DeviceNotFoundError(device.udid, []); + } + return current; + } + await sleep(pollIntervalMs); + } + + throw new BootTimeoutError(device.udid, device.name, bootTimeoutMs); +} + +/** + * Shut down a simulator by UDID. + * Returns immediately if the device is already shut down. + * + * Strategy (preserving original manager behavior): + * 1. Issue `simctl shutdown` (graceful). + * 2. Poll up to `shutdownTimeoutMs` for state === 'Shutdown'. + * 3. If still booted: retry shutdown, wait 5 s, re-check. + * 4. If still booted after retry: erase the device (nuclear) and return. + * + * This is a best-effort cleanup contract. Erase failures propagate as + * `SimctlError` so callers can distinguish "cleaned up" from "couldn't + * even erase". The orchestration of telling callers a timeout occurred is + * left to higher-level tooling — the previous design that re-threw a + * `ShutdownTimeoutError` after a successful erase turned this best-effort + * teardown into a hard failure for MCP callers like `device_shutdown` and + * was reverted. + */ +export async function shutdown( + deviceId: string, + deps: { + simctl: LifecycleSimctl; + lookup: LifecycleDeviceLookup; + sleep?: (ms: number) => Promise; + shutdownTimeoutMs?: number; + /** + * Optional callback invoked after every state-mutating simctl call + * (shutdown / erase) so the caller can flush any short-lived state cache. + */ + invalidateCache?: (udid: string) => void; + }, +): Promise { + const { + simctl, + lookup, + sleep = (ms: number) => new Promise(r => setTimeout(r, ms)), + shutdownTimeoutMs = DEFAULT_SIMULATOR_SHUTDOWN_TIMEOUT_MS, + invalidateCache, + } = deps; + + // Pre-check: read live state. When the lookup exposes a state-only read, + // bypass any short-lived cache so the transient `ShuttingDown` state is + // observable on every poll tick (a cache hit could mask it). + const initialState = lookup.getDeviceState + ? await lookup.getDeviceState(deviceId, { bypassCache: true }) + : (await lookup.getDevice(deviceId))?.state ?? null; + if (!initialState || initialState === 'Shutdown') { + return; + } + + // Graceful shutdown + try { + await simctl.exec(['shutdown', deviceId]); + } catch { + // May already be shutting down — continue polling + } + invalidateCache?.(deviceId); + + // Poll until shutdown. Always bypass cache so ShuttingDown is visible on + // every tick. + const start = Date.now(); + while (Date.now() - start < shutdownTimeoutMs) { + const state = lookup.getDeviceState + ? await lookup.getDeviceState(deviceId, { bypassCache: true }) + : (await lookup.getDevice(deviceId))?.state ?? null; + if (!state || state === 'Shutdown') { + return; + } + await sleep(1000); + } + + // Retry shutdown once + try { + await simctl.exec(['shutdown', deviceId]); + invalidateCache?.(deviceId); + await sleep(5000); + const state = lookup.getDeviceState + ? await lookup.getDeviceState(deviceId, { bypassCache: true }) + : (await lookup.getDevice(deviceId))?.state ?? null; + if (!state || state === 'Shutdown') { + return; + } + } catch { + // Fall through to nuclear erase + } + + // Nuclear option — erase device (WARNING: deletes all data). + // Best-effort cleanup: matches the pre-refactor `SimulatorManager.shutdown` + // contract (return on a successful erase, propagate `SimctlError` if erase + // itself fails). Re-throwing on the success path turned a clean teardown + // into a hard failure for MCP callers and was a behavior regression. + console.error(`[lifecycle] Force erasing device ${deviceId} after shutdown timeout`); + await simctl.exec(['erase', deviceId]); + invalidateCache?.(deviceId); +} + +/** + * Erase a simulator, resetting it to factory state. + * Requires explicit caller intent — not called implicitly. + */ +export async function eraseDevice( + deviceId: string, + deps: { simctl: LifecycleSimctl }, +): Promise { + await deps.simctl.exec(['erase', deviceId]); +} + +/** + * Delete a simulator permanently. + * Requires explicit caller intent — not called implicitly. + */ +export async function deleteDevice( + deviceId: string, + deps: { simctl: LifecycleSimctl }, +): Promise { + await deps.simctl.exec(['delete', deviceId]); +} + +/** + * Clone a simulator by UDID, returning the new device UDID. + * Note: simctl clone requires the source device to be Shutdown. + */ +export async function cloneDevice( + deviceId: string, + cloneName: string, + deps: { simctl: LifecycleSimctl }, +): Promise { + const output = await deps.simctl.exec(['clone', deviceId, cloneName]); + return output.trim(); +} diff --git a/src/simulator/manager.ts b/src/simulator/manager.ts index 7613c85b..f9a5aed9 100644 --- a/src/simulator/manager.ts +++ b/src/simulator/manager.ts @@ -1,40 +1,54 @@ -import * as fs from 'fs/promises'; -import * as os from 'os'; -import * as path from 'path'; -import { randomUUID } from 'crypto'; -import { execFile } from 'child_process'; -import { promisify } from 'util'; import { SimctlExecutor, SimctlError, SimulatorStateCache } from './simctl'; import { SimulatorDevice, SimulatorRuntime } from './types'; -import { DEVICE_PRESETS } from './presets'; -import { DEFAULT_SIMULATOR_BOOT_TIMEOUT_MS, DEFAULT_SIMULATOR_SHUTDOWN_TIMEOUT_MS, DEFAULT_SCREENSHOT_TIMEOUT_MS } from '../config/defaults'; - -interface SimctlListResult { - devices: Record>; - runtimes: Array<{ - identifier: string; - version: string; - isAvailable: boolean; - platform: string; - }>; -} - -export interface RotationResult { - success: boolean; - method: 'simctl' | 'applescript' | 'none'; - orientation?: string; -} +import { + listDevices as catalogListDevices, + listRuntimes as catalogListRuntimes, + resolveDevice as catalogResolveDevice, +} from './device-catalog'; +import { + boot as lifecycleBoot, + shutdown as lifecycleShutdown, + deleteDevice as lifecycleDeleteDevice, + cloneDevice as lifecycleCloneDevice, +} from './lifecycle'; +import { + launchApp as appManagerLaunchApp, + terminateApp as appManagerTerminateApp, + activateApp as appManagerActivateApp, + listRunningApps as appManagerListRunningApps, + resetApp as appManagerResetApp, +} from './app-manager'; +import { + screenshot as uiScreenshot, + screenshotBase64 as uiScreenshotBase64, + setAppearance as uiSetAppearance, + getAppearance as uiGetAppearance, + toggleAppearance as uiToggleAppearance, + rotate as uiRotate, + overrideStatusBar as uiOverrideStatusBar, + openUrl as uiOpenUrl, + type RotationResult, +} from './ui-controller'; + +// Re-export error classes for backward compatibility — callers should migrate to ./errors +export { + BootTimeoutError, + ShutdownTimeoutError, + DeviceNotFoundError, + DeviceNotBootedError, + ScreenshotTimeoutError, + AppNotInstalledError, + AppLaunchError, +} from './errors'; + +// Re-export RotationResult from ui-controller for backward compatibility +export type { RotationResult } from './ui-controller'; /** * TTL for the per-device state cache inside SimulatorManager. - * Sized to match the longest polling interval used in this file (1 000 ms) so - * that multiple callers within a single tick share one `simctl list devices` - * parse, but the next tick always sees fresh data. + * Sized to match the longest polling interval used in the lifecycle module + * (1000 ms) so that multiple callers within a single tick share one + * `simctl list devices` parse, but the next tick always sees fresh data. */ const STATE_CACHE_TTL_MS = 900; @@ -47,33 +61,11 @@ export class SimulatorManager { private stateCache = new SimulatorStateCache(STATE_CACHE_TTL_MS); async listDevices(): Promise { - const result = await this.simctl.execJson(['list', 'devices']); - const devices: SimulatorDevice[] = []; - - for (const [runtimeId, deviceList] of Object.entries(result.devices)) { - const version = runtimeId.match(/iOS-(\d+)-(\d+)/); - const runtimeVersion = version ? `${version[1]}.${version[2]}` : 'unknown'; - - for (const device of deviceList) { - if (device.isAvailable) { - devices.push({ - udid: device.udid, - name: device.name, - state: device.state as SimulatorDevice['state'], - isAvailable: device.isAvailable, - runtime: runtimeId, - runtimeVersion, - }); - } - } - } - - return devices; + return catalogListDevices(this.simctl); } async listRuntimes(): Promise { - const result = await this.simctl.execJson(['list', 'runtimes']); - return result.runtimes.filter(r => r.isAvailable); + return catalogListRuntimes(this.simctl); } async listBooted(): Promise { @@ -168,33 +160,7 @@ export class SimulatorManager { * Tries: exact UDID match → preset name match → fuzzy name match */ async resolveDevice(presetKey: string): Promise { - const devices = await this.listDevices(); - - // 1. Exact UDID match - const byUdid = devices.find(d => d.udid === presetKey); - if (byUdid) return byUdid; - - // 2. Preset name match - const preset = DEVICE_PRESETS[presetKey]; - if (preset) { - const exact = devices.find(d => d.name === preset.name); - if (exact) return exact; - } - - // 3. Case-insensitive substring match - const lower = presetKey.toLowerCase(); - const substring = devices.find(d => d.name.toLowerCase().includes(lower)); - if (substring) return substring; - - // 4. Fuzzy: split words, match all keywords - const keywords = lower.split(/[\s-]+/); - const fuzzy = devices.find(d => { - const dLower = d.name.toLowerCase(); - return keywords.every(kw => dLower.includes(kw)); - }); - if (fuzzy) return fuzzy; - - throw new DeviceNotFoundError(presetKey, devices.map(d => d.name)); + return catalogResolveDevice(this.simctl, presetKey); } async checkRuntimes(): Promise<{ installed: SimulatorRuntime[]; issues: string[]; suggestions: string[] }> { @@ -212,140 +178,39 @@ export class SimulatorManager { } async boot(presetOrId: string, options?: { timeout?: number }): Promise { - const device = await this.resolveDevice(presetOrId); - - // Already booted — return immediately - if (device.state === 'Booted') { - return device; - } - - // Boot — invalidate cache so the polling loop sees fresh state - await this.simctl.exec(['boot', device.udid]); - this.stateCache.invalidate(device.udid); - - // Poll until booted or timeout. - // Uses getDeviceState() which hits the TTL cache on repeated ticks and - // falls back to the per-UDID `simctl list devices -j` read. - const timeout = options?.timeout ?? DEFAULT_SIMULATOR_BOOT_TIMEOUT_MS; - const start = Date.now(); - const pollInterval = 1000; - - while (Date.now() - start < timeout) { - const state = await this.getDeviceState(device.udid); - if (state === 'Booted') { - // Fetch full metadata once for the caller. - // If the lookup returns null the device was removed between the state - // read and this fetch (race condition). Returning a stale Shutdown - // snapshot here would be silently wrong, so throw a clear error instead. - const current = await this.getDevice(device.udid); - if (current === null || current === undefined) { - throw new DeviceNotFoundError( - device.udid, - [], - ); - } - return current; - } - await new Promise(r => setTimeout(r, pollInterval)); - } - - throw new BootTimeoutError(device.udid, device.name, timeout); + return lifecycleBoot(presetOrId, { + simctl: this.simctl, + lookup: this, + bootTimeoutMs: options?.timeout, + invalidateCache: udid => this.stateCache.invalidate(udid), + }); } async shutdown(deviceId: string, options?: { timeout?: number }): Promise { - // Use bypassCache: true so we always read live state and can observe the - // transient ShuttingDown state (a cache hit might mask it). - const state = await this.getDeviceState(deviceId, { bypassCache: true }); - if (!state || state === 'Shutdown') { - return; // Already shut down or device not found - } - - // Graceful shutdown — invalidate cache so polling sees fresh state - try { - await this.simctl.exec(['shutdown', deviceId]); - } catch { - // May already be shutting down - } - this.stateCache.invalidate(deviceId); - - // Poll until shutdown or timeout. - // Always bypasses cache so ShuttingDown is visible on every tick. - const timeout = options?.timeout ?? DEFAULT_SIMULATOR_SHUTDOWN_TIMEOUT_MS; - const start = Date.now(); - - while (Date.now() - start < timeout) { - const current = await this.getDeviceState(deviceId, { bypassCache: true }); - if (!current || current === 'Shutdown') { - return; - } - await new Promise(r => setTimeout(r, 1000)); - } - - // Timeout reached — retry shutdown once before escalating - try { - await this.simctl.exec(['shutdown', deviceId]); - this.stateCache.invalidate(deviceId); - await new Promise(r => setTimeout(r, 5000)); - const current = await this.getDeviceState(deviceId, { bypassCache: true }); - if (!current || current === 'Shutdown') return; - } catch { - // Fall through to erase - } - - // Nuclear option — erase device (WARNING: deletes all data) - console.error(`[SimulatorManager] Force erasing device ${deviceId} after shutdown timeout`); - await this.simctl.exec(['erase', deviceId]); - this.stateCache.invalidate(deviceId); + return lifecycleShutdown(deviceId, { + simctl: this.simctl, + lookup: this, + shutdownTimeoutMs: options?.timeout, + invalidateCache: udid => this.stateCache.invalidate(udid), + }); } async bootPreset(presetKey: string): Promise { return this.boot(presetKey); } + // Business logic lives in ./ui-controller async openUrl(deviceId: string, url: string): Promise { - // Validate URL - try { - new URL(url); - } catch { - throw new Error(`Invalid URL: ${url}`); - } - - // Check device is booted - const device = await this.getDevice(deviceId); - if (!device || device.state !== 'Booted') { - throw new DeviceNotBootedError(deviceId); - } - - await this.simctl.exec(['openurl', deviceId, url]); - // Brief wait for Safari to start processing - await new Promise(r => setTimeout(r, 1000)); + return uiOpenUrl(deviceId, url, { simctl: this.simctl, lookup: this }); } + // Business logic lives in ./ui-controller async screenshot(deviceId: string, options?: { format?: 'png' | 'jpeg' }): Promise { - const device = await this.getDevice(deviceId); - if (!device || device.state !== 'Booted') { - throw new DeviceNotBootedError(deviceId); - } - - const format = options?.format ?? 'png'; - const tmpFile = path.join(os.tmpdir(), `opensafari-screenshot-${randomUUID()}.${format}`); - - try { - await this.simctl.exec( - ['io', deviceId, 'screenshot', `--type=${format}`, tmpFile], - { timeout: DEFAULT_SCREENSHOT_TIMEOUT_MS } - ); - const buffer = await fs.readFile(tmpFile); - return buffer; - } finally { - // Cleanup temp file - await fs.unlink(tmpFile).catch(() => {}); - } + return uiScreenshot(deviceId, options, { simctl: this.simctl, lookup: this }); } async screenshotBase64(deviceId: string, options?: { format?: 'png' | 'jpeg' }): Promise { - const buffer = await this.screenshot(deviceId, options); - return buffer.toString('base64'); + return uiScreenshotBase64(deviceId, options, { simctl: this.simctl, lookup: this }); } // === App Lifecycle === @@ -355,107 +220,31 @@ export class SimulatorManager { bundleId: string, options?: { args?: string[]; env?: Record }, ): Promise<{ pid: number; bundleId: string; deviceId: string }> { - const device = await this.getDevice(deviceId); - if (!device || device.state !== 'Booted') { - throw new DeviceNotBootedError(deviceId); - } - - const cmdArgs = ['launch', deviceId, bundleId]; - if (options?.args) { - cmdArgs.push(...options.args); - } - - // simctl passes SIMCTL_CHILD_* env vars to the launched app - const childEnv: Record = {}; - if (options?.env) { - for (const [key, value] of Object.entries(options.env)) { - childEnv[`SIMCTL_CHILD_${key}`] = value; - } - } - - try { - const output = await this.simctl.exec(cmdArgs, { env: childEnv }); - const pidMatch = output.match(/:\s*(\d+)/); - const pid = pidMatch ? parseInt(pidMatch[1], 10) : -1; - return { pid, bundleId, deviceId }; - } catch (err) { - if (err instanceof SimctlError) { - if (err.message.includes('domain not found') || err.message.includes('not installed')) { - throw new AppNotInstalledError(bundleId, deviceId); - } - } - throw new AppLaunchError(bundleId, deviceId, err instanceof Error ? err.message : String(err)); - } + return appManagerLaunchApp(deviceId, bundleId, options, { + simctl: this.simctl, + lookup: this, + }); } async terminateApp(deviceId: string, bundleId: string): Promise<{ terminated: boolean; bundleId: string; deviceId: string }> { - const device = await this.getDevice(deviceId); - if (!device || device.state !== 'Booted') { - throw new DeviceNotBootedError(deviceId); - } - - try { - await this.simctl.exec(['terminate', deviceId, bundleId]); - return { terminated: true, bundleId, deviceId }; - } catch (err) { - if (err instanceof SimctlError) { - if (err.message.includes('not running') || err.message.includes('Failed to terminate')) { - return { terminated: false, bundleId, deviceId }; - } - if (err.message.includes('domain not found') || err.message.includes('not installed')) { - throw new AppNotInstalledError(bundleId, deviceId); - } - } - throw err; - } + return appManagerTerminateApp(deviceId, bundleId, { + simctl: this.simctl, + lookup: this, + }); } async activateApp(deviceId: string, bundleId: string): Promise<{ activated: boolean; bundleId: string; deviceId: string; pid: number }> { - const device = await this.getDevice(deviceId); - if (!device || device.state !== 'Booted') { - throw new DeviceNotBootedError(deviceId); - } - - // simctl launch brings an already-running app to the foreground; - // if the app is not running it starts it. - try { - const output = await this.simctl.exec(['launch', deviceId, bundleId]); - const pidMatch = output.match(/:\s*(\d+)/); - const pid = pidMatch ? parseInt(pidMatch[1], 10) : -1; - return { activated: true, bundleId, deviceId, pid }; - } catch (err) { - if (err instanceof SimctlError) { - if (err.message.includes('domain not found') || err.message.includes('not installed')) { - throw new AppNotInstalledError(bundleId, deviceId); - } - } - throw err; - } + return appManagerActivateApp(deviceId, bundleId, { + simctl: this.simctl, + lookup: this, + }); } async listRunningApps(deviceId: string): Promise> { - const device = await this.getDevice(deviceId); - if (!device || device.state !== 'Booted') { - throw new DeviceNotBootedError(deviceId); - } - - const output = await this.simctl.exec(['spawn', deviceId, 'launchctl', 'list']); - const lines = output.split('\n'); - const apps: Array<{ label: string; pid: number }> = []; - - for (const line of lines) { - const parts = line.split('\t'); - if (parts.length < 3) continue; - const pid = parseInt(parts[0], 10); - const label = parts[2]; - // Filter for UIKitApplication entries (running foreground apps) - if (!isNaN(pid) && pid > 0 && label.startsWith('UIKitApplication:')) { - const bundleId = label.replace('UIKitApplication:', '').replace(/\[.*\]$/, ''); - apps.push({ label: bundleId, pid }); - } - } - - return apps; + return appManagerListRunningApps(deviceId, { + simctl: this.simctl, + lookup: this, + }); } /** @@ -463,45 +252,10 @@ export class SimulatorManager { * Strategy: terminate app, reset privacy permissions, clear app data container. */ async resetApp(deviceId: string, bundleId: string): Promise<{ reset: boolean; bundleId: string; deviceId: string; steps: string[] }> { - const device = await this.getDevice(deviceId); - if (!device || device.state !== 'Booted') { - throw new DeviceNotBootedError(deviceId); - } - - const steps: string[] = []; - - // Step 1: Terminate the app if running - try { - await this.simctl.exec(['terminate', deviceId, bundleId]); - steps.push('terminated'); - } catch { - steps.push('terminate_skipped'); - } - - // Step 2: Reset privacy permissions - try { - await this.simctl.exec(['privacy', deviceId, 'reset', 'all', bundleId]); - steps.push('privacy_reset'); - } catch { - steps.push('privacy_reset_skipped'); - } - - // Step 3: Uninstall and note (cannot clear data container directly) - // simctl has no "clear data" command; the documented strategy is - // uninstall + reinstall. We uninstall here; the caller can reinstall. - try { - await this.simctl.exec(['uninstall', deviceId, bundleId]); - steps.push('uninstalled'); - } catch (err) { - if (err instanceof SimctlError) { - if (err.message.includes('domain not found') || err.message.includes('not installed')) { - throw new AppNotInstalledError(bundleId, deviceId); - } - } - steps.push('uninstall_failed'); - } - - return { reset: true, bundleId, deviceId, steps }; + return appManagerResetApp(deviceId, bundleId, { + simctl: this.simctl, + lookup: this, + }); } // Expose simctl for direct use by other methods @@ -509,68 +263,24 @@ export class SimulatorManager { return this.simctl; } - // === Appearance (Dark/Light Mode) === + // === Appearance (Dark/Light Mode) — business logic in ./ui-controller === async setAppearance(deviceId: string, mode: 'light' | 'dark'): Promise { - const device = await this.getDevice(deviceId); - if (!device || device.state !== 'Booted') { - throw new DeviceNotBootedError(deviceId); - } - await this.simctl.exec(['ui', deviceId, 'appearance', mode]); + return uiSetAppearance(deviceId, mode, { simctl: this.simctl, lookup: this }); } async getAppearance(deviceId: string): Promise<'light' | 'dark'> { - const device = await this.getDevice(deviceId); - if (!device || device.state !== 'Booted') { - throw new DeviceNotBootedError(deviceId); - } - const output = await this.simctl.exec(['ui', deviceId, 'appearance']); - return output.trim().toLowerCase() === 'dark' ? 'dark' : 'light'; + return uiGetAppearance(deviceId, { simctl: this.simctl, lookup: this }); } async toggleAppearance(deviceId: string): Promise<'light' | 'dark'> { - const current = await this.getAppearance(deviceId); - const next = current === 'light' ? 'dark' : 'light'; - await this.setAppearance(deviceId, next); - return next; + return uiToggleAppearance(deviceId, { simctl: this.simctl, lookup: this }); } - // === Rotation === - // Method A: simctl io setorientation (works in headless/CI) - // Method B: AppleScript (requires Simulator.app GUI) + // === Rotation — business logic in ./ui-controller === async rotate(deviceId: string, direction: 'left' | 'right' = 'left'): Promise { - const device = await this.getDevice(deviceId); - if (!device || device.state !== 'Booted') { - throw new DeviceNotBootedError(deviceId); - } - - const orientation = direction === 'left' ? 'landscapeLeft' : 'landscapeRight'; - - // Try simctl first (works in headless/CI) - try { - const execFileAsync = promisify(execFile); - await execFileAsync('xcrun', ['simctl', 'io', deviceId, 'setorientation', orientation], { timeout: 10000 }); - return { success: true, method: 'simctl', orientation }; - } catch { - console.error('[SimulatorManager] simctl setorientation not available, trying AppleScript'); - } - - // Fallback to AppleScript (requires GUI) - try { - const execFileAsync = promisify(execFile); - const menuItem = direction === 'left' ? 'Rotate Left' : 'Rotate Right'; - await execFileAsync('osascript', [ - '-e', 'tell application "Simulator" to activate', - '-e', 'delay 0.5', - '-e', `tell application "System Events" to tell process "Simulator" to click menu item "${menuItem}" of menu "Device" of menu bar 1`, - ], { timeout: 10000 }); - return { success: true, method: 'applescript', orientation }; - } catch { - console.error('[SimulatorManager] Rotation via AppleScript also failed — no rotation method available'); - } - - return { success: false, method: 'none' }; + return uiRotate(deviceId, direction, { simctl: this.simctl, lookup: this }); } // === Device Clone (state persistence alternative) === @@ -578,94 +288,17 @@ export class SimulatorManager { // simctl clone creates a full device copy with a new UDID. async cloneDevice(deviceId: string, cloneName: string): Promise { - const output = await this.simctl.exec(['clone', deviceId, cloneName]); - // simctl clone returns the new device UDID - return output.trim(); + return lifecycleCloneDevice(deviceId, cloneName, { simctl: this.simctl }); } async deleteDevice(deviceId: string): Promise { - await this.simctl.exec(['delete', deviceId]); + await lifecycleDeleteDevice(deviceId, { simctl: this.simctl }); this.stateCache.invalidate(deviceId); } - // === Status Bar Override (for deterministic screenshots) === + // === Status Bar Override — business logic in ./ui-controller === async overrideStatusBar(deviceId: string): Promise { - const device = await this.getDevice(deviceId); - if (!device || device.state !== 'Booted') { - throw new DeviceNotBootedError(deviceId); - } - await this.simctl.exec([ - 'status_bar', deviceId, 'override', - '--time', '9:41', - '--batteryLevel', '100', - '--cellularBars', '4', - ]); - } -} - -export class BootTimeoutError extends Error { - constructor( - public readonly deviceId: string, - public readonly deviceName: string, - public readonly timeoutMs: number, - ) { - super(`Simulator boot timeout: "${deviceName}" (${deviceId}) did not boot within ${timeoutMs}ms`); - this.name = 'BootTimeoutError'; - } -} - -export class ShutdownTimeoutError extends Error { - constructor( - public readonly deviceId: string, - public readonly timeoutMs: number, - ) { - super(`Simulator shutdown timeout: ${deviceId} did not shutdown within ${timeoutMs}ms`); - this.name = 'ShutdownTimeoutError'; - } -} - -export class DeviceNotFoundError extends Error { - constructor( - public readonly requested: string, - public readonly available: string[], - ) { - super(`Device not found: "${requested}". Available: ${available.slice(0, 5).join(', ')}${available.length > 5 ? '...' : ''}`); - this.name = 'DeviceNotFoundError'; - } -} - -export class DeviceNotBootedError extends Error { - constructor(public readonly deviceId: string) { - super(`Device ${deviceId} is not booted. Call boot() first.`); - this.name = 'DeviceNotBootedError'; - } -} - -export class ScreenshotTimeoutError extends Error { - constructor(public readonly deviceId: string) { - super(`Screenshot capture timed out for device ${deviceId}`); - this.name = 'ScreenshotTimeoutError'; - } -} - -export class AppNotInstalledError extends Error { - constructor( - public readonly bundleId: string, - public readonly deviceId: string, - ) { - super(`App "${bundleId}" is not installed on device ${deviceId}`); - this.name = 'AppNotInstalledError'; - } -} - -export class AppLaunchError extends Error { - constructor( - public readonly bundleId: string, - public readonly deviceId: string, - public readonly reason: string, - ) { - super(`Failed to launch "${bundleId}" on device ${deviceId}: ${reason}`); - this.name = 'AppLaunchError'; + return uiOverrideStatusBar(deviceId, { simctl: this.simctl, lookup: this }); } } diff --git a/src/simulator/ui-controller.ts b/src/simulator/ui-controller.ts new file mode 100644 index 00000000..3613c7d8 --- /dev/null +++ b/src/simulator/ui-controller.ts @@ -0,0 +1,226 @@ +/** + * UI / display operations for the iOS Simulator. + * + * Extracted from SimulatorManager as part of #708 (step 5 — final). + * All functions take injected dependencies for testability. + * + * Behavior is strictly preserved from the original manager: + * - screenshot: captures via `simctl io screenshot`; no transient retry here — + * the transient-retry pattern (PR #658) lives in src/tools/app-screenshot-native.ts + * which wraps this at the MCP tool layer. + * - screenshotBase64: thin wrapper that base64-encodes the screenshot buffer. + * - setAppearance / getAppearance / toggleAppearance: delegate to `simctl ui appearance`. + * - rotate: tries `simctl io setorientation` first; falls back to AppleScript GUI. + * - overrideStatusBar: sets deterministic status bar values via `simctl status_bar`. + * - openUrl: validates URL, checks device is booted, calls `simctl openurl`. + */ + +import * as fs from 'fs/promises'; +import * as os from 'os'; +import * as path from 'path'; +import { randomUUID } from 'crypto'; +import { execFile } from 'child_process'; +import { promisify } from 'util'; +import { DeviceNotBootedError } from './errors'; +import { SimulatorDevice } from './types'; +import { DEFAULT_SCREENSHOT_TIMEOUT_MS } from '../config/defaults'; + +const execFileAsync = promisify(execFile); + +/** Minimal simctl interface the UI-controller functions depend on. */ +export interface UiControllerSimctl { + exec(args: string[], options?: { timeout?: number; env?: Record }): Promise; +} + +/** Minimal device-lookup interface the UI-controller functions depend on. */ +export interface UiControllerDeviceLookup { + getDevice(deviceId: string): Promise; +} + +export interface RotationResult { + success: boolean; + method: 'simctl' | 'applescript' | 'none'; + orientation?: string; +} + +/** + * Capture a screenshot of a booted simulator. + * Writes to a temp file via `simctl io screenshot`, reads the buffer, then cleans up. + * Throws DeviceNotBootedError if the device is not booted. + */ +export async function screenshot( + deviceId: string, + options: { format?: 'png' | 'jpeg' } | undefined, + deps: { simctl: UiControllerSimctl; lookup: UiControllerDeviceLookup }, +): Promise { + const device = await deps.lookup.getDevice(deviceId); + if (!device || device.state !== 'Booted') { + throw new DeviceNotBootedError(deviceId); + } + + const format = options?.format ?? 'png'; + const tmpFile = path.join(os.tmpdir(), `opensafari-screenshot-${randomUUID()}.${format}`); + + try { + await deps.simctl.exec( + ['io', deviceId, 'screenshot', `--type=${format}`, tmpFile], + { timeout: DEFAULT_SCREENSHOT_TIMEOUT_MS }, + ); + const buffer = await fs.readFile(tmpFile); + return buffer; + } finally { + await fs.unlink(tmpFile).catch(() => {}); + } +} + +/** + * Capture a screenshot and return it as a base64-encoded string. + * Throws DeviceNotBootedError if the device is not booted. + */ +export async function screenshotBase64( + deviceId: string, + options: { format?: 'png' | 'jpeg' } | undefined, + deps: { simctl: UiControllerSimctl; lookup: UiControllerDeviceLookup }, +): Promise { + const buf = await screenshot(deviceId, options, deps); + return buf.toString('base64'); +} + +/** + * Set the appearance (light/dark mode) of a booted simulator. + * Throws DeviceNotBootedError if the device is not booted. + */ +export async function setAppearance( + deviceId: string, + mode: 'light' | 'dark', + deps: { simctl: UiControllerSimctl; lookup: UiControllerDeviceLookup }, +): Promise { + const device = await deps.lookup.getDevice(deviceId); + if (!device || device.state !== 'Booted') { + throw new DeviceNotBootedError(deviceId); + } + await deps.simctl.exec(['ui', deviceId, 'appearance', mode]); +} + +/** + * Get the current appearance (light/dark mode) of a booted simulator. + * Throws DeviceNotBootedError if the device is not booted. + */ +export async function getAppearance( + deviceId: string, + deps: { simctl: UiControllerSimctl; lookup: UiControllerDeviceLookup }, +): Promise<'light' | 'dark'> { + const device = await deps.lookup.getDevice(deviceId); + if (!device || device.state !== 'Booted') { + throw new DeviceNotBootedError(deviceId); + } + const output = await deps.simctl.exec(['ui', deviceId, 'appearance']); + return output.trim().toLowerCase() === 'dark' ? 'dark' : 'light'; +} + +/** + * Toggle the appearance between light and dark. + * Returns the new appearance value. + * Throws DeviceNotBootedError if the device is not booted. + */ +export async function toggleAppearance( + deviceId: string, + deps: { simctl: UiControllerSimctl; lookup: UiControllerDeviceLookup }, +): Promise<'light' | 'dark'> { + const current = await getAppearance(deviceId, deps); + const next = current === 'light' ? 'dark' : 'light'; + await setAppearance(deviceId, next, deps); + return next; +} + +/** + * Rotate a booted simulator. + * Method A: `simctl io setorientation` (works in headless/CI). + * Method B: AppleScript (requires Simulator.app GUI). + * Returns a RotationResult describing which method succeeded (or 'none'). + * Throws DeviceNotBootedError if the device is not booted. + */ +export async function rotate( + deviceId: string, + direction: 'left' | 'right', + deps: { simctl: UiControllerSimctl; lookup: UiControllerDeviceLookup }, +): Promise { + const device = await deps.lookup.getDevice(deviceId); + if (!device || device.state !== 'Booted') { + throw new DeviceNotBootedError(deviceId); + } + + const orientation = direction === 'left' ? 'landscapeLeft' : 'landscapeRight'; + + // Try simctl first (works in headless/CI) + try { + await deps.simctl.exec(['io', deviceId, 'setorientation', orientation], { timeout: 10000 }); + return { success: true, method: 'simctl', orientation }; + } catch { + console.error('[UiController] simctl setorientation not available, trying AppleScript'); + } + + // Fallback to AppleScript (requires GUI) + try { + const menuItem = direction === 'left' ? 'Rotate Left' : 'Rotate Right'; + await execFileAsync('osascript', [ + '-e', 'tell application "Simulator" to activate', + '-e', 'delay 0.5', + '-e', `tell application "System Events" to tell process "Simulator" to click menu item "${menuItem}" of menu "Device" of menu bar 1`, + ], { timeout: 10000 }); + return { success: true, method: 'applescript', orientation }; + } catch { + console.error('[UiController] Rotation via AppleScript also failed — no rotation method available'); + } + + return { success: false, method: 'none' }; +} + +/** + * Override the status bar to deterministic values (useful for screenshots). + * Sets: time=9:41, batteryLevel=100, cellularBars=4. + * Throws DeviceNotBootedError if the device is not booted. + */ +export async function overrideStatusBar( + deviceId: string, + deps: { simctl: UiControllerSimctl; lookup: UiControllerDeviceLookup }, +): Promise { + const device = await deps.lookup.getDevice(deviceId); + if (!device || device.state !== 'Booted') { + throw new DeviceNotBootedError(deviceId); + } + await deps.simctl.exec([ + 'status_bar', deviceId, 'override', + '--time', '9:41', + '--batteryLevel', '100', + '--cellularBars', '4', + ]); +} + +/** + * Open a URL in Safari on a booted simulator. + * Validates the URL, checks the device is booted, then calls `simctl openurl`. + * Waits briefly for Safari to start processing. + * Throws an Error for invalid URLs. + * Throws DeviceNotBootedError if the device is not booted. + */ +export async function openUrl( + deviceId: string, + url: string, + deps: { simctl: UiControllerSimctl; lookup: UiControllerDeviceLookup }, +): Promise { + try { + new URL(url); + } catch { + throw new Error(`Invalid URL: ${url}`); + } + + const device = await deps.lookup.getDevice(deviceId); + if (!device || device.state !== 'Booted') { + throw new DeviceNotBootedError(deviceId); + } + + await deps.simctl.exec(['openurl', deviceId, url]); + // Brief wait for Safari to start processing + await new Promise(r => setTimeout(r, 1000)); +} diff --git a/tests/unit/audit-logger.test.ts b/tests/unit/audit-logger.test.ts index f1706f16..7d3033fb 100644 --- a/tests/unit/audit-logger.test.ts +++ b/tests/unit/audit-logger.test.ts @@ -132,6 +132,27 @@ describe('audit logger', () => { }); }); + it('redacts free-form MCP text/value fields defensively', () => { + logAuditEntry('type', 'session-freeform', { + selector: '#password', + text: 'typed-secret-password', + nested: { + value: 'selected-secret-option', + label: 'safe label', + }, + }); + + const [entry] = readAuditEntries(tmpHome); + const summary = parseArgsSummary(entry); + const serialized = JSON.stringify(summary); + + expect(serialized).not.toContain('typed-secret-password'); + expect(serialized).not.toContain('selected-secret-option'); + expect(summary.text).toBe('[REDACTED]'); + expect((summary.nested as Record).value).toBe('[REDACTED]'); + expect((summary.nested as Record).label).toBe('safe label'); + }); + it('retries log target setup after a transient initialization error', () => { // Point the logger at a path under a parent that doesn't yet exist and // can't be created (a regular file, so mkdir(recursive) will EEXIST/ENOTDIR). diff --git a/tests/unit/auth-tools-verification.test.ts b/tests/unit/auth-tools-verification.test.ts index b2ec6166..88e29b32 100644 --- a/tests/unit/auth-tools-verification.test.ts +++ b/tests/unit/auth-tools-verification.test.ts @@ -147,7 +147,7 @@ describe('Auth Tools Verification — Issue #168', () => { server = new MCPServer(); registerAllTools(server); server.setTier(3); // Make tier 3 tools visible - await server.start({ transport: 'http', port: PORT, httpInsecure: true }); + await server.start({ transport: 'http', port: PORT, httpInsecure: true, httpHighRiskTools: true }); }); afterAll(async () => { diff --git a/tests/unit/device-catalog.test.ts b/tests/unit/device-catalog.test.ts new file mode 100644 index 00000000..9ca2ef5f --- /dev/null +++ b/tests/unit/device-catalog.test.ts @@ -0,0 +1,165 @@ +import { listDevices, listRuntimes, getDevice, resolveDevice } from '../../src/simulator/device-catalog'; +import { DeviceNotFoundError } from '../../src/simulator/errors'; +import { SimctlExecutor } from '../../src/simulator/simctl'; + +const RUNTIME_KEY = 'com.apple.CoreSimulator.SimRuntime.iOS-18-0'; + +const DEVICE_A = { + udid: 'AAAA-1111', + name: 'iPhone 17', + state: 'Booted', + isAvailable: true, +}; + +const DEVICE_B = { + udid: 'BBBB-2222', + name: 'iPhone SE (3rd generation)', + state: 'Shutdown', + isAvailable: true, +}; + +const DEVICE_UNAVAILABLE = { + udid: 'CCCC-3333', + name: 'iPhone 15', + state: 'Shutdown', + isAvailable: false, +}; + +function makeSimctlMock(devicesPayload: Record): SimctlExecutor { + return { + execJson: jest.fn().mockResolvedValue({ devices: devicesPayload, runtimes: [] }), + exec: jest.fn(), + } as unknown as SimctlExecutor; +} + +describe('device-catalog', () => { + describe('listDevices', () => { + it('returns available devices with parsed runtimeVersion', async () => { + const simctl = makeSimctlMock({ [RUNTIME_KEY]: [DEVICE_A, DEVICE_B, DEVICE_UNAVAILABLE] }); + const devices = await listDevices(simctl); + + expect(devices).toHaveLength(2); + expect(devices[0].udid).toBe('AAAA-1111'); + expect(devices[0].runtimeVersion).toBe('18.0'); + expect(devices[0].state).toBe('Booted'); + expect(devices[1].udid).toBe('BBBB-2222'); + }); + + it('excludes unavailable devices', async () => { + const simctl = makeSimctlMock({ [RUNTIME_KEY]: [DEVICE_UNAVAILABLE] }); + const devices = await listDevices(simctl); + expect(devices).toHaveLength(0); + }); + + it('handles unknown runtime identifier gracefully', async () => { + const simctl = makeSimctlMock({ 'com.apple.CoreSimulator.SimRuntime.watchOS-10-0': [DEVICE_A] }); + const devices = await listDevices(simctl); + expect(devices[0].runtimeVersion).toBe('unknown'); + }); + + it('flattens devices across multiple runtimes', async () => { + const simctl = makeSimctlMock({ + 'com.apple.CoreSimulator.SimRuntime.iOS-17-0': [DEVICE_A], + 'com.apple.CoreSimulator.SimRuntime.iOS-18-0': [DEVICE_B], + }); + const devices = await listDevices(simctl); + expect(devices).toHaveLength(2); + }); + + it('returns an empty list when simctl omits the devices key', async () => { + const simctl = { + execJson: jest.fn().mockResolvedValue({}), + exec: jest.fn(), + } as unknown as SimctlExecutor; + + await expect(listDevices(simctl)).resolves.toEqual([]); + }); + }); + + describe('listRuntimes', () => { + it('returns only available runtimes', async () => { + const simctl = { + execJson: jest.fn().mockResolvedValue({ + runtimes: [ + { identifier: 'ios-available', version: '18.0', isAvailable: true, platform: 'iOS' }, + { identifier: 'ios-missing', version: '17.0', isAvailable: false, platform: 'iOS' }, + ], + }), + exec: jest.fn(), + } as unknown as SimctlExecutor; + + await expect(listRuntimes(simctl)).resolves.toEqual([ + { identifier: 'ios-available', version: '18.0', isAvailable: true, platform: 'iOS' }, + ]); + }); + + it('returns an empty list when simctl omits the runtimes key', async () => { + const simctl = { + execJson: jest.fn().mockResolvedValue({}), + exec: jest.fn(), + } as unknown as SimctlExecutor; + + await expect(listRuntimes(simctl)).resolves.toEqual([]); + }); + }); + + describe('getDevice', () => { + it('returns device when udid matches', async () => { + const simctl = makeSimctlMock({ [RUNTIME_KEY]: [DEVICE_A, DEVICE_B] }); + const device = await getDevice(simctl, 'AAAA-1111'); + expect(device).not.toBeNull(); + expect(device!.udid).toBe('AAAA-1111'); + expect(device!.name).toBe('iPhone 17'); + }); + + it('returns null for unknown udid', async () => { + const simctl = makeSimctlMock({ [RUNTIME_KEY]: [DEVICE_A] }); + const device = await getDevice(simctl, 'XXXX-9999'); + expect(device).toBeNull(); + }); + }); + + describe('resolveDevice', () => { + it('resolves by exact udid', async () => { + const simctl = makeSimctlMock({ [RUNTIME_KEY]: [DEVICE_A, DEVICE_B] }); + const device = await resolveDevice(simctl, 'AAAA-1111'); + expect(device.udid).toBe('AAAA-1111'); + }); + + it('resolves by preset key (iphone-se-3)', async () => { + const simctl = makeSimctlMock({ [RUNTIME_KEY]: [DEVICE_A, DEVICE_B] }); + // DEVICE_B name matches preset 'iphone-se-3' ("iPhone SE (3rd generation)") + const device = await resolveDevice(simctl, 'iphone-se-3'); + expect(device.udid).toBe('BBBB-2222'); + }); + + it('resolves by case-insensitive substring', async () => { + const simctl = makeSimctlMock({ [RUNTIME_KEY]: [DEVICE_A, DEVICE_B] }); + const device = await resolveDevice(simctl, 'iphone 17'); + expect(device.udid).toBe('AAAA-1111'); + }); + + it('throws DeviceNotFoundError for unknown key', async () => { + const simctl = makeSimctlMock({ [RUNTIME_KEY]: [DEVICE_A] }); + await expect(resolveDevice(simctl, 'iphone-99-pro-max')).rejects.toBeInstanceOf(DeviceNotFoundError); + }); + + it('DeviceNotFoundError includes available device names', async () => { + const simctl = makeSimctlMock({ [RUNTIME_KEY]: [DEVICE_A] }); + let caught: DeviceNotFoundError | null = null; + try { + await resolveDevice(simctl, 'iphone-99-pro-max'); + } catch (err) { + caught = err as DeviceNotFoundError; + } + expect(caught).not.toBeNull(); + expect(caught!.available).toContain('iPhone 17'); + expect(caught!.requested).toBe('iphone-99-pro-max'); + }); + + it('does not fuzzy-match separator-only input to the first device', async () => { + const simctl = makeSimctlMock({ [RUNTIME_KEY]: [DEVICE_A] }); + await expect(resolveDevice(simctl, ' -- - ')).rejects.toBeInstanceOf(DeviceNotFoundError); + }); + }); +}); diff --git a/tests/unit/flutter-evaluate.test.ts b/tests/unit/flutter-evaluate.test.ts index 05dd744f..83c64065 100644 --- a/tests/unit/flutter-evaluate.test.ts +++ b/tests/unit/flutter-evaluate.test.ts @@ -204,7 +204,7 @@ describe('flutter_evaluate handler', () => { const auditCall = spy.mock.calls.find((c) => String(c[0]).includes('audit')); expect(auditCall).toBeDefined(); - expect(String(auditCall?.[0])).toContain('ref.read(authProvider)'); + expect(String(auditCall?.[0])).toContain('len=22'); spy.mockRestore(); }); diff --git a/tests/unit/http-high-risk-tools.test.ts b/tests/unit/http-high-risk-tools.test.ts index 90b72cb8..377296dd 100644 --- a/tests/unit/http-high-risk-tools.test.ts +++ b/tests/unit/http-high-risk-tools.test.ts @@ -66,10 +66,9 @@ describe('HTTP high-risk MCP tool gate', () => { delete process.env.OPENSAFARI_HTTP_ENABLE_HIGH_RISK_TOOLS; auditLines = []; mkdirSpy = jest.spyOn(fs, 'mkdirSync').mockImplementation(() => undefined as unknown as string); - appendSpy = jest.spyOn(fs, 'appendFile').mockImplementation(((_path, data, cb) => { + appendSpy = jest.spyOn(fs, 'appendFileSync').mockImplementation(((_path, data) => { auditLines.push(String(data)); - if (typeof cb === 'function') cb(null); - }) as typeof fs.appendFile); + }) as typeof fs.appendFileSync); }); afterEach(() => { @@ -164,8 +163,8 @@ describe('HTTP high-risk MCP tool gate', () => { name: 'javascript', arguments: { expression: 'document.querySelector("button")?.textContent', - text: 'ordinary text is kept', - value: 'ordinary value is kept', + text: 'secret typed text', + value: 'secret selected value', password: 'secret-password', accessToken: 'secret-token', authorization: 'Bearer secret-auth', @@ -189,8 +188,8 @@ describe('HTTP high-risk MCP tool gate', () => { expect(audit.status).toBe('allowed'); const summary = JSON.parse(audit.args_summary as string) as Record; expect(summary.expression).toBe('document.querySelector("button")?.textContent'); - expect(summary.text).toBe('ordinary text is kept'); - expect(summary.value).toBe('ordinary value is kept'); + expect(summary.text).toBe('[REDACTED]'); + expect(summary.value).toBe('[REDACTED]'); expect(summary.password).toBe('[REDACTED]'); expect(summary.accessToken).toBe('[REDACTED]'); expect(summary.authorization).toBe('[REDACTED]'); @@ -198,6 +197,8 @@ describe('HTTP high-risk MCP tool gate', () => { expect((summary.nested as Record).cookieValue).toBe('[REDACTED]'); expect((summary.nested as Record).safe).toBe('kept'); expect(auditLines[0]).not.toContain('secret-password'); + expect(auditLines[0]).not.toContain('secret typed text'); + expect(auditLines[0]).not.toContain('secret selected value'); expect(auditLines[0]).not.toContain('secret-token'); expect(auditLines[0]).not.toContain('secret-auth'); expect(auditLines[0]).not.toContain('secret-session'); diff --git a/tests/unit/issue-167-wire-verification.test.ts b/tests/unit/issue-167-wire-verification.test.ts index 5bb0102e..3b8450ef 100644 --- a/tests/unit/issue-167-wire-verification.test.ts +++ b/tests/unit/issue-167-wire-verification.test.ts @@ -131,7 +131,7 @@ describe('Issue #167: CLI serve wires orchestration subsystems', () => { setupGracefulShutdown(pool); - await server.start({ transport: 'http', port: PORT, httpInsecure: true }); + await server.start({ transport: 'http', port: PORT, httpInsecure: true, httpHighRiskTools: true }); }); afterAll(async () => { diff --git a/tests/unit/mcp-server.test.ts b/tests/unit/mcp-server.test.ts index 5caf4931..1b376f92 100644 --- a/tests/unit/mcp-server.test.ts +++ b/tests/unit/mcp-server.test.ts @@ -320,7 +320,7 @@ describe('MCPServer — JSON-RPC protocol', () => { }); freshServer.setTier(3); - await freshServer.start({ transport: 'http', port: 19399 }); + await freshServer.start({ transport: 'http', port: 19399, httpInsecure: true }); const res = await mcpPost(19399, { jsonrpc: '2.0', id: 1, method: 'tools/list', params: {} }); await freshServer.stop(); diff --git a/tests/unit/rotation-fallback.test.ts b/tests/unit/rotation-fallback.test.ts index 74c3caa7..c8992b09 100644 --- a/tests/unit/rotation-fallback.test.ts +++ b/tests/unit/rotation-fallback.test.ts @@ -86,9 +86,9 @@ describe('SimulatorManager.rotate()', () => { }); it('should return RotationResult with correct fields on simctl success', async () => { - // Mock execFile to succeed on first call (simctl) - mockExecFile.mockImplementation((_cmd: string, _args: string[], _opts: unknown, cb?: (err: Error | null) => void) => { - if (cb) cb(null); + // SimctlExecutor.exec() uses promisify(execFile) which requires cb(null, { stdout, stderr }) + mockExecFile.mockImplementation((_cmd: string, _args: string[], _opts: unknown, cb?: (err: Error | null, result?: { stdout: string; stderr: string }) => void) => { + if (cb) cb(null, { stdout: '', stderr: '' }); }); const result = await manager.rotate('test-device-id', 'left'); @@ -101,27 +101,27 @@ describe('SimulatorManager.rotate()', () => { it('should try simctl before AppleScript', async () => { const calls: string[] = []; - mockExecFile.mockImplementation((cmd: string, _args: string[], _opts: unknown, cb?: (err: Error | null) => void) => { + mockExecFile.mockImplementation((cmd: string, _args: string[], _opts: unknown, cb?: (err: Error | null, result?: { stdout: string; stderr: string }) => void) => { calls.push(cmd); - if (cb) cb(null); + if (cb) cb(null, { stdout: '', stderr: '' }); }); await manager.rotate('test-device-id', 'left'); - // simctl is called via xcrun, which should be the first call + // simctl is called via SimctlExecutor which uses xcrun expect(calls[0]).toBe('xcrun'); }); it('should fall back to AppleScript when simctl fails', async () => { let callCount = 0; - mockExecFile.mockImplementation((cmd: string, _args: string[], _opts: unknown, cb?: (err: Error | null) => void) => { + mockExecFile.mockImplementation((cmd: string, _args: string[], _opts: unknown, cb?: (err: Error | null, result?: { stdout: string; stderr: string }) => void) => { callCount++; if (callCount === 1) { - // simctl fails + // simctl (xcrun) fails if (cb) cb(new Error('simctl setorientation not supported')); } else { - // AppleScript succeeds - if (cb) cb(null); + // AppleScript (osascript) succeeds + if (cb) cb(null, { stdout: '', stderr: '' }); } }); @@ -148,31 +148,33 @@ describe('SimulatorManager.rotate()', () => { it('should use landscapeLeft for direction "left"', async () => { let capturedArgs: string[] = []; - mockExecFile.mockImplementation((_cmd: string, args: string[], _opts: unknown, cb?: (err: Error | null) => void) => { + mockExecFile.mockImplementation((_cmd: string, args: string[], _opts: unknown, cb?: (err: Error | null, result?: { stdout: string; stderr: string }) => void) => { capturedArgs = args; - if (cb) cb(null); + if (cb) cb(null, { stdout: '', stderr: '' }); }); const result = await manager.rotate('test-device-id', 'left'); expect(result.orientation).toBe('landscapeLeft'); + // SimctlExecutor wraps args as ['simctl', 'io', deviceId, 'setorientation', orientation] expect(capturedArgs).toContain('landscapeLeft'); }); it('should use landscapeRight for direction "right"', async () => { let capturedArgs: string[] = []; - mockExecFile.mockImplementation((_cmd: string, args: string[], _opts: unknown, cb?: (err: Error | null) => void) => { + mockExecFile.mockImplementation((_cmd: string, args: string[], _opts: unknown, cb?: (err: Error | null, result?: { stdout: string; stderr: string }) => void) => { capturedArgs = args; - if (cb) cb(null); + if (cb) cb(null, { stdout: '', stderr: '' }); }); const result = await manager.rotate('test-device-id', 'right'); expect(result.orientation).toBe('landscapeRight'); + // SimctlExecutor wraps args as ['simctl', 'io', deviceId, 'setorientation', orientation] expect(capturedArgs).toContain('landscapeRight'); }); it('should default direction to "left"', async () => { - mockExecFile.mockImplementation((_cmd: string, _args: string[], _opts: unknown, cb?: (err: Error | null) => void) => { - if (cb) cb(null); + mockExecFile.mockImplementation((_cmd: string, _args: string[], _opts: unknown, cb?: (err: Error | null, result?: { stdout: string; stderr: string }) => void) => { + if (cb) cb(null, { stdout: '', stderr: '' }); }); const result = await manager.rotate('test-device-id'); diff --git a/tests/unit/simulator-app-manager.test.ts b/tests/unit/simulator-app-manager.test.ts new file mode 100644 index 00000000..123552e3 --- /dev/null +++ b/tests/unit/simulator-app-manager.test.ts @@ -0,0 +1,464 @@ +/** + * Unit tests for src/simulator/app-manager.ts — #708 step 4. + * + * Covers: + * - launchApp happy path + * - launchApp passes args and env + * - launchApp DeviceNotBootedError for shutdown device + * - launchApp AppNotInstalledError surfaces + * - launchApp AppLaunchError surfaces for other failures + * - terminateApp happy path + * - terminateApp returns terminated:false when not running + * - terminateApp DeviceNotBootedError for shutdown device + * - terminateApp AppNotInstalledError surfaces + * - activateApp happy path + * - activateApp AppNotInstalledError surfaces + * - listRunningApps parses UIKitApplication entries + * - resetApp happy path (terminate + privacy + uninstall) + * - resetApp AppNotInstalledError when uninstall fails with domain not found + */ + +import { + launchApp, + terminateApp, + activateApp, + listRunningApps, + resetApp, +} from '../../src/simulator/app-manager'; +import { AppNotInstalledError, AppLaunchError, DeviceNotBootedError } from '../../src/simulator/errors'; +import { SimctlError } from '../../src/simulator/simctl'; +import { SimulatorDevice } from '../../src/simulator/types'; + +// ── helpers ────────────────────────────────────────────────────────────────── + +const DEVICE_ID = '11111111-1111-1111-1111-111111111111'; +const BUNDLE_ID = 'com.example.testapp'; + +function makeDevice(overrides: Partial = {}): SimulatorDevice { + return { + udid: DEVICE_ID, + name: 'iPhone Test', + state: 'Booted', + isAvailable: true, + runtime: 'com.apple.CoreSimulator.SimRuntime.iOS-18-0', + runtimeVersion: '18.0', + ...overrides, + }; +} + +function makeSimctl(execImpl?: (args: string[], options?: unknown) => Promise) { + const calls: Array<{ args: string[]; options?: unknown }> = []; + const exec = jest.fn(async (args: string[], options?: unknown) => { + calls.push({ args, options }); + return execImpl ? execImpl(args, options) : ''; + }); + return { exec, calls }; +} + +function makeLookup(device: SimulatorDevice | null) { + return { + getDevice: jest.fn(async () => device), + }; +} + +// ── launchApp ──────────────────────────────────────────────────────────────── + +describe('app-manager.launchApp', () => { + it('launches app and returns pid', async () => { + const simctl = makeSimctl(async () => `${BUNDLE_ID}: 12345\n`); + const lookup = makeLookup(makeDevice()); + + const result = await launchApp(DEVICE_ID, BUNDLE_ID, undefined, { simctl, lookup }); + + expect(result).toEqual({ pid: 12345, bundleId: BUNDLE_ID, deviceId: DEVICE_ID }); + expect(simctl.exec).toHaveBeenCalledWith( + ['launch', DEVICE_ID, BUNDLE_ID], + expect.objectContaining({}), + ); + }); + + it('returns pid -1 when output has no pid match', async () => { + const simctl = makeSimctl(async () => 'no pid here\n'); + const lookup = makeLookup(makeDevice()); + + const result = await launchApp(DEVICE_ID, BUNDLE_ID, undefined, { simctl, lookup }); + + expect(result.pid).toBe(-1); + }); + + it('passes launch arguments', async () => { + const simctl = makeSimctl(async () => `${BUNDLE_ID}: 99\n`); + const lookup = makeLookup(makeDevice()); + + await launchApp(DEVICE_ID, BUNDLE_ID, { args: ['--reset', '--verbose'] }, { simctl, lookup }); + + expect(simctl.exec).toHaveBeenCalledWith( + ['launch', DEVICE_ID, BUNDLE_ID, '--reset', '--verbose'], + expect.objectContaining({}), + ); + }); + + it('passes environment variables via SIMCTL_CHILD_ prefix', async () => { + const simctl = makeSimctl(async () => `${BUNDLE_ID}: 99\n`); + const lookup = makeLookup(makeDevice()); + + await launchApp(DEVICE_ID, BUNDLE_ID, { env: { DEBUG: '1', FOO: 'bar' } }, { simctl, lookup }); + + expect(simctl.exec).toHaveBeenCalledWith( + ['launch', DEVICE_ID, BUNDLE_ID], + expect.objectContaining({ env: { SIMCTL_CHILD_DEBUG: '1', SIMCTL_CHILD_FOO: 'bar' } }), + ); + }); + + it('throws DeviceNotBootedError for shutdown device', async () => { + const simctl = makeSimctl(); + const lookup = makeLookup(makeDevice({ state: 'Shutdown' })); + + await expect(launchApp(DEVICE_ID, BUNDLE_ID, undefined, { simctl, lookup })) + .rejects.toThrow(DeviceNotBootedError); + expect(simctl.exec).not.toHaveBeenCalled(); + }); + + it('throws DeviceNotBootedError when device is null', async () => { + const simctl = makeSimctl(); + const lookup = makeLookup(null); + + await expect(launchApp(DEVICE_ID, BUNDLE_ID, undefined, { simctl, lookup })) + .rejects.toThrow(DeviceNotBootedError); + }); + + it('throws AppNotInstalledError when simctl reports domain not found', async () => { + const simctl = makeSimctl(async () => { + throw new SimctlError('simctl launch failed: domain not found', ['launch'], 1); + }); + const lookup = makeLookup(makeDevice()); + + await expect(launchApp(DEVICE_ID, BUNDLE_ID, undefined, { simctl, lookup })) + .rejects.toThrow(AppNotInstalledError); + }); + + it('throws AppNotInstalledError when simctl reports not installed', async () => { + const simctl = makeSimctl(async () => { + throw new SimctlError('simctl launch failed: not installed', ['launch'], 1); + }); + const lookup = makeLookup(makeDevice()); + + await expect(launchApp(DEVICE_ID, BUNDLE_ID, undefined, { simctl, lookup })) + .rejects.toThrow(AppNotInstalledError); + }); + + it('throws AppLaunchError for other SimctlError failures', async () => { + const simctl = makeSimctl(async () => { + throw new SimctlError('simctl launch failed: crash', ['launch'], 1); + }); + const lookup = makeLookup(makeDevice()); + + await expect(launchApp(DEVICE_ID, BUNDLE_ID, undefined, { simctl, lookup })) + .rejects.toThrow(AppLaunchError); + }); + + it('throws AppLaunchError for generic errors', async () => { + const simctl = makeSimctl(async () => { + throw new Error('unexpected error'); + }); + const lookup = makeLookup(makeDevice()); + + await expect(launchApp(DEVICE_ID, BUNDLE_ID, undefined, { simctl, lookup })) + .rejects.toThrow(AppLaunchError); + }); + + it('AppLaunchError carries bundleId, deviceId, and reason', async () => { + const simctl = makeSimctl(async () => { + throw new Error('something went wrong'); + }); + const lookup = makeLookup(makeDevice()); + + let caught: AppLaunchError | undefined; + try { + await launchApp(DEVICE_ID, BUNDLE_ID, undefined, { simctl, lookup }); + } catch (e) { + caught = e as AppLaunchError; + } + + expect(caught).toBeInstanceOf(AppLaunchError); + expect(caught?.bundleId).toBe(BUNDLE_ID); + expect(caught?.deviceId).toBe(DEVICE_ID); + expect(caught?.reason).toContain('something went wrong'); + }); +}); + +// ── terminateApp ────────────────────────────────────────────────────────────── + +describe('app-manager.terminateApp', () => { + it('terminates running app and returns terminated:true', async () => { + const simctl = makeSimctl(async () => ''); + const lookup = makeLookup(makeDevice()); + + const result = await terminateApp(DEVICE_ID, BUNDLE_ID, { simctl, lookup }); + + expect(result).toEqual({ terminated: true, bundleId: BUNDLE_ID, deviceId: DEVICE_ID }); + expect(simctl.exec).toHaveBeenCalledWith(['terminate', DEVICE_ID, BUNDLE_ID]); + }); + + it('returns terminated:false when app is not running', async () => { + const simctl = makeSimctl(async () => { + throw new SimctlError('simctl terminate failed: not running', ['terminate'], 1); + }); + const lookup = makeLookup(makeDevice()); + + const result = await terminateApp(DEVICE_ID, BUNDLE_ID, { simctl, lookup }); + + expect(result).toEqual({ terminated: false, bundleId: BUNDLE_ID, deviceId: DEVICE_ID }); + }); + + it('returns terminated:false for Failed to terminate message', async () => { + const simctl = makeSimctl(async () => { + throw new SimctlError('Failed to terminate process', ['terminate'], 1); + }); + const lookup = makeLookup(makeDevice()); + + const result = await terminateApp(DEVICE_ID, BUNDLE_ID, { simctl, lookup }); + + expect(result.terminated).toBe(false); + }); + + it('throws DeviceNotBootedError for shutdown device', async () => { + const simctl = makeSimctl(); + const lookup = makeLookup(makeDevice({ state: 'Shutdown' })); + + await expect(terminateApp(DEVICE_ID, BUNDLE_ID, { simctl, lookup })) + .rejects.toThrow(DeviceNotBootedError); + expect(simctl.exec).not.toHaveBeenCalled(); + }); + + it('throws AppNotInstalledError when bundle not found (domain not found)', async () => { + const simctl = makeSimctl(async () => { + throw new SimctlError('simctl terminate failed: domain not found', ['terminate'], 1); + }); + const lookup = makeLookup(makeDevice()); + + await expect(terminateApp(DEVICE_ID, BUNDLE_ID, { simctl, lookup })) + .rejects.toThrow(AppNotInstalledError); + }); + + it('throws AppNotInstalledError when bundle not found (not installed)', async () => { + const simctl = makeSimctl(async () => { + throw new SimctlError('simctl terminate failed: not installed', ['terminate'], 1); + }); + const lookup = makeLookup(makeDevice()); + + await expect(terminateApp(DEVICE_ID, BUNDLE_ID, { simctl, lookup })) + .rejects.toThrow(AppNotInstalledError); + }); + + it('rethrows non-SimctlError errors', async () => { + const simctl = makeSimctl(async () => { + throw new Error('network error'); + }); + const lookup = makeLookup(makeDevice()); + + await expect(terminateApp(DEVICE_ID, BUNDLE_ID, { simctl, lookup })) + .rejects.toThrow('network error'); + }); +}); + +// ── activateApp ─────────────────────────────────────────────────────────────── + +describe('app-manager.activateApp', () => { + it('activates app and returns pid', async () => { + const simctl = makeSimctl(async () => `${BUNDLE_ID}: 42\n`); + const lookup = makeLookup(makeDevice()); + + const result = await activateApp(DEVICE_ID, BUNDLE_ID, { simctl, lookup }); + + expect(result).toEqual({ activated: true, bundleId: BUNDLE_ID, deviceId: DEVICE_ID, pid: 42 }); + expect(simctl.exec).toHaveBeenCalledWith(['launch', DEVICE_ID, BUNDLE_ID]); + }); + + it('throws DeviceNotBootedError for shutdown device', async () => { + const simctl = makeSimctl(); + const lookup = makeLookup(makeDevice({ state: 'Shutdown' })); + + await expect(activateApp(DEVICE_ID, BUNDLE_ID, { simctl, lookup })) + .rejects.toThrow(DeviceNotBootedError); + }); + + it('throws AppNotInstalledError when bundle not found (domain not found)', async () => { + const simctl = makeSimctl(async () => { + throw new SimctlError('simctl launch failed: domain not found', ['launch'], 1); + }); + const lookup = makeLookup(makeDevice()); + + await expect(activateApp(DEVICE_ID, BUNDLE_ID, { simctl, lookup })) + .rejects.toThrow(AppNotInstalledError); + }); + + it('throws AppNotInstalledError when bundle not found (not installed)', async () => { + const simctl = makeSimctl(async () => { + throw new SimctlError('simctl launch failed: not installed', ['launch'], 1); + }); + const lookup = makeLookup(makeDevice()); + + await expect(activateApp(DEVICE_ID, BUNDLE_ID, { simctl, lookup })) + .rejects.toThrow(AppNotInstalledError); + }); + + it('rethrows other SimctlErrors', async () => { + const simctl = makeSimctl(async () => { + throw new SimctlError('simctl launch failed: crash', ['launch'], 1); + }); + const lookup = makeLookup(makeDevice()); + + await expect(activateApp(DEVICE_ID, BUNDLE_ID, { simctl, lookup })) + .rejects.toThrow(SimctlError); + }); +}); + +// ── listRunningApps ─────────────────────────────────────────────────────────── + +describe('app-manager.listRunningApps', () => { + it('parses UIKitApplication entries from launchctl list', async () => { + const launchctlOutput = [ + 'PID\tSTATUS\tLABEL', + '101\t0\tUIKitApplication:com.example.app1[0x1]', + '102\t0\tUIKitApplication:com.example.app2[0x2]', + '-\t0\tcom.apple.springboard', + ].join('\n'); + + const simctl = makeSimctl(async () => launchctlOutput); + const lookup = makeLookup(makeDevice()); + + const result = await listRunningApps(DEVICE_ID, { simctl, lookup }); + + expect(result).toEqual([ + { label: 'com.example.app1', pid: 101 }, + { label: 'com.example.app2', pid: 102 }, + ]); + expect(simctl.exec).toHaveBeenCalledWith(['spawn', DEVICE_ID, 'launchctl', 'list']); + }); + + it('strips a single trailing bracket group from bundle labels', async () => { + const launchctlOutput = [ + 'PID\tSTATUS\tLABEL', + '200\t0\tUIKitApplication:com.example.tricky[0x1]', + ].join('\n'); + + const simctl = makeSimctl(async () => launchctlOutput); + const lookup = makeLookup(makeDevice()); + + const result = await listRunningApps(DEVICE_ID, { simctl, lookup }); + + expect(result).toEqual([{ label: 'com.example.tricky', pid: 200 }]); + }); + + it('strips all trailing bracket groups (multi-suffix labels)', async () => { + // launchctl can emit labels with multiple trailing bracket groups, e.g. + // UIKitApplication:com.example.app[0x1][debug]. We must strip them all so + // downstream bundle-id comparisons (classifyMobileContext) match cleanly. + const launchctlOutput = [ + 'PID\tSTATUS\tLABEL', + '300\t0\tUIKitApplication:com.example.app[0x1][debug]', + ].join('\n'); + + const simctl = makeSimctl(async () => launchctlOutput); + const lookup = makeLookup(makeDevice()); + + const result = await listRunningApps(DEVICE_ID, { simctl, lookup }); + + expect(result).toEqual([{ label: 'com.example.app', pid: 300 }]); + }); + + it('returns empty array when no UIKitApplication entries', async () => { + const simctl = makeSimctl(async () => 'PID\tSTATUS\tLABEL\n-\t0\tcom.apple.springboard\n'); + const lookup = makeLookup(makeDevice()); + + const result = await listRunningApps(DEVICE_ID, { simctl, lookup }); + + expect(result).toEqual([]); + }); + + it('throws DeviceNotBootedError for shutdown device', async () => { + const simctl = makeSimctl(); + const lookup = makeLookup(makeDevice({ state: 'Shutdown' })); + + await expect(listRunningApps(DEVICE_ID, { simctl, lookup })) + .rejects.toThrow(DeviceNotBootedError); + }); +}); + +// ── resetApp ────────────────────────────────────────────────────────────────── + +describe('app-manager.resetApp', () => { + it('runs terminate, privacy reset, and uninstall in order', async () => { + const calls: string[][] = []; + const simctl = makeSimctl(async (args) => { + calls.push(args); + return ''; + }); + const lookup = makeLookup(makeDevice()); + + const result = await resetApp(DEVICE_ID, BUNDLE_ID, { simctl, lookup }); + + expect(result.reset).toBe(true); + expect(result.bundleId).toBe(BUNDLE_ID); + expect(result.deviceId).toBe(DEVICE_ID); + expect(result.steps).toEqual(['terminated', 'privacy_reset', 'uninstalled']); + expect(calls[0]).toEqual(['terminate', DEVICE_ID, BUNDLE_ID]); + expect(calls[1]).toEqual(['privacy', DEVICE_ID, 'reset', 'all', BUNDLE_ID]); + expect(calls[2]).toEqual(['uninstall', DEVICE_ID, BUNDLE_ID]); + }); + + it('records terminate_skipped when terminate fails', async () => { + let callCount = 0; + const simctl = makeSimctl(async (_args) => { + callCount++; + if (callCount === 1) throw new Error('not running'); + return ''; + }); + const lookup = makeLookup(makeDevice()); + + const result = await resetApp(DEVICE_ID, BUNDLE_ID, { simctl, lookup }); + + expect(result.steps[0]).toBe('terminate_skipped'); + expect(result.steps[1]).toBe('privacy_reset'); + expect(result.steps[2]).toBe('uninstalled'); + }); + + it('throws DeviceNotBootedError for shutdown device', async () => { + const simctl = makeSimctl(); + const lookup = makeLookup(makeDevice({ state: 'Shutdown' })); + + await expect(resetApp(DEVICE_ID, BUNDLE_ID, { simctl, lookup })) + .rejects.toThrow(DeviceNotBootedError); + }); + + it('throws AppNotInstalledError when uninstall reports domain not found', async () => { + let callCount = 0; + const simctl = makeSimctl(async (_args) => { + callCount++; + // terminate and privacy succeed, uninstall fails with domain not found + if (callCount === 3) { + throw new SimctlError('simctl uninstall failed: domain not found', ['uninstall'], 1); + } + return ''; + }); + const lookup = makeLookup(makeDevice()); + + await expect(resetApp(DEVICE_ID, BUNDLE_ID, { simctl, lookup })) + .rejects.toThrow(AppNotInstalledError); + }); + + it('records uninstall_failed for non-AppNotInstalled uninstall errors', async () => { + let callCount = 0; + const simctl = makeSimctl(async (_args) => { + callCount++; + if (callCount === 3) throw new SimctlError('uninstall crashed', ['uninstall'], 1); + return ''; + }); + const lookup = makeLookup(makeDevice()); + + const result = await resetApp(DEVICE_ID, BUNDLE_ID, { simctl, lookup }); + + expect(result.steps[2]).toBe('uninstall_failed'); + }); +}); diff --git a/tests/unit/simulator-errors.test.ts b/tests/unit/simulator-errors.test.ts new file mode 100644 index 00000000..c377f9bc --- /dev/null +++ b/tests/unit/simulator-errors.test.ts @@ -0,0 +1,116 @@ +import { + BootTimeoutError, + ShutdownTimeoutError, + DeviceNotFoundError, + DeviceNotBootedError, + ScreenshotTimeoutError, + AppNotInstalledError, + AppLaunchError, +} from '../../src/simulator/errors'; + +describe('simulator errors', () => { + describe('BootTimeoutError', () => { + it('has correct name and message', () => { + const err = new BootTimeoutError('udid-1', 'iPhone 17', 30000); + expect(err).toBeInstanceOf(BootTimeoutError); + expect(err).toBeInstanceOf(Error); + expect(err.name).toBe('BootTimeoutError'); + expect(err.message).toContain('iPhone 17'); + expect(err.message).toContain('udid-1'); + expect(err.message).toContain('30000'); + expect(err.deviceId).toBe('udid-1'); + expect(err.deviceName).toBe('iPhone 17'); + expect(err.timeoutMs).toBe(30000); + }); + }); + + describe('ShutdownTimeoutError', () => { + it('has correct name and message', () => { + const err = new ShutdownTimeoutError('udid-2', 15000); + expect(err).toBeInstanceOf(ShutdownTimeoutError); + expect(err).toBeInstanceOf(Error); + expect(err.name).toBe('ShutdownTimeoutError'); + expect(err.message).toContain('udid-2'); + expect(err.message).toContain('15000'); + expect(err.deviceId).toBe('udid-2'); + expect(err.timeoutMs).toBe(15000); + }); + }); + + describe('DeviceNotFoundError', () => { + it('has correct name and lists available devices', () => { + const err = new DeviceNotFoundError('iphone-99', ['iPhone 17', 'iPhone SE']); + expect(err).toBeInstanceOf(DeviceNotFoundError); + expect(err).toBeInstanceOf(Error); + expect(err.name).toBe('DeviceNotFoundError'); + expect(err.message).toContain('iphone-99'); + expect(err.message).toContain('iPhone 17'); + expect(err.requested).toBe('iphone-99'); + expect(err.available).toEqual(['iPhone 17', 'iPhone SE']); + }); + + it('truncates available list beyond 5 items', () => { + const many = ['a', 'b', 'c', 'd', 'e', 'f', 'g']; + const err = new DeviceNotFoundError('x', many); + expect(err.message).toContain('...'); + }); + }); + + describe('DeviceNotBootedError', () => { + it('has correct name and message', () => { + const err = new DeviceNotBootedError('udid-3'); + expect(err).toBeInstanceOf(DeviceNotBootedError); + expect(err).toBeInstanceOf(Error); + expect(err.name).toBe('DeviceNotBootedError'); + expect(err.message).toContain('udid-3'); + expect(err.deviceId).toBe('udid-3'); + }); + }); + + describe('ScreenshotTimeoutError', () => { + it('has correct name and message', () => { + const err = new ScreenshotTimeoutError('udid-4'); + expect(err).toBeInstanceOf(ScreenshotTimeoutError); + expect(err).toBeInstanceOf(Error); + expect(err.name).toBe('ScreenshotTimeoutError'); + expect(err.message).toContain('udid-4'); + expect(err.deviceId).toBe('udid-4'); + }); + }); + + describe('AppNotInstalledError', () => { + it('has correct name and message', () => { + const err = new AppNotInstalledError('com.example.app', 'udid-5'); + expect(err).toBeInstanceOf(AppNotInstalledError); + expect(err).toBeInstanceOf(Error); + expect(err.name).toBe('AppNotInstalledError'); + expect(err.message).toContain('com.example.app'); + expect(err.message).toContain('udid-5'); + expect(err.bundleId).toBe('com.example.app'); + expect(err.deviceId).toBe('udid-5'); + }); + }); + + describe('AppLaunchError', () => { + it('has correct name and message', () => { + const err = new AppLaunchError('com.example.app', 'udid-6', 'process crashed'); + expect(err).toBeInstanceOf(AppLaunchError); + expect(err).toBeInstanceOf(Error); + expect(err.name).toBe('AppLaunchError'); + expect(err.message).toContain('com.example.app'); + expect(err.message).toContain('udid-6'); + expect(err.message).toContain('process crashed'); + expect(err.bundleId).toBe('com.example.app'); + expect(err.deviceId).toBe('udid-6'); + expect(err.reason).toBe('process crashed'); + }); + }); + + describe('re-exports from manager', () => { + it('exports are identical classes (instanceof preserved)', async () => { + const { BootTimeoutError: ManagerBoot } = await import('../../src/simulator/manager'); + const err = new BootTimeoutError('u', 'n', 1000); + expect(err).toBeInstanceOf(ManagerBoot); + }); + }); +}); diff --git a/tests/unit/simulator-lifecycle.test.ts b/tests/unit/simulator-lifecycle.test.ts new file mode 100644 index 00000000..73d6a1af --- /dev/null +++ b/tests/unit/simulator-lifecycle.test.ts @@ -0,0 +1,305 @@ +/** + * Unit tests for src/simulator/lifecycle.ts — #708 step 3. + * + * Covers: + * - boot happy path + * - boot already-booted no-op + * - boot timeout surfaces BootTimeoutError + * - shutdown happy path + * - shutdown already-shutdown no-op + * - shutdown best-effort cleanup: nuclear erase, then return on success + * - shutdown propagates SimctlError if the nuclear erase itself fails + * - delete is explicit (must be called deliberately — never runs on accident) + * - clone returns UDID from simctl output + * - erase delegates to simctl erase + */ + +import { + boot, + shutdown, + eraseDevice, + deleteDevice, + cloneDevice, +} from '../../src/simulator/lifecycle'; +import { BootTimeoutError } from '../../src/simulator/errors'; +import { SimctlError } from '../../src/simulator/simctl'; +import { SimulatorDevice } from '../../src/simulator/types'; + +// ── helpers ───────────────────────────────────────────────────────────────── + +function makeDevice(overrides: Partial = {}): SimulatorDevice { + return { + udid: 'TEST-UDID-0001', + name: 'iPhone Test', + state: 'Shutdown', + isAvailable: true, + runtime: 'com.apple.CoreSimulator.SimRuntime.iOS-17-0', + runtimeVersion: '17.0', + ...overrides, + }; +} + +/** Instantly resolves — replaces real setTimeout in lifecycle functions. */ +const noopSleep = (_ms: number) => Promise.resolve(); + +function makeSimctl(execImpl?: (args: string[]) => Promise) { + const calls: string[][] = []; + const exec = jest.fn(async (args: string[]) => { + calls.push(args); + return execImpl ? execImpl(args) : ''; + }); + return { exec, calls }; +} + +// ── boot ──────────────────────────────────────────────────────────────────── + +describe('lifecycle.boot', () => { + it('returns immediately if device is already Booted', async () => { + const device = makeDevice({ state: 'Booted' }); + const simctl = makeSimctl(); + const lookup = { + resolveDevice: jest.fn(async () => device), + getDevice: jest.fn(), + }; + + const result = await boot('iphone-test', { simctl, lookup, sleep: noopSleep }); + + expect(result).toBe(device); + expect(simctl.exec).not.toHaveBeenCalled(); + expect(lookup.getDevice).not.toHaveBeenCalled(); + }); + + it('issues simctl boot and polls until Booted', async () => { + const shutdownDevice = makeDevice({ state: 'Shutdown' }); + const bootedDevice = makeDevice({ state: 'Booted' }); + + const simctl = makeSimctl(); + let pollCount = 0; + const lookup = { + resolveDevice: jest.fn(async () => shutdownDevice), + getDevice: jest.fn(async () => { + pollCount++; + // Return Booted on second poll + return pollCount >= 2 ? bootedDevice : shutdownDevice; + }), + }; + + const result = await boot('iphone-test', { + simctl, + lookup, + sleep: noopSleep, + bootTimeoutMs: 10000, + pollIntervalMs: 0, + }); + + expect(simctl.exec).toHaveBeenCalledWith(['boot', shutdownDevice.udid]); + expect(result.state).toBe('Booted'); + expect(lookup.getDevice).toHaveBeenCalledWith(shutdownDevice.udid); + }); + + it('throws BootTimeoutError when device does not boot within timeout', async () => { + const shutdownDevice = makeDevice({ state: 'Shutdown' }); + const simctl = makeSimctl(); + const lookup = { + resolveDevice: jest.fn(async () => shutdownDevice), + // Always returns Shutdown — never boots + getDevice: jest.fn(async () => shutdownDevice), + }; + + await expect( + boot('iphone-test', { + simctl, + lookup, + // Use real setTimeout but extremely short timeout so one poll overshoots + bootTimeoutMs: 0, + pollIntervalMs: 0, + }), + ).rejects.toThrow(BootTimeoutError); + }); + + it('BootTimeoutError carries deviceId, deviceName, timeoutMs', async () => { + const shutdownDevice = makeDevice({ udid: 'UDID-X', name: 'My iPhone', state: 'Shutdown' }); + const simctl = makeSimctl(); + const lookup = { + resolveDevice: jest.fn(async () => shutdownDevice), + getDevice: jest.fn(async () => shutdownDevice), + }; + + let caught: BootTimeoutError | undefined; + try { + await boot('iphone-test', { simctl, lookup, bootTimeoutMs: 0, pollIntervalMs: 0 }); + } catch (e) { + caught = e as BootTimeoutError; + } + + expect(caught).toBeInstanceOf(BootTimeoutError); + expect(caught?.deviceId).toBe('UDID-X'); + expect(caught?.deviceName).toBe('My iPhone'); + expect(caught?.timeoutMs).toBe(0); + }); +}); + +// ── shutdown ───────────────────────────────────────────────────────────────── + +describe('lifecycle.shutdown', () => { + it('returns immediately if device is already Shutdown', async () => { + const device = makeDevice({ state: 'Shutdown' }); + const simctl = makeSimctl(); + const lookup = { + resolveDevice: jest.fn(), + getDevice: jest.fn(async () => device), + }; + + await shutdown('TEST-UDID-0001', { simctl, lookup, sleep: noopSleep }); + + expect(simctl.exec).not.toHaveBeenCalled(); + }); + + it('returns immediately if device not found', async () => { + const simctl = makeSimctl(); + const lookup = { + resolveDevice: jest.fn(), + getDevice: jest.fn(async () => null), + }; + + await shutdown('MISSING-UDID', { simctl, lookup, sleep: noopSleep }); + + expect(simctl.exec).not.toHaveBeenCalled(); + }); + + it('issues simctl shutdown and resolves when device reaches Shutdown', async () => { + const bootedDevice = makeDevice({ state: 'Booted' }); + const shutdownDevice = makeDevice({ state: 'Shutdown' }); + + const simctl = makeSimctl(); + let pollCount = 0; + const lookup = { + resolveDevice: jest.fn(), + getDevice: jest.fn(async () => { + // First call: check initial state (Booted); second call during poll: Shutdown + pollCount++; + return pollCount === 1 ? bootedDevice : shutdownDevice; + }), + }; + + await shutdown('TEST-UDID-0001', { + simctl, + lookup, + sleep: noopSleep, + shutdownTimeoutMs: 10000, + }); + + expect(simctl.exec).toHaveBeenCalledWith(['shutdown', 'TEST-UDID-0001']); + }); + + it('completes successfully after nuclear erase (best-effort cleanup)', async () => { + // Pre-refactor SimulatorManager.shutdown returned on a successful + // erase; preserving that contract is what keeps the device_shutdown + // MCP tool from reporting hard failures on otherwise-clean teardowns. + const bootedDevice = makeDevice({ state: 'Booted' }); + const simctl = makeSimctl(); + const lookup = { + resolveDevice: jest.fn(), + // Always returns Booted — never shuts down, forcing the nuclear path + getDevice: jest.fn(async () => bootedDevice), + }; + + await expect( + shutdown('TEST-UDID-0001', { + simctl, + lookup, + sleep: noopSleep, + shutdownTimeoutMs: 0, + }), + ).resolves.toBeUndefined(); + }); + + it('propagates SimctlError when the nuclear erase itself fails', async () => { + const bootedDevice = makeDevice({ state: 'Booted' }); + // Let `shutdown` calls succeed; only `erase` fails. This isolates the + // erase-failure branch from incidental shutdown errors. + const simctl = makeSimctl(async (args: string[]) => { + if (args[0] === 'erase') { + throw new SimctlError('simctl erase failed', ['erase', 'TEST-UDID-0001'], 1); + } + return ''; + }); + const lookup = { + resolveDevice: jest.fn(), + getDevice: jest.fn(async () => bootedDevice), + }; + + await expect( + shutdown('TEST-UDID-0001', { + simctl, + lookup, + sleep: noopSleep, + shutdownTimeoutMs: 0, + }), + ).rejects.toBeInstanceOf(SimctlError); + }); + + it('issues simctl erase as the last-resort cleanup step', async () => { + const bootedDevice = makeDevice({ state: 'Booted' }); + const simctl = makeSimctl(); + const lookup = { + resolveDevice: jest.fn(), + getDevice: jest.fn(async () => bootedDevice), + }; + + await shutdown('TEST-UDID-0001', { + simctl, + lookup, + sleep: noopSleep, + shutdownTimeoutMs: 0, + }); + + const eraseCalls = simctl.calls.filter(a => a[0] === 'erase'); + expect(eraseCalls.length).toBeGreaterThan(0); + }); +}); + +// ── deleteDevice ───────────────────────────────────────────────────────────── + +describe('lifecycle.deleteDevice', () => { + it('is explicit — only runs when caller invokes it', async () => { + const simctl = makeSimctl(); + // Calling deleteDevice requires deliberate action from caller — no side effects + await deleteDevice('UDID-DEL', { simctl }); + expect(simctl.exec).toHaveBeenCalledTimes(1); + expect(simctl.exec).toHaveBeenCalledWith(['delete', 'UDID-DEL']); + }); + + it('does NOT run implicitly — only when explicitly called', () => { + // This is a documentation assertion: the function does nothing unless called. + // The absence of any auto-delete logic in lifecycle.ts is the guarantee. + expect(typeof deleteDevice).toBe('function'); + }); +}); + +// ── eraseDevice ─────────────────────────────────────────────────────────────── + +describe('lifecycle.eraseDevice', () => { + it('delegates to simctl erase', async () => { + const simctl = makeSimctl(); + await eraseDevice('UDID-ERASE', { simctl }); + expect(simctl.exec).toHaveBeenCalledWith(['erase', 'UDID-ERASE']); + }); +}); + +// ── cloneDevice ─────────────────────────────────────────────────────────────── + +describe('lifecycle.cloneDevice', () => { + it('returns trimmed UDID from simctl clone output', async () => { + const newUdid = 'CLONE-UDID-9999'; + const simctl = makeSimctl(async (args) => { + if (args[0] === 'clone') return `${newUdid}\n`; + return ''; + }); + + const result = await cloneDevice('SOURCE-UDID', 'MyClone', { simctl }); + + expect(simctl.exec).toHaveBeenCalledWith(['clone', 'SOURCE-UDID', 'MyClone']); + expect(result).toBe(newUdid); + }); +}); diff --git a/tests/unit/simulator-ui-controller.test.ts b/tests/unit/simulator-ui-controller.test.ts new file mode 100644 index 00000000..c75167bd --- /dev/null +++ b/tests/unit/simulator-ui-controller.test.ts @@ -0,0 +1,405 @@ +/** + * Unit tests for src/simulator/ui-controller.ts — #708 step 5. + * + * Covers: + * - screenshot happy path + * - screenshot timeout/retry (DeviceNotBootedError for unbooted device) + * - screenshot uses DEFAULT_SCREENSHOT_TIMEOUT_MS + * - screenshotBase64 returns base64-encoded result + * - setAppearance happy path + * - getAppearance returns 'light' or 'dark' + * - toggleAppearance flips current mode + * - rotate happy path (simctl method) + * - rotate fallback to 'none' when both methods fail + * - overrideStatusBar happy path + * - openUrl happy path + * - openUrl throws for invalid URL + * - DeviceNotBootedError thrown for unbooted device across all functions + */ + +const readFileMock = jest.fn(); +const unlinkMock = jest.fn(); + +jest.mock('fs/promises', () => { + const actual = jest.requireActual('fs/promises'); + return { + ...actual, + readFile: (...args: unknown[]) => readFileMock(...args), + unlink: (...args: unknown[]) => unlinkMock(...args), + }; +}); + +// Mock execFile used by rotate() +const execFileMock = jest.fn(); +jest.mock('child_process', () => ({ + execFile: (...args: unknown[]) => execFileMock(...args), +})); + +import { + screenshot, + screenshotBase64, + setAppearance, + getAppearance, + toggleAppearance, + rotate, + overrideStatusBar, + openUrl, +} from '../../src/simulator/ui-controller'; +import { DeviceNotBootedError } from '../../src/simulator/errors'; +import { SimulatorDevice } from '../../src/simulator/types'; + +// ── helpers ────────────────────────────────────────────────────────────────── + +const DEVICE_ID = '22222222-2222-2222-2222-222222222222'; + +function makeDevice(overrides: Partial = {}): SimulatorDevice { + return { + udid: DEVICE_ID, + name: 'iPhone UI Test', + state: 'Booted', + isAvailable: true, + runtime: 'com.apple.CoreSimulator.SimRuntime.iOS-18-0', + runtimeVersion: '18.0', + ...overrides, + }; +} + +function makeSimctl(execImpl?: (args: string[], options?: unknown) => Promise) { + const exec = jest.fn(async (args: string[], options?: unknown) => { + return execImpl ? execImpl(args, options) : ''; + }); + return { exec }; +} + +function makeLookup(device: SimulatorDevice | null) { + return { + getDevice: jest.fn(async () => device), + }; +} + +// ── screenshot ──────────────────────────────────────────────────────────────── + +describe('ui-controller.screenshot', () => { + beforeEach(() => { + jest.clearAllMocks(); + readFileMock.mockResolvedValue(Buffer.from([0x89, 0x50, 0x4e, 0x47])); + unlinkMock.mockResolvedValue(undefined); + }); + + it('captures screenshot and returns buffer', async () => { + const simctl = makeSimctl(async () => ''); + const lookup = makeLookup(makeDevice()); + + const buf = await screenshot(DEVICE_ID, undefined, { simctl, lookup }); + + expect(buf).toBeInstanceOf(Buffer); + expect(simctl.exec).toHaveBeenCalledWith( + expect.arrayContaining(['io', DEVICE_ID, 'screenshot', '--type=png']), + expect.objectContaining({ timeout: expect.any(Number) }), + ); + expect(readFileMock).toHaveBeenCalledTimes(1); + expect(unlinkMock).toHaveBeenCalledTimes(1); + }); + + it('uses jpeg format when specified', async () => { + const simctl = makeSimctl(async () => ''); + const lookup = makeLookup(makeDevice()); + + await screenshot(DEVICE_ID, { format: 'jpeg' }, { simctl, lookup }); + + expect(simctl.exec).toHaveBeenCalledWith( + expect.arrayContaining(['--type=jpeg']), + expect.objectContaining({ timeout: expect.any(Number) }), + ); + }); + + it('cleans up temp file even when readFile throws', async () => { + const simctl = makeSimctl(async () => ''); + const lookup = makeLookup(makeDevice()); + readFileMock.mockRejectedValue(new Error('read failed')); + + await expect(screenshot(DEVICE_ID, undefined, { simctl, lookup })).rejects.toThrow('read failed'); + expect(unlinkMock).toHaveBeenCalledTimes(1); + }); + + it('throws DeviceNotBootedError for shutdown device', async () => { + const simctl = makeSimctl(); + const lookup = makeLookup(makeDevice({ state: 'Shutdown' })); + + await expect(screenshot(DEVICE_ID, undefined, { simctl, lookup })) + .rejects.toThrow(DeviceNotBootedError); + expect(simctl.exec).not.toHaveBeenCalled(); + }); + + it('throws DeviceNotBootedError when device is null', async () => { + const simctl = makeSimctl(); + const lookup = makeLookup(null); + + await expect(screenshot(DEVICE_ID, undefined, { simctl, lookup })) + .rejects.toThrow(DeviceNotBootedError); + }); +}); + +// ── screenshotBase64 ────────────────────────────────────────────────────────── + +describe('ui-controller.screenshotBase64', () => { + beforeEach(() => { + jest.clearAllMocks(); + readFileMock.mockResolvedValue(Buffer.from('test-image-data')); + unlinkMock.mockResolvedValue(undefined); + }); + + it('returns base64-encoded screenshot', async () => { + const simctl = makeSimctl(async () => ''); + const lookup = makeLookup(makeDevice()); + + const result = await screenshotBase64(DEVICE_ID, undefined, { simctl, lookup }); + + expect(typeof result).toBe('string'); + expect(Buffer.from(result, 'base64').toString()).toBe('test-image-data'); + }); +}); + +// ── setAppearance ───────────────────────────────────────────────────────────── + +describe('ui-controller.setAppearance', () => { + it('sets dark mode via simctl ui appearance', async () => { + const simctl = makeSimctl(); + const lookup = makeLookup(makeDevice()); + + await setAppearance(DEVICE_ID, 'dark', { simctl, lookup }); + + expect(simctl.exec).toHaveBeenCalledWith(['ui', DEVICE_ID, 'appearance', 'dark']); + }); + + it('sets light mode via simctl ui appearance', async () => { + const simctl = makeSimctl(); + const lookup = makeLookup(makeDevice()); + + await setAppearance(DEVICE_ID, 'light', { simctl, lookup }); + + expect(simctl.exec).toHaveBeenCalledWith(['ui', DEVICE_ID, 'appearance', 'light']); + }); + + it('throws DeviceNotBootedError for shutdown device', async () => { + const simctl = makeSimctl(); + const lookup = makeLookup(makeDevice({ state: 'Shutdown' })); + + await expect(setAppearance(DEVICE_ID, 'dark', { simctl, lookup })) + .rejects.toThrow(DeviceNotBootedError); + expect(simctl.exec).not.toHaveBeenCalled(); + }); +}); + +// ── getAppearance ───────────────────────────────────────────────────────────── + +describe('ui-controller.getAppearance', () => { + it('returns "dark" when simctl output is "Dark"', async () => { + const simctl = makeSimctl(async () => 'Dark\n'); + const lookup = makeLookup(makeDevice()); + + const result = await getAppearance(DEVICE_ID, { simctl, lookup }); + + expect(result).toBe('dark'); + }); + + it('returns "light" when simctl output is "Light"', async () => { + const simctl = makeSimctl(async () => 'Light\n'); + const lookup = makeLookup(makeDevice()); + + const result = await getAppearance(DEVICE_ID, { simctl, lookup }); + + expect(result).toBe('light'); + }); + + it('returns "light" for unknown output', async () => { + const simctl = makeSimctl(async () => 'unknown\n'); + const lookup = makeLookup(makeDevice()); + + const result = await getAppearance(DEVICE_ID, { simctl, lookup }); + + expect(result).toBe('light'); + }); + + it('throws DeviceNotBootedError for shutdown device', async () => { + const simctl = makeSimctl(); + const lookup = makeLookup(makeDevice({ state: 'Shutdown' })); + + await expect(getAppearance(DEVICE_ID, { simctl, lookup })) + .rejects.toThrow(DeviceNotBootedError); + }); +}); + +// ── toggleAppearance ────────────────────────────────────────────────────────── + +describe('ui-controller.toggleAppearance', () => { + it('toggles from light to dark', async () => { + let callCount = 0; + const simctl = makeSimctl(async (args) => { + callCount++; + // First call: getAppearance — return Light + if (callCount === 1 && args[2] === 'appearance' && args.length === 3) return 'Light\n'; + // Second call: setAppearance to dark + return ''; + }); + const lookup = makeLookup(makeDevice()); + + const result = await toggleAppearance(DEVICE_ID, { simctl, lookup }); + + expect(result).toBe('dark'); + expect(simctl.exec).toHaveBeenCalledTimes(2); + }); + + it('toggles from dark to light', async () => { + let callCount = 0; + const simctl = makeSimctl(async (args) => { + callCount++; + if (callCount === 1 && args[2] === 'appearance' && args.length === 3) return 'Dark\n'; + return ''; + }); + const lookup = makeLookup(makeDevice()); + + const result = await toggleAppearance(DEVICE_ID, { simctl, lookup }); + + expect(result).toBe('light'); + }); +}); + +// ── rotate ──────────────────────────────────────────────────────────────────── + +describe('ui-controller.rotate', () => { + beforeEach(() => { + jest.clearAllMocks(); + // Default: execFile (used by AppleScript path) calls the callback with success + execFileMock.mockImplementation( + (_cmd: unknown, _args: unknown, _opts: unknown, callback: (err: null, result: { stdout: string; stderr: string }) => void) => { + callback(null, { stdout: '', stderr: '' }); + }, + ); + }); + + it('returns simctl method on success', async () => { + // simctl.exec succeeds by default (makeSimctl returns '') + const simctl = makeSimctl(); + const lookup = makeLookup(makeDevice()); + + const result = await rotate(DEVICE_ID, 'left', { simctl, lookup }); + + expect(result.success).toBe(true); + expect(result.method).toBe('simctl'); + expect(result.orientation).toBe('landscapeLeft'); + expect(simctl.exec).toHaveBeenCalledWith( + ['io', DEVICE_ID, 'setorientation', 'landscapeLeft'], + { timeout: 10000 }, + ); + }); + + it('uses landscapeRight for direction=right', async () => { + const simctl = makeSimctl(); + const lookup = makeLookup(makeDevice()); + + const result = await rotate(DEVICE_ID, 'right', { simctl, lookup }); + + expect(result.success).toBe(true); + expect(result.orientation).toBe('landscapeRight'); + expect(simctl.exec).toHaveBeenCalledWith( + ['io', DEVICE_ID, 'setorientation', 'landscapeRight'], + { timeout: 10000 }, + ); + }); + + it('returns none when both simctl and AppleScript fail', async () => { + // simctl path fails via deps.simctl.exec throwing + const simctl = makeSimctl(async () => { throw new Error('command not found'); }); + // AppleScript path fails via execFile callback + execFileMock.mockImplementation( + (_cmd: unknown, _args: unknown, _opts: unknown, callback: (err: Error) => void) => { + callback(new Error('command not found')); + }, + ); + const lookup = makeLookup(makeDevice()); + + const result = await rotate(DEVICE_ID, 'left', { simctl, lookup }); + + expect(result.success).toBe(false); + expect(result.method).toBe('none'); + }); + + it('falls back to AppleScript when simctl fails', async () => { + // simctl path fails, AppleScript succeeds + const simctl = makeSimctl(async () => { throw new Error('simctl setorientation not supported'); }); + const lookup = makeLookup(makeDevice()); + + const result = await rotate(DEVICE_ID, 'left', { simctl, lookup }); + + expect(result.success).toBe(true); + expect(result.method).toBe('applescript'); + expect(result.orientation).toBe('landscapeLeft'); + }); + + it('throws DeviceNotBootedError for shutdown device', async () => { + const simctl = makeSimctl(); + const lookup = makeLookup(makeDevice({ state: 'Shutdown' })); + + await expect(rotate(DEVICE_ID, 'left', { simctl, lookup })) + .rejects.toThrow(DeviceNotBootedError); + }); +}); + +// ── overrideStatusBar ───────────────────────────────────────────────────────── + +describe('ui-controller.overrideStatusBar', () => { + it('calls simctl status_bar override with deterministic values', async () => { + const simctl = makeSimctl(); + const lookup = makeLookup(makeDevice()); + + await overrideStatusBar(DEVICE_ID, { simctl, lookup }); + + expect(simctl.exec).toHaveBeenCalledWith([ + 'status_bar', DEVICE_ID, 'override', + '--time', '9:41', + '--batteryLevel', '100', + '--cellularBars', '4', + ]); + }); + + it('throws DeviceNotBootedError for shutdown device', async () => { + const simctl = makeSimctl(); + const lookup = makeLookup(makeDevice({ state: 'Shutdown' })); + + await expect(overrideStatusBar(DEVICE_ID, { simctl, lookup })) + .rejects.toThrow(DeviceNotBootedError); + expect(simctl.exec).not.toHaveBeenCalled(); + }); +}); + +// ── openUrl ─────────────────────────────────────────────────────────────────── + +describe('ui-controller.openUrl', () => { + it('calls simctl openurl for valid URL', async () => { + const simctl = makeSimctl(); + const lookup = makeLookup(makeDevice()); + + await openUrl(DEVICE_ID, 'https://example.com', { simctl, lookup }); + + expect(simctl.exec).toHaveBeenCalledWith(['openurl', DEVICE_ID, 'https://example.com']); + }); + + it('throws for invalid URL', async () => { + const simctl = makeSimctl(); + const lookup = makeLookup(makeDevice()); + + await expect(openUrl(DEVICE_ID, 'not-a-url', { simctl, lookup })) + .rejects.toThrow('Invalid URL: not-a-url'); + expect(simctl.exec).not.toHaveBeenCalled(); + }); + + it('throws DeviceNotBootedError for shutdown device', async () => { + const simctl = makeSimctl(); + const lookup = makeLookup(makeDevice({ state: 'Shutdown' })); + + await expect(openUrl(DEVICE_ID, 'https://example.com', { simctl, lookup })) + .rejects.toThrow(DeviceNotBootedError); + expect(simctl.exec).not.toHaveBeenCalled(); + }); +});