Skip to main content

macos-ui-testing

Background-safe macOS app UI testing using Peekaboo CLI and osascript. This skill should be used when the user asks to "test the app UI", "check the UI", "interact with the running app", "take a screenshot of the app", "fill in the form", "click the button", "verify the UI state", "automate the app", or wants to automate interaction with a running macOS application without stealing keyboard/mouse focus from the user.

Source facts

Repository
tobihagemann/ducko
Last source activity
October 2, 2026 at 12:36
Detected SKILL.md language
English
Stars
3
Forks
0

Install options

The review-first prompt is selected by default. You can switch to a direct command or download a local copy.

Review the source files

Read SKILL.md and any companion files shown by SkillsMP before deciding whether to install.

File Explorer
2 files

Showing SKILL.md

SKILL.md
Source instructions · Read-only preview
name
macos-ui-testing
description
Background-safe macOS app UI testing using Peekaboo CLI and osascript. This skill should be used when the user asks to "test the app UI", "check the UI", "interact with the running app", "take a screenshot of the app", "fill in the form", "click the button", "verify the UI state", "automate the app", or wants to automate interaction with a running macOS application without stealing keyboard/mouse focus from the user.
# macOS UI Testing (Background-Safe) Run the `/peekaboo` skill first to load full Peekaboo CLI reference. This skill adds background-safe patterns on top. ## Background Safety Rules Simulated keystrokes (`peekaboo type`, `peekaboo paste`, `peekaboo hotkey`) go to the **frontmost app**, not the target app. Always use background-safe alternatives. | Action | Background-Safe | Command | |---|---|---| | Read UI tree | Yes | `peekaboo see --app APP --json` | | Screenshot | Yes | `peekaboo see --window-id WID --no-elements` | | Click element | Yes | `peekaboo click --no-auto-focus --on ELEM --app APP` | | Set text value | Yes* | `osascript` with `set value of` | | Read text value | Yes | `osascript` with `get value of` | | Click button | Yes | `osascript` with `click button` | | Type keystrokes | **NO** | Lands in frontmost app | | Paste (Cmd+V) | **NO** | Lands in frontmost app | | Hotkeys | **NO** | Lands in frontmost app | *`set value` updates the accessibility layer but **does not trigger SwiftUI `@State` bindings**. Use `keystroke` inside a single osascript block when SwiftUI binding updates are needed (see Multi-Step Interaction Sequences below). ## Check for a Locked Screen While the session screen is locked, window accessibility elements report the role `AXApplication`, controls inside windows can't be resolved, and window captures fail (`screencapture -l` reports "could not create image from window"). Read the lock state before starting the workflow: ```bash ioreg -n Root -d1 -a | grep -A1 CGSSessionScreenIsLocked ``` `<true/>` on the line after the key means locked. No output means unlocked, because the key is absent then. An `ioreg` error, such as "can't open file" inside a sandbox, leaves the lock state unknown. When the screen is locked, record the GUI check as inconclusive instead of working around it. ## Workflow ### Step 1: Launch the App ```bash # For SwiftPM-built apps: swift run AppName &>/dev/null & APP_PID=$! sleep 3 # For installed apps: open -a "AppName" ``` ### Step 2: Verify the App Is Running ```bash peekaboo app list | grep -i AppName ``` ### Step 3: Read the UI Tree ```bash peekaboo see --app AppName --json ``` Parse the JSON output to find element IDs (`elem_N`), roles, and labels. ### Step 4: Take a Screenshot Resolve the window ID first, then capture by ID: ```bash peekaboo window list --app AppName peekaboo see --window-id WID --no-elements --path /tmp/screenshot.png ``` ### Step 5: Interact with Elements **Click an element** (accessibility API, no focus needed): ```bash peekaboo click --no-auto-focus --on elem_4 --app AppName ``` **Set a text field value** (background-safe, but does NOT trigger SwiftUI bindings): ```bash osascript -e 'tell application "System Events" to set value of text field 1 of group 1 of window 1 of process "AppName" to "text"' ``` **Read a text field value**: ```bash osascript -e 'tell application "System Events" to get value of text field 1 of group 1 of window 1 of process "AppName"' ``` **Click a button by name**: ```bash osascript -e 'tell application "System Events" to click button "Submit" of group 1 of window 1 of process "AppName"' ``` > **Synthetic coordinate clicks don't drive SwiftUI gestures.** AppleScript `click at {x,y}` and `peekaboo click --coords` do **not** trigger `.onTapGesture`/`.simultaneousGesture` or `List(selection:)` selection — only real user clicks or an accessibility **`AXPress`** on an actual `Button` do (`peekaboo perform-action --on ELEM --action AXPress`, or `peekaboo click --on ELEM` from `see --json`). So a `Button` is verifiable, but UI relying on tap gestures or list selection **can't be confirmed by synthetic clicks** — treat such a result as a signal, not proof, and hand the app to the user when only real clicks suffice. > > Peekaboo targets apps by **display name** (`--app Ducko`), not the executable name (`DuckoApp`). ### Step 6: Verify Results Take another screenshot or read values back to confirm the interaction succeeded. ### Step 7: Stop the App ```bash kill $APP_PID ``` ## Multi-Step Interaction Sequences Each tool call returns focus to the terminal. Multi-step flows **must** be in a single `osascript` heredoc -- splitting across tool calls loses focus between steps. ```bash osascript << 'APPLESCRIPT' tell application "System Events" set frontmost of process "AppName" to true delay 0.5 tell process "AppName" click text field 1 of group 1 of window 1 delay 0.3 keystroke "username" delay 0.2 keystroke tab delay 0.2 keystroke "password" delay 0.3 click button 1 of group 1 of window 1 end tell end tell APPLESCRIPT ``` This pattern uses `keystroke` (triggers SwiftUI bindings) and keeps focus on the target app throughout the sequence. **Important**: Use `osascript << 'APPLESCRIPT'` heredoc syntax (not `osascript -e`) to avoid shell escaping issues. ## osascript Element Discovery When the UI hierarchy is unknown, explore it incrementally with osascript. See [references/osascript-patterns.md](references/osascript-patterns.md) for element addressing patterns and exploration techniques. ## Peekaboo Focus Timeout Issue Peekaboo's `click` and `type` with `--app` try to activate the app first. For SwiftPM-built apps this often times out because `NSRunningApplication.activate()` is not acknowledged. **Workarounds:** - Use `--no-auto-focus` on `click` commands - `see` works fine with `--app` or `--window-id` (read-only, skips focus) **Caveat with `--no-auto-focus`**: Clicks use absolute screen coordinates. If another window overlaps the target, the click hits the wrong window. Prefer `osascript` click (accessibility API, position-independent) for reliable button clicks. ## Driving SwiftUI Controls (gotchas) SwiftUI controls bridge to accessibility in ways that defeat naive synthetic automation: - **Auto-focusing fields need confirmed focus and targeted input.** Activate the intended app and, for an identifiable field, confirm that its application’s `kAXFocusedUIElementAttribute` resolves to that field before typing. Ducko’s integration harness posts Unicode CGEvents to the owned PID with `postToPid`. System Events keystrokes and global event taps depend on the frontmost app, so avoid them when multiple instances are open. If a field does not expose its typed text through `kAXValue`, assert the resulting behavior, such as filtered rows. - **A SwiftUI `Menu` (`.menuStyle(.button)`) opens its menu as a top-level / process-level `AXMenu`** — a sibling of the windows, like a context menu — NOT as a descendant of the button. `kAXShownMenuUIElementAttribute` on the button and a popup-scoped descendant search both miss it; resolve the opened menu from the **application root** (search the app's top children excluding the menu bar for a `kAXMenuRole`). To open it, use an `AXPress` (`kAXPressAction`), not a coordinate click (see the Step 5 note). The Menu's label text isn't in `kAXValue` — add `.accessibilityValue(...)` in the app so the current selection is readable (also a VoiceOver win). - **A menu opened only to read its items stays open on screen.** Pressing an item closes a menu; `AXCancel` (`kAXCancelAction`) on it leaves it up. While it is up, another `AXShowMenu` opens nothing, so a lookup of the open menu returns the earlier one and every reading after the first is stale. Close a menu that was only read by activating the app and posting Escape to that process alone (`CGEvent.postToPid`, virtual key 53). Confirm it closed from the process's on-screen windows: in `CGWindowListCopyWindowInfo` filtered by owner PID, an open menu is a window at the pop-up-menu level (layer 101). - **osascript `entire contents` silently truncates on deep SwiftUI AX trees** — it misses deeply-nested identifiers that `peekaboo see --json` and a Swift `AXUIElement` walk find reliably; `... of window 1` (e.g. `UI element of window 1`) only checks **direct** children, not descendants. Don't rely on osascript element-finding for deeply-nested SwiftUI elements — use peekaboo or a Swift AX walk. Menu-bar navigation (`click menu item "X" of menu 1 of menu bar item "Y" of menu bar 1`) stays reliable and is the robust way to drive menu commands from osascript. - **A context menu opened in an AppKit `NSTableView` that hosts SwiftUI rows can hang off the table.** On macOS 27, `AXShowMenu` on a row's static text opened a menu that appeared as an `AXMenu` under the `AXTable`, not among the application's top children and not under the text. When the application-root lookup finds nothing, search the windows for a `kAXMenuRole` element. - **A `Button` at `.opacity(0)` was not found in the accessibility tree by identifier**, and a control inserted only while hovering (`if isHovering { … }`) exists only while the pointer is over it. To reach such a control's effect without a pointer, give an always-present element a named action (`.accessibilityAction(named:)`). It shows up in that element's action names (`AXUIElementCopyActionNames`) as a multi-line string that starts with `Name:<title>` (observed: `Name:Copy Code\nTarget:0x0\nSelector:(null)`); pass that exact string to `AXUIElementPerformAction`. - **Key presses with modifiers can be posted to one process.** Set `flags` on the `CGEvent` (`.maskShift`, `.maskAlternate`) before `postToPid`; SwiftUI `.onKeyPress` handlers receive the modifiers. Virtual key 36 is Return, and 76 is the keypad's Enter (posted with `.maskNumericPad`). In the observed run the app had been activated with `NSRunningApplication.activate()`, yet `NSApp.keyWindow` was nil while keys were posted this way, and `NSApp.sendAction(_:to:from:)` with a nil target returned false. Confirm a key window before relying on a responder-chain action. - **`AXScrollToVisible` scrolled an element of a `LazyVStack` into view** when performed on an element already in the tree. Confirm with a window screenshot. - **A test that triggers a copy overwrites the user's clipboard.** Save every pasteboard item's types and data first (`NSPasteboard.general.pasteboardItems`) and read the result. Then call `clearContents()` and `writeObjects` with new `NSPasteboardItem`s built from the saved data. ## Additional Resources - Full Peekaboo CLI reference: `peekaboo --help` or `peekaboo <subcommand> --help` - osascript UI scripting patterns: [references/osascript-patterns.md](references/osascript-patterns.md)
View on GitHub