simgadget
Two runtime dependencies. Full TypeScript types. Every action answers with what actually happened.
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.
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",
});
No parsing the return string for the values. Everything is well typed.
const result = await sim.tap({ label: "Sound" });
// {
// acted: "activation",
// element: { AXLabel: "Sound", type: "Switch", … },
// before: "off",
// after: "on",
// }
upside_down.code and a payload,
so no caller ever regexes a message.swipe, typeText — the return is void. The
companion acks delivery and knows no more than you do.findByLabel, findByIdentifier and describePoint
return null for a clean miss.| 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
src/index.ts on every
deploy, so it cannot drift.
Everything else hangs off a Simulator handle, which these return.
| Signature | Description |
|---|---|
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).
Verbs in, policy out. Nothing implicit ever destroys a simulator.
| Member | Description |
|---|---|
readonly udid: string | The simulator's UDID. |
readonly name: string | Its simctl device name. |
readonly lastBoot?: ReadyResult | How 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. |
| Member | Description |
|---|---|
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. |
Absent is null, not a throw.
| Member | Description |
|---|---|
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. |
tap is two different verbs under one name, because callers think of them as one.
| Member | Description |
|---|---|
tap(target: TapTarget, opts?: TapOptions): Promise<TapResult> |
|
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.
| Member | Description |
|---|---|
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. |
| Member | Description |
|---|---|
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. |
You should never need these.
| Member | Description |
|---|---|
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. |
AXElement keeps Apple's key names deliberately — it is the vocabulary of
the source data. It is a closed type: no index signature.
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;
}
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.
code | Class and payload |
|---|---|
unsupported-architecture | UnsupportedArchitectureError — message names the architecture |
companion-download-failed | CompanionDownloadError — HTTP failure or checksum mismatch |
companion-start-failed | CompanionStartError · stderrTail: string[] |
simulator-not-found | SimulatorNotFoundError · udid |
device-type-not-found | DeviceTypeNotFoundError · keyword, available: string[] |
no-ios-runtime | SimGadgetError |
not-answering | SimulatorNotAnsweringError · recoveryTried — the wedge, after recovery was tried or suppressed by cooldown |
accessibility-unreadable | AccessibilityUnreadableError · verdict: "booting" | "unrecoverable" |
element-not-found | ElementNotFoundError · query |
element-disabled | ElementDisabledError · element |
element-unusable-frame | SimGadgetError — resolved, but no frame to aim at |
tap-obstructed | TapObstructedError · element, obstruction, point |
toggle-needs-plain-tap | ToggleGestureError · element, gesture: "hold" | "multi-tap" |
untypeable-text | UntypeableTextError · characters: string[] |
recording-already-active | SimGadgetError |
no-active-recording | SimGadgetError |
app-bundle-not-found | SimGadgetError |
Nothing in the library ever destroys a simulator except a delete() you
wrote yourself.