Skip to Content
ReferenceActionsIOS

iOS Action

The iOS action drives an app on an iOS simulator through XCUITest: it launches the app, taps and types into it, waits for elements, reads their text and takes screenshots. It runs on macOS alone, with Xcode.

It is an external action, published in mozership/probe-ios . Probe downloads it the first time a workflow uses it, and runs the executable whose SHA-256 the action.yml at the pinned commit names. It needs Probe v1.27.0 or later, which calls an action once a run is over.

How It Works

The action works through a test runner of XCUITest, which it builds from its source with the Mac’s own Xcode the first time a step needs it, and keeps in a cache. The first step of a run that uses it starts the runner on the simulator with xcodebuild test-without-building, and sends it each action over HTTP on 127.0.0.1; the runner finds the element with XCUITest and acts on it. The runner is kept for the run’s other steps, and stopped once the run is over.

WhatHow
Xcodexcode-select -p has to point at it, not at the Command Line Tools: sudo xcode-select -s /Applications/Xcode.app
An iOS simulator runtimexcodebuild -downloadPlatform iOS
A booted simulatorxcrun simctl boot "iPhone 17", or open Simulator.app
The app under testInstalled on the simulator: xcrun simctl install booted path/to/MyApp.app

The action does not boot the simulator or install the app. Do that in a step of its own, such as a shell step, so that a failed boot is not taken for a failed test.

Basic Syntax

A workflow pins the action by a full commit SHA. The notes of each release  start with the action and its commit to copy.

name: Settings actions: ios: github.com/mozership/probe-ios@aed5436143e9536754e2123b953831f60cd635eb # v0.2.0 jobs: - name: settings steps: - name: Open General uses: ios timeout: 15m with: app: com.apple.Preferences actions: - name: launch args: ["-AppleLanguages", "(en)", "-AppleLocale", "en_US"] - name: tap selector: com.apple.settings.general - name: wait selector: type=navigationbar identifier=General - name: text id: back selector: type=button identifier=BackButton test: res.code == 0 && res.results.back == "Settings"

The launch arguments start the app in English whatever language the simulator is set to, so that the labels a selector names do not change with it.

The First Run

The first step that uses the action builds the runner, which takes from tens of seconds to a few minutes, and says so even without -v:

[WARN] ios: building the iOS test runner; the first run takes a few minutes, so give the step timeout: 10m if it runs out

A step has 5 minutes by default, which a first build can run out of, so the example gives the first step a timeout of its own. Later steps take the runner from the cache, ~/Library/Caches/probe-ios, or PROBE_IOS_CACHE_DIR when it is set; it is built again once the action or Xcode changes. Steps that start at once build it once, and the others wait for that build.

A simulator runs one runner at a time, so two runs of Probe at once need a simulator each. Steps of one run that use the same simulator take turns.

Parameters

ParameterTypeRequiredDefaultDescription
appStringYes-The bundle identifier of the app the actions drive
actionsListYes-The actions to take, in order. See Actions
deviceStringNothe one booted simulatorThe simulator, by its name or UDID. It has to be booted, and has to be given when more than one is
timeoutDuration string or number of secondsNo30sLimit for the actions, from the first to the last. An action on an element that it runs out on waits up to a minute more for the runner to say why it failed, as XCUITest first waits for the app to be idle, which can take that long on a simulator just booted. It does not count building the runner or starting it, which the step’s own timeout bounds

Actions

Each entry of actions names its action in name:

ActionFieldsWhat it does
launchargsLaunches the app afresh, ending it first if it runs, with args as its launch arguments
activateBrings the app to the foreground, launching it if it is not running
terminateEnds the app
tapselectorTaps the element
typeselector, valueTaps the element and types value into it
waitselectorWaits until the element exists
textselector, idReads the element’s label, or its value when it has no label, into res.results
treeidReads the app’s accessibility tree, as XCUITest describes it, into res.results. It shows what a selector can name
screenshotidSaves the screen as PNG, and puts its path in res.filepaths

id is the key of the result; without it, the action’s name is. An action on an element waits for it to appear, for what is left of timeout, and the step fails when it does not. An entry with a key an action does not take, such as a misspelt selector, is refused before the step runs.

Selectors

A selector names an element by key=value pairs, separated by spaces. A value with a space goes in double quotes.

KeyMatches
identifier (or id)The accessibility identifier, as .accessibilityIdentifier("login") in SwiftUI sets it
labelThe accessibility label, which is mostly the text shown
typeThe element type: alert, any, button, cell, image, link, navigationbar, other, searchfield, securetextfield, slider, switch, tabbar, text, textfield, textview
indexWhich of the matching elements, from 0. Without it, the first

A selector with no = is an identifier, so login is identifier=login.

Response Object

FieldDescription
res.code0 when every action succeeded
res.resultsWhat text and tree read, by key
res.filepathsThe files screenshot saved, by key
res.deviceThe UDID of the simulator the actions ran on

An action that cannot finish fails the step with an action error, which exits the run with status 3, and there is no res to test:

actions[0] tap: no element matches label=No Such Row within 4.5s

CI

On GitHub Actions, run the workflow on a macOS runner, and create and boot a simulator before Probe runs. probe-action  installs Probe on Linux alone, so the job installs it with go install. Keep the cache directory between runs, so that the runner is not built every time:

name: E2E on: [pull_request] jobs: e2e: runs-on: macos-26 env: PROBE_IOS_CACHE_DIR: ${{ github.workspace }}/.probe-ios steps: - uses: actions/checkout@v6 - uses: actions/setup-go@v6 with: go-version: stable - run: go install github.com/mozership/probe/cmd/probe@latest - uses: actions/cache@v5 with: path: .probe-ios/runner key: probe-ios-runner-${{ runner.os }} - name: Boot a simulator run: | UDID=$(xcrun simctl create e2e "iPhone 17" com.apple.CoreSimulator.SimRuntime.iOS-26-5) xcrun simctl bootstatus "$UDID" -b - run: probe e2e.yml

The action names its cache by the Xcode and the runner it builds, so a cache that is out of date is built over, not used.

Updated at