Skip to content

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.

  • 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 -i around 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 reflow from, 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.

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.

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:

Terminal window
mkdir -p /private/tmp/my-test-run
ditto /path/to/Build/MyApp.app /private/tmp/my-test-run/MyApp.app
plutil -replace CFBundleIdentifier -string com.example.myapp.test \
/private/tmp/my-test-run/MyApp.app/Contents/Info.plist
codesign --force --sign - /private/tmp/my-test-run/MyApp.app

Editing 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.

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
}
}
}
FieldRequiredMeaning
applicationyesAbsolute path to the isolated .app copy.
bundleIdentifieryesIts CFBundleIdentifier; the run refuses a mismatch.
environmentFilenoPrivate (chmod 600) JSON object of environment variables for the app.
argumentsFilenoPrivate (chmod 600) JSON array of launch arguments.
workingDirectorynoAbsolute directory (default: the bundle’s directory).
capturenooff, optional (default) or required (refused without Screen Recording).
keyEventsnoAllow key and type actions.
leaseWaitMsnoWait for the per-login GUI lease (default 30000).
quitTimeoutMsnoTime 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.

Terminal window
chmod 600 /absolute/path/to/bindings.json
reflow target --group macos-tests --collection <collection-id> --bindings /absolute/path/to/bindings.json

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 mode
providers:
app: {use: MacOSApp, binding: macos-app}
---
### Switch to Blocks
```rfl
app.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:

Terminal window
reflow flows create --name switch-app-mode --file /tmp/switch-app-mode.md
reflow plan switch-app-mode
reflow apply switch-app-mode
reflow dashboard

To find selectors, run a step containing app.inspect. It launches the app, retains its accessibility tree and a window capture, and quits it.

CommandArgumentsEffect
app.runscenario (JSON string, required); optional bundleDigest, environmentSha256, argumentsSha256Launch, run the scenario, quit, prove cleanup.
app.inspectoptional 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:

ActionFieldsWhat it does
press, increment, decrement, confirm, cancel, showMenuselectorPerform that Accessibility action on one element.
focus, setValueselector (+ value)Set AXFocused or AXValue; refused if the element does not allow it.
key, typekeys ("Down", "command+Z") or textKeyboard events to the app only; needs keyEvents.
expect, waitForselector, state (present/absent), optional value, valueContains, enabled, selected, focused, timeoutMsAssert, polling until true or the timeout.
capture, snapshotnameRetain window PNGs or the accessibility tree.
sleepms (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.

  • 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/*.json and window/*.png (owned windows only).

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.appStopExpect a profile?
graceful_quitYes: the app’s own Quit ran its normal exit path (lifecycle.forced: false).
already_exitedOnly 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.
terminatedSIGTERM, sent when the app’s own Quit was unavailable or did not finish in time: depends on how the app handles SIGTERM.
killedNo: SIGKILL runs no exit handlers.
driver_unavailableUncertain: the provider stopped the app by its verified identity.
not_launchedNo app was started.

See coverage from a MacOSApp run for binding the exact executable, counting processes and ingesting.

  • 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 leaseWaitMs expires.
  • 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.