The library · simgadget

The simulator, as an object.

Two runtime dependencies. Full TypeScript types. Every action answers with what actually happened.

$npm install simgadget
01 The pitch

You don't need a protocol between you and a simulator.

You have a shell and a Node runtime. That's enough.

createSimulator doesn't return until the simulator is genuinely driveable — not when simctl boot returns, which is a minute or more early. Every piece of hard-won knowledge about that boot is folded into the call.

signup.tsTypeScript
import { createSimulator } from "simgadget";

const sim = await createSimulator({ deviceType: "iPhone 16 Pro" });
await sim.installApp("./build/MyApp.app");
await sim.launchApp("com.example.myapp");

await sim.tap({ label: "Sign Up" });
await sim.typeText("test@example.com");

const shot = await sim.screenshot({
  format: "png",
  path: "./signup.png",
});
02 Every action

Well defined schema.

No parsing the return string for the values. Everything is well typed.

what a tap tells you
const result = await sim.tap({ label: "Sound" });

// {
//   acted:   "activation",
//   element: { AXLabel: "Sound", type: "Switch", … },
//   before:  "off",
//   after:   "on",
// }
  • A toggle tells you the state it read back — and when it can't read it back, it says so rather than claiming success.
  • A touch tells you where it landed and which element it resolved.
  • A rotate tells you which orientation the interface adopted, not which one you asked for. Apps decline orientations; no Face ID iPhone ever adopts upside_down.
  • A failure is a typed error with a code and a payload, so no caller ever regexes a message.
  • Where there is genuinely nothing to read backswipe, typeText — the return is void. The companion acks delivery and knows no more than you do.
  • "Absent" is an answer, not an exception. findByLabel, findByIdentifier and describePoint return null for a clean miss.
03 Requirements

System requirements

macOS 16 or higher on Apple Silicon
Xcode 25+ with the Simulator runtime (and its associated binaries).
Node v18 or higher. It has two dependencies (@grpc/grpc-js and @bufbuild/protobuf npm will install).

The server downloads the latest idb_companion binary from the github repo, caching it for faster startups the next time. For CI environments you can run

$npx simgadget prefetch
04 Reference

API reference

The full reference is generated from the source. simgadget.dev/api has every signature, option, default and thrown error, typed exactly as the compiler sees them, with the doc comments that live next to the code — and a search box. It is rebuilt from src/index.ts on every deploy, so it cannot drift.

What follows is the shorter version: the shape of the API, grouped by what you are trying to do, for reading rather than looking up.

Top-level functions

Everything else hangs off a Simulator handle, which these return.

SignatureDescription
listSimulators(): Promise<SimInfo[]> Every simulator simctl knows about.
createSimulator(opts?: CreateOptions): Promise<Simulator> Creates on the latest available iOS runtime and, by default, boots and waits until actually driveable. Does not throw on a boot that timed out — the simulator exists either way; inspect sim.lastBoot. Throws DeviceTypeNotFoundError carrying the available list.
attachSimulator(udid: string): Promise<Simulator> Adopts an existing simulator. Verifies it exists; does not probe, does not boot, claims no knowledge of orientation. Call waitReady() next if you need it driveable.
prefetchCompanion(onProgress?): Promise<string> Resolves — downloading if necessary — the pinned idb_companion and returns its absolute path. Also exposed as npx simgadget prefetch.

CreateOptions: deviceType (substring match against simctl devicetypes, newest match wins, default "iPhone"), name, boot (default true), budgetMs (default 55_000).

Simulator — lifecycle

Verbs in, policy out. Nothing implicit ever destroys a simulator.

MemberDescription
readonly udid: stringThe simulator's UDID.
readonly name: stringIts simctl device name.
readonly lastBoot?: ReadyResultHow the last boot or waitReady went. Undefined on a fresh attach.
state(): Promise<SimulatorState>Current simctl state. Cheap.
boot(opts?): Promise<ReadyResult>Boots and waits until driveable. Does not throw on timeout. An already-booted simulator still performs the wait.
waitReady(opts?): Promise<ReadyResult>Waits, without booting, until an accessibility read answers with a real frame. Costs nothing when already up.
shutdown(): Promise<void>Shuts down. The simulator still exists.
delete(): Promise<void>Shuts down and deletes. Stops the companion first and blocks respawn for this udid. The handle is stale afterwards; every method then throws SimulatorNotFoundError.

Simulator — apps

MemberDescription
installApp(appPath: string): Promise<void>An .app directory or an .ipa. Throws app-bundle-not-found before calling simctl if the path does not exist.
launchApp(bundleId, opts?): Promise<{ pid: number | null }>opts.terminateRunning relaunches an app that is already running.

Simulator — reading

Absent is null, not a throw.

MemberDescription
describeScreen(): Promise<ScreenRead> The complete tree — AXBridge backend, so tab bars, nav bars and toolbars have their contents — with remote-hosted subtrees rebased into screen coordinates, pruned to elements you can act on. ~350 ms. Runs the full recovery ladder internally and throws AccessibilityUnreadableError only when both cures failed.
screenSize(): Promise<{ width, height }> Logical screen dimensions from the cheap (~13 ms) read. Refreshes the orientation aspect hint as a side effect.
findByLabel(label): Promise<AXElement | null> Resolves one element by the text you know it by. Fast marker query first (~13 ms), then identifier, then the AXBridge tree walk with typography folding — curly quotes, dashes, non-breaking spaces.
findByIdentifier(identifier): Promise<AXElement | null> Exact match on the accessibility identifier.
describePoint(x, y): Promise<AXElement | null> The element at a logical-space point. Hit-tests (~10 ms). Corrects remote-hosted frames internally.

