Allowed runtime deps
xcrunxcrun simctlxcodebuildXCTest/XCUITest- Swift code owned by this repository
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 / launch / termination, step serialization per mobile target, implicit waits, and artifact collection. The Swift driver lives under drivers/apple/TalesAppleDriver/ and exposes a small HTTP/JSON surface for hierarchy, tap, input text, clear text, and screenshot operations.
Allowed runtime deps
xcrunxcrun simctlxcodebuildXCTest / XCUITestExplicitly not used
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.title, welcome.registerregister.screen, register.email, register.password, register.submit, register.errorverify.screen, verify.code, verify.submit, verify.errorhome.screen, home.title, home.emailThe verification code is intentionally hardcoded to A1B2C3 so the mobile e2e flow is deterministic.
Cross-platform CI targets remain platform-neutral:
make testmake lintmake e2emake e2e-failuremacOS / Xcode-only targets:
make doctor-iosmake build-ios-demomake e2e-iosmake e2e-ios-failuremake 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:
xcodebuild and xcrune2e/ios/demoapp/TalesDemoApp.xcodeprojbuild/ios/demoappTalesDemoApp.app for iOS Simulatorbuild/ios/demoapp/app_path.txtmake e2e-ios:
IOS_APP_PATH, IOS_BUNDLE_ID, and IOS_DEVICE_NAME (defaults to IOS_DEVICE_NAME="iPhone 17")tales test ./e2e/ios/pass --seed 1234 --parallel 1build/reportsbuild/artifactsmake 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.
IOS_DEVICE_NAME="iPhone 17" make e2e-iosIOS_DEVICE_NAME="iPhone 17 Pro" make e2e-ios-failureThe 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:
IOS_APP_PATH=/path/to/MyApp.app \IOS_BUNDLE_ID=com.example.MyApp \IOS_DEVICE_NAME="iPhone 17" \ tales test ./my/mobile/suite --seed 1234config { mobile = { targets = { iphone = { platform = "ios" device_name = env("IOS_DEVICE_NAME", "iPhone 17") app = env("IOS_APP_PATH") bundle_id = env("IOS_BUNDLE_ID", "org.taleslabs.tales.demo") driver = { host = env("IOS_DRIVER_HOST", "127.0.0.1") port = 9080 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.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.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.Actions have an implicit wait of 10s with 250ms polling. Each action may set a per-call timeout.
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 timetext("id"), element text content at capture timerequest.actions[N].value, the evaluated action value at index Nstep "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.
The driver block selects one of three execution modes:
| Configuration | Mode |
|---|---|
external = false, no source_path | Embedded (default). Extract + build + cache. |
external = false, source_path = "..." | Developer override. Same pipeline, local source. |
external = true | External. Health-check only; never spawn or kill. |
No extra fields are required. Tales:
<source-hash>-xcode-<version>-sdk-<version>-dev-<DEVELOPER_DIR>-ios-<runtime>-mac-<major>.<cache>/source/ atomically (rename-after-write).xcodebuild build-for-testing once, capturing output to <cache>/logs/build.log and writing a build.ok marker on success.xcodebuild test-without-building -xctestrun ... on every subsequent session./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.
~/Library/Caches/tales/apple-driver/<cache-key>/ on macOS.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 talesmake 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.
| Stream | Path |
|---|---|
| Embedded driver build | <cache>/logs/build.log |
| Runtime driver process | build/artifacts/mobile/driver/<target>/driver.log |
| Failure-step screenshots | build/artifacts/mobile/<scenario>-<hash>/<step>/<phase>/attempt-N/screenshot.png |
| Failure-step hierarchy | build/artifacts/mobile/<scenario>-<hash>/<step>/<phase>/attempt-N/hierarchy.json |
When duplicate simulator names exist across runtimes, Tales selects deterministically:
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.
On mobile step failure Tales writes:
build/artifacts/mobile/<scenario>-<file-hash>/<step>/<phase>/attempt-<n>/screenshot.pngbuild/artifacts/mobile/<scenario>-<file-hash>/<step>/<phase>/attempt-<n>/hierarchy.jsonThe 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.
tales doctor (or make doctor-ios) to collect system, Xcode, runtime, device, and environment diagnostics.IOS_DEVICE_NAME with xcrun simctl list devices.make build-ios-demo or set IOS_APP_PATH to a simulator .app bundle.-sdk iphonesimulator.IOS_BUNDLE_ID matches the app’s PRODUCT_BUNDLE_IDENTIFIER.<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.build/artifacts/mobile/driver/<target>/driver.log.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..accessibilityIdentifier(...) and inspect hierarchy.json.xcrun simctl io screenshot availability.IOS_DRIVER_HOST or update the target driver port in the .tales file.