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