mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-05 16:34:00 +01:00
* fix(plan-design-review): say the design check uses OpenAI vision, not GPT-4o (#3350) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(health): compute the GBrain score in code so the rubric and formula can't disagree (#3351) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(ios-qa): demo mode states its UI-only rule without overriding the skill's other rules (#3352) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(office-hours): diff against origin/HEAD, not a hard-coded origin/main with stderr hidden (#3347) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs(changelog): #3347 #3350 #3351 #3352 skill template fixes under [Unreleased] Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --------- Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
302 lines
15 KiB
Cheetah
302 lines
15 KiB
Cheetah
---
|
|
name: ios-qa
|
|
preamble-tier: 3
|
|
version: 1.0.0
|
|
description: |
|
|
Live-device iOS QA for SwiftUI apps. Connects to a real iPhone or iPad via
|
|
USB CoreDevice IPv6 tunnel, reads Swift source to understand every screen, then
|
|
runs a vision-driven agent loop: screenshot → analyze → decide → act →
|
|
verify → repeat. All interaction happens via HTTP to an embedded
|
|
StateServer in the app under test. Optionally exposes the device over
|
|
Tailscale so remote agents (OpenClaw, Codex, any HTTP-capable agent) can
|
|
run iOS QA from anywhere without touching the hardware.
|
|
Use when asked to "ios qa", "test my iPhone app", "test my iPad app",
|
|
"find bugs on the device", or "qa the iOS app". (gstack)
|
|
voice-triggers:
|
|
- "iOS quality check"
|
|
- "test the iPhone app"
|
|
- "test the iPad app"
|
|
- "run iOS QA"
|
|
allowed-tools:
|
|
- Bash
|
|
- Read
|
|
- Write
|
|
- Edit
|
|
- Grep
|
|
- Glob
|
|
- AskUserQuestion
|
|
triggers:
|
|
- ios qa
|
|
- test the iphone app
|
|
- test the ipad app
|
|
- test my ios app
|
|
- find bugs on the device
|
|
- qa the ios app
|
|
---
|
|
|
|
{{PREAMBLE}}
|
|
|
|
# Live-device iOS QA
|
|
|
|
This skill drives a real iPhone or iPad via USB. The agent reads your Swift source,
|
|
generates typed state accessors, deploys a debug bridge, and runs a closed
|
|
find→fix→verify loop. No simulator, no XCTest, no WebDriverAgent.
|
|
|
|
## Architecture
|
|
|
|
```
|
|
┌──────────────────────┐ USB CoreDevice (IPv6) ┌──────────────────┐
|
|
│ gstack-ios-qa daemon │ ────────────────────────▶ │ iOS app │
|
|
│ (Mac, bun/TS) │ bearer + X-Session-Id │ StateServer │
|
|
│ │ │ (loopback only) │
|
|
│ - boot token rotate │ │ - /tap /swipe │
|
|
│ - session minting │ │ - /type /state │
|
|
│ - audit + redact │ │ - /snapshot │
|
|
└──────────────────────┘ └──────────────────┘
|
|
▲
|
|
│ Tailscale (optional, --tailnet)
|
|
│
|
|
┌──────────────────────┐
|
|
│ Remote agent │
|
|
│ (OpenClaw, etc.) │
|
|
└──────────────────────┘
|
|
```
|
|
|
|
The iOS app's `StateServer` binds loopback only (`::1` + `127.0.0.1`). Tailnet
|
|
ingress is exclusively the Mac daemon's job. The daemon validates Tailscale
|
|
identities via the local `tailscaled` socket and mints short-lived session
|
|
tokens (default 1h) for remote agents.
|
|
|
|
## Prerequisites
|
|
|
|
- macOS (the daemon uses `devicectl` from Xcode).
|
|
- iPhone or iPad connected via USB, paired and trusted. With more than one
|
|
connected, pick one before starting the daemon:
|
|
`export GSTACK_IOS_TARGET_UDID=<udid>` (`xcrun devicectl list devices` shows
|
|
UDIDs). Otherwise the daemon refuses to guess, lists each device with its
|
|
UDID, and prints that export line.
|
|
- Xcode + Swift toolchain installed (`swift --version` reports >= 5.9).
|
|
- App source available on disk, with at least one `@Observable` class.
|
|
- For remote-control mode: Tailscale installed and the user logged in.
|
|
|
|
## Phase 0: Session warm-start (optional)
|
|
|
|
After each successful bootstrap the daemon writes the rotated StateServer
|
|
bearer to `$GSTACK_STATE_ROOT/ios-qa-session.json` (mode 0600), bound to the
|
|
device UDID, bundle ID and StateServer port. A restarted daemon resumes that
|
|
bearer on its first device request, so the app keeps its QA state instead of
|
|
being relaunched. It bootstraps normally instead when the app was relaunched
|
|
(the app rejects the bearer), another device or app answers, devicectl reports
|
|
no tunnel address for the bound UDID, or other users can read the file.
|
|
|
|
If the file exists and the daemon answers, the bridge is already installed:
|
|
skip Phase 1-2 and jump to Phase 3. `--cold` deletes the file so the next
|
|
daemon start runs a full bootstrap (the app relaunches once); a daemon that is
|
|
already running keeps its in-memory session.
|
|
|
|
```bash
|
|
GSTACK_STATE_ROOT=$(~/.claude/skills/gstack/bin/gstack-paths --get GSTACK_STATE_ROOT); : "${GSTACK_STATE_ROOT:?gstack-paths failed; reinstall with ./setup or /gstack-upgrade}"
|
|
SESSION="$GSTACK_STATE_ROOT/ios-qa-session.json"
|
|
PORT="${GSTACK_IOS_DAEMON_PORT:-9099}"
|
|
if [ "$COLD" = "1" ]; then
|
|
rm -f "$SESSION"
|
|
elif [ -f "$SESSION" ] && curl -sf "http://127.0.0.1:$PORT/healthz" > /dev/null; then
|
|
echo "Warm start: daemon alive on port $PORT with a persisted device session"
|
|
fi
|
|
```
|
|
|
|
## Phase 1: Read source, plan codegen
|
|
|
|
1. Before changing the app or replacing an installed build, verify that the
|
|
bridge is compatible with the project:
|
|
- The generator currently supports file-scope `@Observable` classes only;
|
|
`ObservableObject`, `@StateObject`, and other observation models do not
|
|
produce accessors.
|
|
- The documented dependency wiring assumes a SwiftPM app manifest. For an
|
|
`.xcodeproj` or `.xcworkspace`, do not invent package or target wiring.
|
|
If either requirement is unmet, stop the bridge bootstrap without modifying
|
|
the app. Preserve any installed production or TestFlight build. Prefer an
|
|
existing real-device XCUITest harness; when a separate QA build is needed,
|
|
use an isolated bundle identifier and non-production entitlements so it can
|
|
coexist with the production app. Report fixture-driven state, provider UI,
|
|
and actual external-provider success as distinct evidence tiers.
|
|
2. Walk the app source (passed as `--source <dir>`) and identify all `@Observable`
|
|
classes. Note any property immediately preceded by the generator marker
|
|
comment `// @Snapshotable` — those are the snapshot-eligible fields. The
|
|
marker is a comment so it composes with the `@Observable` macro. Each
|
|
marked field must belong to a file-scope observable class and be a writable
|
|
instance `var` with an explicit type and an internal or public setter.
|
|
Snapshot types are JSON-native scalars (`String`, `Bool`, integer widths,
|
|
`Float`, `Double`, `CGFloat`), arrays, String-keyed dictionaries, and their
|
|
Optional compositions. Keys must be unique across observable classes.
|
|
Codegen stops with a source diagnostic instead of emitting a broken or
|
|
lossy harness when any of these constraints is violated.
|
|
3. Show the user the accessor list and ask whether to install the DebugBridge
|
|
SPM dependency into their `Package.swift` (one AskUserQuestion).
|
|
|
|
## Phase 2: Bootstrap the device bridge
|
|
|
|
1. Generate the canonical local bridge package, typed accessors, and installed
|
|
version marker with one deterministic command:
|
|
```bash
|
|
~/.claude/skills/gstack/bin/gstack-ios-qa-regen \
|
|
--app-source "<source-dir>" \
|
|
--bridge-dir "<source-dir>/DebugBridge"
|
|
```
|
|
The regenerator also removes the explicit obsolete flat-file set created by
|
|
older ios-sync versions, preventing a stale second harness from remaining
|
|
in the app target.
|
|
Source control: `DebugBridge/` is generated; never hand-edit it. Commit it
|
|
when teammates or CI build the Debug configuration without gstack (re-run
|
|
the regenerator after a gstack upgrade); otherwise add `DebugBridge/` to
|
|
`.gitignore` and have each developer run the regenerator. Tell the user
|
|
which one you picked.
|
|
2. Add the generated `DebugBridge` local SPM dependency to the app's
|
|
`Package.swift`. The package
|
|
ships three Debug-config-only library products:
|
|
- `DebugBridgeCore` (Swift, cross-platform) — StateServer + bridge protocols.
|
|
- `DebugBridgeTouch` (Objective-C, iOS-only) — KIF-derived in-process touch
|
|
synthesis with iOS 18+ `_UIHitTestContext` SwiftUI hit-testing.
|
|
- `DebugBridgeUI` (Swift, iOS-only) — Screenshot / Elements / Mutation
|
|
bridge implementations.
|
|
The app target depends on `DebugBridgeUI` with `.when(configuration: .debug)`
|
|
(transitively pulls in Core + Touch). Release builds refuse to link these
|
|
targets.
|
|
3. Wire the bridges from the `@main` App init, gated on `#if DEBUG`:
|
|
```swift
|
|
#if DEBUG
|
|
import DebugBridgeCore
|
|
#if canImport(UIKit)
|
|
import DebugBridgeUI
|
|
// Install resolvers before StateServer opens its listener.
|
|
DebugBridgeUIWiring.installAll()
|
|
#endif
|
|
// Replace AppState/AppStateAccessor with the type discovered in Phase 1.
|
|
DebugBridgeManager.shared.start(
|
|
appState: appState,
|
|
register: AppStateAccessor.register
|
|
)
|
|
#endif
|
|
```
|
|
4. Build + deploy to the device with `xcodebuild -scheme <SchemeName>
|
|
-destination 'platform=iOS,id=<UDID>' build install`.
|
|
5. Launch via `devicectl device process launch --device <UDID> <bundle-id>`.
|
|
On launch the StateServer writes a one-use boot token to a 0600 file in the
|
|
app's `tmp/`; the daemon copies it out with `devicectl`. The token is never
|
|
printed to `os_log`. If the app cannot write that file, it logs `NOT READY`
|
|
and the daemon reports `boot_token_unavailable` with the cause.
|
|
6. Spawn the Mac-side daemon (on-demand) — `gstack-ios-qa-daemon`. Daemon
|
|
acquires an exclusive flock on `~/.gstack/ios-qa-daemon.pid`. If another
|
|
daemon is alive, the second invocation discovers its port and connects.
|
|
7. Daemon immediately calls `POST /auth/rotate` on the iOS StateServer with a
|
|
fresh in-memory-only token. Rotation deletes the boot-token file, so a copy
|
|
taken after this point is a dead credential.
|
|
If a fresh daemon finds the app running after another daemon consumed that
|
|
one-use token, it verifies the bundle owner, relaunches the target once,
|
|
waits for the new token, verifies ownership again, and then rotates.
|
|
|
|
## Phase 3: Vision-driven agent loop
|
|
|
|
Each iteration:
|
|
|
|
1. `GET /screenshot` (via daemon) → save PNG.
|
|
2. `GET /elements` → accessibility tree.
|
|
3. `GET /state/snapshot` (only `// @Snapshotable` fields) → current state.
|
|
4. Decide next action based on what's on the screen vs the test goal.
|
|
5. `POST /session/acquire` to grab the device lock.
|
|
6. Execute `POST /tap`, `/swipe`, `/type`, or `POST /state/<key>` write.
|
|
7. Re-screenshot; compare; record finding if buggy.
|
|
8. `POST /session/release` once the iteration is done.
|
|
|
|
Each authenticated mutating request through the tailnet listener (if remote
|
|
mode is active) writes an audit row to
|
|
`~/.gstack/security/ios-qa-audit.jsonl`.
|
|
|
|
## Modes
|
|
|
|
**Local-USB mode (default).** Daemon binds loopback only; no Tailscale
|
|
required. The spawning skill gets full-surface access. Best for solo
|
|
development.
|
|
|
|
**Tailnet mode (`--tailnet`).** Daemon additionally binds the Tailscale
|
|
interface (never `0.0.0.0`). Requires `tailscaled` to be running locally and
|
|
the daemon to be able to read `/var/run/tailscale.sock`. Fails closed if the
|
|
socket is missing, permission-denied, or returns an unparseable WhoIs
|
|
response. Remote agents hit `POST /auth/mint` over tailnet, daemon
|
|
canonicalizes identity via WhoIs, checks the allowlist file, mints a
|
|
session token. See `ios-qa/docs/tailscale-acl-example.md`.
|
|
|
|
**Capability tiers (tailnet mode).** Minted tokens default to `interact`
|
|
(taps, swipes, types). Higher tiers require explicit owner mint:
|
|
|
|
- **observe:** `/screenshot`, `/elements`, `GET /state/*`, `/healthz`,
|
|
`/session/heartbeat`.
|
|
- **interact:** observe + `/tap`, `/swipe`, `/type`.
|
|
- **mutate:** interact + `POST /state/<key>`.
|
|
- **restore:** mutate + `POST /state/restore`.
|
|
|
|
Owner mints via `gstack-ios-qa-mint --remote <identity> --capability <tier>`
|
|
on the Mac. Self-service mint over tailnet only succeeds for already-allowlisted
|
|
identities.
|
|
|
|
**Recording mode (`--recording`).** DebugOverlay renders a small diagonal
|
|
"AGENT DEMO" watermark in a corner so screencasts are unambiguous about the
|
|
device being agent-driven.
|
|
|
|
## Demo mode
|
|
|
|
If the user says "demo", "demo mode", "show me", or "I want to see it
|
|
working", run in **DEMO MODE**. This changes how the agent interacts with
|
|
the app:
|
|
|
|
In demo mode, drive every action through visible UI (`/tap`, `/swipe`,
|
|
`/type`) rather than `POST /state/*` writes, because viewers watch each
|
|
step: they see the agent type every key and tap every button. All other
|
|
rules in this skill still apply. The on-device DebugOverlay attribution
|
|
chip shows "Driven by Claude Code (demo)" or the remote agent identity.
|
|
|
|
In demo mode, the screencap rate is bumped to 4fps so the recording feels
|
|
live.
|
|
|
|
## Failure modes + recovery
|
|
|
|
| Symptom | Likely cause | Action |
|
|
|---|---|---|
|
|
| `curl: connection refused` to daemon | daemon crashed | Re-run `/ios-qa`; spawn-race lock will fail closed |
|
|
| `403 identity_not_allowed` from `/auth/mint` | identity missing from allowlist | Run `gstack-ios-qa-mint --remote <identity>` on the Mac |
|
|
| `409 schema_mismatch` on `/state/restore` | snapshot from older app build | Discard the snapshot; re-capture |
|
|
| `503 device_disconnected` / `504 upstream_timeout` from proxy | USB route dropped, app stopped, or app relaunched | Daemon probes the running app with its session bearer and keeps the session (no relaunch, app state intact). It bootstraps only when the app rejected the bearer (401), is not running, or a different device is now selected. A lost `/tap`/`/swipe`/`/type` response is never replayed: check the screen before retrying. If it persists, reconnect/unlock the device |
|
|
| `multiple_devices` at bootstrap | iPhone and iPad (or two devices) connected, no target set | Run the printed `export GSTACK_IOS_TARGET_UDID=<udid>`, then restart the daemon |
|
|
| `boot_token_unavailable ... could not write` | app's `tmp/` not writable | Fix the app container, relaunch the app |
|
|
| App relaunched after the daemon restarted | a new daemon has no session bearer and the one-use boot token is gone | Expected: the first bootstrap relaunches the app once; keep one daemon alive for a session |
|
|
| `429 rate_limited` from `/auth/mint` | >10 mints/min from one identity | Wait 60s; check audit log for anomalies |
|
|
| `413 body_too_large` on `/state/restore` | snapshot >1MB | Increase `--max-body` or trim snapshot |
|
|
|
|
## Known limits
|
|
|
|
Device-verified by users, not fixable in the bridge today. Plan around them:
|
|
|
|
- **SwiftUI gestures on iOS 26.** In-process synthesized touches report success
|
|
but never reach a SwiftUI `DragGesture` (for example a `Canvas` driven by
|
|
drag input), even with phase-separated touches (seen on iOS 26.5). Buttons and
|
|
UIKit controls still respond. For gesture-driven views, have the app expose
|
|
its input handlers to the bridge under `#if DEBUG` and drive them through a
|
|
state write, or cover the flow with an XCUITest harness.
|
|
- **`/swipe` scrolls only.** It moves the nearest enclosing `UIScrollView` and
|
|
returns `false` when there is none; it is not a drag. Custom pan or drag
|
|
views need the input-routing approach above.
|
|
- **`/elements` on iOS 26.** The in-process SwiftUI accessibility tree is often
|
|
not materialized: an iPhone 12 Pro on iOS 26.3.1 returned only the three
|
|
hosting views, with no identifiers or labels. Locate controls from the
|
|
screenshot and tap by coordinate.
|
|
- **iPad windows.** iPad sessions work like iPhone sessions, but the overlay
|
|
and window selection have not been verified with Stage Manager or multiple
|
|
scenes; report what you see.
|
|
|
|
## Cleanup
|
|
|
|
Use `/ios-clean` to remove the DebugBridge SPM dependency and all `#if DEBUG`
|
|
wiring before a Release build. This is a convenience flow; the structural
|
|
Release-build guard (Package.swift `.when(configuration: .debug)` + CI
|
|
`swift build -c release` check) is the safety-critical path.
|