Skip to content

Mobile iOS

Tales V1 supports iOS automation through Apple’s official tooling and a repository-owned Swift/XCUITest HTTP driver. There is no Appium server, no Maestro runtime, no IDB requirement, and no external WebDriverAgent dependency.

.tales scenario
→ step "mobile"
→ internal/runtime/mobile.go
→ internal/provider/mobile (Go)
→ xcrun simctl + xcodebuild
→ embedded XCUITest driver (extracted from the binary on first use)
→ XCUIApplication(bundleIdentifier: <SUT>)

The Go provider owns simulator lifecycle, app installation (and clear_state uninstall/install) via simctl, step serialization per mobile target, implicit waits, and artifact collection. App launch and termination are driven through the Swift driver (XCUIApplication.launch() / terminate()) so XCTest keeps a live handle on the app process. The Swift driver lives under drivers/apple/TalesAppleDriver/ and exposes a small HTTP/JSON surface for hierarchy, tap, input text, clear text, screenshot, launch, and terminate operations.

Allowed runtime deps

  • xcrun
  • xcrun simctl
  • xcodebuild
  • XCTest / XCUITest
  • Swift code owned by this repository

Explicitly not used

  • Appium
  • Maestro runtime or CLI
  • IDB as a required runtime dependency
  • external WebDriverAgent
  • Selenium / Playwright for mobile automation
  • third-party mobile automation runtimes

Maestro-style architecture can be useful inspiration, but Tales does not vendor or execute Maestro code.

The demo app lives under e2e/ios/demoapp/. It is a minimal SwiftUI app with bundle id org.taleslabs.tales.demo.

Screens:

  • Welcome, welcome.title, welcome.register
  • Register, register.screen, register.email, register.password, register.submit, register.error
  • Verification, verify.screen, verify.code, verify.submit, verify.error
  • Home, home.screen, home.title, home.email

The verification code is intentionally hardcoded to A1B2C3 so the mobile e2e flow is deterministic.

Cross-platform CI targets remain platform-neutral:

Terminal window
make test
make lint
make e2e
make e2e-failure

macOS / Xcode-only targets:

Terminal window
make doctor-ios
make build-ios-demo
make e2e-ios
make e2e-ios-failure

make doctor-ios prints system, Xcode, simctl, and iOS-related environment state without requiring optional variables to be set. Run it first when a local simulator behaves strangely after an Xcode upgrade. Prefer tales doctor when the Tales binary is available, it covers the same ground and adds embedded-driver cache introspection.

make build-ios-demo:

  • requires macOS, xcodebuild and xcrun
  • builds e2e/ios/demoapp/TalesDemoApp.xcodeproj
  • writes derived data under build/ios/demoapp
  • produces TalesDemoApp.app for iOS Simulator
  • writes the resolved app bundle to build/ios/demoapp/app_path.txt

make e2e-ios:

  • builds the Tales binary
  • builds the demo app
  • sets IOS_APP_PATH, IOS_BUNDLE_ID, and IOS_DEVICE_NAME (defaults to IOS_DEVICE_NAME="iPhone 17")
  • runs tales test ./e2e/ios/pass --seed 1234 --parallel 1
  • writes reports under build/reports
  • writes mobile artifacts under build/artifacts

make e2e-ios-failure runs the failing iOS suite, expects exit code 1, and verifies that the failure is the expected missing_element visibility failure, not a simulator/driver environment failure.

Terminal window
IOS_DEVICE_NAME="iPhone 17" make e2e-ios
IOS_DEVICE_NAME="iPhone 17 Pro" make e2e-ios-failure

The application under test must be built for the iOS Simulator. A physical-device .app bundle will not install into the simulator.

Tales V1 only auto-builds the repository demo app via make build-ios-demo. User applications should be built by the owning project and passed through:

