diff --git a/AGENTS.md b/AGENTS.md
index 69651022d..4df7eec35 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -92,6 +92,7 @@ Companion CLIs (run on the Mac that's plugged into the device):
|---------|-------------|
| `gstack-ios-qa-daemon` | Mac-side broker. Loopback by default; `--tailnet` adds a Tailscale-facing listener with capability tiers and audit logging. |
| `gstack-ios-qa-mint` | Owner-grant CLI for the tailnet allowlist (`grant`/`revoke`/`list`). |
+| `gstack-ios-qa-regen` | Regenerate the canonical local DebugBridge package and typed accessors (`--app-source` / `--bridge-dir`). |
End-to-end walkthrough: [docs/howto-ios-testing-with-gstack.md](docs/howto-ios-testing-with-gstack.md).
diff --git a/README.md b/README.md
index 4bb177c3a..af534d2c5 100644
--- a/README.md
+++ b/README.md
@@ -245,6 +245,7 @@ Beyond the slash-command skills, gstack ships standalone CLIs for workflows that
| `gstack-taste-update` | **Design taste learning** — writes approvals and rejections from `/design-shotgun` into a persistent per-project taste profile. Decays 5%/week. Feeds back into future variant generation so the system learns what you actually pick. |
| `gstack-ios-qa-daemon` | **iOS QA daemon** — Mac-side broker between an agent and a connected iPhone over USB CoreDevice. Loopback by default; `--tailnet` opens a Tailscale-facing listener with identity-gated capability tiers. Single-instance via flock on `~/.gstack/ios-qa-daemon.pid`. See [docs/howto-ios-testing-with-gstack.md](docs/howto-ios-testing-with-gstack.md). |
| `gstack-ios-qa-mint` | **iOS allowlist manager** — owner-grant CLI for the tailnet allowlist. `grant`/`revoke`/`list` against `~/.gstack/ios-qa-allowlist.json` (mode 0600). Remote agents never auto-allowlist; this is the explicit-intent path. |
+| `gstack-ios-qa-regen` | **iOS bridge regenerator** — deterministically installs the canonical DebugBridge package, generates typed state accessors, and records the installed gstack version. Safe to rerun after source changes or upgrades. |
### Continuous checkpoint mode (opt-in, local by default)
diff --git a/bin/gstack-ios-qa-regen b/bin/gstack-ios-qa-regen
new file mode 100755
index 000000000..ffaec9ffb
--- /dev/null
+++ b/bin/gstack-ios-qa-regen
@@ -0,0 +1,154 @@
+#!/usr/bin/env bash
+# gstack-ios-qa-regen — deterministically regenerate the iOS DebugBridge
+# package and the app-owned typed state accessors.
+
+set -euo pipefail
+
+usage() {
+ cat <<'EOF'
+Usage: gstack-ios-qa-regen --app-source
--bridge-dir
+
+ --app-source Swift source tree to scan for @Observable state
+ --bridge-dir Destination for the generated local DebugBridge package
+EOF
+}
+
+APP_SOURCE=""
+BRIDGE_DIR=""
+
+while [[ $# -gt 0 ]]; do
+ case "$1" in
+ --app-source)
+ [[ $# -ge 2 ]] || { echo "gstack-ios-qa-regen: --app-source requires a value" >&2; exit 2; }
+ APP_SOURCE="$2"
+ shift 2
+ ;;
+ --bridge-dir)
+ [[ $# -ge 2 ]] || { echo "gstack-ios-qa-regen: --bridge-dir requires a value" >&2; exit 2; }
+ BRIDGE_DIR="$2"
+ shift 2
+ ;;
+ -h|--help)
+ usage
+ exit 0
+ ;;
+ *)
+ echo "gstack-ios-qa-regen: unknown argument: $1" >&2
+ usage >&2
+ exit 2
+ ;;
+ esac
+done
+
+if [[ -z "$APP_SOURCE" || -z "$BRIDGE_DIR" ]]; then
+ echo "gstack-ios-qa-regen: both --app-source and --bridge-dir are required" >&2
+ usage >&2
+ exit 2
+fi
+
+if [[ ! -d "$APP_SOURCE" ]]; then
+ echo "gstack-ios-qa-regen: app source directory not found: $APP_SOURCE" >&2
+ exit 1
+fi
+
+if ! command -v bun >/dev/null 2>&1; then
+ echo "gstack-ios-qa-regen: bun runtime not on PATH — install from https://bun.sh" >&2
+ exit 1
+fi
+
+SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
+GSTACK_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
+TEMPLATE_DIR="$GSTACK_ROOT/ios-qa/templates"
+GENERATOR="$GSTACK_ROOT/ios-qa/scripts/gen-accessors.ts"
+VERSION_FILE="$GSTACK_ROOT/VERSION"
+GENERATED_DIR="$APP_SOURCE/DebugBridgeGenerated"
+
+for required in "$GENERATOR" "$VERSION_FILE"; do
+ if [[ ! -f "$required" ]]; then
+ echo "gstack-ios-qa-regen: missing required gstack file: $required" >&2
+ exit 1
+ fi
+done
+
+TMP_FILE=""
+cleanup() {
+ if [[ -n "$TMP_FILE" ]]; then
+ rm -f "$TMP_FILE"
+ fi
+}
+trap cleanup EXIT
+
+# Copy through a sibling temporary file so interruption never leaves a
+# truncated generated source. Preserve an unchanged destination byte-for-byte
+# and metadata-for-metadata on repeated runs.
+install_file() {
+ local source="$1"
+ local destination="$2"
+
+ if [[ ! -f "$source" ]]; then
+ echo "gstack-ios-qa-regen: missing template: $source" >&2
+ exit 1
+ fi
+ if [[ -f "$destination" ]] && cmp -s "$source" "$destination"; then
+ return
+ fi
+
+ mkdir -p "$(dirname "$destination")"
+ TMP_FILE="${destination}.tmp.$$"
+ cp "$source" "$TMP_FILE"
+ mv "$TMP_FILE" "$destination"
+ TMP_FILE=""
+}
+
+# Invalidate the completion marker before changing any package source. A
+# failed or interrupted regeneration must never look current to ios-sync.
+mkdir -p "$GENERATED_DIR"
+rm -f -- "$GENERATED_DIR/.gstack-version"
+
+# This is intentionally an allowlist, not a template glob. Wiring belongs to
+# the consuming app and StateAccessor.swift is emitted by the parser below.
+install_file "$TEMPLATE_DIR/Package.swift.template" \
+ "$BRIDGE_DIR/Package.swift"
+install_file "$TEMPLATE_DIR/StateServer.swift.template" \
+ "$BRIDGE_DIR/Sources/DebugBridgeCore/StateServer.swift"
+install_file "$TEMPLATE_DIR/DebugBridgeManager.swift.template" \
+ "$BRIDGE_DIR/Sources/DebugBridgeCore/DebugBridgeManager.swift"
+install_file "$TEMPLATE_DIR/Bridges.swift.template" \
+ "$BRIDGE_DIR/Sources/DebugBridgeUI/Bridges.swift"
+install_file "$TEMPLATE_DIR/DebugOverlay.swift.template" \
+ "$BRIDGE_DIR/Sources/DebugBridgeUI/DebugOverlay.swift"
+install_file "$TEMPLATE_DIR/DebugBridgeTouch.m.template" \
+ "$BRIDGE_DIR/Sources/DebugBridgeTouch/DebugBridgeTouch.m"
+install_file "$TEMPLATE_DIR/DebugBridgeTouch.h.template" \
+ "$BRIDGE_DIR/Sources/DebugBridgeTouch/include/DebugBridgeTouch.h"
+
+# Older ios-sync versions copied the entire template set flat into the app's
+# generated-source directory. Those files can shadow the package modules or
+# make Xcode compile two harness implementations. Remove only the explicit
+# obsolete generated paths; handwritten app sources are never touched.
+for obsolete in \
+ "$BRIDGE_DIR/DebugBridgeWiring.swift" \
+ "$BRIDGE_DIR/StateAccessor.swift" \
+ "$GENERATED_DIR/Package.swift" \
+ "$GENERATED_DIR/StateServer.swift" \
+ "$GENERATED_DIR/DebugBridgeManager.swift" \
+ "$GENERATED_DIR/Bridges.swift" \
+ "$GENERATED_DIR/DebugOverlay.swift" \
+ "$GENERATED_DIR/DebugBridgeTouch.m" \
+ "$GENERATED_DIR/DebugBridgeTouch.h" \
+ "$GENERATED_DIR/DebugBridgeWiring.swift"
+do
+ if [[ -f "$obsolete" || -L "$obsolete" ]]; then
+ rm -f -- "$obsolete"
+ echo "gstack-ios-qa-regen: removed obsolete generated file $obsolete"
+ fi
+done
+
+bun run "$GENERATOR" --input "$APP_SOURCE" --output "$GENERATED_DIR"
+
+# Stamp only after successful accessor generation. ios-sync uses this marker
+# to distinguish a complete current install from an interrupted regeneration.
+install_file "$VERSION_FILE" "$GENERATED_DIR/.gstack-version"
+
+echo "gstack-ios-qa-regen: bridge package ready at $BRIDGE_DIR"
+echo "gstack-ios-qa-regen: accessors ready at $GENERATED_DIR/StateAccessor.swift"
diff --git a/careful/bin/check-careful.sh b/careful/bin/check-careful.sh
index d9c39e48c..22bf8b922 100755
--- a/careful/bin/check-careful.sh
+++ b/careful/bin/check-careful.sh
@@ -25,26 +25,14 @@ fi
# Normalize: lowercase for case-insensitive SQL matching
CMD_LOWER=$(printf '%s' "$CMD" | tr '[:upper:]' '[:lower:]')
-# --- Check for safe exceptions (rm -rf of build artifacts) ---
-if printf '%s' "$CMD" | grep -qE 'rm\s+(-[a-zA-Z]*r[a-zA-Z]*\s+|--recursive\s+)' 2>/dev/null; then
- SAFE_ONLY=true
- RM_ARGS=$(printf '%s' "$CMD" | sed -E 's/.*rm[[:space:]]+(-[a-zA-Z]+[[:space:]]+)*//;s/--recursive[[:space:]]*//')
- for target in $RM_ARGS; do
- case "$target" in
- */node_modules|node_modules|*/\.next|\.next|*/dist|dist|*/__pycache__|__pycache__|*/\.cache|\.cache|*/build|build|*/\.turbo|\.turbo|*/coverage|coverage)
- ;; # safe target
- -*)
- ;; # flag, skip
- *)
- SAFE_ONLY=false
- break
- ;;
- esac
- done
- if [ "$SAFE_ONLY" = true ]; then
- echo '{}'
- exit 0
- fi
+# --- Check for safe exceptions (one standalone rm of build artifacts) ---
+# Match the complete command. Parsing only the last rm is unsafe because shell
+# syntax or comments can hide an earlier destructive command, for example:
+# rm -rf / # rm -rf node_modules
+# Unknown syntax fails closed and falls through to the destructive checks.
+if printf '%s' "$CMD" | grep -qE '^[[:space:]]*rm[[:space:]]+(-[a-zA-Z]*r[a-zA-Z]*[[:space:]]+|--recursive[[:space:]]+)(([^[:space:];&|#]*/)?(node_modules|\.next|dist|__pycache__|\.cache|build|\.turbo|coverage)[[:space:]]*)+$' 2>/dev/null; then
+ echo '{}'
+ exit 0
fi
# --- Destructive pattern checks ---
diff --git a/docs/howto-ios-testing-with-gstack.md b/docs/howto-ios-testing-with-gstack.md
index 1187e9a85..647a77c98 100644
--- a/docs/howto-ios-testing-with-gstack.md
+++ b/docs/howto-ios-testing-with-gstack.md
@@ -9,7 +9,7 @@ Everything below has been verified end-to-end on a real iPhone 17 Pro Max runnin
- macOS with Xcode 16.0+ installed (`xcrun devicectl --version` must succeed). Xcode 16 ships the CoreDevice tunnel `devicectl` uses to reach the device over USB.
- A real iPhone running iOS 16 or later. Unlocked, paired with your Mac, with **Developer Mode** enabled in Settings → Privacy & Security.
- An Apple developer team — the free personal team works fine for live-device debug deploys. You'll need the team ID (e.g. `623FYQ2M88`), not the certificate ID. Find it in Xcode → Settings → Accounts → your Apple ID → team list. The setup signs the app for your device on first deploy via `-allowProvisioningUpdates -allowProvisioningDeviceRegistration`.
-- gstack installed (`./setup` complete; `bin/gstack-ios-qa-daemon` must be on disk and executable).
+- gstack installed (`./setup` complete; `gstack-ios-qa-regen` and `gstack-ios-qa-daemon` must be on PATH).
- Bun runtime on PATH (`bun --version`). The Mac-side daemon is a bun process.
For the optional remote-agent (Tailscale) mode, you'll additionally need Tailscale installed on the Mac with `/var/run/tailscale.sock` readable.
@@ -30,28 +30,49 @@ For the optional remote-agent (Tailscale) mode, you'll additionally need Tailsca
The iOS `StateServer` is loopback-only **always**, even in remote mode. Identity validation happens Mac-side because the iPhone has no way to validate a Tailscale identity.
-## Step 1: Add the DebugBridge templates to your iOS app
+## Step 1: Generate the DebugBridge package
-The templates live at `~/.claude/skills/gstack/ios-qa/templates/` after `./setup`. The fastest install is to invoke the `/ios-qa` skill in Claude Code from your app's root — it reads your Swift source, codegens typed `@Observable` state accessors, and lays down the templates with your bundle ID. Or do it by hand:
+Run `/ios-qa` from the app root, or invoke the same deterministic regenerator directly:
-1. Copy these into a `DebugBridge/` SPM package inside your app workspace:
- - `Sources/DebugBridgeCore/StateServer.swift` (from `StateServer.swift.template`)
- - `Sources/DebugBridgeCore/DebugBridgeManager.swift` (from `DebugBridgeManager.swift.template`)
- - `Sources/DebugBridgeTouch/DebugBridgeTouch.m` + `Sources/DebugBridgeTouch/include/DebugBridgeTouch.h` (from the two `.template` files)
- - `Sources/DebugBridgeUI/Bridges.swift` (from `Bridges.swift.template`)
- - `Sources/DebugBridgeUI/DebugOverlay.swift` (from `DebugOverlay.swift.template`)
- - `Package.swift` (from `Package.swift.template`)
-2. Add the package as a local dependency of your app. Depend on the `DebugBridgeUI` product with `condition: .when(configuration: .debug)`. `DebugBridgeCore` and `DebugBridgeTouch` come in transitively.
-3. In your `@main` App init, gate the wiring on `#if DEBUG`:
+```bash
+gstack-ios-qa-regen \
+ --app-source "$PWD/Sources/YourApp" \
+ --bridge-dir "$PWD/DebugBridge"
+```
+
+The command copies an explicit allowlist of canonical templates into the local
+`DebugBridge/` Swift package, generates
+`DebugBridgeGenerated/StateAccessor.swift`, and writes the installed version to
+`DebugBridgeGenerated/.gstack-version`. It excludes generated output from its
+own schema hash, so rerunning it with unchanged source is a fast, byte-stable
+cache hit. It also removes the explicit legacy generated-file set from older
+flat harness layouts so stale bridge sources cannot shadow the package.
+
+1. Add `DebugBridge/` as a local package dependency. Depend on the
+ `DebugBridgeUI` product only in Debug configuration; `DebugBridgeCore` and
+ `DebugBridgeTouch` come in transitively.
+2. Add `DebugBridgeGenerated/StateAccessor.swift` to the app target.
+3. In your `@main` App init, install the UIKit resolvers before starting the
+ server, then register the generated accessor. Replace the example
+ state/accessor names with the type the generator found:
```swift
#if DEBUG
import DebugBridgeCore
- StateServer.shared.start()
#if canImport(UIKit)
import DebugBridgeUI
+ #endif
+ #endif
+
+ // Inside App.init(), after appState is initialized:
+ #if DEBUG
+ #if canImport(UIKit)
DebugBridgeUIWiring.installAll()
#endif
+ DebugBridgeManager.shared.start(
+ appState: appState,
+ register: AppStateAccessor.register
+ )
#endif
```
@@ -109,7 +130,16 @@ GSTACK_IOS_TARGET_BUNDLE_ID=com.yourorg.yourapp
GSTACK_IOS_DAEMON_PORT=9099 # loopback listener port; default 9099
```
-If `GSTACK_IOS_TARGET_UDID` is unset, the daemon picks the first paired connected device.
+If `GSTACK_IOS_TARGET_UDID` is unset, the daemon picks the best paired,
+available iPhone.
+Automatic selection is restricted to available iPhones and prefers a wired
+phone. The daemon keeps a healthy rotated tunnel, then invalidates and
+rebootstraps once on an app-relaunch 401 or recoverable CoreDevice connection
+failure.
+If a newly started daemon reaches an already-running target whose one-use boot
+token was consumed by an earlier daemon, it verifies the bundle owner, force
+relaunches that target once, waits for a fresh token, verifies ownership again,
+and rotates normally.
## Step 4: Drive the device
@@ -123,15 +153,39 @@ Once the daemon is running, you have an HTTP surface at `http://127.0.0.1:9099`
| `POST /session/release` | Release the lock. | bearer + session |
| `GET /screenshot` | Capture a PNG of the active window. Returns `{png_base64: "..."}`. | bearer |
| `GET /elements` | Accessibility-tree snapshot. | bearer |
-| `GET /state/snapshot` | Dump every `@Snapshotable` field as JSON. | bearer |
-| `POST /state/restore` | Atomically restore a full snapshot. | bearer + session, mutate tier |
+| `GET /state/snapshot` | Dump every `// @Snapshotable` field as JSON. | bearer |
+| `POST /state/restore` | Validate the full snapshot, then restore it on MainActor. | bearer + session, mutate tier |
| `POST /tap` `{x,y}` | Synthesize a real UITouch at window coordinates. SwiftUI Buttons fire. | bearer + session, interact tier |
| `POST /swipe` `{from_x,from_y,to_x,to_y}` | Scroll the nearest enclosing UIScrollView. | bearer + session, interact tier |
| `POST /type` `{text}` | Set text on the current first responder. | bearer + session, interact tier |
Mutating requests require both an `Authorization: Bearer ` header AND an `X-Session-Id` header. Read endpoints (`/screenshot`, `/elements`, `GET /state/*`) only need the bearer.
-The state snapshot is opt-in per field via a `@Snapshotable` property wrapper on your canonical state struct. Fields you don't annotate never appear in the snapshot, which keeps tokens, PII, and auth state out of recorded fixtures by default.
+The state snapshot is opt-in per field via a standalone generator marker
+comment immediately above a property. It is intentionally not a property
+wrapper, so it compiles cleanly with Observation:
+
+```swift
+@Observable
+final class AppState {
+ // @Snapshotable
+ var username: String = ""
+
+ var authToken: String = "" // never exported
+}
+```
+
+Unmarked fields never appear in the snapshot, which keeps tokens, PII, and
+auth state out of recorded fixtures by default. A marked field must be a
+writable instance `var` on a file-scope observable class, with an explicit type
+and an internal or public setter. Supported snapshot types are JSON-native
+scalars (`String`, `Bool`, signed/unsigned integer widths, `Float`, `Double`,
+`CGFloat`), arrays, String-keyed dictionaries, and Optional compositions of
+those types. Snapshot keys must be unique across observable classes. The
+generator reports and stops on invalid declarations, custom values, implicitly
+unwrapped Optionals, nested observable classes, or duplicate keys instead of
+emitting broken or lossy Swift. Restore uses two phases: every model validates
+the complete input first, and only then are assignments applied on MainActor.
## Step 5: Make remote agents work (optional)
diff --git a/ios-clean/SKILL.md b/ios-clean/SKILL.md
index 127649646..6c466896b 100644
--- a/ios-clean/SKILL.md
+++ b/ios-clean/SKILL.md
@@ -821,9 +821,8 @@ Each item is reverted only after AskUserQuestion confirmation:
1. The `DebugBridge` SPM target from `Package.swift`.
2. The `#if DEBUG` block in the app's `@main` entry that calls
`DebugBridgeManager.shared.start()`.
-3. Any `@Snapshotable` property wrappers on the canonical app state struct
- (the codegen-detection markers — the wrapper file lives inside
- DebugBridge so removing the SPM dep removes the wrapper too).
+3. Any standalone `// @Snapshotable` generator marker comments on the
+ canonical app state class.
4. Generated `StateAccessor.swift` files anywhere under the app source.
5. The `gstack-ios-qa.token` file under `NSTemporaryDirectory()` on the
device (best-effort — only works if device is connected when /ios-clean
diff --git a/ios-clean/SKILL.md.tmpl b/ios-clean/SKILL.md.tmpl
index 3a64481a9..21a1d5495 100644
--- a/ios-clean/SKILL.md.tmpl
+++ b/ios-clean/SKILL.md.tmpl
@@ -50,9 +50,8 @@ Each item is reverted only after AskUserQuestion confirmation:
1. The `DebugBridge` SPM target from `Package.swift`.
2. The `#if DEBUG` block in the app's `@main` entry that calls
`DebugBridgeManager.shared.start()`.
-3. Any `@Snapshotable` property wrappers on the canonical app state struct
- (the codegen-detection markers — the wrapper file lives inside
- DebugBridge so removing the SPM dep removes the wrapper too).
+3. Any standalone `// @Snapshotable` generator marker comments on the
+ canonical app state class.
4. Generated `StateAccessor.swift` files anywhere under the app source.
5. The `gstack-ios-qa.token` file under `NSTemporaryDirectory()` on the
device (best-effort — only works if device is connected when /ios-clean
diff --git a/ios-qa/SKILL.md b/ios-qa/SKILL.md
index a5d4575d4..24624be5f 100644
--- a/ios-qa/SKILL.md
+++ b/ios-qa/SKILL.md
@@ -869,17 +869,33 @@ fi
## Phase 1: Read source, plan codegen
1. Walk the app source (passed as `--source `) and identify all `@Observable`
- classes. Note any property marked with the `@Snapshotable` wrapper — those
- are the snapshot-eligible fields.
-2. Run `swift run --package-path $GSTACK_HOME/ios-qa/scripts/gen-accessors-tool gen-accessors --input `.
- First invocation builds the swift-syntax dependency tree (cold: 2-5 min).
- Subsequent runs are content-hash-cached and finish in ~50ms.
-3. Show the user the accessor list and ask whether to install the DebugBridge
+ 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.
+2. 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. Add the `DebugBridge` SPM dependency to the app's `Package.swift`. The package
+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 "" \
+ --bridge-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.
+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
@@ -889,27 +905,35 @@ fi
The app target depends on `DebugBridgeUI` with `.when(configuration: .debug)`
(transitively pulls in Core + Touch). Release builds refuse to link these
targets.
-2. Wire the bridges from the `@main` App init, gated on `#if DEBUG`:
+3. Wire the bridges from the `@main` App init, gated on `#if DEBUG`:
```swift
#if DEBUG
import DebugBridgeCore
- StateServer.shared.start()
#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
```
-3. Build + deploy to the device with `xcodebuild -scheme
+4. Build + deploy to the device with `xcodebuild -scheme
-destination 'platform=iOS,id=' build install`.
-4. Launch via `devicectl device process launch --device --console `.
+5. Launch via `devicectl device process launch --device --console `.
Capture the boot token printed to `os_log` on first run.
-5. Spawn the Mac-side daemon (on-demand) — `gstack-ios-qa-daemon`. Daemon
+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.
-6. Daemon immediately calls `POST /auth/rotate` on the iOS StateServer with a
+7. Daemon immediately calls `POST /auth/rotate` on the iOS StateServer with a
fresh in-memory-only token. The boot token becomes useless ~5s later.
Anything scraping `os_log` past this point sees 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
@@ -917,7 +941,7 @@ Each iteration:
1. `GET /screenshot` (via daemon) → save PNG.
2. `GET /elements` → accessibility tree.
-3. `GET /state/snapshot` (only `@Snapshotable` fields) → current state.
+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/` write.
@@ -981,7 +1005,7 @@ live.
| `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 ` on the Mac |
| `409 schema_mismatch` on `/state/restore` | snapshot from older app build | Discard the snapshot; re-capture |
-| `503 device_disconnected` from proxy | USB tunnel dropped | Reconnect device; daemon auto-reconnects within 30s |
+| `503 device_disconnected` from proxy | USB route dropped or app relaunched | Daemon invalidates the stale tunnel and retries one fresh bootstrap; reconnect/unlock the iPhone if it persists |
| `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 |
diff --git a/ios-qa/SKILL.md.tmpl b/ios-qa/SKILL.md.tmpl
index e93d2831a..78f9454b1 100644
--- a/ios-qa/SKILL.md.tmpl
+++ b/ios-qa/SKILL.md.tmpl
@@ -97,17 +97,33 @@ fi
## Phase 1: Read source, plan codegen
1. Walk the app source (passed as `--source `) and identify all `@Observable`
- classes. Note any property marked with the `@Snapshotable` wrapper — those
- are the snapshot-eligible fields.
-2. Run `swift run --package-path $GSTACK_HOME/ios-qa/scripts/gen-accessors-tool gen-accessors --input `.
- First invocation builds the swift-syntax dependency tree (cold: 2-5 min).
- Subsequent runs are content-hash-cached and finish in ~50ms.
-3. Show the user the accessor list and ask whether to install the DebugBridge
+ 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.
+2. 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. Add the `DebugBridge` SPM dependency to the app's `Package.swift`. The package
+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 "" \
+ --bridge-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.
+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
@@ -117,27 +133,35 @@ fi
The app target depends on `DebugBridgeUI` with `.when(configuration: .debug)`
(transitively pulls in Core + Touch). Release builds refuse to link these
targets.
-2. Wire the bridges from the `@main` App init, gated on `#if DEBUG`:
+3. Wire the bridges from the `@main` App init, gated on `#if DEBUG`:
```swift
#if DEBUG
import DebugBridgeCore
- StateServer.shared.start()
#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
```
-3. Build + deploy to the device with `xcodebuild -scheme
+4. Build + deploy to the device with `xcodebuild -scheme
-destination 'platform=iOS,id=' build install`.
-4. Launch via `devicectl device process launch --device --console `.
+5. Launch via `devicectl device process launch --device --console `.
Capture the boot token printed to `os_log` on first run.
-5. Spawn the Mac-side daemon (on-demand) — `gstack-ios-qa-daemon`. Daemon
+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.
-6. Daemon immediately calls `POST /auth/rotate` on the iOS StateServer with a
+7. Daemon immediately calls `POST /auth/rotate` on the iOS StateServer with a
fresh in-memory-only token. The boot token becomes useless ~5s later.
Anything scraping `os_log` past this point sees 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
@@ -145,7 +169,7 @@ Each iteration:
1. `GET /screenshot` (via daemon) → save PNG.
2. `GET /elements` → accessibility tree.
-3. `GET /state/snapshot` (only `@Snapshotable` fields) → current state.
+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/` write.
@@ -209,7 +233,7 @@ live.
| `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 ` on the Mac |
| `409 schema_mismatch` on `/state/restore` | snapshot from older app build | Discard the snapshot; re-capture |
-| `503 device_disconnected` from proxy | USB tunnel dropped | Reconnect device; daemon auto-reconnects within 30s |
+| `503 device_disconnected` from proxy | USB route dropped or app relaunched | Daemon invalidates the stale tunnel and retries one fresh bootstrap; reconnect/unlock the iPhone if it persists |
| `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 |
diff --git a/ios-qa/daemon/src/devicectl.ts b/ios-qa/daemon/src/devicectl.ts
index ee1696eb9..c17aec751 100644
--- a/ios-qa/daemon/src/devicectl.ts
+++ b/ios-qa/daemon/src/devicectl.ts
@@ -13,7 +13,10 @@ export interface DeviceEntry {
identifier: string;
name: string;
model: string;
+ platform: string; // "iOS" | "iPadOS" | "visionOS" | ...
+ deviceType: string; // "iPhone" | "iPad" | "realityDevice" | ...
state: string; // "connected" | "available" | "available (paired)" | ...
+ transport: string; // "wired" on USB devices; empty for stale/unavailable entries
paired: boolean;
}
@@ -83,7 +86,10 @@ export function listDevices(spawn: SpawnImpl = defaultSpawn): DeviceEntry[] {
identifier: String(d.identifier ?? ''),
name: String(props?.name ?? 'unknown'),
model: String(hw?.productType ?? 'unknown'),
+ platform: String(hw?.platform ?? ''),
+ deviceType: String(hw?.deviceType ?? ''),
state: String(conn?.tunnelState ?? 'unknown'),
+ transport: String(conn?.transportType ?? ''),
paired: pairingState === 'paired',
};
});
diff --git a/ios-qa/daemon/src/index.ts b/ios-qa/daemon/src/index.ts
index 82628b136..f3a70c89f 100644
--- a/ios-qa/daemon/src/index.ts
+++ b/ios-qa/daemon/src/index.ts
@@ -62,16 +62,36 @@ export async function startDaemon(opts: DaemonOptions): Promise | null = null;
const getTunnel = async (): Promise => {
- // Cache the tunnel for 30s; refresh on demand.
- if (tunnel && Date.now() - cachedTunnelAt < 30_000) return tunnel;
- if (opts.tunnelProvider) {
- tunnel = await opts.tunnelProvider();
- cachedTunnelAt = Date.now();
+ // A successful bootstrap consumes and deletes the app's one-shot boot
+ // token. Keep that rotated tunnel for this daemon's lifetime instead of
+ // trying to bootstrap it again on a timer. Failed attempts are not cached.
+ if (tunnel) return tunnel;
+ if (!opts.tunnelProvider) return null;
+
+ // Multiple first requests can arrive before bootstrap completes. Share
+ // one provider call so they cannot race through independent rotations.
+ if (!tunnelInFlight) {
+ tunnelInFlight = Promise.resolve()
+ .then(() => opts.tunnelProvider!())
+ .then((candidate) => {
+ if (candidate) tunnel = candidate;
+ return candidate;
+ })
+ .finally(() => {
+ tunnelInFlight = null;
+ });
}
- return tunnel;
+ return tunnelInFlight;
+ };
+
+ const invalidateTunnel = (failedTunnel: DeviceTunnel): void => {
+ // A late response from the old app must not evict a tunnel that another
+ // request has already refreshed. Object identity gives each bootstrap a
+ // cheap generation token without exposing generation state elsewhere.
+ if (tunnel === failedTunnel) tunnel = null;
};
// 2. Tailnet probe (fail-closed).
@@ -86,7 +106,7 @@ export async function startDaemon(opts: DaemonOptions): Promise {
- await handleLoopback({ req, res, tokenStore, getTunnel });
+ await handleLoopback({ req, res, tokenStore, getTunnel, invalidateTunnel });
});
// Use port 0 for OS-assigned port when test/random port collisions are a risk.
const requestedPort = opts.loopbackPort;
@@ -97,7 +117,7 @@ export async function startDaemon(opts: DaemonOptions): Promise {
- await handleLoopback({ req, res, tokenStore, getTunnel });
+ await handleLoopback({ req, res, tokenStore, getTunnel, invalidateTunnel });
});
let v6Bound = false;
try {
@@ -118,6 +138,7 @@ export async function startDaemon(opts: DaemonOptions): Promise Promise;
+ invalidateTunnel: (failedTunnel: DeviceTunnel) => void;
// Explicit security-log + allowlist paths (default to env-derived when undefined).
auditPath?: string;
attemptsPath?: string;
allowlistPath?: string;
}
+type DeviceProxyResponse = Awaited>;
+const RECOVERABLE_SOCKET_ERRORS = new Set([
+ 'ECONNABORTED',
+ 'ECONNREFUSED',
+ 'ECONNRESET',
+ 'EHOSTUNREACH',
+ 'ENETUNREACH',
+ 'EPIPE',
+ 'ETIMEDOUT',
+]);
+
+function localProxyError(status: number, error: string): DeviceProxyResponse {
+ const body = Buffer.from(JSON.stringify({ error }, sanitizeReplacer));
+ return {
+ status,
+ headers: { 'content-type': 'application/json', 'content-length': String(body.length) },
+ body,
+ };
+}
+
+async function proxyAttempt(opts: Parameters[0]): Promise {
+ try {
+ return await proxyToDevice(opts);
+ } catch (err) {
+ const code = (err as { code?: string }).code;
+ // CoreDevice can surface the same stale route as several different socket
+ // failures while an app is being relaunched or replaced. Normalize those
+ // failures so the cache recovery path below can handle all of them.
+ if (code && RECOVERABLE_SOCKET_ERRORS.has(code)) {
+ return localProxyError(503, 'device_disconnected');
+ }
+ throw err;
+ }
+}
+
+function shouldRefreshTunnel(upstream: DeviceProxyResponse): boolean {
+ // A relaunched app has a new in-memory bearer and rejects the daemon's old
+ // rotated token. A redeploy can instead leave the old CoreDevice route
+ // refusing connections or timing out. Both cases require a fresh bootstrap.
+ if (upstream.status === 401) return true;
+ if (upstream.status !== 503 && upstream.status !== 504) return false;
+ try {
+ const body = JSON.parse(upstream.body.toString('utf-8')) as { error?: string };
+ return body.error === 'device_disconnected' || body.error === 'upstream_timeout';
+ } catch {
+ return false;
+ }
+}
+
+function canReplayAfterRefresh(inbound: IncomingMessage, upstream: DeviceProxyResponse): boolean {
+ // A 401 proves the stale bearer was rejected before StateServer dispatched
+ // the operation, so retrying is safe even for a mutation. Connection loss
+ // is ambiguous: the app may have applied a tap/write before its response was
+ // lost. Replay only read-only requests in that case to prevent double taps.
+ if (upstream.status === 401) return true;
+ const method = inbound.method ?? 'GET';
+ return method === 'GET' || method === 'HEAD' || method === 'OPTIONS';
+}
+
+async function proxyWithTunnelRecovery(opts: {
+ inbound: IncomingMessage;
+ body: Buffer;
+ sessionId: string | null;
+ agentIdentity?: string;
+ getTunnel: HandlerCtx['getTunnel'];
+ invalidateTunnel: HandlerCtx['invalidateTunnel'];
+}): Promise<{ tunnel: DeviceTunnel; upstream: DeviceProxyResponse } | null> {
+ let tunnel = await opts.getTunnel();
+ if (!tunnel) return null;
+
+ const makeAttempt = (candidate: DeviceTunnel) => proxyAttempt({
+ inbound: opts.inbound,
+ body: opts.body,
+ tunnel: candidate,
+ sessionId: opts.sessionId,
+ agentIdentity: opts.agentIdentity,
+ });
+
+ let upstream = await makeAttempt(tunnel);
+ if (!shouldRefreshTunnel(upstream)) return { tunnel, upstream };
+
+ const failedTunnel = tunnel;
+ const replaySafe = canReplayAfterRefresh(opts.inbound, upstream);
+ opts.invalidateTunnel(tunnel);
+ const refreshed = await opts.getTunnel();
+ if (!refreshed) return replaySafe ? null : { tunnel: failedTunnel, upstream };
+
+ // The replacement is now cached for the next request, but never replay an
+ // ambiguous mutation whose response was lost: doing so could double-tap or
+ // apply a state transition twice.
+ if (!replaySafe) return { tunnel: failedTunnel, upstream };
+
+ tunnel = refreshed;
+ upstream = await makeAttempt(tunnel);
+ // Do not loop forever if the replacement app is itself unavailable. Leave
+ // the cache empty so the next independent request can bootstrap again.
+ if (shouldRefreshTunnel(upstream)) opts.invalidateTunnel(tunnel);
+ return { tunnel, upstream };
+}
+
function readBody(req: IncomingMessage, maxBytes = 1_048_576): Promise {
return new Promise((resolve, reject) => {
const chunks: Buffer[] = [];
@@ -228,7 +350,7 @@ function sendJson(res: ServerResponse, status: number, body: unknown): void {
* loopback bind itself is the boundary).
*/
async function handleLoopback(ctx: HandlerCtx): Promise {
- const { req, res, tokenStore, getTunnel } = ctx;
+ const { req, res, tokenStore, getTunnel, invalidateTunnel } = ctx;
const url = parseUrl(req.url ?? '/');
const path = url.pathname ?? '/';
const method = req.method ?? 'GET';
@@ -262,16 +384,23 @@ async function handleLoopback(ctx: HandlerCtx): Promise {
}
// Other endpoints — proxy to the device.
- const tunnel = await getTunnel();
- if (!tunnel) {
- sendJson(res, 503, { error: 'device_not_connected' });
- return;
- }
const body = await readBody(req);
if ('error' in body) { sendJson(res, 413, body); return; }
const sessionId = (req.headers['x-session-id'] as string | undefined) ?? null;
const agentIdentity = (req.headers['x-agent-identity'] as string | undefined) ?? undefined;
- const upstream = await proxyToDevice({ inbound: req, body, tunnel, sessionId, agentIdentity });
+ const proxied = await proxyWithTunnelRecovery({
+ inbound: req,
+ body,
+ sessionId,
+ agentIdentity,
+ getTunnel,
+ invalidateTunnel,
+ });
+ if (!proxied) {
+ sendJson(res, 503, { error: 'device_not_connected' });
+ return;
+ }
+ const { upstream } = proxied;
res.writeHead(upstream.status, upstream.headers);
res.end(upstream.body);
} catch (err) {
@@ -287,7 +416,7 @@ interface TailnetCtx extends HandlerCtx {
* Tailnet handler — locked allowlist + capability tiers.
*/
async function handleTailnet(ctx: TailnetCtx): Promise {
- const { req, res, tokenStore, getTunnel, whoIsImpl, auditPath, attemptsPath, allowlistPath } = ctx;
+ const { req, res, tokenStore, getTunnel, invalidateTunnel, whoIsImpl, auditPath, attemptsPath, allowlistPath } = ctx;
const url = parseUrl(req.url ?? '/');
const path = url.pathname ?? '/';
const method = req.method ?? 'GET';
@@ -375,19 +504,20 @@ async function handleTailnet(ctx: TailnetCtx): Promise {
}
// Proxy to device.
- const tunnel = await getTunnel();
- if (!tunnel) {
+ const sessionId = (req.headers['x-session-id'] as string | undefined) ?? null;
+ const proxied = await proxyWithTunnelRecovery({
+ inbound: req,
+ body,
+ sessionId,
+ agentIdentity: session.identity,
+ getTunnel,
+ invalidateTunnel,
+ });
+ if (!proxied) {
sendJson(res, 503, { error: 'device_not_connected' });
return;
}
- const sessionId = (req.headers['x-session-id'] as string | undefined) ?? null;
- const upstream = await proxyToDevice({
- inbound: req,
- body,
- tunnel,
- sessionId,
- agentIdentity: session.identity,
- });
+ const { tunnel, upstream } = proxied;
// Audit the action (mutating endpoints only).
if (requiredCapability !== 'observe') {
diff --git a/ios-qa/daemon/src/tunnel-bootstrap.ts b/ios-qa/daemon/src/tunnel-bootstrap.ts
index aa6636938..f1be1ca48 100644
--- a/ios-qa/daemon/src/tunnel-bootstrap.ts
+++ b/ios-qa/daemon/src/tunnel-bootstrap.ts
@@ -5,6 +5,8 @@
// 2. launch the app on it (no-op if already running)
// 3. wait briefly for the in-app StateServer to start
// 4. copy the boot token from the app's sandbox via devicectl copy from
+// If an earlier daemon already consumed it, relaunch the app once to mint
+// a fresh boot token, then verify the relaunched StateServer again.
// 5. POST /auth/rotate to swap boot token → fresh in-memory token
// 6. return a DeviceTunnel pointing at the device's IPv6 with the rotated
// bearer that subsequent proxied requests carry
@@ -14,6 +16,7 @@
// live token, which it scopes per-tailnet-session via /auth/mint.
import { randomBytes } from 'crypto';
+import { spawnSync } from 'child_process';
import type { DeviceTunnel } from './proxy';
import {
listDevices,
@@ -21,12 +24,13 @@ import {
isAppRunning,
launchApp,
copyFileFromAppContainer,
+ type DeviceEntry,
type SpawnImpl,
type ResolveImpl,
} from './devicectl';
export interface BootstrapOptions {
- /** Target device UDID. If null, picks the first connected paired device. */
+ /** Target iPhone UDID. If null, picks the best connected paired iPhone. */
udid?: string;
/** Bundle ID of the iOS app hosting the StateServer. */
bundleId: string;
@@ -53,10 +57,85 @@ export type BootstrapErrorReason =
| 'launch_failed'
| 'device_locked'
| 'state_server_unreachable'
+ | 'wrong_app'
| 'boot_token_unavailable'
| 'rotate_failed'
| 'resolve_failed';
+function isIPhoneDevice(device: DeviceEntry): boolean {
+ const platform = device.platform.trim().toLowerCase();
+ const deviceType = device.deviceType.trim().toLowerCase();
+ const model = device.model.trim().toLowerCase();
+
+ // productType is present even on older CoreDevice versions. Prefer the
+ // explicit platform/type fields when available, but retain productType as
+ // a compatibility fallback. An explicit non-iOS platform always loses.
+ if (platform && platform !== 'ios') return false;
+ return deviceType === 'iphone' || model.startsWith('iphone');
+}
+
+function isAvailableDevice(device: Pick): boolean {
+ const state = device.state.trim().toLowerCase();
+ const transport = device.transport.trim().toLowerCase();
+ // Xcode 26.6 / iOS 27 beta can report a USB-reachable iPhone as
+ // tunnelState=disconnected until the next devicectl command establishes
+ // the CoreDevice tunnel. The wired transport is the authoritative signal
+ // in that transitional state. Stale devices have no wired transport.
+ return state === 'connected'
+ || state.startsWith('available')
+ || (state === 'disconnected' && transport === 'wired');
+}
+
+function defaultDeviceRank(device: DeviceEntry): number {
+ if (!device.paired || !isIPhoneDevice(device) || !isAvailableDevice(device)) return -1;
+
+ const state = device.state.trim().toLowerCase();
+ const transport = device.transport.trim().toLowerCase();
+ // Prefer the USB-connected phone the user is actively working with. Then
+ // prefer an established CoreDevice tunnel over a merely available device.
+ return (transport === 'wired' ? 100 : 0)
+ + (state === 'connected' ? 10 : 0)
+ + (state.startsWith('available') ? 1 : 0);
+}
+
+function pickDefaultDevice(devices: DeviceEntry[]): DeviceEntry | undefined {
+ let best: DeviceEntry | undefined;
+ let bestRank = -1;
+ for (const device of devices) {
+ const rank = defaultDeviceRank(device);
+ if (rank > bestRank) {
+ best = device;
+ bestRank = rank;
+ }
+ }
+ return best;
+}
+
+const defaultSpawn: SpawnImpl = (cmd, args) => spawnSync(cmd, args, {
+ stdio: 'pipe',
+ timeout: 60_000,
+});
+
+function relaunchApp(
+ udid: string,
+ bundleId: string,
+ spawn: SpawnImpl = defaultSpawn,
+): { ok: true } | { ok: false; error: 'device_locked' | 'launch_failed'; detail?: string } {
+ const r = spawn('xcrun', [
+ 'devicectl', 'device', 'process', 'launch',
+ '--device', udid,
+ '--terminate-existing',
+ bundleId,
+ ]);
+ if (r.status === 0) return { ok: true };
+
+ const detail = `${r.stderr?.toString() ?? ''}${r.stdout?.toString() ?? ''}`.trim();
+ if (detail.includes('was not, or could not be, unlocked')) {
+ return { ok: false, error: 'device_locked', detail };
+ }
+ return { ok: false, error: 'launch_failed', detail };
+}
+
/**
* Bootstrap a real CoreDevice tunnel to an iOS app's StateServer. Used by
* the daemon's default tunnelProvider when GSTACK_IOS_TARGET_UDID is set
@@ -77,9 +156,39 @@ export async function bootstrapTunnel(opts: BootstrapOptions): Promise d.identifier === opts.udid)
- : devices.find((d) => d.paired) ?? devices[0];
+ : pickDefaultDevice(devices);
if (!target) {
- return { ok: false, error: 'device_not_found', detail: opts.udid };
+ if (opts.udid) {
+ return { ok: false, error: 'device_not_found', detail: opts.udid };
+ }
+ const pairedIPhone = devices.find((d) => d.paired && isIPhoneDevice(d));
+ if (pairedIPhone) {
+ return {
+ ok: false,
+ error: 'device_not_found',
+ detail: `paired iPhone ${pairedIPhone.name} (${pairedIPhone.identifier}) is ${pairedIPhone.state}; connect it over USB and unlock it`,
+ };
+ }
+ const firstIPhone = devices.find(isIPhoneDevice);
+ if (!firstIPhone) {
+ return {
+ ok: false,
+ error: 'device_not_found',
+ detail: 'no iPhone is connected; non-iOS devices are not eligible for iOS QA',
+ };
+ }
+ return {
+ ok: false,
+ error: 'no_paired_device',
+ detail: `device ${firstIPhone.name} (${firstIPhone.identifier}) is ${firstIPhone.state}; run \`xcrun devicectl manage pair --device ${firstIPhone.identifier}\` and tap Trust on the iPhone`,
+ };
+ }
+ if (!isIPhoneDevice(target)) {
+ return {
+ ok: false,
+ error: 'device_not_found',
+ detail: `device ${target.name} (${target.identifier}) is ${target.platform || target.model}, not an iPhone`,
+ };
}
if (!target.paired) {
return {
@@ -88,6 +197,13 @@ export async function bootstrapTunnel(opts: BootstrapOptions): Promise setTimeout(res, 250));
- }
- if (!healthOK) {
- return { ok: false, error: 'state_server_unreachable', detail: `no /healthz response from [${ipv6}]:${port} within ${startupTimeoutMs}ms` };
- }
+ const waitForStateServer = async (): Promise => {
+ const deadline = Date.now() + startupTimeoutMs;
+ while (Date.now() < deadline) {
+ try {
+ const r = await fetchFn(`http://[${ipv6}]:${port}/healthz`, {
+ signal: AbortSignal.timeout(2_000),
+ });
+ if (r.ok) {
+ const health = await r.json().catch(() => null) as { bundle_id?: string } | null;
+ // Older bridges did not identify their bundle. Preserve compatibility,
+ // but reject an explicit mismatch from current bridges: another debug
+ // app already owns the fixed StateServer port on this device.
+ if (health?.bundle_id && health.bundle_id !== opts.bundleId) {
+ return {
+ ok: false,
+ error: 'wrong_app',
+ detail: `expected ${opts.bundleId} but StateServer port ${port} belongs to ${health.bundle_id}; terminate the other debug app`,
+ };
+ }
+ return null;
+ }
+ } catch { /* retry */ }
+ await new Promise((res) => setTimeout(res, 250));
+ }
+ return {
+ ok: false,
+ error: 'state_server_unreachable',
+ detail: `no /healthz response from [${ipv6}]:${port} within ${startupTimeoutMs}ms`,
+ };
+ };
- const bootToken = copyFileFromAppContainer({
+ const healthFailure = await waitForStateServer();
+ if (healthFailure) return healthFailure;
+
+ const readBootToken = () => copyFileFromAppContainer({
udid: target.identifier,
bundleId: opts.bundleId,
sourceRelativePath: tokenPath,
spawn,
});
+
+ let bootToken = readBootToken();
if (!bootToken) {
- return { ok: false, error: 'boot_token_unavailable', detail: `couldn't read ${tokenPath} from ${opts.bundleId}` };
+ // A healthy running app can lack a boot token when an earlier daemon
+ // already rotated it. A new daemon has no way to recover that in-memory
+ // bearer, so restart exactly once to make StateServer mint a fresh one.
+ // The explicit bundle check above prevents disrupting an unrelated app
+ // that happens to own the fixed StateServer port.
+ const relaunched = relaunchApp(target.identifier, opts.bundleId, spawn);
+ if (!relaunched.ok) {
+ return { ok: false, error: relaunched.error, detail: relaunched.detail };
+ }
+
+ // The token is written before StateServer opens its listener. Waiting for
+ // it first prevents a stale response from the terminating process from
+ // being mistaken for readiness of the replacement process.
+ const tokenDeadline = Date.now() + startupTimeoutMs;
+ while (!bootToken && Date.now() < tokenDeadline) {
+ bootToken = readBootToken();
+ if (!bootToken) await new Promise((res) => setTimeout(res, 250));
+ }
+ if (!bootToken) {
+ return {
+ ok: false,
+ error: 'boot_token_unavailable',
+ detail: `couldn't read ${tokenPath} from ${opts.bundleId} after relaunch`,
+ };
+ }
+
+ const relaunchedHealthFailure = await waitForStateServer();
+ if (relaunchedHealthFailure) return relaunchedHealthFailure;
}
// Step 5: rotate the boot token to a fresh in-memory-only one.
diff --git a/ios-qa/daemon/test/daemon-integration.test.ts b/ios-qa/daemon/test/daemon-integration.test.ts
index b131cc3ff..b41144ecd 100644
--- a/ios-qa/daemon/test/daemon-integration.test.ts
+++ b/ios-qa/daemon/test/daemon-integration.test.ts
@@ -132,6 +132,258 @@ describe('daemon — loopback listener', () => {
expect(lastReq?.headers['x-session-id']).toBe('sess-loopback-1');
});
+ test('concurrent first requests share one tunnel bootstrap', async () => {
+ let bootstraps = 0;
+ let releaseBootstrap!: () => void;
+ let markBootstrapStarted!: () => void;
+ const bootstrapStarted = new Promise((resolve) => { markBootstrapStarted = resolve; });
+ const bootstrapGate = new Promise((resolve) => { releaseBootstrap = resolve; });
+ const tunnel: DeviceTunnel = {
+ udid: 'STUB-UDID',
+ ipv6Addr: '127.0.0.1',
+ port: stub.port,
+ bootTokenRotated: STATE_SERVER_TOKEN,
+ };
+ const d = await startDaemon({
+ loopbackPort: 0,
+ tailnetEnabled: false,
+ pidfilePath: join(workDir, 'daemon-concurrent-bootstrap.pid'),
+ tunnelProvider: async () => {
+ bootstraps++;
+ markBootstrapStarted();
+ await bootstrapGate;
+ return tunnel;
+ },
+ });
+ if ('error' in d) throw new Error(d.error);
+
+ try {
+ const base = `http://127.0.0.1:${d.loopbackPort}`;
+ const requests = [
+ fetchWith('GET', `${base}/screenshot`),
+ fetchWith('GET', `${base}/screenshot`),
+ fetchWith('GET', `${base}/screenshot`),
+ ];
+ await bootstrapStarted;
+ await new Promise((resolve) => setTimeout(resolve, 25));
+ releaseBootstrap();
+ const responses = await Promise.all(requests);
+ expect(responses.map((response) => response.status)).toEqual([200, 200, 200]);
+ expect(bootstraps).toBe(1);
+ } finally {
+ releaseBootstrap();
+ await d.close();
+ }
+ });
+
+ test('reuses a healthy rotated tunnel beyond the old 30-second boundary', async () => {
+ let bootstraps = 0;
+ const tunnel: DeviceTunnel = {
+ udid: 'STUB-UDID',
+ ipv6Addr: '127.0.0.1',
+ port: stub.port,
+ bootTokenRotated: STATE_SERVER_TOKEN,
+ };
+ const d = await startDaemon({
+ loopbackPort: 0,
+ tailnetEnabled: false,
+ pidfilePath: join(workDir, 'daemon-one-shot-bootstrap.pid'),
+ tunnelProvider: async () => {
+ bootstraps++;
+ if (bootstraps > 1) throw new Error('one-shot boot token was already consumed');
+ return tunnel;
+ },
+ });
+ if ('error' in d) throw new Error(d.error);
+
+ const realNow = Date.now;
+ const firstRequestAt = realNow();
+ try {
+ const base = `http://127.0.0.1:${d.loopbackPort}`;
+ const first = await fetchWith('GET', `${base}/screenshot`);
+ expect(first.status).toBe(200);
+
+ Date.now = () => firstRequestAt + 30_001;
+ const later = await fetchWith('GET', `${base}/screenshot`);
+ expect(later.status).toBe(200);
+ expect(bootstraps).toBe(1);
+ } finally {
+ Date.now = realNow;
+ await d.close();
+ }
+ });
+
+ test('401 after app relaunch invalidates the token and concurrent requests share one rebootstrap', async () => {
+ let bootstraps = 0;
+ let markRefreshStarted!: () => void;
+ let releaseRefresh!: () => void;
+ const refreshStarted = new Promise((resolve) => { markRefreshStarted = resolve; });
+ const refreshGate = new Promise((resolve) => { releaseRefresh = resolve; });
+ const staleTunnel: DeviceTunnel = {
+ udid: 'STUB-UDID',
+ ipv6Addr: '127.0.0.1',
+ port: stub.port,
+ bootTokenRotated: 'expired-after-relaunch',
+ };
+ const refreshedTunnel: DeviceTunnel = {
+ ...staleTunnel,
+ bootTokenRotated: STATE_SERVER_TOKEN,
+ };
+ const d = await startDaemon({
+ loopbackPort: 0,
+ tailnetEnabled: false,
+ pidfilePath: join(workDir, 'daemon-relaunch-refresh.pid'),
+ tunnelProvider: async () => {
+ bootstraps++;
+ if (bootstraps === 1) return staleTunnel;
+ if (bootstraps === 2) {
+ markRefreshStarted();
+ await refreshGate;
+ return refreshedTunnel;
+ }
+ throw new Error('concurrent 401s caused duplicate bootstraps');
+ },
+ });
+ if ('error' in d) throw new Error(d.error);
+
+ const requestStart = stub.receivedRequests.length;
+ try {
+ const base = `http://127.0.0.1:${d.loopbackPort}`;
+ const requests = [
+ fetchWith('GET', `${base}/screenshot`),
+ fetchWith('GET', `${base}/screenshot`),
+ fetchWith('GET', `${base}/screenshot`),
+ ];
+ await refreshStarted;
+ await new Promise((resolve) => setTimeout(resolve, 25));
+ expect(bootstraps).toBe(2);
+ releaseRefresh();
+
+ const responses = await Promise.all(requests);
+ expect(responses.map((response) => response.status)).toEqual([200, 200, 200]);
+ const attempts = stub.receivedRequests.slice(requestStart);
+ expect(attempts.filter((request) => request.headers.authorization === 'Bearer expired-after-relaunch')).toHaveLength(3);
+ expect(attempts.filter((request) => request.headers.authorization === `Bearer ${STATE_SERVER_TOKEN}`)).toHaveLength(3);
+
+ const healthyReuse = await fetchWith('GET', `${base}/screenshot`);
+ expect(healthyReuse.status).toBe(200);
+ expect(bootstraps).toBe(2);
+ } finally {
+ releaseRefresh();
+ await d.close();
+ }
+ });
+
+ test('connection failure after redeploy reboots the tunnel once and keeps the replacement cached', async () => {
+ const deadPort = await new Promise((resolve, reject) => {
+ const reservation = createServer();
+ reservation.once('error', reject);
+ reservation.listen(0, '127.0.0.1', () => {
+ const address = reservation.address();
+ const port = typeof address === 'object' && address ? address.port : 0;
+ reservation.close((err) => err ? reject(err) : resolve(port));
+ });
+ });
+ let bootstraps = 0;
+ const d = await startDaemon({
+ loopbackPort: 0,
+ tailnetEnabled: false,
+ pidfilePath: join(workDir, 'daemon-redeploy-refresh.pid'),
+ tunnelProvider: async () => {
+ bootstraps++;
+ if (bootstraps === 1) {
+ return {
+ udid: 'STUB-UDID',
+ ipv6Addr: '127.0.0.1',
+ port: deadPort,
+ bootTokenRotated: 'old-deploy-token',
+ };
+ }
+ if (bootstraps === 2) {
+ return {
+ udid: 'STUB-UDID',
+ ipv6Addr: '127.0.0.1',
+ port: stub.port,
+ bootTokenRotated: STATE_SERVER_TOKEN,
+ };
+ }
+ throw new Error('healthy replacement tunnel was not reused');
+ },
+ });
+ if ('error' in d) throw new Error(d.error);
+
+ try {
+ const base = `http://127.0.0.1:${d.loopbackPort}`;
+ const recovered = await fetchWith('GET', `${base}/screenshot`);
+ expect(recovered.status).toBe(200);
+ expect(JSON.parse(recovered.bodyText)).toEqual({ png_base64: 'abc=' });
+ expect(bootstraps).toBe(2);
+
+ const healthyReuse = await fetchWith('GET', `${base}/screenshot`);
+ expect(healthyReuse.status).toBe(200);
+ expect(bootstraps).toBe(2);
+ } finally {
+ await d.close();
+ }
+ });
+
+ test('connection failure refreshes but never replays an ambiguous tap', async () => {
+ const deadPort = await new Promise((resolve, reject) => {
+ const reservation = createServer();
+ reservation.once('error', reject);
+ reservation.listen(0, '127.0.0.1', () => {
+ const address = reservation.address();
+ const port = typeof address === 'object' && address ? address.port : 0;
+ reservation.close((err) => err ? reject(err) : resolve(port));
+ });
+ });
+ let bootstraps = 0;
+ const d = await startDaemon({
+ loopbackPort: 0,
+ tailnetEnabled: false,
+ pidfilePath: join(workDir, 'daemon-mutation-no-replay.pid'),
+ tunnelProvider: async () => {
+ bootstraps++;
+ return bootstraps === 1
+ ? {
+ udid: 'STUB-UDID',
+ ipv6Addr: '127.0.0.1',
+ port: deadPort,
+ bootTokenRotated: 'old-deploy-token',
+ }
+ : {
+ udid: 'STUB-UDID',
+ ipv6Addr: '127.0.0.1',
+ port: stub.port,
+ bootTokenRotated: STATE_SERVER_TOKEN,
+ };
+ },
+ });
+ if ('error' in d) throw new Error(d.error);
+
+ const beforeTaps = stub.receivedRequests.filter((request) => request.path === '/tap').length;
+ try {
+ const base = `http://127.0.0.1:${d.loopbackPort}`;
+ const ambiguous = await fetchWith('POST', `${base}/tap`, {
+ headers: { 'x-session-id': 'old-session', 'content-type': 'application/json' },
+ body: JSON.stringify({ x: 10, y: 20 }),
+ });
+ expect(ambiguous.status).toBe(503);
+ expect(bootstraps).toBe(2);
+ expect(stub.receivedRequests.filter((request) => request.path === '/tap')).toHaveLength(beforeTaps);
+
+ const explicitRetry = await fetchWith('POST', `${base}/tap`, {
+ headers: { 'x-session-id': 'new-session', 'content-type': 'application/json' },
+ body: JSON.stringify({ x: 10, y: 20 }),
+ });
+ expect(explicitRetry.status).toBe(200);
+ expect(stub.receivedRequests.filter((request) => request.path === '/tap')).toHaveLength(beforeTaps + 1);
+ expect(bootstraps).toBe(2);
+ } finally {
+ await d.close();
+ }
+ });
+
test('returns 503 when no device tunnel is provided', async () => {
// Force tunnel provider to return null by closing + restarting with null provider.
await daemon.close();
diff --git a/ios-qa/daemon/test/tunnel-bootstrap.test.ts b/ios-qa/daemon/test/tunnel-bootstrap.test.ts
index 188659b78..37af481be 100644
--- a/ios-qa/daemon/test/tunnel-bootstrap.test.ts
+++ b/ios-qa/daemon/test/tunnel-bootstrap.test.ts
@@ -105,6 +105,219 @@ describe('bootstrapTunnel', () => {
}
});
+ test('never auto-selects a connected paired Vision Pro ahead of an iPhone', async () => {
+ const spawn = makeSpawn([
+ {
+ argsMatch: /devicectl list devices/,
+ jsonOutput: {
+ result: { devices: [
+ {
+ identifier: 'VISION',
+ connectionProperties: { tunnelState: 'connected', pairingState: 'paired' },
+ deviceProperties: { name: 'Vision Pro' },
+ hardwareProperties: {
+ productType: 'RealityDevice17,1',
+ platform: 'visionOS',
+ deviceType: 'realityDevice',
+ },
+ },
+ {
+ identifier: 'IPHONE',
+ connectionProperties: { tunnelState: 'connected', pairingState: 'paired', transportType: 'wired' },
+ deviceProperties: { name: 'USB iPhone' },
+ hardwareProperties: {
+ productType: 'iPhone17,1',
+ platform: 'iOS',
+ deviceType: 'iPhone',
+ },
+ },
+ ] },
+ },
+ },
+ {
+ argsMatch: /devicectl device info processes -d IPHONE/,
+ jsonOutput: { result: { runningProcesses: [{ executable: 'file:///var/containers/Bundle/Application/X/com.test.app/com.test' }] } },
+ },
+ {
+ argsMatch: /devicectl device info details --device IPHONE/,
+ jsonOutput: { result: { connectionProperties: { tunnelIPAddress: 'fd00::17' } } },
+ },
+ {
+ argsMatch: /devicectl device copy from --device IPHONE/,
+ destOutput: 'TOKEN\n',
+ },
+ ]);
+
+ const r = await bootstrapTunnel({
+ bundleId: 'com.test',
+ spawnImpl: spawn,
+ fetchImpl: (async () => new Response('{"ok":true}', { status: 200 })) as typeof fetch,
+ });
+
+ expect(r.ok).toBe(true);
+ if (r.ok) expect(r.tunnel.udid).toBe('IPHONE');
+ });
+
+ test('fails clearly when only non-iPhone platforms are connected', async () => {
+ const spawn = makeSpawn([
+ {
+ argsMatch: /devicectl list devices/,
+ jsonOutput: {
+ result: { devices: [
+ {
+ identifier: 'VISION',
+ connectionProperties: { tunnelState: 'connected', pairingState: 'paired' },
+ deviceProperties: { name: 'Vision Pro' },
+ hardwareProperties: {
+ productType: 'RealityDevice17,1',
+ platform: 'visionOS',
+ deviceType: 'realityDevice',
+ },
+ },
+ {
+ identifier: 'IPAD',
+ connectionProperties: { tunnelState: 'connected', pairingState: 'paired' },
+ deviceProperties: { name: 'iPad' },
+ hardwareProperties: {
+ productType: 'iPad16,6',
+ platform: 'iOS',
+ deviceType: 'iPad',
+ },
+ },
+ ] },
+ },
+ },
+ ]);
+
+ const r = await bootstrapTunnel({ bundleId: 'com.test', spawnImpl: spawn });
+ expect(r.ok).toBe(false);
+ if (!r.ok) {
+ expect(r.error).toBe('device_not_found');
+ expect(r.detail).toContain('no iPhone');
+ }
+ });
+
+ test('rejects an explicitly targeted non-iPhone device', async () => {
+ const spawn = makeSpawn([
+ {
+ argsMatch: /devicectl list devices/,
+ jsonOutput: {
+ result: { devices: [{
+ identifier: 'VISION',
+ connectionProperties: { tunnelState: 'connected', pairingState: 'paired' },
+ deviceProperties: { name: 'Vision Pro' },
+ hardwareProperties: {
+ productType: 'RealityDevice17,1',
+ platform: 'visionOS',
+ deviceType: 'realityDevice',
+ },
+ }] },
+ },
+ },
+ ]);
+
+ const r = await bootstrapTunnel({ bundleId: 'com.test', udid: 'VISION', spawnImpl: spawn });
+ expect(r.ok).toBe(false);
+ if (!r.ok) {
+ expect(r.error).toBe('device_not_found');
+ expect(r.detail).toContain('not an iPhone');
+ }
+ });
+
+ test('skips an unavailable paired device for a connected or available paired device', async () => {
+ const spawn = makeSpawn([
+ {
+ argsMatch: /devicectl list devices/,
+ jsonOutput: {
+ result: { devices: [
+ {
+ identifier: 'STALE',
+ connectionProperties: { tunnelState: 'unavailable', pairingState: 'paired' },
+ deviceProperties: { name: 'Stale iPhone' },
+ hardwareProperties: { productType: 'iPhone17,1' },
+ },
+ {
+ identifier: 'WIRED',
+ connectionProperties: { tunnelState: 'available', pairingState: 'paired', transportType: 'wired' },
+ deviceProperties: { name: 'Wired iPhone' },
+ hardwareProperties: { productType: 'iPhone18,2' },
+ },
+ ] },
+ },
+ },
+ {
+ argsMatch: /devicectl device info processes -d WIRED/,
+ jsonOutput: { result: { runningProcesses: [{ executable: 'file:///var/containers/Bundle/Application/X/com.test.app/com.test' }] } },
+ },
+ {
+ argsMatch: /devicectl device info details --device WIRED/,
+ jsonOutput: { result: { connectionProperties: { tunnelIPAddress: 'fd00::2' } } },
+ },
+ {
+ argsMatch: /devicectl device copy from --device WIRED/,
+ destOutput: 'TOKEN\n',
+ },
+ ]);
+
+ const r = await bootstrapTunnel({
+ bundleId: 'com.test',
+ spawnImpl: spawn,
+ fetchImpl: (async () => new Response('{"ok":true}', { status: 200 })) as typeof fetch,
+ });
+
+ expect(r.ok).toBe(true);
+ if (r.ok) expect(r.tunnel.udid).toBe('WIRED');
+ });
+
+ test('accepts a wired iPhone whose tunnel is disconnected until devicectl wakes it', async () => {
+ const spawn = makeSpawn([
+ {
+ argsMatch: /devicectl list devices/,
+ jsonOutput: {
+ result: { devices: [
+ {
+ identifier: 'STALE-VISION',
+ connectionProperties: { tunnelState: 'unavailable', pairingState: 'paired' },
+ deviceProperties: { name: 'Stale Vision Pro' },
+ hardwareProperties: { productType: 'RealityDevice14,1' },
+ },
+ {
+ identifier: 'WIRED-DISCONNECTED',
+ connectionProperties: {
+ tunnelState: 'disconnected',
+ pairingState: 'paired',
+ transportType: 'wired',
+ },
+ deviceProperties: { name: 'USB iPhone' },
+ hardwareProperties: { productType: 'iPhone18,2' },
+ },
+ ] },
+ },
+ },
+ {
+ argsMatch: /devicectl device info processes -d WIRED-DISCONNECTED/,
+ jsonOutput: { result: { runningProcesses: [{ executable: 'file:\/\/\/var\/containers\/Bundle\/Application\/X\/com.test.app\/com.test' }] } },
+ },
+ {
+ argsMatch: /devicectl device info details --device WIRED-DISCONNECTED/,
+ jsonOutput: { result: { connectionProperties: { tunnelIPAddress: 'fd00::27' } } },
+ },
+ {
+ argsMatch: /devicectl device copy from --device WIRED-DISCONNECTED/,
+ destOutput: 'TOKEN\n',
+ },
+ ]);
+
+ const r = await bootstrapTunnel({
+ bundleId: 'com.test',
+ spawnImpl: spawn,
+ fetchImpl: (async () => new Response('{"ok":true}', { status: 200 })) as typeof fetch,
+ });
+
+ expect(r.ok).toBe(true);
+ if (r.ok) expect(r.tunnel.udid).toBe('WIRED-DISCONNECTED');
+ });
+
test('returns device_locked when launchApp errors due to lock', async () => {
const spawn = makeSpawn([
{
@@ -166,6 +379,48 @@ describe('bootstrapTunnel', () => {
if (!r.ok) expect(r.error).toBe('state_server_unreachable');
});
+ test('rejects a StateServer owned by a different app bundle', async () => {
+ const spawn = makeSpawn([
+ {
+ argsMatch: /devicectl list devices/,
+ jsonOutput: {
+ result: { devices: [{
+ identifier: 'TEST',
+ connectionProperties: { tunnelState: 'connected', pairingState: 'paired' },
+ deviceProperties: { name: 'Test' },
+ hardwareProperties: { productType: 'iPhone18,2' },
+ }] },
+ },
+ },
+ {
+ argsMatch: /devicectl device info processes/,
+ jsonOutput: { result: { runningProcesses: [] } },
+ },
+ {
+ argsMatch: /devicectl device process launch/,
+ },
+ {
+ argsMatch: /devicectl device info details/,
+ jsonOutput: { result: { connectionProperties: { tunnelIPAddress: 'fd00::9' } } },
+ },
+ ]);
+
+ const r = await bootstrapTunnel({
+ bundleId: 'com.expected.app',
+ spawnImpl: spawn,
+ fetchImpl: (async () => new Response(
+ JSON.stringify({ bundle_id: 'com.other.debug-app' }),
+ { status: 200, headers: { 'content-type': 'application/json' } },
+ )) as typeof fetch,
+ });
+
+ expect(r.ok).toBe(false);
+ if (!r.ok) {
+ expect(r.error).toBe('wrong_app');
+ expect(r.detail).toContain('com.other.debug-app');
+ }
+ });
+
test('happy path: returns DeviceTunnel with rotated token', async () => {
const spawn = makeSpawn([
{
@@ -234,6 +489,125 @@ describe('bootstrapTunnel', () => {
expect(fetchCalls[fetchCalls.length - 1]?.url).toContain('/auth/rotate');
});
+ test('relaunches once when a prior daemon consumed the running app boot token', async () => {
+ const spawn = makeSpawn([
+ {
+ argsMatch: /devicectl list devices/,
+ jsonOutput: {
+ result: { devices: [{
+ identifier: 'TEST-UDID',
+ connectionProperties: { tunnelState: 'connected', pairingState: 'paired' },
+ deviceProperties: { name: 'Test Device' },
+ hardwareProperties: { productType: 'iPhone18,2' },
+ }] },
+ },
+ },
+ {
+ argsMatch: /devicectl device info processes/,
+ jsonOutput: { result: { runningProcesses: [{ executable: 'file:\/\/\/var\/containers\/Bundle\/Application\/X\/com.test.app\/com.test' }] } },
+ },
+ {
+ argsMatch: /devicectl device info details/,
+ jsonOutput: { result: { connectionProperties: { tunnelIPAddress: 'fd99::beef' } } },
+ },
+ {
+ // A prior daemon rotated the one-use token, so StateServer deleted it.
+ argsMatch: /devicectl device copy from/,
+ exitCode: 1,
+ stderr: 'source does not exist',
+ },
+ {
+ argsMatch: /devicectl device process launch --device TEST-UDID --terminate-existing com\.test/,
+ },
+ {
+ argsMatch: /devicectl device copy from/,
+ destOutput: 'FRESH-BOOT-TOKEN\n',
+ },
+ ]);
+ const fetchCalls: Array<{ url: string; authorization?: string }> = [];
+
+ const r = await bootstrapTunnel({
+ bundleId: 'com.test',
+ spawnImpl: spawn,
+ fetchImpl: (async (url, init) => {
+ const u = String(url);
+ const headers = init?.headers as Record | undefined;
+ fetchCalls.push({ url: u, authorization: headers?.Authorization });
+ if (u.endsWith('/healthz')) {
+ return new Response(JSON.stringify({ bundle_id: 'com.test' }), {
+ status: 200,
+ headers: { 'content-type': 'application/json' },
+ });
+ }
+ if (u.endsWith('/auth/rotate') && headers?.Authorization === 'Bearer FRESH-BOOT-TOKEN') {
+ return new Response('{"ok":true}', { status: 200 });
+ }
+ return new Response('unauthorized', { status: 401 });
+ }) as typeof fetch,
+ startupTimeoutMs: 1_000,
+ });
+
+ expect(r.ok).toBe(true);
+ expect(fetchCalls.filter((call) => call.url.endsWith('/healthz'))).toHaveLength(2);
+ expect(fetchCalls.at(-1)?.authorization).toBe('Bearer FRESH-BOOT-TOKEN');
+ });
+
+ test('rechecks bundle ownership after recovering a consumed boot token', async () => {
+ const spawn = makeSpawn([
+ {
+ argsMatch: /devicectl list devices/,
+ jsonOutput: {
+ result: { devices: [{
+ identifier: 'TEST-UDID',
+ connectionProperties: { tunnelState: 'connected', pairingState: 'paired' },
+ deviceProperties: { name: 'Test Device' },
+ hardwareProperties: { productType: 'iPhone18,2' },
+ }] },
+ },
+ },
+ {
+ argsMatch: /devicectl device info processes/,
+ jsonOutput: { result: { runningProcesses: [{ executable: 'file:\/\/\/var\/containers\/Bundle\/Application\/X\/com.test.app\/com.test' }] } },
+ },
+ {
+ argsMatch: /devicectl device info details/,
+ jsonOutput: { result: { connectionProperties: { tunnelIPAddress: 'fd99::beef' } } },
+ },
+ { argsMatch: /devicectl device copy from/, exitCode: 1 },
+ {
+ argsMatch: /devicectl device process launch --device TEST-UDID --terminate-existing com\.test/,
+ },
+ { argsMatch: /devicectl device copy from/, destOutput: 'FRESH-BOOT-TOKEN\n' },
+ ]);
+ let healthChecks = 0;
+ let rotateCalls = 0;
+
+ const r = await bootstrapTunnel({
+ bundleId: 'com.test',
+ spawnImpl: spawn,
+ fetchImpl: (async (url) => {
+ const u = String(url);
+ if (u.endsWith('/healthz')) {
+ healthChecks++;
+ return new Response(JSON.stringify({
+ bundle_id: healthChecks === 1 ? 'com.test' : 'com.other.debug-app',
+ }), {
+ status: 200,
+ headers: { 'content-type': 'application/json' },
+ });
+ }
+ rotateCalls++;
+ return new Response('{"ok":true}', { status: 200 });
+ }) as typeof fetch,
+ startupTimeoutMs: 1_000,
+ });
+
+ expect(r.ok).toBe(false);
+ if (!r.ok) expect(r.error).toBe('wrong_app');
+ expect(healthChecks).toBe(2);
+ expect(rotateCalls).toBe(0);
+ });
+
test('resolve_failed when hostname cant be resolved to an IPv6', async () => {
const spawn = makeSpawn([
{
diff --git a/ios-qa/scripts/gen-accessors-tool/Package.swift b/ios-qa/scripts/gen-accessors-tool/Package.swift
index 653b68f36..477aa61a0 100644
--- a/ios-qa/scripts/gen-accessors-tool/Package.swift
+++ b/ios-qa/scripts/gen-accessors-tool/Package.swift
@@ -1,8 +1,8 @@
// swift-tools-version:5.9
//
// gen-accessors-tool — SwiftPM tool that reads an app's Swift source via
-// swift-syntax, finds @Observable classes with @Snapshotable-marked fields,
-// and emits StateAccessor.swift for each one.
+// swift-syntax, finds @Observable classes with `// @Snapshotable`-marked
+// fields, and emits StateAccessor.swift for each one.
//
// First build is 2-5 min on a cold machine (swift-syntax compile chain).
// Subsequent runs are content-hash-cached and finish in ~50ms.
@@ -31,10 +31,5 @@ let package = Package(
],
path: "Sources/GenAccessors"
),
- .testTarget(
- name: "GenAccessorsTests",
- dependencies: ["GenAccessors"],
- path: "Tests/GenAccessorsTests"
- ),
]
)
diff --git a/ios-qa/scripts/gen-accessors-tool/Sources/GenAccessors/main.swift b/ios-qa/scripts/gen-accessors-tool/Sources/GenAccessors/main.swift
index 8287350cb..c47e3cae3 100644
--- a/ios-qa/scripts/gen-accessors-tool/Sources/GenAccessors/main.swift
+++ b/ios-qa/scripts/gen-accessors-tool/Sources/GenAccessors/main.swift
@@ -1,6 +1,7 @@
// gen-accessors entry point. Walks the input dir for *.swift files, parses
-// each via SwiftParser, finds @Observable classes with @Snapshotable-marked
-// properties, and emits StateAccessor.swift for each.
+// each via SwiftParser, finds @Observable classes with `// @Snapshotable`
+// source-marker comments (plus the legacy attribute form), and emits
+// StateAccessor.swift for each.
//
// Output goes to --output (default: same dir as input). Cache key is
// computed from a composite hash and stored at
@@ -15,6 +16,8 @@ struct AccessorSpec {
let fields: [(name: String, typeText: String)]
}
+private let generatorFormatVersion = "accessor-generator-v5"
+
@main
struct GenAccessors {
static func main() async {
@@ -32,38 +35,83 @@ struct GenAccessors {
}()
// Walk + collect *.swift files
- guard let swiftFiles = collectSwiftFiles(at: inputDir) else {
+ guard let swiftFiles = collectSwiftFiles(at: inputDir, excluding: outputDir) else {
FileHandle.standardError.write(Data("input dir not found: \(inputDir)\n".utf8))
exit(3)
}
- // Composite cache key — codex catch (source content alone misses
- // generator-logic changes).
- let cacheKey = computeCacheKey(swiftFiles: swiftFiles)
- let cacheDir = ("~/.gstack/cache/gen-accessors" as NSString).expandingTildeInPath
+ // Parse + validate before consulting the cache. Invalid marked fields
+ // must fail deterministically rather than being hidden by an older
+ // cached output.
+ var specs: [AccessorSpec] = []
+ var diagnostics: [String] = []
+ for path in swiftFiles {
+ guard let source = try? String(contentsOfFile: path, encoding: .utf8) else { continue }
+ let tree = Parser.parse(source: source)
+ let visitor = ObservableClassVisitor(sourcePath: path, viewMode: .sourceAccurate)
+ visitor.walk(tree)
+ specs.append(contentsOf: visitor.specs)
+ diagnostics.append(contentsOf: visitor.diagnostics)
+ }
+ var snapshotKeyOwners: [String: String] = [:]
+ for spec in specs {
+ for field in spec.fields {
+ if let previous = snapshotKeyOwners[field.name] {
+ diagnostics.append(
+ "snapshot key '\(field.name)' is declared by both \(previous) and \(spec.className); "
+ + "keys must be unique across @Observable types"
+ )
+ } else {
+ snapshotKeyOwners[field.name] = spec.className
+ }
+ }
+ }
+ if !diagnostics.isEmpty {
+ let message = "gen-accessors: invalid @Snapshotable declaration(s):\n"
+ + diagnostics.map { " - \($0)" }.joined(separator: "\n") + "\n"
+ FileHandle.standardError.write(Data(message.utf8))
+ exit(4)
+ }
+
+ let buildId = detectedBuildId()
+ let accessorHash = computeAccessorHash(specs: specs)
+ // Cache identity includes build provenance and generator ABI. The
+ // separately computed accessorHash is schema-only and remains stable
+ // across checkout paths, unrelated sources, and app rebuilds.
+ let cacheKey = computeCacheKey(swiftFiles: swiftFiles, buildId: buildId)
+ let cacheDir = getEnv("GSTACK_IOS_CACHE_ROOT")
+ ?? ("~/.gstack/cache/gen-accessors" as NSString).expandingTildeInPath
let cachedOutput = "\(cacheDir)/\(cacheKey)/StateAccessor.swift"
+ do {
+ try FileManager.default.createDirectory(atPath: outputDir, withIntermediateDirectories: true)
+ } catch {
+ FileHandle.standardError.write(Data("gen-accessors: cannot create output directory: \(error)\n".utf8))
+ exit(5)
+ }
if FileManager.default.fileExists(atPath: cachedOutput) {
// Cache hit. Copy to output dir.
- try? FileManager.default.removeItem(atPath: "\(outputDir)/StateAccessor.swift")
- try? FileManager.default.copyItem(atPath: cachedOutput, toPath: "\(outputDir)/StateAccessor.swift")
+ let finalOutput = "\(outputDir)/StateAccessor.swift"
+ do {
+ if FileManager.default.fileExists(atPath: finalOutput) {
+ try FileManager.default.removeItem(atPath: finalOutput)
+ }
+ try FileManager.default.copyItem(atPath: cachedOutput, toPath: finalOutput)
+ } catch {
+ FileHandle.standardError.write(Data("gen-accessors: cannot restore cached output: \(error)\n".utf8))
+ exit(5)
+ }
print("gen-accessors: cache hit (\(cacheKey))")
return
}
- // Parse + extract specs
- var specs: [AccessorSpec] = []
- for path in swiftFiles {
- guard let source = try? String(contentsOfFile: path, encoding: .utf8) else { continue }
- let tree = Parser.parse(source: source)
- let visitor = ObservableClassVisitor(viewMode: .sourceAccurate)
- visitor.walk(tree)
- specs.append(contentsOf: visitor.specs)
- }
-
// Emit
- let output = render(specs: specs, buildId: getEnv("APP_BUILD_ID") ?? "unknown", accessorHash: cacheKey)
- try? FileManager.default.createDirectory(atPath: outputDir, withIntermediateDirectories: true)
- try? output.write(toFile: "\(outputDir)/StateAccessor.swift", atomically: true, encoding: .utf8)
+ let output = render(specs: specs, buildId: buildId, accessorHash: accessorHash)
+ do {
+ try output.write(toFile: "\(outputDir)/StateAccessor.swift", atomically: true, encoding: .utf8)
+ } catch {
+ FileHandle.standardError.write(Data("gen-accessors: cannot write output: \(error)\n".utf8))
+ exit(5)
+ }
// Populate cache
try? FileManager.default.createDirectory(atPath: "\(cacheDir)/\(cacheKey)", withIntermediateDirectories: true)
@@ -72,46 +120,154 @@ struct GenAccessors {
print("gen-accessors: wrote \(specs.count) accessor(s) to \(outputDir)/StateAccessor.swift")
}
- static func collectSwiftFiles(at path: String) -> [String]? {
- guard let enumerator = FileManager.default.enumerator(atPath: path) else { return nil }
+ static func collectSwiftFiles(at path: String, excluding outputPath: String) -> [String]? {
+ let inputURL = URL(fileURLWithPath: path).standardizedFileURL
+ let outputURL = URL(fileURLWithPath: outputPath).standardizedFileURL
+ var isDirectory: ObjCBool = false
+ guard FileManager.default.fileExists(atPath: inputURL.path, isDirectory: &isDirectory),
+ isDirectory.boolValue,
+ let enumerator = FileManager.default.enumerator(
+ at: inputURL,
+ includingPropertiesForKeys: [.isDirectoryKey],
+ options: []
+ ) else { return nil }
+
+ // When --output is the input root, scan the root but still exclude
+ // StateAccessor.swift. For a nested output, skip the entire subtree.
+ let shouldExcludeOutputSubtree = outputURL.path != inputURL.path
var files: [String] = []
- for case let f as String in enumerator {
- if f.hasSuffix(".swift") { files.append("\(path)/\(f)") }
+ for case let fileURL as URL in enumerator {
+ let normalized = fileURL.standardizedFileURL
+ let values = try? normalized.resourceValues(forKeys: [.isDirectoryKey])
+ if values?.isDirectory == true {
+ if normalized.lastPathComponent == "DebugBridgeGenerated"
+ || (shouldExcludeOutputSubtree && normalized.path == outputURL.path) {
+ enumerator.skipDescendants()
+ }
+ continue
+ }
+ guard normalized.pathExtension == "swift",
+ normalized.lastPathComponent != "StateAccessor.swift" else { continue }
+ files.append(normalized.path)
}
return files.sorted()
}
- static func computeCacheKey(swiftFiles: [String]) -> String {
+ static func computeCacheKey(swiftFiles: [String], buildId: String) -> String {
// Codex-flagged: hash must include Swift version, tool git rev, platform.
let swiftVer = getEnv("SWIFT_VERSION") ?? "unknown"
- let toolRev = getEnv("GEN_ACCESSORS_REV") ?? "dev"
- let platform = "darwin-arm64" // simplified for the test harness
- var combined = "swift=\(swiftVer)|tool=\(toolRev)|platform=\(platform)|"
+ let toolRev = getEnv("GEN_ACCESSORS_REV") ?? generatorFormatVersion
+ #if arch(arm64)
+ let platform = "darwin-arm64"
+ #elseif arch(x86_64)
+ let platform = "darwin-x86_64"
+ #else
+ let platform = "darwin-unknown"
+ #endif
+ var combined = Data("\(generatorFormatVersion)|swift=\(swiftVer)|tool=\(toolRev)|platform=\(platform)|build=\(buildId)|".utf8)
for path in swiftFiles {
if let data = try? Data(contentsOf: URL(fileURLWithPath: path)) {
- combined += "\(path):\(data.count):\(data.sha256())|"
+ // Do not include absolute checkout paths in cache identity.
+ combined.append(Data("\(data.count):".utf8))
+ combined.append(data)
+ combined.append(Data("|".utf8))
}
}
- return combined.data(using: .utf8)!.sha256()
+ return combined.sha256()
+ }
+
+ static func computeAccessorHash(specs: [AccessorSpec]) -> String {
+ var signature = "snapshot-schema-v1\n"
+ for spec in specs {
+ signature += "C\(spec.className.utf8.count):\(spec.className)\n"
+ for field in spec.fields {
+ signature += "F\(field.name.utf8.count):\(field.name)"
+ signature += "T\(field.typeText.utf8.count):\(field.typeText)\n"
+ }
+ signature += "E\n"
+ }
+ return signature.sha256()
}
static func render(specs: [AccessorSpec], buildId: String, accessorHash: String) -> String {
var out = "// AUTO-GENERATED — DO NOT EDIT. Regenerate with /ios-sync.\n"
- out += "#if DEBUG\nimport Foundation\nimport DebugBridge\n\n"
+ out += "#if DEBUG\nimport Foundation\nimport DebugBridgeCore\n\n"
+ if !specs.isEmpty {
+ // JSONSerialization produces Foundation bridge objects. Direct
+ // `as?` casts let NSNumber cross-cast between Bool and numeric
+ // Swift types. Round-tripping through JSONDecoder enforces the
+ // declared Codable shape (including every collection element)
+ // and preserves a successful Optional nil as a double Optional.
+ out += "private enum _GStackDebugBridgeSnapshotJSON {\n"
+ out += " private struct Box: Decodable {\n"
+ out += " let value: Value\n"
+ out += " }\n\n"
+ out += " static func decode(_ value: Any, as _: Value.Type) -> Value? {\n"
+ out += " let object: [String: Any] = [\"value\": value]\n"
+ out += " guard JSONSerialization.isValidJSONObject(object),\n"
+ out += " let data = try? JSONSerialization.data(withJSONObject: object) else {\n"
+ out += " return nil\n"
+ out += " }\n"
+ out += " do {\n"
+ out += " return try JSONDecoder().decode(Box.self, from: data).value\n"
+ out += " } catch {\n"
+ out += " return nil\n"
+ out += " }\n"
+ out += " }\n"
+ out += "}\n\n"
+ }
for spec in specs {
- out += "@MainActor\npublic enum \(spec.className)Accessor {\n"
- out += " public static func register(_ state: \(spec.className)) {\n"
+ // Accessors live in the app target beside its usually-internal
+ // state types. A public signature cannot expose an internal type.
+ out += "@MainActor\nenum \(spec.className)Accessor {\n"
+ out += " static func register(_ state: \(spec.className)) {\n"
out += " StateServer.shared.register(\n"
- out += " buildId: \"\(buildId)\",\n"
- out += " accessorHash: \"\(accessorHash)\",\n"
- out += " atomicRestore: { _ in .ok }\n"
+ out += " buildId: {\n"
+ out += " let shortVersion = Bundle.main.object(forInfoDictionaryKey: \"CFBundleShortVersionString\") as? String\n"
+ out += " let bundleVersion = Bundle.main.object(forInfoDictionaryKey: \"CFBundleVersion\") as? String\n"
+ out += " if let shortVersion, let bundleVersion { return \"\\(shortVersion) (\\(bundleVersion))\" }\n"
+ out += " return shortVersion ?? bundleVersion ?? \(String(reflecting: buildId))\n"
+ out += " }(),\n"
+ out += " accessorHash: \(String(reflecting: accessorHash)),\n"
+ out += " atomicRestore: { keys, apply in\n"
+ out += " // Validate every key and value before assignment.\n"
+ out += " // StateServer invokes every model once with apply=false,\n"
+ out += " // then applies every validated model with apply=true.\n"
+ for (index, field) in spec.fields.enumerated() {
+ let (name, typeText) = field
+ out += " guard let raw\(index) = keys[\"\(name)\"] else {\n"
+ out += " return .missingKey(\"\(name)\")\n"
+ out += " }\n"
+ out += " guard let restored\(index): \(typeText) = _GStackDebugBridgeSnapshotJSON.decode(raw\(index), as: \(typeText).self) else {\n"
+ out += " return .typeMismatch(\"\(name)\")\n"
+ out += " }\n"
+ }
+ out += " if apply {\n"
+ for (index, field) in spec.fields.enumerated() {
+ out += " state.\(field.name) = restored\(index)\n"
+ }
+ out += " }\n"
+ out += " return .ok\n"
+ out += " }\n"
out += " )\n"
- for (name, _) in spec.fields {
+ for (name, typeText) in spec.fields {
+ let wrapped = optionalWrappedType(typeText)
out += " StateServer.shared.registerAccessor(\n"
out += " key: \"\(name)\",\n"
- out += " type: \"Any\",\n"
- out += " read: { state.\(name) as Any? },\n"
- out += " write: { _ in false }\n"
+ out += " type: \"\(typeText)\",\n"
+ if wrapped != nil {
+ out += " read: {\n"
+ out += " guard let value = state.\(name) else { return NSNull() }\n"
+ out += " return value as Any\n"
+ out += " },\n"
+ } else {
+ out += " read: { state.\(name) as Any? },\n"
+ }
+ out += " write: { value in\n"
+ out += " guard let typed: \(typeText) = _GStackDebugBridgeSnapshotJSON.decode(value, as: \(typeText).self) else { return false }\n"
+ out += " state.\(name) = typed\n"
+ out += " return true\n"
+ out += " }\n"
out += " )\n"
}
out += " }\n}\n\n"
@@ -123,6 +279,59 @@ struct GenAccessors {
final class ObservableClassVisitor: SyntaxVisitor {
var specs: [AccessorSpec] = []
+ var diagnostics: [String] = []
+ private let sourcePath: String
+
+ init(sourcePath: String, viewMode: SyntaxTreeViewMode) {
+ self.sourcePath = sourcePath
+ super.init(viewMode: viewMode)
+ }
+
+ private func hasSnapshotableMarker(_ declaration: VariableDeclSyntax) -> Bool {
+ // Preserve compatibility with source that defines a custom attribute
+ // or wrapper, even though that form conflicts with @Observable in
+ // ordinary app models.
+ if declaration.attributes.contains(where: { attribute in
+ guard let attribute = attribute.as(AttributeSyntax.self) else { return false }
+ return attribute.attributeName.trimmedDescription == "Snapshotable"
+ }) {
+ return true
+ }
+
+ // Match a standalone ordinary line-comment trivia piece exactly.
+ // Avoid declaration.description/contains: both would accept prose
+ // such as `// do not expose through @Snapshotable`.
+ return declaration.leadingTrivia.contains { piece in
+ guard case .lineComment(let text) = piece else { return false }
+ return text.dropFirst(2).trimmingCharacters(in: .whitespaces) == "@Snapshotable"
+ }
+ }
+
+ private func enclosingScopeDescription(for node: ClassDeclSyntax) -> String? {
+ var ancestor = Syntax(node).parent
+ while let current = ancestor {
+ if let declaration = current.as(ClassDeclSyntax.self) {
+ return "class \(declaration.name.text)"
+ }
+ if let declaration = current.as(StructDeclSyntax.self) {
+ return "struct \(declaration.name.text)"
+ }
+ if let declaration = current.as(EnumDeclSyntax.self) {
+ return "enum \(declaration.name.text)"
+ }
+ if let declaration = current.as(ActorDeclSyntax.self) {
+ return "actor \(declaration.name.text)"
+ }
+ if let declaration = current.as(ExtensionDeclSyntax.self) {
+ return "extension \(declaration.extendedType.trimmedDescription)"
+ }
+ if current.is(CodeBlockSyntax.self) {
+ return "a local scope"
+ }
+ ancestor = current.parent
+ }
+ return nil
+ }
override func visit(_ node: ClassDeclSyntax) -> SyntaxVisitorContinueKind {
// Look for @Observable attribute
@@ -133,23 +342,88 @@ final class ObservableClassVisitor: SyntaxVisitor {
guard isObservable else { return .visitChildren }
let className = node.name.text
+ let markedMembers = node.memberBlock.members.compactMap {
+ $0.decl.as(VariableDeclSyntax.self)
+ }.filter(hasSnapshotableMarker)
+ if !markedMembers.isEmpty, let enclosingScope = enclosingScopeDescription(for: node) {
+ diagnostics.append(
+ "\((sourcePath as NSString).lastPathComponent): nested @Observable class "
+ + "\(className) inside \(enclosingScope) is unsupported; "
+ + "move snapshot-enabled models to file scope"
+ )
+ // Continue walking so a more deeply nested marked model is also
+ // diagnosed rather than silently omitted.
+ return .visitChildren
+ }
var fields: [(String, String)] = []
for member in node.memberBlock.members {
guard let varDecl = member.decl.as(VariableDeclSyntax.self) else { continue }
- // Field must be marked @Snapshotable to be included
- let isSnapshotable = varDecl.attributes.contains(where: { attr in
- guard let attr = attr.as(AttributeSyntax.self) else { return false }
- return attr.attributeName.trimmedDescription == "Snapshotable"
- })
- guard isSnapshotable else { continue }
+ // Field must opt in with the source marker (or legacy attribute).
+ guard hasSnapshotableMarker(varDecl) else { continue }
+
+ let bindingName = varDecl.bindings.first?
+ .pattern.as(IdentifierPatternSyntax.self)?.identifier.text ?? ""
+ let context = "\((sourcePath as NSString).lastPathComponent): \(className).\(bindingName)"
+ if varDecl.bindingSpecifier.text == "let" {
+ diagnostics.append("\(context) must be declared var, not let")
+ continue
+ }
+ let modifierNames = varDecl.modifiers.map { modifier -> String in
+ let detail = modifier.detail?.trimmedDescription ?? ""
+ return modifier.name.text + detail
+ }
+ if modifierNames.contains(where: {
+ $0 == "private" || $0 == "fileprivate"
+ || $0 == "private(set)" || $0 == "fileprivate(set)"
+ }) {
+ diagnostics.append("\(context) cannot be private, fileprivate, private(set), or fileprivate(set)")
+ continue
+ }
+ if modifierNames.contains("static") || modifierNames.contains("class") {
+ diagnostics.append("\(context) must be an instance property")
+ continue
+ }
+ if varDecl.bindings.count != 1 {
+ diagnostics.append("\(context) declaration must contain exactly one binding")
+ continue
+ }
for binding in varDecl.bindings {
- if let pattern = binding.pattern.as(IdentifierPatternSyntax.self) {
- let name = pattern.identifier.text
- let typeText = binding.typeAnnotation?.type.trimmedDescription ?? "Any"
- fields.append((name, typeText))
+ guard let pattern = binding.pattern.as(IdentifierPatternSyntax.self) else {
+ diagnostics.append("\(context) only supports an identifier binding")
+ continue
}
+ // An initializer plus an accessor block is a stored property
+ // with observers. Without an initializer, conservatively
+ // reject the block rather than emitting a setter for a
+ // computed/read-only declaration.
+ if binding.accessorBlock != nil && binding.initializer == nil {
+ diagnostics.append("\(context) must be stored and writable")
+ continue
+ }
+ guard let annotation = binding.typeAnnotation else {
+ diagnostics.append("\(context) requires an explicit type annotation")
+ continue
+ }
+ let name = pattern.identifier.text
+ let typeText = annotation.type.trimmedDescription
+ .split(whereSeparator: { $0.isWhitespace })
+ .joined(separator: " ")
+ switch parseJSONSnapshotType(typeText) {
+ case .valid:
+ break
+ case .implicitlyUnwrappedOptional:
+ diagnostics.append("\(context) cannot use an implicitly unwrapped Optional type")
+ continue
+ case .nestedOptional:
+ diagnostics.append("\(context) cannot use a nested Optional type")
+ continue
+ case .unsupported:
+ diagnostics.append("\(context) uses unsupported non-JSON snapshot type '\(typeText)'")
+ continue
+ }
+ fields.append((name, typeText))
}
}
@@ -160,10 +434,233 @@ final class ObservableClassVisitor: SyntaxVisitor {
}
}
+private indirect enum JSONSnapshotType {
+ case scalar
+ case optional(JSONSnapshotType)
+ case array(JSONSnapshotType)
+ case dictionary(JSONSnapshotType)
+
+ var isOptional: Bool {
+ if case .optional = self { return true }
+ return false
+ }
+}
+
+private enum JSONSnapshotTypeParseResult {
+ case valid(JSONSnapshotType)
+ case implicitlyUnwrappedOptional
+ case nestedOptional
+ case unsupported
+}
+
+/// Parse the deliberately small set of Swift types that can make a lossless
+/// trip through JSONSerialization and JSONDecoder without application-defined
+/// encoding behavior. Type aliases and arbitrary Codable models are rejected:
+/// the bridge has no compiler/type-checker context in which to prove that they
+/// are JSON-native.
+private func parseJSONSnapshotType(_ source: String) -> JSONSnapshotTypeParseResult {
+ let type = source.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !type.isEmpty else { return .unsupported }
+
+ if type.hasSuffix("!") {
+ return .implicitlyUnwrappedOptional
+ }
+
+ if type.hasSuffix("?") {
+ let wrappedText = String(type.dropLast()).trimmingCharacters(in: .whitespacesAndNewlines)
+ switch parseJSONSnapshotType(wrappedText) {
+ case .valid(let wrapped):
+ return wrapped.isOptional ? .nestedOptional : .valid(.optional(wrapped))
+ case let issue:
+ return issue
+ }
+ }
+
+ if let bracketContents = outerDelimitedContents(type, open: "[", close: "]") {
+ let dictionaryParts = splitTopLevel(bracketContents, separator: ":")
+ if dictionaryParts.count == 1 {
+ switch parseJSONSnapshotType(dictionaryParts[0]) {
+ case .valid(let element): return .valid(.array(element))
+ case let issue: return issue
+ }
+ }
+ guard dictionaryParts.count == 2, isStringType(dictionaryParts[0]) else {
+ return .unsupported
+ }
+ switch parseJSONSnapshotType(dictionaryParts[1]) {
+ case .valid(let value): return .valid(.dictionary(value))
+ case let issue: return issue
+ }
+ }
+
+ if let generic = genericTypeParts(type) {
+ let base = compactTypeName(generic.base)
+ if base == "Optional" || base == "Swift.Optional" {
+ guard generic.arguments.count == 1 else { return .unsupported }
+ switch parseJSONSnapshotType(generic.arguments[0]) {
+ case .valid(let wrapped):
+ return wrapped.isOptional ? .nestedOptional : .valid(.optional(wrapped))
+ case let issue:
+ return issue
+ }
+ }
+ if base == "Array" || base == "Swift.Array" {
+ guard generic.arguments.count == 1 else { return .unsupported }
+ switch parseJSONSnapshotType(generic.arguments[0]) {
+ case .valid(let element): return .valid(.array(element))
+ case let issue: return issue
+ }
+ }
+ if base == "Dictionary" || base == "Swift.Dictionary" {
+ guard generic.arguments.count == 2, isStringType(generic.arguments[0]) else {
+ return .unsupported
+ }
+ switch parseJSONSnapshotType(generic.arguments[1]) {
+ case .valid(let value): return .valid(.dictionary(value))
+ case let issue: return issue
+ }
+ }
+ return .unsupported
+ }
+
+ let scalar = compactTypeName(type)
+ let supportedScalars: Set = [
+ "String", "Swift.String",
+ "Bool", "Swift.Bool",
+ "Int", "Swift.Int", "Int8", "Swift.Int8", "Int16", "Swift.Int16",
+ "Int32", "Swift.Int32", "Int64", "Swift.Int64",
+ "UInt", "Swift.UInt", "UInt8", "Swift.UInt8", "UInt16", "Swift.UInt16",
+ "UInt32", "Swift.UInt32", "UInt64", "Swift.UInt64",
+ "Float", "Swift.Float", "Double", "Swift.Double",
+ "CGFloat", "CoreGraphics.CGFloat",
+ ]
+ return supportedScalars.contains(scalar) ? .valid(.scalar) : .unsupported
+}
+
+private func isStringType(_ source: String) -> Bool {
+ let type = compactTypeName(source)
+ return type == "String" || type == "Swift.String"
+}
+
+private func compactTypeName(_ source: String) -> String {
+ source.filter { !$0.isWhitespace }
+}
+
+private func outerDelimitedContents(_ source: String, open: Character, close: Character) -> String? {
+ let characters = Array(source)
+ guard characters.first == open, characters.last == close else { return nil }
+ var depth = 0
+ for (index, character) in characters.enumerated() {
+ if character == open {
+ depth += 1
+ } else if character == close {
+ depth -= 1
+ guard depth >= 0 else { return nil }
+ if depth == 0, index != characters.count - 1 { return nil }
+ }
+ }
+ guard depth == 0 else { return nil }
+ return String(characters.dropFirst().dropLast())
+}
+
+private func genericTypeParts(_ source: String) -> (base: String, arguments: [String])? {
+ let characters = Array(source)
+ guard let openIndex = characters.firstIndex(of: "<") else { return nil }
+ var depth = 0
+ var closeIndex: Int?
+ for index in openIndex.." {
+ depth -= 1
+ guard depth >= 0 else { return nil }
+ if depth == 0 {
+ closeIndex = index
+ break
+ }
+ }
+ }
+ guard let closeIndex, closeIndex == characters.count - 1 else { return nil }
+ let base = String(characters[.. [String] {
+ let characters = Array(source)
+ var angleDepth = 0
+ var squareDepth = 0
+ var parenDepth = 0
+ var start = 0
+ var parts: [String] = []
+
+ for (index, character) in characters.enumerated() {
+ switch character {
+ case "<": angleDepth += 1
+ case ">": angleDepth -= 1
+ case "[": squareDepth += 1
+ case "]": squareDepth -= 1
+ case "(": parenDepth += 1
+ case ")": parenDepth -= 1
+ default: break
+ }
+ if character == separator, angleDepth == 0, squareDepth == 0, parenDepth == 0 {
+ parts.append(
+ String(characters[start.. String? {
ProcessInfo.processInfo.environment[key]
}
+func detectedBuildId() -> String {
+ if let explicit = getEnv("APP_BUILD_ID"), !explicit.isEmpty { return explicit }
+ let marketing = getEnv("MARKETING_VERSION")
+ let build = getEnv("CURRENT_PROJECT_VERSION")
+ if let marketing, let build, !marketing.isEmpty, !build.isEmpty {
+ return "\(marketing) (\(build))"
+ }
+ if let build, !build.isEmpty { return build }
+ return "unknown"
+}
+
+func optionalWrappedType(_ typeText: String) -> String? {
+ let type = typeText.trimmingCharacters(in: .whitespacesAndNewlines)
+ if type.hasSuffix("?") {
+ let wrapped = String(type.dropLast()).trimmingCharacters(in: .whitespacesAndNewlines)
+ return wrapped.isEmpty ? nil : wrapped
+ }
+
+ guard let optionalRange = type.range(of: #"^Optional\s*<"#, options: .regularExpression),
+ let open = type[optionalRange].lastIndex(of: "<") else { return nil }
+ let openIndex = type.distance(from: type.startIndex, to: open)
+ var depth = 0
+ for (offset, char) in type.enumerated() where offset >= openIndex {
+ if char == "<" { depth += 1 }
+ else if char == ">" {
+ depth -= 1
+ if depth == 0 {
+ let close = type.index(type.startIndex, offsetBy: offset)
+ guard type[type.index(after: close)...].trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
+ return nil
+ }
+ let wrappedStart = type.index(after: open)
+ let wrapped = String(type[wrappedStart..30d entries removed, recent kept
import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
+import { spawnSync } from 'child_process';
import { mkdtempSync, rmSync, writeFileSync, existsSync, readFileSync, mkdirSync, utimesSync } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
@@ -13,9 +16,11 @@ import {
collectSwiftFiles,
parseSwift,
computeCacheKey,
+ computeAccessorHash,
generate,
pruneCache,
render,
+ AccessorGenerationError,
type AccessorSpec,
} from './gen-accessors';
@@ -30,12 +35,14 @@ afterEach(() => {
});
describe('parseSwift — fork regex-failure-mode fixtures', () => {
- test('parses @Observable class with simple @Snapshotable fields', () => {
+ test('parses @Observable class with source-marker comments', () => {
const src = `
@Observable
final class AppState {
- @Snapshotable var isLoggedIn: Bool = false
- @Snapshotable var username: String = ""
+ // @Snapshotable
+ var isLoggedIn: Bool = false
+ // @Snapshotable
+ var username: String = ""
var notSnapshotable: Int = 0
}
`;
@@ -46,12 +53,100 @@ final class AppState {
expect(specs[0]!.fields.find(f => f.name === 'isLoggedIn')!.typeText).toBe('Bool');
});
+ test('retains legacy @Snapshotable attribute parsing', () => {
+ const specs = parseSwift(`
+@Observable
+final class LegacyState {
+ @Snapshotable var counter: Int = 0
+}
+`);
+ expect(specs).toEqual([{
+ className: 'LegacyState',
+ fields: [{ name: 'counter', typeText: 'Int' }],
+ }]);
+ });
+
+ test('does not treat documentation prose as a source marker', () => {
+ const specs = parseSwift(`
+@Observable
+final class PrivateState {
+ /// Do not expose this field through @Snapshotable.
+ var token: String = "secret"
+}
+`);
+ expect(specs).toHaveLength(0);
+ });
+
+ test('does not let a trailing marker comment bleed into the next field', () => {
+ const specs = parseSwift(`
+@Observable
+final class PrivateState {
+ var oldValue: Int = 0 // @Snapshotable
+ var nextValue: Int = 1
+}
+`);
+ expect(specs).toHaveLength(0);
+ });
+
+ test('ignores exact-looking markers inside nested block comments', () => {
+ const specs = parseSwift(`
+@Observable
+final class PrivateState {
+ /*
+ /* // @Snapshotable */
+ // @Snapshotable
+ var leaked: String = "secret"
+ */
+ // @Snapshotable
+ var visible: Int = 1
+}
+`);
+ expect(specs).toEqual([{
+ className: 'PrivateState',
+ fields: [{ name: 'visible', typeText: 'Int' }],
+ }]);
+ });
+
+ test('ignores declarations and markers inside multiline and raw strings', () => {
+ const specs = parseSwift(`
+let ordinary = """
+@Observable class FakeA {
+ // @Snapshotable
+ var leaked: Int = 0
+}
+"""
+let raw = #"""
+@Observable class FakeB { @Snapshotable var leaked: String = "" }
+"""#
+@Observable
+final class RealState {
+ // @Snapshotable
+ var safe: Bool = true
+}
+`);
+ expect(specs).toEqual([{
+ className: 'RealState',
+ fields: [{ name: 'safe', typeText: 'Bool' }],
+ }]);
+ });
+
+ test('ignores marker words in standalone trailing prose', () => {
+ const specs = parseSwift(`
+@Observable
+final class PrivateState {
+ // @Snapshotable fields are intentionally disabled here.
+ var token: String = "secret"
+}
+`);
+ expect(specs).toHaveLength(0);
+ });
+
test('handles @Snapshotable on multi-line type signatures', () => {
const src = `
@Observable
class Cart {
@Snapshotable var items:
- [CartItem]
+ [Dictionary]
= []
var unrelated: Int = 0
}
@@ -60,20 +155,67 @@ class Cart {
expect(specs).toHaveLength(1);
expect(specs[0]!.fields).toHaveLength(1);
expect(specs[0]!.fields[0]!.name).toBe('items');
- expect(specs[0]!.fields[0]!.typeText).toContain('CartItem');
+ expect(specs[0]!.fields[0]!.typeText).toContain('Dictionary');
});
- test('handles generic types in property signatures', () => {
+ test('handles JSON-compatible generic types in property signatures', () => {
const src = `
@Observable
class Repo {
- @Snapshotable var pages: Dictionary]> = [:]
+ @Snapshotable var pages: Dictionary]> = [:]
}
`;
const specs = parseSwift(src);
expect(specs).toHaveLength(1);
expect(specs[0]!.fields[0]!.typeText).toContain('Dictionary');
- expect(specs[0]!.fields[0]!.typeText).toContain('Result');
+ expect(specs[0]!.fields[0]!.typeText).toContain('Optional');
+ });
+
+ test('accepts qualified JSON-native scalar and collection spellings', () => {
+ const specs = parseSwift(`
+@Observable
+class Metrics {
+ // @Snapshotable
+ var ratio: CoreGraphics.CGFloat = 0
+ // @Snapshotable
+ var counts: Swift.Array = []
+}
+`);
+ expect(specs[0]!.fields.map((field) => field.typeText)).toEqual([
+ 'CoreGraphics.CGFloat',
+ 'Swift.Array',
+ ]);
+ });
+
+ test('rejects custom values that Foundation JSON cannot serialize', () => {
+ expect(() => parseSwift(`
+@Observable
+class Repo {
+ // @Snapshotable
+ var pages: Dictionary]> = [:]
+}
+`)).toThrow("unsupported snapshot type 'Result- '");
+ });
+
+ test('rejects nested observable types instead of emitting an unqualified reference', () => {
+ expect(() => parseSwift(`
+enum Namespace {
+ @Observable
+ class State {
+ // @Snapshotable
+ var count: Int = 0
+ }
+}
+`)).toThrow('nested @Observable types are not supported');
+ });
+
+ test('ignores nested observable types that expose no snapshot fields', () => {
+ expect(parseSwift(`
+enum Namespace {
+ @Observable
+ class State { var transient: Int = 0 }
+}
+`)).toEqual([]);
});
test('ignores fields without @Snapshotable marker', () => {
@@ -114,10 +256,7 @@ class B {
expect(specs.map(s => s.className).sort()).toEqual(['A', 'B']);
});
- test('skips fields with computed body braces', () => {
- // Codex flagged "Properties with computed getters / didSet blocks" as a
- // failure mode of the fork's regex. We deliberately exclude them here —
- // computed properties are not snapshot-eligible.
+ test('diagnoses fields with computed body braces', () => {
const src = `
@Observable
class M {
@@ -127,9 +266,50 @@ class M {
}
}
`;
- const specs = parseSwift(src);
- expect(specs).toHaveLength(1);
- expect(specs[0]!.fields.map(f => f.name)).toEqual(['snapshotted']);
+ expect(() => parseSwift(src)).toThrow("field 'computed' must be stored and writable");
+ });
+
+ test.each([
+ ['let', '// @Snapshotable\n let immutable: Int = 1', 'must be declared var, not let'],
+ ['private', '// @Snapshotable\n private var secret: String = ""', 'cannot be private'],
+ ['fileprivate', '// @Snapshotable\n fileprivate var secret: String = ""', 'cannot be private'],
+ ['private(set)', '// @Snapshotable\n public private(set) var count: Int = 0', 'cannot be private'],
+ ['fileprivate(set)', '// @Snapshotable\n fileprivate(set) var count: Int = 0', 'cannot be private'],
+ ['inferred type', '// @Snapshotable\n var inferred = 42', 'requires an explicit type annotation'],
+ ['multiple bindings', '// @Snapshotable\n var a: Int = 1, b: Int = 2', 'exactly one binding'],
+ ['static', '// @Snapshotable\n static var shared: Int = 0', 'must be an instance property'],
+ ['nested Optional', '// @Snapshotable\n var nested: String?? = nil', 'cannot use a nested Optional'],
+ ['nested Optional in collection', '// @Snapshotable\n var nestedItems: [String??] = []', 'cannot use a nested Optional'],
+ ['implicitly unwrapped Optional', '// @Snapshotable\n var legacy: String! = nil', 'cannot use an implicitly unwrapped Optional'],
+ ['custom type', '// @Snapshotable\n var custom: CustomValue = .init()', 'uses unsupported snapshot type'],
+ ])('diagnoses invalid %s declarations', (_label, declaration, expected) => {
+ expect(() => parseSwift(`
+@Observable
+final class InvalidState {
+ ${declaration}
+}
+`)).toThrow(expected);
+ });
+
+ test('aggregates invalid declaration diagnostics with class and line context', () => {
+ try {
+ parseSwift(`
+@Observable
+final class InvalidState {
+ // @Snapshotable
+ let first: Int = 1
+ // @Snapshotable
+ private var second: String = ""
+}
+`);
+ throw new Error('expected parseSwift to fail');
+ } catch (error) {
+ expect(error).toBeInstanceOf(AccessorGenerationError);
+ const generationError = error as AccessorGenerationError;
+ expect(generationError.diagnostics).toHaveLength(2);
+ expect(generationError.message).toContain('InvalidState (line 4)');
+ expect(generationError.message).toContain('InvalidState (line 6)');
+ }
});
});
@@ -244,6 +424,68 @@ describe('computeCacheKey', () => {
});
expect(k1).not.toBe(k2);
});
+
+ test('app build provenance invalidates the cache key', () => {
+ const f = join(workDir, 'a.swift');
+ writeFileSync(f, '@Observable class A {}');
+ const shared = {
+ swiftFiles: [f],
+ swiftVersion: '6.0.0',
+ toolGitRev: 'abc',
+ platformTriple: 'darwin-arm64',
+ };
+ expect(computeCacheKey({ ...shared, buildId: '100' })).not.toBe(
+ computeCacheKey({ ...shared, buildId: '101' }),
+ );
+ });
+
+ test('equivalent source content does not depend on its absolute checkout path', () => {
+ const firstDir = join(workDir, 'checkout-a');
+ const secondDir = join(workDir, 'checkout-b');
+ mkdirSync(firstDir);
+ mkdirSync(secondDir);
+ const first = join(firstDir, 'State.swift');
+ const second = join(secondDir, 'State.swift');
+ writeFileSync(first, '@Observable class A {}');
+ writeFileSync(second, '@Observable class A {}');
+ const versioning = { swiftVersion: '6', toolGitRev: 't', platformTriple: 'p' };
+ expect(computeCacheKey({ swiftFiles: [first], ...versioning })).toBe(
+ computeCacheKey({ swiftFiles: [second], ...versioning }),
+ );
+ });
+});
+
+describe('computeAccessorHash', () => {
+ const base: AccessorSpec[] = [{
+ className: 'AppState',
+ fields: [
+ { name: 'count', typeText: 'Int' },
+ { name: 'nickname', typeText: 'String?' },
+ ],
+ }];
+
+ test('is deterministic for the same ordered accessor signatures', () => {
+ expect(computeAccessorHash(base)).toBe(computeAccessorHash(structuredClone(base)));
+ });
+
+ test('changes when field order, name, or type changes', () => {
+ const reordered: AccessorSpec[] = [{
+ className: 'AppState',
+ fields: [...base[0]!.fields].reverse(),
+ }];
+ const renamed: AccessorSpec[] = [{
+ className: 'AppState',
+ fields: [{ name: 'total', typeText: 'Int' }, base[0]!.fields[1]!],
+ }];
+ const retyped: AccessorSpec[] = [{
+ className: 'AppState',
+ fields: [{ name: 'count', typeText: 'Int64' }, base[0]!.fields[1]!],
+ }];
+ const hash = computeAccessorHash(base);
+ expect(computeAccessorHash(reordered)).not.toBe(hash);
+ expect(computeAccessorHash(renamed)).not.toBe(hash);
+ expect(computeAccessorHash(retyped)).not.toBe(hash);
+ });
});
describe('generate', () => {
@@ -283,6 +525,35 @@ class AppState {
expect(r1.cacheKey).toBe(r2.cacheKey);
});
+ test('custom nested output subtree cannot poison the second-run cache key', () => {
+ const inputDir = join(workDir, 'src');
+ const outputDir = join(inputDir, 'generated', 'accessors');
+ mkdirSync(inputDir);
+ writeFileSync(join(inputDir, 'state.swift'), '@Observable class A { @Snapshotable var x: Int = 0 }');
+ const cacheRoot = join(workDir, 'cache');
+
+ const r1 = generate({
+ inputDir,
+ outputDir,
+ cacheRoot,
+ swiftVersion: '6',
+ toolGitRev: 't',
+ platformTriple: 'p',
+ });
+ writeFileSync(join(outputDir, 'Ignored.swift'), '@Observable class Poison { @Snapshotable var bad: Int = 1 }');
+ const r2 = generate({
+ inputDir,
+ outputDir,
+ cacheRoot,
+ swiftVersion: '6',
+ toolGitRev: 't',
+ platformTriple: 'p',
+ });
+
+ expect(r2.cacheHit).toBe(true);
+ expect(r2.cacheKey).toBe(r1.cacheKey);
+ });
+
test('modifying source invalidates the cache', () => {
const inputDir = join(workDir, 'src');
mkdirSync(inputDir);
@@ -295,6 +566,62 @@ class AppState {
expect(r1.cacheKey).not.toBe(r2.cacheKey);
expect(r2.cacheHit).toBe(false);
});
+
+ test('unmarked source churn changes cache identity but not schema identity', () => {
+ const inputDir = join(workDir, 'src');
+ mkdirSync(inputDir);
+ const state = join(inputDir, 'state.swift');
+ const unrelated = join(inputDir, 'unrelated.swift');
+ writeFileSync(state, '@Observable class A { // @Snapshotable\n var x: Int = 0\n }');
+ writeFileSync(unrelated, 'struct Unrelated { let a = 1 }');
+ const cacheRoot = join(workDir, 'cache');
+ const options = { inputDir, cacheRoot, swiftVersion: '6', toolGitRev: 't', platformTriple: 'p' };
+ const first = generate(options);
+ writeFileSync(unrelated, 'struct Unrelated { let a = 2 }');
+ const second = generate(options);
+ expect(second.cacheKey).not.toBe(first.cacheKey);
+ expect(second.accessorHash).toBe(first.accessorHash);
+ });
+
+ test('buildId changes cannot reuse generated fallback provenance', () => {
+ const inputDir = join(workDir, 'src');
+ mkdirSync(inputDir);
+ writeFileSync(join(inputDir, 'state.swift'), '@Observable class A { @Snapshotable var x: Int = 0 }');
+ const cacheRoot = join(workDir, 'cache');
+ const shared = { inputDir, cacheRoot, swiftVersion: '6', toolGitRev: 't', platformTriple: 'p' };
+ const first = generate({ ...shared, buildId: 'build-100' });
+ const second = generate({ ...shared, buildId: 'build-101' });
+ expect(second.cacheKey).not.toBe(first.cacheKey);
+ expect(second.cacheHit).toBe(false);
+ expect(readFileSync(second.outputPath, 'utf8')).toContain('?? "build-101"');
+ });
+
+ test('CLI exits 4 with actionable diagnostics and no generated output', () => {
+ const inputDir = join(workDir, 'src');
+ const outputDir = join(workDir, 'generated');
+ mkdirSync(inputDir);
+ writeFileSync(join(inputDir, 'state.swift'), `
+@Observable final class InvalidState {
+ // @Snapshotable
+ private let secret = "nope"
+}
+`);
+ const result = spawnSync('bun', [
+ join(import.meta.dir, 'gen-accessors.ts'),
+ '--input', inputDir,
+ '--output', outputDir,
+ ], {
+ encoding: 'utf8',
+ env: { ...process.env, GSTACK_IOS_CACHE_ROOT: join(workDir, 'cache') },
+ });
+ expect(result.status).toBe(4);
+ // `let` is diagnosed first; either way the class/property is named and
+ // generation never emits an accessor that will fail in xcodebuild.
+ expect(result.stderr).toContain('InvalidState');
+ expect(result.stderr).toContain("field 'secret' must be declared var, not let");
+ expect(result.stderr).not.toContain('AccessorGenerationError:');
+ expect(existsSync(join(outputDir, 'StateAccessor.swift'))).toBe(false);
+ });
});
describe('pruneCache', () => {
@@ -332,14 +659,395 @@ describe('render', () => {
fields: [{ name: 'a', typeText: 'Int' }, { name: 'b', typeText: 'String' }],
}];
const out = render(specs, 'build-1.2.3', 'hash-abc');
- expect(out).toContain('public enum AppStateAccessor');
+ expect(out).toContain('enum AppStateAccessor');
+ expect(out).not.toContain('public enum AppStateAccessor');
+ expect(out).toContain('static func register(_ state: AppState)');
+ expect(out).not.toContain('public static func register(_ state: AppState)');
expect(out).toContain('key: "a"');
expect(out).toContain('key: "b"');
- expect(out).toContain('buildId: "build-1.2.3"');
+ expect(out).toContain('return .missingKey("a")');
+ expect(out).toContain('return .typeMismatch("b")');
+ expect(out).toContain('guard let restored0 = Self.decodeSnapshotValue(raw0, as: Int.self)');
+ expect(out).toContain('guard let restored1 = Self.decodeSnapshotValue(raw1, as: String.self)');
+ expect(out).toContain('atomicRestore: { keys, apply in');
+ expect(out).toContain('if apply {');
+ expect(out).toContain('state.a = restored0');
+ expect(out).toContain('state.b = restored1');
+ expect(out.indexOf('state.a = restored0')).toBeGreaterThan(out.indexOf('guard let restored1'));
+ expect(out).toContain('guard let typed = Self.decodeSnapshotValue(value, as: Int.self) else { return false }');
+ expect(out).toContain('state.a = typed');
+ expect(out).toContain('return true');
+ expect(out).not.toContain('atomicRestore: { _ in .ok }');
+ expect(out).not.toContain('write: { _ in false }');
+ expect(out).toContain('CFBundleShortVersionString');
+ expect(out).toContain('CFBundleVersion');
+ expect(out).toContain('return shortVersion ?? bundleVersion ?? "build-1.2.3"');
expect(out).toContain('accessorHash: "hash-abc"');
+ expect(out).toContain('import DebugBridgeCore');
+ expect(out).not.toContain('import DebugBridge\n');
expect(out).toContain('#if DEBUG');
expect(out).toContain('#endif');
});
+
+ test('emits explicit NSNull round-trip handling for Optional fields', () => {
+ const out = render([{
+ className: 'AppState',
+ fields: [
+ { name: 'nickname', typeText: 'String?' },
+ { name: 'selection', typeText: 'Optional' },
+ ],
+ }], 'build', 'schema');
+
+ expect(out).toContain('let restored0: String?');
+ expect(out).toContain('if raw0 is NSNull');
+ expect(out).toContain('else if let typed = Self.decodeSnapshotValue(raw0, as: String.self)');
+ expect(out).toContain('let restored1: Optional');
+ expect(out).toContain('else if let typed = Self.decodeSnapshotValue(raw1, as: Int.self)');
+ expect(out).toContain('guard let value = state.nickname else { return NSNull() }');
+ expect(out).toContain('if value is NSNull');
+ expect(out).toContain('state.nickname = nil');
+ expect(out).not.toContain('value as? String?');
+ });
+
+ test('rejects duplicate snapshot keys across observable models', () => {
+ expect(() => render([
+ { className: 'FirstState', fields: [{ name: 'count', typeText: 'Int' }] },
+ { className: 'SecondState', fields: [{ name: 'count', typeText: 'Int' }] },
+ ], 'build', 'schema')).toThrow("snapshot key 'count' is declared by both FirstState and SecondState");
+ });
+
+ test('typechecks beside an internal @Observable app state using a comment marker', () => {
+ if (spawnSync('swiftc', ['--version'], { encoding: 'utf8' }).status !== 0) return;
+
+ const coreSource = join(workDir, 'DebugBridgeCore.swift');
+ const coreModule = join(workDir, 'DebugBridgeCore.swiftmodule');
+ writeFileSync(coreSource, `
+public typealias JSONDict = [String: Any]
+
+@MainActor
+public final class StateServer {
+ public static let shared = StateServer()
+ public enum RestoreResult {
+ case ok
+ case missingKey(String)
+ case typeMismatch(String)
+ }
+ private init() {}
+ public func register(
+ buildId: String,
+ accessorHash: String,
+ atomicRestore: @escaping (JSONDict, Bool) -> RestoreResult
+ ) {}
+ public func registerAccessor(
+ key: String,
+ type: String,
+ read: @escaping () -> Any?,
+ write: @escaping (Any) -> Bool
+ ) {}
+}
+`);
+ const emitModule = spawnSync('swiftc', [
+ '-emit-module',
+ '-parse-as-library',
+ '-module-name', 'DebugBridgeCore',
+ coreSource,
+ '-emit-module-path', coreModule,
+ ], { encoding: 'utf8' });
+ if (emitModule.status !== 0) {
+ throw new Error(`failed to build DebugBridgeCore test stub:\n${emitModule.stderr}`);
+ }
+
+ const appSource = join(workDir, 'AppState.swift');
+ writeFileSync(appSource, `import Observation
+
+@Observable
+final class AppState {
+ // @Snapshotable
+ var counter: Int = 0
+}
+
+${render([{
+ className: 'AppState',
+ fields: [{ name: 'counter', typeText: 'Int' }],
+ }], 'build-test', 'hash-test')}`);
+ const typecheck = spawnSync('swiftc', [
+ '-typecheck',
+ '-D', 'DEBUG',
+ '-I', workDir,
+ appSource,
+ ], { encoding: 'utf8' });
+ if (typecheck.status !== 0) {
+ throw new Error(`generated accessor failed Swift type checking:\n${typecheck.stderr}`);
+ }
+ });
+
+ test('strict JSON typing and cross-model validate-before-apply restore run correctly', () => {
+ if (process.platform !== 'darwin') return;
+ if (spawnSync('swiftc', ['--version'], { encoding: 'utf8' }).status !== 0) return;
+
+ const coreSource = join(workDir, 'DebugBridgeCore.swift');
+ const coreModule = join(workDir, 'DebugBridgeCore.swiftmodule');
+ const coreLibrary = join(workDir, 'libDebugBridgeCore.dylib');
+ writeFileSync(coreSource, `
+public typealias JSONDict = [String: Any]
+
+@MainActor
+public final class StateServer {
+ public typealias Restore = (JSONDict, Bool) -> RestoreResult
+ public enum RestoreResult { case ok, missingKey(String), typeMismatch(String) }
+ public static let shared = StateServer()
+ public var restores: [Restore] = []
+ public var reads: [String: () -> Any?] = [:]
+ public var writes: [String: (Any) -> Bool] = [:]
+ private init() {}
+ public func register(buildId: String, accessorHash: String, atomicRestore: @escaping Restore) {
+ restores.append(atomicRestore)
+ }
+ public func registerAccessor(
+ key: String,
+ type: String,
+ read: @escaping () -> Any?,
+ write: @escaping (Any) -> Bool
+ ) {
+ reads[key] = read
+ writes[key] = write
+ }
+ public func restoreAll(_ keys: JSONDict) -> RestoreResult {
+ for restore in restores {
+ let result = restore(keys, false)
+ guard case .ok = result else { return result }
+ }
+ for restore in restores {
+ let result = restore(keys, true)
+ guard case .ok = result else { return result }
+ }
+ return .ok
+ }
+}
+`);
+ const emitCore = spawnSync('swiftc', [
+ '-emit-library', '-emit-module', '-parse-as-library',
+ '-module-name', 'DebugBridgeCore', coreSource,
+ '-emit-module-path', coreModule,
+ '-o', coreLibrary,
+ ], { encoding: 'utf8' });
+ if (emitCore.status !== 0) throw new Error(`failed to build runtime stub:\n${emitCore.stderr}`);
+
+ const appSource = join(workDir, 'OptionalRoundTrip.swift');
+ writeFileSync(appSource, `
+import Foundation
+import Observation
+import DebugBridgeCore
+
+@Observable
+final class AppState {
+ // @Snapshotable
+ var nickname: String? = nil
+ // @Snapshotable
+ var count: Int = 1
+}
+
+@Observable
+final class FeatureState {
+ // @Snapshotable
+ var enabled: Bool = false
+}
+
+${render([
+ {
+ className: 'AppState',
+ fields: [
+ { name: 'nickname', typeText: 'String?' },
+ { name: 'count', typeText: 'Int' },
+ ],
+ },
+ {
+ className: 'FeatureState',
+ fields: [{ name: 'enabled', typeText: 'Bool' }],
+ },
+ ], 'fallback-build', 'schema-hash')}
+
+@main
+struct Runner {
+ @MainActor static func main() {
+ let state = AppState()
+ let feature = FeatureState()
+ AppStateAccessor.register(state)
+ FeatureStateAccessor.register(feature)
+ guard StateServer.shared.reads["nickname"]?() is NSNull else { fatalError("nil read") }
+ guard StateServer.shared.writes["nickname"]?("Ada") == true, state.nickname == "Ada" else {
+ fatalError("optional write")
+ }
+ guard StateServer.shared.writes["nickname"]?(NSNull()) == true, state.nickname == nil else {
+ fatalError("null write")
+ }
+ guard StateServer.shared.writes["count"]?(true) == false, state.count == 1 else {
+ fatalError("boolean must not coerce to integer")
+ }
+ guard StateServer.shared.writes["enabled"]?(1) == false, feature.enabled == false else {
+ fatalError("integer must not coerce to boolean")
+ }
+ let valid = try! JSONSerialization.jsonObject(
+ with: Data(#"{"nickname":"Grace","count":7,"enabled":true}"#.utf8)
+ ) as! JSONDict
+ switch StateServer.shared.restoreAll(valid) {
+ case .ok: break
+ default: fatalError("valid restore")
+ }
+ guard state.nickname == "Grace", state.count == 7, feature.enabled else { fatalError("restore values") }
+ switch StateServer.shared.restoreAll(["nickname": NSNull(), "count": 8, "enabled": false]) {
+ case .ok: break
+ default: fatalError("null restore")
+ }
+ guard state.nickname == nil, state.count == 8, !feature.enabled else { fatalError("null restore values") }
+ state.nickname = "unchanged"
+ state.count = 9
+ feature.enabled = false
+ switch StateServer.shared.restoreAll(["nickname": "would-partially-apply", "count": 10, "enabled": 1]) {
+ case .typeMismatch("enabled"): break
+ default: fatalError("expected mismatch")
+ }
+ guard state.nickname == "unchanged", state.count == 9, !feature.enabled else { fatalError("cross-model partial mutation") }
+ }
+}
+`);
+ const executable = join(workDir, 'optional-round-trip');
+ const compile = spawnSync('swiftc', [
+ '-D', 'DEBUG', '-I', workDir, '-L', workDir, '-lDebugBridgeCore',
+ '-parse-as-library', appSource, '-o', executable,
+ ], { encoding: 'utf8' });
+ if (compile.status !== 0) throw new Error(`generated Optional accessor failed compilation:\n${compile.stderr}`);
+ const run = spawnSync(executable, [], {
+ encoding: 'utf8',
+ env: { ...process.env, DYLD_LIBRARY_PATH: workDir },
+ });
+ if (run.status !== 0) throw new Error(`generated Optional accessor failed at runtime:\n${run.stderr}`);
+ });
+});
+
+describe('SwiftSyntax generator parity', () => {
+ test('isolates canonical markers and rejects inaccessible fields', () => {
+ if (process.platform !== 'darwin') return;
+ if (spawnSync('swift', ['--version'], { encoding: 'utf8' }).status !== 0) return;
+
+ const packageDir = join(import.meta.dir, 'gen-accessors-tool');
+ const inputDir = join(workDir, 'swift-syntax-input');
+ const outputDir = join(workDir, 'swift-syntax-output');
+ const cacheRoot = join(workDir, 'swift-syntax-cache');
+ mkdirSync(inputDir);
+ writeFileSync(join(inputDir, 'State.swift'), `
+import Observation
+@Observable
+final class ToolState {
+ let documentation = """
+ // @Snapshotable
+ var stringLeak: String = "no"
+ """
+ /* // @Snapshotable */
+ var blockLeak: Int = 1
+ var trailingLeak: Int = 2 // @Snapshotable
+ // prose about @Snapshotable must not opt in
+ var proseLeak: Int = 3
+ // @Snapshotable
+ var nickname: String? = nil
+ // @Snapshotable
+ var names:
+ [String]
+ = []
+ // @Snapshotable
+ var count: Int = 0
+ // @Snapshotable
+ var enabled: Bool = false
+}
+`);
+ const env = {
+ ...process.env,
+ GSTACK_IOS_CACHE_ROOT: cacheRoot,
+ APP_BUILD_ID: 'syntax-test-build',
+ GEN_ACCESSORS_REV: 'syntax-test-v5',
+ };
+ const run = spawnSync('swift', [
+ 'run', '--package-path', packageDir, 'gen-accessors',
+ '--input', inputDir, '--output', outputDir,
+ ], { encoding: 'utf8', env, timeout: 180_000 });
+ if (run.status !== 0) throw new Error(`SwiftSyntax generator failed:\n${run.stderr}`);
+ const output = readFileSync(join(outputDir, 'StateAccessor.swift'), 'utf8');
+ expect(output).toContain('key: "nickname"');
+ expect(output).toContain('key: "names"');
+ expect(output).toContain('key: "count"');
+ expect(output).toContain('key: "enabled"');
+ expect(output).toContain('_GStackDebugBridgeSnapshotJSON.decode(raw0');
+ expect(output).toContain('atomicRestore: { keys, apply in');
+ expect(output).toContain('if apply {');
+ expect(output).not.toMatch(/raw\d+ as\?/);
+ expect(output).toContain('CFBundleShortVersionString');
+ expect(output).toContain(`accessorHash: "${computeAccessorHash([{
+ className: 'ToolState',
+ fields: [
+ { name: 'nickname', typeText: 'String?' },
+ { name: 'names', typeText: '[String]' },
+ { name: 'count', typeText: 'Int' },
+ { name: 'enabled', typeText: 'Bool' },
+ ],
+ }])}"`);
+ expect(output).not.toContain('key: "stringLeak"');
+ expect(output).not.toContain('key: "blockLeak"');
+ expect(output).not.toContain('key: "trailingLeak"');
+ expect(output).not.toContain('key: "proseLeak"');
+
+ const invalidInput = join(workDir, 'swift-syntax-invalid');
+ const invalidOutput = join(workDir, 'swift-syntax-invalid-output');
+ mkdirSync(invalidInput);
+ writeFileSync(join(invalidInput, 'Invalid.swift'), `
+import Observation
+@Observable
+final class InvalidState {
+ // @Snapshotable
+ public private(set) var token: String = "secret"
+ // @Snapshotable
+ let immutable: Int = 1
+ // @Snapshotable
+ var inferred = 2
+ // @Snapshotable
+ var legacy: String! = nil
+ // @Snapshotable
+ var custom: Date = .now
+}
+
+enum Namespace {
+ @Observable
+ final class NestedState {
+ // @Snapshotable
+ var nestedValue: Int = 0
+ }
+}
+
+@Observable
+final class FirstState {
+ // @Snapshotable
+ var shared: String = "first"
+}
+
+@Observable
+final class DuplicateState {
+ // @Snapshotable
+ var shared: String = "duplicate"
+}
+`);
+ const invalid = spawnSync('swift', [
+ 'run', '--package-path', packageDir, 'gen-accessors',
+ '--input', invalidInput, '--output', invalidOutput,
+ ], { encoding: 'utf8', env, timeout: 180_000 });
+ expect(invalid.status).toBe(4);
+ expect(invalid.stderr).toContain('InvalidState.token cannot be private');
+ expect(invalid.stderr).toContain('InvalidState.immutable must be declared var, not let');
+ expect(invalid.stderr).toContain('InvalidState.inferred requires an explicit type annotation');
+ expect(invalid.stderr).toContain('InvalidState.legacy cannot use an implicitly unwrapped Optional type');
+ expect(invalid.stderr).toContain("InvalidState.custom uses unsupported non-JSON snapshot type 'Date'");
+ expect(invalid.stderr).toContain('nested @Observable class NestedState');
+ expect(invalid.stderr).toContain("snapshot key 'shared' is declared by both FirstState and DuplicateState");
+ expect(existsSync(join(invalidOutput, 'StateAccessor.swift'))).toBe(false);
+ }, 180_000);
});
describe('collectSwiftFiles', () => {
@@ -355,4 +1063,17 @@ describe('collectSwiftFiles', () => {
const files = collectSwiftFiles(workDir);
expect(files.sort()).toEqual([a, b].sort());
});
+
+ test('excludes the configured output subtree and every StateAccessor.swift', () => {
+ const source = join(workDir, 'App.swift');
+ const staleAccessor = join(workDir, 'old', 'StateAccessor.swift');
+ const outputDir = join(workDir, 'custom-output');
+ mkdirSync(join(workDir, 'old'));
+ mkdirSync(outputDir);
+ writeFileSync(source, 'struct App {}');
+ writeFileSync(staleAccessor, '// stale generated output');
+ writeFileSync(join(outputDir, 'Poison.swift'), '// must not be scanned');
+
+ expect(collectSwiftFiles(workDir, { outputDir })).toEqual([source]);
+ });
});
diff --git a/ios-qa/scripts/gen-accessors.ts b/ios-qa/scripts/gen-accessors.ts
index 05475f918..738e08364 100644
--- a/ios-qa/scripts/gen-accessors.ts
+++ b/ios-qa/scripts/gen-accessors.ts
@@ -5,15 +5,16 @@
// first time. Also exercised by tests so we can verify the cache + parse
// behavior without a Swift toolchain.
//
-// The TS port uses a stricter regex than the fork's original — it understands:
+// The TS fast path uses a lightweight Swift lexical scanner — it understands:
// - @Observable class declarations
-// - @Snapshotable property markers (only marked fields are exported)
+// - `// @Snapshotable` property markers (only marked fields are exported)
+// - Legacy @Snapshotable attributes for existing integrations
// - Multi-line type signatures (collapses whitespace before matching)
-// - Generic type parameters (matched as opaque text inside the type)
+// - JSON-native generic arrays and String-keyed dictionaries
//
-// What it does NOT handle (deferred to the SwiftPM tool):
-// - Computed properties with bodies (regex can mis-parse braces)
-// - Property wrappers other than @Snapshotable
+// Invalid marked declarations (computed/immutable/inaccessible/untyped,
+// nested, duplicate-keyed, or non-JSON) fail generation instead of producing
+// Swift that only fails later in xcodebuild or at snapshot time.
//
// Composite cache key (codex-flagged): swift_version || tool_git_rev ||
// platform_triple || source_content_hash. Source-only hash misses generator
@@ -35,6 +36,16 @@ export interface AccessorSpec {
fields: AccessorField[];
}
+export class AccessorGenerationError extends Error {
+ readonly diagnostics: string[];
+
+ constructor(diagnostics: string[]) {
+ super(`gen-accessors: invalid @Snapshotable declaration(s):\n${diagnostics.map(d => ` - ${d}`).join('\n')}`);
+ this.name = 'AccessorGenerationError';
+ this.diagnostics = diagnostics;
+ }
+}
+
export interface GenInputs {
inputDir: string;
outputDir?: string;
@@ -48,56 +59,105 @@ export interface GenInputs {
export interface GenResult {
outputPath: string;
cacheKey: string;
+ accessorHash: string;
specs: AccessorSpec[];
cacheHit: boolean;
}
const FALLBACK_PLATFORM = process.platform === 'darwin' ? 'darwin-arm64' : `${process.platform}-${process.arch}`;
+const GENERATOR_FORMAT_VERSION = 'accessor-generator-v5';
-export function collectSwiftFiles(dir: string, opts: { excludeGenerated?: boolean } = {}): string[] {
+const JSON_SCALAR_TYPES = new Set([
+ 'String',
+ 'Bool',
+ 'Int', 'Int8', 'Int16', 'Int32', 'Int64',
+ 'UInt', 'UInt8', 'UInt16', 'UInt32', 'UInt64',
+ 'Float', 'Double', 'CGFloat',
+]);
+
+export function collectSwiftFiles(
+ dir: string,
+ opts: { excludeGenerated?: boolean; outputDir?: string } = {},
+): string[] {
const out: string[] = [];
const excludeGenerated = opts.excludeGenerated ?? true;
- for (const name of readdirSync(dir)) {
- const full = join(dir, name);
- const s = statSync(full);
- if (s.isDirectory()) {
- // Skip generated output dir (when it lives under the input dir)
- if (excludeGenerated && name === 'DebugBridgeGenerated') continue;
- out.push(...collectSwiftFiles(full, opts));
- } else if (name.endsWith('.swift')) {
- // Skip the codegen output file. Otherwise the second run picks it up,
- // changes the cache key, and the cache never hits.
- if (excludeGenerated && name === 'StateAccessor.swift') continue;
- out.push(full);
+ const root = resolve(dir);
+ const outputDir = opts.outputDir ? resolve(opts.outputDir) : undefined;
+
+ function walk(currentDir: string): void {
+ for (const name of readdirSync(currentDir)) {
+ const full = join(currentDir, name);
+ const s = statSync(full);
+ if (s.isDirectory()) {
+ // The generated subtree must never participate in its own cache key.
+ // Keep the well-known-name guard for direct collectSwiftFiles callers,
+ // and use the actual --output path when generate() supplies one.
+ const isOutputSubtree = outputDir !== undefined
+ && outputDir !== root
+ && resolve(full) === outputDir;
+ if (excludeGenerated && (name === 'DebugBridgeGenerated' || isOutputSubtree)) continue;
+ walk(full);
+ } else if (name.endsWith('.swift')) {
+ // Skip generated accessor files wherever they live. Otherwise moving
+ // an old copy outside the output directory poisons the next cache key.
+ if (excludeGenerated && name === 'StateAccessor.swift') continue;
+ out.push(full);
+ }
}
}
+
+ walk(root);
return out.sort();
}
export function parseSwift(source: string): AccessorSpec[] {
const specs: AccessorSpec[] = [];
+ const diagnostics: string[] = [];
+ const masked = maskSwiftSource(source);
// Find `@Observable\n(public )?(final )?class ` followed by a brace
// block. We then scan inside that block for @Snapshotable fields.
const classPattern = /@Observable\s*(?:(?:public|internal|fileprivate|private)\s+)?(?:final\s+)?class\s+(\w+)[^{]*\{/g;
- let match: RegExpExecArray | null;
- while ((match = classPattern.exec(source)) !== null) {
+ for (const match of masked.matchAll(classPattern)) {
const className = match[1]!;
- const startIdx = classPattern.lastIndex;
- const endIdx = findMatchingBrace(source, startIdx - 1);
+ const matchOffset = match.index!;
+ const openBraceOffset = matchOffset + match[0].lastIndexOf('{');
+ const startIdx = openBraceOffset + 1;
+ const endIdx = findMatchingBrace(masked, startIdx - 1);
if (endIdx === -1) continue;
- const body = source.slice(startIdx, endIdx);
+ const body = masked.slice(startIdx, endIdx);
- const fields = parseFields(body);
+ const parsed = parseFields(body, source, startIdx, className);
+ if (braceDepthAt(masked, matchOffset) !== 0) {
+ if (parsed.fields.length > 0 || parsed.diagnostics.length > 0) {
+ const line = source.slice(0, matchOffset).split(/\r?\n/).length;
+ diagnostics.push(`${className} (line ${line}): nested @Observable types are not supported; move the type to file scope`);
+ }
+ continue;
+ }
+ diagnostics.push(...parsed.diagnostics);
+ const fields = parsed.fields;
if (fields.length > 0) {
specs.push({ className, fields });
}
}
+
+ if (diagnostics.length > 0) throw new AccessorGenerationError(diagnostics);
return specs;
}
+function braceDepthAt(masked: string, offset: number): number {
+ let depth = 0;
+ for (let i = 0; i < offset; i++) {
+ if (masked[i] === '{') depth++;
+ else if (masked[i] === '}') depth = Math.max(0, depth - 1);
+ }
+ return depth;
+}
+
function findMatchingBrace(s: string, openIdx: number): number {
- // openIdx points at '{'. Return idx of matching '}', or -1.
+ // Strings and comments have already been blanked by maskSwiftSource, so
+ // braces here are syntax rather than prose or literal content.
let depth = 0;
for (let i = openIdx; i < s.length; i++) {
const c = s[i];
@@ -105,48 +165,305 @@ function findMatchingBrace(s: string, openIdx: number): number {
else if (c === '}') {
depth--;
if (depth === 0) return i;
- } else if (c === '"' || c === "'") {
- // skip string literal
- const quote = c;
- i++;
- while (i < s.length && s[i] !== quote) {
- if (s[i] === '\\') i++;
- i++;
- }
- } else if (c === '/' && s[i + 1] === '/') {
- // skip line comment
- while (i < s.length && s[i] !== '\n') i++;
- } else if (c === '/' && s[i + 1] === '*') {
- i += 2;
- while (i < s.length - 1 && !(s[i] === '*' && s[i + 1] === '/')) i++;
- i++;
}
}
return -1;
}
-function parseFields(body: string): AccessorField[] {
- // Look for @Snapshotable followed by var/let declarations. Allow attribute
- // ordering: `@Snapshotable var name: Type` OR `@Snapshotable\n var name: Type`.
- // Multi-line types are handled by greedy non-newline match in the type, but
- // we collapse adjacent whitespace first to avoid false negatives.
- const normalized = body.replace(/[\t ]*\r?\n[\t ]*/g, ' ');
- const fieldPattern = /@Snapshotable\s+(?:(?:public|internal|fileprivate|private)\s+)?(?:var|let)\s+(\w+)\s*:\s*([^={]+?)(?=\s*(?:=|\{|@Snapshotable|\bvar\b|\blet\b|\bfunc\b|\}|$))/g;
- const fields: AccessorField[] = [];
- let m: RegExpExecArray | null;
- while ((m = fieldPattern.exec(normalized)) !== null) {
- // Codex catch: skip fields that have a computed body (`{ get ... }` or
- // `{ didSet ... }` after the type). The match boundary stops before `{`,
- // so we peek at what comes after the type in the original body.
- const afterMatchIdx = m.index + m[0].length;
- const afterMatch = normalized.slice(afterMatchIdx, afterMatchIdx + 4).trim();
- // If the next non-space character is `{`, this is a computed property.
- // We're conservative: snapshot-eligible fields must be stored properties
- // (initialized with `=` or just declared).
- if (afterMatch.startsWith('{')) continue;
- fields.push({ name: m[1]!, typeText: m[2]!.trim() });
+/**
+ * Blank comments and string literals while preserving byte offsets/newlines.
+ * A canonical standalone `// @Snapshotable` comment is rewritten to the
+ * equivalent attribute token. This is intentionally lexical rather than a
+ * whole-file regexp: markers inside nested block comments, normal/triple/raw
+ * strings, trailing comments, and prose comments must never opt a field in.
+ */
+function maskSwiftSource(source: string): string {
+ const out = source.split('');
+
+ const blank = (start: number, end: number): void => {
+ for (let j = start; j < end; j++) {
+ if (out[j] !== '\n' && out[j] !== '\r') out[j] = ' ';
+ }
+ };
+
+ let i = 0;
+ while (i < source.length) {
+ if (source[i] === '/' && source[i + 1] === '/') {
+ let end = i + 2;
+ while (end < source.length && source[end] !== '\n' && source[end] !== '\r') end++;
+ const lineStart = source.lastIndexOf('\n', i - 1) + 1;
+ const standalone = source.slice(lineStart, i).trim().length === 0;
+ const isMarker = standalone && source.slice(i + 2, end).trim() === '@Snapshotable';
+ blank(i, end);
+ if (isMarker) {
+ const marker = '@Snapshotable';
+ for (let j = 0; j < marker.length; j++) out[i + j] = marker[j]!;
+ }
+ i = end;
+ continue;
+ }
+
+ if (source[i] === '/' && source[i + 1] === '*') {
+ const start = i;
+ i += 2;
+ let depth = 1;
+ while (i < source.length && depth > 0) {
+ if (source[i] === '/' && source[i + 1] === '*') {
+ depth++;
+ i += 2;
+ } else if (source[i] === '*' && source[i + 1] === '/') {
+ depth--;
+ i += 2;
+ } else {
+ i++;
+ }
+ }
+ blank(start, i);
+ continue;
+ }
+
+ // Swift strings may be ordinary, multiline, or raw (`#"..."#` and
+ // `#"""..."""#`). Mask the entire literal, including interpolation;
+ // declarations cannot legally originate inside a literal.
+ let hashCount = 0;
+ while (source[i + hashCount] === '#') hashCount++;
+ const quoteIdx = i + hashCount;
+ if (source[quoteIdx] === '"') {
+ const start = i;
+ const triple = source.slice(quoteIdx, quoteIdx + 3) === '"""';
+ const quoteCount = triple ? 3 : 1;
+ i = quoteIdx + quoteCount;
+ while (i < source.length) {
+ if (hashCount === 0 && source[i] === '\\') {
+ i += 2;
+ continue;
+ }
+ const quoteRun = source.slice(i, i + quoteCount) === '"'.repeat(quoteCount);
+ const hashesMatch = source.slice(i + quoteCount, i + quoteCount + hashCount) === '#'.repeat(hashCount);
+ if (quoteRun && hashesMatch) {
+ i += quoteCount + hashCount;
+ break;
+ }
+ i++;
+ }
+ blank(start, i);
+ continue;
+ }
+
+ i++;
}
- return fields;
+ return out.join('');
+}
+
+const DECL_MODIFIERS = new Set([
+ 'public', 'internal', 'package', 'open', 'private', 'fileprivate',
+ 'final', 'static', 'class', 'lazy', 'weak', 'unowned', 'override',
+ 'nonisolated', 'isolated', 'borrowing', 'consuming',
+]);
+
+function parseFields(
+ body: string,
+ fullSource: string,
+ bodyOffset: number,
+ className: string,
+): { fields: AccessorField[]; diagnostics: string[] } {
+ const fields: AccessorField[] = [];
+ const diagnostics: string[] = [];
+ let braceDepth = 0;
+
+ const lineFor = (localOffset: number): number => {
+ const absolute = bodyOffset + localOffset;
+ let line = 1;
+ for (let j = 0; j < absolute; j++) if (fullSource[j] === '\n') line++;
+ return line;
+ };
+ const fail = (offset: number, message: string): void => {
+ diagnostics.push(`${className} (line ${lineFor(offset)}): ${message}`);
+ };
+ const skipWhitespace = (start: number): number => {
+ let at = start;
+ while (at < body.length && /\s/.test(body[at]!)) at++;
+ return at;
+ };
+ const identifierAt = (start: number): { text: string; end: number } | undefined => {
+ const m = /^[A-Za-z_]\w*/.exec(body.slice(start));
+ return m ? { text: m[0], end: start + m[0].length } : undefined;
+ };
+ const skipBalancedParens = (start: number): number => {
+ if (body[start] !== '(') return start;
+ let depth = 0;
+ for (let at = start; at < body.length; at++) {
+ if (body[at] === '(') depth++;
+ else if (body[at] === ')' && --depth === 0) return at + 1;
+ }
+ return body.length;
+ };
+
+ for (let i = 0; i < body.length; i++) {
+ if (body[i] === '{') {
+ braceDepth++;
+ continue;
+ }
+ if (body[i] === '}') {
+ braceDepth--;
+ continue;
+ }
+ if (braceDepth !== 0 || !body.startsWith('@Snapshotable', i)) continue;
+ const before = i === 0 ? '' : body[i - 1]!;
+ const after = body[i + '@Snapshotable'.length] ?? '';
+ if (/\w/.test(before) || /\w/.test(after)) continue;
+
+ const markerOffset = i;
+ let at = skipWhitespace(i + '@Snapshotable'.length);
+ const modifiers: string[] = [];
+ let bindingKind: 'var' | 'let' | undefined;
+
+ // Permit other Swift attributes between the marker and declaration. They
+ // remain the compiler's responsibility; this scanner only owns the
+ // snapshot contract. Parenthesized attribute arguments are skipped.
+ while (body[at] === '@') {
+ const attribute = identifierAt(at + 1);
+ if (!attribute) break;
+ at = skipWhitespace(attribute.end);
+ if (body[at] === '(') at = skipBalancedParens(at);
+ at = skipWhitespace(at);
+ }
+
+ while (at < body.length) {
+ const token = identifierAt(at);
+ if (!token) break;
+ if (token.text === 'var' || token.text === 'let') {
+ bindingKind = token.text;
+ at = token.end;
+ break;
+ }
+ if (!DECL_MODIFIERS.has(token.text)) break;
+ let modifier = token.text;
+ at = skipWhitespace(token.end);
+ if (body[at] === '(') {
+ const end = skipBalancedParens(at);
+ modifier += body.slice(at, end).replace(/\s+/g, '');
+ at = skipWhitespace(end);
+ }
+ modifiers.push(modifier);
+ }
+
+ if (!bindingKind) {
+ fail(markerOffset, '@Snapshotable must immediately precede a stored property declaration');
+ continue;
+ }
+
+ at = skipWhitespace(at);
+ const identifier = identifierAt(at);
+ const fieldName = identifier?.text ?? '';
+ if (!identifier) {
+ fail(markerOffset, '@Snapshotable only supports a single identifier binding');
+ continue;
+ }
+ at = skipWhitespace(identifier.end);
+
+ if (bindingKind === 'let') {
+ fail(markerOffset, `@Snapshotable field '${fieldName}' must be declared var, not let`);
+ continue;
+ }
+ if (modifiers.some(m => /^(?:private|fileprivate)(?:\(set\))?$/.test(m))) {
+ fail(markerOffset, `@Snapshotable field '${fieldName}' cannot be private, fileprivate, private(set), or fileprivate(set)`);
+ continue;
+ }
+ if (modifiers.some(m => m === 'static' || m === 'class')) {
+ fail(markerOffset, `@Snapshotable field '${fieldName}' must be an instance property`);
+ continue;
+ }
+ if (body[at] !== ':') {
+ fail(markerOffset, `@Snapshotable field '${fieldName}' requires an explicit type annotation`);
+ continue;
+ }
+
+ at = skipWhitespace(at + 1);
+ const typeStart = at;
+ let parens = 0;
+ let brackets = 0;
+ let angles = 0;
+ let typeEnd = at;
+ let delimiter = '';
+ for (; at < body.length; at++) {
+ const c = body[at]!;
+ if (c === '(') parens++;
+ else if (c === ')') parens--;
+ else if (c === '[') brackets++;
+ else if (c === ']') brackets--;
+ else if (c === '<') angles++;
+ else if (c === '>') angles = Math.max(0, angles - 1);
+ const topLevel = parens === 0 && brackets === 0 && angles === 0;
+ if (topLevel && (c === '=' || c === '{' || c === ',' || c === ';')) {
+ delimiter = c;
+ break;
+ }
+ if (topLevel && (c === '\n' || c === '\r')) {
+ const soFar = body.slice(typeStart, at).trim();
+ if (soFar.length > 0 && !/(?:->|[&.,])\s*$/.test(soFar)) break;
+ }
+ typeEnd = at + 1;
+ }
+ const typeText = body.slice(typeStart, typeEnd).replace(/\s+/g, ' ').trim();
+ if (typeText.length === 0) {
+ fail(markerOffset, `@Snapshotable field '${fieldName}' requires an explicit type annotation`);
+ continue;
+ }
+ if (delimiter === '{') {
+ fail(markerOffset, `@Snapshotable field '${fieldName}' must be stored and writable`);
+ continue;
+ }
+ if (delimiter === ',') {
+ fail(markerOffset, '@Snapshotable declarations must contain exactly one binding');
+ continue;
+ }
+ if (delimiter === '=') {
+ // A declaration such as `var a: Int = 1, b: Int = 2` reaches `=`
+ // before its binding comma. Scan the initializer at syntax depth zero
+ // so commas in arrays/calls/closures remain valid.
+ let initializerAt = at + 1;
+ let expressionParens = 0;
+ let expressionBrackets = 0;
+ let expressionBraces = 0;
+ let hasSecondBinding = false;
+ for (; initializerAt < body.length; initializerAt++) {
+ const c = body[initializerAt]!;
+ if (c === '(') expressionParens++;
+ else if (c === ')') expressionParens--;
+ else if (c === '[') expressionBrackets++;
+ else if (c === ']') expressionBrackets--;
+ else if (c === '{') expressionBraces++;
+ else if (c === '}') {
+ if (expressionBraces === 0) break;
+ expressionBraces--;
+ }
+ const topLevel = expressionParens === 0 && expressionBrackets === 0 && expressionBraces === 0;
+ if (topLevel && c === ',') {
+ hasSecondBinding = true;
+ break;
+ }
+ if (topLevel && (c === '\n' || c === '\r' || c === ';')) break;
+ }
+ if (hasSecondBinding) {
+ fail(markerOffset, '@Snapshotable declarations must contain exactly one binding');
+ continue;
+ }
+ }
+ const wrappedOptional = optionalWrappedType(typeText);
+ if (wrappedOptional !== undefined && optionalWrappedType(wrappedOptional) !== undefined) {
+ fail(markerOffset, `@Snapshotable field '${fieldName}' cannot use a nested Optional type`);
+ continue;
+ }
+ const typeIssue = snapshotTypeIssue(typeText);
+ if (typeIssue !== undefined) {
+ fail(markerOffset, `@Snapshotable field '${fieldName}' ${typeIssue}`);
+ continue;
+ }
+
+ fields.push({ name: fieldName, typeText });
+ }
+ return { fields, diagnostics };
}
export function computeCacheKey(inputs: {
@@ -154,35 +471,263 @@ export function computeCacheKey(inputs: {
swiftVersion: string;
toolGitRev: string;
platformTriple: string;
+ buildId?: string;
}): string {
const h = createHash('sha256');
- h.update(`swift=${inputs.swiftVersion}|tool=${inputs.toolGitRev}|platform=${inputs.platformTriple}|`);
+ h.update(`${GENERATOR_FORMAT_VERSION}|swift=${inputs.swiftVersion}|tool=${inputs.toolGitRev}|platform=${inputs.platformTriple}|build=${inputs.buildId ?? 'unknown'}|`);
for (const f of inputs.swiftFiles) {
const content = readFileSync(f);
- h.update(`${f}:${content.length}:`);
+ // Cache identity is content-based. Absolute checkout paths must not make
+ // equivalent source trees produce different generated output.
+ h.update(`${content.length}:`);
h.update(content);
h.update('|');
}
return h.digest('hex');
}
-export function render(specs: AccessorSpec[], buildId: string, accessorHash: string): string {
- let out = '// AUTO-GENERATED — DO NOT EDIT. Regenerate with /ios-sync.\n';
- out += '#if DEBUG\nimport Foundation\nimport DebugBridge\n\n';
+/** Stable snapshot-schema fingerprint, deliberately independent of cache ABI,
+ * source paths, app build provenance, and unmarked source. Field/class order is
+ * source order because restore payload compatibility is an ordered contract. */
+export function computeAccessorHash(specs: AccessorSpec[]): string {
+ let signature = 'snapshot-schema-v1\n';
for (const spec of specs) {
- out += `@MainActor\npublic enum ${spec.className}Accessor {\n`;
- out += ` public static func register(_ state: ${spec.className}) {\n`;
+ signature += `C${Buffer.byteLength(spec.className)}:${spec.className}\n`;
+ for (const field of spec.fields) {
+ signature += `F${Buffer.byteLength(field.name)}:${field.name}`;
+ signature += `T${Buffer.byteLength(field.typeText)}:${field.typeText}\n`;
+ }
+ signature += 'E\n';
+ }
+ return createHash('sha256').update(signature).digest('hex');
+}
+
+/** Return the wrapped type for one top-level Optional spelling. */
+export function optionalWrappedType(typeText: string): string | undefined {
+ const type = typeText.trim();
+ if (type.endsWith('?')) {
+ const wrapped = type.slice(0, -1).trim();
+ return wrapped.length > 0 ? wrapped : undefined;
+ }
+
+ const optional = /^(?:Swift\.)?Optional\s*') {
+ depth--;
+ if (depth === 0) {
+ if (type.slice(i + 1).trim().length > 0) return undefined;
+ const wrapped = type.slice(open + 1, i).trim();
+ return wrapped.length > 0 ? wrapped : undefined;
+ }
+ }
+ }
+ return undefined;
+}
+
+function splitTopLevel(type: string, delimiter: string): string[] {
+ const parts: string[] = [];
+ let start = 0;
+ let angle = 0;
+ let square = 0;
+ let paren = 0;
+ for (let i = 0; i < type.length; i++) {
+ const char = type[i]!;
+ if (char === '<') angle++;
+ else if (char === '>') angle--;
+ else if (char === '[') square++;
+ else if (char === ']') square--;
+ else if (char === '(') paren++;
+ else if (char === ')') paren--;
+ else if (char === delimiter && angle === 0 && square === 0 && paren === 0) {
+ parts.push(type.slice(start, i));
+ start = i + 1;
+ }
+ }
+ parts.push(type.slice(start));
+ return parts;
+}
+
+function genericArgument(type: string, name: string): string | undefined {
+ const prefix = `${name}<`;
+ if (!type.startsWith(prefix) || !type.endsWith('>')) return undefined;
+ let depth = 0;
+ for (let i = name.length; i < type.length; i++) {
+ if (type[i] === '<') depth++;
+ else if (type[i] === '>') {
+ depth--;
+ if (depth === 0 && i !== type.length - 1) return undefined;
+ if (depth < 0) return undefined;
+ }
+ }
+ return depth === 0 ? type.slice(prefix.length, -1) : undefined;
+}
+
+/** Return a user-facing suffix when a Swift type cannot round-trip through
+ * Foundation JSON without custom encoding. The accepted grammar is deliberately
+ * narrow: JSON scalar values, arrays, string-keyed dictionaries, and Optional
+ * compositions of those types. */
+export function snapshotTypeIssue(typeText: string): string | undefined {
+ const type = typeText.replace(/\s+/g, '');
+ if (type.length === 0) return 'requires a JSON-compatible type';
+ if (type.endsWith('!')) {
+ return 'cannot use an implicitly unwrapped Optional; use T? instead';
+ }
+
+ if (type.endsWith('?')) {
+ const wrapped = type.slice(0, -1);
+ if (wrapped.endsWith('?')
+ || genericArgument(wrapped, 'Optional') !== undefined
+ || genericArgument(wrapped, 'Swift.Optional') !== undefined) {
+ return 'cannot use a nested Optional type';
+ }
+ return snapshotTypeIssue(wrapped);
+ }
+ const optional = genericArgument(type, 'Optional') ?? genericArgument(type, 'Swift.Optional');
+ if (optional !== undefined) {
+ if (optional.endsWith('?')
+ || genericArgument(optional, 'Optional') !== undefined
+ || genericArgument(optional, 'Swift.Optional') !== undefined) {
+ return 'cannot use a nested Optional type';
+ }
+ return snapshotTypeIssue(optional);
+ }
+
+ const scalar = type === 'CoreGraphics.CGFloat'
+ ? 'CGFloat'
+ : (type.startsWith('Swift.') ? type.slice('Swift.'.length) : type);
+ if (JSON_SCALAR_TYPES.has(scalar)) return undefined;
+
+ if (type.startsWith('[') && type.endsWith(']')) {
+ const inner = type.slice(1, -1);
+ const dictionaryParts = splitTopLevel(inner, ':');
+ if (dictionaryParts.length === 1) return snapshotTypeIssue(inner);
+ if (dictionaryParts.length === 2) {
+ const key = dictionaryParts[0]!.replace(/^Swift\./, '');
+ if (key !== 'String') return 'must use String keys for snapshot dictionaries';
+ return snapshotTypeIssue(dictionaryParts[1]!);
+ }
+ return 'requires a JSON-compatible array or dictionary type';
+ }
+
+ const array = genericArgument(type, 'Array') ?? genericArgument(type, 'Swift.Array');
+ if (array !== undefined) return snapshotTypeIssue(array);
+ const dictionary = genericArgument(type, 'Dictionary') ?? genericArgument(type, 'Swift.Dictionary');
+ if (dictionary !== undefined) {
+ const parts = splitTopLevel(dictionary, ',');
+ if (parts.length !== 2) return 'requires a JSON-compatible dictionary type';
+ const key = parts[0]!.replace(/^Swift\./, '');
+ if (key !== 'String') return 'must use String keys for snapshot dictionaries';
+ return snapshotTypeIssue(parts[1]!);
+ }
+
+ return `uses unsupported snapshot type '${typeText}'; use JSON scalar, array, or String-keyed dictionary types`;
+}
+
+export function validateAccessorSpecs(specs: AccessorSpec[]): void {
+ const owners = new Map();
+ const diagnostics: string[] = [];
+ for (const spec of specs) {
+ for (const field of spec.fields) {
+ const previous = owners.get(field.name);
+ if (previous !== undefined) {
+ diagnostics.push(`snapshot key '${field.name}' is declared by both ${previous} and ${spec.className}; keys must be unique across @Observable types`);
+ } else {
+ owners.set(field.name, spec.className);
+ }
+ }
+ }
+ if (diagnostics.length > 0) throw new AccessorGenerationError(diagnostics);
+}
+
+function swiftStringLiteral(value: string): string {
+ return JSON.stringify(value);
+}
+
+export function render(specs: AccessorSpec[], buildId: string, accessorHash: string): string {
+ validateAccessorSpecs(specs);
+ let out = '// AUTO-GENERATED — DO NOT EDIT. Regenerate with /ios-sync.\n';
+ out += '#if DEBUG\nimport Foundation\nimport DebugBridgeCore\n\n';
+ for (const spec of specs) {
+ // Accessors compile in the app target beside its usually-internal state
+ // types. Making this API public would make valid internal models fail
+ // Swift type checking (a public signature cannot expose an internal type).
+ out += `@MainActor\nenum ${spec.className}Accessor {\n`;
+ out += ` private static func decodeSnapshotValue(_ value: Any, as type: T.Type) -> T? {\n`;
+ out += ` guard JSONSerialization.isValidJSONObject(["value": value]),\n`;
+ out += ` let data = try? JSONSerialization.data(withJSONObject: ["value": value]),\n`;
+ out += ` let decoded = try? JSONDecoder().decode([String: T].self, from: data) else { return nil }\n`;
+ out += ` return decoded["value"]\n`;
+ out += ` }\n\n`;
+ out += ` static func register(_ state: ${spec.className}) {\n`;
out += ` StateServer.shared.register(\n`;
- out += ` buildId: "${buildId}",\n`;
- out += ` accessorHash: "${accessorHash}",\n`;
- out += ` atomicRestore: { _ in .ok }\n`;
+ out += ` buildId: {\n`;
+ out += ` let shortVersion = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String\n`;
+ out += ` let bundleVersion = Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String\n`;
+ out += ` if let shortVersion, let bundleVersion { return "\\(shortVersion) (\\(bundleVersion))" }\n`;
+ out += ` return shortVersion ?? bundleVersion ?? ${swiftStringLiteral(buildId)}\n`;
+ out += ` }(),\n`;
+ out += ` accessorHash: ${swiftStringLiteral(accessorHash)},\n`;
+ out += ` atomicRestore: { keys, apply in\n`;
+ out += ` // Validate every key and value before assignment.\n`;
+ out += ` // Successful assignments are sequential on MainActor.\n`;
+ spec.fields.forEach((field, index) => {
+ out += ` guard let raw${index} = keys["${field.name}"] else {\n`;
+ out += ` return .missingKey("${field.name}")\n`;
+ out += ` }\n`;
+ const wrapped = optionalWrappedType(field.typeText);
+ if (wrapped !== undefined) {
+ out += ` let restored${index}: ${field.typeText}\n`;
+ out += ` if raw${index} is NSNull {\n`;
+ out += ` restored${index} = nil\n`;
+ out += ` } else if let typed = Self.decodeSnapshotValue(raw${index}, as: ${wrapped}.self) {\n`;
+ out += ` restored${index} = typed\n`;
+ out += ` } else {\n`;
+ out += ` return .typeMismatch("${field.name}")\n`;
+ out += ` }\n`;
+ } else {
+ out += ` guard let restored${index} = Self.decodeSnapshotValue(raw${index}, as: ${field.typeText}.self) else {\n`;
+ out += ` return .typeMismatch("${field.name}")\n`;
+ out += ` }\n`;
+ }
+ });
+ out += ` if apply {\n`;
+ spec.fields.forEach((field, index) => {
+ out += ` state.${field.name} = restored${index}\n`;
+ });
+ out += ` }\n`;
+ out += ` return .ok\n`;
+ out += ` }\n`;
out += ` )\n`;
for (const field of spec.fields) {
+ const wrapped = optionalWrappedType(field.typeText);
out += ` StateServer.shared.registerAccessor(\n`;
out += ` key: "${field.name}",\n`;
out += ` type: "${field.typeText}",\n`;
- out += ` read: { state.${field.name} as Any? },\n`;
- out += ` write: { _ in false }\n`;
+ if (wrapped !== undefined) {
+ out += ` read: {\n`;
+ out += ` guard let value = state.${field.name} else { return NSNull() }\n`;
+ out += ` return value as Any\n`;
+ out += ` },\n`;
+ } else {
+ out += ` read: { state.${field.name} as Any? },\n`;
+ }
+ out += ` write: { value in\n`;
+ if (wrapped !== undefined) {
+ out += ` if value is NSNull {\n`;
+ out += ` state.${field.name} = nil\n`;
+ out += ` return true\n`;
+ out += ` }\n`;
+ out += ` guard let typed = Self.decodeSnapshotValue(value, as: ${wrapped}.self) else { return false }\n`;
+ } else {
+ out += ` guard let typed = Self.decodeSnapshotValue(value, as: ${field.typeText}.self) else { return false }\n`;
+ }
+ out += ` state.${field.name} = typed\n`;
+ out += ` return true\n`;
+ out += ` }\n`;
out += ` )\n`;
}
out += ` }\n}\n\n`;
@@ -215,6 +760,15 @@ function detectToolGitRev(): string {
}
}
+function detectBuildId(): string {
+ if (process.env.APP_BUILD_ID) return process.env.APP_BUILD_ID;
+ const marketing = process.env.MARKETING_VERSION;
+ const build = process.env.CURRENT_PROJECT_VERSION;
+ if (marketing && build) return `${marketing} (${build})`;
+ if (build) return build;
+ return 'unknown';
+}
+
export function defaultCacheRoot(): string {
return process.env.GSTACK_IOS_CACHE_ROOT ?? join(homedir(), '.gstack', 'cache', 'gen-accessors');
}
@@ -223,13 +777,25 @@ export function generate(inputs: GenInputs): GenResult {
const inputDir = resolve(inputs.inputDir);
const outputDir = resolve(inputs.outputDir ?? inputDir);
const cacheRoot = inputs.cacheRoot ?? defaultCacheRoot();
- const swiftFiles = collectSwiftFiles(inputDir);
+ const swiftFiles = collectSwiftFiles(inputDir, { outputDir });
+ const buildId = inputs.buildId ?? detectBuildId();
+
+ // Parse before cache lookup. This keeps diagnostics deterministic even when
+ // an older cache entry exists, and gives us the schema-only accessor hash.
+ const allSpecs: AccessorSpec[] = [];
+ for (const f of swiftFiles) {
+ const src = readFileSync(f, 'utf-8');
+ allSpecs.push(...parseSwift(src));
+ }
+ validateAccessorSpecs(allSpecs);
+ const accessorHash = computeAccessorHash(allSpecs);
const cacheKey = computeCacheKey({
swiftFiles,
swiftVersion: inputs.swiftVersion ?? detectSwiftVersion(),
toolGitRev: inputs.toolGitRev ?? detectToolGitRev(),
platformTriple: inputs.platformTriple ?? FALLBACK_PLATFORM,
+ buildId,
});
const cachedOutput = join(cacheRoot, cacheKey, 'StateAccessor.swift');
@@ -242,18 +808,13 @@ export function generate(inputs: GenInputs): GenResult {
return {
outputPath: finalOutput,
cacheKey,
- specs: [], // intentionally empty on cache hit (no need to re-parse)
+ accessorHash,
+ specs: allSpecs,
cacheHit: true,
};
}
- // Parse + render fresh
- const allSpecs: AccessorSpec[] = [];
- for (const f of swiftFiles) {
- const src = readFileSync(f, 'utf-8');
- allSpecs.push(...parseSwift(src));
- }
- const rendered = render(allSpecs, inputs.buildId ?? 'unknown', cacheKey);
+ const rendered = render(allSpecs, buildId, accessorHash);
writeFileSync(finalOutput, rendered);
// Populate cache (best-effort — cache failures don't break codegen).
@@ -267,6 +828,7 @@ export function generate(inputs: GenInputs): GenResult {
return {
outputPath: finalOutput,
cacheKey,
+ accessorHash,
specs: allSpecs,
cacheHit: false,
};
@@ -300,10 +862,18 @@ if (import.meta.main) {
const inputDir = args[inputIdx + 1]!;
const outputIdx = args.indexOf('--output');
const outputDir = outputIdx !== -1 ? args[outputIdx + 1] : undefined;
- const result = generate({ inputDir, outputDir });
- process.stdout.write(
- result.cacheHit
- ? `gen-accessors: cache hit (${result.cacheKey.slice(0, 12)})\n`
- : `gen-accessors: wrote ${result.specs.length} accessor(s) to ${result.outputPath}\n`,
- );
+ try {
+ const result = generate({ inputDir, outputDir });
+ process.stdout.write(
+ result.cacheHit
+ ? `gen-accessors: cache hit (${result.cacheKey.slice(0, 12)})\n`
+ : `gen-accessors: wrote ${result.specs.length} accessor(s) to ${result.outputPath}\n`,
+ );
+ } catch (error) {
+ if (error instanceof AccessorGenerationError) {
+ process.stderr.write(`${error.message}\n`);
+ process.exit(4);
+ }
+ throw error;
+ }
}
diff --git a/ios-qa/templates/Bridges.swift.template b/ios-qa/templates/Bridges.swift.template
index bf7af6e3f..4a91142de 100644
--- a/ios-qa/templates/Bridges.swift.template
+++ b/ios-qa/templates/Bridges.swift.template
@@ -27,12 +27,35 @@ public enum DebugBridgeUIWiring {
/// times reinstalls the same closures. Must be called on @MainActor
/// because every UIKit access requires the main actor.
public static func installAll() {
+ // KIF turns on accessibility automation before walking SwiftUI's AX
+ // tree. Without it SwiftUI exposes only the hosting shell and taps
+ // can report success without invoking Button.action.
+ DebugBridgeTouch.prepareForAutomation()
ScreenshotBridge.resolver = { ScreenshotBridgeImpl.capturePNG() }
ElementsBridge.resolver = { ElementsBridgeImpl.snapshot() }
MutationBridge.resolver = { op, payload in MutationBridgeImpl.dispatch(op: op, payload: payload) }
}
}
+/// Return the children UIKit exposes specifically to accessibility automation.
+/// iOS 17 added `automationElements`; unlike the older container APIs it
+/// preserves identified SwiftUI descendants inside accessibility groups.
+@MainActor
+private func debugBridgeAccessibilityChildren(of element: NSObject) -> [NSObject] {
+ if #available(iOS 17.0, *),
+ let automation = element.automationElements,
+ !automation.isEmpty {
+ return automation.compactMap { $0 as? NSObject }
+ }
+ if let accessibility = element.accessibilityElements,
+ !accessibility.isEmpty {
+ return accessibility.compactMap { $0 as? NSObject }
+ }
+ let count = element.accessibilityElementCount()
+ guard count > 0, count < 512 else { return [] }
+ return (0.. Data? {
guard let scene = activeScene(), let window = activeKeyWindow(in: scene) else { return nil }
let bounds = window.bounds
- let renderer = UIGraphicsImageRenderer(bounds: bounds)
+ let format = UIGraphicsImageRendererFormat.default()
+ // /tap consumes UIKit window points. Render at 1x so screenshot pixels
+ // use that same coordinate space on 2x/3x devices.
+ format.scale = 1
+ let renderer = UIGraphicsImageRenderer(bounds: bounds, format: format)
let image = renderer.image { _ in
// drawHierarchy is the documented way to snapshot real UIKit
// layers including layer-backed views. afterScreenUpdates: false
@@ -61,7 +88,10 @@ enum ScreenshotBridgeImpl {
}
private static func activeKeyWindow(in scene: UIWindowScene) -> UIWindow? {
- scene.windows.first(where: { $0.isKeyWindow }) ?? scene.windows.first
+ let windows = scene.windows.filter { window in
+ !window.isHidden && !String(describing: type(of: window)).contains("PassThroughWindow")
+ }
+ return windows.first(where: { $0.isKeyWindow }) ?? windows.max(by: { $0.windowLevel < $1.windowLevel })
}
}
@@ -76,21 +106,40 @@ enum ElementsBridgeImpl {
static func snapshot() -> [JSONDict] {
guard let scene = activeScene(), let window = activeKeyWindow(in: scene) else { return [] }
var elements: [JSONDict] = []
- collect(view: window, parentPath: "", windowBounds: window.bounds, into: &elements)
+ var visited = Set()
+ var remaining = 2_048
+ collect(
+ view: window,
+ parentPath: "",
+ window: window,
+ visited: &visited,
+ remaining: &remaining,
+ into: &elements
+ )
return elements
}
- private static func collect(view: UIView, parentPath: String, windowBounds: CGRect, into elements: inout [JSONDict]) {
+ private static func collect(
+ view: UIView,
+ parentPath: String,
+ window: UIWindow,
+ visited: inout Set,
+ remaining: inout Int,
+ into elements: inout [JSONDict]
+ ) {
+ guard remaining > 0, visited.insert(ObjectIdentifier(view)).inserted else { return }
+ remaining -= 1
+
// Skip hidden / zero-size / off-screen subtrees early.
if view.isHidden || view.alpha < 0.01 { return }
- let frameInWindow = view.convert(view.bounds, to: nil)
- if !windowBounds.intersects(frameInWindow) { return }
+ let frameInWindow = view.convert(view.bounds, to: window)
+ if !window.bounds.intersects(frameInWindow) { return }
let isAccessible = view.isAccessibilityElement
let label = view.accessibilityLabel ?? ""
let identifier = view.accessibilityIdentifier ?? ""
- let traits = Int(view.accessibilityTraits.rawValue)
+ let traits = NSNumber(value: view.accessibilityTraits.rawValue)
let value = (view.accessibilityValue ?? "") as String
let className = String(describing: type(of: view))
let path = parentPath.isEmpty ? className : "\(parentPath) > \(className)"
@@ -120,66 +169,98 @@ enum ElementsBridgeImpl {
])
}
- // Recurse into accessibility-elements first (some custom views vend
- // synthetic children), then UIView subviews. SwiftUI's host views
- // populate accessibilityElements lazily — many return nil before
- // VoiceOver triggers them. Force population by reading accessibilityElementCount.
- _ = view.accessibilityElementCount()
- if let axElements = view.accessibilityElements {
- for case let element as NSObject in axElements {
- if let v = element as? UIView {
- collect(view: v, parentPath: path, windowBounds: windowBounds, into: &elements)
- } else {
- // Synthetic accessibility element (no UIView). Capture frame in screen coords.
- let af = (element.value(forKey: "accessibilityFrame") as? CGRect) ?? .zero
- elements.append([
- "path": "\(path) > ",
- "class": "AccessibilityElement",
- "label": (element.value(forKey: "accessibilityLabel") as? String) ?? "",
- "identifier": (element.value(forKey: "accessibilityIdentifier") as? String) ?? "",
- "value": (element.value(forKey: "accessibilityValue") as? String) ?? "",
- "traits": (element.value(forKey: "accessibilityTraits") as? NSNumber)?.intValue ?? 0,
- "frame": [
- "x": Int(af.origin.x),
- "y": Int(af.origin.y),
- "w": Int(af.size.width),
- "h": Int(af.size.height),
- ],
- "is_user_interaction_enabled": true,
- ])
- }
- }
- } else {
- // accessibilityElements is nil — iterate by index. SwiftUI uses
- // this dynamic protocol pattern; many AX elements only respond
- // to accessibilityElementCount + accessibilityElement(at:).
- let count = view.accessibilityElementCount()
- for i in 0.. ",
- "class": String(describing: type(of: element)),
- "label": (element.value(forKey: "accessibilityLabel") as? String) ?? "",
- "identifier": (element.value(forKey: "accessibilityIdentifier") as? String) ?? "",
- "value": (element.value(forKey: "accessibilityValue") as? String) ?? "",
- "traits": (element.value(forKey: "accessibilityTraits") as? NSNumber)?.intValue ?? 0,
- "frame": [
- "x": Int(af.origin.x),
- "y": Int(af.origin.y),
- "w": Int(af.size.width),
- "h": Int(af.size.height),
- ],
- "is_user_interaction_enabled": true,
- ])
- }
+ // Walk automation children before raw subviews. On iOS 17+ this
+ // exposes identified SwiftUI controls nested inside GroupBox/List.
+ for (index, element) in debugBridgeAccessibilityChildren(of: view).enumerated() {
+ if let child = element as? UIView {
+ collect(
+ view: child,
+ parentPath: path,
+ window: window,
+ visited: &visited,
+ remaining: &remaining,
+ into: &elements
+ )
+ } else {
+ appendSynthetic(
+ element,
+ path: "\(path) > ",
+ window: window,
+ visited: &visited,
+ remaining: &remaining,
+ into: &elements
+ )
}
}
for sub in view.subviews {
- collect(view: sub, parentPath: path, windowBounds: windowBounds, into: &elements)
+ collect(
+ view: sub,
+ parentPath: path,
+ window: window,
+ visited: &visited,
+ remaining: &remaining,
+ into: &elements
+ )
+ }
+ }
+
+ private static func appendSynthetic(
+ _ element: NSObject,
+ path: String,
+ window: UIWindow,
+ visited: inout Set,
+ remaining: inout Int,
+ into elements: inout [JSONDict]
+ ) {
+ guard remaining > 0, visited.insert(ObjectIdentifier(element)).inserted else { return }
+ remaining -= 1
+
+ let screenFrame = (element.value(forKey: "accessibilityFrame") as? CGRect) ?? .zero
+ let frame = window.coordinateSpace.convert(screenFrame, from: window.screen.coordinateSpace)
+ let label = (element.value(forKey: "accessibilityLabel") as? String) ?? ""
+ let identifier = (element.value(forKey: "accessibilityIdentifier") as? String) ?? ""
+ let value = (element.value(forKey: "accessibilityValue") as? String) ?? ""
+ let traits = (element.value(forKey: "accessibilityTraits") as? NSNumber)?.uint64Value ?? 0
+ if !label.isEmpty || !identifier.isEmpty || !value.isEmpty || traits != 0 {
+ elements.append([
+ "path": path,
+ "class": String(describing: type(of: element)),
+ "label": label,
+ "identifier": identifier,
+ "value": value,
+ "traits": NSNumber(value: traits),
+ "frame": [
+ "x": Int(frame.origin.x),
+ "y": Int(frame.origin.y),
+ "w": Int(frame.size.width),
+ "h": Int(frame.size.height),
+ ],
+ "is_user_interaction_enabled": true,
+ ])
+ }
+
+ // Synthetic SwiftUI nodes are themselves accessibility containers.
+ // Recurse even when this grouping node has no metadata of its own.
+ for (index, child) in debugBridgeAccessibilityChildren(of: element).enumerated() {
+ if let childView = child as? UIView {
+ collect(
+ view: childView,
+ parentPath: path,
+ window: window,
+ visited: &visited,
+ remaining: &remaining,
+ into: &elements
+ )
+ } else {
+ appendSynthetic(
+ child,
+ path: "\(path) > ",
+ window: window,
+ visited: &visited,
+ remaining: &remaining,
+ into: &elements
+ )
+ }
}
}
@@ -191,7 +272,10 @@ enum ElementsBridgeImpl {
}
private static func activeKeyWindow(in scene: UIWindowScene) -> UIWindow? {
- scene.windows.first(where: { $0.isKeyWindow }) ?? scene.windows.first
+ let windows = scene.windows.filter { window in
+ !window.isHidden && !String(describing: type(of: window)).contains("PassThroughWindow")
+ }
+ return windows.first(where: { $0.isKeyWindow }) ?? windows.max(by: { $0.windowLevel < $1.windowLevel })
}
}
@@ -210,21 +294,62 @@ enum MutationBridgeImpl {
}
}
- /// Tap at (x, y) in window coordinates. Delegates to DebugBridgeTouch
- /// (KIF-derived in-process touch synthesis). The Obj-C target builds a
- /// real UITouch + IOHIDEvent + UIEvent and dispatches via
- /// `UIApplication.sendEvent`, which is what UIKit uses for real touches.
- /// This works for UIControl, SwiftUI Button (via iOS 18+
- /// `_UIHitTestContext`), gesture recognizers, and anything else that
- /// listens to the real event-dispatch path.
+ /// Tap at (x, y) in window coordinates. Prefer accessibility activation,
+ /// which is stable for SwiftUI buttons across OS releases, then fall back
+ /// to KIF-derived UITouch synthesis for gesture-only/custom controls.
private static func handleTap(_ payload: JSONDict) -> Bool {
guard let x = payload["x"] as? NSNumber,
let y = payload["y"] as? NSNumber else { return false }
let point = CGPoint(x: x.doubleValue, y: y.doubleValue)
guard let scene = activeScene(), let window = activeKeyWindow(in: scene) else { return false }
+ if let element = findActivatableAXElement(at: point, in: window),
+ element.accessibilityActivate() {
+ return true
+ }
return DebugBridgeTouch.sendTap(at: point, in: window)
}
+ private static func findActivatableAXElement(at point: CGPoint, in window: UIWindow) -> NSObject? {
+ let screenPoint = window.screen.coordinateSpace.convert(point, from: window.coordinateSpace)
+ var best: NSObject?
+ var bestArea: CGFloat = .infinity
+ var visited = Set()
+ var remaining = 2_048
+
+ func consider(frame: CGRect, traits: UInt64, element: NSObject) {
+ guard frame.contains(screenPoint),
+ (traits & UIAccessibilityTraits.button.rawValue) != 0 else { return }
+ let area = frame.width * frame.height
+ if area > 0 && area < bestArea {
+ best = element
+ bestArea = area
+ }
+ }
+
+ func visit(_ element: NSObject) {
+ guard remaining > 0, visited.insert(ObjectIdentifier(element)).inserted else { return }
+ remaining -= 1
+
+ if let view = element as? UIView {
+ guard !view.isHidden, view.alpha >= 0.01,
+ view.convert(view.bounds, to: window).contains(point) else { return }
+ if view.isAccessibilityElement {
+ consider(frame: view.accessibilityFrame, traits: view.accessibilityTraits.rawValue, element: view)
+ }
+ for child in debugBridgeAccessibilityChildren(of: view) { visit(child) }
+ for child in view.subviews { visit(child) }
+ } else {
+ let frame = (element.value(forKey: "accessibilityFrame") as? CGRect) ?? .zero
+ let traits = (element.value(forKey: "accessibilityTraits") as? NSNumber)?.uint64Value ?? 0
+ consider(frame: frame, traits: traits, element: element)
+ for child in debugBridgeAccessibilityChildren(of: element) { visit(child) }
+ }
+ }
+
+ visit(window)
+ return best
+ }
+
/// Set text on the first responder if it's a UITextField or UITextView.
private static func handleType(_ payload: JSONDict) -> Bool {
guard let text = payload["text"] as? String else { return false }
@@ -266,7 +391,10 @@ enum MutationBridgeImpl {
var off = scroll.contentOffset
off.x = max(0, min(scroll.contentSize.width - scroll.bounds.width, off.x + dx))
off.y = max(0, min(scroll.contentSize.height - scroll.bounds.height, off.y + dy))
- scroll.setContentOffset(off, animated: true)
+ // Automation commands return synchronously; do not report
+ // success while the target is still moving underneath the
+ // next tap coordinate.
+ scroll.setContentOffset(off, animated: false)
return true
}
node = cur.superview
@@ -301,7 +429,10 @@ enum MutationBridgeImpl {
}
private static func activeKeyWindow(in scene: UIWindowScene) -> UIWindow? {
- scene.windows.first(where: { $0.isKeyWindow }) ?? scene.windows.first
+ let windows = scene.windows.filter { window in
+ !window.isHidden && !String(describing: type(of: window)).contains("PassThroughWindow")
+ }
+ return windows.first(where: { $0.isKeyWindow }) ?? windows.max(by: { $0.windowLevel < $1.windowLevel })
}
}
diff --git a/ios-qa/templates/DebugBridgeManager.swift.template b/ios-qa/templates/DebugBridgeManager.swift.template
index e18e2fb05..8dfb96d88 100644
--- a/ios-qa/templates/DebugBridgeManager.swift.template
+++ b/ios-qa/templates/DebugBridgeManager.swift.template
@@ -14,12 +14,16 @@ import Foundation
public final class DebugBridgeManager {
public static let shared = DebugBridgeManager()
- public func start(appState: AppState) {
- // 1. Register the canonical AppState struct + accessor wiring.
- // AppStateAccessor.register(_:) is generated by gen-accessors-tool.
- AppStateAccessor.register(appState)
+ /// Register app-owned generated accessors, then start the server. The
+ /// registration closure is passed in from the consuming app because the
+ /// DebugBridgeCore package cannot import app-target types. On UIKit apps,
+ /// call DebugBridgeUIWiring.installAll() before this method so a warm
+ /// daemon cannot reach uninitialized resolvers during listener startup.
+ public func start(appState: State, register: (State) -> Void) {
+ register(appState)
- // 2. Boot the StateServer.
+ // Boot only after registration so the first snapshot has a real build
+ // id, schema hash, and key set.
StateServer.shared.start()
// 3. The consuming app installs DebugOverlayWindow separately. See
@@ -31,19 +35,4 @@ public final class DebugBridgeManager {
}
}
-// Placeholder. gen-accessors-tool emits the real `AppStateAccessor` enum next
-// to the app's canonical state struct. Apps that haven't run codegen get a
-// stub that registers no accessors (snapshot is empty, restore returns
-// missing-key for every key).
-@MainActor
-public enum AppStateAccessor {
- public static var register: (Any) -> Void = { _ in }
-}
-
-// Apps declare their canonical state struct; codegen reads it and emits
-// AppStateAccessor.register. The app's struct must be `@Observable` and
-// must hold all snapshot-eligible state in `@Snapshotable`-marked fields.
-@MainActor
-public protocol AppState: AnyObject {}
-
#endif // DEBUG
diff --git a/ios-qa/templates/DebugBridgeTouch.h.template b/ios-qa/templates/DebugBridgeTouch.h.template
index 1f85c1211..86a2ddaa8 100644
--- a/ios-qa/templates/DebugBridgeTouch.h.template
+++ b/ios-qa/templates/DebugBridgeTouch.h.template
@@ -23,6 +23,10 @@ NS_ASSUME_NONNULL_BEGIN
@interface DebugBridgeTouch : NSObject
+/// Enable the in-process accessibility automation tree used by SwiftUI and
+/// UIKit. Call once while installing the debug bridge, before /elements.
++ (void)prepareForAutomation;
+
/// Synthesize a single tap (TouchPhaseBegan + TouchPhaseEnded) at the given
/// window-coordinate point. Returns YES if the touch was delivered (a hit
/// view was found and the event passed through UIApplication.sendEvent).
diff --git a/ios-qa/templates/DebugBridgeTouch.m.template b/ios-qa/templates/DebugBridgeTouch.m.template
index 7f7b7d1a3..aa6954c6d 100644
--- a/ios-qa/templates/DebugBridgeTouch.m.template
+++ b/ios-qa/templates/DebugBridgeTouch.m.template
@@ -126,6 +126,36 @@ static BOOL DBT_LoadIOKit(void) {
return _IOKitLoaded;
}
+// KIF enables accessibility automation before it asks UIKit/SwiftUI for the
+// accessibility tree. Without this switch SwiftUI exposes only its hosting
+// shell: /elements has no controls and a synthesized tap can return YES while
+// Button.action never fires. Keep this DEBUG-only with the rest of this file.
+typedef int (*DBTAXSAutomationEnabledFn)(void);
+typedef void (*DBTAXSSetAutomationEnabledFn)(int);
+static DBTAXSAutomationEnabledFn _DBTAXSAutomationEnabled;
+static DBTAXSSetAutomationEnabledFn _DBTAXSSetAutomationEnabled;
+static int _DBTInitialAutomationState = -1;
+
+static void DBT_RestoreAccessibilityAutomation(void) {
+ if (_DBTAXSSetAutomationEnabled && _DBTInitialAutomationState >= 0) {
+ _DBTAXSSetAutomationEnabled(_DBTInitialAutomationState);
+ }
+}
+
+static void DBT_EnableAccessibilityAutomation(void) {
+ static dispatch_once_t onceToken;
+ dispatch_once(&onceToken, ^{
+ void *handle = dlopen("/usr/lib/libAccessibility.dylib", RTLD_NOW | RTLD_LOCAL);
+ if (!handle) return;
+ _DBTAXSAutomationEnabled = (DBTAXSAutomationEnabledFn)dlsym(handle, "_AXSAutomationEnabled");
+ _DBTAXSSetAutomationEnabled = (DBTAXSSetAutomationEnabledFn)dlsym(handle, "_AXSSetAutomationEnabled");
+ if (!_DBTAXSAutomationEnabled || !_DBTAXSSetAutomationEnabled) return;
+ _DBTInitialAutomationState = _DBTAXSAutomationEnabled();
+ if (!_DBTInitialAutomationState) _DBTAXSSetAutomationEnabled(1);
+ atexit(DBT_RestoreAccessibilityAutomation);
+ });
+}
+
static IOHIDEventRef DBT_IOHIDEventWithTouch(UITouch *touch) CF_RETURNS_RETAINED;
static IOHIDEventRef DBT_IOHIDEventWithTouch(UITouch *touch) {
if (!DBT_LoadIOKit()) return NULL;
@@ -172,6 +202,7 @@ static IOHIDEventRef DBT_IOHIDEventWithTouch(UITouch *touch) {
- (void)setGestureView:(UIView *)view;
- (void)_setLocationInWindow:(CGPoint)location resetPrevious:(BOOL)resetPrevious;
- (void)_setIsFirstTouchForView:(BOOL)firstTouchForView;
+- (void)_setIsTapToClick:(BOOL)tapToClick;
- (void)_setHidEvent:(IOHIDEventRef)event;
@end
@@ -229,6 +260,10 @@ static id DBT_HitTestView(UIWindow *window, CGPoint point) {
@implementation DebugBridgeTouch
++ (void)prepareForAutomation {
+ DBT_EnableAccessibilityAutomation();
+}
+
+ (BOOL)sendTapAtPoint:(CGPoint)point inWindow:(UIWindow *)window {
if (!window) return NO;
@@ -247,10 +282,17 @@ static id DBT_HitTestView(UIWindow *window, CGPoint point) {
[touch setPhase:UITouchPhaseBegan];
if ([touch respondsToSelector:@selector(_setIsFirstTouchForView:)]) {
[touch _setIsFirstTouchForView:YES];
+ } else if ([touch respondsToSelector:@selector(_setIsTapToClick:)]) {
+ [touch _setIsTapToClick:YES];
+ Ivar flagsIvar = class_getInstanceVariable([UITouch class], "_touchFlags");
+ if (flagsIvar) {
+ ptrdiff_t offset = ivar_getOffset(flagsIvar);
+ char *flags = (__bridge void *)touch + offset;
+ *flags |= (char)0x01;
+ }
}
[touch setTimestamp:[[NSProcessInfo processInfo] systemUptime]];
- if ([touch respondsToSelector:@selector(setGestureView:)] &&
- [hit isKindOfClass:[UIView class]]) {
+ if ([touch respondsToSelector:@selector(setGestureView:)]) {
[touch setGestureView:(UIView *)hit];
}
diff --git a/ios-qa/templates/DebugBridgeWiring.swift.template b/ios-qa/templates/DebugBridgeWiring.swift.template
index 009a70861..bf7016d87 100644
--- a/ios-qa/templates/DebugBridgeWiring.swift.template
+++ b/ios-qa/templates/DebugBridgeWiring.swift.template
@@ -6,22 +6,35 @@
// the DebugBridge target.
#if DEBUG
-import DebugBridge
+import Foundation
+import DebugBridgeCore
+#if canImport(UIKit)
+import DebugBridgeUI
+#endif
@MainActor
-func startGstackDebugBridge(appState: AppState) {
+func startGstackDebugBridge(
+ appState: State,
+ register: (State) -> Void
+) {
// Read --recording flag from launch arguments
let recording = ProcessInfo.processInfo.arguments.contains("--gstack-recording")
- // Install accessibility + screenshot + mutation bridges before starting
- // the server so the first authenticated request can use them.
- ElementsBridge.resolver = { AccessibilityScanner.snapshot() }
- ScreenshotBridge.resolver = { SnapshotCapture.capturePNG() }
- MutationBridge.resolver = { op, payload in
- MutationDispatcher.shared.run(op: op, payload: payload)
- }
+ // Install the UI resolvers before opening the listener. A warm daemon can
+ // issue its first request as soon as StateServer starts; it must never see
+ // the default empty screenshot/elements/mutation handlers.
+ #if canImport(UIKit)
+ DebugBridgeUIWiring.installAll()
+ #endif
- DebugBridgeManager.shared.start(appState: appState, recording: recording)
+ // Generated typed accessors live in the app target, so pass their register
+ // function into the package instead of asking DebugBridgeCore to import
+ // app-owned types.
+ DebugBridgeManager.shared.start(appState: appState, register: register)
+
+ #if canImport(UIKit)
+ DebugOverlayWindow.shared.install(recording: recording)
+ #endif
}
#endif
@@ -33,7 +46,10 @@ func startGstackDebugBridge(appState: AppState) {
//
// init() {
// #if DEBUG
-// startGstackDebugBridge(appState: appState)
+// startGstackDebugBridge(
+// appState: appState,
+// register: MyAppStateAccessor.register
+// )
// #endif
// }
//
diff --git a/ios-qa/templates/Package.swift.template b/ios-qa/templates/Package.swift.template
index 88d6bd319..427a3399d 100644
--- a/ios-qa/templates/Package.swift.template
+++ b/ios-qa/templates/Package.swift.template
@@ -1,3 +1,4 @@
+// swift-tools-version:5.9
// AUTO-GENERATED from gstack/ios-qa/templates/Package.swift.template
//
// Drop-in SPM package definition for the DebugBridge stack. Three targets:
@@ -18,7 +19,6 @@
// CI invariant: `swift build -c release` + `nm -j build/Release/
// | grep -q DebugBridge && exit 1`.
-// swift-tools-version:5.9
import PackageDescription
let package = Package(
@@ -58,10 +58,5 @@ let package = Package(
.define("DEBUG", .when(configuration: .debug)),
]
),
- .testTarget(
- name: "DebugBridgeCoreTests",
- dependencies: ["DebugBridgeCore"],
- path: "Tests/DebugBridgeCoreTests"
- ),
]
)
diff --git a/ios-qa/templates/StateAccessor.swift.template b/ios-qa/templates/StateAccessor.swift.template
index 07de99e19..a9a87b6f8 100644
--- a/ios-qa/templates/StateAccessor.swift.template
+++ b/ios-qa/templates/StateAccessor.swift.template
@@ -3,12 +3,12 @@
//
// This file is a TEMPLATE that gen-accessors-tool fills in. The placeholders
// are filled per-class from swift-syntax AST inspection of the app's
-// @Observable types. Only properties marked with @Snapshotable are emitted.
+// @Observable types. Only properties preceded by `// @Snapshotable` are emitted.
//
// {{CLASS_NAME}} — the canonical AppState struct name
// {{APP_BUILD_ID}} — bundle short version + git SHA at codegen time
// {{ACCESSOR_HASH}} — sha256 of accessor signatures (snapshot schema fingerprint)
-// {{ACCESSORS}} — generated register/read/write blocks per @Snapshotable field
+// {{ACCESSORS}} — generated register/read/write blocks per marked field
#if DEBUG
@@ -21,13 +21,16 @@ public enum {{CLASS_NAME}}Accessor {
StateServer.shared.register(
buildId: "{{APP_BUILD_ID}}",
accessorHash: "{{ACCESSOR_HASH}}",
- atomicRestore: { keys in
- // Validate every key + type FIRST, then apply in one struct
- // assignment so SwiftUI observers see exactly one change.
+ atomicRestore: { keys, apply in
+ // Validate every key + type before mutating any state. Invalid
+ // input therefore cannot leave a partially restored model.
var snapshot = state.snapshotable
{{VALIDATION_BLOCK}}
- // Apply atomically.
- state.snapshotable = snapshot
+ // StateServer validates every model first, then invokes the
+ // same handlers with apply=true for a cross-model commit.
+ if apply {
+ state.snapshotable = snapshot
+ }
return .ok
}
)
diff --git a/ios-qa/templates/StateServer.swift.template b/ios-qa/templates/StateServer.swift.template
index 803bedf31..b8441df16 100644
--- a/ios-qa/templates/StateServer.swift.template
+++ b/ios-qa/templates/StateServer.swift.template
@@ -59,18 +59,20 @@ public final class StateServer {
private var writeHandlers: [String: WriteHandler] = [:]
private var typeNames: [String: TypeName] = [:]
- // Atomic-restore hook. Codegen wires this to the canonical AppState struct.
- // Restore replaces the entire struct in one assignment so SwiftUI's Combine
- // pipeline observes exactly one change notification — true observable
- // atomicity. @MainActor alone doesn't guarantee that.
- public typealias AtomicRestoreFn = (JSONDict) -> RestoreResult
+ // Validated-restore hooks. Every generated model registers one two-phase
+ // handler. The server validates all models before it lets any model mutate,
+ // so invalid input can never cause a cross-model partial restore.
+ // Valid restores apply properties on MainActor; observers may receive one
+ // change notification per property because arbitrary @Observable models do
+ // not expose a general single-assignment transaction API.
+ public typealias AtomicRestoreFn = (JSONDict, Bool) -> RestoreResult
public enum RestoreResult {
case ok
case missingKey(String)
case typeMismatch(String)
case schemaMismatch(expected: String, got: String)
}
- private var atomicRestore: AtomicRestoreFn?
+ private var atomicRestores: [AtomicRestoreFn] = []
// Snapshot schema hash — written by codegen, stable across builds with
// identical accessor signatures.
@@ -109,7 +111,7 @@ public final class StateServer {
public func register(buildId: String, accessorHash: String, atomicRestore: @escaping AtomicRestoreFn) {
self.appBuildId = buildId
self.accessorHash = accessorHash
- self.atomicRestore = atomicRestore
+ self.atomicRestores.append(atomicRestore)
}
public func registerAccessor(key: String, type: String, read: @escaping ReadHandler, write: @escaping WriteHandler) {
@@ -284,6 +286,7 @@ public final class StateServer {
"version": "1.0.0",
"build": appBuildId,
"accessor_hash": accessorHash,
+ "bundle_id": Bundle.main.bundleIdentifier ?? "unknown",
])
return
}
@@ -476,22 +479,36 @@ public final class StateServer {
send(connection: connection, status: 400, body: ["error": "missing_keys"])
return
}
- guard let restore = atomicRestore else {
+ guard !atomicRestores.isEmpty else {
send(connection: connection, status: 503, body: ["error": "atomic_restore_not_registered"])
return
}
- // Validate-then-apply via the codegen-supplied closure. The closure does
- // a single struct-assignment so SwiftUI sees one change notification.
- switch restore(keys) {
- case .ok:
- send(connection: connection, status: 200, body: ["ok": true])
- case .missingKey(let k):
- send(connection: connection, status: 400, body: ["error": "validation_failed", "key": k, "reason": "missing"])
- case .typeMismatch(let k):
- send(connection: connection, status: 400, body: ["error": "validation_failed", "key": k, "reason": "type-mismatch"])
- case .schemaMismatch(let expected, let got):
- send(connection: connection, status: 409, body: ["error": "schema_mismatch", "expected_hash": expected, "got_hash": got])
+ // Phase one validates every registered model without assignment.
+ for restore in atomicRestores {
+ switch restore(keys, false) {
+ case .ok:
+ continue
+ case .missingKey(let k):
+ send(connection: connection, status: 400, body: ["error": "validation_failed", "key": k, "reason": "missing"])
+ return
+ case .typeMismatch(let k):
+ send(connection: connection, status: 400, body: ["error": "validation_failed", "key": k, "reason": "type-mismatch"])
+ return
+ case .schemaMismatch(let expected, let got):
+ send(connection: connection, status: 409, body: ["error": "schema_mismatch", "expected_hash": expected, "got_hash": got])
+ return
+ }
}
+
+ // Phase two applies only after every model accepted the immutable input.
+ // A valid multi-field restore may notify once per property.
+ for restore in atomicRestores {
+ guard case .ok = restore(keys, true) else {
+ send(connection: connection, status: 500, body: ["error": "restore_apply_failed"])
+ return
+ }
+ }
+ send(connection: connection, status: 200, body: ["ok": true])
}
// MARK: Stubs (real impls live in DebugBridgeManager + UIKit)
@@ -522,9 +539,19 @@ public final class StateServer {
// MARK: Response
private func send(connection: NWConnection, status: Int, body: JSONDict) {
- let json = (try? JSONSerialization.data(withJSONObject: body)) ?? Data("{}".utf8)
+ let responseStatus: Int
+ let json: Data
+ if JSONSerialization.isValidJSONObject(body),
+ let encoded = try? JSONSerialization.data(withJSONObject: body) {
+ responseStatus = status
+ json = encoded
+ } else {
+ logger.error("Refusing to send a non-JSON response body")
+ responseStatus = 500
+ json = Data("{\"error\":\"response_not_json_serializable\"}".utf8)
+ }
let statusText: String
- switch status {
+ switch responseStatus {
case 200: statusText = "OK"
case 400: statusText = "Bad Request"
case 401: statusText = "Unauthorized"
@@ -537,7 +564,7 @@ public final class StateServer {
case 503: statusText = "Service Unavailable"
default: statusText = "Status"
}
- let header = "HTTP/1.1 \(status) \(statusText)\r\nContent-Type: application/json\r\nContent-Length: \(json.count)\r\nConnection: close\r\n\r\n"
+ let header = "HTTP/1.1 \(responseStatus) \(statusText)\r\nContent-Type: application/json\r\nContent-Length: \(json.count)\r\nConnection: close\r\n\r\n"
var packet = Data(header.utf8)
packet.append(json)
connection.send(content: packet, completion: .contentProcessed { _ in
diff --git a/ios-sync/SKILL.md b/ios-sync/SKILL.md
index 2f689c4d6..95e0bca0a 100644
--- a/ios-sync/SKILL.md
+++ b/ios-sync/SKILL.md
@@ -806,49 +806,53 @@ After `/ios-qa` is installed in an app, the user may:
1. Add new `@Observable` classes or properties that need accessor coverage.
2. Upgrade gstack to a newer version with hardening fixes.
-3. Move the `@Snapshotable` marker to a different field.
+3. Move the `// @Snapshotable` generator marker comment to a different field.
This skill regenerates the relevant artifacts in place.
-**Templates live in upstream gstack.** This skill resolves them from
-`~/.claude/skills/gstack/ios-qa/templates/` (or the worktree's
-`ios-qa/templates/` when developing gstack itself). The fork's HTTP-fetch
-pattern is gone.
+**Templates live in upstream gstack.** The installed
+`gstack-ios-qa-regen` launcher resolves its own gstack root and copies only
+the supported bridge files from `ios-qa/templates/`. The fork's HTTP-fetch
+and wildcard-copy patterns are gone.
## Phase 1: Detect installed version
1. Read `/DebugBridgeGenerated/.gstack-version` (written by /ios-qa
during install). If missing, treat the install as "unknown old version".
-2. Read upstream version from `$GSTACK_HOME/ios-qa/.gstack-version` (or the
- value baked into the installed gstack binary).
+2. Read upstream version from `$GSTACK_ROOT/VERSION`.
3. If versions match AND no new `@Observable` classes were added, exit
early with "already up to date".
## Phase 2: Regenerate codegen output
-Run `gstack-ios-qa-regen` (or the underlying SwiftPM tool directly):
+Run the deterministic regenerator once. `--app-source` is the directory the
+accessor scanner should inspect; `--bridge-dir` is the local Swift package
+that the app links in Debug builds:
```bash
-swift run --package-path "$GSTACK_HOME/ios-qa/scripts/gen-accessors-tool" \
- gen-accessors --input "$APP_SOURCE_DIR" --output "$APP_SOURCE_DIR/DebugBridgeGenerated"
+~/.claude/skills/gstack/bin/gstack-ios-qa-regen \
+ --app-source "$APP_SOURCE_DIR" \
+ --bridge-dir "$APP_SOURCE_DIR/DebugBridge"
```
+The command removes only the known obsolete generated files from the former
+flat `DebugBridgeGenerated/` layout before emitting the current accessor.
+Generation accepts file-scope observable classes and JSON-native scalar,
+array, String-keyed dictionary, and Optional field types. It rejects custom
+types, implicitly unwrapped Optionals, nested observable classes, and duplicate
+snapshot keys before writing a completion marker.
+
The composite-hash cache key handles whether anything actually needs
regenerating; if Swift version, generator git rev, lockfile, source content,
and platform triple all match the cache, this is a ~50ms no-op.
-## Phase 3: Update templated Swift files in place
+## Phase 3: Review the generated diff
-For each file that comes from `ios-qa/templates/*.swift.template`:
-
-1. Read the current installed file at
- `/DebugBridgeGenerated/.swift`.
-2. Read the upstream template at
- `$GSTACK_HOME/ios-qa/templates/.swift.template`.
-3. If the installed file has a `// GSTACK-EDIT-LINE` marker, fold the user's
- edits forward.
-4. Otherwise, replace the file outright with the new template (after
- AskUserQuestion if the diff is non-trivial).
+1. Review changes under `/DebugBridge/` and
+ `/DebugBridgeGenerated/StateAccessor.swift`.
+2. Confirm the command did not modify the app's handwritten Swift files.
+3. Keep app-specific wiring in the app target; canonical bridge package files
+ are regenerated from upstream and should not be hand-edited.
## Phase 4: Verify
@@ -862,5 +866,6 @@ For each file that comes from `ios-qa/templates/*.swift.template`:
| Symptom | Action |
|---|---|
| Swift compile fails after regen | Revert via `git restore` + AskUserQuestion: surface the compile error |
-| Schema hash unchanged after adding new @Observable | The new class isn't marked `@Snapshotable` — the codegen excludes it correctly. If the user wanted it snapshotted, add the wrapper. |
-| `--input` source dir contains test fixtures | gen-accessors scans the input dir recursively; exclude test/ via `--exclude` |
+| Codegen reports an invalid marked declaration | Use a file-scope observable class and a writable instance `var` with an explicit JSON-native type, internal/public setter, and a key unique across models; otherwise remove the `// @Snapshotable` marker. |
+| Schema hash unchanged after adding new @Observable | No field has the standalone `// @Snapshotable` marker comment — codegen excludes unmarked state correctly. Add the comment immediately above each field that should be snapshotted. |
+| Scanner sees generated bridge sources | Pass the narrow app source directory; the regenerator automatically excludes `DebugBridgeGenerated` and `StateAccessor.swift`. |
diff --git a/ios-sync/SKILL.md.tmpl b/ios-sync/SKILL.md.tmpl
index 156a33c4c..add54c3d8 100644
--- a/ios-sync/SKILL.md.tmpl
+++ b/ios-sync/SKILL.md.tmpl
@@ -35,49 +35,53 @@ After `/ios-qa` is installed in an app, the user may:
1. Add new `@Observable` classes or properties that need accessor coverage.
2. Upgrade gstack to a newer version with hardening fixes.
-3. Move the `@Snapshotable` marker to a different field.
+3. Move the `// @Snapshotable` generator marker comment to a different field.
This skill regenerates the relevant artifacts in place.
-**Templates live in upstream gstack.** This skill resolves them from
-`~/.claude/skills/gstack/ios-qa/templates/` (or the worktree's
-`ios-qa/templates/` when developing gstack itself). The fork's HTTP-fetch
-pattern is gone.
+**Templates live in upstream gstack.** The installed
+`gstack-ios-qa-regen` launcher resolves its own gstack root and copies only
+the supported bridge files from `ios-qa/templates/`. The fork's HTTP-fetch
+and wildcard-copy patterns are gone.
## Phase 1: Detect installed version
1. Read `/DebugBridgeGenerated/.gstack-version` (written by /ios-qa
during install). If missing, treat the install as "unknown old version".
-2. Read upstream version from `$GSTACK_HOME/ios-qa/.gstack-version` (or the
- value baked into the installed gstack binary).
+2. Read upstream version from `$GSTACK_ROOT/VERSION`.
3. If versions match AND no new `@Observable` classes were added, exit
early with "already up to date".
## Phase 2: Regenerate codegen output
-Run `gstack-ios-qa-regen` (or the underlying SwiftPM tool directly):
+Run the deterministic regenerator once. `--app-source` is the directory the
+accessor scanner should inspect; `--bridge-dir` is the local Swift package
+that the app links in Debug builds:
```bash
-swift run --package-path "$GSTACK_HOME/ios-qa/scripts/gen-accessors-tool" \
- gen-accessors --input "$APP_SOURCE_DIR" --output "$APP_SOURCE_DIR/DebugBridgeGenerated"
+~/.claude/skills/gstack/bin/gstack-ios-qa-regen \
+ --app-source "$APP_SOURCE_DIR" \
+ --bridge-dir "$APP_SOURCE_DIR/DebugBridge"
```
+The command removes only the known obsolete generated files from the former
+flat `DebugBridgeGenerated/` layout before emitting the current accessor.
+Generation accepts file-scope observable classes and JSON-native scalar,
+array, String-keyed dictionary, and Optional field types. It rejects custom
+types, implicitly unwrapped Optionals, nested observable classes, and duplicate
+snapshot keys before writing a completion marker.
+
The composite-hash cache key handles whether anything actually needs
regenerating; if Swift version, generator git rev, lockfile, source content,
and platform triple all match the cache, this is a ~50ms no-op.
-## Phase 3: Update templated Swift files in place
+## Phase 3: Review the generated diff
-For each file that comes from `ios-qa/templates/*.swift.template`:
-
-1. Read the current installed file at
- `/DebugBridgeGenerated/.swift`.
-2. Read the upstream template at
- `$GSTACK_HOME/ios-qa/templates/.swift.template`.
-3. If the installed file has a `// GSTACK-EDIT-LINE` marker, fold the user's
- edits forward.
-4. Otherwise, replace the file outright with the new template (after
- AskUserQuestion if the diff is non-trivial).
+1. Review changes under `/DebugBridge/` and
+ `/DebugBridgeGenerated/StateAccessor.swift`.
+2. Confirm the command did not modify the app's handwritten Swift files.
+3. Keep app-specific wiring in the app target; canonical bridge package files
+ are regenerated from upstream and should not be hand-edited.
## Phase 4: Verify
@@ -91,5 +95,6 @@ For each file that comes from `ios-qa/templates/*.swift.template`:
| Symptom | Action |
|---|---|
| Swift compile fails after regen | Revert via `git restore` + AskUserQuestion: surface the compile error |
-| Schema hash unchanged after adding new @Observable | The new class isn't marked `@Snapshotable` — the codegen excludes it correctly. If the user wanted it snapshotted, add the wrapper. |
-| `--input` source dir contains test fixtures | gen-accessors scans the input dir recursively; exclude test/ via `--exclude` |
+| Codegen reports an invalid marked declaration | Use a file-scope observable class and a writable instance `var` with an explicit JSON-native type, internal/public setter, and a key unique across models; otherwise remove the `// @Snapshotable` marker. |
+| Schema hash unchanged after adding new @Observable | No field has the standalone `// @Snapshotable` marker comment — codegen excludes unmarked state correctly. Add the comment immediately above each field that should be snapshotted. |
+| Scanner sees generated bridge sources | Pass the narrow app source directory; the regenerator automatically excludes `DebugBridgeGenerated` and `StateAccessor.swift`. |
diff --git a/lib/gstack-memory-helpers.ts b/lib/gstack-memory-helpers.ts
index 11a3f2239..3a1c563ba 100644
--- a/lib/gstack-memory-helpers.ts
+++ b/lib/gstack-memory-helpers.ts
@@ -106,9 +106,15 @@ export function canonicalizeRemote(url: string | null | undefined): string {
// strip user@ prefix on URL-style remotes
s = s.replace(/^[^@\/]+@/, "");
}
+ // strip trailing slash(es) first, so a URL written with a trailing slash
+ // still matches the `.git$` suffix below (e.g. ".../repo.git/" must
+ // canonicalize to ".../repo", not ".../repo.git").
+ s = s.replace(/\/+$/, "");
// strip trailing .git
s = s.replace(/\.git$/i, "");
- // strip trailing slash
+ // re-strip trailing slash(es): a path remote ending in a `.git` directory
+ // component ("/repo/.git") exposes a new trailing slash once `.git` is
+ // stripped, which would split the repo into a second identity.
s = s.replace(/\/+$/, "");
// collapse multiple slashes (after path normalization)
s = s.replace(/\/{2,}/g, "/");
@@ -396,6 +402,7 @@ function extractGbrainBlock(frontmatter: string): GbrainManifest | null {
const globM = body.match(/(?:^|\n)\s*glob\s*:\s*"?([^"\n]+?)"?\s*$/m);
const sortM = body.match(/(?:^|\n)\s*sort\s*:\s*([^\n]+)/);
const tailM = body.match(/(?:^|\n)\s*tail\s*:\s*(\d+)/);
+ const filterMap = parseFilterMap(body);
if (idM) q.id = idM[1].trim();
if (kindM) {
@@ -408,6 +415,7 @@ function extractGbrainBlock(frontmatter: string): GbrainManifest | null {
if (globM) q.glob = globM[1].trim();
if (sortM) q.sort = sortM[1].trim();
if (tailM) q.tail = parseInt(tailM[1], 10);
+ if (filterMap) q.filter = filterMap;
if (q.id && q.kind && q.render_as) {
queries.push(q as GbrainManifestQuery);
@@ -418,6 +426,39 @@ function extractGbrainBlock(frontmatter: string): GbrainManifest | null {
return { schema, context_queries: queries };
}
+/**
+ * Parse a nested `filter:` block map out of a single context_queries item body.
+ *
+ * The block is a YAML map nested under the `filter:` key:
+ *
+ * filter:
+ * type: timeline
+ * tags_contains: "repo:{repo_slug}"
+ *
+ * Each sub-key sits one indent level deeper than `filter:`. Surrounding quotes
+ * are stripped and template vars ({repo_slug}, now-7d, ...) are left intact for
+ * downstream substitution, matching how dispatchList stringifies each value
+ * into a `--filter k=v` argument. Returns undefined when there is no `filter:`
+ * block or it is empty.
+ */
+function parseFilterMap(body: string): Record | undefined {
+ const lines = body.split("\n");
+ const filterIdx = lines.findIndex((l) => /^\s*filter\s*:\s*$/.test(l));
+ if (filterIdx === -1) return undefined;
+ const filterIndent = lines[filterIdx].match(/^\s*/)![0].length;
+
+ const filter: Record = {};
+ for (let i = filterIdx + 1; i < lines.length; i++) {
+ const line = lines[i];
+ if (line.trim() === "") continue; // tolerate blank lines within the block
+ const indent = line.match(/^\s*/)![0].length;
+ if (indent <= filterIndent) break; // dedent to a sibling key ends the block
+ const kv = line.match(/^\s*([A-Za-z0-9_]+)\s*:\s*"?(.*?)"?\s*$/);
+ if (kv) filter[kv[1]] = kv[2].trim();
+ }
+ return Object.keys(filter).length > 0 ? filter : undefined;
+}
+
// ── Public: withErrorContext ──────────────────────────────────────────────
const ERROR_LOG_PATH = join(gstackHome(), ".gbrain-errors.jsonl");
diff --git a/scripts/eval-list.ts b/scripts/eval-list.ts
index 12c5f0a94..67d3f71a2 100644
--- a/scripts/eval-list.ts
+++ b/scripts/eval-list.ts
@@ -18,10 +18,23 @@ let filterBranch: string | null = null;
let filterTier: string | null = null;
let limit = 20;
+function parseLimit(raw: string | undefined): number {
+ if (!raw || !/^[1-9]\d*$/.test(raw)) {
+ console.error('eval:list: --limit requires a positive integer');
+ process.exit(1);
+ }
+ const parsed = Number(raw);
+ if (!Number.isSafeInteger(parsed)) {
+ console.error('eval:list: --limit requires a positive integer');
+ process.exit(1);
+ }
+ return parsed;
+}
+
for (let i = 0; i < args.length; i++) {
if (args[i] === '--branch' && args[i + 1]) { filterBranch = args[++i]; }
else if (args[i] === '--tier' && args[i + 1]) { filterTier = args[++i]; }
- else if (args[i] === '--limit' && args[i + 1]) { limit = parseInt(args[++i], 10); }
+ else if (args[i] === '--limit') { limit = parseLimit(args[++i]); }
}
// Read eval files
diff --git a/scripts/host-config-export.ts b/scripts/host-config-export.ts
index bca436f26..5ef703624 100644
--- a/scripts/host-config-export.ts
+++ b/scripts/host-config-export.ts
@@ -15,6 +15,7 @@
import { ALL_HOST_CONFIGS, getHostConfig, ALL_HOST_NAMES } from '../hosts/index';
import { validateAllConfigs } from './host-config';
+import { RESOLVERS } from './resolvers';
import { execSync } from 'child_process';
const CLI_REGEX = /^[a-z][a-z0-9_-]*$/;
@@ -82,7 +83,7 @@ switch (command) {
}
case 'validate': {
- const errors = validateAllConfigs(ALL_HOST_CONFIGS);
+ const errors = validateAllConfigs(ALL_HOST_CONFIGS, new Set(Object.keys(RESOLVERS)));
if (errors.length > 0) {
for (const error of errors) {
console.error(`ERROR: ${error}`);
diff --git a/scripts/host-config.ts b/scripts/host-config.ts
index 4421c4a79..c7a3ae9f3 100644
--- a/scripts/host-config.ts
+++ b/scripts/host-config.ts
@@ -117,7 +117,7 @@ const NAME_REGEX = /^[a-z][a-z0-9-]*$/;
const PATH_REGEX = /^[a-zA-Z0-9_.\/${}~-]+$/;
const CLI_REGEX = /^[a-z][a-z0-9_-]*$/;
-export function validateHostConfig(config: HostConfig): string[] {
+export function validateHostConfig(config: HostConfig, validResolverNames?: ReadonlySet): string[] {
const errors: string[] = [];
if (!NAME_REGEX.test(config.name)) {
@@ -152,15 +152,27 @@ export function validateHostConfig(config: HostConfig): string[] {
errors.push(`install.linkingStrategy must be 'real-dir-symlink' or 'symlink-generated'`);
}
+ // Cross-check suppressedResolvers against the known resolver names (injected to avoid a
+ // circular import on the resolver registry). A typo would otherwise silently no-op: the
+ // generator short-circuits suppressed names before the "unknown placeholder" throw, so an
+ // unknown entry never surfaces at generation time either.
+ if (validResolverNames && config.suppressedResolvers) {
+ for (const name of config.suppressedResolvers) {
+ if (!validResolverNames.has(name)) {
+ errors.push(`suppressedResolvers entry '${name}' is not a known resolver`);
+ }
+ }
+ }
+
return errors;
}
-export function validateAllConfigs(configs: HostConfig[]): string[] {
+export function validateAllConfigs(configs: HostConfig[], validResolverNames?: ReadonlySet): string[] {
const errors: string[] = [];
// Per-config validation
for (const config of configs) {
- const configErrors = validateHostConfig(config);
+ const configErrors = validateHostConfig(config, validResolverNames);
errors.push(...configErrors.map(e => `[${config.name}] ${e}`));
}
diff --git a/scripts/one-way-doors.ts b/scripts/one-way-doors.ts
index 6735c386d..35f0f69cd 100644
--- a/scripts/one-way-doors.ts
+++ b/scripts/one-way-doors.ts
@@ -62,9 +62,12 @@ const DESTRUCTIVE_PATTERNS: RegExp[] = [
/\bterraform\s+destroy\b/i,
/\brollback\b/i,
- // Credentials / auth — allow filler words ("the", "my") between verb and noun
- /\brevoke\s+[\w\s]*\b(api key|token|credential|access key|password)\b/i,
- /\breset\s+[\w\s]*\b(api key|token|password|credential)\b/i,
+ // Credentials / auth — allow filler words ("the", "my") between verb and noun.
+ // Keep the noun alternation identical across revoke/reset/rotate so the three
+ // verbs stay parallel; a noun in one but not the others is a false-negative
+ // safety hole (e.g. "reset my secret" must be one-way just like "rotate my secret").
+ /\brevoke\s+[\w\s]*\b(api key|token|secret|credential|access key|password)\b/i,
+ /\breset\s+[\w\s]*\b(api key|token|secret|credential|access key|password)\b/i,
/\brotate\s+[\w\s]*\b(api key|token|secret|credential|access key|password)\b/i,
// Scope / architecture forks (reversible with effort — still deserve confirmation)
diff --git a/test/eval-list-cli.test.ts b/test/eval-list-cli.test.ts
new file mode 100644
index 000000000..f742ec706
--- /dev/null
+++ b/test/eval-list-cli.test.ts
@@ -0,0 +1,89 @@
+import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
+import * as fs from 'fs';
+import * as path from 'path';
+import * as os from 'os';
+import { spawnSync } from 'child_process';
+
+const ROOT = path.resolve(import.meta.dir, '..');
+
+let tmpHome: string;
+
+beforeEach(() => {
+ tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-eval-list-'));
+ const evalDir = path.join(tmpHome, '.gstack-dev', 'evals');
+ fs.mkdirSync(evalDir, { recursive: true });
+ writeEvalRun(evalDir, '2026-a.json', '2026-05-24T01:00:00Z', 2);
+ writeEvalRun(evalDir, '2026-b.json', '2026-05-24T02:00:00Z', 3);
+});
+
+afterEach(() => {
+ fs.rmSync(tmpHome, { recursive: true, force: true });
+});
+
+function writeEvalRun(evalDir: string, filename: string, timestamp: string, turns: number) {
+ fs.writeFileSync(
+ path.join(evalDir, filename),
+ JSON.stringify({
+ schema_version: 1,
+ version: '1.44.0.0',
+ branch: 'main',
+ git_sha: filename,
+ timestamp,
+ tier: 'e2e',
+ total_tests: 1,
+ passed: 1,
+ failed: 0,
+ total_cost_usd: 0,
+ total_duration_ms: 1000,
+ tests: [
+ {
+ name: filename,
+ suite: 'sample',
+ tier: 'e2e',
+ passed: true,
+ duration_ms: 1000,
+ cost_usd: 0,
+ turns_used: turns,
+ },
+ ],
+ }),
+ );
+}
+
+function runEvalList(...args: string[]): { stdout: string; stderr: string; status: number } {
+ const result = spawnSync('bun', ['run', 'scripts/eval-list.ts', ...args], {
+ cwd: ROOT,
+ env: {
+ ...process.env,
+ HOME: tmpHome,
+ GSTACK_HOME: path.join(tmpHome, '.gstack'),
+ },
+ encoding: 'utf-8',
+ });
+ return {
+ stdout: result.stdout ?? '',
+ stderr: result.stderr ?? '',
+ status: result.status ?? -1,
+ };
+}
+
+describe('eval:list CLI', () => {
+ test('limits displayed eval runs with a valid positive integer', () => {
+ const result = runEvalList('--limit', '1');
+
+ expect(result.status).toBe(0);
+ expect(result.stdout).toContain('Eval History (2 total runs)');
+ expect(result.stdout).toContain('Showing: 1');
+ expect(result.stdout).toContain('2026-05-24 02:00');
+ expect(result.stdout).not.toContain('2026-05-24 01:00');
+ });
+
+ test('rejects malformed limit values instead of silently slicing output', () => {
+ for (const value of ['1abc', 'nope', '0', '-1', '1.5']) {
+ const result = runEvalList('--limit', value);
+ expect(result.status).not.toBe(0);
+ expect(result.stderr).toContain('--limit requires a positive integer');
+ expect(result.stdout).toBe('');
+ }
+ });
+});
diff --git a/test/fixtures/ios-fix/ios-qa-swiftui-tap-pre.json b/test/fixtures/ios-fix/ios-qa-swiftui-tap-pre.json
new file mode 100644
index 000000000..dd6ec5a68
--- /dev/null
+++ b/test/fixtures/ios-fix/ios-qa-swiftui-tap-pre.json
@@ -0,0 +1,6 @@
+{
+ "_schema_version": 1,
+ "_app_build_id": "uninitialized",
+ "_accessor_hash": "uninitialized",
+ "keys": {}
+}
diff --git a/test/fixtures/ios-fix/ios-qa-swiftui-tap-pre.png b/test/fixtures/ios-fix/ios-qa-swiftui-tap-pre.png
new file mode 100644
index 000000000..c5f22e90e
Binary files /dev/null and b/test/fixtures/ios-fix/ios-qa-swiftui-tap-pre.png differ
diff --git a/test/fixtures/ios-qa/FixtureApp/Sources/DebugBridgeCore/DebugBridgeManager.swift b/test/fixtures/ios-qa/FixtureApp/Sources/DebugBridgeCore/DebugBridgeManager.swift
index e18e2fb05..8dfb96d88 100644
--- a/test/fixtures/ios-qa/FixtureApp/Sources/DebugBridgeCore/DebugBridgeManager.swift
+++ b/test/fixtures/ios-qa/FixtureApp/Sources/DebugBridgeCore/DebugBridgeManager.swift
@@ -14,12 +14,16 @@ import Foundation
public final class DebugBridgeManager {
public static let shared = DebugBridgeManager()
- public func start(appState: AppState) {
- // 1. Register the canonical AppState struct + accessor wiring.
- // AppStateAccessor.register(_:) is generated by gen-accessors-tool.
- AppStateAccessor.register(appState)
+ /// Register app-owned generated accessors, then start the server. The
+ /// registration closure is passed in from the consuming app because the
+ /// DebugBridgeCore package cannot import app-target types. On UIKit apps,
+ /// call DebugBridgeUIWiring.installAll() before this method so a warm
+ /// daemon cannot reach uninitialized resolvers during listener startup.
+ public func start(appState: State, register: (State) -> Void) {
+ register(appState)
- // 2. Boot the StateServer.
+ // Boot only after registration so the first snapshot has a real build
+ // id, schema hash, and key set.
StateServer.shared.start()
// 3. The consuming app installs DebugOverlayWindow separately. See
@@ -31,19 +35,4 @@ public final class DebugBridgeManager {
}
}
-// Placeholder. gen-accessors-tool emits the real `AppStateAccessor` enum next
-// to the app's canonical state struct. Apps that haven't run codegen get a
-// stub that registers no accessors (snapshot is empty, restore returns
-// missing-key for every key).
-@MainActor
-public enum AppStateAccessor {
- public static var register: (Any) -> Void = { _ in }
-}
-
-// Apps declare their canonical state struct; codegen reads it and emits
-// AppStateAccessor.register. The app's struct must be `@Observable` and
-// must hold all snapshot-eligible state in `@Snapshotable`-marked fields.
-@MainActor
-public protocol AppState: AnyObject {}
-
#endif // DEBUG
diff --git a/test/fixtures/ios-qa/FixtureApp/Sources/DebugBridgeCore/StateServer.swift b/test/fixtures/ios-qa/FixtureApp/Sources/DebugBridgeCore/StateServer.swift
index 803bedf31..b8441df16 100644
--- a/test/fixtures/ios-qa/FixtureApp/Sources/DebugBridgeCore/StateServer.swift
+++ b/test/fixtures/ios-qa/FixtureApp/Sources/DebugBridgeCore/StateServer.swift
@@ -59,18 +59,20 @@ public final class StateServer {
private var writeHandlers: [String: WriteHandler] = [:]
private var typeNames: [String: TypeName] = [:]
- // Atomic-restore hook. Codegen wires this to the canonical AppState struct.
- // Restore replaces the entire struct in one assignment so SwiftUI's Combine
- // pipeline observes exactly one change notification — true observable
- // atomicity. @MainActor alone doesn't guarantee that.
- public typealias AtomicRestoreFn = (JSONDict) -> RestoreResult
+ // Validated-restore hooks. Every generated model registers one two-phase
+ // handler. The server validates all models before it lets any model mutate,
+ // so invalid input can never cause a cross-model partial restore.
+ // Valid restores apply properties on MainActor; observers may receive one
+ // change notification per property because arbitrary @Observable models do
+ // not expose a general single-assignment transaction API.
+ public typealias AtomicRestoreFn = (JSONDict, Bool) -> RestoreResult
public enum RestoreResult {
case ok
case missingKey(String)
case typeMismatch(String)
case schemaMismatch(expected: String, got: String)
}
- private var atomicRestore: AtomicRestoreFn?
+ private var atomicRestores: [AtomicRestoreFn] = []
// Snapshot schema hash — written by codegen, stable across builds with
// identical accessor signatures.
@@ -109,7 +111,7 @@ public final class StateServer {
public func register(buildId: String, accessorHash: String, atomicRestore: @escaping AtomicRestoreFn) {
self.appBuildId = buildId
self.accessorHash = accessorHash
- self.atomicRestore = atomicRestore
+ self.atomicRestores.append(atomicRestore)
}
public func registerAccessor(key: String, type: String, read: @escaping ReadHandler, write: @escaping WriteHandler) {
@@ -284,6 +286,7 @@ public final class StateServer {
"version": "1.0.0",
"build": appBuildId,
"accessor_hash": accessorHash,
+ "bundle_id": Bundle.main.bundleIdentifier ?? "unknown",
])
return
}
@@ -476,22 +479,36 @@ public final class StateServer {
send(connection: connection, status: 400, body: ["error": "missing_keys"])
return
}
- guard let restore = atomicRestore else {
+ guard !atomicRestores.isEmpty else {
send(connection: connection, status: 503, body: ["error": "atomic_restore_not_registered"])
return
}
- // Validate-then-apply via the codegen-supplied closure. The closure does
- // a single struct-assignment so SwiftUI sees one change notification.
- switch restore(keys) {
- case .ok:
- send(connection: connection, status: 200, body: ["ok": true])
- case .missingKey(let k):
- send(connection: connection, status: 400, body: ["error": "validation_failed", "key": k, "reason": "missing"])
- case .typeMismatch(let k):
- send(connection: connection, status: 400, body: ["error": "validation_failed", "key": k, "reason": "type-mismatch"])
- case .schemaMismatch(let expected, let got):
- send(connection: connection, status: 409, body: ["error": "schema_mismatch", "expected_hash": expected, "got_hash": got])
+ // Phase one validates every registered model without assignment.
+ for restore in atomicRestores {
+ switch restore(keys, false) {
+ case .ok:
+ continue
+ case .missingKey(let k):
+ send(connection: connection, status: 400, body: ["error": "validation_failed", "key": k, "reason": "missing"])
+ return
+ case .typeMismatch(let k):
+ send(connection: connection, status: 400, body: ["error": "validation_failed", "key": k, "reason": "type-mismatch"])
+ return
+ case .schemaMismatch(let expected, let got):
+ send(connection: connection, status: 409, body: ["error": "schema_mismatch", "expected_hash": expected, "got_hash": got])
+ return
+ }
}
+
+ // Phase two applies only after every model accepted the immutable input.
+ // A valid multi-field restore may notify once per property.
+ for restore in atomicRestores {
+ guard case .ok = restore(keys, true) else {
+ send(connection: connection, status: 500, body: ["error": "restore_apply_failed"])
+ return
+ }
+ }
+ send(connection: connection, status: 200, body: ["ok": true])
}
// MARK: Stubs (real impls live in DebugBridgeManager + UIKit)
@@ -522,9 +539,19 @@ public final class StateServer {
// MARK: Response
private func send(connection: NWConnection, status: Int, body: JSONDict) {
- let json = (try? JSONSerialization.data(withJSONObject: body)) ?? Data("{}".utf8)
+ let responseStatus: Int
+ let json: Data
+ if JSONSerialization.isValidJSONObject(body),
+ let encoded = try? JSONSerialization.data(withJSONObject: body) {
+ responseStatus = status
+ json = encoded
+ } else {
+ logger.error("Refusing to send a non-JSON response body")
+ responseStatus = 500
+ json = Data("{\"error\":\"response_not_json_serializable\"}".utf8)
+ }
let statusText: String
- switch status {
+ switch responseStatus {
case 200: statusText = "OK"
case 400: statusText = "Bad Request"
case 401: statusText = "Unauthorized"
@@ -537,7 +564,7 @@ public final class StateServer {
case 503: statusText = "Service Unavailable"
default: statusText = "Status"
}
- let header = "HTTP/1.1 \(status) \(statusText)\r\nContent-Type: application/json\r\nContent-Length: \(json.count)\r\nConnection: close\r\n\r\n"
+ let header = "HTTP/1.1 \(responseStatus) \(statusText)\r\nContent-Type: application/json\r\nContent-Length: \(json.count)\r\nConnection: close\r\n\r\n"
var packet = Data(header.utf8)
packet.append(json)
connection.send(content: packet, completion: .contentProcessed { _ in
diff --git a/test/fixtures/ios-qa/FixtureApp/Sources/DebugBridgeTouch/DebugBridgeTouch.m b/test/fixtures/ios-qa/FixtureApp/Sources/DebugBridgeTouch/DebugBridgeTouch.m
index 7f7b7d1a3..aa6954c6d 100644
--- a/test/fixtures/ios-qa/FixtureApp/Sources/DebugBridgeTouch/DebugBridgeTouch.m
+++ b/test/fixtures/ios-qa/FixtureApp/Sources/DebugBridgeTouch/DebugBridgeTouch.m
@@ -126,6 +126,36 @@ static BOOL DBT_LoadIOKit(void) {
return _IOKitLoaded;
}
+// KIF enables accessibility automation before it asks UIKit/SwiftUI for the
+// accessibility tree. Without this switch SwiftUI exposes only its hosting
+// shell: /elements has no controls and a synthesized tap can return YES while
+// Button.action never fires. Keep this DEBUG-only with the rest of this file.
+typedef int (*DBTAXSAutomationEnabledFn)(void);
+typedef void (*DBTAXSSetAutomationEnabledFn)(int);
+static DBTAXSAutomationEnabledFn _DBTAXSAutomationEnabled;
+static DBTAXSSetAutomationEnabledFn _DBTAXSSetAutomationEnabled;
+static int _DBTInitialAutomationState = -1;
+
+static void DBT_RestoreAccessibilityAutomation(void) {
+ if (_DBTAXSSetAutomationEnabled && _DBTInitialAutomationState >= 0) {
+ _DBTAXSSetAutomationEnabled(_DBTInitialAutomationState);
+ }
+}
+
+static void DBT_EnableAccessibilityAutomation(void) {
+ static dispatch_once_t onceToken;
+ dispatch_once(&onceToken, ^{
+ void *handle = dlopen("/usr/lib/libAccessibility.dylib", RTLD_NOW | RTLD_LOCAL);
+ if (!handle) return;
+ _DBTAXSAutomationEnabled = (DBTAXSAutomationEnabledFn)dlsym(handle, "_AXSAutomationEnabled");
+ _DBTAXSSetAutomationEnabled = (DBTAXSSetAutomationEnabledFn)dlsym(handle, "_AXSSetAutomationEnabled");
+ if (!_DBTAXSAutomationEnabled || !_DBTAXSSetAutomationEnabled) return;
+ _DBTInitialAutomationState = _DBTAXSAutomationEnabled();
+ if (!_DBTInitialAutomationState) _DBTAXSSetAutomationEnabled(1);
+ atexit(DBT_RestoreAccessibilityAutomation);
+ });
+}
+
static IOHIDEventRef DBT_IOHIDEventWithTouch(UITouch *touch) CF_RETURNS_RETAINED;
static IOHIDEventRef DBT_IOHIDEventWithTouch(UITouch *touch) {
if (!DBT_LoadIOKit()) return NULL;
@@ -172,6 +202,7 @@ static IOHIDEventRef DBT_IOHIDEventWithTouch(UITouch *touch) {
- (void)setGestureView:(UIView *)view;
- (void)_setLocationInWindow:(CGPoint)location resetPrevious:(BOOL)resetPrevious;
- (void)_setIsFirstTouchForView:(BOOL)firstTouchForView;
+- (void)_setIsTapToClick:(BOOL)tapToClick;
- (void)_setHidEvent:(IOHIDEventRef)event;
@end
@@ -229,6 +260,10 @@ static id DBT_HitTestView(UIWindow *window, CGPoint point) {
@implementation DebugBridgeTouch
++ (void)prepareForAutomation {
+ DBT_EnableAccessibilityAutomation();
+}
+
+ (BOOL)sendTapAtPoint:(CGPoint)point inWindow:(UIWindow *)window {
if (!window) return NO;
@@ -247,10 +282,17 @@ static id DBT_HitTestView(UIWindow *window, CGPoint point) {
[touch setPhase:UITouchPhaseBegan];
if ([touch respondsToSelector:@selector(_setIsFirstTouchForView:)]) {
[touch _setIsFirstTouchForView:YES];
+ } else if ([touch respondsToSelector:@selector(_setIsTapToClick:)]) {
+ [touch _setIsTapToClick:YES];
+ Ivar flagsIvar = class_getInstanceVariable([UITouch class], "_touchFlags");
+ if (flagsIvar) {
+ ptrdiff_t offset = ivar_getOffset(flagsIvar);
+ char *flags = (__bridge void *)touch + offset;
+ *flags |= (char)0x01;
+ }
}
[touch setTimestamp:[[NSProcessInfo processInfo] systemUptime]];
- if ([touch respondsToSelector:@selector(setGestureView:)] &&
- [hit isKindOfClass:[UIView class]]) {
+ if ([touch respondsToSelector:@selector(setGestureView:)]) {
[touch setGestureView:(UIView *)hit];
}
diff --git a/test/fixtures/ios-qa/FixtureApp/Sources/DebugBridgeTouch/include/DebugBridgeTouch.h b/test/fixtures/ios-qa/FixtureApp/Sources/DebugBridgeTouch/include/DebugBridgeTouch.h
index 1f85c1211..86a2ddaa8 100644
--- a/test/fixtures/ios-qa/FixtureApp/Sources/DebugBridgeTouch/include/DebugBridgeTouch.h
+++ b/test/fixtures/ios-qa/FixtureApp/Sources/DebugBridgeTouch/include/DebugBridgeTouch.h
@@ -23,6 +23,10 @@ NS_ASSUME_NONNULL_BEGIN
@interface DebugBridgeTouch : NSObject
+/// Enable the in-process accessibility automation tree used by SwiftUI and
+/// UIKit. Call once while installing the debug bridge, before /elements.
++ (void)prepareForAutomation;
+
/// Synthesize a single tap (TouchPhaseBegan + TouchPhaseEnded) at the given
/// window-coordinate point. Returns YES if the touch was delivered (a hit
/// view was found and the event passed through UIApplication.sendEvent).
diff --git a/test/fixtures/ios-qa/FixtureApp/Sources/DebugBridgeUI/Bridges.swift b/test/fixtures/ios-qa/FixtureApp/Sources/DebugBridgeUI/Bridges.swift
index bf7af6e3f..4a91142de 100644
--- a/test/fixtures/ios-qa/FixtureApp/Sources/DebugBridgeUI/Bridges.swift
+++ b/test/fixtures/ios-qa/FixtureApp/Sources/DebugBridgeUI/Bridges.swift
@@ -27,12 +27,35 @@ public enum DebugBridgeUIWiring {
/// times reinstalls the same closures. Must be called on @MainActor
/// because every UIKit access requires the main actor.
public static func installAll() {
+ // KIF turns on accessibility automation before walking SwiftUI's AX
+ // tree. Without it SwiftUI exposes only the hosting shell and taps
+ // can report success without invoking Button.action.
+ DebugBridgeTouch.prepareForAutomation()
ScreenshotBridge.resolver = { ScreenshotBridgeImpl.capturePNG() }
ElementsBridge.resolver = { ElementsBridgeImpl.snapshot() }
MutationBridge.resolver = { op, payload in MutationBridgeImpl.dispatch(op: op, payload: payload) }
}
}
+/// Return the children UIKit exposes specifically to accessibility automation.
+/// iOS 17 added `automationElements`; unlike the older container APIs it
+/// preserves identified SwiftUI descendants inside accessibility groups.
+@MainActor
+private func debugBridgeAccessibilityChildren(of element: NSObject) -> [NSObject] {
+ if #available(iOS 17.0, *),
+ let automation = element.automationElements,
+ !automation.isEmpty {
+ return automation.compactMap { $0 as? NSObject }
+ }
+ if let accessibility = element.accessibilityElements,
+ !accessibility.isEmpty {
+ return accessibility.compactMap { $0 as? NSObject }
+ }
+ let count = element.accessibilityElementCount()
+ guard count > 0, count < 512 else { return [] }
+ return (0.. Data? {
guard let scene = activeScene(), let window = activeKeyWindow(in: scene) else { return nil }
let bounds = window.bounds
- let renderer = UIGraphicsImageRenderer(bounds: bounds)
+ let format = UIGraphicsImageRendererFormat.default()
+ // /tap consumes UIKit window points. Render at 1x so screenshot pixels
+ // use that same coordinate space on 2x/3x devices.
+ format.scale = 1
+ let renderer = UIGraphicsImageRenderer(bounds: bounds, format: format)
let image = renderer.image { _ in
// drawHierarchy is the documented way to snapshot real UIKit
// layers including layer-backed views. afterScreenUpdates: false
@@ -61,7 +88,10 @@ enum ScreenshotBridgeImpl {
}
private static func activeKeyWindow(in scene: UIWindowScene) -> UIWindow? {
- scene.windows.first(where: { $0.isKeyWindow }) ?? scene.windows.first
+ let windows = scene.windows.filter { window in
+ !window.isHidden && !String(describing: type(of: window)).contains("PassThroughWindow")
+ }
+ return windows.first(where: { $0.isKeyWindow }) ?? windows.max(by: { $0.windowLevel < $1.windowLevel })
}
}
@@ -76,21 +106,40 @@ enum ElementsBridgeImpl {
static func snapshot() -> [JSONDict] {
guard let scene = activeScene(), let window = activeKeyWindow(in: scene) else { return [] }
var elements: [JSONDict] = []
- collect(view: window, parentPath: "", windowBounds: window.bounds, into: &elements)
+ var visited = Set()
+ var remaining = 2_048
+ collect(
+ view: window,
+ parentPath: "",
+ window: window,
+ visited: &visited,
+ remaining: &remaining,
+ into: &elements
+ )
return elements
}
- private static func collect(view: UIView, parentPath: String, windowBounds: CGRect, into elements: inout [JSONDict]) {
+ private static func collect(
+ view: UIView,
+ parentPath: String,
+ window: UIWindow,
+ visited: inout Set,
+ remaining: inout Int,
+ into elements: inout [JSONDict]
+ ) {
+ guard remaining > 0, visited.insert(ObjectIdentifier(view)).inserted else { return }
+ remaining -= 1
+
// Skip hidden / zero-size / off-screen subtrees early.
if view.isHidden || view.alpha < 0.01 { return }
- let frameInWindow = view.convert(view.bounds, to: nil)
- if !windowBounds.intersects(frameInWindow) { return }
+ let frameInWindow = view.convert(view.bounds, to: window)
+ if !window.bounds.intersects(frameInWindow) { return }
let isAccessible = view.isAccessibilityElement
let label = view.accessibilityLabel ?? ""
let identifier = view.accessibilityIdentifier ?? ""
- let traits = Int(view.accessibilityTraits.rawValue)
+ let traits = NSNumber(value: view.accessibilityTraits.rawValue)
let value = (view.accessibilityValue ?? "") as String
let className = String(describing: type(of: view))
let path = parentPath.isEmpty ? className : "\(parentPath) > \(className)"
@@ -120,66 +169,98 @@ enum ElementsBridgeImpl {
])
}
- // Recurse into accessibility-elements first (some custom views vend
- // synthetic children), then UIView subviews. SwiftUI's host views
- // populate accessibilityElements lazily — many return nil before
- // VoiceOver triggers them. Force population by reading accessibilityElementCount.
- _ = view.accessibilityElementCount()
- if let axElements = view.accessibilityElements {
- for case let element as NSObject in axElements {
- if let v = element as? UIView {
- collect(view: v, parentPath: path, windowBounds: windowBounds, into: &elements)
- } else {
- // Synthetic accessibility element (no UIView). Capture frame in screen coords.
- let af = (element.value(forKey: "accessibilityFrame") as? CGRect) ?? .zero
- elements.append([
- "path": "\(path) > ",
- "class": "AccessibilityElement",
- "label": (element.value(forKey: "accessibilityLabel") as? String) ?? "",
- "identifier": (element.value(forKey: "accessibilityIdentifier") as? String) ?? "",
- "value": (element.value(forKey: "accessibilityValue") as? String) ?? "",
- "traits": (element.value(forKey: "accessibilityTraits") as? NSNumber)?.intValue ?? 0,
- "frame": [
- "x": Int(af.origin.x),
- "y": Int(af.origin.y),
- "w": Int(af.size.width),
- "h": Int(af.size.height),
- ],
- "is_user_interaction_enabled": true,
- ])
- }
- }
- } else {
- // accessibilityElements is nil — iterate by index. SwiftUI uses
- // this dynamic protocol pattern; many AX elements only respond
- // to accessibilityElementCount + accessibilityElement(at:).
- let count = view.accessibilityElementCount()
- for i in 0.. ",
- "class": String(describing: type(of: element)),
- "label": (element.value(forKey: "accessibilityLabel") as? String) ?? "",
- "identifier": (element.value(forKey: "accessibilityIdentifier") as? String) ?? "",
- "value": (element.value(forKey: "accessibilityValue") as? String) ?? "",
- "traits": (element.value(forKey: "accessibilityTraits") as? NSNumber)?.intValue ?? 0,
- "frame": [
- "x": Int(af.origin.x),
- "y": Int(af.origin.y),
- "w": Int(af.size.width),
- "h": Int(af.size.height),
- ],
- "is_user_interaction_enabled": true,
- ])
- }
+ // Walk automation children before raw subviews. On iOS 17+ this
+ // exposes identified SwiftUI controls nested inside GroupBox/List.
+ for (index, element) in debugBridgeAccessibilityChildren(of: view).enumerated() {
+ if let child = element as? UIView {
+ collect(
+ view: child,
+ parentPath: path,
+ window: window,
+ visited: &visited,
+ remaining: &remaining,
+ into: &elements
+ )
+ } else {
+ appendSynthetic(
+ element,
+ path: "\(path) > ",
+ window: window,
+ visited: &visited,
+ remaining: &remaining,
+ into: &elements
+ )
}
}
for sub in view.subviews {
- collect(view: sub, parentPath: path, windowBounds: windowBounds, into: &elements)
+ collect(
+ view: sub,
+ parentPath: path,
+ window: window,
+ visited: &visited,
+ remaining: &remaining,
+ into: &elements
+ )
+ }
+ }
+
+ private static func appendSynthetic(
+ _ element: NSObject,
+ path: String,
+ window: UIWindow,
+ visited: inout Set,
+ remaining: inout Int,
+ into elements: inout [JSONDict]
+ ) {
+ guard remaining > 0, visited.insert(ObjectIdentifier(element)).inserted else { return }
+ remaining -= 1
+
+ let screenFrame = (element.value(forKey: "accessibilityFrame") as? CGRect) ?? .zero
+ let frame = window.coordinateSpace.convert(screenFrame, from: window.screen.coordinateSpace)
+ let label = (element.value(forKey: "accessibilityLabel") as? String) ?? ""
+ let identifier = (element.value(forKey: "accessibilityIdentifier") as? String) ?? ""
+ let value = (element.value(forKey: "accessibilityValue") as? String) ?? ""
+ let traits = (element.value(forKey: "accessibilityTraits") as? NSNumber)?.uint64Value ?? 0
+ if !label.isEmpty || !identifier.isEmpty || !value.isEmpty || traits != 0 {
+ elements.append([
+ "path": path,
+ "class": String(describing: type(of: element)),
+ "label": label,
+ "identifier": identifier,
+ "value": value,
+ "traits": NSNumber(value: traits),
+ "frame": [
+ "x": Int(frame.origin.x),
+ "y": Int(frame.origin.y),
+ "w": Int(frame.size.width),
+ "h": Int(frame.size.height),
+ ],
+ "is_user_interaction_enabled": true,
+ ])
+ }
+
+ // Synthetic SwiftUI nodes are themselves accessibility containers.
+ // Recurse even when this grouping node has no metadata of its own.
+ for (index, child) in debugBridgeAccessibilityChildren(of: element).enumerated() {
+ if let childView = child as? UIView {
+ collect(
+ view: childView,
+ parentPath: path,
+ window: window,
+ visited: &visited,
+ remaining: &remaining,
+ into: &elements
+ )
+ } else {
+ appendSynthetic(
+ child,
+ path: "\(path) > ",
+ window: window,
+ visited: &visited,
+ remaining: &remaining,
+ into: &elements
+ )
+ }
}
}
@@ -191,7 +272,10 @@ enum ElementsBridgeImpl {
}
private static func activeKeyWindow(in scene: UIWindowScene) -> UIWindow? {
- scene.windows.first(where: { $0.isKeyWindow }) ?? scene.windows.first
+ let windows = scene.windows.filter { window in
+ !window.isHidden && !String(describing: type(of: window)).contains("PassThroughWindow")
+ }
+ return windows.first(where: { $0.isKeyWindow }) ?? windows.max(by: { $0.windowLevel < $1.windowLevel })
}
}
@@ -210,21 +294,62 @@ enum MutationBridgeImpl {
}
}
- /// Tap at (x, y) in window coordinates. Delegates to DebugBridgeTouch
- /// (KIF-derived in-process touch synthesis). The Obj-C target builds a
- /// real UITouch + IOHIDEvent + UIEvent and dispatches via
- /// `UIApplication.sendEvent`, which is what UIKit uses for real touches.
- /// This works for UIControl, SwiftUI Button (via iOS 18+
- /// `_UIHitTestContext`), gesture recognizers, and anything else that
- /// listens to the real event-dispatch path.
+ /// Tap at (x, y) in window coordinates. Prefer accessibility activation,
+ /// which is stable for SwiftUI buttons across OS releases, then fall back
+ /// to KIF-derived UITouch synthesis for gesture-only/custom controls.
private static func handleTap(_ payload: JSONDict) -> Bool {
guard let x = payload["x"] as? NSNumber,
let y = payload["y"] as? NSNumber else { return false }
let point = CGPoint(x: x.doubleValue, y: y.doubleValue)
guard let scene = activeScene(), let window = activeKeyWindow(in: scene) else { return false }
+ if let element = findActivatableAXElement(at: point, in: window),
+ element.accessibilityActivate() {
+ return true
+ }
return DebugBridgeTouch.sendTap(at: point, in: window)
}
+ private static func findActivatableAXElement(at point: CGPoint, in window: UIWindow) -> NSObject? {
+ let screenPoint = window.screen.coordinateSpace.convert(point, from: window.coordinateSpace)
+ var best: NSObject?
+ var bestArea: CGFloat = .infinity
+ var visited = Set()
+ var remaining = 2_048
+
+ func consider(frame: CGRect, traits: UInt64, element: NSObject) {
+ guard frame.contains(screenPoint),
+ (traits & UIAccessibilityTraits.button.rawValue) != 0 else { return }
+ let area = frame.width * frame.height
+ if area > 0 && area < bestArea {
+ best = element
+ bestArea = area
+ }
+ }
+
+ func visit(_ element: NSObject) {
+ guard remaining > 0, visited.insert(ObjectIdentifier(element)).inserted else { return }
+ remaining -= 1
+
+ if let view = element as? UIView {
+ guard !view.isHidden, view.alpha >= 0.01,
+ view.convert(view.bounds, to: window).contains(point) else { return }
+ if view.isAccessibilityElement {
+ consider(frame: view.accessibilityFrame, traits: view.accessibilityTraits.rawValue, element: view)
+ }
+ for child in debugBridgeAccessibilityChildren(of: view) { visit(child) }
+ for child in view.subviews { visit(child) }
+ } else {
+ let frame = (element.value(forKey: "accessibilityFrame") as? CGRect) ?? .zero
+ let traits = (element.value(forKey: "accessibilityTraits") as? NSNumber)?.uint64Value ?? 0
+ consider(frame: frame, traits: traits, element: element)
+ for child in debugBridgeAccessibilityChildren(of: element) { visit(child) }
+ }
+ }
+
+ visit(window)
+ return best
+ }
+
/// Set text on the first responder if it's a UITextField or UITextView.
private static func handleType(_ payload: JSONDict) -> Bool {
guard let text = payload["text"] as? String else { return false }
@@ -266,7 +391,10 @@ enum MutationBridgeImpl {
var off = scroll.contentOffset
off.x = max(0, min(scroll.contentSize.width - scroll.bounds.width, off.x + dx))
off.y = max(0, min(scroll.contentSize.height - scroll.bounds.height, off.y + dy))
- scroll.setContentOffset(off, animated: true)
+ // Automation commands return synchronously; do not report
+ // success while the target is still moving underneath the
+ // next tap coordinate.
+ scroll.setContentOffset(off, animated: false)
return true
}
node = cur.superview
@@ -301,7 +429,10 @@ enum MutationBridgeImpl {
}
private static func activeKeyWindow(in scene: UIWindowScene) -> UIWindow? {
- scene.windows.first(where: { $0.isKeyWindow }) ?? scene.windows.first
+ let windows = scene.windows.filter { window in
+ !window.isHidden && !String(describing: type(of: window)).contains("PassThroughWindow")
+ }
+ return windows.first(where: { $0.isKeyWindow }) ?? windows.max(by: { $0.windowLevel < $1.windowLevel })
}
}
diff --git a/test/fixtures/ios-qa/FixtureApp/Sources/FixtureApp/FixtureAppApp.swift b/test/fixtures/ios-qa/FixtureApp/Sources/FixtureApp/FixtureAppApp.swift
index af18b72e3..c96c92eaf 100644
--- a/test/fixtures/ios-qa/FixtureApp/Sources/FixtureApp/FixtureAppApp.swift
+++ b/test/fixtures/ios-qa/FixtureApp/Sources/FixtureApp/FixtureAppApp.swift
@@ -1,15 +1,21 @@
-// FixtureApp — minimal SwiftUI app used by the ios-qa device-path E2E test.
+// FixtureApp — interaction-rich SwiftUI app used by the ios-qa device-path
+// E2E test. Every control exposes a stable accessibility identifier and writes
+// to visible verification state so device-driven taps have an explicit oracle.
//
// On launch:
-// 1. Boot StateServer (loopback :::1/127.0.0.1 + 9999)
-// 2. Log boot token to os_log so devicectl + the Mac daemon can scrape it
-// 3. Render a single ContentView so the app stays foreground
+// 1. Install UI resolvers before any request can arrive
+// 2. Register typed state and boot StateServer (::1/127.0.0.1 + 9999)
+// 3. Log the one-use boot token, then render the interaction harness
//
// Everything ios-qa-related is gated #if DEBUG. Release builds compile this
// to a no-op app (no StateServer, no DebugBridge import, no overlay).
import SwiftUI
+#if canImport(UIKit)
+import UIKit
+#endif
+
#if DEBUG
import DebugBridgeCore
#endif
@@ -20,14 +26,21 @@ import DebugBridgeUI
@main
struct FixtureAppApp: App {
+ #if DEBUG
+ private let appState = FixtureAppState()
+ #endif
+
init() {
#if DEBUG
- StateServer.shared.start()
// Wire the three UIKit-backed bridges so /screenshot, /elements,
- // /tap, /type, /swipe actually do something on the device.
+ // /tap, /type, /swipe are ready before the listener accepts requests.
#if canImport(UIKit)
DebugBridgeUIWiring.installAll()
#endif
+ DebugBridgeManager.shared.start(
+ appState: appState,
+ register: FixtureAppStateAccessor.register
+ )
#endif
}
@@ -39,22 +52,707 @@ struct FixtureAppApp: App {
}
struct ContentView: View {
- @State private var counter: Int = 0
+ private enum HarnessTab: String, Hashable {
+ case controls
+ case inputs
+ case rows
+
+ var title: String { rawValue.capitalized }
+ }
+
+ private struct HarnessRow: Identifiable {
+ let id: String
+ let title: String
+ let symbol: String
+ }
+
+ private static let harnessRows = [
+ HarnessRow(id: "alpha", title: "Alpha row", symbol: "a.circle.fill"),
+ HarnessRow(id: "bravo", title: "Bravo row", symbol: "b.circle.fill"),
+ HarnessRow(id: "charlie", title: "Charlie row", symbol: "c.circle.fill"),
+ HarnessRow(id: "delta", title: "Delta row", symbol: "d.circle.fill"),
+ ]
+
+ @State private var selectedTab: HarnessTab = .controls
+ @State private var lastAction = "Harness ready"
+ @State private var totalActions = 0
+ @State private var tabChangeCount = 0
+
+ @State private var primaryButtonCount = 0
+ @State private var borderedButtonCount = 0
+ @State private var plainButtonCount = 0
+ @State private var destructiveButtonCount = 0
+ @State private var toolbarRefreshCount = 0
+ @State private var menuAddCount = 0
+ @State private var menuArchiveCount = 0
+ @State private var detailOpenCount = 0
+ @State private var detailCloseCount = 0
+ @State private var detailButtonCount = 0
+ @State private var isShowingDetail = false
+
+ @State private var toggleValue = false
+ @State private var toggleChangeCount = 0
+ @State private var stepperValue = 0
+ @State private var stepperChangeCount = 0
+ @State private var selectedMode = "One"
+ @State private var pickerChangeCount = 0
+ @State private var draftText = ""
+ @FocusState private var isTextFieldFocused: Bool
+ @State private var textChangeCount = 0
+ @State private var textCommitCount = 0
+ @State private var uikitButtonCount = 0
+
+ @State private var rowTapCounts: [String: Int] = [:]
+ @State private var rowFlagCount = 0
+ @State private var rowArchiveCount = 0
+ @State private var rowToolbarCount = 0
var body: some View {
- VStack(spacing: 24) {
- Text("ios-qa fixture")
- .font(.largeTitle.bold())
- Text("StateServer should be on :9999")
- .font(.subheadline)
- .foregroundColor(.secondary)
- Button("Tap (\(counter))") {
- counter += 1
- }
- .buttonStyle(.borderedProminent)
- .accessibilityIdentifier("tap-button")
+ TabView(selection: $selectedTab) {
+ controlsTab
+ .tabItem {
+ Label("Controls", systemImage: "hand.tap.fill")
+ .accessibilityIdentifier("tab-controls")
+ }
+ .tag(HarnessTab.controls)
+
+ inputsTab
+ .tabItem {
+ Label("Inputs", systemImage: "slider.horizontal.3")
+ .accessibilityIdentifier("tab-inputs")
+ }
+ .tag(HarnessTab.inputs)
+
+ rowsTab
+ .tabItem {
+ Label("Rows", systemImage: "list.bullet.rectangle")
+ .accessibilityIdentifier("tab-rows")
+ }
+ .tag(HarnessTab.rows)
}
- .padding()
- .accessibilityIdentifier("fixture-content")
+ .accessibilityIdentifier("fixture-tab-view")
+ .onChange(of: selectedTab) { newTab in
+ tabChangeCount += 1
+ record("Selected \(newTab.title) tab")
+ }
+ }
+
+ private var controlsTab: some View {
+ NavigationStack {
+ ScrollView {
+ VStack(spacing: 14) {
+ verificationPanel
+
+ GroupBox {
+ LazyVGrid(
+ columns: [GridItem(.flexible()), GridItem(.flexible())],
+ spacing: 10
+ ) {
+ Button {
+ primaryButtonCount += 1
+ record("Primary button tapped")
+ } label: {
+ buttonLabel("Primary", count: primaryButtonCount, symbol: "bolt.fill")
+ }
+ .buttonStyle(.borderedProminent)
+ .accessibilityIdentifier("primary-button")
+ .accessibilityValue("\(primaryButtonCount) taps")
+
+ Button {
+ borderedButtonCount += 1
+ record("Bordered button tapped")
+ } label: {
+ buttonLabel("Bordered", count: borderedButtonCount, symbol: "square")
+ }
+ .buttonStyle(.bordered)
+ .accessibilityIdentifier("bordered-button")
+ .accessibilityValue("\(borderedButtonCount) taps")
+
+ Button {
+ plainButtonCount += 1
+ record("Plain button tapped")
+ } label: {
+ buttonLabel("Plain", count: plainButtonCount, symbol: "circle")
+ .padding(.vertical, 8)
+ .background(Color.accentColor.opacity(0.12))
+ .clipShape(RoundedRectangle(cornerRadius: 8))
+ }
+ .buttonStyle(.plain)
+ .accessibilityIdentifier("plain-button")
+ .accessibilityValue("\(plainButtonCount) taps")
+
+ Button(role: .destructive) {
+ destructiveButtonCount += 1
+ record("Destructive-style button tapped")
+ } label: {
+ buttonLabel(
+ "Destructive",
+ count: destructiveButtonCount,
+ symbol: "exclamationmark.triangle.fill"
+ )
+ }
+ .buttonStyle(.bordered)
+ .accessibilityIdentifier("destructive-button")
+ .accessibilityValue("\(destructiveButtonCount) taps")
+ }
+ } label: {
+ Label("SwiftUI button styles", systemImage: "rectangle.3.group")
+ .font(.headline)
+ }
+ .accessibilityIdentifier("button-styles-group")
+
+ GroupBox {
+ VStack(spacing: 10) {
+ verificationRow(
+ "Refresh",
+ value: toolbarRefreshCount,
+ identifier: "nav-refresh-count"
+ )
+ verificationRow(
+ "Menu add",
+ value: menuAddCount,
+ identifier: "menu-add-count"
+ )
+ verificationRow(
+ "Menu archive",
+ value: menuArchiveCount,
+ identifier: "menu-archive-count"
+ )
+ verificationRow(
+ "Detail opened / closed",
+ textValue: "\(detailOpenCount) / \(detailCloseCount)",
+ identifier: "detail-navigation-count"
+ )
+ }
+ } label: {
+ Label("Navigation and menu results", systemImage: "menubar.rectangle")
+ .font(.headline)
+ }
+ .accessibilityIdentifier("navigation-results-group")
+
+ Button {
+ detailOpenCount += 1
+ isShowingDetail = true
+ } label: {
+ Label("Open detail screen (\(detailOpenCount))", systemImage: "chevron.forward.circle.fill")
+ .frame(maxWidth: .infinity)
+ }
+ .buttonStyle(.borderedProminent)
+ .tint(.indigo)
+ .accessibilityIdentifier("open-detail-button")
+ .accessibilityValue("Opened \(detailOpenCount) times")
+ }
+ .padding()
+ }
+ .accessibilityIdentifier("controls-scroll-view")
+ .navigationTitle("Tap Lab")
+ .navigationBarTitleDisplayMode(.inline)
+ .toolbar {
+ ToolbarItem(placement: .navigationBarLeading) {
+ Button {
+ toolbarRefreshCount += 1
+ record("Navigation refresh tapped")
+ } label: {
+ Label("Refresh", systemImage: "arrow.clockwise")
+ }
+ .accessibilityIdentifier("nav-refresh-button")
+ .accessibilityValue("\(toolbarRefreshCount) refreshes")
+ }
+
+ ToolbarItem(placement: .navigationBarTrailing) {
+ Menu {
+ Button {
+ menuAddCount += 1
+ record("Add menu item selected")
+ } label: {
+ Label("Add item (\(menuAddCount))", systemImage: "plus")
+ }
+ .accessibilityIdentifier("menu-add-item")
+
+ Button {
+ menuArchiveCount += 1
+ record("Archive menu item selected")
+ } label: {
+ Label("Archive item (\(menuArchiveCount))", systemImage: "archivebox")
+ }
+ .accessibilityIdentifier("menu-archive-item")
+ } label: {
+ Image(systemName: "ellipsis.circle")
+ .accessibilityLabel("Harness actions menu")
+ }
+ .accessibilityIdentifier("toolbar-actions-menu")
+ .accessibilityValue("Add \(menuAddCount), archive \(menuArchiveCount)")
+ }
+ }
+ .navigationDestination(isPresented: $isShowingDetail) {
+ VStack(spacing: 18) {
+ verificationPanel
+
+ Image(systemName: "rectangle.stack.badge.play.fill")
+ .font(.system(size: 46))
+ .foregroundColor(.indigo)
+ .accessibilityIdentifier("detail-screen-artwork")
+
+ Text("Navigation destination")
+ .font(.title2.bold())
+ .accessibilityIdentifier("detail-screen-title")
+
+ Button {
+ detailButtonCount += 1
+ record("Detail button tapped")
+ } label: {
+ Label("Detail tap (\(detailButtonCount))", systemImage: "hand.tap")
+ .frame(maxWidth: .infinity)
+ }
+ .buttonStyle(.borderedProminent)
+ .accessibilityIdentifier("detail-action-button")
+ .accessibilityValue("\(detailButtonCount) taps")
+
+ Text("Detail count: \(detailButtonCount)")
+ .font(.headline.monospacedDigit())
+ .accessibilityIdentifier("detail-action-count")
+ }
+ .padding()
+ .navigationTitle("Detail")
+ .navigationBarTitleDisplayMode(.inline)
+ .navigationBarBackButtonHidden(true)
+ .toolbar {
+ ToolbarItem(placement: .navigationBarLeading) {
+ Button {
+ isShowingDetail = false
+ } label: {
+ Label("Back to Tap Lab", systemImage: "chevron.backward")
+ }
+ .accessibilityIdentifier("detail-back-button")
+ }
+ }
+ }
+ .onChange(of: isShowingDetail) { isShowing in
+ if isShowing {
+ record("Opened detail screen")
+ } else {
+ detailCloseCount += 1
+ record("Closed detail screen")
+ }
+ }
+ }
+ .accessibilityIdentifier("controls-navigation-stack")
+ }
+
+ private var inputsTab: some View {
+ NavigationStack {
+ ScrollView {
+ VStack(spacing: 14) {
+ verificationPanel
+
+ GroupBox {
+ VStack(spacing: 14) {
+ Toggle(isOn: Binding(
+ get: { toggleValue },
+ set: { newValue in
+ toggleValue = newValue
+ toggleChangeCount += 1
+ record("Toggle changed to \(newValue ? "on" : "off")")
+ }
+ )) {
+ Label("Harness toggle", systemImage: toggleValue ? "checkmark.circle.fill" : "circle")
+ }
+ .accessibilityIdentifier("harness-toggle")
+ .accessibilityValue(toggleValue ? "On" : "Off")
+
+ verificationRow(
+ "Toggle changes",
+ value: toggleChangeCount,
+ identifier: "toggle-change-count"
+ )
+
+ Divider()
+
+ Stepper(
+ value: Binding(
+ get: { stepperValue },
+ set: { newValue in
+ let direction = newValue > stepperValue ? "up" : "down"
+ stepperValue = newValue
+ stepperChangeCount += 1
+ record("Stepper moved \(direction) to \(newValue)")
+ }
+ ),
+ in: 0...9
+ ) {
+ Label("Stepper value: \(stepperValue)", systemImage: "plusminus.circle")
+ .monospacedDigit()
+ }
+ .accessibilityIdentifier("harness-stepper")
+ .accessibilityValue("Value \(stepperValue), changed \(stepperChangeCount) times")
+
+ verificationRow(
+ "Stepper changes",
+ value: stepperChangeCount,
+ identifier: "stepper-change-count"
+ )
+ verificationRow(
+ "Stepper value",
+ value: stepperValue,
+ identifier: "stepper-value"
+ )
+ }
+ } label: {
+ Label("Toggle and stepper", systemImage: "switch.2")
+ .font(.headline)
+ }
+ .accessibilityIdentifier("toggle-stepper-group")
+
+ GroupBox {
+ VStack(alignment: .leading, spacing: 12) {
+ Picker("Harness mode", selection: Binding(
+ get: { selectedMode },
+ set: { newValue in
+ selectedMode = newValue
+ pickerChangeCount += 1
+ record("Segment selected: \(newValue)")
+ }
+ )) {
+ Text("One").tag("One")
+ Text("Two").tag("Two")
+ Text("Three").tag("Three")
+ }
+ .pickerStyle(.segmented)
+ .accessibilityIdentifier("harness-segmented-picker")
+ .accessibilityValue("Selected \(selectedMode)")
+
+ verificationRow(
+ "Selected segment",
+ textValue: selectedMode,
+ identifier: "picker-selection-value"
+ )
+ verificationRow(
+ "Segment changes",
+ value: pickerChangeCount,
+ identifier: "picker-change-count"
+ )
+ }
+ } label: {
+ Label("Segmented picker", systemImage: "rectangle.split.3x1")
+ .font(.headline)
+ }
+ .accessibilityIdentifier("picker-group")
+
+ GroupBox {
+ VStack(alignment: .leading, spacing: 10) {
+ TextField("Type a device message", text: $draftText)
+ .focused($isTextFieldFocused)
+ .textFieldStyle(.roundedBorder)
+ .textInputAutocapitalization(.never)
+ .autocorrectionDisabled()
+ .submitLabel(.done)
+ .accessibilityIdentifier("harness-text-field")
+ .accessibilityValue(draftText)
+ .onSubmit {
+ commitText(source: "keyboard submit")
+ }
+ .onChange(of: draftText) { newValue in
+ textChangeCount += 1
+ record("Text changed to \(newValue.isEmpty ? "empty" : newValue)")
+ }
+
+ HStack {
+ Text("Echo: \(draftText.isEmpty ? "" : draftText)")
+ .font(.subheadline.monospaced())
+ .lineLimit(1)
+ .accessibilityIdentifier("text-echo-value")
+ .accessibilityValue(draftText)
+ Spacer()
+ Button("Commit") {
+ commitText(source: "commit button")
+ }
+ .buttonStyle(.bordered)
+ .accessibilityIdentifier("commit-text-button")
+ .accessibilityValue("Committed \(textCommitCount) times")
+ }
+
+ verificationRow(
+ "Text changes",
+ value: textChangeCount,
+ identifier: "text-change-count"
+ )
+ verificationRow(
+ "Text commits",
+ value: textCommitCount,
+ identifier: "text-commit-count"
+ )
+ }
+ } label: {
+ Label("Text entry", systemImage: "keyboard")
+ .font(.headline)
+ }
+ .accessibilityIdentifier("text-entry-group")
+
+ #if canImport(UIKit)
+ GroupBox {
+ VStack(spacing: 10) {
+ UIKitHarnessButton(count: uikitButtonCount) {
+ uikitButtonCount += 1
+ record("UIKit UIButton tapped")
+ }
+ .frame(maxWidth: .infinity, minHeight: 48)
+
+ verificationRow(
+ "UIKit taps",
+ value: uikitButtonCount,
+ identifier: "uikit-button-count"
+ )
+ }
+ } label: {
+ Label("Native UIKit control", systemImage: "iphone")
+ .font(.headline)
+ }
+ .accessibilityIdentifier("uikit-control-group")
+ #endif
+ }
+ .padding()
+ }
+ .accessibilityIdentifier("inputs-scroll-view")
+ .navigationTitle("Input Lab")
+ .navigationBarTitleDisplayMode(.inline)
+ }
+ .accessibilityIdentifier("inputs-navigation-stack")
+ }
+
+ private var rowsTab: some View {
+ NavigationStack {
+ List {
+ Section {
+ verificationPanel
+ .listRowInsets(EdgeInsets())
+ .listRowBackground(Color.clear)
+ }
+
+ Section("Tap a row") {
+ ForEach(Self.harnessRows) { row in
+ Button {
+ let count = rowTapCounts[row.id, default: 0] + 1
+ rowTapCounts[row.id] = count
+ record("\(row.title) tapped")
+ } label: {
+ HStack(spacing: 12) {
+ Image(systemName: row.symbol)
+ .foregroundColor(.accentColor)
+ Text(row.title)
+ Spacer()
+ Text("\(rowTapCounts[row.id, default: 0])")
+ .font(.headline.monospacedDigit())
+ .foregroundColor(.secondary)
+ .accessibilityIdentifier("row-\(row.id)-count")
+ }
+ .contentShape(Rectangle())
+ }
+ .buttonStyle(.plain)
+ .accessibilityIdentifier("row-\(row.id)-button")
+ .accessibilityValue("\(rowTapCounts[row.id, default: 0]) taps")
+ .swipeActions(edge: .leading, allowsFullSwipe: false) {
+ Button {
+ rowFlagCount += 1
+ record("Flagged \(row.title)")
+ } label: {
+ Label("Flag", systemImage: "flag.fill")
+ }
+ .tint(.orange)
+ .accessibilityIdentifier("row-\(row.id)-flag-action")
+ }
+ .swipeActions(edge: .trailing, allowsFullSwipe: false) {
+ Button {
+ rowArchiveCount += 1
+ record("Archived \(row.title)")
+ } label: {
+ Label("Archive", systemImage: "archivebox.fill")
+ }
+ .tint(.indigo)
+ .accessibilityIdentifier("row-\(row.id)-archive-action")
+ }
+ }
+ }
+
+ Section("Row action results") {
+ verificationRow(
+ "Flags",
+ value: rowFlagCount,
+ identifier: "row-flag-count"
+ )
+ verificationRow(
+ "Archives",
+ value: rowArchiveCount,
+ identifier: "row-archive-count"
+ )
+ verificationRow(
+ "Toolbar checks",
+ value: rowToolbarCount,
+ identifier: "row-toolbar-count"
+ )
+ }
+ }
+ .listStyle(.insetGrouped)
+ .accessibilityIdentifier("rows-list")
+ .navigationTitle("Row Lab")
+ .navigationBarTitleDisplayMode(.inline)
+ .toolbar {
+ ToolbarItem(placement: .navigationBarTrailing) {
+ Button {
+ rowToolbarCount += 1
+ record("Rows toolbar check tapped")
+ } label: {
+ Label("Check rows", systemImage: "checkmark.circle")
+ }
+ .accessibilityIdentifier("rows-toolbar-button")
+ .accessibilityValue("\(rowToolbarCount) checks")
+ }
+ }
+ }
+ .accessibilityIdentifier("rows-navigation-stack")
+ }
+
+ private var verificationPanel: some View {
+ VerificationPanel(
+ lastAction: lastAction,
+ totalActions: totalActions,
+ selectedTab: selectedTab.title,
+ tabChangeCount: tabChangeCount
+ )
+ }
+
+ private func buttonLabel(_ title: String, count: Int, symbol: String) -> some View {
+ VStack(spacing: 4) {
+ Label(title, systemImage: symbol)
+ .lineLimit(1)
+ Text("Count \(count)")
+ .font(.caption.monospacedDigit())
+ .accessibilityIdentifier("\(title.lowercased())-button-count")
+ }
+ .frame(maxWidth: .infinity, minHeight: 40)
+ }
+
+ private func verificationRow(_ label: String, value: Int, identifier: String) -> some View {
+ verificationRow(label, textValue: String(value), identifier: identifier)
+ }
+
+ private func verificationRow(_ label: String, textValue: String, identifier: String) -> some View {
+ HStack {
+ Text(label)
+ .foregroundColor(.secondary)
+ Spacer()
+ Text(textValue)
+ .font(.subheadline.bold().monospacedDigit())
+ .accessibilityIdentifier(identifier)
+ .accessibilityLabel(label)
+ .accessibilityValue(textValue)
+ }
+ }
+
+ private func commitText(source: String) {
+ textCommitCount += 1
+ isTextFieldFocused = false
+ record("Text committed from \(source): \(draftText.isEmpty ? "empty" : draftText)")
+ }
+
+ private func record(_ action: String) {
+ totalActions += 1
+ lastAction = action
}
}
+
+private struct VerificationPanel: View {
+ let lastAction: String
+ let totalActions: Int
+ let selectedTab: String
+ let tabChangeCount: Int
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 8) {
+ HStack {
+ Label("LIVE", systemImage: "waveform.path.ecg")
+ .font(.caption.bold())
+ .foregroundColor(.green)
+ .accessibilityIdentifier("harness-live-indicator")
+ Spacer()
+ Text("Total \(totalActions)")
+ .font(.caption.bold().monospacedDigit())
+ .accessibilityIdentifier("total-action-count")
+ .accessibilityLabel("Total actions")
+ .accessibilityValue("\(totalActions)")
+ }
+
+ Text(lastAction)
+ .font(.subheadline.weight(.semibold))
+ .lineLimit(2)
+ .accessibilityIdentifier("last-action-status")
+ .accessibilityLabel("Last action")
+ .accessibilityValue(lastAction)
+
+ HStack {
+ Text("Tab: \(selectedTab)")
+ .accessibilityIdentifier("selected-tab-status")
+ .accessibilityValue(selectedTab)
+ Spacer()
+ Text("Tab changes: \(tabChangeCount)")
+ .monospacedDigit()
+ .accessibilityIdentifier("tab-change-count")
+ .accessibilityValue("\(tabChangeCount)")
+ }
+ .font(.caption)
+ .foregroundColor(.secondary)
+ }
+ .padding(12)
+ .background(Color.green.opacity(0.10))
+ .overlay {
+ RoundedRectangle(cornerRadius: 12)
+ .stroke(Color.green.opacity(0.35), lineWidth: 1)
+ }
+ .clipShape(RoundedRectangle(cornerRadius: 12))
+ .accessibilityElement(children: .contain)
+ .accessibilityIdentifier("verification-panel")
+ }
+}
+
+#if canImport(UIKit)
+private struct UIKitHarnessButton: UIViewRepresentable {
+ let count: Int
+ let action: () -> Void
+
+ final class Coordinator: NSObject {
+ var action: () -> Void
+
+ init(action: @escaping () -> Void) {
+ self.action = action
+ }
+
+ @objc func tapped() {
+ action()
+ }
+ }
+
+ func makeCoordinator() -> Coordinator {
+ Coordinator(action: action)
+ }
+
+ func makeUIView(context: Context) -> UIButton {
+ let button = UIButton(type: .system)
+ var configuration = UIButton.Configuration.filled()
+ configuration.cornerStyle = .medium
+ configuration.image = UIImage(systemName: "hand.tap.fill")
+ configuration.imagePadding = 8
+ button.configuration = configuration
+ button.accessibilityIdentifier = "uikit-button"
+ button.accessibilityLabel = "UIKit button"
+ button.addTarget(context.coordinator, action: #selector(Coordinator.tapped), for: .touchUpInside)
+ return button
+ }
+
+ func updateUIView(_ button: UIButton, context: Context) {
+ context.coordinator.action = action
+ var configuration = button.configuration ?? UIButton.Configuration.filled()
+ configuration.title = "UIKit Tap (\(count))"
+ button.configuration = configuration
+ button.accessibilityValue = "\(count) taps"
+ }
+}
+#endif
diff --git a/test/fixtures/ios-qa/FixtureApp/Sources/FixtureApp/FixtureAppState.swift b/test/fixtures/ios-qa/FixtureApp/Sources/FixtureApp/FixtureAppState.swift
index 2980cd9e7..13861ab03 100644
--- a/test/fixtures/ios-qa/FixtureApp/Sources/FixtureApp/FixtureAppState.swift
+++ b/test/fixtures/ios-qa/FixtureApp/Sources/FixtureApp/FixtureAppState.swift
@@ -1,32 +1,22 @@
-// Canonical app state for the fixture. Every snapshot-eligible field is
-// marked with the @Snapshotable property wrapper that the codegen tool
-// detects via attribute scan.
-//
-// Note: we DON'T use @Observable here because the macro expansion converts
-// stored properties into computed ones, which the @Snapshotable wrapper
-// can't apply to. In production apps that need both observability AND
-// snapshotting, the right pattern is:
-// - Use ObservableObject + @Published (older API), or
-// - Hold all @Snapshotable state in a nested struct + replace it
-// wholesale on restore so SwiftUI sees a single change notification
-// (the canonical-state-struct atomicity strategy from the plan).
+// Canonical observable app state for the fixture. Snapshot eligibility is a
+// generator-only source marker, not a property wrapper, so it composes with
+// Observation's @Observable macro.
import Foundation
+import Observation
-public final class FixtureAppState {
- @Snapshotable public var isLoggedIn: Bool = false
- @Snapshotable public var username: String = ""
- @Snapshotable public var tapCounter: Int = 0
+@Observable
+final class FixtureAppState {
+ // @Snapshotable
+ var isLoggedIn: Bool = false
+ // @Snapshotable
+ var username: String = ""
+ // @Snapshotable
+ var tapCounter: Int = 0
+ // @Snapshotable
+ var nickname: String? = nil
/// Not snapshotted — ephemeral cache that should never leak via /state/snapshot.
- public var ephemeralCache: [String: String] = [:]
+ var ephemeralCache: [String: String] = [:]
- public init() {}
-}
-
-/// Property wrapper marker for snapshot-eligible state. The actual wrapper
-/// is a no-op at runtime; codegen-tool detection happens via attribute scan.
-@propertyWrapper
-public struct Snapshotable {
- public var wrappedValue: Value
- public init(wrappedValue: Value) { self.wrappedValue = wrappedValue }
+ init() {}
}
diff --git a/test/fixtures/ios-qa/FixtureApp/project.yml b/test/fixtures/ios-qa/FixtureApp/project.yml
index 35906f7b2..c3c172883 100644
--- a/test/fixtures/ios-qa/FixtureApp/project.yml
+++ b/test/fixtures/ios-qa/FixtureApp/project.yml
@@ -1,7 +1,7 @@
name: FixtureApp
options:
deploymentTarget:
- iOS: "16.0"
+ iOS: "17.0"
bundleIdPrefix: com.gstack.iosqa
developmentLanguage: en
createIntermediateGroups: true
@@ -23,7 +23,7 @@ targets:
FixtureApp:
type: application
platform: iOS
- deploymentTarget: "16.0"
+ deploymentTarget: "17.0"
sources:
- path: Sources/FixtureApp
dependencies:
@@ -45,5 +45,5 @@ targets:
CODE_SIGN_STYLE: Automatic
TARGETED_DEVICE_FAMILY: "1"
SWIFT_VERSION: "5.9"
- IPHONEOS_DEPLOYMENT_TARGET: "16.0"
+ IPHONEOS_DEPLOYMENT_TARGET: "17.0"
ENABLE_PREVIEWS: YES
diff --git a/test/gstack-memory-helpers.test.ts b/test/gstack-memory-helpers.test.ts
index 7b356a99c..244a64976 100644
--- a/test/gstack-memory-helpers.test.ts
+++ b/test/gstack-memory-helpers.test.ts
@@ -67,6 +67,30 @@ describe("canonicalizeRemote", () => {
it("collapses redundant slashes", () => {
expect(canonicalizeRemote("https://github.com//foo//bar")).toBe("github.com/foo/bar");
});
+
+ it("strips .git even when the URL has a trailing slash", () => {
+ // A remote configured with both a .git suffix and a trailing slash must
+ // canonicalize to the same key as one without — otherwise the same repo
+ // gets two dedup/source-id keys across machines.
+ expect(canonicalizeRemote("https://github.com/garrytan/gstack.git/")).toBe("github.com/garrytan/gstack");
+ expect(canonicalizeRemote("git@github.com:garrytan/gstack.git/")).toBe("github.com/garrytan/gstack");
+ expect(canonicalizeRemote("https://github.com/foo/bar.git///")).toBe("github.com/foo/bar");
+ });
+
+ it("produces the same key with or without a trailing slash", () => {
+ expect(canonicalizeRemote("https://github.com/garrytan/gstack.git/")).toBe(
+ canonicalizeRemote("https://github.com/garrytan/gstack.git")
+ );
+ });
+
+ it("canonicalizes a path remote ending in a .git directory component", () => {
+ // Stripping the `.git` suffix exposes a new trailing slash
+ // ("/repo/.git" → "/repo/") which must also be stripped, or the same
+ // repo splits into two identities.
+ expect(canonicalizeRemote("file:///Users/x/repo/.git")).toBe(
+ canonicalizeRemote("file:///Users/x/repo")
+ );
+ });
});
// ── secretScanFile ─────────────────────────────────────────────────────────
@@ -244,6 +268,62 @@ body
expect(m!.context_queries[0].id).toBe("complete");
rmSync(dir, { recursive: true, force: true });
});
+
+ it("parses a nested filter: block on a list query", () => {
+ const dir = mkdtempSync(join(tmpdir(), "gstack-test-"));
+ const file = join(dir, "filtered.md");
+ writeFileSync(
+ file,
+ `---
+name: investigate
+gbrain:
+ schema: 1
+ context_queries:
+ - id: prior-investigations
+ kind: list
+ filter:
+ type: timeline
+ tags_contains: "repo:{repo_slug}"
+ content_contains: "investigate"
+ sort: updated_at_desc
+ limit: 5
+ render_as: "## Prior investigations in this repo"
+ - id: recent-no-filter
+ kind: list
+ sort: created_at_desc
+ limit: 3
+ render_as: "## Recent (no filter)"
+---
+
+body
+`
+ );
+
+ const m = parseSkillManifest(file);
+ expect(m).not.toBeNull();
+ expect(m!.context_queries).toHaveLength(2);
+
+ // The filter: sub-block is parsed into a key/value map, with quotes
+ // stripped and template vars left intact for downstream substitution.
+ const filtered = m!.context_queries[0];
+ expect(filtered.id).toBe("prior-investigations");
+ expect(filtered.filter).toEqual({
+ type: "timeline",
+ tags_contains: "repo:{repo_slug}",
+ content_contains: "investigate",
+ });
+ // Sibling fields on the same item still parse alongside the filter.
+ expect(filtered.sort).toBe("updated_at_desc");
+ expect(filtered.limit).toBe(5);
+ expect(filtered.render_as).toBe("## Prior investigations in this repo");
+
+ // A list query with no filter: leaves filter undefined (no regression).
+ expect(m!.context_queries[1].id).toBe("recent-no-filter");
+ expect(m!.context_queries[1].filter).toBeUndefined();
+ expect(m!.context_queries[1].sort).toBe("created_at_desc");
+
+ rmSync(dir, { recursive: true, force: true });
+ });
});
// ── withErrorContext ───────────────────────────────────────────────────────
diff --git a/test/helpers/session-runner.ts b/test/helpers/session-runner.ts
index 88933d739..f433332f5 100644
--- a/test/helpers/session-runner.ts
+++ b/test/helpers/session-runner.ts
@@ -173,13 +173,9 @@ export async function runSkillTest(options: {
// restores operator MCP along with the operator env.
if (isHermeticEnabled()) args.push('--strict-mcp-config');
- // Write prompt to a temp file OUTSIDE workingDirectory to avoid race conditions
- // where afterAll cleanup deletes the dir before cat reads the file (especially
- // with --concurrent --retry). Using os.tmpdir() + unique suffix keeps it stable.
- const promptFile = path.join(os.tmpdir(), `.prompt-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}`);
- fs.writeFileSync(promptFile, prompt);
-
- const proc = Bun.spawn(['sh', '-c', `cat "${promptFile}" | claude ${args.map(a => `"${a}"`).join(' ')}`], {
+ // Spawn claude directly with array-form args (no shell interpolation).
+ // Prompt is piped via stdin using a Blob to avoid temp files and shell escaping.
+ const proc = Bun.spawn(['claude', ...args], {
cwd: workingDirectory,
// Hermetic by default (see test/helpers/hermetic-env.ts): operator
// session context (CONDUCTOR_*, CLAUDECODE, ~/.claude config, ~/.gstack)
@@ -189,6 +185,7 @@ export async function runSkillTest(options: {
// suite exercising the INTERACTIVE prose-fallback path opts out by passing
// `env: { GSTACK_HEADLESS: '' }` — extraEnv wins because it spreads last.
env: hermeticChildEnv({ GSTACK_HEADLESS: '1', ...extraEnv }),
+ stdin: new Blob([prompt]),
stdout: 'pipe',
stderr: 'pipe',
});
@@ -201,11 +198,12 @@ export async function runSkillTest(options: {
const timeoutId = setTimeout(() => {
timedOut = true;
proc.kill();
- // proc.kill() only signals the `sh -c` wrapper. The claude child it
- // spawned can survive as an orphan that inherited our stdout/stderr
- // pipes, so without cancel() the read loop below blocks until the
- // orphan finally exits (observed: a 600s timeout stretching past 1400s
- // and tripping bun's per-test timeout instead of returning a result).
+ // proc.kill() signals claude itself (direct spawn, no shell wrapper),
+ // but tool subprocesses claude spawned can survive as orphans that
+ // inherited our stdout/stderr pipes, so without cancel() the read loop
+ // below blocks until the orphan finally exits (observed: a 600s timeout
+ // stretching past 1400s and tripping bun's per-test timeout instead of
+ // returning a result).
reader.cancel().catch(() => { /* stream already closed */ });
}, timeout);
@@ -233,6 +231,11 @@ export async function runSkillTest(options: {
if (!line.trim()) continue;
collectedLines.push(line);
+ // Track time to first NDJSON line (measures latency from spawn to first Claude response)
+ if (firstResponseMs === 0) {
+ firstResponseMs = Date.now() - startTime;
+ }
+
// Real-time progress to stderr + persistent logs
try {
const event = JSON.parse(line);
@@ -244,8 +247,7 @@ export async function runSkillTest(options: {
liveToolCount++;
const now = Date.now();
const elapsed = Math.round((now - startTime) / 1000);
- // Track timing telemetry
- if (firstResponseMs === 0) firstResponseMs = now - startTime;
+ // Track inter-turn latency (tool call to tool call)
if (lastToolTime > 0) {
const interTurn = now - lastToolTime;
if (interTurn > maxInterTurnMs) maxInterTurnMs = interTurn;
@@ -310,8 +312,6 @@ export async function runSkillTest(options: {
const exitCode = await proc.exited;
clearTimeout(timeoutId);
- try { fs.unlinkSync(promptFile); } catch { /* non-fatal */ }
-
if (timedOut) {
exitReason = 'timeout';
} else if (exitCode === 0) {
diff --git a/test/hook-scripts.test.ts b/test/hook-scripts.test.ts
index f1ffe1239..db2e7629f 100644
--- a/test/hook-scripts.test.ts
+++ b/test/hook-scripts.test.ts
@@ -96,6 +96,21 @@ describe('check-careful.sh', () => {
expect(output.permissionDecision).toBe('ask');
expect(output.message).toContain('recursive delete');
});
+
+ test.each([
+ 'rm -rf /; rm -rf node_modules',
+ 'rm -rf / && rm -rf node_modules',
+ 'rm -rf / # rm -rf node_modules',
+ 'rm -rf node_modules; rm -rf /',
+ 'rm -rf node_modules || rm -rf /',
+ 'echo ok && rm -rf /',
+ 'rm -rf node_modules\nrm -rf /',
+ ])('never lets a safe-looking target hide a destructive command: %s', (command) => {
+ const { exitCode, output } = runHook(CAREFUL_SCRIPT, carefulInput(command));
+ expect(exitCode).toBe(0);
+ expect(output.permissionDecision).toBe('ask');
+ expect(output.message).toContain('recursive delete');
+ });
});
// --- SQL destructive commands ---
diff --git a/test/host-config.test.ts b/test/host-config.test.ts
index 577057033..1c939d7be 100644
--- a/test/host-config.test.ts
+++ b/test/host-config.test.ts
@@ -24,8 +24,10 @@ import {
openclaw,
} from '../hosts/index';
import { HOST_PATHS } from '../scripts/resolvers/types';
+import { RESOLVERS } from '../scripts/resolvers';
const ROOT = path.resolve(import.meta.dir, '..');
+const RESOLVER_NAMES = new Set(Object.keys(RESOLVERS));
// ─── hosts/index.ts ─────────────────────────────────────────
@@ -205,13 +207,32 @@ describe('validateHostConfig', () => {
c.cliCommand = 'opencode;rm -rf /';
expect(validateHostConfig(c).some(e => e.includes('cliCommand'))).toBe(true);
});
+
+ test('valid suppressedResolvers pass when resolver names provided', () => {
+ const c = makeValid();
+ c.suppressedResolvers = ['DESIGN_OUTSIDE_VOICES', 'REVIEW_ARMY'];
+ expect(validateHostConfig(c, RESOLVER_NAMES)).toEqual([]);
+ });
+
+ test('unknown suppressedResolvers entry is caught', () => {
+ const c = makeValid();
+ c.suppressedResolvers = ['DESIGN_OUTSIDE_VOICES', 'NONEXISTENT_RESOLVER'];
+ const errors = validateHostConfig(c, RESOLVER_NAMES);
+ expect(errors.some(e => e.includes('NONEXISTENT_RESOLVER'))).toBe(true);
+ });
+
+ test('suppressedResolvers unchecked when resolver names omitted', () => {
+ const c = makeValid();
+ c.suppressedResolvers = ['TYPO_RESOLVER'];
+ expect(validateHostConfig(c)).toEqual([]);
+ });
});
// ─── validateAllConfigs ─────────────────────────────────────
describe('validateAllConfigs', () => {
test('real configs all pass validation', () => {
- const errors = validateAllConfigs(ALL_HOST_CONFIGS);
+ const errors = validateAllConfigs(ALL_HOST_CONFIGS, RESOLVER_NAMES);
expect(errors).toEqual([]);
});
@@ -233,6 +254,12 @@ describe('validateAllConfigs', () => {
expect(errors.some(e => e.includes('Duplicate globalRoot'))).toBe(true);
});
+ test('unknown suppressedResolvers entry surfaces with host-name prefix', () => {
+ const bad = { ...codex, name: 'bad-host', hostSubdir: '.bad', globalRoot: '.bad/skills/gstack', suppressedResolvers: ['BOGUS_RESOLVER'] } as HostConfig;
+ const errors = validateAllConfigs([bad], RESOLVER_NAMES);
+ expect(errors.some(e => e.startsWith('[bad-host]') && e.includes('BOGUS_RESOLVER'))).toBe(true);
+ });
+
test('per-config validation errors are prefixed with host name', () => {
const bad = { ...codex, name: 'BAD', cliCommand: 'also bad' } as HostConfig;
const errors = validateAllConfigs([bad]);
diff --git a/test/ios-qa-regen.test.ts b/test/ios-qa-regen.test.ts
new file mode 100644
index 000000000..0378869dd
--- /dev/null
+++ b/test/ios-qa-regen.test.ts
@@ -0,0 +1,258 @@
+import { afterEach, describe, expect, test } from 'bun:test';
+import { createHash } from 'crypto';
+import { spawnSync } from 'child_process';
+import {
+ chmodSync,
+ copyFileSync,
+ existsSync,
+ mkdirSync,
+ mkdtempSync,
+ readFileSync,
+ readdirSync,
+ rmSync,
+ statSync,
+ writeFileSync,
+} from 'fs';
+import { tmpdir } from 'os';
+import { join, relative } from 'path';
+
+const ROOT = join(import.meta.dir, '..');
+const SAFE_TEMPLATE_MAP = [
+ ['Package.swift.template', 'Package.swift'],
+ ['StateServer.swift.template', 'Sources/DebugBridgeCore/StateServer.swift'],
+ ['DebugBridgeManager.swift.template', 'Sources/DebugBridgeCore/DebugBridgeManager.swift'],
+ ['Bridges.swift.template', 'Sources/DebugBridgeUI/Bridges.swift'],
+ ['DebugOverlay.swift.template', 'Sources/DebugBridgeUI/DebugOverlay.swift'],
+ ['DebugBridgeTouch.m.template', 'Sources/DebugBridgeTouch/DebugBridgeTouch.m'],
+ ['DebugBridgeTouch.h.template', 'Sources/DebugBridgeTouch/include/DebugBridgeTouch.h'],
+] as const;
+
+const workDirs: string[] = [];
+
+afterEach(() => {
+ for (const dir of workDirs.splice(0)) {
+ rmSync(dir, { recursive: true, force: true });
+ }
+});
+
+function copyIntoFakeInstall(workDir: string): { root: string; launcher: string } {
+ const root = join(workDir, 'fake gstack install');
+ const binDir = join(root, 'bin');
+ const scriptsDir = join(root, 'ios-qa', 'scripts');
+ const templatesDir = join(root, 'ios-qa', 'templates');
+ mkdirSync(binDir, { recursive: true });
+ mkdirSync(scriptsDir, { recursive: true });
+ mkdirSync(templatesDir, { recursive: true });
+
+ const launcher = join(binDir, 'gstack-ios-qa-regen');
+ copyFileSync(join(ROOT, 'bin', 'gstack-ios-qa-regen'), launcher);
+ chmodSync(launcher, 0o755);
+ copyFileSync(join(ROOT, 'ios-qa', 'scripts', 'gen-accessors.ts'), join(scriptsDir, 'gen-accessors.ts'));
+ for (const [template] of SAFE_TEMPLATE_MAP) {
+ copyFileSync(join(ROOT, 'ios-qa', 'templates', template), join(templatesDir, template));
+ }
+
+ // These files deliberately exist in the discovered template directory. If
+ // the launcher ever regresses to wildcard copying, their sentinel content
+ // will escape into the app and fail the absence assertions below.
+ writeFileSync(join(templatesDir, 'DebugBridgeWiring.swift.template'), '// FORBIDDEN-WIRING-SENTINEL\n');
+ writeFileSync(join(templatesDir, 'StateAccessor.swift.template'), '// FORBIDDEN-STATE-SENTINEL\n');
+ writeFileSync(join(root, 'VERSION'), '9.8.7.6\n');
+ return { root, launcher };
+}
+
+function treeHash(...roots: string[]): string {
+ const hash = createHash('sha256');
+ for (const root of roots) {
+ const visit = (dir: string): void => {
+ for (const name of readdirSync(dir).sort()) {
+ const path = join(dir, name);
+ const stat = statSync(path);
+ if (stat.isDirectory()) {
+ visit(path);
+ } else {
+ hash.update(relative(root, path));
+ hash.update('\0');
+ hash.update(readFileSync(path));
+ hash.update('\0');
+ }
+ }
+ };
+ visit(root);
+ }
+ return hash.digest('hex');
+}
+
+function allFileContents(root: string): string {
+ let contents = '';
+ const visit = (dir: string): void => {
+ for (const name of readdirSync(dir)) {
+ const path = join(dir, name);
+ if (statSync(path).isDirectory()) visit(path);
+ else contents += readFileSync(path, 'utf8');
+ }
+ };
+ visit(root);
+ return contents;
+}
+
+describe('gstack-ios-qa-regen', () => {
+ test('repository launcher is executable', () => {
+ expect(statSync(join(ROOT, 'bin', 'gstack-ios-qa-regen')).mode & 0o111).not.toBe(0);
+ });
+
+ test('requires the documented app-source and bridge-dir contract', () => {
+ const workDir = mkdtempSync(join(tmpdir(), 'ios-qa-regen-'));
+ workDirs.push(workDir);
+ const { launcher } = copyIntoFakeInstall(workDir);
+ const result = spawnSync('bash', [launcher, '--app-source', workDir], { encoding: 'utf8' });
+
+ expect(result.status).toBe(2);
+ expect(result.stderr).toContain('both --app-source and --bridge-dir are required');
+ });
+
+ test('leaves no completion marker when accessor generation fails', () => {
+ const workDir = mkdtempSync(join(tmpdir(), 'ios-qa-regen-'));
+ workDirs.push(workDir);
+ const { launcher } = copyIntoFakeInstall(workDir);
+ const appSource = join(workDir, 'app-source');
+ const bridgeDir = join(workDir, 'bridge');
+ const generatedDir = join(appSource, 'DebugBridgeGenerated');
+ const fakeBin = join(workDir, 'fake-bin');
+ mkdirSync(generatedDir, { recursive: true });
+ mkdirSync(fakeBin, { recursive: true });
+ writeFileSync(join(appSource, 'AppState.swift'), '@Observable final class AppState {}\n');
+ writeFileSync(join(generatedDir, '.gstack-version'), 'stale-complete-marker\n');
+ const fakeBun = join(fakeBin, 'bun');
+ writeFileSync(fakeBun, '#!/bin/sh\nexit 17\n');
+ chmodSync(fakeBun, 0o755);
+
+ const result = spawnSync('bash', [
+ launcher,
+ '--app-source', appSource,
+ '--bridge-dir', bridgeDir,
+ ], {
+ encoding: 'utf8',
+ env: { ...process.env, PATH: `${fakeBin}:${process.env.PATH ?? ''}` },
+ });
+
+ expect(result.status).toBe(17);
+ expect(existsSync(join(generatedDir, '.gstack-version'))).toBe(false);
+ });
+
+ test('regenerates the allowlisted package and accessors idempotently', () => {
+ const workDir = mkdtempSync(join(tmpdir(), 'ios-qa-regen-'));
+ workDirs.push(workDir);
+ const { root, launcher } = copyIntoFakeInstall(workDir);
+ const appSource = join(workDir, 'app source');
+ const bridgeDir = join(appSource, 'DebugBridge');
+ const generatedDir = join(appSource, 'DebugBridgeGenerated');
+ const cacheRoot = join(workDir, 'isolated cache');
+ mkdirSync(appSource, { recursive: true });
+ writeFileSync(join(appSource, 'AppState.swift'), `
+@Observable
+final class AppState {
+ // @Snapshotable
+ var counter: Int = 0
+}
+`);
+
+ // Seed the flat legacy layout that old ios-sync versions produced. A
+ // correct regeneration must remove these known generated artifacts rather
+ // than letting Xcode compile a second, stale harness implementation.
+ mkdirSync(generatedDir, { recursive: true });
+ for (const obsolete of [
+ join(bridgeDir, 'DebugBridgeWiring.swift'),
+ join(bridgeDir, 'StateAccessor.swift'),
+ join(generatedDir, 'Package.swift'),
+ join(generatedDir, 'StateServer.swift'),
+ join(generatedDir, 'DebugBridgeManager.swift'),
+ join(generatedDir, 'Bridges.swift'),
+ join(generatedDir, 'DebugOverlay.swift'),
+ join(generatedDir, 'DebugBridgeTouch.m'),
+ join(generatedDir, 'DebugBridgeTouch.h'),
+ join(generatedDir, 'DebugBridgeWiring.swift'),
+ ]) {
+ mkdirSync(join(obsolete, '..'), { recursive: true });
+ writeFileSync(obsolete, '// OBSOLETE-HARNESS-SENTINEL\n');
+ }
+
+ const env = {
+ ...process.env,
+ GSTACK_IOS_CACHE_ROOT: cacheRoot,
+ SWIFT_VERSION: '6.3.3',
+ GEN_ACCESSORS_REV: 'regen-test',
+ };
+ const args = [launcher, '--app-source', appSource, '--bridge-dir', bridgeDir];
+ const first = spawnSync('bash', args, { encoding: 'utf8', env });
+ expect(first.status).toBe(0);
+ expect(first.stderr).toBe('');
+
+ // Every installed package file must be byte-identical to its explicit
+ // source template: this is the durable template/output parity contract.
+ for (const [template, destination] of SAFE_TEMPLATE_MAP) {
+ expect(readFileSync(join(bridgeDir, destination))).toEqual(
+ readFileSync(join(root, 'ios-qa', 'templates', template)),
+ );
+ }
+
+ const accessorPath = join(generatedDir, 'StateAccessor.swift');
+ const accessor = readFileSync(accessorPath, 'utf8');
+ expect(accessor).toContain('import DebugBridgeCore');
+ expect(accessor).toContain('enum AppStateAccessor');
+ expect(accessor).not.toContain('public enum AppStateAccessor');
+ expect(accessor).not.toContain('import DebugBridge\n');
+ expect(accessor).toContain('guard let restored0 = Self.decodeSnapshotValue(raw0, as: Int.self)');
+ expect(accessor).toContain('atomicRestore: { keys, apply in');
+ expect(accessor).toContain('state.counter = restored0');
+ expect(accessor).toContain('state.counter = typed');
+ expect(accessor).toContain('return true');
+ expect(accessor).not.toContain('atomicRestore: { _ in .ok }');
+ expect(accessor).not.toContain('write: { _ in false }');
+ expect(readFileSync(join(generatedDir, '.gstack-version'), 'utf8')).toBe(
+ readFileSync(join(root, 'VERSION'), 'utf8'),
+ );
+
+ expect(existsSync(join(bridgeDir, 'DebugBridgeWiring.swift'))).toBe(false);
+ expect(existsSync(join(bridgeDir, 'StateAccessor.swift'))).toBe(false);
+ for (const obsoleteName of [
+ 'Package.swift',
+ 'StateServer.swift',
+ 'DebugBridgeManager.swift',
+ 'Bridges.swift',
+ 'DebugOverlay.swift',
+ 'DebugBridgeTouch.m',
+ 'DebugBridgeTouch.h',
+ 'DebugBridgeWiring.swift',
+ ]) {
+ expect(existsSync(join(generatedDir, obsoleteName))).toBe(false);
+ }
+ const installedContents = allFileContents(bridgeDir) + allFileContents(generatedDir);
+ expect(installedContents).not.toContain('FORBIDDEN-WIRING-SENTINEL');
+ expect(installedContents).not.toContain('FORBIDDEN-STATE-SENTINEL');
+ expect(installedContents).not.toContain('OBSOLETE-HARNESS-SENTINEL');
+
+ const swiftAvailable = spawnSync('swift', ['--version'], { encoding: 'utf8' }).status === 0;
+ if (swiftAvailable) {
+ const dump = spawnSync('swift', ['package', 'dump-package', '--package-path', bridgeDir], {
+ encoding: 'utf8',
+ });
+ expect(dump.status).toBe(0);
+ const manifest = JSON.parse(dump.stdout) as { targets: Array<{ name: string }> };
+ expect(manifest.targets.map(target => target.name).sort()).toEqual([
+ 'DebugBridgeCore',
+ 'DebugBridgeTouch',
+ 'DebugBridgeUI',
+ ]);
+ }
+
+ const firstHash = treeHash(bridgeDir, generatedDir);
+ const firstAccessorHash = accessor.match(/accessorHash: "([a-f0-9]+)"/)?.[1];
+ const second = spawnSync('bash', args, { encoding: 'utf8', env });
+ expect(second.status).toBe(0);
+ expect(second.stderr).toBe('');
+ expect(second.stdout).toContain('gen-accessors: cache hit');
+ expect(treeHash(bridgeDir, generatedDir)).toBe(firstHash);
+ expect(readFileSync(accessorPath, 'utf8').match(/accessorHash: "([a-f0-9]+)"/)?.[1]).toBe(firstAccessorHash);
+ });
+});
diff --git a/test/ios-qa-swiftui-tap-regression.test.ts b/test/ios-qa-swiftui-tap-regression.test.ts
new file mode 100644
index 000000000..36aa7e924
--- /dev/null
+++ b/test/ios-qa-swiftui-tap-regression.test.ts
@@ -0,0 +1,32 @@
+import { describe, expect, test } from 'bun:test';
+import { readFileSync } from 'fs';
+import { join } from 'path';
+
+const ROOT = join(import.meta.dir, '..');
+const PRE_FIXTURE = join(ROOT, 'test/fixtures/ios-fix/ios-qa-swiftui-tap-pre.json');
+const PRE_SCREENSHOT = join(ROOT, 'test/fixtures/ios-fix/ios-qa-swiftui-tap-pre.png');
+
+describe('ios-fix regression fixture — SwiftUI taps reported success without acting', () => {
+ test('preserves the pre-fix state and physical-device screenshot', () => {
+ const state = JSON.parse(readFileSync(PRE_FIXTURE, 'utf8'));
+ expect(state).toEqual({
+ _schema_version: 1,
+ _app_build_id: 'uninitialized',
+ _accessor_hash: 'uninitialized',
+ keys: {},
+ });
+
+ const png = readFileSync(PRE_SCREENSHOT);
+ expect([...png.subarray(0, 8)]).toEqual([137, 80, 78, 71, 13, 10, 26, 10]);
+ expect(png.readUInt32BE(16)).toBe(1206);
+ expect(png.readUInt32BE(20)).toBe(2622);
+ });
+
+ test('keeps the physical-device deploy/tap test opt-in and executable', () => {
+ const deviceTest = readFileSync(join(ROOT, 'test/skill-e2e-ios-device.test.ts'), 'utf8');
+ expect(deviceTest).toContain("process.env.GSTACK_IOS_DEVICE_DEPLOY === '1'");
+ expect(deviceTest).toContain("'primary-button'");
+ expect(deviceTest).toContain("'/tap'");
+ expect(deviceTest).not.toContain("test.skip('TODO(deploy)");
+ });
+});
diff --git a/test/one-way-doors.test.ts b/test/one-way-doors.test.ts
index 382200408..dcd1a5b38 100644
--- a/test/one-way-doors.test.ts
+++ b/test/one-way-doors.test.ts
@@ -29,4 +29,17 @@ describe("one-way-door credential keyword net (#1839)", () => {
expect(classifyQuestion({ summary: `rotate my ${noun}` }).oneWay).toBe(true);
}
});
+
+ // revoke/reset/rotate must all share the same credential noun list. Previously
+ // "secret" was only in rotate (missing from revoke and reset) and "access key"
+ // was missing from reset, so "revoke my secret" / "reset my secret" /
+ // "reset my access key" leaked through as two-way (auto-decidable).
+ test("revoke/reset/rotate are all parallel for every credential noun", () => {
+ for (const verb of ["revoke", "reset", "rotate"]) {
+ for (const noun of ["api key", "token", "secret", "credential", "access key", "password"]) {
+ const r = classifyQuestion({ summary: `${verb} my ${noun}` });
+ expect(r.oneWay, `${verb} my ${noun} should be one-way`).toBe(true);
+ }
+ }
+ });
});
diff --git a/test/skill-e2e-ios-device.test.ts b/test/skill-e2e-ios-device.test.ts
index 1517be80c..200cb6557 100644
--- a/test/skill-e2e-ios-device.test.ts
+++ b/test/skill-e2e-ios-device.test.ts
@@ -1,4 +1,8 @@
-// GSTACK_HAS_IOS_DEVICE=1 device-path test. Runs only when:
+// Real-device tests. The lightweight CoreDevice checks run with
+// GSTACK_HAS_IOS_DEVICE=1; the signing/install/interaction smoke test has the
+// separate, explicit GSTACK_IOS_DEVICE_DEPLOY=1 opt-in.
+//
+// Runs only when:
// - An iPhone is connected via USB and reachable through CoreDevice
// - The iPhone is paired (user has tapped "Trust" on the trust dialog)
// - Developer Mode is enabled on the iPhone (Settings → Privacy → Developer Mode)
@@ -10,57 +14,91 @@
// 4. The fixture iOS SPM package builds with `swift build` for iOS target
// (verifies the templates compile against the iOS SDK, not just macOS)
//
-// What it does NOT exercise (out of scope for this test):
-// - Building + signing a full iOS app via xcodebuild (requires provisioning
-// profile + dev team — environment-specific, not portable across CI)
-// - Actually deploying + launching the StateServer on the device (same)
-//
-// The first three steps prove the CoreDevice path is wired end-to-end on the
-// agent's side. The fourth proves the Swift templates compile against the
-// iOS SDK, not just macOS — which catches UIKit/SwiftUI gating bugs before
-// they reach a real app deployment.
+// GSTACK_IOS_DEVICE_DEPLOY=1 additionally generates the fixture Xcode project,
+// signs it with GSTACK_IOS_DEVELOPMENT_TEAM + GSTACK_IOS_BUNDLE_ID, installs
+// and launches it, then proves screenshot/elements/tap through the real daemon.
+// It remains skipped in normal CI because signing and a paired iPhone are
+// intentionally machine-specific.
import { describe, test, expect } from 'bun:test';
import { spawnSync } from 'child_process';
+import { cpSync, existsSync, mkdtempSync, readFileSync, rmSync, unlinkSync } from 'fs';
+import { tmpdir } from 'os';
import { join } from 'path';
+import { startDaemon, type RunningDaemon } from '../ios-qa/daemon/src/index';
+import { startTunnelKeepalive } from '../ios-qa/daemon/src/devicectl';
+import { bootstrapTunnel } from '../ios-qa/daemon/src/tunnel-bootstrap';
+import type { DeviceTunnel } from '../ios-qa/daemon/src/proxy';
const ROOT = join(import.meta.dir, '..');
const FIXTURE_PATH = join(ROOT, 'test/fixtures/ios-qa/FixtureApp');
const HAS_DEVICE = process.env.GSTACK_HAS_IOS_DEVICE === '1';
+const DEPLOY_TO_DEVICE = process.env.GSTACK_IOS_DEVICE_DEPLOY === '1';
const describeIfDevice = HAS_DEVICE ? describe : describe.skip;
+const testIfDeploy = DEPLOY_TO_DEVICE ? test : test.skip;
interface DeviceListEntry {
identifier: string;
state: string; // "available" | "available (pairing)" | "unavailable" | ...
name: string;
model: string;
+ platform: string;
+ transport: string;
+ paired: boolean;
+}
+
+interface DeviceElement {
+ identifier?: string;
+ label?: string;
+ value?: string;
+ frame?: { x: number; y: number; w: number; h: number };
+}
+
+interface StateSnapshot {
+ _app_build_id?: string;
+ _accessor_hash?: string;
+ keys?: Record;
}
function listDevices(): DeviceListEntry[] {
// devicectl JSON output requires --json-output to a path. Use a tempfile.
const tmp = `/tmp/devicectl-list-${process.pid}-${Date.now()}.json`;
- const r = spawnSync('xcrun', ['devicectl', 'list', 'devices', '--json-output', tmp], {
- stdio: 'pipe',
- timeout: 30_000,
- });
- if (r.status !== 0) return [];
try {
- const fs = require('fs');
- const raw = fs.readFileSync(tmp, 'utf-8');
+ const r = spawnSync('xcrun', ['devicectl', 'list', 'devices', '--json-output', tmp], {
+ stdio: 'pipe',
+ timeout: 30_000,
+ });
+ if (r.status !== 0) return [];
+ const raw = readFileSync(tmp, 'utf-8');
const obj = JSON.parse(raw);
- fs.unlinkSync(tmp);
- return (obj.result?.devices ?? []).map((d: { identifier: string; connectionProperties: { tunnelState: string }; deviceProperties: { name: string }; hardwareProperties: { productType: string } }) => ({
+ return (obj.result?.devices ?? []).map((d: { identifier: string; connectionProperties: { tunnelState: string; pairingState?: string; transportType?: string }; deviceProperties: { name: string }; hardwareProperties: { productType: string; platform?: string } }) => ({
identifier: d.identifier,
state: d.connectionProperties?.tunnelState ?? 'unknown',
name: d.deviceProperties?.name ?? 'unknown',
model: d.hardwareProperties?.productType ?? 'unknown',
+ platform: d.hardwareProperties?.platform ?? 'unknown',
+ transport: d.connectionProperties?.transportType ?? '',
+ paired: d.connectionProperties?.pairingState === 'paired',
}));
} catch {
return [];
+ } finally {
+ try { unlinkSync(tmp); } catch { /* ignore */ }
}
}
+function isAvailableIPhone(device: DeviceListEntry): boolean {
+ const state = device.state.trim().toLowerCase();
+ const available = state === 'connected'
+ || state.startsWith('available')
+ || (state === 'disconnected' && device.transport.trim().toLowerCase() === 'wired');
+ return available
+ && device.paired
+ && device.platform.toLowerCase() === 'ios'
+ && device.model.toLowerCase().startsWith('iphone');
+}
+
function isPaired(udid: string): boolean {
// devicectl device info processes returns a clean exit when paired.
const tmp = `/tmp/devicectl-info-${process.pid}-${Date.now()}.json`;
@@ -69,12 +107,146 @@ function isPaired(udid: string): boolean {
'-d', udid,
'--json-output', tmp,
], { stdio: 'pipe', timeout: 30_000 });
- try { require('fs').unlinkSync(tmp); } catch { /* ignore */ }
+ try { unlinkSync(tmp); } catch { /* ignore */ }
// Pair-required errors surface on stderr with "must be paired" or
// CoreDeviceError 2. Treat any non-zero exit as not-paired.
return r.status === 0;
}
+function requireDeployEnv(name: 'GSTACK_IOS_DEVELOPMENT_TEAM' | 'GSTACK_IOS_BUNDLE_ID'): string {
+ const value = process.env[name]?.trim();
+ if (!value) {
+ throw new Error(`${name} is required when GSTACK_IOS_DEVICE_DEPLOY=1`);
+ }
+ return value;
+}
+
+function runChecked(
+ command: string,
+ args: string[],
+ opts: { cwd?: string; timeout?: number } = {},
+): string {
+ const result = spawnSync(command, args, {
+ cwd: opts.cwd,
+ env: process.env,
+ stdio: 'pipe',
+ timeout: opts.timeout ?? 60_000,
+ maxBuffer: 32 * 1024 * 1024,
+ });
+ const output = `${result.stdout?.toString() ?? ''}${result.stderr?.toString() ?? ''}`;
+ if (result.error || result.status !== 0) {
+ const tail = output.split('\n').slice(-120).join('\n');
+ throw new Error([
+ `${command} ${args.join(' ')} failed (${result.error?.message ?? `exit ${result.status}`})`,
+ tail,
+ ].filter(Boolean).join('\n'));
+ }
+ return output;
+}
+
+async function daemonJson(
+ baseURL: string,
+ path: string,
+ init: RequestInit = {},
+): Promise<{ status: number; body: T; raw: string }> {
+ const response = await fetch(`${baseURL}${path}`, {
+ ...init,
+ signal: AbortSignal.timeout(60_000),
+ });
+ const raw = await response.text();
+ let body: T;
+ try {
+ body = JSON.parse(raw) as T;
+ } catch {
+ throw new Error(`${init.method ?? 'GET'} ${path} returned non-JSON HTTP ${response.status}: ${raw.slice(0, 500)}`);
+ }
+ return { status: response.status, body, raw };
+}
+
+function findElement(elements: DeviceElement[], identifier: string): DeviceElement | undefined {
+ return elements.find((element) =>
+ element.identifier === identifier
+ && (element.frame?.w ?? 0) > 0
+ && (element.frame?.h ?? 0) > 0,
+ );
+}
+
+type DeviceElementPredicate = (element: DeviceElement) => boolean;
+
+interface DeviceViewport {
+ w: number;
+ h: number;
+}
+
+function isInsideViewport(element: DeviceElement, viewport: DeviceViewport): boolean {
+ const frame = element.frame;
+ if (!frame || frame.w <= 0 || frame.h <= 0) return false;
+ const centerX = frame.x + frame.w / 2;
+ const centerY = frame.y + frame.h / 2;
+ return centerX >= 0 && centerX <= viewport.w && centerY >= 0 && centerY <= viewport.h;
+}
+
+async function readDeviceElements(baseURL: string): Promise {
+ const result = await daemonJson<{ elements?: DeviceElement[] }>(baseURL, '/elements');
+ if (result.status !== 200 || !Array.isArray(result.body.elements)) {
+ throw new Error(`GET /elements failed with HTTP ${result.status}: ${result.raw.slice(0, 500)}`);
+ }
+ return result.body.elements;
+}
+
+async function waitForDeviceElement(
+ baseURL: string,
+ predicate: DeviceElementPredicate,
+ description: string,
+ options: {
+ condition?: DeviceElementPredicate;
+ tappableIn?: DeviceViewport;
+ timeoutMs?: number;
+ } = {},
+): Promise {
+ const deadline = Date.now() + (options.timeoutMs ?? 10_000);
+ let lastElements: DeviceElement[] = [];
+ while (Date.now() < deadline) {
+ lastElements = await readDeviceElements(baseURL);
+ const match = lastElements.find((element) =>
+ predicate(element)
+ && (options.condition?.(element) ?? true)
+ && (!options.tappableIn || isInsideViewport(element, options.tappableIn)),
+ );
+ if (match) return match;
+ await new Promise((resolve) => setTimeout(resolve, 200));
+ }
+ const visible = lastElements
+ .filter((element) => element.identifier || element.label)
+ .slice(0, 80)
+ .map((element) => element.identifier ?? element.label)
+ .join(', ');
+ throw new Error(`timed out waiting for ${description}; last elements: ${visible}`);
+}
+
+async function tapDeviceElement(
+ baseURL: string,
+ sessionId: string,
+ element: DeviceElement,
+): Promise {
+ const frame = element.frame;
+ if (!frame) throw new Error('cannot tap an element without a frame');
+ const tapped = await daemonJson<{ ok?: boolean; op?: string }>(baseURL, '/tap', {
+ method: 'POST',
+ headers: {
+ 'content-type': 'application/json',
+ 'x-session-id': sessionId,
+ },
+ body: JSON.stringify({
+ x: frame.x + frame.w / 2,
+ y: frame.y + frame.h / 2,
+ }),
+ });
+ if (tapped.status !== 200 || tapped.body.ok !== true) {
+ throw new Error(`tap failed with HTTP ${tapped.status}: ${tapped.raw.slice(0, 500)}`);
+ }
+}
+
describeIfDevice('ios device path', () => {
test('devicectl lists at least one connected device', () => {
const devices = listDevices();
@@ -104,11 +276,9 @@ describeIfDevice('ios device path', () => {
expect(paired.length).toBeGreaterThan(0);
});
- test('fixture Swift package compiles for iOS target', () => {
- // Use xcrun --sdk iphoneos to get the iOS SDK path, then pass it through
- // to swift build via SDKROOT. This validates that the Swift templates
- // (StateServer, DebugBridgeManager, DebugOverlay) compile against the
- // iOS SDK — catches UIKit/SwiftUI gating bugs that macOS-only builds miss.
+ test('fixture iOS SDK and UIKit compile guards are available', () => {
+ // This is an environment + source-guard preflight. The explicit deployment
+ // test below performs the real signed iOS xcodebuild before installation.
const sdkPath = spawnSync('xcrun', ['--sdk', 'iphoneos', '--show-sdk-path'], { stdio: 'pipe' });
if (sdkPath.status !== 0) {
console.error('iOS SDK not found. Install via Xcode.');
@@ -117,16 +287,8 @@ describeIfDevice('ios device path', () => {
const sdk = sdkPath.stdout.toString().trim();
expect(sdk).toContain('iPhoneOS');
- // Build the DebugBridgeUI target specifically for iOS. We can't use
- // `swift build --triple arm64-apple-ios` directly because SwiftPM
- // doesn't ship an iOS toolchain out of the box. The xcodebuild path
- // requires a project — skip if no .xcodeproj exists.
- // Instead, verify the iOS-only code compiles by parsing the canImport
- // guards: if the template's `#if canImport(UIKit)` is wrong, the macOS
- // build would have failed in the swift-build invariant test. The iOS
- // SDK path being present is sufficient signal that the toolchain is
- // installed; the deeper iOS-target build belongs to xcodebuild + a real
- // app target, which is the "deploy to device" path documented below.
+ // SwiftPM cannot directly cross-build this UIKit package with the standalone
+ // host command, so keep the static guard assertion honest and narrowly named.
const fs = require('fs') as typeof import('fs');
const overlay = fs.readFileSync(
join(FIXTURE_PATH, 'Sources/DebugBridgeUI/DebugOverlay.swift'),
@@ -137,26 +299,575 @@ describeIfDevice('ios device path', () => {
expect(overlay).toContain('#endif');
});
- // Documented next step. Becomes a real test once we have:
- // - test/fixtures/ios-qa/FixtureApp/FixtureApp.xcodeproj (or generated)
- // - A signing certificate + provisioning profile on the test machine
- // - GSTACK_IOS_DEVICE_DEPLOY=1 environment opt-in
- //
- // The flow would be:
- // xcodebuild -scheme FixtureApp -destination 'platform=iOS,id=' \
- // -allowProvisioningUpdates build install
- // xcrun devicectl device process launch -d --console
- // # Scrape boot token from os_log
- // curl http://[]:9999/healthz
- // # ... full smoke loop ...
- test.skip('TODO(deploy): build + deploy fixture to device + smoke test full StateServer loop', () => {});
+});
+
+describe('ios device deployment (explicit opt-in)', () => {
+ testIfDeploy('generates, signs, installs, launches, and drives the fixture through the daemon', async () => {
+ const developmentTeam = requireDeployEnv('GSTACK_IOS_DEVELOPMENT_TEAM');
+ const bundleId = requireDeployEnv('GSTACK_IOS_BUNDLE_ID');
+ const devices = listDevices();
+ const device = devices.find((candidate) => isAvailableIPhone(candidate) && isPaired(candidate.identifier));
+ if (!device) {
+ const summary = devices.length > 0
+ ? devices.map((d) => ` ${d.name} (${d.model}, ${d.platform}, ${d.identifier}): state=${d.state}, paired=${d.paired}`).join('\n')
+ : ' devicectl returned no devices';
+ throw new Error([
+ 'GSTACK_IOS_DEVICE_DEPLOY=1 requires an available, paired iPhone; stale unavailable devices are never selected.',
+ summary,
+ ].join('\n'));
+ }
+
+ const workDir = mkdtempSync(join(tmpdir(), 'gstack-ios-device-deploy-'));
+ const fixtureDir = join(workDir, 'FixtureApp');
+ const derivedData = join(workDir, 'DerivedData');
+ let daemon: RunningDaemon | undefined;
+ let keepalive: { stop: () => void } | undefined;
+ let sessionId: string | undefined;
+
+ try {
+ cpSync(FIXTURE_PATH, fixtureDir, { recursive: true });
+
+ // Exercise the same deterministic bootstrap that /ios-qa and /ios-sync
+ // install for users. This creates the app-owned typed accessor before
+ // XcodeGen discovers the fixture sources.
+ runChecked(join(ROOT, 'bin/gstack-ios-qa-regen'), [
+ '--app-source', join(fixtureDir, 'Sources/FixtureApp'),
+ '--bridge-dir', fixtureDir,
+ ], { cwd: fixtureDir });
+ const generatedAccessor = join(
+ fixtureDir,
+ 'Sources/FixtureApp/DebugBridgeGenerated/StateAccessor.swift',
+ );
+ expect(existsSync(generatedAccessor)).toBe(true);
+ expect(readFileSync(generatedAccessor, 'utf8')).toContain('enum FixtureAppStateAccessor');
+
+ runChecked('xcodegen', [
+ 'generate',
+ '--spec', join(fixtureDir, 'project.yml'),
+ '--project', fixtureDir,
+ '--project-root', fixtureDir,
+ ], { cwd: fixtureDir });
+
+ const projectPath = join(fixtureDir, 'FixtureApp.xcodeproj');
+ expect(existsSync(projectPath)).toBe(true);
+
+ runChecked('xcodebuild', [
+ '-project', projectPath,
+ '-scheme', 'FixtureApp',
+ '-configuration', 'Debug',
+ '-destination', `platform=iOS,id=${device.identifier}`,
+ '-derivedDataPath', derivedData,
+ '-allowProvisioningUpdates',
+ `DEVELOPMENT_TEAM=${developmentTeam}`,
+ `PRODUCT_BUNDLE_IDENTIFIER=${bundleId}`,
+ 'CODE_SIGN_STYLE=Automatic',
+ 'build',
+ ], { cwd: fixtureDir, timeout: 300_000 });
+
+ const appBundle = join(derivedData, 'Build/Products/Debug-iphoneos/FixtureApp.app');
+ expect(existsSync(appBundle)).toBe(true);
+ const builtBundleId = runChecked('/usr/libexec/PlistBuddy', [
+ '-c', 'Print :CFBundleIdentifier',
+ join(appBundle, 'Info.plist'),
+ ]).trim();
+ expect(builtBundleId).toBe(bundleId);
+
+ runChecked('xcrun', [
+ 'devicectl', 'device', 'install', 'app',
+ '--device', device.identifier,
+ appBundle,
+ ], { timeout: 120_000 });
+
+ runChecked('xcrun', [
+ 'devicectl', 'device', 'process', 'launch',
+ '--device', device.identifier,
+ '--terminate-existing',
+ bundleId,
+ ], { timeout: 60_000 });
+
+ keepalive = startTunnelKeepalive(device.identifier);
+ const bootstrap = await bootstrapTunnel({
+ udid: device.identifier,
+ bundleId,
+ startupTimeoutMs: 30_000,
+ });
+ if (!bootstrap.ok) {
+ throw new Error(`daemon tunnel bootstrap failed: ${bootstrap.error}${bootstrap.detail ? ` (${bootstrap.detail})` : ''}`);
+ }
+
+ // The first provider call consumes the already-rotated bootstrap. Later
+ // calls perform a fresh bootstrap so the same daemon can recover after
+ // this app is relaunched and its in-memory bearer changes.
+ let pendingTunnel: DeviceTunnel | undefined = bootstrap.tunnel;
+ let tunnelProviderCalls = 0;
+ const provideTunnel = async (): Promise => {
+ tunnelProviderCalls += 1;
+ if (pendingTunnel) {
+ const first = pendingTunnel;
+ pendingTunnel = undefined;
+ return first;
+ }
+ const refreshed = await bootstrapTunnel({
+ udid: device.identifier,
+ bundleId,
+ startupTimeoutMs: 30_000,
+ });
+ if (!refreshed.ok) {
+ throw new Error(`daemon rebootstrap failed: ${refreshed.error}${refreshed.detail ? ` (${refreshed.detail})` : ''}`);
+ }
+ return refreshed.tunnel;
+ };
+
+ const started = await startDaemon({
+ loopbackPort: 0,
+ tailnetEnabled: false,
+ pidfilePath: join(workDir, 'daemon.pid'),
+ tunnelProvider: provideTunnel,
+ });
+ if ('error' in started) {
+ throw new Error(`daemon failed to start: ${started.error}${started.reason ? ` (${started.reason})` : ''}`);
+ }
+ daemon = started;
+ const baseURL = `http://127.0.0.1:${daemon.loopbackPort}`;
+
+ const initialState = await daemonJson(baseURL, '/state/snapshot');
+ expect(initialState.status).toBe(200);
+ expect(typeof initialState.body._app_build_id).toBe('string');
+ expect(initialState.body._app_build_id).not.toBe('unknown');
+ expect(initialState.body._app_build_id).not.toBe('uninitialized');
+ expect(initialState.body._accessor_hash).toMatch(/^[a-f0-9]{64}$/);
+ expect(initialState.body._accessor_hash).not.toBe('uninitialized');
+ expect(Object.keys(initialState.body.keys ?? {}).sort()).toEqual([
+ 'isLoggedIn',
+ 'nickname',
+ 'tapCounter',
+ 'username',
+ ]);
+ expect(initialState.body.keys?.nickname).toBeNull();
+
+ const screenshot = await daemonJson<{ png_base64?: string; error?: string }>(baseURL, '/screenshot');
+ expect(screenshot.status).toBe(200);
+ expect(typeof screenshot.body.png_base64).toBe('string');
+ const png = Buffer.from(screenshot.body.png_base64!, 'base64');
+ expect(png.length).toBeGreaterThan(1_000);
+ expect([...png.subarray(0, 8)]).toEqual([137, 80, 78, 71, 13, 10, 26, 10]);
+
+ const requiredIdentifiers = [
+ 'primary-button',
+ 'toolbar-actions-menu',
+ 'open-detail-button',
+ 'tab-controls',
+ 'tab-inputs',
+ 'tab-rows',
+ ];
+ let elementsBefore: DeviceElement[] = [];
+ let identifiers = new Set();
+ const elementsDeadline = Date.now() + 10_000;
+ while (Date.now() < elementsDeadline) {
+ const before = await daemonJson<{ elements?: DeviceElement[] }>(baseURL, '/elements');
+ expect(before.status).toBe(200);
+ expect(Array.isArray(before.body.elements)).toBe(true);
+ elementsBefore = before.body.elements ?? [];
+ identifiers = new Set(
+ elementsBefore.map((element) => element.identifier).filter((value): value is string => Boolean(value)),
+ );
+ if (requiredIdentifiers.every((identifier) => identifiers.has(identifier))) break;
+ await new Promise((resolve) => setTimeout(resolve, 250));
+ }
+ expect(elementsBefore.length).toBeGreaterThan(30);
+ expect(requiredIdentifiers.filter((identifier) => !identifiers.has(identifier))).toEqual([]);
+ const appFrame = findElement(elementsBefore, 'fixture-tab-view')?.frame;
+ expect(appFrame).toBeDefined();
+ // Screenshot pixels and /tap coordinates must share UIKit's point
+ // space. A 3x PNG here recreates the original missed-tap bug.
+ expect(png.readUInt32BE(16)).toBe(appFrame!.w);
+ expect(png.readUInt32BE(20)).toBe(appFrame!.h);
+
+ const buttonBefore = findElement(elementsBefore, 'primary-button');
+ expect(buttonBefore).toBeDefined();
+ expect(typeof buttonBefore!.value).toBe('string');
+
+ const acquired = await daemonJson<{ session_id?: string }>(baseURL, '/session/acquire', { method: 'POST' });
+ expect(acquired.status).toBe(200);
+ expect(typeof acquired.body.session_id).toBe('string');
+ sessionId = acquired.body.session_id!;
+
+ const rejectedBooleanAsInteger = await daemonJson<{ error?: string }>(baseURL, '/state/tapCounter', {
+ method: 'POST',
+ headers: {
+ 'content-type': 'application/json',
+ 'x-session-id': sessionId,
+ },
+ body: JSON.stringify({ value: true }),
+ });
+ expect(rejectedBooleanAsInteger.status).toBe(400);
+ expect(rejectedBooleanAsInteger.body.error).toBe('type_mismatch');
+
+ const rejectedIntegerAsBoolean = await daemonJson<{ error?: string }>(baseURL, '/state/isLoggedIn', {
+ method: 'POST',
+ headers: {
+ 'content-type': 'application/json',
+ 'x-session-id': sessionId,
+ },
+ body: JSON.stringify({ value: 1 }),
+ });
+ expect(rejectedIntegerAsBoolean.status).toBe(400);
+ expect(rejectedIntegerAsBoolean.body.error).toBe('type_mismatch');
+ const afterRejectedCoercions = await daemonJson(baseURL, '/state/snapshot');
+ expect(afterRejectedCoercions.body.keys?.tapCounter).toBe(0);
+ expect(afterRejectedCoercions.body.keys?.isLoggedIn).toBe(false);
+
+ const wroteState = await daemonJson<{ ok?: boolean }>(baseURL, '/state/tapCounter', {
+ method: 'POST',
+ headers: {
+ 'content-type': 'application/json',
+ 'x-session-id': sessionId,
+ },
+ body: JSON.stringify({ value: 7 }),
+ });
+ expect(wroteState.status).toBe(200);
+ expect(wroteState.body).toEqual({ ok: true });
+ const updatedState = await daemonJson(baseURL, '/state/snapshot');
+ expect(updatedState.status).toBe(200);
+ expect(updatedState.body.keys?.tapCounter).toBe(7);
+
+ const wroteOptional = await daemonJson<{ ok?: boolean }>(baseURL, '/state/nickname', {
+ method: 'POST',
+ headers: {
+ 'content-type': 'application/json',
+ 'x-session-id': sessionId,
+ },
+ body: JSON.stringify({ value: 'Device' }),
+ });
+ expect(wroteOptional.status).toBe(200);
+ const optionalValue = await daemonJson(baseURL, '/state/snapshot');
+ expect(optionalValue.body.keys?.nickname).toBe('Device');
+
+ const clearedOptional = await daemonJson<{ ok?: boolean }>(baseURL, '/state/nickname', {
+ method: 'POST',
+ headers: {
+ 'content-type': 'application/json',
+ 'x-session-id': sessionId,
+ },
+ body: JSON.stringify({ value: null }),
+ });
+ expect(clearedOptional.status).toBe(200);
+ const clearedValue = await daemonJson(baseURL, '/state/snapshot');
+ expect(clearedValue.body.keys?.nickname).toBeNull();
+
+ const restoredState = await daemonJson<{ ok?: boolean }>(baseURL, '/state/restore', {
+ method: 'POST',
+ headers: {
+ 'content-type': 'application/json',
+ 'x-session-id': sessionId,
+ },
+ body: JSON.stringify(initialState.body),
+ });
+ expect(restoredState.status).toBe(200);
+ expect(restoredState.body).toEqual({ ok: true });
+ const afterRestore = await daemonJson(baseURL, '/state/snapshot');
+ expect(afterRestore.body.keys?.tapCounter).toBe(0);
+ expect(afterRestore.body.keys?.nickname).toBeNull();
+
+ const viewport = { w: appFrame!.w, h: appFrame!.h };
+ const byIdentifier = (identifier: string): DeviceElementPredicate =>
+ (element) => element.identifier === identifier;
+ const byLabel = (label: string): DeviceElementPredicate =>
+ (element) => element.label?.trim() === label;
+
+ const tapAndWaitForValueChange = async (
+ target: DeviceElementPredicate,
+ oracle: DeviceElementPredicate,
+ description: string,
+ ): Promise => {
+ const before = await waitForDeviceElement(
+ baseURL,
+ oracle,
+ `${description} oracle before tap`,
+ { condition: (element) => typeof element.value === 'string' },
+ );
+ const targetElement = await waitForDeviceElement(
+ baseURL,
+ target,
+ `${description} target`,
+ { tappableIn: viewport },
+ );
+ await tapDeviceElement(baseURL, sessionId!, targetElement);
+ const after = await waitForDeviceElement(
+ baseURL,
+ oracle,
+ `${description} value change`,
+ { condition: (element) => typeof element.value === 'string' && element.value !== before.value },
+ );
+ expect(after.value).not.toBe(before.value);
+ return after;
+ };
+
+ const firstInteger = (value: string | undefined): number | undefined => {
+ const match = value?.match(/-?\d+/);
+ return match ? Number(match[0]) : undefined;
+ };
+
+ const tapAndWaitForCountIncrement = async (
+ target: DeviceElementPredicate,
+ oracle: DeviceElementPredicate,
+ description: string,
+ ): Promise => {
+ const before = await waitForDeviceElement(
+ baseURL,
+ oracle,
+ `${description} counter before tap`,
+ { condition: (element) => firstInteger(element.value) !== undefined },
+ );
+ const beforeCount = firstInteger(before.value)!;
+ const targetElement = await waitForDeviceElement(
+ baseURL,
+ target,
+ `${description} target`,
+ { tappableIn: viewport },
+ );
+ await tapDeviceElement(baseURL, sessionId!, targetElement);
+ const after = await waitForDeviceElement(
+ baseURL,
+ oracle,
+ `${description} exactly-once counter increment`,
+ { condition: (element) => firstInteger(element.value) === beforeCount + 1 },
+ );
+ expect(firstInteger(after.value)).toBe(beforeCount + 1);
+ return after;
+ };
+
+ // SwiftUI button styles and both navigation-bar controls.
+ for (const identifier of [
+ 'primary-button',
+ 'bordered-button',
+ 'plain-button',
+ 'destructive-button',
+ 'nav-refresh-button',
+ ]) {
+ await tapAndWaitForCountIncrement(
+ byIdentifier(identifier),
+ byIdentifier(identifier),
+ identifier,
+ );
+ }
+
+ // Menu presentation and both menu commands. The menu's own value is the
+ // stable oracle after each transient command element disappears.
+ for (const commandIdentifier of ['menu-add-item', 'menu-archive-item']) {
+ const menuBefore = await waitForDeviceElement(
+ baseURL,
+ byIdentifier('toolbar-actions-menu'),
+ 'toolbar menu value',
+ { condition: (element) => typeof element.value === 'string' },
+ );
+ const menu = await waitForDeviceElement(
+ baseURL,
+ byIdentifier('toolbar-actions-menu'),
+ 'toolbar actions menu',
+ { tappableIn: viewport },
+ );
+ await tapDeviceElement(baseURL, sessionId, menu);
+ const command = await waitForDeviceElement(
+ baseURL,
+ byIdentifier(commandIdentifier),
+ commandIdentifier,
+ { tappableIn: viewport },
+ );
+ await tapDeviceElement(baseURL, sessionId, command);
+ const beforeCounts = [...(menuBefore.value?.matchAll(/\d+/g) ?? [])].map((match) => Number(match[0]));
+ expect(beforeCounts).toHaveLength(2);
+ const changedIndex = commandIdentifier === 'menu-add-item' ? 0 : 1;
+ const expectedCounts = beforeCounts.map((count, index) => count + (index === changedIndex ? 1 : 0));
+ const menuAfter = await waitForDeviceElement(
+ baseURL,
+ byIdentifier('toolbar-actions-menu'),
+ `${commandIdentifier} exactly-once result`,
+ {
+ condition: (element) => {
+ const counts = [...(element.value?.matchAll(/\d+/g) ?? [])].map((match) => Number(match[0]));
+ return counts.length === 2 && counts.every((count, index) => count === expectedCounts[index]);
+ },
+ },
+ );
+ const afterCounts = [...(menuAfter.value?.matchAll(/\d+/g) ?? [])].map((match) => Number(match[0]));
+ expect(afterCounts).toEqual(expectedCounts);
+ }
+
+ // Push, interact with, and pop the explicit navigation destination.
+ const openDetail = await waitForDeviceElement(
+ baseURL,
+ byIdentifier('open-detail-button'),
+ 'open detail button',
+ { tappableIn: viewport },
+ );
+ await tapDeviceElement(baseURL, sessionId, openDetail);
+ await waitForDeviceElement(baseURL, byIdentifier('detail-screen-title'), 'detail destination');
+ await tapAndWaitForCountIncrement(
+ byIdentifier('detail-action-button'),
+ byIdentifier('detail-action-button'),
+ 'detail action button',
+ );
+ const back = await waitForDeviceElement(
+ baseURL,
+ byIdentifier('detail-back-button'),
+ 'detail back button',
+ { tappableIn: viewport },
+ );
+ await tapDeviceElement(baseURL, sessionId, back);
+ await waitForDeviceElement(baseURL, byIdentifier('primary-button'), 'controls after detail pop');
+
+ // Tab navigation plus native toggle, stepper, segmented picker, text
+ // input/commit, and a UIKit UIButton.
+ const inputsTab = await waitForDeviceElement(
+ baseURL,
+ byIdentifier('tab-inputs'),
+ 'Inputs tab',
+ { tappableIn: viewport },
+ );
+ await tapDeviceElement(baseURL, sessionId, inputsTab);
+ await waitForDeviceElement(baseURL, byIdentifier('harness-toggle'), 'Inputs controls');
+ await tapAndWaitForValueChange(
+ byIdentifier('harness-toggle'),
+ byIdentifier('harness-toggle'),
+ 'toggle',
+ );
+ await tapAndWaitForCountIncrement(
+ byIdentifier('harness-stepper-Increment'),
+ byIdentifier('harness-stepper'),
+ 'stepper increment',
+ );
+ const segmentTwo = await waitForDeviceElement(
+ baseURL,
+ byLabel('Two'),
+ 'segmented picker option Two',
+ { tappableIn: viewport },
+ );
+ await tapDeviceElement(baseURL, sessionId, segmentTwo);
+ const selectedTwo = await waitForDeviceElement(
+ baseURL,
+ byIdentifier('harness-segmented-picker'),
+ 'segmented picker selection',
+ { condition: (element) => element.value?.includes('Two') === true },
+ );
+ expect(selectedTwo.value).toContain('Two');
+
+ const textField = await waitForDeviceElement(
+ baseURL,
+ byIdentifier('harness-text-field'),
+ 'text field',
+ { tappableIn: viewport },
+ );
+ await tapDeviceElement(baseURL, sessionId, textField);
+ const typed = await daemonJson<{ ok?: boolean; op?: string }>(baseURL, '/type', {
+ method: 'POST',
+ headers: {
+ 'content-type': 'application/json',
+ 'x-session-id': sessionId,
+ },
+ body: JSON.stringify({ text: 'device matrix' }),
+ });
+ expect(typed.status).toBe(200);
+ expect(typed.body).toMatchObject({ op: 'type', ok: true });
+ await waitForDeviceElement(
+ baseURL,
+ byIdentifier('harness-text-field'),
+ 'typed text value',
+ { condition: (element) => element.value === 'device matrix' },
+ );
+ await tapAndWaitForCountIncrement(
+ byIdentifier('commit-text-button'),
+ byIdentifier('commit-text-button'),
+ 'text commit button',
+ );
+ // Commit clears FocusState; let the keyboard dismissal animation finish
+ // before choosing a scroll-view hit point for the UIKit control below.
+ await new Promise((resolve) => setTimeout(resolve, 500));
+
+ const scrollInputs = await daemonJson<{ ok?: boolean; op?: string }>(baseURL, '/swipe', {
+ method: 'POST',
+ headers: {
+ 'content-type': 'application/json',
+ 'x-session-id': sessionId,
+ },
+ body: JSON.stringify({ from_x: 200, from_y: 650, to_x: 200, to_y: 220 }),
+ });
+ expect(scrollInputs.status).toBe(200);
+ expect(scrollInputs.body).toMatchObject({ op: 'swipe', ok: true });
+ await tapAndWaitForCountIncrement(
+ byIdentifier('uikit-button'),
+ byIdentifier('uikit-button'),
+ 'UIKit button',
+ );
+
+ // All four list rows and the row navigation-bar action.
+ const rowsTab = await waitForDeviceElement(
+ baseURL,
+ byIdentifier('tab-rows'),
+ 'Rows tab',
+ { tappableIn: viewport },
+ );
+ await tapDeviceElement(baseURL, sessionId, rowsTab);
+ await waitForDeviceElement(baseURL, byIdentifier('row-alpha-button'), 'Rows list');
+ for (const row of ['alpha', 'bravo', 'charlie', 'delta']) {
+ await tapAndWaitForCountIncrement(
+ byIdentifier(`row-${row}-button`),
+ byIdentifier(`row-${row}-button`),
+ `${row} row`,
+ );
+ }
+ await tapAndWaitForCountIncrement(
+ byIdentifier('rows-toolbar-button'),
+ byIdentifier('rows-toolbar-button'),
+ 'rows toolbar button',
+ );
+
+ const controlsTab = await waitForDeviceElement(
+ baseURL,
+ byIdentifier('tab-controls'),
+ 'Controls tab',
+ { tappableIn: viewport },
+ );
+ await tapDeviceElement(baseURL, sessionId, controlsTab);
+ await waitForDeviceElement(baseURL, byIdentifier('primary-button'), 'Controls tab restored');
+
+ // Keep the daemon alive while the app process gets a new boot token.
+ // The first proxied request must observe the stale bearer, invalidate
+ // only that tunnel, single-flight a fresh bootstrap, and retry once.
+ runChecked('xcrun', [
+ 'devicectl', 'device', 'process', 'launch',
+ '--device', device.identifier,
+ '--terminate-existing',
+ bundleId,
+ ], { timeout: 60_000 });
+ await new Promise((resolve) => setTimeout(resolve, 500));
+ const afterRelaunch = await daemonJson<{ png_base64?: string; error?: string }>(baseURL, '/screenshot');
+ expect(afterRelaunch.status).toBe(200);
+ expect(Buffer.from(afterRelaunch.body.png_base64 ?? '', 'base64').length).toBeGreaterThan(1_000);
+ expect(tunnelProviderCalls).toBe(2);
+ const stateAfterRelaunch = await daemonJson(baseURL, '/state/snapshot');
+ expect(stateAfterRelaunch.status).toBe(200);
+ expect(stateAfterRelaunch.body._accessor_hash).toBe(initialState.body._accessor_hash);
+ } finally {
+ if (daemon && sessionId) {
+ await daemonJson(`http://127.0.0.1:${daemon.loopbackPort}`, '/session/release', { method: 'POST' }).catch(() => undefined);
+ }
+ if (daemon) await daemon.close();
+ keepalive?.stop();
+ rmSync(workDir, { recursive: true, force: true });
+ }
+ }, 600_000);
});
// Always-on instructions if not paired. Surfaces actionable steps even when
// the test is opted in via env var but the device isn't ready.
if (HAS_DEVICE) {
const devices = listDevices();
- const unpaired = devices.filter(d => !isPaired(d.identifier));
+ const unpaired = devices.filter(d =>
+ d.platform.toLowerCase() === 'ios'
+ && d.model.toLowerCase().startsWith('iphone')
+ && !d.paired,
+ );
if (unpaired.length > 0) {
console.error('');
console.error('=== iOS DEVICE PAIRING REQUIRED ===');
diff --git a/test/skill-e2e-ios-swift-build.test.ts b/test/skill-e2e-ios-swift-build.test.ts
index 8a8c3b92b..253b4cb84 100644
--- a/test/skill-e2e-ios-swift-build.test.ts
+++ b/test/skill-e2e-ios-swift-build.test.ts
@@ -19,38 +19,90 @@
import { describe, test, expect } from 'bun:test';
import { spawnSync } from 'child_process';
-import { existsSync, readFileSync } from 'fs';
+import { readFileSync } from 'fs';
import { join } from 'path';
const ROOT = join(import.meta.dir, '..');
const FIXTURE_PATH = join(ROOT, 'test/fixtures/ios-qa/FixtureApp');
const TEMPLATES_PATH = join(ROOT, 'ios-qa/templates');
+const GEN_ACCESSORS_PACKAGE = join(ROOT, 'ios-qa/scripts/gen-accessors-tool/Package.swift');
-// Parity: canonical Obj-C touch templates must match the fixture's working
-// copy. The fixture is the only place the .m / .h are exercised end-to-end
-// on a real device, so any divergence means consuming apps would ship a
-// stale, untested version of the SwiftUI hit-test fix.
+const COPIED_BRIDGE_TEMPLATES = [
+ ['StateServer.swift.template', 'Sources/DebugBridgeCore/StateServer.swift'],
+ ['DebugBridgeManager.swift.template', 'Sources/DebugBridgeCore/DebugBridgeManager.swift'],
+ ['DebugOverlay.swift.template', 'Sources/DebugBridgeUI/DebugOverlay.swift'],
+ ['Bridges.swift.template', 'Sources/DebugBridgeUI/Bridges.swift'],
+ ['DebugBridgeTouch.h.template', 'Sources/DebugBridgeTouch/include/DebugBridgeTouch.h'],
+ ['DebugBridgeTouch.m.template', 'Sources/DebugBridgeTouch/DebugBridgeTouch.m'],
+] as const;
+
+function readTemplate(name: string): string {
+ return readFileSync(join(TEMPLATES_PATH, name), 'utf-8');
+}
+
+function normalizeBridgePackage(source: string): string {
+ // Package.swift.template has a generated-file prologue while the fixture has
+ // a fixture-specific one. The tools-version declaration must remain first in
+ // both real files, but neither header is part of the copied bridge surface.
+ const importOffset = source.indexOf('import PackageDescription');
+ expect(importOffset).toBeGreaterThanOrEqual(0);
+ let packageBody = source.slice(importOffset);
+
+ // The fixture deliberately has its own package identity and XCTest target.
+ // Normalize only those fixture concerns; all three bridge products, targets,
+ // dependencies, settings, and paths must otherwise stay in lockstep.
+ packageBody = packageBody.replace(
+ /(let package = Package\(\s*name:)\s*"[^"]+"/,
+ '$1 ""',
+ );
+ packageBody = packageBody.replace(
+ /\n\s*\.testTarget\(\s*\n\s*name:\s*"DebugBridgeCoreTests",[\s\S]*?\n\s{8}\),?/,
+ '',
+ );
+
+ // Ignore prose and formatting so a template-only explanatory comment does
+ // not conceal a meaningful manifest mismatch.
+ return packageBody
+ .replace(/\/\/.*$/gm, '')
+ .replace(/\s+/g, '')
+ .replace(/,([\])])/g, '$1');
+}
+
+function escapeRegExp(value: string): string {
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+}
+
+function bracedBlock(source: string, openBraceOffset: number): string {
+ let depth = 0;
+ for (let offset = openBraceOffset; offset < source.length; offset++) {
+ if (source[offset] === '{') depth++;
+ if (source[offset] !== '}') continue;
+ depth--;
+ if (depth === 0) return source.slice(openBraceOffset, offset + 1);
+ }
+ return '';
+}
+
+// The fixture is where the bridge is compiled and exercised end-to-end. Every
+// source copied into consuming apps must therefore be the canonical template,
+// or device QA can pass against code that /ios-qa never installs.
describe('template ↔ fixture parity', () => {
- test('DebugBridgeTouch.h.template matches fixture include', () => {
- const tmpl = readFileSync(join(TEMPLATES_PATH, 'DebugBridgeTouch.h.template'), 'utf-8');
- const fixture = readFileSync(
- join(FIXTURE_PATH, 'Sources/DebugBridgeTouch/include/DebugBridgeTouch.h'),
- 'utf-8',
- );
- expect(tmpl).toBe(fixture);
- });
+ for (const [templateName, fixtureDestination] of COPIED_BRIDGE_TEMPLATES) {
+ test(`${templateName} matches ${fixtureDestination}`, () => {
+ expect(readTemplate(templateName)).toBe(
+ readFileSync(join(FIXTURE_PATH, fixtureDestination), 'utf-8'),
+ );
+ });
+ }
- test('DebugBridgeTouch.m.template matches fixture .m', () => {
- const tmpl = readFileSync(join(TEMPLATES_PATH, 'DebugBridgeTouch.m.template'), 'utf-8');
- const fixture = readFileSync(
- join(FIXTURE_PATH, 'Sources/DebugBridgeTouch/DebugBridgeTouch.m'),
- 'utf-8',
- );
- expect(tmpl).toBe(fixture);
+ test('Package.swift bridge declarations match after fixture-only normalization', () => {
+ const template = readTemplate('Package.swift.template');
+ const fixture = readFileSync(join(FIXTURE_PATH, 'Package.swift'), 'utf-8');
+ expect(normalizeBridgePackage(template)).toBe(normalizeBridgePackage(fixture));
});
test('Package.swift.template declares all 3 DebugBridge targets', () => {
- const tmpl = readFileSync(join(TEMPLATES_PATH, 'Package.swift.template'), 'utf-8');
+ const tmpl = readTemplate('Package.swift.template');
// Each target must be present as a library product AND a target definition.
for (const name of ['DebugBridgeCore', 'DebugBridgeUI', 'DebugBridgeTouch']) {
expect(tmpl).toContain(`name: "${name}"`);
@@ -59,6 +111,190 @@ describe('template ↔ fixture parity', () => {
// app gets the transitive set with one dependency entry.
expect(tmpl).toMatch(/name:\s*"DebugBridgeUI"[\s\S]*?dependencies:\s*\["DebugBridgeCore",\s*"DebugBridgeTouch"\]/);
});
+
+ test('generated Swift packages only reference shipped test directories', () => {
+ const genAccessorsPackage = readFileSync(GEN_ACCESSORS_PACKAGE, 'utf-8');
+ const debugBridgePackage = readFileSync(join(TEMPLATES_PATH, 'Package.swift.template'), 'utf-8');
+
+ expect(genAccessorsPackage).not.toContain('Tests/GenAccessorsTests');
+ expect(debugBridgePackage).not.toContain('Tests/DebugBridgeCoreTests');
+ });
+
+ test('Package.swift.template keeps swift-tools-version on the first line', () => {
+ const tmpl = readTemplate('Package.swift.template');
+ expect(tmpl.split(/\r?\n/, 1)[0]).toBe('// swift-tools-version:5.9');
+ });
+});
+
+describe('iOS tap harness regressions', () => {
+ test('manager receives app-owned generated accessors instead of a no-op package stub', () => {
+ const manager = readTemplate('DebugBridgeManager.swift.template');
+ const wiring = readTemplate('DebugBridgeWiring.swift.template');
+ const fixtureApp = readFileSync(
+ join(FIXTURE_PATH, 'Sources/FixtureApp/FixtureAppApp.swift'),
+ 'utf-8',
+ );
+
+ expect(manager).toContain('func start');
+ expect(manager).toContain('register: (State) -> Void');
+ expect(manager).toContain('register(appState)');
+ expect(manager).not.toContain('public enum AppStateAccessor');
+ expect(manager).not.toContain('protocol AppState');
+
+ expect(wiring).toContain('import DebugBridgeCore');
+ expect(wiring).toContain('import DebugBridgeUI');
+ expect(wiring).toContain('DebugBridgeUIWiring.installAll()');
+ expect(wiring.indexOf('DebugBridgeUIWiring.installAll()')).toBeLessThan(
+ wiring.indexOf('DebugBridgeManager.shared.start'),
+ );
+ expect(fixtureApp.indexOf('DebugBridgeUIWiring.installAll()')).toBeLessThan(
+ fixtureApp.indexOf('DebugBridgeManager.shared.start'),
+ );
+ expect(wiring).not.toContain('import DebugBridge\n');
+ expect(wiring).not.toContain('AccessibilityScanner');
+ expect(wiring).not.toContain('MutationDispatcher');
+ });
+
+ test('fixture uses an @Observable-compatible source marker, not a property wrapper', () => {
+ const state = readFileSync(
+ join(FIXTURE_PATH, 'Sources/FixtureApp/FixtureAppState.swift'),
+ 'utf-8',
+ );
+ expect(state).toContain('@Observable');
+ expect(state.match(/\/\/ @Snapshotable/g)?.length).toBe(4);
+ expect(state).not.toContain('@propertyWrapper');
+ expect(state).not.toMatch(/^[\t ]*@Snapshotable[\t ]+(?:public[\t ]+)?var/m);
+ });
+
+ test('recurses through iOS automation elements to expose nested SwiftUI controls', () => {
+ const bridges = readTemplate('Bridges.swift.template');
+ expect(bridges).toContain('element.automationElements');
+ expect(bridges).toContain('debugBridgeAccessibilityChildren(of: element)');
+ expect(bridges).toContain('visited.insert(ObjectIdentifier(element)).inserted');
+ expect(bridges).toContain('var remaining = 2_048');
+ });
+
+ test('enables accessibility automation before SwiftUI AX is installed', () => {
+ const implementation = readTemplate('DebugBridgeTouch.m.template');
+ const bridges = readTemplate('Bridges.swift.template');
+ const helper = [...implementation.matchAll(
+ /static\s+void\s+([A-Za-z_]\w*)\s*\(\s*void\s*\)\s*\{/g,
+ )].find((candidate) => {
+ const body = bracedBlock(
+ implementation,
+ candidate.index! + candidate[0].lastIndexOf('{'),
+ );
+ return body.includes('_AXSAutomationEnabled') && body.includes('_AXSSetAutomationEnabled');
+ });
+ expect(helper).toBeDefined();
+
+ const helperName = helper![1];
+ const helperBody = bracedBlock(
+ implementation,
+ helper!.index! + helper![0].lastIndexOf('{'),
+ );
+ expect(helperBody).toContain('_AXSAutomationEnabled');
+ expect(helperBody).toContain('_AXSSetAutomationEnabled');
+
+ // Accept either Objective-C's eager +load hook or an explicit public
+ // bootstrap selector, but require the enabling helper to be called before
+ // Swift installs the resolver that walks SwiftUI's accessibility tree.
+ const bootstrap = [...implementation.matchAll(/\+\s*\(void\)\s*([A-Za-z_]\w*)\s*\{/g)]
+ .find((candidate) => bracedBlock(
+ implementation,
+ candidate.index! + candidate[0].lastIndexOf('{'),
+ ).match(new RegExp(`\\b${escapeRegExp(helperName)}\\s*\\(`)));
+ expect(bootstrap).toBeDefined();
+
+ if (bootstrap![1] === 'load') {
+ expect(bootstrap!.index!).toBeLessThan(implementation.indexOf('+ (BOOL)sendTapAtPoint:'));
+ } else {
+ const bootstrapCall = bridges.search(
+ new RegExp(`DebugBridgeTouch\\.${escapeRegExp(bootstrap![1])}\\s*\\(`),
+ );
+ expect(bootstrapCall).toBeGreaterThanOrEqual(0);
+ expect(bootstrapCall).toBeLessThan(bridges.indexOf('ElementsBridge.resolver'));
+ }
+ });
+
+ test('renders screenshots at one pixel per window point', () => {
+ const bridges = readTemplate('Bridges.swift.template');
+ const declaration = bridges.match(
+ /(?:let|var)\s+([A-Za-z_]\w*)\s*=\s*UIGraphicsImageRendererFormat(?:\.default)?\(\)/,
+ );
+ expect(declaration).not.toBeNull();
+
+ const formatName = declaration![1];
+ const scalePattern = new RegExp(`\\b${escapeRegExp(formatName)}\\.scale\\s*=\\s*1(?:\\.0)?\\b`);
+ const rendererPattern = new RegExp(
+ `UIGraphicsImageRenderer\\(\\s*bounds:\\s*bounds,\\s*format:\\s*${escapeRegExp(formatName)}\\s*\\)`,
+ );
+ const declarationOffset = declaration!.index!;
+ const scaleOffset = bridges.search(scalePattern);
+ const rendererOffset = bridges.search(rendererPattern);
+
+ expect(scaleOffset).toBeGreaterThan(declarationOffset);
+ expect(rendererOffset).toBeGreaterThan(scaleOffset);
+ });
+
+ test('uses accessibilityActivate for SwiftUI while retaining synthesized-touch delivery', () => {
+ const bridges = readTemplate('Bridges.swift.template');
+ const tapStart = bridges.indexOf('private static func handleTap');
+ const tapEnd = bridges.indexOf('private static func handleType', tapStart);
+ expect(tapStart).toBeGreaterThanOrEqual(0);
+ expect(tapEnd).toBeGreaterThan(tapStart);
+ const handleTap = bridges.slice(tapStart, tapEnd);
+
+ const synthesizedTouchOffset = handleTap.indexOf('DebugBridgeTouch.sendTap');
+ const fallbackCall = handleTap.match(
+ /\b([A-Za-z_]\w*)\s*\(\s*at:\s*point\s*,\s*in:\s*window\s*\)/,
+ );
+ const activationOffset = handleTap.indexOf('.accessibilityActivate()');
+ expect(synthesizedTouchOffset).toBeGreaterThanOrEqual(0);
+ expect(fallbackCall).not.toBeNull();
+ expect(activationOffset).toBeGreaterThan(fallbackCall!.index!);
+
+ const fallbackName = fallbackCall![1];
+ expect(bridges).toMatch(
+ new RegExp(`(?:private\\s+)?static\\s+func\\s+${escapeRegExp(fallbackName)}\\s*\\(`),
+ );
+ });
+
+ test('finishes programmatic scrolls before returning success to the next tap', () => {
+ const bridges = readTemplate('Bridges.swift.template');
+ expect(bridges).toContain('setContentOffset(off, animated: false)');
+ expect(bridges).not.toContain('setContentOffset(off, animated: true)');
+ });
+
+ test('serializes accessibility traits without signed Int truncation', () => {
+ const bridges = readTemplate('Bridges.swift.template');
+ expect(bridges).toMatch(/\btraits\s*:\s*UInt64\b/);
+ expect(bridges).toContain('.uint64Value');
+ expect(bridges).not.toMatch(/\bInt(?:64)?\s*\(\s*view\.accessibilityTraits\.rawValue\s*\)/);
+ expect(bridges).not.toMatch(/accessibilityTraits[\s\S]{0,120}?\.intValue\b/);
+ });
+
+ test('validates every generated model before applying any snapshot state', () => {
+ const server = readTemplate('StateServer.swift.template');
+ const validationLoop = server.indexOf('for restore in atomicRestores');
+ const applyComment = server.indexOf('Phase two applies only after every model accepted');
+ const applyLoop = server.indexOf('for restore in atomicRestores', validationLoop + 1);
+
+ expect(server).toContain('typealias AtomicRestoreFn = (JSONDict, Bool) -> RestoreResult');
+ expect(server).toContain('restore(keys, false)');
+ expect(server).toContain('restore(keys, true)');
+ expect(validationLoop).toBeGreaterThanOrEqual(0);
+ expect(applyComment).toBeGreaterThan(validationLoop);
+ expect(applyLoop).toBeGreaterThan(applyComment);
+ });
+
+ test('turns non-JSON response bodies into an explicit HTTP 500', () => {
+ const server = readTemplate('StateServer.swift.template');
+ expect(server).toContain('JSONSerialization.isValidJSONObject(body)');
+ expect(server).toContain('responseStatus = 500');
+ expect(server).toContain('response_not_json_serializable');
+ expect(server).not.toContain('?? Data("{}".utf8)');
+ });
});
function hasSwift(): boolean {
diff --git a/test/skill-e2e-ios.test.ts b/test/skill-e2e-ios.test.ts
index 8d8f09c56..56211637a 100644
--- a/test/skill-e2e-ios.test.ts
+++ b/test/skill-e2e-ios.test.ts
@@ -171,9 +171,12 @@ describe('ios-qa E2E (no-device path)', () => {
writeFileSync(join(srcDir, 'AppState.swift'), `
@Observable
class AppState {
- @Snapshotable var isLoggedIn: Bool = false
- @Snapshotable var username: String = ""
- @Snapshotable var counter: Int = 0
+ // @Snapshotable
+ var isLoggedIn: Bool = false
+ // @Snapshotable
+ var username: String = ""
+ // @Snapshotable
+ var counter: Int = 0
var ephemeralCache: [String: Any] = [:]
}
`);
@@ -189,7 +192,8 @@ class AppState {
expect(result.specs).toHaveLength(1);
expect(result.specs[0]!.fields.map(f => f.name).sort()).toEqual(['counter', 'isLoggedIn', 'username']);
const generatedSwift = readFileSync(result.outputPath, 'utf-8');
- expect(generatedSwift).toContain('public enum AppStateAccessor');
+ expect(generatedSwift).toContain('enum AppStateAccessor');
+ expect(generatedSwift).not.toContain('public enum AppStateAccessor');
expect(generatedSwift).toContain('key: "isLoggedIn"');
expect(generatedSwift).toContain('key: "counter"');
expect(generatedSwift).not.toContain('key: "ephemeralCache"'); // not marked @Snapshotable