MacOSApp plugin
Use MacOSApp to drive a native macOS application the way a person would: press
buttons, set values, send keys, assert what the app shows and capture its windows.
Each run launches an isolated copy of your app, performs a bounded scenario,
quits the app through its own Quit command and proves that every process it
started has stopped.
MacOSApp is not yet released, and this page does not give an install command. It is not bundled with the public CLI; Reflow will build it from its private source repository. It runs only on a Mac with an unlocked desktop. See availability.
Before you start
Section titled “Before you start”- macOS 14 or later, with a completed graphical login that is on the console and
unlocked for the whole run. Keep the display awake (for example
caffeinate -d -iaround the run). Headless CI, remote or background sessions and a locked screen are refused before anything is launched. - Accessibility, and Screen & System Audio Recording for window captures, granted
in System Settings › Privacy & Security to the application that started the
Reflow device service: the terminal you ran
reflowfrom, or the desktop app hosting your MCP connection. If the device service was already running when you granted a different app, it keeps the permissions of the app that started it. A refused run names the missing permission. MacOSApp never opens a permission prompt.
Availability
Section titled “Availability”MacOSApp is not yet available to install. Earlier source commits are not
supported for installation and are not listed here. A reviewed source pin and
the exact reflow init command will be added to this page after the plugin
passes native acceptance on a real macOS desktop.
When it is released, MacOSApp will be built from its private source repository
with reflow init --plugin-source, --plugin-catalog-commit and
--trust-plugin-source, as for any
non-built-in provider. Building it will need access to
that repository, the pinned Go toolchain and the Xcode Command Line Tools
(xcrun swiftc).
The rest of this page describes how the plugin behaves, so you can prepare an isolated copy of your app, bindings and flows in advance.
Prepare an isolated copy
Section titled “Prepare an isolated copy”MacOSApp never attaches to a running app and refuses apps under /Applications,
/System, /Library, /usr, /bin, /sbin, ~/Applications and
~/Library/Application Support. Copy your build and give the copy a test-only
bundle identifier:
mkdir -p /private/tmp/my-test-runditto /path/to/Build/MyApp.app /private/tmp/my-test-run/MyApp.appplutil -replace CFBundleIdentifier -string com.example.myapp.test \ /private/tmp/my-test-run/MyApp.app/Contents/Info.plistcodesign --force --sign - /private/tmp/my-test-run/MyApp.appEditing Info.plist invalidates the signature, so re-sign the copy. The ad-hoc
signature drops the original team identity and entitlements; an app that relies
on them (iCloud, keychain groups, sandbox containers) needs a development
signature that grants them. Signing changes the bundle’s bytes, so re-sign before
you bind code coverage, and do not rebuild or re-sign the copy while a coverage
window is open. A unique identifier keeps the copy’s preferences apart from an
installed app.
Connect
Section titled “Connect”Save the private connection, replacing the paths and identifier:
{ "macos-app": { "provider": "MacOSApp", "configuration": { "application": "/private/tmp/my-test-run/MyApp.app", "bundleIdentifier": "com.example.myapp.test", "environmentFile": "/private/tmp/my-test-run/env.json", "capture": "required", "keyEvents": true } }}| Field | Required | Meaning |
|---|---|---|
application | yes | Absolute path to the isolated .app copy. |
bundleIdentifier | yes | Its CFBundleIdentifier; the run refuses a mismatch. |
environmentFile | no | Private (chmod 600) JSON object of environment variables for the app. |
argumentsFile | no | Private (chmod 600) JSON array of launch arguments. |
workingDirectory | no | Absolute directory (default: the bundle’s directory). |
capture | no | off, optional (default) or required (refused without Screen Recording). |
keyEvents | no | Allow key and type actions. |
leaseWaitMs | no | Wait for the per-login GUI lease (default 30000). |
quitTimeoutMs | no | Time allowed for the app’s own Quit before it is forced (default 10000). |
The app is started directly from its bundle executable, not through
LaunchServices or open, with a small base environment (HOME, USER,
LOGNAME, PATH, TMPDIR, LANG, LC_ALL, LC_CTYPE,
__CF_USER_TEXT_ENCODING) plus every entry of environmentFile, which overrides
the base. Your shell exports do not reach the app. Names starting with
REFLOW_PLUGIN_ or DYLD_ are refused.
chmod 600 /absolute/path/to/bindings.jsonreflow target --group macos-tests --collection <collection-id> --bindings /absolute/path/to/bindings.jsonWrite a flow
Section titled “Write a flow”Copy this into /tmp/switch-app-mode.md, outside your checkout. The scenario is
a JSON string, so quotes inside it are escaped:
---name: Switch app modeproviders: app: {use: MacOSApp, binding: macos-app}---
### Switch to Blocks
```rflapp.run scenario="{\"version\":1,\"actions\":[{\"do\":\"press\",\"selector\":{\"role\":\"AXButton\",\"label\":\"Blocks\"}},{\"do\":\"waitFor\",\"state\":\"present\",\"selector\":{\"identifier\":\"mode-label\"},\"value\":\"Mode: Blocks\"},{\"do\":\"capture\",\"name\":\"blocks\"}]}"```Once MacOSApp is installed, create the test in the team’s chosen location and run it:
reflow flows create --name switch-app-mode --file /tmp/switch-app-mode.mdreflow plan switch-app-modereflow apply switch-app-modereflow dashboardTo find selectors, run a step containing app.inspect. It launches the app,
retains its accessibility tree and a window capture, and quits it.
Commands
Section titled “Commands”| Command | Arguments | Effect |
|---|---|---|
app.run | scenario (JSON string, required); optional bundleDigest, environmentSha256, argumentsSha256 | Launch, run the scenario, quit, prove cleanup. |
app.inspect | optional maxDepth (1-100), maxNodes (1-20000) | Launch, record the accessibility tree and a capture, quit. |
Both commands launch and quit the app, so both are mutations. The optional digests refuse the run before launch if the bundle or a private file differs, so changing them changes the step’s inputs.
A scenario is {"version":1,"actions":[...]} with up to 200 actions, an optional
timeoutMs, a window title to wait for and windowTimeoutMs. Actions:
| Action | Fields | What it does |
|---|---|---|
press, increment, decrement, confirm, cancel, showMenu | selector | Perform that Accessibility action on one element. |
focus, setValue | selector (+ value) | Set AXFocused or AXValue; refused if the element does not allow it. |
key, type | keys ("Down", "command+Z") or text | Keyboard events to the app only; needs keyEvents. |
expect, waitFor | selector, state (present/absent), optional value, valueContains, enabled, selected, focused, timeoutMs | Assert, polling until true or the timeout. |
capture, snapshot | name | Retain window PNGs or the accessibility tree. |
sleep | ms (up to 10000) | Prefer waitFor. |
Selectors use role, subrole, identifier, label, title, value, help,
labelContains, titleContains, valueContains, window, within and
index. Without index a selector must match exactly one element. Checks fail
closed: if the accessibility tree is incomplete, an element cannot be read, a
value is too long to compare, or an element does not report the state you
assert, the step fails instead of passing.
Results and evidence
Section titled “Results and evidence”- The step succeeds when every action passed. It fails at the first failing action (later actions are skipped) or when the run is refused before launch, and is unknown when an effect may have been delivered without confirmation (Accessibility timeout, focus taken during key input, cancellation) or cleanup could not be proven.
- State records the app identity and complete-bundle digest, each action’s
outcome, permissions, capture status, how the app stopped
(
lifecycle.appStop,lifecycle.forced), cleanup, and the SHA-256 of the environment and arguments files. - Artifacts:
timeline.json(timings and candidate elements for a selector that missed),ax/*.jsonandwindow/*.png(owned windows only).
Code coverage
Section titled “Code coverage”In a Reflow run the plugin receives a fixed minimal environment, so
environmentFile is the only way to give the app GOCOVERDIR or
LLVM_PROFILE_FILE. Every run or inspect that reaches launch starts the app
once; a step refused before launch, or reused from earlier evidence, starts
nothing. Expect one profile per launched process of the bound executable; a
helper or other instrumented binary is a separate document with its own count.
How the app stopped decides whether to expect its profile:
lifecycle.appStop | Expect a profile? |
|---|---|
graceful_quit | Yes: the app’s own Quit ran its normal exit path (lifecycle.forced: false). |
already_exited | Only if it exited normally. The app ended before MacOSApp’s stop, for example quit by a scenario action, a crash or an outside kill. lifecycle.appExitCode means it called exit (a process using _exit skips exit handlers and writes nothing); lifecycle.appExitSignal means a signal ended it, which leaves no profile. forced is false either way. |
terminated | SIGTERM, sent when the app’s own Quit was unavailable or did not finish in time: depends on how the app handles SIGTERM. |
killed | No: SIGKILL runs no exit handlers. |
driver_unavailable | Uncertain: the provider stopped the app by its verified identity. |
not_launched | No app was started. |
See coverage from a MacOSApp run for binding the exact executable, counting processes and ingesting.
Limits
Section titled “Limits”- Only processes started by the run are touched. Keyboard actions briefly make the app frontmost and then restore the previous app if focus did not move.
- One MacOSApp run at a time per login; a second run waits for the lease and is
refused when
leaseWaitMsexpires. - The lock-state check reads
CGSessionCopyCurrentDictionary. Its lock keys are not documented by Apple; the check encodes what macOS 26.6 reports and could miss a future change. Keep the Mac unlocked and awake for the whole run.