MAV
Use mav when validating an iOS app locally. MAV is deterministic: it
does not explore or repair routes by itself. The agent decides the next action.
mav --version reports the binary's version, and mav doctor carries it as
mav_version. Include it when you report a problem: behaviour differs between releases
and a report without it starts with a guess.
Platforms
mav drives iOS simulators, physical iOS devices, and macOS apps. The platform comes
from target_kind in .mav/config.yaml: simulator, device or macos.
A repo whose app ships on more than one platform (same codebase, different build target) uses profiles instead of two configs:
app_target: "//App:MyAppiOS"
launch:
commands:
build: "bazelisk build '//App:MyAppiOS'"
profiles:
mac:
target_kind: macos
app_target: "//App:MyAppMac"
launch:
commands:
build: "bazelisk build '//App:MyAppMac'"
install: "" # explicitly none: macOS has no simctl install
Select one with mav open --profile mac, or set default_profile. An empty string in a
profile annuls the inherited value; an absent key inherits it. A profile that does not
exist fails with profile_not_found rather than silently using the base.
On macOS these work: ui tree, ui tap, ui doubleTap (by --id/--text selector or
--x/--y; inline-rename UIs that open on double click need this), ui type, ui erase, ui swipe, ui wait,
capture, open, app list, openURL, clipboard, logs, crashes, evidence
(including video), run, network and time travel|reset. ui hideKeyboard succeeds without doing
anything: there is no on-screen keyboard to hide, and failing would force a shared flow
to branch by platform.
What does not exist there, with a structured error saying why: multitouch gestures
(pinch, rotate, twoFingerPan), hardware buttons, the simulator/device commands
(including sim appearance and sim statusbar),
time freeze|scale (a system clock runs, it cannot be stopped or accelerated) and
location (macOS has no supported way to feed CoreLocation a fake fix; Xcode's "Simulate
Location" is an iOS-device feature and does nothing against a Mac app).
ui swipe becomes a scroll with the direction inverted, so one flow means the same thing
on both platforms. time travel --to moves the machine's clock, so it is refused
outside a VM unless you pass --system-clock. network start also points the system at
the proxy and restores it on stop.
Selectors behave differently from iOS: the driver does not expose AXIdentifier, so
--id takes an element_token that is only valid inside the snapshot that produced it.
Re-read the tree before acting on it; a stale token is refused rather than applied to the
wrong element. --text is the selector that survives across snapshots.
macOS in a disposable VM
vm: true next to target_kind: macos runs the app in a throwaway machine instead of
on the user's. That is the entire config surface; there is no host, key or tool to
name.
target_kind: macos
vm: true
Nothing about how you drive mav changes. Same commands, same arguments, same
output, and evidence still lands in the local .mav/runs/<id>/. The one visible
difference is vm=true in the response fields, which is how you tell whether what you
just drove was the VM's app or the user's own machine.
What you do need to know:
mav doctorreportsvm_tooling,vm_imageandvm_lease. Run it first when a VM project misbehaves.- Every VM failure carries the command that fixes it in
next. Tell the user to run that; do not go looking for the underlying hypervisor.vm_tooling_missing,vm_tooling_outdated→mav setup --install vmvm_image_missing,vm_image_incomplete,vm_image_ungranted→scripts/build-mav-vm-image.sh. The last one names which permission switch is off, and flipping it needs a human at the VM's screen: macOS has no scriptable way to grant those.
- Call
mav stopwhen you are done. Only two macOS VMs can exist at once, so a machine you leave leased blocks the next run. An idle timeout catches the case where you crash, but it costs the user twenty minutes of a slot they could be using. - The first
mav openis slow: it boots a machine and ships the built bundle across. Later commands reuse it. buildstill runs on the user's machine; only the app runs in the VM. A build failure is a build failure, not a VM problem.openmay answerresigned=adhoc. That means the bundle would not have launched in the VM and mav re-signed the guest's copy: iCloud, push and anything else tied to the provisioning profile are gone from what you are driving. Say so if you report on behaviour that could depend on them.mav evidence startrecords video here too. It goes through the driver daemon rather thanscreencapture, which over SSH sees no display, so it works the same in a VM as on the user's own Mac. Callmav evidence stopto finalize it: the mp4's index is written on stop, and a run killed without it leaves a file no player opens.
Fixtures
fixtures are named states — lists of commands that leave the app in a known situation:
fixtures:
seeded:
- "./scripts/seed-db.sh"
empty:
- "./scripts/wipe.sh"
Apply one with mav open --fixture seeded, or fixture: seeded in a flow's open step.
They run after install and before launch — the only window where the container exists
and nothing holds the app's database open — and the app is closed first for the same
reason. They compose with --clear-state: the container is wiped, then the fixture seeds
on top. The applied fixture is recorded in report.json.
--fixture cannot be combined with --no-relaunch, which skips the launch recipe
entirely.
Workflow
Run
mav doctor. If it reports CoreSimulator or idb sandbox/permission failures, rerun MAV outside the sandbox.If the project lacks
.mav/config.yaml, runmav setupand review the prompts. Setup is idempotent and interactive by default: it detects app identity, simulator defaults, UI tools, and an editablelaunch.commandsrecipe, then lets the user accept or replace each value. Usemav setup --non-interactivefor CI/scripts. Detection is conservative: explicit MAV Make/Just targets, explicitscripts/mav-build+scripts/mav-app-path, and standard Bazel/Xcode/Tuist shapes.If the validation needs a specific simulator, runtime, or locale, use
mav sim list, thenmav sim select --device ... --ios ... --locale ... --language .... For App Store screenshots,mav sim appearance light|darkandmav sim statusbar set --preset appstorecontrol the simulator's appearance and status bar; see App Store Screenshots below. You can also pass the same target flags tomav open. For a physical iOS device, usemav device list, thenmav device select --udid ...ormav device select --name .... Physical devices require idb for install, launch, logs, screenshots, and crashes. Multitouch, system UI, andhideKeyboardare simulator-only and return structured errors on device. Simulator crash checks use local DiagnosticReports directly.Start the app with
mav open. Usemav open --clear-statefor a fresh install. Usemav open --skip-buildwhen the app is already built and the launch recipe'sbuildstep would only rebuild the same artifact --app_path,installandlaunchstill run. Usemav open --no-relaunchwhen the app was launched manually with customSIMCTL_CHILD_*environment and MAV should only attach to the app already in front. This creates.mav/runs/<run-id>/and startslogs.txt. MAV captures a filtered unified log stream for MAV probes and app-process logs whenprocess_nameis configured. On physical devices, generated simulator install/launch recipes are mapped to idb when possible. MAV writes/tmp/mav/sim-locks/<udid>.jsonfor simulator runs; if another worktree owns a fresh lock, pick a different simulator unless you are sure you own that run and pass--force.Prefer
mav ui treeto understand the current screen. It prints compact screen metadata followed by boundednode ...lines with ids, labels, roles, values, enabled state, subroles, titles, pids, focus state, and frames when available. Treat this as the primary structured UI source for agents; do not ask for--json. If the simulator accessibility service returns an emptyAXApplicationtree, MAV attempts recovery internally; do not work around it with screenshots unlessmav ui treefails after recovery. Usemav ui tree --include-systemwhen inspecting system UI, PHPicker, permission prompts, SpringBoard, or cross-app service processes; this asks baguette for the SpringBoard/system tree on simulator. The--include-systemflag is sim-only — on a physical device it returnstree_system_unsupported_on_device.Use
mav capture --name <descriptive-name>only when the tree is insufficient or visual evidence is needed. Captures are unique by default under.mav/runs/<run-id>/captures/, and--namegives the client and report a stable, readable proof point such aslargest-videos-after-pinch.Use
mav ui tap/type/erase/hideKeyboard/swipe/longPress/wait/scrollUntilfor manual exploration. Prefer accessibility identifiers first (--id).mav ui erase --focusedclears a focused field: baguette on simulator, and on macOS the driver sets the field to the empty value, which does not depend on the field holding focus.mav ui hideKeyboarddismisses the keyboard via baguette on simulator and is a successful no-op on macOS. Both return structured errors on a physical device (erase_unsupported_on_device,hide_keyboard_unsupported_on_device). UsescrollUntilbefore tapping targets that are present in the tree but may be off-screen. Use coordinates only when the tree is insufficient and the screenshot makes the target unambiguous. Use text as the last option because labels change with localization and copy edits. Usemav ui wait --id,--text, or--valuefor readiness checks.Coordinates are always in the space
mav ui treereports. On a rotated simulator every coordinate gesture —tap,doubleTap,swipe,longPress,pinch,twoFingerPan,drag,dragPath— rotates them into the touch surface's own space for you (90/270 only; an upside-down tree cannot be told apart from an app that never flipped, so 180 dispatches raw withrotation_unavailable=180). Do not pre-rotate them yourself, that compensates twice. The result line carriesrotation=plus the dispatchedhid_x/hid_y, orhid_start/hid_endfor a swipe's two endpoints.Pass all four swipe coordinate flags or none: a partial set fails
swipe_coordinates_incomplete.ui swipe --directionhas no caller-supplied points, so its endpoints are re-derived as fractions of the rotated screen and reported withdirection_endpoints=derived— "up" is up on the screen you are looking at. If no screen size can be resolved the fixed portrait defaults go out as written androtation_unavailable=says the axis is uncompensated.Rotate through
mav ui orientation, not around it. MAV can only compensate for a rotation it can see, and there are exactly two it can: one it applied itself, and one Simulator.app's window records. A simulator turned bybaguette orientationor a raw GSEvent leaves no readable trace, and the tree alone cannot say which of the two landscapes is in effect — they differ by 180°, so a guess puts every tap in the opposite corner. On a headless boot (everysimpoolrun) Simulator.app has no window at all, so this is the only source there is.mav ui orientation landscape-right mav ui tap --x 624 --y 330 ok ... rotation=90 hid_x=72 hid_y=624 rotation_source=mavrotation_unavailable=means MAV saw a rotation it could not apply and dispatched raw;unknown_landscapespecifically means runmav ui orientationand try again.portrait-upside-downis applied but never compensated.rotation_rerouted=axemeans the gesture avoided AXe, which refuses coordinate gestures on a rotated simulator it cannot read the orientation of.ui rotatecannot dispatch at all: no driver provides that capability.mav ui treemay report a natural screen id when the AX root already has aView-suffix identifier, such asSettingsView→settings-view. This is a labelling/observability signal only. Selectors for tapping still work regardless.Sessions started with
mav openhave a renewable 15-minute inactivity lease. Each MAV command keeps the lease alive, including heartbeats during long commands. Expiration automatically stops run-owned streams, resets non-preserved time control, and releases the simulator lock. Usemav stoponly for immediate cleanup.mav runstops run-owned streams deterministically.mav run flow.yamlalways creates its own run and never adopts or kills whatever.mav/current-runnames -- safe to run concurrently against the same repo from separate agents. It still publishes.mav/current-runfor manual follow-up (mav logs/mav stop/mav evidence reportwithout--run), but never by stealing the pointer from a different run that's still alive. Pass--run RUN_IDto continue an existing run (e.g. a second flow appending evidence to a run already opened); an id that doesn't name a real run fails withrun_not_found.
For evidence flows that need mitmproxy on macOS 26+, remember that mitmproxy's
local Network Extension may take several seconds to become ready after launch.
If traffic is not appearing, check mav network start output and the run log
before assuming the app made no requests.
Internal Execution Validation
To prove code reached a point:
- Add a temporary
OSLog.Loggermarker with a stable key. Use thelog_subsystemandlog_categoryfrom.mav/config.yaml, and make the message start withMAV_LOG key=<StableKey>. - Trigger the behavior with MAV.
- Run
mav logs --key <StableKey>. - Remove the temporary logger code before finishing unless it is intentionally becoming product logging.
Example Swift marker:
import OSLog
private let mavLog = Logger(
subsystem: "mav.com.example.app",
category: "probe"
)
mavLog.notice("MAV_LOG key=SettingsReached")
mav logs reads the run log captured from mav open; it does not start new log
streams. Do not use Swift print for MAV validation probes.
Native MAV flows may include project-local shell assertions when the repo has
allow_shell: true in .mav/config.yaml:
- exec: { cmd: "grep -F 'MAV_LOG key=SettingsReached' $MAV_LOGS", contains: SettingsReached, timeout: 5s }
Use this for narrow checks against logs, generated files, or local test API
calls. MAV runs the command in the project root with MAV_ROOT, MAV_RUN_ID,
MAV_RUN_DIR, and MAV_LOGS set, writes stdout/stderr into the run directory,
and applies the requested timeout. Treat this as a trusted-project opt-in, not
as a hard sandbox for arbitrary untrusted commands.
Use out when a trusted helper should feed later steps. MAV binds trimmed
stdout; binding names must use letters, numbers, _, or -, and cannot start
with a number or -. JSON stdout exposes fields through ${exec.NAME.field},
while plain text stdout is available as ${exec.NAME}:
- exec:
cmd: "node utils/get_test_user.js sellersXp"
out: credentials
timeout: 10s
- type: "${exec.credentials.email}"
Evidence
Use evidence when the user needs proof of verification:
Prefer writing a temporary MAV YAML flow and running it with
mav run.The flow should navigate to the relevant state first when that setup is not the behavior under test. Start recording as late as possible while still covering the verified action, use
waitfor a singleid,text, orvalue, usewaitUntilwithanyorchangedFromfor alternate/visual outcomes, usewhenfor optional UI that may already be dismissed or in a different state, and usedelayonly for fixed launch/animation waits when tree-based waits are not possible. Capture named proof points, perform the tested action, capture the result, stop recording immediately after the result is visible, check crashes, and generate a report.Names should describe the assertion, for example
settings-before-toggleandsettings-after-toggle.mav evidence reportwrites a verified evidence manifest at<run-dir>/report.json. MAV owns the facts: video duration/frames, screenshot decodability, network HAR status/counts, issue severity, crashes, commands, and log tail. Current project runs normally live under.mav/runs/<run-id>/; legacy or ad-hoc runs may live under/tmp/mav/<run-id>/. Use the paths printed by MAV instead of guessing.Author the HTML report. This is mandatory, not optional.
mav evidence reportonly writes the JSON manifest; the skill owns the HTML. Aftermav evidence reportsucceeds, readreport.jsonand author a self-contained<run-dir>/report.htmlfor that specific run. Use./templates/evidence-report.htmlas a reference, not as a fixed renderer: rewrite the copy, metrics, media, and sections so the report explains the actual evidence.Do not treat any of the following as a valid evidence deliverable:
- opening the run folder in Finder
- opening
video.movin QuickTime - linking only to
report.json - saying "see the artifacts in
<run-dir>"
The HTML is the deliverable. Share it and the manifest as clickable Markdown links using absolute paths, for example
[MAV evidence report](/path/to/repo/.mav/runs/<run-id>/report.html)and[evidence data](/path/to/repo/.mav/runs/<run-id>/report.json). Embed the MP4 video whenvideo_status=acceptedandvideo_mp4is present; otherwise fall back tovideoonly when the browser can play it. The HTML must include a visible video download button whenevervideo_status=accepted; ifvideo_mp4exists, download that browser-friendly file, otherwise downloadvideo. If the manifest reportsvideo_status=missingorinvalid, say that video evidence was not accepted. If the manifest includesnetwork.har, link it directly and include the manifest's request, response, error-status, domain, active, and issue fields in the HTML. Embed key captures when they are relevant, for example[video](/path/to/repo/.mav/runs/<run-id>/video.mov)and[after-toggle](/path/to/repo/.mav/runs/<run-id>/steps/02_after-toggle.png).
Evidence Report Standard
MAV reports must be built like visual explainers: dense, visual, and explicit about what each artifact proves. Do not treat a media file as proof just because it exists.
- Before authoring HTML, read:
./references/evidence-html.mdfor the MAV evidence report structure../references/style-rules.mdfor visual-explainer-grade design rules../references/quality-checks.mdbefore delivery../templates/evidence-report.htmlas a starting shape, not as a fixed renderer.
- Think briefly before writing. Decide the audience, claim under test, evidence shape, and aesthetic. Evidence reports are usually timeline + dashboard + media review: CSS timeline for named captures, dashboard metrics for manifest health, and full-width video/image sections for the primary proof.
- Use the CLI manifest as the source of truth. Do not hand-wave around manifest blockers: invalid video, zero/too-short duration, low frame count, missing screenshots, undecodable images, missing assertion notes, active network captures, empty HAR files, or HAR parse failures must be surfaced in the report and in the final answer.
- The HTML should start with evidence, not prose: a large accepted video or strongest valid screenshot must dominate the first viewport. Put verdict, video status, valid/invalid step counts, network request count when present, crash count, and command count next to that evidence.
- Every evidence step needs a human explanation. The note should state the observed claim, not merely repeat the file name. Weak notes such as "after" or "screen" should be treated as low-quality evidence and clarified in the narrative.
- Screenshots must be verified by the manifest as decodable images with real dimensions. If the image is invalid, too small, missing, or unrelated to the claim, mark it rejected and rerun the flow when possible.
- Videos of zero seconds, missing duration, too-short duration, or too few frames are not accepted as video evidence. Rerun the flow with a recording window that covers the behavior itself, not unrelated setup or idle padding.
- The report must be self-contained in styling and narrative. Use local evidence files only; do not use remote CSS, JS, fonts, or image assets. Do not use a generic renderer that blindly maps JSON to cards. The agent must compose the page like visual-explainer does: choose an aesthetic, establish a visual hierarchy, write the explanatory text, and place video/images where they carry the claim.
- Reports are evidence workspaces. Include direct
Open,Download, andCopy pathaffordances for the primary video and named captures. The primary video download button is mandatory whenever the manifest has accepted video. Include directOpen,Download, andCopy pathaffordances fornetwork.harwhen present. IncludeCopy logsandCopy commandscontrols for audit sections. - Prefer a before/action/after sequence. If a behavior is visual, pair the
screenshots with a wait or
changedFromassertion so the report explains why the captured state is meaningful. - If the HTML has 4 or more major sections, include a compact sticky table of contents on desktop and a horizontal section nav on mobile. Evidence readers should be able to jump from verdict to video, timeline, integrity, logs, and command trail without hunting.
- After writing the HTML, inspect it when possible. Check that local videos and images load, that rejected artifacts are visibly rejected, that no text overflows at mobile width, and that the first viewport immediately shows the verdict plus primary visual proof.
The video should be limited to the relevant verification moment. Do not record long setup, idle time, or repeated navigation unless the navigation itself is being validated. The screenshots must prove the behavior itself, not just that the app opened. For a notification toggle, navigate to Settings first if Settings is not under test, start recording, capture before toggling, toggle it, capture after toggling, then stop.
YAML flow steps type, delay, and sleep accept concise scalar forms as
aliases for their object forms:
- type: "Search text"
- type: { text: "Search text" }
- delay: 500ms
- delay: { duration: 500ms }
- sleep: 500ms
- sleep: { duration: 500ms }
Use when to guard optional UI. It checks once and skips the do block without
failing when the condition is not visible. Keep open and exec as top-level
steps; they are not valid inside do blocks.
- when: { visible: { text: Continue } }
do:
- tap: { text: Continue }
Use whileNotVisible for chained onboarding or permission prompts. MAV repeats
the do block until the target id, text, value, or any condition is
visible, or until timeout expires. Mark dismiss taps as optional: true when
only some prompts appear:
- whileNotVisible:
text: "You"
timeout: 30s
do:
- tap: { id: onboarding_dismiss, optional: true }
- delay: 500ms
An optional step that fails is skipped, not done. The run still passes, but the
pass line carries skipped=N and skipped_steps=<step>:<action>, the trail
records that step as status: skipped with the reason in error, and
run.json lists it. Read those before believing a green run did everything the
flow says.
skipped=N always counts top-level steps. A when or whileNotVisible step
that skipped an optional child inside its do: block reports that separately,
as skipped_children=N on its own step record, and its executed=N counts
only the children that actually ran.
mav run --prefer-driver axe flow.yaml forces AXe for semantic UI steps. Use a
per-step prefer-driver override when a single interaction needs to pin the
driver:
- tap: { text: "Deporte y ocio", prefer-driver: axe }
- wait: { text: "Continuar", prefer-driver: axe, timeout: 5s }
- hideKeyboard: {}
open: { clearState: true } and open: { clear-state: true } are both valid
flow spellings. open: { skipBuild: true } skips the launch recipe's build
step for that one step; mav run flow.yaml --skip-build skips it for every
open step in the flow. See Reusing a build across runs below.
mav ui hideKeyboard dispatches through baguette on simulator and returns
hide_keyboard_unsupported_on_device on a physical device.
Use include to compose reusable flow fragments. Resolve paths relative to the
including YAML file and pass values through env; included steps can reference
them with ${env.NAME}. The file field can reference values from the same
env block:
- include:
file: "components/auth/${env.USER}.mav.yaml"
env:
USER: sellersXp
FRESH_INSTALL: true
The supported flow recording steps are video.start and video.stop;
evidence.start and evidence.stop remain supported aliases. Do not use or
invent recordVideo: true.
Use mav run for feature verification evidence where the tested behavior
needs taps, waits, logs, crash checks, or assertions. Keep the recording window
around the behavior rather than the whole setup path.
For off-screen elements, use scrollUntil in a MAV flow or mav ui scrollUntil
manually before tapping:
- scrollUntil: { id: privacy_policy_button, direction: up, maxSwipes: 4 }
- tap: { id: privacy_policy_button }
mav ui scrollUntil --id privacy_policy_button --direction up --max-swipes 4
mav ui tap --id privacy_policy_button
If there is no stable id, use coordinates only after capturing/inspecting a
screenshot. Coordinate taps can be useful for manual visual fallback, but they
are not the preferred basis for reliable routes. Use text only when neither
id nor coordinates are appropriate.
Gestures
Multitouch gestures (pinch, rotate, two-finger pan) dispatch through baguette on
simulator. They return gesture_unsupported_on_device on a physical device:
mav ui pinch --x 200 --y 450 --scale 0.5 --duration 800ms
mav ui pinch --x 200 --y 450 --scale 0.5 --pan-x 80 --pan-y -40 --duration 800ms --hold 2s
mav ui rotate --x 200 --y 450 --degrees 30 --hold 1s
mav ui twoFingerPan --x 200 --y 450 --pan-x 80 --pan-y -40 --hold 1s
--hold DURATION keeps both fingers down at the final positions before
releasing. This also applies to simultaneous pinch+pan via mav ui pinch --pan-x/--pan-y. MAV waits for duration + hold before returning or advancing
to the next flow step, so a following mav capture --name ... or evidence step
does not race ahead of the gesture.
In YAML flows, gesture steps accept the same hold key:
- pinch: { x: 200, y: 450, scale: 0.5, panX: 80, panY: -40, duration: 800ms, hold: 2s }
- capture: { name: zoom-held }
App Store Screenshots
Two simulator-wide knobs make the shots reproducible. Both are simulator-only and
return a structured error on a physical device (appearance_unsupported_on_device,
status_bar_unsupported_on_device) and on a macOS target (the same codes ending
in _unsupported_on_macos). The codes are emitted by the CLI; inside a flow the
step fails as appearance_set_failed / status_bar_set_failed, as the other
device actions already do.
mav sim appearance dark
mav sim appearance light
mav sim statusbar set --preset appstore # 9:41, full battery, full signal
mav sim statusbar set --time 9:41 --battery-level 100 --cellular-bars 4 --wifi-bars 3
mav sim statusbar clear
--preset appstore is the status bar Apple uses in its own marketing shots. Every
field stays individually settable and an explicit flag overrides the preset, so a
screenshot that needs a different clock or a low battery is still one command.
The override is additive: --time alone changes the clock and leaves the rest of
the status bar as it is. Clear it when the run is done, or the next capture in the
same simulator inherits it.
Both are also flow actions, so the screenshot matrix is one flow, re-run per
language after mav sim select --language de --locale de_DE (the language is a
launch argument, not a flow param). Build once and pass --skip-build on every
run, or the same unchanged app is rebuilt once per language:
mav open
for locale in en_US de_DE es_ES; do
# Name the device: `mav sim select` with no target selector re-picks one,
# and a leftover booted simulator from another project can win it.
mav sim select --device "iPhone 17 Pro Max" --ios 26 \
--language "${locale%%_*}" --locale "$locale"
mav run app_store_shots.yaml --skip-build
done
The same flow, unchanged:
name: app_store_shots
steps:
- sim.statusbar.set: { preset: appstore }
- sim.appearance: { appearance: light }
- open: { clearState: true }
- capture: { name: home-light }
- sim.appearance: { appearance: dark }
- capture: { name: home-dark }
- sim.statusbar.clear: {}
sim.statusbar.set accepts preset, time, dataNetwork, wifiMode, wifiBars,
cellularMode, cellularBars, operatorName, batteryState, batteryLevel.
Quote time in YAML.
mav flow lint flow.yaml validates those fields with the parser the run uses:
appearance must be light or dark, preset must be appstore, the enum and
0-N fields are range-checked, and a sim.statusbar.set with no fields at all is
an error, while a ${params.x} binding is left for the run to resolve. Lint the
matrix before running it — a bad value found at step 12 costs
the eleven captures before it. sim.statusbar.clear takes no fields; passing any
is a warning, since it resets the whole bar.
Appearance and the status bar are simulator state, not app state: they survive a relaunch, so set them once per matrix cell rather than per capture.
mav sim appearance waits two seconds after the switch, long enough for the
screen to repaint: the capture path otherwise serves the pre-switch frame and the
dark cell of the matrix comes out light. No delay step is needed between it and
the capture.
Launch Recipes
MAV does not own the project build system. .mav/config.yaml should define
the commands needed to run the app:
app:
bundle_id: com.example.app
process_name: Example
launch:
mode: custom
commands:
build: ./scripts/mav-build.sh
app_path: ./scripts/mav-app-path.sh
install: xcrun simctl install "$MAV_UDID" "$MAV_APP_PATH"
launch: xcrun simctl launch "$MAV_UDID" "$MAV_BUNDLE_ID"
Physical device launch recipes should use idb:
launch:
mode: custom
commands:
build: ./scripts/mav-build-device.sh
app_path: ./scripts/mav-app-path-device.sh
install: idb install --udid "$MAV_UDID" "$MAV_APP_PATH"
launch: idb launch --udid "$MAV_UDID" -f "$MAV_BUNDLE_ID"
Each command runs from MAV_ROOT with MAV_RUN_DIR, MAV_TARGET_KIND,
MAV_IS_DEVICE, MAV_UDID, MAV_BUNDLE_ID, MAV_APP_PATH,
MAV_DEVICE_NAME, MAV_RUNTIME, and MAV_PLATFORM. app_path must print
exactly one .app path. If the app is already installed, configure only
launch.
Giving the app its own environment
Put NAME=value in front of the launch command and it reaches the app:
launch: BOXY_FORCE_PAID=1 xcrun simctl launch "$MAV_UDID" "$MAV_BUNDLE_ID"
MAV translates it per target (SIMCTL_CHILD_* on a simulator, IDB_* on a
device, the process environment on macOS), so relaunching by hand with
SIMCTL_CHILD_* is no longer needed for a flag the app reads at start. Values
can use the MAV_* variables (OUT=$MAV_RUN_DIR/out). The commands trail
records the names, never the values: launch.launch driver=simctl env=BOXY_FORCE_PAID. Read that line to confirm the variable was passed — if it
has no env=, MAV did not pass one. On a physical device the names idb uses
itself (UDID, COMPANION, COMPANION_TLS) are refused with an error; the
match is exact, so a lowercase udid — which idb never reads — is allowed.
A prefix on install runs verbatim in the shell instead: those variables are
for the install tool, not for the app (its values are redacted in the trail).
Values follow shell rules: single quotes mean literal, and command substitution
($(...), backticks) is refused (launch_env_command_substitution) because the
driver path has no shell — compute it in build/app_path instead. A launch
line that reduces to assignments only, which one missing quote produces, fails
with launch_command_only_env rather than launching the app as though the
command had run.
The translation only fires for a launch line MAV recognizes: the canonical
xcrun simctl launch "$MAV_UDID" "$MAV_BUNDLE_ID" / idb launch ... "$MAV_BUNDLE_ID" form, or an empty command with bundle_id set. A hardcoded
bundle id or a wrapper script instead runs in the shell verbatim, where the
prefix sets the variable on the launch tool, not the app; MAV emits a
launch_env_not_translated warning when it can tell that drop is certain
(not for a wrapper script, which may re-export on its own).
Reusing a build across runs
The build step is the expensive one and the one that produces nothing new when
the checkout has not changed. --skip-build drops it and keeps app_path,
install and launch. It is applied to the recipe's build step, not to one
build system, so it works for every launch mode -- with the caveat that
mode: already_installed has no build and no app_path to begin with, so
there it is a no-op rather than a saving.
mav open --skip-buildcovers that one launch.mav run flow.yaml --skip-buildcovers everyopenstep in the flow, including the ones that do not mention it. Use this for a matrix that runs the same flow once per language: build once, then reuse.open: { skipBuild: true }marks a single flow step, for a flow that builds in its firstopenand reuses it in later ones.mav run --target ... --target ...already builds once and each target's child run carries--skip-build.--skip-buildis rejected with--no-relaunch, which skips the whole recipe.
If nothing was built, app_path cannot resolve an artifact and mav open says
so itself instead of passing the build system's error through:
fail code=build_skipped_app_missing logs=.mav/runs/439a2e85/logs.txt next="rerun without --skip-build" run=439a2e85 stderr="build was skipped (--skip-build) and no built app was found: app_path printed /repo/build/App.app, which does not exist" step=app_path
The same code comes back when app_path prints a path that is not on disk.
Inside a flow the step fails as open_failed, like every command wrapped into a
flow step, and carries that whole line in detail:
fail code=open_failed action=open detail="fail code=build_skipped_app_missing ... next=\"rerun without --skip-build\" ..." step=1
Either way the run's commands.jsonl gets a launch.skip_build_check entry
naming the path MAV looked for. On that code, rerun the same command without
--skip-build once, then resume.
video.start / evidence.start video recording is simulator-only in this
release. On physical devices, use capture / evidence.step screenshots,
crash checks, logs, and reports for evidence.
Environment variables MAV reads
These are read from your environment. The launch recipe's own variables
(MAV_ROOT, MAV_UDID, MAV_APP_PATH, ...) are the other direction --
MAV sets those for the commands it runs; see "Custom launch recipes" above.
| Variable | Status | What it does |
|---|---|---|
MAV_TARGET_KIND / MAV_TARGET_UDID / MAV_TARGET_NAME / MAV_TARGET_RUNTIME |
supported | Pin the target, beating both a config pin and target_command. mav run --target ... sets them on each matrix child. |
MAV_PROFILE |
supported | Selects a platform profile, below --profile and above default_profile. |
MAV_EXACT_RUN_DIR |
supported, internal | Pins run state to this exact directory instead of allocating one under .mav/runs/. mav run --target ... --target ... sets it per matrix child so each target gets an unambiguous run dir. Set it yourself only to place a run's state somewhere specific. |
MAV_DRIVERS_DISABLE |
supported, internal | Comma-separated driver ids to suppress. Changes routing, so a stale export makes mav doctor disagree with reality. |
MAV_MATRIX_CHILD |
internal, do not set | Marks a matrix child. Exporting it makes mav run --target a --target b stop fanning out, silently. |
MAV_SKIP_BUILD |
gone | Was the private channel mav run --target used to tell its children not to rebuild. Removed in v0.16.2 and now silently ignored. The supported spelling is the --skip-build flag (mav open --skip-build, mav run flow.yaml --skip-build, open: { skipBuild: true }). |
When target_command cannot pick a simulator
If .mav/config.yaml sets target_command, that is the source of the
simulator -- unless --target / MAV_TARGET_* or a pinned simulator_udid
overrides it. A pin wins outright and reports target_command_ignored on
the ok line; it is a warning, not a failure.
Where target_command is what should answer and cannot -- it exits
non-zero, prints nothing, or exceeds target_command_timeout (3 minutes by
default) -- the command fails. MAV does not fall back to whatever
simulator is booted:
fail code=target_command_timeout detail="no UDID after 3m0s" fallback=none remediation="Raise target_command_timeout in .mav/config.yaml, or set target_command_required: false to allow the booted-simulator fallback" target_command="simpool lease --device \"iPhone 17 Pro\" --os 26.3" target_command_timeout=3m0s title="Configured target_command timed out; no fallback"
Codes: target_command_failed (non-zero exit -- the pool said no),
target_command_timeout (raise target_command_timeout, or make the
command faster), target_command_empty (the command printed no UDID),
target_command_timeout_invalid (target_command_timeout is not a Go
duration). All carry fallback=none and exit non-zero.
Two commands are exempt and still work through the failure, on purpose:
mav doctor reports it as target_command_warn and still gives you the
diagnosis, and mav sim select does not consult target_command at all, so
pinning a simulator remains available as the escape from a broken pool
manager.
Do not work around a failure by unsetting target_command -- on a machine
with several simulators booted that is exactly how a capture ends up taken
on a device nobody chose. Fix the command, or raise the timeout. The one
deliberate escape hatch is target_command_required: false in
.mav/config.yaml, which restores the warn-and-fall-back behaviour and
reports target_command_warn=... on the command's success output.
Command Output
Output is intentionally compact and agent-friendly by default:
ok cmd=open run=7fd log
…(truncated)