The release gate drives and screenshots Spark apps from inside the app process. Agents running the gate have no Screen Recording, Accessibility or input-synthesis (TCC) permissions by default. The optional pointer backend below requires an Accessibility grant.
The driver (packages/desktop-app/macos/SparkTestDriver*.mm, started by the
@legendapp/spark-desktop-host app delegate) is compiled into Debug builds of every macOS Spark app,
and into release builds only of apps that opt in (Builds). It is inert unless the gate
runner launches the app with the contract below. Windows has no driver yet.
| Build | Driver |
|---|---|
Debug (spark dev, spark build --dev, the Spark Runner) |
Compiled in |
Release (spark build), default |
Compiled out: no driver code, no driver commands from other packages |
Release with "macos": { "testDriverInRelease": true } in desktop.config.json |
Compiled in. The Kitchen Sink sets it, so the gate drives the exact release artifact |
Never set testDriverInRelease for an app you distribute. How it works:
- The config plugin writes
ENV['SPARK_TEST_DRIVER_RELEASE'] = '1'into the generated Podfile when the option is set. Every Spark pod that includesSparkTestDriver.h(RNDesktopApp,RNDesktopWindows) sets the build settingSPARK_TEST_DRIVERto 1 in the Debug configuration, and in other configurations to 1 with that variable or 0 without it, and passes it to the preprocessor.RNDesktopAppalso gives it to the app target, for the host's app delegate. - At 0,
RNDesktopAppexcludesSparkTestDriver*.mmfrom the build (EXCLUDED_SOURCE_FILE_NAMES).SparkTestDriver.hdeclaresSparkTestDriverStart,Active,DidMountandEndingas inline no-ops and leavesSparkTestDriverRegisterCommandundeclared, so code that registers driver commands sits inside#if SPARK_TEST_DRIVER; outside it, the build fails to compile. A file that includes the header without the setting gets the driver declarations, so a release build without the driver fails to link rather than ship driver code. - A build without the driver that is launched with
SPARK_TEST_DRIVER_DIRlogsSpark: this build has no test driver; SPARK_TEST_DRIVER_DIR is ignoredand starts normally. - After every macOS release build,
spark buildreads the app's code (Contents/MacOSandContents/Frameworks) for strings only driver code contains: the socket namedriver.sockand the log prefixSpark test driver:. WithouttestDriverInReleaseit fails if it finds either; with it, it fails unless it finds both (packages/cli/src/release-driver.ts).
tests/test-driver-builds.test.ts checks the configuration, the Podfile line and the podspecs' build
settings. It also compiles a stripped, dead-stripped binary each way and runs the release check on both:
the one without the driver has no driver strings or SparkTestDriver symbols, and logs and starts normally
with SPARK_TEST_DRIVER_DIR set. It also checks that every SparkTestDriverRegisterCommand call outside the
driver's own sources sits after #if SPARK_TEST_DRIVER.
Build the development Kitchen Sink once, and again after native changes:
cd examples/kitchen-sink && bun run rebuild:macos # spark build --devThen capture from the repository root:
bun run ks:capture <area>/<screen> [--appearance light|dark] [--locale ar] [--out e2e/verification/<N>/]
bun run ks:capture --wait-for infra-shell-root --name catalog-dark --appearance dark # the launch screenks:capture bundles the current JavaScript (no Metro), launches the app in driver mode,
navigates to spark-ks://<area>/<screen>, waits for <area>-<screen>-root (or
--wait-for <testID>), applies the app appearance (light by default), renders the main
window to <out>/<name>.png and quits. The default name is
<area>-<screen>-<appearance>[-<locale>] and the default output directory is
examples/kitchen-sink/.spark/captures. --locale launches with -AppleLanguages.
Flows run through the driver runner backend
(docs/e2e-flows.md):
bun run e2e:run <flow> --backend driver. It and ks:capture share
scripts/e2e/kitchen-sink.ts, which resolves the app (a .app path, or the last
development build), bundles and serves JavaScript to development builds, and launches
in driver mode. Both use the client in scripts/e2e/driver.ts:
const driver = await launchDriver({ executable, args, env });
await driver.navigate("spark-ks://windows/frame-autosave");
await driver.openFile("/path/to/notes.md");
await driver.waitFor("windows-frame-autosave-root");
await driver.setAppAppearance("dark");
const { file } = await driver.capture("main", "frame-autosave-dark");
await driver.quit();
await driver.close();SPARK_TEST_DRIVER_DIRnames a run directory the runner created: absolute, mode 0700, owned by the user. It holdstoken, a 0600 file of 32-256 bytes. The app reads the token, deletes the file, and removes the variable from its environment, so child processes never inherit driver mode and nothing can read the token later. Any violation exits the app with status 78 and aSpark test driver:log line.- The app listens on
<run directory>/driver.sock(mode 0600, backlog 1). It accepts one connection, from the same user only (getpeereid). The first message must carry the token, compared in constant time, within 10 seconds. A wrong token ends the session. - Captures are created only in the run directory, with
O_EXCL | O_NOFOLLOW, mode 0600. Names are 1-100 characters of[A-Za-z0-9_-]; the runner copies files out. - One session per launch. When it ends (quit, disconnect or failed authentication),
the socket is removed and the app quits through
-[NSApp terminate:]. If no client authenticates within 30 seconds, the app quits. - Accepted sockets are made blocking explicitly and set
SO_NOSIGPIPE, so partial messages are read whole and a client that disconnects cannot kill the app.
By default, a driven app never takes focus from the person using the Mac:
- Activation policy
NSApplicationActivationPolicyProhibited: no Dock icon, no menu bar, cannot activate. - The main window is created and rendered but never ordered on screen.
- It skips the single-instance lock, so it never signals (and activates) a normal instance of the same app.
- App Nap is disabled for the process, so timers and rendering are not throttled.
- Window state is neither restored nor saved, so every driven launch starts at the
configured window sizes whatever earlier launches (driven or not) left behind: the main
window's frame autosave, child window bounds (
restoreBounds), the window manager's restorable windows and split-view chrome. AppKit's own restoration recreates no Spark window (Spark registers no restoration class).tests/driver-window-state.native.mmseeds a saved frame and checks a driven main window keeps its size.
Windows that app code opens itself (for example with @legendapp/spark/windows) are
still ordered on screen, without activation.
Run bun run e2e:run <flow> --backend driver --pointer to enable tapOn. Pointer flows
live in e2e/pointer/flows/, outside the release gate; bun run gate:pointer runs them all
before a release. This explicitly
allows the driver to show the target window, change the activation policy from Prohibited
to Regular, and activate the app for foreground clicks. At flow end (including failure),
restorePointer hides windows the driver showed, restores their animation behavior and the
activation policy, and reactivates the previously foreground app. Disconnect also restores
this state before quitting. Ordinary driver runs retain the hidden-window behavior above.
The driver waits for mounted UI updates to settle before preparing pointer input, allowing
native gesture recognizers to attach to their views. tapOn waits up to its timeout; on a
screen that keeps mounting, such as a spinner, it taps once the timeout is spent.
The pointer backend resolves id selectors through the existing driver, so element lookup
is white-box; the click itself is OS input, posted as Quartz left mouse-down/up events by
a small native helper using CGEventPostToPid. It does not invoke a view action or a
JavaScript callback. Modifiers and other gestures are unsupported. The driver translates
its captured-frame coordinates to AppKit window points after showing the window, including
the title bar and Y flip. It constructs and serializes the mouse events in the window-owning
process, where AppKit can resolve the window number and local hit point. The helper decodes
and posts those events to that process without a CGWindow search.
Foreground clicks work with React Native Pressable and gesture-handler targets without
per-view acceptsFirstMouse props. Each tap uses one monotonic event number shared by its
down and up events so React Native can pair the press and release.
Grant the e2e runner Accessibility access in System Settings > Privacy & Security >
Accessibility. The helper is compiled once per run and checks CGPreflightPostEventAccess
at each flow start, before launching the app;
missing permission fails with E_PERMISSION_DENIED, without prompting or waiting for a grant.
Screen Recording permission is unnecessary. Posting events is not proof that a control
handled them; flows must assert the resulting callback or state change.
Newline-delimited JSON. After {"token": "..."} (answered {"ok": true, "protocol": 1}),
send one request at a time; each reply echoes id. Failures are
{"id", "ok": false, "error": {"code", "message"}}. timeoutMs is optional (maximum 120000).
| Command | Fields | Reply |
|---|---|---|
ping |
ok |
|
navigate |
url, timeoutMs (30000) |
Waits for the first rendered UI to settle (so link listeners are subscribed), then opens the URL through application:openURLs:, the path the OS uses for deep links. |
openFile |
path, timeoutMs (30000) |
path must be absolute and exist (E_INVALID_ARGUMENT, E_NOT_FOUND). Waits like navigate, then opens a file URL for it through application:openURLs:, the path the OS uses for a file opened from Finder. navigate rejects file URLs, so only openFile delivers files. |
waitFor |
testID, timeoutMs (10000) |
window identifier and frame of the first visible view whose accessibility identifier (React Native testID) matches. frame is in points from the top-left of the window's frame view, the area capture renders, so frame × scale is the view's pixels in a capture. E_TIMEOUT otherwise; timeoutMs: 0 checks once. |
setAppAppearance |
appearance (light/dark), timeoutMs (10000) |
Waits for the first rendered UI to settle (an override set while the app is still launching did not last), then sets NSApp.appearance (an app override; the OS setting is untouched; per-window appearances still win). If the effective appearance changed, replies changed: true once a UI update has mounted and settled, else E_TIMEOUT. timeoutMs bounds each wait. |
capture |
window (main, key, id:<identifier>, title:<title>), name, timeoutMs (5000) |
file, width, height (pixels), scale, once mounted UI updates have settled. E_NOT_SETTLED if they never do. |
restorePointer |
Restores window visibility, animation and app activation state changed by pointerEvents. Safe to repeat; also runs on disconnect. |
|
pointerEvents |
window (same as capture), x, y (top-left captured-frame points), timeoutMs (5000; 0 does not wait) |
Validates the window and point, waits for mounted UI updates to settle, shows and activates the window, then returns pid and base64-encoded down/up CGEvent data for that window and point. Explicitly opts out of hidden-window behavior. E_NOT_SETTLED, E_NO_WINDOW, E_INVALID_ARGUMENT, or E_EVENT on failure. Posts no input itself. |
quit |
ok, then the session ends and the app quits. |
"Settled" means no Fabric mount transaction for 100 ms. The host reports mounts through
RCTSurfacePresenterObserver. main is the window with identifier spark.main. key
fails with E_NO_WINDOW in driver mode, because an app that never activates has no key window.
| --- | --- |
the table above). Mac App Store review flags private API names found in a binary, even when they are
never called. So these functions compile only with the preprocessor flag
SPARK_TEST_DRIVER_PRIVATE_EVENTS=1, in builds that have the driver (Builds):
contains them. tests/test-driver-builds.test.ts checks the configuration, the Podfile
| --- | --- |
| --- | --- |
capture renders the window's frame view (contentView.superview, including the title
bar) with bitmapImageRepForCachingDisplayInRect: and cacheDisplayInRect:. The PNG is
at the window's backing scale and tagged with its color space (Display P3 on most Macs).
Measured in tests/test-driver.native.mm, a window that was never shown:
| Content | Rendered |
|---|---|
| Layer background colors (React Native view styles) | Yes, exact color |
drawRect: content (React Native text) |
Yes |
Layer contents images |
Yes |
Native controls and labels (NSButton, NSTextField) |
Yes |
| Title bar and traffic lights | Yes, in the inactive style |
Behind-window vibrancy (NSVisualEffectView, BehindWindow) |
No: a flat material gray, because the blur comes from the window server |
| Window shadow and rounded corners | No: outside the frame view |
-[CALayer renderInContext:] on the frame view's or content view's layer was also tried.
For a window that was never shown, it produced an empty, transparent image even after
[CATransaction flush], with no layer colors, drawn content or images. The driver does
not offer it. Content the window server composites, such as behind-window vibrancy,
needs an out-of-process capture, which requires the TCC grant tracked in #50.