Terminal window
IOS_APP_PATH=/path/to/MyApp.app \
IOS_BUNDLE_ID=com.example.MyApp \
IOS_DEVICE_NAME="iPhone 17" \
tales test ./my/mobile/suite --seed 1234
config {
mobile = {
targets = {
iphone = {
platform = "ios"
device_name = env("IOS_DEVICE_NAME", "iPhone 17")
app = env("IOS_APP_PATH")
app_id = env("IOS_BUNDLE_ID", "org.taleslabs.tales.demo")
driver = {
host = env("IOS_DRIVER_HOST", "127.0.0.1")
port = 9080 // optional in embedded mode; omit to auto-allocate a free port
external = false
// Embedded mode is the default: project/scheme omitted.
// The driver is extracted from the tales binary and built once.
}
}
}
}
}
scenario "iOS register demo app" {
# Guard the scenario so cross-platform CI runs do not try to
# exercise the mobile provider on Linux / Windows.
skip_unless {
os = ["darwin"]
env_set = ["IOS_APP_PATH"]
reason = "iOS tests require macOS and IOS_APP_PATH pointing at a simulator-built app"
}
step "mobile" "launch" {
platform = "ios"
target = "iphone"
launch { clear_state = true }
expect {
visible { id = "welcome.register"; timeout = "20s" }
}
}
step "mobile" "open_register" {
platform = "ios"
target = "iphone"
actions {
tap { id = "welcome.register" }
}
expect {
visible { id = "register.screen"; timeout = "10s" }
}
}
}