Simulator — acting

tap is two different verbs under one name, because callers think of them as one.

MemberDescription
tap(target: TapTarget, opts?: TapOptions): Promise<TapResult>

{x, y} is a literal touch at your coordinates, delivered with the 0.1 s floor. No resolution, no verification — coordinates are you saying where.

{label} is "find this and operate it": resolve (ElementNotFoundError), refuse disabled controls (ElementDisabledError), route toggles through accessibility activation with state read-back, refuse hold and multi-tap on toggles (ToggleGestureError), hit-test the centre and refuse if the touch would not land (TapObstructedError, naming the obstruction), then touch.

swipe(from, to, opts?): Promise<void>Logical-space swipe. Void because the companion acks delivery and knows no more than you do. opts: durationSeconds, delta.
typeText(text: string): Promise<void>Printable ASCII plus newline, as key events. Throws UntypeableTextError listing the offending characters before any event goes out — never a half-typed string.
pressButton(button, opts?): Promise<void>"home" | "lock" | "side-button" | "siri" | "apple-pay". home is the only way to leave an app without launching another.

TapOptions: durationSeconds — a floor of 0.1 s is always applied, so passing less changes nothing; above ~0.5 s UIKit reads it as a long press. count — 2 is a double-tap.

Simulator — orientation

MemberDescription
rotate(to: Orientation): Promise<RotateResult>Device vocabulary, as the Simulator's own menus use it; the crossed mapping to idb's interface vocabulary is internal. Waits out the animation, then detects what the interface adopted. The result is authoritative for the coordinate space.
detectOrientation(): Promise<Orientation>Probes the current orientation (a few hundred ms) and refreshes the hint. Call after something external rotated the simulator.

Simulator — capture

MemberDescription
screenshot(opts?: ScreenshotOptions): Promise<Screenshot>Always rotated to match the interface orientation — simctl captures physical portrait regardless. resizeTo: "points" returns the logical dimensions your coordinates live in.
startRecording(path, opts?): Promise<void>One recording per handle. Throws recording-already-active.
stopRecording(): Promise<{ path: string }>Stops and finalizes. Throws no-active-recording.

Simulator — low level

You should never need these.

MemberDescription
restartBridge(): Promise<void>Restarts the guest's CoreSimulator bridge — the wedge cure. The recovery machinery calls this itself; it is public for hosts that want to force it.
releaseCompanion(): Promise<void>Stops this simulator's companion process. The exit hook does this anyway; long-lived hosts get tidier teardown. The simulator keeps running, state intact.

Types

AXElement keeps Apple's key names deliberately — it is the vocabulary of the source data. It is a closed type: no index signature.

simgadget.d.ts
interface Frame { x: number; y: number; width: number; height: number }

interface AXElement {
  AXLabel?: string;
  AXValue?: string | number;
  AXUniqueId?: string;
  type?: string;        // normalised role: "Button", "Switch", "SearchField", …
  enabled?: boolean;
  frame?: Frame;
  children?: AXElement[];
}

type Orientation = "portrait" | "upside_down"
                  | "landscape_left" | "landscape_right" | string;

type SimulatorState = "Booted" | "Shutdown" | "Booting"
                     | "Shutting Down" | "Creating" | string;

interface SimInfo {
  udid: string; name: string; state: SimulatorState;
  deviceTypeIdentifier: string; runtimeIdentifier: string;
}

interface ReadyResult {
  ready: boolean; waitedMs: number;
  recoveryTried: boolean; recovered: boolean;
}

interface ScreenRead {
  elements: AXElement[];              // [0] is the screen root
  screen: { width: number; height: number };
}

type TapTarget = { x: number; y: number } | { label: string };

type TapResult =
  | { acted: "touch"; x: number; y: number;
      count: number; durationSeconds: number; element?: AXElement }
  | { acted: "activation"; element: AXElement;
      before?: string | number; after?: string | number };

interface RotateResult { requested: Orientation; adopted: Orientation }

interface Screenshot {
  data: Buffer; format: string;
  width: number; height: number;      // pixels of the returned image
  orientation: Orientation;
}

Errors

One base class, SimGadgetError, with a code you branch on. Subclasses exist only where there is a payload to carry. Messages are host-agnostic: they never name a tool, a URL, or remediation that assumes a particular caller.

codeClass and payload
unsupported-architectureUnsupportedArchitectureError — message names the architecture
companion-download-failedCompanionDownloadError — HTTP failure or checksum mismatch
companion-start-failedCompanionStartError · stderrTail: string[]
simulator-not-foundSimulatorNotFoundError · udid
device-type-not-foundDeviceTypeNotFoundError · keyword, available: string[]
no-ios-runtimeSimGadgetError
not-answeringSimulatorNotAnsweringError · recoveryTried — the wedge, after recovery was tried or suppressed by cooldown
accessibility-unreadableAccessibilityUnreadableError · verdict: "booting" | "unrecoverable"
element-not-foundElementNotFoundError · query
element-disabledElementDisabledError · element
element-unusable-frameSimGadgetError — resolved, but no frame to aim at
tap-obstructedTapObstructedError · element, obstruction, point
toggle-needs-plain-tapToggleGestureError · element, gesture: "hold" | "multi-tap"
untypeable-textUntypeableTextError · characters: string[]
recording-already-activeSimGadgetError
no-active-recordingSimGadgetError
app-bundle-not-foundSimGadgetError

Three lines to a booted simulator.

Nothing in the library ever destroys a simulator except a delete() you wrote yourself.

$npm install simgadget
$npx simgadget prefetch