Launches Skip apps on iOS Simulator and Android emulator simultaneously, drives Maestro UI tests, and generates localized screenshots for app store metadata.
How this skill is triggered — by the user, by Claude, or both
Slash command
/skip-testing-deployment:skip-ui-automationThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
Skip projects can be launched on the iOS Simulator and an Android emulator side-by-side from a single command, and the same flow can be exercised on both platforms with [Maestro](https://maestro.dev). This skill covers the inner loop, the `.accessibilityIdentifier()` convention that holds flows together, and the project layout for `.maestro/` flows.
Skip projects can be launched on the iOS Simulator and an Android emulator side-by-side from a single command, and the same flow can be exercised on both platforms with Maestro. This skill covers the inner loop, the .accessibilityIdentifier() convention that holds flows together, and the project layout for .maestro/ flows.
For the platform-specific gotchas, locale-switching commands, and build-state recoveries that come up often enough to deserve their own files, consult:
references/platform-quirks.md — iOS vs. Android assertVisible: differences, clearState: true blank-screen issue, hideKeyboard failing on iOS, inputText ASCII-only on Android, Compose OutlinedTextField ID propagation, Toggle thumb hit target, WebView scroll/text opacity, settings sheet dismissal.references/locale-switching.md — iOS simctl spawn defaults write and Android adb cmd locale set-app-locales recipes, the MAESTRO_OUT_SUFFIX env-substitution pattern for per-language screenshot paths, the typical end-to-end localised screenshot workflow.references/build-state-recovery.md — when to rm -rf .build, the stale Bundle.module recovery, stale Gradle/Compose-compiler cache after a Skip upgrade, stale Xcode DerivedData, stale Android install, and the nuclear "wipe everything" reset.swift test and swift build validate that the code compiles and the test suites pass. Neither tells you that the running app behaves correctly on both platforms. skip app launch puts the app in front of you on both simulators in one step, and Maestro replays the same gestures against both — so when you change a SwiftUI view you find out immediately whether it still renders, responds, and looks right under Compose.
This is the canonical inner loop for cross-platform UI work:
edit SwiftUI → skip app launch → eyeball both platforms → maestro test .maestro/feature.yaml → fix → repeat
skip app launch does not boot the iOS Simulator or start an Android emulator for you. Boot them first:
xcrun simctl boot <UDID>; open -a Simulator # iOS
emulator -avd Pixel_6_API_34 # Android (blocks; another terminal)
# Or, on CI:
skip android emulator launch --background --headless
# Confirm
xcrun simctl list devices booted
adb devices
With both running:
skip app launch # both platforms
skip app launch --verbose # full xcodebuild / gradle output
skip app launch --ios # iOS only (fastest)
skip app launch --android # Android only
skip app launch --configuration release # release build
When you have both a USB-connected Android phone and a running emulator, scope the install to the emulator with the standard env var — skip app launch has no --android-serial flag:
ANDROID_SERIAL=emulator-5554 skip app launch
For iterating on a peer Skip framework checkout, point at the local tree:
SKIP_DEPENDENCY_ROOT=/path/to/skiptools skip app launch
(The convention is documented at the bottom of every Skip project's Package.swift — see the template comment block.)
skip app launchswift build will never catch a Kotlin compile failure or a Compose rendering bug.Maestro is a YAML-driven UI test runner that speaks both XCUITest (iOS) and UIAutomator (Android) underneath. A single flow file can target either platform — only the appId differs.
brew tap mobile-dev-inc/tap
brew install maestro
maestro --version
Maestro is a Java app; the Homebrew formula installs a compatible JDK if one isn't present. On Apple Silicon the iOS driver downloads on first use — allow it through Gatekeeper if prompted.
Put Maestro flows in a .maestro/ directory next to Package.swift:
my-app/
Package.swift
Sources/…
.maestro/
config.yaml # optional shared config
helpers/
seed-empty.yaml # reusable sub-flows
screenshots/ # output of takeScreenshot:
00-smoke.yaml # baseline cross-platform smoke
10-add-task.yaml # one flow per feature
20-walkthrough-en.yaml # per-language walkthrough flows
21-walkthrough-fr.yaml
Most cross-platform divergence is hidden by the helpers — most flow files differ only in their appId line.
# .maestro/00-smoke.yaml
appId: dev.skip.todo
---
- launchApp # no clearState — see references/platform-quirks.md
- assertVisible: "My Tasks"
- assertVisible:
id: "field.newTask"
- assertVisible:
id: "button.add"
- takeScreenshot: .maestro/screenshots/00-smoke
# Find your device IDs
xcrun simctl list devices booted # iOS Sim UDIDs
adb devices # Android emulator/device serials
# Single device
maestro --device <iOS-UDID> test .maestro/00-smoke.yaml
maestro --device emulator-5554 test .maestro/00-smoke.yaml
# In parallel
maestro --device <iOS-UDID> test .maestro/00-smoke.yaml &
maestro --device emulator-5554 test .maestro/00-smoke.yaml &
wait
Each command line prints COMPLETED or FAILED. On FAILED, Maestro writes debug artefacts to ~/.maestro/tests/<timestamp>/:
screenshot-❌-*.png — the screen at the moment of failure. Open it first; an unexpected dialog, a slow page load, an off-screen element is usually visible.commands-(*.yaml).json — the full hierarchy Maestro saw, with text / accessibilityText / accessibilityIdentifier on each node.maestro.log — the runner's own log.takeScreenshot: resolves relative to the current working directory. When maestro test is run from the project root, prefix paths with .maestro/ so they land inside the project tree:
- takeScreenshot: .maestro/screenshots/feature-after-tap
A bare screenshots/foo would resolve to <cwd>/screenshots/ — wherever you ran Maestro from — which surprises users who expect "test-relative" paths.
For per-language screenshot paths driven by env-substitution (${MAESTRO_OUT_SUFFIX}), see references/locale-switching.md.
.accessibilityIdentifier() — load-bearingEvery interactive view that a test or an agent might want to drive must have an .accessibilityIdentifier(...). This is not optional politeness; it's the difference between a test suite that survives refactors and one that re-breaks every time a label is translated or a layout shifts.
Maestro can locate elements by visible text (tapOn: "Search Tabs") or by ID (tapOn: { id: "field.tabSearch" }). Text-based matching is fragile:
"Done" in English breaks in German (Fertig).tapOn: "Cancel" may hit the wrong button.WebView. WKWebView content is not in the iOS accessibility tree — Maestro can match a native button labelled "Open", but not text inside a webpage.OutlinedTextField and other primitives don't always surface SwiftUI labels.IDs are stable across translation, refactor, and SwiftUI-vs-Compose rendering differences. They cost one line of code per view.
Button(action: closeAction) {
Image("xmark", bundle: .module)
}
.accessibilityIdentifier("button.close")
.accessibilityLabel(Text("Close", bundle: .module, comment: "accessibility label for the close button"))
TextField("URL", text: $url)
.accessibilityIdentifier("field.url")
Toggle("Block Ads", isOn: $blockAds)
.accessibilityIdentifier("toggle.blockAds")
NavigationLink {
DetailView()
} label: {
Label {
Text("Settings", bundle: .module, comment: "settings navigation link label")
} icon: {
Image("gearshape", bundle: .module)
}
}
.accessibilityIdentifier("link.settings")
For icon-only buttons, always pair .accessibilityIdentifier with .accessibilityLabel — VoiceOver/TalkBack users can't tap a button whose only signal is a glyph.
Use dotted lowercase strings that encode the element kind:
| Prefix | Element |
|---|---|
button. | Buttons (button.close, button.tabs) |
field. | TextFields, TextEditors |
toggle. | Toggles |
picker. | Pickers |
link. | NavigationLinks, hyperlinks |
menu. | Menu items |
label. | Static labels worth asserting |
Dotted names are easy to grep for and survive refactors. button.menu and button.tabs.new are far more searchable than firstMenuButton or tabsAddButton.
Button, Toggle, Picker, TextField, Slider, and NavigationLink a user can interact with.Menu (the menu's label and every Button inside it).You don't need to identify every Text, Spacer, HStack, or layout container.
For Compose-specific ID-propagation issues (notably OutlinedTextField and iOS Menu items), see references/platform-quirks.md.
Put any tap sequence used by more than one flow into .maestro/helpers/ and invoke with runFlow:
# .maestro/helpers/seed-empty.yaml
appId: dev.skip.todo
---
- runFlow:
when:
notVisible:
id: "label.emptyState"
commands:
- tapOn: { id: "button.menu" }
- tapOn: "Clear All"
- tapOn: "Delete All"
# .maestro/10-add-task.yaml
appId: dev.skip.todo
---
- launchApp
- runFlow: { file: helpers/seed-empty.yaml }
- tapOn: { id: "field.newTask" }
- inputText: "Buy milk"
- tapOn: { id: "button.add" }
- assertVisible: "Buy milk"
The runFlow: when: visible: (or notVisible:) form lets the helper be a no-op when its precondition isn't met, so it can be used both on first launch and after the app has accumulated state. This keeps flows idempotent and re-runnable.
.accessibilityIdentifier("button.foo") to anything new that's interactive.skip app launch — build and install on both Sim and emulator..maestro/feature.yaml.maestro --device <iOS-UDID> test .maestro/feature.yaml and maestro --device emulator-5554 test .maestro/feature.yaml, in parallel.FAILED, open the debug screenshot under ~/.maestro/tests/<timestamp>/ first.takeScreenshot: .maestro/screenshots/<name> and keep them in the repo alongside the flow.XCSkipTests, see skip-testing..accessibilityIdentifier — why icon-bearing buttons need both an .accessibilityLabel(...) and a .symbolset-backed Image(...) — see skip-icons.Text(_, bundle: .module, comment:) strings the flow's assertVisible: lines match against, see skip-localization.skip app launch and maestro fit into the build process, see skip-app-walkthrough.npx claudepluginhub skiptools/skills --plugin skip-testing-deploymentCreates, edits, and verifies skills using a test-driven development approach with pressure scenarios and subagents.