Element-targeted actions:

  • tap { id = "..." }
  • double_tap { id = "..." }
  • long_press { id = "..." duration = "1s" }, duration optional (default 1s).
  • input_text { id = "..." value = "..." secure = true }
  • clear_text { id = "..." }
  • swipe { id = "..." direction = "up" distance = 0.6 duration = "300ms" }, drags one finger across the element. direction is the finger travel (up / down / left / right); distance (optional, a fraction in (0, 1], default 0.6) is the travel as a share of the element’s relevant dimension; duration optional (default 300ms).
  • scroll { id = "..." direction = "down" }, scrolls the element’s content. direction is the content direction to reveal (the finger travels the opposite way). Accepts the same optional distance / duration as swipe.
  • scroll_to { id = "..." } (or label = "..." / text = "..."), scrolls the element into the viewport so a follow-up tap / input_text can hit it. Best-effort: a no-op when the element is already in the safe area, and equally when there is nothing to scroll or the container runs out of travel; the only failure is a locator that matches no element. timeout (default 10s) and interval size how long Tales keeps retrying the whole action while the driver still reports the element as unreachable; the driver bounds its own scroll attempts within each try. The retry is what makes scroll_to safe to write straight after the tap that opens a screen: a single dispatch failed the moment the element was missing, so a screen still being built lost the race, and the wait_visible written after it never ran (issue #64). Useful before input_text on offscreen fields on iOS 26.x: a focus tap that misses the actual input affordance leaves no first responder, the synth path then trips Failed to synthesize event: Neither element nor any descendant has keyboard focus, and that XCTest API violation tears the runner down mid-scenario. Tales’ input_text driver handler (both paste mode for SecureField and the regular TextField path) auto-attempts one scroll + retap before bailing with a 500, so most cases work without an explicit scroll_to; reach for it when the auto-scroll is not enough (deeply nested scroll containers, custom keyboard accessories) or to make the test intent explicit.

Waiting actions poll the same hierarchy the expectations read, and take the same locator (id XOR label XOR text) plus optional timeout / interval:

  • wait_visible { id = "..." }, until the element exists and is visible.
  • wait_not_visible { id = "..." }, until the element is gone or hidden.
  • wait_enabled { id = "..." }, until the element exists and is enabled.
  • wait_disabled { id = "..." }, until the element exists and is disabled.

wait_enabled is the one to reach for whenever a screen arms asynchronously: a capture button waiting on the camera, a submit button unlocked by a background check, a control re-enabled once an upload finishes. Being visible is not being actionable, and a tap on a disabled control is swallowed with no error at all — the run then fails several steps later on an assertion that says nothing about the real cause.

actions {
input_text { id = "login.email" value = "[email protected]" }
input_text { id = "login.password" value = "Secret123!" secure = true }
wait_enabled { id = "login.submit" timeout = "10s" }
tap { id = "login.submit" }
}

expect { enabled { ... } } polls too, so the same wait can be expressed by ending a step on that expectation. Use the expectation when the arming is the assertion, and wait_enabled when it is just a precondition of the next action: that way the step boundaries follow the scenario rather than the tool.

Device-level actions take no id:

  • press_key { key = "return" }, presses a hardware keyboard key. key is one of return, enter, tab, space, escape, delete. When key is return or enter and a soft keyboard is up, the driver routes the press to the keyboard’s submit button (Return / Done / Send / locale variants) rather than the text-input event-synthesis path — synthesizing \r while a SwiftUI TextField is the first responder crashes the XCTest runner on iOS 26.x, so the keyboard-button path is both safer and semantically more accurate (it fires the field’s submitLabel).
  • press_button { button = "home" }, presses a device button (home or lock).
  • set_orientation { orientation = "landscape_left" }, rotates the device. orientation is one of portrait, landscape_left, landscape_right, upside_down.
  • dismiss_keyboard {}, dismisses the soft keyboard if one is up. Idempotent (a no-op when no keyboard is present). Useful before a snapshot-heavy step on a tall SwiftUI form: see Snapshot scope for why dismissing the keyboard first matters on iOS 26.x.

Actions have an implicit wait of 10s with 250ms polling. Each action may set a per-call timeout, except scroll_to, which bounds its own attempts and rejects timeout / interval.

Hierarchy snapshots are scoped to the app’s first window (app.windows.firstMatch.snapshot()) rather than the full application (app.snapshot()). The application snapshot pulls in every connected accessory the runner can reach, including the iOS keyboard daemon process, whose accessibility tree on a focused SwiftUI TextField is enormous (predictive bar, every key, modifiers, hardware-key passthroughs). On tall SwiftUI forms with the keyboard up, that subtree alone can push a single XCUIElement.snapshot() past the driver’s 8s bounded timeout. Scoping to the window stays inside the app’s own process and skips the keyboard daemon tree.

Modal sheets, SwiftUI .sheet content, and UIAlertController-style alerts all live inside that same window via the presentation hierarchy, so locators on them keep resolving. Window-scoped snapshot falls back to app.snapshot() when no window is exposed yet (between terminate and the next launch, or while the app is mid-launch).

If your scenario types into a long form and then needs a hierarchy snapshot, insert a dismiss_keyboard {} before the next assertion — it removes the keyboard from the window subtree and unbounds the snapshot time for follow-up steps.

Duplicate-id sibling resolution (first = true)

Section titled “Duplicate-id sibling resolution (first = true)”

Every element-targeted action (tap, double_tap, long_press, input_text, clear_text, swipe, scroll, wait_visible, wait_not_visible, wait_enabled, wait_disabled) accepts an optional first = true attribute. By default the resolver requires that an id matches a single logical element: a node and a same-id descendant collapse to one match (the SwiftUI wrapper + inner control case), but two genuinely distinct sibling matches surface multiple elements share the same id. That strict default is deliberate — it keeps explicit expect assertions backed by a real uniqueness guarantee.

System pickers break that contract: iOS’s PhotosPicker, the file importer and similar surfaces expose every cell of a grid under the same accessibility identifier (e.g. PXGGridLayout-Info), as siblings inside a collection view rather than as a parent-child pair. first = true opts the action into pre-order first-match resolution — the same semantics as XCUITest’s descendants(matching:).matching(identifier:).firstMatch — so the first cell wins instead of failing with ErrDuplicate.

actions {
wait_visible {
id = "PXGGridLayout-Info"
first = true
timeout = "15s"
}
tap {
id = "PXGGridLayout-Info"
first = true
}
}

The strict default is intentionally kept on every expect { visible | not_visible | text | value | enabled | disabled } block: assertions still error when an id resolves to two distinct subtrees. Use first = true only at the action level, where the goal is to act on the picker, not to assert its shape.

Every element-targeted action and every expectation block takes exactly one locator. They are mutually exclusive at the parse layer: setting two is rejected with Conflicting element locator, setting none with Missing element locator.

LocatoriOS source
idaccessibilityIdentifier
labelaccessibilityLabel
textthe element’s visible text

text is the locator of last resort — visible copy changes far more often than an identifier — but it is the only handle on UI that ships no identifiers at all, and it is the one locator that reaches the same control on both platforms: Android reports a button’s caption as text while iOS reports it as the accessibility label, so text falls back to the label here.

Matching by accessibilityLabel (label = "...")

Section titled “Matching by accessibilityLabel (label = "...")”

Every element-targeted action (tap, double_tap, long_press, input_text, clear_text, swipe, scroll, wait_visible, wait_not_visible, wait_enabled, wait_disabled) and every expectation block (visible, not_visible, text, value, enabled, disabled) accepts an optional label = "<accessibilityLabel>" attribute as an alternative to id. The two are mutually exclusive at the parse layer: setting both is rejected with Conflicting element locator, setting neither with Missing element locator.

id matches accessibilityIdentifier — the canonical, locale-independent locator authored on the element. label matches accessibilityLabel — the user-facing string that screen readers announce. Use id whenever the app defines one; reach for label to interact with iOS system controllers that don’t expose an identifier:

  • PHPickerViewController (the SwiftUI PhotosPicker) — Done / Cancel.
  • UIDocumentPickerViewController — file importer.
  • UIActivityViewController — the share sheet.
  • MFMailComposeViewController / MFMessageComposeViewController.
  • Most system alerts and action sheets where Apple leaves the identifier blank.
actions {
wait_visible { label = "Done" timeout = "10s" }
tap { label = "Done" }
}
expect {
visible { label = "Done" }
}

Resolution is XCUIElement.descendants(matching: .any).matching(NSPredicate(format: "label == %@", label)).firstMatch on the driver side, mirrored by tree.FindFirstByLabel on the Go side for snapshot-only expectations. Match is exact and case-sensitive; firstMatch is implicit, so first = true next to label is redundant but accepted. Because system labels change with the simulator locale (Done vs Terminé), keep label for cases where id is unavailable — prefer id whenever the app under test defines one.

Unlike first, label IS accepted in expect blocks: the goal is to assert on system controllers as well as interact with them. The uniqueness guarantee of strict id resolution does not extend to label resolution.

expect {
visible { id = "..." timeout = "10s" }
not_visible { id = "..." timeout = "10s" }
text { id = "..." value = contains("Welcome") }
value { id = "..." value = "..." }
enabled { id = "..." }
disabled { id = "..." }
}

Expectations default to 10s with 250ms polling.

A step-level permissions { <service> = "allow" | "deny" } block sets privacy permissions via simctl privacy after install and before the app launches. Service names are simctl privacy services, camera, photos, location, contacts, microphone, calendar, reminders, motion, media-library, etc.

step "mobile" "launch" {
permissions {
camera = "allow"
photos = "deny"
}
launch { clear_state = true }
}
  • value("id"), element attribute value at capture time
  • text("id"), element text content at capture time
  • request.actions[N].value, the evaluated action value at index N
step "mobile" "launch" {
launch { clear_state = true }
actions { wait_visible { id = "welcome.signin" } }
capture {
password = generate("password_gen") # generated once, real value
}
}
step "mobile" "fill" {
depends_on = ["launch"]
actions {
input_text { id = "form.password", value = result.launch.password, secure = true }
input_text { id = "form.password_confirm", value = result.launch.password, secure = true }
}
}

Calling generate(...) twice produces two different values (the seed mixer includes the expression path), so the capture-once pattern is the only way to get matching values.

A scenario-level record { ... } block instructs the iOS provider to capture a screen recording for the duration of the scenario using xcrun simctl io <UDID> recordVideo. Use it to produce App Store preview videos or to capture flaky-bug repros.

scenario "app_store_preview" {
tags = ["video"]
skip_unless {
env_set = ["TALES_RECORD"]
reason = "Set TALES_RECORD=1 to record an App Store preview"
}
record {
output = "preview.mp4" # required, relative to scenario.workdir
codec = "h264" # optional: h264 (default), hevc
mask = "black" # optional: ignored (default), alpha, black
force = true # optional, default true
}
step "mobile" "intro" { ... }
step "mobile" "feature" { ... }
}

Attributes (all forwarded to simctl io recordVideo unchanged when set):

  • output (required): path relative to scenario.workdir. The workspace resolver rejects any attempt to escape the per-scenario directory.
  • codec: h264 (App Store-friendly) or hevc. Defaults to simctl’s own default when omitted.
  • mask: ignored, alpha, or black. Controls device-mask handling for notched / Dynamic Island simulators.
  • display: internal or external. Selects which display to capture on devices that expose more than one.
  • target: the mobile target name (config.mobile.targets.<name>) the recording should bind to. Omit when the scenario only drives one target.
  • force: defaults to true; passes --force so simctl overwrites a stale file from a previous run instead of erroring.

The recording is wired through a generic provider capability (ScenarioHook), so:

  • It is not started until the first step "mobile" runs in the scenario, which is the earliest point where the simulator is guaranteed booted and a UDID is known.
  • It is stopped after the main steps complete and before any teardown { step ... } runs, so the recording shows the app under test (not the terminate {} / cleanup that closes it) and so a teardown step can assert on the produced file. The recorder sends SIGINT only and waits for simctl to flush the MP4 moov atom; a forceful kill would leave the file unplayable. A panic or early return falls back to a defer so the recorder is always stopped.
  • The resulting path is surfaced as a scenario-level artifact (type = "recording") in console, JSONL (scenario.artifacts[]), JUnit (<system-out>), and the embedded JSON payload of the visual HTML report.

There is no dedicated CLI flag. Combine the existing primitives:

scenario "my_recording" {
tags = ["video"]
skip_unless { env_set = ["TALES_RECORD"] }
record { output = "preview.mp4" }
# steps...
}

Run with TALES_RECORD=1 tales test e2e/ios-record; without that variable, the scenario is reported as SKIPPED and xcrun simctl is never invoked. The e2e-ios-record Make target wraps the same invocation for the demo app.

  • The raw simctl output is not App Store submittable on its own (Apple requires fixed dimensions / framerate / audio). Post-process with ffmpeg after Tales finishes.
  • Concurrent scenarios cannot record on the same simulator UDID: the provider rejects the second start with a clear error. Run video scenarios with --parallel 1 (or --scenario "..." / --tag video) to avoid the conflict.
  • The visual HTML template does not yet render a <video> element; the recording path is in the embedded JSON, so external tooling can pick it up.

The driver block selects one of three execution modes:

ConfigurationMode
external = false, no source_pathEmbedded (default). Extract + build + cache.
external = false, source_path = "..."Developer override. Same pipeline, local source.
external = trueExternal. Health-check only; never spawn or kill.

No extra fields are required. Tales:

  1. Hashes the embedded driver source and assembles a cache key from <source-hash>-xcode-<version>-sdk-<version>-dev-<DEVELOPER_DIR>-ios-<runtime>-mac-<major>.
  2. Extracts the source into <cache>/source/ atomically (rename-after-write).
  3. Runs xcodebuild build-for-testing once, capturing output to <cache>/logs/build.log and writing a build.ok marker on success.
  4. Launches the driver via xcodebuild test-without-building -xctestrun ... on every subsequent session.
  5. Self-heals once on /health failure: invalidates build.ok and rebuilds from scratch before failing the test.

When iterating on the Swift driver, point source_path at a local checkout:

driver = {
external = false
source_path = "/path/to/drivers/apple/TalesAppleDriver"
}

The cache key still includes the source hash, so edits invalidate the cache automatically.

When you launch xcodebuild test yourself (for example to attach a debugger or capture detailed logs), point Tales at the existing endpoint:

driver = {
external = true
host = "127.0.0.1"
port = 9080
}

Tales only health-checks the URL; it never spawns or kills an external driver.

In embedded modes (default and developer override) driver.port is optional. When omitted, Tales auto-allocates a free host port per target at session start. Set it explicitly only when you need a fixed port (for example to attach external tooling). In external mode the port (and host) must point at the already-running driver, so set them explicitly.

This matters for multiple simulators in parallel: the simulator binds the driver’s HTTP server on the host’s shared loopback, so two drivers running at once must use distinct ports. Define one target per simulator and leave port omitted, and each gets a distinct port automatically, with no manual bookkeeping:

config {
mobile = {
targets = {
iphone = { platform = "ios", device_name = "iPhone 17", app = env("IOS_APP_PATH"), app_id = "com.example.app" }
ipad = { platform = "ios", device_name = "iPad Pro 13-inch (M4)", app = env("IOS_APP_PATH"), app_id = "com.example.app" }
}
}
}

A launch step is two operations, deliberately split:

  1. simctl launch on the host performs the cold start (after a simctl terminate, so the step really does give you a fresh app).
  2. POST /activate on the driver calls XCUIApplication.activate(), which binds XCTest’s automation session to the running process.

Step 2 is what keeps scenarios that share a target working: without it the next /hierarchy snapshot queries a stale, terminated process and times out.

The split exists because of what happens when a launch fails. XCUIApplication.launch() runs inside the XCTest runner, and it does not throw when the simulator declines to open the app. It records XCTest failures, waits about a minute for accessibility on a process that never existed, captures a diagnostic spindump, and returns normally:

Failed to launch com.example.app: The request to open "com.example.app" failed.
The request was denied by service delegate (SBMainWorkspace) for reason: NotFound
("Application "com.example.app" is unknown to FrontBoard")
Application 'com.example.app' does not have a process ID
Application 'com.example.app' has not loaded accessibility

XCTest then treats that as a test interruption and tears the driver’s test case down, so every later scenario fails with connection refused. One transient refusal cost an entire CI suite, reported as a missing element four minutes after the fact.

The same refusal from simctl is an immediate error naming the real cause, costing one step. Tales retries it three times, 500 ms apart, because the condition is transient: right after a clear_state reinstall a loaded machine can briefly leave the app unknown to FrontBoard. An app that is genuinely missing still fails, a second later, with the simulator’s own message.

driver.timeout bounds every HTTP request Tales sends the driver. It defaults to 30s and takes a duration string:

driver = {
timeout = env("TALES_DRIVER_TIMEOUT", "30s")
}

A bare number is rejected rather than guessed at, since timeout = 30 reads as both 30 seconds and 30 milliseconds depending on who is reading.

The default is a developer-machine number. Raise it on any host where UI automation is slow, a shared CI runner first of all: on a GitHub-hosted macos-26 runner a single XCUIApplication.launch() has been measured at 4.8s, 9.2s, 32.7s and once 245s, while taps and hierarchy fetches stayed at their local speed.

The failure mode is worth understanding, because it does not look like a timeout. When the client gives up on a /launch the driver goes on to complete, the two sides disagree about what the app is doing: Tales moves to the next scenario while the driver is still finishing the previous one, so the failure surfaces several scenarios later as an unrelated-looking error, and eventually as connection refused once XCTest tears the runner down. One tight timeout produces a whole red suite. Reading env() keeps a suite fast locally and patient in CI without forking the config.

  • Default, ~/Library/Caches/tales/apple-driver/<cache-key>/ on macOS.
  • Override, set TALES_DRIVER_CACHE_DIR to a directory of your choice (used as the final base, no extra suffix). Useful in CI to share or pin a cache.
~/Library/Caches/tales/apple-driver/<cache-key>/
source/ extracted Swift driver source
TalesAppleDriver.xcodeproj/
...
derived-data/ xcodebuild -derivedDataPath
logs/
build.log build-for-testing stdout+stderr
extract.ok marker, written after a successful extract
build.ok marker, contains the cached .xctestrun path
metadata.json source_hash, xcode_version, ios_runtime, ...
.lock cross-process flock to serialize parallel tales
Terminal window
make clean-ios-driver-cache
# or, for a custom base:
rm -rf "$TALES_DRIVER_CACHE_DIR"

Wipe the cache after a major Xcode upgrade, when you suspect a corrupted build, or before single-binary smoke testing.

Use tales doctor for a one-screen view of everything that influences the embedded driver pipeline.

iOS suites need a macOS runner: a simulator cannot be hosted on Linux. GitHub-hosted macOS runners are free for public repositories, and macos-26 ships Xcode 26.6 plus pre-created iPhone 17 simulators, so nothing has to be installed.

Three things separate a job that passes from one that does not:

  • Raise driver.timeout. This is the one that decides whether the suite is green. The 30s default is too tight for a shared runner, and the resulting failure cascades across scenarios rather than reporting itself where it happened.
  • Cache the embedded driver build. Point TALES_DRIVER_CACHE_DIR inside the workspace so the cache action can address it by a relative path. The first run builds the Swift driver with xcodebuild build-for-testing; every later run reuses it.
  • Resolve the simulator name up front, and boot it before the suite. Targets match a simulator by exact name, so a runner image that renames or drops a device fails deep inside the run rather than at startup. Booting first also keeps startup out of the first scenario’s timings.
jobs:
ios:
runs-on: macos-26
timeout-minutes: 60
env:
TALES_DRIVER_CACHE_DIR: ${{ github.workspace }}/.cache/tales/apple-driver
TALES_DRIVER_TIMEOUT: 300s
steps:
- uses: actions/checkout@v6
- uses: actions/setup-go@v6
with:
go-version-file: go.mod
- name: Restore the embedded driver build
id: driver-cache
uses: actions/cache/restore@v4
with:
path: .cache/tales/apple-driver
key: apple-driver-${{ runner.arch }}-${{ hashFiles('drivers/apple/**') }}
- run: xcrun simctl bootstatus 'iPhone 17' -b
- run: make e2e-ios
env:
IOS_DEVICE_NAME: iPhone 17
# Not actions/cache: its post step saves only when the job
# succeeded, so a run that failed after building the driver saves
# nothing and the next attempt pays the build again.
- name: Save the embedded driver build
if: always() && steps.driver-cache.outputs.cache-hit != 'true'
uses: actions/cache/save@v4
with:
path: .cache/tales/apple-driver
key: apple-driver-${{ runner.arch }}-${{ hashFiles('drivers/apple/**') }}

The cache key that Tales itself computes is finer-grained than the workflow’s (it mixes the source hash, Xcode version, SDK, DEVELOPER_DIR, simulator runtime and macOS major). A restored cache that no longer matches is ignored and rebuilt, never wrongly reused, so a coarse workflow key is safe.

Upload build/reports/e2e-ios*.* and build/artifacts/mobile/ on failure: the screenshots, UI hierarchies and driver log are what make a CI-only failure diagnosable. Tales’ own workflow lives in .github/workflows/ios.yml.

On a shared runner, plan for one retry of the whole suite. The reason is structural rather than a Tales defect: when the app under test is slow to become foreground-able, the XCUITest call waiting on it (launch() or activate()) records a failure that XCTest treats as fatal, and it tears the driver’s test case down. Every scenario after that point fails with connection refused, so one transient stall costs the entire run. continueAfterFailure is already true, and nothing the driver does prevents XCTest from ending its own test.

A second attempt starts a fresh xcodebuild, and therefore a fresh runner. Move the first attempt’s build/reports and build/artifacts aside before retrying — otherwise a retried-then-green run silently discards the only evidence of the stall — and upload them even when the job ends green.

A genuine regression still fails both attempts.

StreamPath
Embedded driver build<cache>/logs/build.log
Runtime driver processbuild/artifacts/mobile/driver/<target>/driver.log
Failure-step screenshotsbuild/artifacts/mobile/<scenario>-<hash>/<step>/<phase>/attempt-N/screenshot.png
Failure-step hierarchybuild/artifacts/mobile/<scenario>-<hash>/<step>/<phase>/attempt-N/hierarchy.json

When duplicate simulator names exist across runtimes, Tales selects deterministically:

  • available iOS devices only
  • newest iOS runtime first
  • booted device first when runtime and name are equal
  • UDID as a stable tie-breaker

The selected simulator name, UDID, and runtime are printed before the session is used. This avoids accidentally choosing an older duplicate runtime when Xcode ships several simulator runtimes.

Tales selectors are accessibility identifiers only. Do not rely on visible text as a selector.

TextField("Email", text: $email)
.accessibilityIdentifier("register.email")
SecureField("Password", text: $password)
.accessibilityIdentifier("register.password")
Button("Register") {
submit()
}
.accessibilityIdentifier("register.submit")

Every element used by Tales should have a stable identifier. Duplicate IDs are reported as errors instead of guessed.

The provider serializes mobile step execution per target name. Two scenarios using the same target (for example iphone) cannot clear state or terminate the app while each other is tapping or asserting. Different targets may still run in parallel when configured separately, each driving its own simulator on its own auto-allocated driver port.

On mobile step failure Tales writes:

build/artifacts/mobile/<scenario>-<file-hash>/<step>/<phase>/attempt-<n>/screenshot.png
build/artifacts/mobile/<scenario>-<file-hash>/<step>/<phase>/attempt-<n>/hierarchy.json

The file hash prevents collisions when two files contain scenarios with the same name. Paths are included in console, JUnit, and JSONL reports when available.

When Tales starts the managed Apple driver, stdout and stderr are written to build/artifacts/mobile/driver/<target>/driver.log. If the driver does not become healthy, the failure message includes this log path and suggests make doctor-ios.

When the XCUITest runner crashes during an action (the driver socket closes, the next Tales request sees connect: connection refused / EOF / broken pipe), the failure message switches to the diagnostic-files form: the original transport-level error is preserved, then Tales appends the absolute paths of the three files that hold the real post-mortem information:

… connect: connection refused
driver process appears to have terminated mid-scenario; diagnostic files:
driver log: build/artifacts/mobile/driver/iphone/driver.log
xcresult bundle dir: ~/Library/Caches/tales/apple-driver/<key>/derived-data/Logs/Test (open <path>/*.xcresult in Xcode for the full XCTest crash report)
build log: ~/Library/Caches/tales/apple-driver/<key>/logs/build.log

The same paths are attached to the step report as driver_log, xcresult_dir, and driver_build_log artifacts (JSONL + visual HTML report).

  • driver.log holds the live Swift driver log, including the request log added in this release. Each handled HTTP request now emits [tales-driver] request: METHOD /path + [tales-driver] response: METHOD /path status=<n> elapsed=<ms>ms, so the last request before XCTest tore down is the one that triggered the crash.
  • <xcresult dir>/*.xcresult is the Xcode test results bundle. open ~/Library/Caches/tales/apple-driver/<key>/derived-data/Logs/Test/*.xcresult opens it in Xcode where you get the full failure chain, including XCTest API violations, SIGABRT, runaway accessibility-engine queries, and any Test Suite … failed line with its underlying reason.
  • build.log is only useful when the runner died because the embedded driver build was wrong, which is rare but cheap to expose.

External drivers (driver.external = true) skip every Tales-owned path: the diagnostic block is omitted because Tales does not control the runner there.

  • Run tales doctor (or make doctor-ios) to collect system, Xcode, runtime, device, and environment diagnostics.
  • Simulator not found, verify IOS_DEVICE_NAME with xcrun simctl list devices.
  • App path missing, run make build-ios-demo or set IOS_APP_PATH to a simulator .app bundle.
  • Device build installed into simulator, rebuild the app with -sdk iphonesimulator.
  • Bundle ID mismatch, check IOS_BUNDLE_ID matches the app’s PRODUCT_BUNDLE_IDENTIFIER.
  • Driver build failure, read <cache>/logs/build.log (path printed in the error). Common causes: SDK no longer installed, signing config drift, stale derived data. make clean-ios-driver-cache then retry.
  • Driver health timeout, Tales auto-retries once with a rebuilt cache. If it still fails, inspect the runtime log at build/artifacts/mobile/driver/<target>/driver.log.
  • Stale CoreSimulator after Xcode upgrade, run sudo xcodebuild -runFirstLaunch, then xcrun simctl shutdown all, then killall -9 com.apple.CoreSimulator.CoreSimulatorService || true, then xcrun simctl list devices. Optionally make clean-ios-driver-cache to force a fresh driver build.
  • Element not found, verify .accessibilityIdentifier(...) and inspect hierarchy.json.
  • Toolbar / navigation-bar button “not found” even though it is on screen, SwiftUI exposes a .topBarLeading / .topBarTrailing toolbar item under one identifier twice (an other wrapper and the inner button). Tales collapses that nested pair to a single element, so a unique id resolves; only two genuinely separate elements sharing an id are rejected as a duplicate.
  • Step times out on a screen that does a heavy refresh, while the app’s main thread is blocked it cannot serve accessibility snapshots, so /hierarchy returns a retryable error and Tales keeps polling until the app frees up. Give such a step a timeout (or retry) comfortably above the refresh duration; the driver no longer wedges or drops other requests while one snapshot is slow.
  • No screenshot, check simulator permissions and fallback xcrun simctl io screenshot availability.
  • Port conflict, omit driver.port so Tales auto-allocates a free port, or set a different explicit port. See Driver port.
  • iOS only; Android is intentionally not implemented in V1.
  • No OCR, image matching, XPath, or predicate-based selection.
  • No cloud device support.
  • Real-device signing is best effort and not a V1 target.
  • The driver transport is HTTP/JSON for now.