unixzii/simrun/simrun
Claude Code and agent skill by unixzii/simrun
- community
Checking that the SKILL.md file is still in the repository.
Run iOS Simulator apps on the Mac with SimRun and drive them from the command line. Launch an app, read its screen as text, tap, type, scroll and assert with a Playwright-like JavaScript API (`simrun eval`), take screenshots, and call into Objective-C inside the app. Use when building, testing, verifying or debugging the UI of an iOS app, reproducing a UI bug, or automating a flow in an app, on a Mac where SimRun is installed.
Install
npx vskill@latest install unixzii/simrun/simrunSKILL.md
Driving iOS apps with SimRun
SimRun runs an app built for the iOS Simulator as a plain Mac app, in its own window, with no Simulator.app and no device. simrun eval runs JavaScript inside the running app, on its main thread, with an API modeled on Playwright's:
- a snapshot of the screen as text, with a ref for each element
- locators that find elements by role, name or identifier
- actions that wait for their element and act through real touches and keys
expectassertions that retry
Find simrun
If neither exists, ask the user where SimRun is. A SimRun checkout has it at build/bin/simrun. Use whichever path you find in place of simrun below. If only the app's copy exists, the user can put simrun on PATH with SimRun ▸ Install Command Line Tool….
1. Build the app for the Simulator
SimRun runs Simulator builds (Debug-iphonesimulator); build for the Simulator. (Given a device build, such as Debug-iphoneos or an ad hoc one, it runs a copy converted for the Simulator; not App Store builds, which are encrypted.) The app must use scenes: any SwiftUI App does, and so does a UIKit app with a UIScene configuration. An app delegate alone won't show a window.
Add -workspace MyApp.xcworkspace or -project MyApp.xcodeproj when the directory has more than one.
A watchOS app works the same way: build it for generic/platform=watchOS Simulator (the app is in Debug-watchsimulator), and SimRun runs it on the newest watchOS runtime as an Apple Watch. Turn its Digital Crown with app.crown(delta). Its text fields can't be typed into. An iPhone app with a Watch app in it (its Watch/ folder) can run that app on SimRun's paired Apple Watch too, so WatchConnectivity works between them: simrun run --with-watch-app.
2. Launch it
simrun run stays in the foreground for as long as the app runs. The app's stdout and stderr go to it, and the app quits when `simrun run` exits. So start it in the background with its output going to a file:
- If your harness can run a command in the background, use that.
- Otherwise, use
nohup:
--headless keeps the app off the screen: no window, nothing comes to the front, and it gets a clipboard of its own, so it doesn't get in the way of the user or of other agents running apps at the same time. --mute keeps it quiet. Everything below works the same; look at the app with snapshots and simrun screenshot. Leave them out only when the user wants to watch (or hear) the app. File pickers have no panel: answer them with simrun pick (see File pickers).
Then wait until the app can take scripts. eval fails while the app is starting ("No running app matches", then "is still launching"), so loop until it succeeds. A successful eval is the signal to go by: simrun list may still say "launching" for a moment.
- Naming an app: commands take its name, bundle ID, executable name or pid.
simrun listshows the running
apps.
- Launch options:
- Anything after the app's path is passed to the app as launch arguments.
-d "iPhone SE (3rd generation)"runs it as another device;simrun deviceslists them.-p <profile>gives it a separate data container. A new profile name means an empty container, so it's the way
to start from a clean state. The app's data otherwise persists between launches, as on a phone.
OS_ACTIVITY_DT_MODE=1 simrun run …also sendsNSLogandos_logoutput to the log.- Quitting:
simrun quit MyApp. Thesimrun runthat launched the app then exits with status 137 (SIGKILL).
That's normal, not a crash.
- After rebuilding, quit and run again. A running app doesn't pick up a new build.
- When the app crashes or never shows a window, read its output file first, then
simrun log, SimRun's own log.
3. Look, act, check
Work in a loop:
- Look at the screen with a snapshot.
- Act with a locator. Add
--snapshotto see the result in the same call. - Confirm the result with
expect.
A snapshot shows the accessibility elements on screen in reading order. It nests them under the containers they are in (navigation bar, tab bar, toolbar, list, table, labelled group):
Each line reads role "name" [ref] value="…" id="…" states:
- name: the accessibility label, which is what VoiceOver reads.
- id: the accessibility identifier.
- states:
selected,disabled,focused, andoffscreen(only with{ all: true }).
The first snapshot or locator in each launch takes several seconds (4–8 s) while the app's accessibility support loads. Later ones take milliseconds. Don't retry or assume the app is stuck.
Writing scripts
- A script is the body of an async function.
awaitworks at top level. A one-expression script's value is that
expression's value; a longer script needs return. Always `await` actions and assertions. Without it, the script moves on before the action has happened.
- Quoting. Put a short script in single quotes and use double quotes inside the JavaScript. For anything longer,
or with a ' in it, use a quoted heredoc or a file:
``bash simrun eval MyApp <<'EOF' await app.getByRole("textbox").filter({ hasText: "Email" }).fill("me@example.com"); // "Email": its placeholder await app.getByRole("button", { name: "Continue" }).tap(); await expect(app.getByRole("heading", { name: "Password" })).toBeVisible(); EOF simrun eval MyApp -f flow.js ``
- Output.
console.loglines print as they happen. The script's value comes after them: strings as they are,
anything else as JSON. A thrown error, a failed expect included, goes to stderr with its line (at script.js:3:12), and the exit status is 1.
- Getting data. When you need specific facts rather than the whole screen, return them:
return (await app.ax.elements()).filter(e => e.role === "button").map(e => e.name).
- State carries over. Each script starts where the last one left the app. Scripts in one launch also share a
JavaScript context, so a globalThis.x set in one script is there in the next.
- Check where you ended up. After a tap that navigates, assert on the destination
(expect(app.getByRole("heading", { name: "Settings" })).toBeVisible()). For the page you left, assert it's gone with toBeHidden(), rather than reading one snapshot. During a transition, both pages can be in the tree.
- Don't sleep. Actions already wait for their element, and for the UI to settle afterwards. To wait for
something to happen, use expect(…) or locator.waitFor(), not sleep(ms).
Choosing locators
Prefer them in this order:
app.getByTestId("id"): the accessibility identifier, matched exactly. This is the most stable.app.getByRole("button", { name: "Save" }).app.getByText("Saved").app.ref("e12"): fine for exploring right after a snapshot, but brittle in a saved script.
- If you can change the app's code and an element is hard to target, give it an identifier: in SwiftUI
.accessibilityIdentifier("…"), in UIKit the accessibilityIdentifier property.
- Names match as a case-insensitive substring. The full name of a SwiftUI button often includes its subtitle
("Keyboard, Type, use shortcuts…"), and a substring match handles that. Use { exact: true } when a short name is part of others ("1" and "10"), or pass a RegExp.
- Matching is strict. An action on a locator that matches several elements fails right away and lists them.
Narrow it down with exact, .filter({ hasText }), .first() or .nth(i).
- The roles are
button,link,image,heading,text,textbox,searchbox,switch,slider,tab,
key, and element for anything else.
How it differs from Playwright
- It's
appinstead ofpage. There are no CSS or XPath selectors, frames,gotoorevaluate, since the script
already runs inside the app.
- Tapping:
tap(), notclick(). There's no hover orcheck(): tap a switch, then
expect(sw).toHaveValue("on"). tap() taps the element's activation point, where VoiceOver would tap, so on a Toggle row it hits the switch.
- Text fields have the role
textboxorsearchbox. They usually have no name: a SwiftUI
TextField("Email", …) shows "Email" only as its value, and only while it's empty. To target one:
getByTestId(…), if it has an identifier.getByRole("textbox").filter({ hasText: "Email" }). This matches the placeholder, so it finds the field only
while it's empty.
getByRole("textbox").nth(i), in reading order.
fill() replaces the text, and type() types at the cursor. The first text-field focus in a launch can stall the app for 2–3 s; fill() and type() wait it out.
- Steppers show up as two buttons, named after the label and the action ("Count: 3, Increment"). Each carries the
current value. Tap one, then expect(…).toHaveValue("4").
- Values are strings:
"3","on". UseNumber(await loc.value())to compute with them. - Keys use Playwright's names (
"Enter","Tab","ArrowDown","Meta+a"), andMetais ⌘. Typing goes in as
a hardware keyboard would. There's no on-screen keyboard.
- There's no test runner. A script is one flow, and a failure exits 1. See the template at the end.
Reading failures
The error message usually says what to do next.
| Message | Meaning | |
|---|---|---|
matches N elements | Strict mode. The list shows them; narrow the locator. | |
no element matches. Similar: … | Nothing has that name. Use one of the suggestions, or take a snapshot. | |
… is off screen (scrolling didn't bring it on screen) | Scroll with app.swipe(…), or use locator.activate(), which needs no tap. | |
… is disabled / … keeps moving | The element isn't enabled, or its frame keeps changing (an animation that never ends). | |
No running app matches / is still launching | Not started yet, or it has quit. Check simrun list and the app's log. | |
the app went away before the script finished | The app crashed or was quit. Read its log. | |
SimRun isn't running | Only run starts SimRun. Launch the app first. |
Beyond the accessibility tree
- Screenshots.
simrun screenshot MyApp -o /tmp/screen.pngsaves the screen, which you can then view. Use it for
layout, colors, images, and anything the snapshot doesn't explain.
- Touches at points. Use
app.tap(x, y),app.longPress(x, y)andapp.swipe(x0, y0, x1, y1), in points. app.screen()gives the screen's size.(await locator.info()).framegives an element's frame.- To scroll down:
const { width: w, height: h } = app.screen(); await app.swipe(w / 2, h * 0.75, w / 2, h * 0.25). - Objective-C.
objc.send(target, "selector:", …args)calls into the app. The target is a class name or an
object returned by an earlier call. Swift code is reachable only through @objc members. Two examples:
This is the start of the file. Read the full SKILL.md on GitHub.
Security
- Security scanPassed, 90 of 100Pattern checks on the skill's files.
- Model reviewPassed
Quality evaluation
- Task evaluationNot runNo evaluation yet. Run one in Skill Studio.
Source
- SKILL.md in the repositoryCheckingskills/simrun/SKILL.md
Popularity
- Repository stars
- 294
- Repository forks
- 20
- 7-day trend score
- 0
Stars and forks are for the whole GitHub repository, not this skill alone. Not refreshed yet.
Works with
Any agent vskill installs to: 56 targets in all. These 39 have a page on this site.
- Claude Code
- Cursor
- GitHub Copilot
- Windsurf
- Codex
- Gemini CLI
- Cline
- Amp
- Roo Code
- Goose
- Continue
- Kilo Code
- Junie
- Trae
- OpenHands
- Qwen Code
- AI Agent
- Tabnine Chat
- Sourcegraph Cody
- Amazon Q
- Firebase Genkit
- Sweep
- Devin
- Phind
- Mentat
- Claude Desktop
- Aider
- GPT Pilot
- Plandex
- bolt.new
- v0
- Replit Agent
- Anse
- TypingMind
- BetterChatGPT
- LibreChat
- Warp AI
- Pieces
- Fig AI
Embed badge
[](https://verified-skill.com/skills/unixzii/simrun/simrun)