Install
$ agentstack add skill-skiptools-skills-skip-ui-automation ✓ scanned · ✓ verified — works with Claude Code, Cursor, and more.
Security review
✓ PassedNo issues found. Passed automated security review. · v0.1.0 How review works →
- ✓ Prompt-injection patterns
- ✓ Secret / credential exfiltration
- ✓ Dangerous shell & filesystem operations
- ✓ Untrusted network calls
- ✓ Known-malicious package signatures
What it can access
- ✓ Network access No
- ✓ Filesystem access No
- ✓ Shell / process execution No
- ✓ Environment & secrets No
- ✓ Dynamic code execution No
From automated source analysis of v0.1.0. “Used” means the capability is present in the source — more access means more to trust, not that it’s unsafe.
About
Skip UI Automation
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](references/platform-quirks.md) — iOS vs. AndroidassertVisible:differences,clearState: trueblank-screen issue,hideKeyboardfailing on iOS,inputTextASCII-only on Android, ComposeOutlinedTextFieldID propagation, Toggle thumb hit target, WebView scroll/text opacity, settings sheet dismissal. - [
references/locale-switching.md](references/locale-switching.md) — iOSsimctl spawn defaults writeand Androidadb cmd locale set-app-localesrecipes, theMAESTRO_OUT_SUFFIXenv-substitution pattern for per-language screenshot paths, the typical end-to-end localised screenshot workflow. - [
references/build-state-recovery.md](references/build-state-recovery.md) — when torm -rf .build, the staleBundle.modulerecovery, stale Gradle/Compose-compiler cache after a Skip upgrade, stale Xcode DerivedData, stale Android install, and the nuclear "wipe everything" reset.
The inner loop
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
Pre-flight
skip app launch does not boot the iOS Simulator or start an Android emulator for you. Boot them first:
xcrun simctl boot ; 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.)
When to reach for skip app launch
- After any change to a SwiftUI view —
swift buildwill never catch a Kotlin compile failure or a Compose rendering bug. - Before running Maestro tests — the test runs against the installed build, so a stale install will silently test old behaviour.
- When debugging platform divergence — running both side by side is the cheapest way to see "this looks fine on iOS but breaks on Android."
Maestro
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.
Install
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.
Project layout
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.
A minimal cross-platform flow
# .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
Running flows
# Find your device IDs
xcrun simctl list devices booted # iOS Sim UDIDs
adb devices # Android emulator/device serials
# Single device
maestro --device test .maestro/00-smoke.yaml
maestro --device emulator-5554 test .maestro/00-smoke.yaml
# In parallel
maestro --device test .maestro/00-smoke.yaml &
maestro --device emulator-5554 test .maestro/00-smoke.yaml &
wait
Reading failures
Each command line prints COMPLETED or FAILED. On FAILED, Maestro writes debug artefacts to ~/.maestro/tests//:
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, withtext/accessibilityText/accessibilityIdentifieron each node.maestro.log— the runner's own log.
Screenshots
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 /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](references/locale-switching.md).
.accessibilityIdentifier() — load-bearing
Every 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.
Why identifiers, not labels
Maestro can locate elements by visible text (tapOn: "Search Tabs") or by ID (tapOn: { id: "field.tabSearch" }). Text-based matching is fragile:
- The string changes on translation. A flow that taps
"Done"in English breaks in German (Fertig). - The string appears in multiple places. A
tapOn: "Cancel"may hit the wrong button. - The string is rendered inside a
WebView. WKWebView content is not in the iOS accessibility tree — Maestro can match a native button labelled "Open", but not text inside a webpage. - Compose
OutlinedTextFieldand 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.
How to attach IDs
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.
Identifier naming convention
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.
What to identify
- Every
Button,Toggle,Picker,TextField,Slider, andNavigationLinka user can interact with. - Each item inside a
Menu(the menu's label and everyButtoninside it). - Any view whose visibility you'd want to assert on (an empty-state label, a loading spinner).
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](references/platform-quirks.md).
Reusable helpers
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.
Putting it together — typical iteration
- Edit the SwiftUI view, attach
.accessibilityIdentifier("button.foo")to anything new that's interactive. skip app launch— build and install on both Sim and emulator.- Eyeball the change on both screens.
- Write a flow file under
.maestro/feature.yaml. maestro --device test .maestro/feature.yamlandmaestro --device emulator-5554 test .maestro/feature.yaml, in parallel.- On
FAILED, open the debug screenshot under~/.maestro/tests//first. - Capture release-quality screenshots with
takeScreenshot: .maestro/screenshots/and keep them in the repo alongside the flow.
Cross-references
- For logic-level (non-UI) tests that run on both platforms via
XCSkipTests, see [skip-testing](../skip-testing/SKILL.md). - For the icon side of
.accessibilityIdentifier— why icon-bearing buttons need both an.accessibilityLabel(...)and a.symbolset-backedImage(...)— see [skip-icons](../../../skip-app-design/skills/skip-icons/SKILL.md). - For localization of the
Text(_, bundle: .module, comment:)strings the flow'sassertVisible:lines match against, see [skip-localization](../../../skip-app-design/skills/skip-localization/SKILL.md). - For end-to-end how
skip app launchandmaestrofit into the build process, see [skip-app-walkthrough](../../../skip-app-design/skills/skip-app-walkthrough/SKILL.md).
Source & license
This open-source skill is cataloged on AgentStack and links to its original source — we do not rehost the code.
- Author: skiptools
- Source: skiptools/skills
- License: Apache-2.0
Install and usage instructions live in the source repository linked above.
Reviews
No reviews yet — be the first.
Write a review
Versions
- v0.1.0 Imported from the upstream source.