From 55efeb64368da98838dc302f76cec5c4431ae2bf Mon Sep 17 00:00:00 2001 From: t Date: Tue, 14 Jul 2026 18:52:31 -0700 Subject: [PATCH] revert(ios-qa): remove oversized wave expansion --- bin/gstack-ios-qa-regen | 154 ---- docs/howto-ios-testing-with-gstack.md | 88 +- ios-clean/SKILL.md | 5 +- ios-clean/SKILL.md.tmpl | 5 +- ios-qa/SKILL.md | 54 +- ios-qa/SKILL.md.tmpl | 54 +- ios-qa/daemon/src/devicectl.ts | 6 - ios-qa/daemon/src/index.ts | 184 +---- ios-qa/daemon/src/tunnel-bootstrap.ts | 202 +---- ios-qa/daemon/test/daemon-integration.test.ts | 252 ------ ios-qa/daemon/test/tunnel-bootstrap.test.ts | 374 --------- .../scripts/gen-accessors-tool/Package.swift | 4 +- .../Sources/GenAccessors/main.swift | 601 ++------------ ios-qa/scripts/gen-accessors.test.ts | 759 +----------------- ios-qa/scripts/gen-accessors.ts | 742 ++--------------- ios-qa/templates/Bridges.swift.template | 279 ++----- .../DebugBridgeManager.swift.template | 29 +- ios-qa/templates/DebugBridgeTouch.h.template | 4 - ios-qa/templates/DebugBridgeTouch.m.template | 46 +- .../DebugBridgeWiring.swift.template | 38 +- ios-qa/templates/StateAccessor.swift.template | 17 +- ios-qa/templates/StateServer.swift.template | 71 +- ios-sync/SKILL.md | 51 +- ios-sync/SKILL.md.tmpl | 51 +- test/skill-e2e-ios-swift-build.test.ts | 51 +- test/skill-e2e-ios.test.ts | 12 +- 26 files changed, 483 insertions(+), 3650 deletions(-) delete mode 100755 bin/gstack-ios-qa-regen diff --git a/bin/gstack-ios-qa-regen b/bin/gstack-ios-qa-regen deleted file mode 100755 index ffaec9ffb..000000000 --- a/bin/gstack-ios-qa-regen +++ /dev/null @@ -1,154 +0,0 @@ -#!/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/docs/howto-ios-testing-with-gstack.md b/docs/howto-ios-testing-with-gstack.md index 647a77c98..1187e9a85 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; `gstack-ios-qa-regen` and `gstack-ios-qa-daemon` must be on PATH). +- gstack installed (`./setup` complete; `bin/gstack-ios-qa-daemon` must be on disk and executable). - 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,49 +30,28 @@ 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: Generate the DebugBridge package +## Step 1: Add the DebugBridge templates to your iOS app -Run `/ios-qa` from the app root, or invoke the same deterministic regenerator directly: +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: -```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: +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`: ```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 ``` @@ -130,16 +109,7 @@ 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 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. +If `GSTACK_IOS_TARGET_UDID` is unset, the daemon picks the first paired connected device. ## Step 4: Drive the device @@ -153,39 +123,15 @@ 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` | Validate the full snapshot, then restore it on MainActor. | bearer + session, mutate tier | +| `GET /state/snapshot` | Dump every `@Snapshotable` field as JSON. | bearer | +| `POST /state/restore` | Atomically restore a full snapshot. | 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 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. +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. ## Step 5: Make remote agents work (optional) diff --git a/ios-clean/SKILL.md b/ios-clean/SKILL.md index 6c466896b..127649646 100644 --- a/ios-clean/SKILL.md +++ b/ios-clean/SKILL.md @@ -821,8 +821,9 @@ 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 standalone `// @Snapshotable` generator marker comments on the - canonical app state class. +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). 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 21a1d5495..3a64481a9 100644 --- a/ios-clean/SKILL.md.tmpl +++ b/ios-clean/SKILL.md.tmpl @@ -50,8 +50,9 @@ 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 standalone `// @Snapshotable` generator marker comments on the - canonical app state class. +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). 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 24624be5f..a5d4575d4 100644 --- a/ios-qa/SKILL.md +++ b/ios-qa/SKILL.md @@ -869,33 +869,17 @@ fi ## Phase 1: Read source, plan codegen 1. Walk the app source (passed as `--source `) and identify all `@Observable` - classes. Note any property immediately preceded by the generator marker - comment `// @Snapshotable` — those are the snapshot-eligible fields. The - marker is a comment so it composes with the `@Observable` macro. Each - marked field must belong to a file-scope observable class and be a writable - instance `var` with an explicit type and an internal or public setter. - Snapshot types are JSON-native scalars (`String`, `Bool`, integer widths, - `Float`, `Double`, `CGFloat`), arrays, String-keyed dictionaries, and their - Optional compositions. Keys must be unique across observable classes. - Codegen stops with a source diagnostic instead of emitting a broken or - lossy harness when any of these constraints is violated. -2. Show the user the accessor list and ask whether to install the DebugBridge + 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 SPM dependency into their `Package.swift` (one AskUserQuestion). ## Phase 2: Bootstrap the device bridge -1. Generate the canonical local bridge package, typed accessors, and installed - version marker with one deterministic command: - ```bash - ~/.claude/skills/gstack/bin/gstack-ios-qa-regen \ - --app-source "" \ - --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 +1. Add the `DebugBridge` 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 @@ -905,35 +889,27 @@ fi The app target depends on `DebugBridgeUI` with `.when(configuration: .debug)` (transitively pulls in Core + Touch). Release builds refuse to link these targets. -3. Wire the bridges from the `@main` App init, gated on `#if DEBUG`: +2. 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 ``` -4. Build + deploy to the device with `xcodebuild -scheme +3. Build + deploy to the device with `xcodebuild -scheme -destination 'platform=iOS,id=' build install`. -5. Launch via `devicectl device process launch --device --console `. +4. Launch via `devicectl device process launch --device --console `. Capture the boot token printed to `os_log` on first run. -6. Spawn the Mac-side daemon (on-demand) — `gstack-ios-qa-daemon`. Daemon +5. Spawn the Mac-side daemon (on-demand) — `gstack-ios-qa-daemon`. Daemon acquires an exclusive flock on `~/.gstack/ios-qa-daemon.pid`. If another daemon is alive, the second invocation discovers its port and connects. -7. Daemon immediately calls `POST /auth/rotate` on the iOS StateServer with a +6. 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 @@ -941,7 +917,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. @@ -1005,7 +981,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 route dropped or app relaunched | Daemon invalidates the stale tunnel and retries one fresh bootstrap; reconnect/unlock the iPhone if it persists | +| `503 device_disconnected` from proxy | USB tunnel dropped | Reconnect device; daemon auto-reconnects within 30s | | `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 78f9454b1..e93d2831a 100644 --- a/ios-qa/SKILL.md.tmpl +++ b/ios-qa/SKILL.md.tmpl @@ -97,33 +97,17 @@ fi ## Phase 1: Read source, plan codegen 1. Walk the app source (passed as `--source `) and identify all `@Observable` - classes. Note any property immediately preceded by the generator marker - comment `// @Snapshotable` — those are the snapshot-eligible fields. The - marker is a comment so it composes with the `@Observable` macro. Each - marked field must belong to a file-scope observable class and be a writable - instance `var` with an explicit type and an internal or public setter. - Snapshot types are JSON-native scalars (`String`, `Bool`, integer widths, - `Float`, `Double`, `CGFloat`), arrays, String-keyed dictionaries, and their - Optional compositions. Keys must be unique across observable classes. - Codegen stops with a source diagnostic instead of emitting a broken or - lossy harness when any of these constraints is violated. -2. Show the user the accessor list and ask whether to install the DebugBridge + 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 SPM dependency into their `Package.swift` (one AskUserQuestion). ## Phase 2: Bootstrap the device bridge -1. Generate the canonical local bridge package, typed accessors, and installed - version marker with one deterministic command: - ```bash - ~/.claude/skills/gstack/bin/gstack-ios-qa-regen \ - --app-source "" \ - --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 +1. Add the `DebugBridge` 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 @@ -133,35 +117,27 @@ fi The app target depends on `DebugBridgeUI` with `.when(configuration: .debug)` (transitively pulls in Core + Touch). Release builds refuse to link these targets. -3. Wire the bridges from the `@main` App init, gated on `#if DEBUG`: +2. 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 ``` -4. Build + deploy to the device with `xcodebuild -scheme +3. Build + deploy to the device with `xcodebuild -scheme -destination 'platform=iOS,id=' build install`. -5. Launch via `devicectl device process launch --device --console `. +4. Launch via `devicectl device process launch --device --console `. Capture the boot token printed to `os_log` on first run. -6. Spawn the Mac-side daemon (on-demand) — `gstack-ios-qa-daemon`. Daemon +5. Spawn the Mac-side daemon (on-demand) — `gstack-ios-qa-daemon`. Daemon acquires an exclusive flock on `~/.gstack/ios-qa-daemon.pid`. If another daemon is alive, the second invocation discovers its port and connects. -7. Daemon immediately calls `POST /auth/rotate` on the iOS StateServer with a +6. 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 @@ -169,7 +145,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. @@ -233,7 +209,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 route dropped or app relaunched | Daemon invalidates the stale tunnel and retries one fresh bootstrap; reconnect/unlock the iPhone if it persists | +| `503 device_disconnected` from proxy | USB tunnel dropped | Reconnect device; daemon auto-reconnects within 30s | | `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 c17aec751..ee1696eb9 100644 --- a/ios-qa/daemon/src/devicectl.ts +++ b/ios-qa/daemon/src/devicectl.ts @@ -13,10 +13,7 @@ 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; } @@ -86,10 +83,7 @@ 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 f3a70c89f..82628b136 100644 --- a/ios-qa/daemon/src/index.ts +++ b/ios-qa/daemon/src/index.ts @@ -62,36 +62,16 @@ export async function startDaemon(opts: DaemonOptions): Promise | null = null; + let cachedTunnelAt = 0; const getTunnel = async (): Promise => { - // 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; - }); + // 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(); } - 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; + return tunnel; }; // 2. Tailnet probe (fail-closed). @@ -106,7 +86,7 @@ export async function startDaemon(opts: DaemonOptions): Promise { - await handleLoopback({ req, res, tokenStore, getTunnel, invalidateTunnel }); + await handleLoopback({ req, res, tokenStore, getTunnel }); }); // Use port 0 for OS-assigned port when test/random port collisions are a risk. const requestedPort = opts.loopbackPort; @@ -117,7 +97,7 @@ export async function startDaemon(opts: DaemonOptions): Promise { - await handleLoopback({ req, res, tokenStore, getTunnel, invalidateTunnel }); + await handleLoopback({ req, res, tokenStore, getTunnel }); }); let v6Bound = false; try { @@ -138,7 +118,6 @@ 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[] = []; @@ -350,7 +228,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, invalidateTunnel } = ctx; + const { req, res, tokenStore, getTunnel } = ctx; const url = parseUrl(req.url ?? '/'); const path = url.pathname ?? '/'; const method = req.method ?? 'GET'; @@ -384,23 +262,16 @@ 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 proxied = await proxyWithTunnelRecovery({ - inbound: req, - body, - sessionId, - agentIdentity, - getTunnel, - invalidateTunnel, - }); - if (!proxied) { - sendJson(res, 503, { error: 'device_not_connected' }); - return; - } - const { upstream } = proxied; + const upstream = await proxyToDevice({ inbound: req, body, tunnel, sessionId, agentIdentity }); res.writeHead(upstream.status, upstream.headers); res.end(upstream.body); } catch (err) { @@ -416,7 +287,7 @@ interface TailnetCtx extends HandlerCtx { * Tailnet handler — locked allowlist + capability tiers. */ async function handleTailnet(ctx: TailnetCtx): Promise { - const { req, res, tokenStore, getTunnel, invalidateTunnel, whoIsImpl, auditPath, attemptsPath, allowlistPath } = ctx; + const { req, res, tokenStore, getTunnel, whoIsImpl, auditPath, attemptsPath, allowlistPath } = ctx; const url = parseUrl(req.url ?? '/'); const path = url.pathname ?? '/'; const method = req.method ?? 'GET'; @@ -504,20 +375,19 @@ async function handleTailnet(ctx: TailnetCtx): Promise { } // Proxy to device. - 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) { + const tunnel = await getTunnel(); + if (!tunnel) { sendJson(res, 503, { error: 'device_not_connected' }); return; } - const { tunnel, upstream } = proxied; + const sessionId = (req.headers['x-session-id'] as string | undefined) ?? null; + const upstream = await proxyToDevice({ + inbound: req, + body, + tunnel, + sessionId, + agentIdentity: session.identity, + }); // 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 f1be1ca48..aa6636938 100644 --- a/ios-qa/daemon/src/tunnel-bootstrap.ts +++ b/ios-qa/daemon/src/tunnel-bootstrap.ts @@ -5,8 +5,6 @@ // 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 @@ -16,7 +14,6 @@ // 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, @@ -24,13 +21,12 @@ import { isAppRunning, launchApp, copyFileFromAppContainer, - type DeviceEntry, type SpawnImpl, type ResolveImpl, } from './devicectl'; export interface BootstrapOptions { - /** Target iPhone UDID. If null, picks the best connected paired iPhone. */ + /** Target device UDID. If null, picks the first connected paired device. */ udid?: string; /** Bundle ID of the iOS app hosting the StateServer. */ bundleId: string; @@ -57,85 +53,10 @@ 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 @@ -156,39 +77,9 @@ export async function bootstrapTunnel(opts: BootstrapOptions): Promise d.identifier === opts.udid) - : pickDefaultDevice(devices); + : devices.find((d) => d.paired) ?? devices[0]; if (!target) { - 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`, - }; + return { ok: false, error: 'device_not_found', detail: opts.udid }; } if (!target.paired) { return { @@ -197,13 +88,6 @@ export async function bootstrapTunnel(opts: BootstrapOptions): 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 deadline = Date.now() + startupTimeoutMs; + let healthOK = false; + while (Date.now() < deadline) { + try { + const r = await fetchFn(`http://[${ipv6}]:${port}/healthz`, { + signal: AbortSignal.timeout(2_000), + }); + if (r.ok) { healthOK = true; break; } + } catch { /* retry */ } + await new Promise((res) => setTimeout(res, 250)); + } + if (!healthOK) { + return { ok: false, error: 'state_server_unreachable', detail: `no /healthz response from [${ipv6}]:${port} within ${startupTimeoutMs}ms` }; + } - const healthFailure = await waitForStateServer(); - if (healthFailure) return healthFailure; - - const readBootToken = () => copyFileFromAppContainer({ + const bootToken = copyFileFromAppContainer({ udid: target.identifier, bundleId: opts.bundleId, sourceRelativePath: tokenPath, spawn, }); - - let bootToken = readBootToken(); if (!bootToken) { - // 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; + return { ok: false, error: 'boot_token_unavailable', detail: `couldn't read ${tokenPath} from ${opts.bundleId}` }; } // 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 b41144ecd..b131cc3ff 100644 --- a/ios-qa/daemon/test/daemon-integration.test.ts +++ b/ios-qa/daemon/test/daemon-integration.test.ts @@ -132,258 +132,6 @@ 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 37af481be..188659b78 100644 --- a/ios-qa/daemon/test/tunnel-bootstrap.test.ts +++ b/ios-qa/daemon/test/tunnel-bootstrap.test.ts @@ -105,219 +105,6 @@ 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([ { @@ -379,48 +166,6 @@ 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([ { @@ -489,125 +234,6 @@ 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 477aa61a0..f71c58793 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. 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 c47e3cae3..8287350cb 100644 --- a/ios-qa/scripts/gen-accessors-tool/Sources/GenAccessors/main.swift +++ b/ios-qa/scripts/gen-accessors-tool/Sources/GenAccessors/main.swift @@ -1,7 +1,6 @@ // gen-accessors entry point. Walks the input dir for *.swift files, parses -// each via SwiftParser, finds @Observable classes with `// @Snapshotable` -// source-marker comments (plus the legacy attribute form), and emits -// StateAccessor.swift for each. +// each via SwiftParser, finds @Observable classes with @Snapshotable-marked +// properties, 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 @@ -16,8 +15,6 @@ struct AccessorSpec { let fields: [(name: String, typeText: String)] } -private let generatorFormatVersion = "accessor-generator-v5" - @main struct GenAccessors { static func main() async { @@ -35,84 +32,39 @@ struct GenAccessors { }() // Walk + collect *.swift files - guard let swiftFiles = collectSwiftFiles(at: inputDir, excluding: outputDir) else { + guard let swiftFiles = collectSwiftFiles(at: inputDir) else { FileHandle.standardError.write(Data("input dir not found: \(inputDir)\n".utf8)) exit(3) } - // 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 + // 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 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. - 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) - } + try? FileManager.default.removeItem(atPath: "\(outputDir)/StateAccessor.swift") + try? FileManager.default.copyItem(atPath: cachedOutput, toPath: "\(outputDir)/StateAccessor.swift") print("gen-accessors: cache hit (\(cacheKey))") return } - // Emit - 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) + // 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) + // Populate cache try? FileManager.default.createDirectory(atPath: "\(cacheDir)/\(cacheKey)", withIntermediateDirectories: true) try? output.write(toFile: cachedOutput, atomically: true, encoding: .utf8) @@ -120,154 +72,46 @@ struct GenAccessors { print("gen-accessors: wrote \(specs.count) accessor(s) to \(outputDir)/StateAccessor.swift") } - 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 + static func collectSwiftFiles(at path: String) -> [String]? { + guard let enumerator = FileManager.default.enumerator(atPath: path) else { return nil } var files: [String] = [] - 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) + for case let f as String in enumerator { + if f.hasSuffix(".swift") { files.append("\(path)/\(f)") } } return files.sorted() } - static func computeCacheKey(swiftFiles: [String], buildId: String) -> String { + static func computeCacheKey(swiftFiles: [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") ?? 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) + let toolRev = getEnv("GEN_ACCESSORS_REV") ?? "dev" + let platform = "darwin-arm64" // simplified for the test harness + var combined = "swift=\(swiftVer)|tool=\(toolRev)|platform=\(platform)|" for path in swiftFiles { if let data = try? Data(contentsOf: URL(fileURLWithPath: path)) { - // Do not include absolute checkout paths in cache identity. - combined.append(Data("\(data.count):".utf8)) - combined.append(data) - combined.append(Data("|".utf8)) + combined += "\(path):\(data.count):\(data.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() + return combined.data(using: .utf8)!.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 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" - } + out += "#if DEBUG\nimport Foundation\nimport DebugBridge\n\n" for spec in specs { - // 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 += "@MainActor\npublic enum \(spec.className)Accessor {\n" + out += " public static func register(_ state: \(spec.className)) {\n" out += " StateServer.shared.register(\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 += " buildId: \"\(buildId)\",\n" + out += " accessorHash: \"\(accessorHash)\",\n" + out += " atomicRestore: { _ in .ok }\n" out += " )\n" - for (name, typeText) in spec.fields { - let wrapped = optionalWrappedType(typeText) + for (name, _) in spec.fields { out += " StateServer.shared.registerAccessor(\n" out += " key: \"\(name)\",\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 += " type: \"Any\",\n" + out += " read: { state.\(name) as Any? },\n" + out += " write: { _ in false }\n" out += " )\n" } out += " }\n}\n\n" @@ -279,59 +123,6 @@ 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 @@ -342,88 +133,23 @@ 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 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 - } + // 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 } for binding in varDecl.bindings { - guard let pattern = binding.pattern.as(IdentifierPatternSyntax.self) else { - diagnostics.append("\(context) only supports an identifier binding") - continue + if let pattern = binding.pattern.as(IdentifierPatternSyntax.self) { + let name = pattern.identifier.text + let typeText = binding.typeAnnotation?.type.trimmedDescription ?? "Any" + fields.append((name, typeText)) } - // 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)) } } @@ -434,233 +160,10 @@ 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'; @@ -16,11 +13,9 @@ import { collectSwiftFiles, parseSwift, computeCacheKey, - computeAccessorHash, generate, pruneCache, render, - AccessorGenerationError, type AccessorSpec, } from './gen-accessors'; @@ -35,14 +30,12 @@ afterEach(() => { }); describe('parseSwift — fork regex-failure-mode fixtures', () => { - test('parses @Observable class with source-marker comments', () => { + test('parses @Observable class with simple @Snapshotable fields', () => { 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 } `; @@ -53,100 +46,12 @@ 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: - [Dictionary] + [CartItem] = [] var unrelated: Int = 0 } @@ -155,67 +60,20 @@ 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('Dictionary'); + expect(specs[0]!.fields[0]!.typeText).toContain('CartItem'); }); - test('handles JSON-compatible generic types in property signatures', () => { + test('handles 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('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([]); + expect(specs[0]!.fields[0]!.typeText).toContain('Result'); }); test('ignores fields without @Snapshotable marker', () => { @@ -256,7 +114,10 @@ class B { expect(specs.map(s => s.className).sort()).toEqual(['A', 'B']); }); - test('diagnoses fields with computed body braces', () => { + 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. const src = ` @Observable class M { @@ -266,50 +127,9 @@ class M { } } `; - 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)'); - } + const specs = parseSwift(src); + expect(specs).toHaveLength(1); + expect(specs[0]!.fields.map(f => f.name)).toEqual(['snapshotted']); }); }); @@ -424,68 +244,6 @@ 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', () => { @@ -525,35 +283,6 @@ 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); @@ -566,62 +295,6 @@ 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', () => { @@ -659,395 +332,14 @@ describe('render', () => { fields: [{ name: 'a', typeText: 'Int' }, { name: 'b', typeText: 'String' }], }]; const out = render(specs, 'build-1.2.3', 'hash-abc'); - 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('public enum AppStateAccessor'); expect(out).toContain('key: "a"'); expect(out).toContain('key: "b"'); - 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('buildId: "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', () => { @@ -1063,17 +355,4 @@ 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 738e08364..05475f918 100644 --- a/ios-qa/scripts/gen-accessors.ts +++ b/ios-qa/scripts/gen-accessors.ts @@ -5,16 +5,15 @@ // first time. Also exercised by tests so we can verify the cache + parse // behavior without a Swift toolchain. // -// The TS fast path uses a lightweight Swift lexical scanner — it understands: +// The TS port uses a stricter regex than the fork's original — it understands: // - @Observable class declarations -// - `// @Snapshotable` property markers (only marked fields are exported) -// - Legacy @Snapshotable attributes for existing integrations +// - @Snapshotable property markers (only marked fields are exported) // - Multi-line type signatures (collapses whitespace before matching) -// - JSON-native generic arrays and String-keyed dictionaries +// - Generic type parameters (matched as opaque text inside the type) // -// 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. +// What it does NOT handle (deferred to the SwiftPM tool): +// - Computed properties with bodies (regex can mis-parse braces) +// - Property wrappers other than @Snapshotable // // Composite cache key (codex-flagged): swift_version || tool_git_rev || // platform_triple || source_content_hash. Source-only hash misses generator @@ -36,16 +35,6 @@ 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; @@ -59,105 +48,56 @@ 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'; -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[] { +export function collectSwiftFiles(dir: string, opts: { excludeGenerated?: boolean } = {}): string[] { const out: string[] = []; const excludeGenerated = opts.excludeGenerated ?? true; - 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); - } + 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); } } - - 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; - for (const match of masked.matchAll(classPattern)) { + let match: RegExpExecArray | null; + while ((match = classPattern.exec(source)) !== null) { const className = match[1]!; - const matchOffset = match.index!; - const openBraceOffset = matchOffset + match[0].lastIndexOf('{'); - const startIdx = openBraceOffset + 1; - const endIdx = findMatchingBrace(masked, startIdx - 1); + const startIdx = classPattern.lastIndex; + const endIdx = findMatchingBrace(source, startIdx - 1); if (endIdx === -1) continue; - const body = masked.slice(startIdx, endIdx); + const body = source.slice(startIdx, endIdx); - 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; + const fields = parseFields(body); 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 { - // Strings and comments have already been blanked by maskSwiftSource, so - // braces here are syntax rather than prose or literal content. + // openIdx points at '{'. Return idx of matching '}', or -1. let depth = 0; for (let i = openIdx; i < s.length; i++) { const c = s[i]; @@ -165,305 +105,48 @@ 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; } -/** - * 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 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[] } { +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[] = []; - 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 }); + 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() }); } - return { fields, diagnostics }; + return fields; } export function computeCacheKey(inputs: { @@ -471,263 +154,35 @@ export function computeCacheKey(inputs: { swiftVersion: string; toolGitRev: string; platformTriple: string; - buildId?: string; }): string { const h = createHash('sha256'); - h.update(`${GENERATOR_FORMAT_VERSION}|swift=${inputs.swiftVersion}|tool=${inputs.toolGitRev}|platform=${inputs.platformTriple}|build=${inputs.buildId ?? 'unknown'}|`); + h.update(`swift=${inputs.swiftVersion}|tool=${inputs.toolGitRev}|platform=${inputs.platformTriple}|`); for (const f of inputs.swiftFiles) { const content = readFileSync(f); - // Cache identity is content-based. Absolute checkout paths must not make - // equivalent source trees produce different generated output. - h.update(`${content.length}:`); + h.update(`${f}:${content.length}:`); h.update(content); h.update('|'); } return h.digest('hex'); } -/** 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) { - 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'; + out += '#if DEBUG\nimport Foundation\nimport DebugBridge\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 += `@MainActor\npublic enum ${spec.className}Accessor {\n`; + out += ` public static func register(_ state: ${spec.className}) {\n`; out += ` StateServer.shared.register(\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 += ` buildId: "${buildId}",\n`; + out += ` accessorHash: "${accessorHash}",\n`; + out += ` atomicRestore: { _ in .ok }\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`; - 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 += ` read: { state.${field.name} as Any? },\n`; + out += ` write: { _ in false }\n`; out += ` )\n`; } out += ` }\n}\n\n`; @@ -760,15 +215,6 @@ 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'); } @@ -777,25 +223,13 @@ 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, { 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 swiftFiles = collectSwiftFiles(inputDir); 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'); @@ -808,13 +242,18 @@ export function generate(inputs: GenInputs): GenResult { return { outputPath: finalOutput, cacheKey, - accessorHash, - specs: allSpecs, + specs: [], // intentionally empty on cache hit (no need to re-parse) cacheHit: true, }; } - const rendered = render(allSpecs, buildId, accessorHash); + // 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); writeFileSync(finalOutput, rendered); // Populate cache (best-effort — cache failures don't break codegen). @@ -828,7 +267,6 @@ export function generate(inputs: GenInputs): GenResult { return { outputPath: finalOutput, cacheKey, - accessorHash, specs: allSpecs, cacheHit: false, }; @@ -862,18 +300,10 @@ if (import.meta.main) { const inputDir = args[inputIdx + 1]!; const outputIdx = args.indexOf('--output'); const outputDir = outputIdx !== -1 ? args[outputIdx + 1] : undefined; - 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; - } + 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`, + ); } diff --git a/ios-qa/templates/Bridges.swift.template b/ios-qa/templates/Bridges.swift.template index 4a91142de..bf7af6e3f 100644 --- a/ios-qa/templates/Bridges.swift.template +++ b/ios-qa/templates/Bridges.swift.template @@ -27,35 +27,12 @@ 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 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 renderer = UIGraphicsImageRenderer(bounds: bounds) let image = renderer.image { _ in // drawHierarchy is the documented way to snapshot real UIKit // layers including layer-backed views. afterScreenUpdates: false @@ -88,10 +61,7 @@ enum ScreenshotBridgeImpl { } private static func activeKeyWindow(in scene: UIWindowScene) -> UIWindow? { - 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 }) + scene.windows.first(where: { $0.isKeyWindow }) ?? scene.windows.first } } @@ -106,40 +76,21 @@ enum ElementsBridgeImpl { static func snapshot() -> [JSONDict] { guard let scene = activeScene(), let window = activeKeyWindow(in: scene) else { return [] } var elements: [JSONDict] = [] - var visited = Set() - var remaining = 2_048 - collect( - view: window, - parentPath: "", - window: window, - visited: &visited, - remaining: &remaining, - into: &elements - ) + collect(view: window, parentPath: "", windowBounds: window.bounds, into: &elements) return elements } - 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 - + private static func collect(view: UIView, parentPath: String, windowBounds: CGRect, into elements: inout [JSONDict]) { // Skip hidden / zero-size / off-screen subtrees early. if view.isHidden || view.alpha < 0.01 { return } - let frameInWindow = view.convert(view.bounds, to: window) - if !window.bounds.intersects(frameInWindow) { return } + let frameInWindow = view.convert(view.bounds, to: nil) + if !windowBounds.intersects(frameInWindow) { return } let isAccessible = view.isAccessibilityElement let label = view.accessibilityLabel ?? "" let identifier = view.accessibilityIdentifier ?? "" - let traits = NSNumber(value: view.accessibilityTraits.rawValue) + let traits = Int(view.accessibilityTraits.rawValue) let value = (view.accessibilityValue ?? "") as String let className = String(describing: type(of: view)) let path = parentPath.isEmpty ? className : "\(parentPath) > \(className)" @@ -169,98 +120,66 @@ enum ElementsBridgeImpl { ]) } - // 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 - ) + // 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, + ]) + } } } for sub in view.subviews { - 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 - ) - } + collect(view: sub, parentPath: path, windowBounds: windowBounds, into: &elements) } } @@ -272,10 +191,7 @@ enum ElementsBridgeImpl { } private static func activeKeyWindow(in scene: UIWindowScene) -> UIWindow? { - 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 }) + scene.windows.first(where: { $0.isKeyWindow }) ?? scene.windows.first } } @@ -294,62 +210,21 @@ enum MutationBridgeImpl { } } - /// 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. + /// 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. 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 } @@ -391,10 +266,7 @@ 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)) - // Automation commands return synchronously; do not report - // success while the target is still moving underneath the - // next tap coordinate. - scroll.setContentOffset(off, animated: false) + scroll.setContentOffset(off, animated: true) return true } node = cur.superview @@ -429,10 +301,7 @@ enum MutationBridgeImpl { } private static func activeKeyWindow(in scene: UIWindowScene) -> UIWindow? { - 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 }) + scene.windows.first(where: { $0.isKeyWindow }) ?? scene.windows.first } } diff --git a/ios-qa/templates/DebugBridgeManager.swift.template b/ios-qa/templates/DebugBridgeManager.swift.template index 8dfb96d88..e18e2fb05 100644 --- a/ios-qa/templates/DebugBridgeManager.swift.template +++ b/ios-qa/templates/DebugBridgeManager.swift.template @@ -14,16 +14,12 @@ import Foundation public final class DebugBridgeManager { public static let shared = DebugBridgeManager() - /// 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) + public func start(appState: AppState) { + // 1. Register the canonical AppState struct + accessor wiring. + // AppStateAccessor.register(_:) is generated by gen-accessors-tool. + AppStateAccessor.register(appState) - // Boot only after registration so the first snapshot has a real build - // id, schema hash, and key set. + // 2. Boot the StateServer. StateServer.shared.start() // 3. The consuming app installs DebugOverlayWindow separately. See @@ -35,4 +31,19 @@ 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 7106791bf..e44034530 100644 --- a/ios-qa/templates/DebugBridgeTouch.h.template +++ b/ios-qa/templates/DebugBridgeTouch.h.template @@ -23,10 +23,6 @@ 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 2c664f08a..a8b413d85 100644 --- a/ios-qa/templates/DebugBridgeTouch.m.template +++ b/ios-qa/templates/DebugBridgeTouch.m.template @@ -131,36 +131,6 @@ 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; @@ -207,7 +177,6 @@ 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 @@ -265,10 +234,6 @@ 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; @@ -287,17 +252,10 @@ 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:)]) { + if ([touch respondsToSelector:@selector(setGestureView:)] && + [hit isKindOfClass:[UIView class]]) { [touch setGestureView:(UIView *)hit]; } diff --git a/ios-qa/templates/DebugBridgeWiring.swift.template b/ios-qa/templates/DebugBridgeWiring.swift.template index bf7016d87..009a70861 100644 --- a/ios-qa/templates/DebugBridgeWiring.swift.template +++ b/ios-qa/templates/DebugBridgeWiring.swift.template @@ -6,35 +6,22 @@ // the DebugBridge target. #if DEBUG -import Foundation -import DebugBridgeCore -#if canImport(UIKit) -import DebugBridgeUI -#endif +import DebugBridge @MainActor -func startGstackDebugBridge( - appState: State, - register: (State) -> Void -) { +func startGstackDebugBridge(appState: AppState) { // Read --recording flag from launch arguments let recording = ProcessInfo.processInfo.arguments.contains("--gstack-recording") - // 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 + // 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) + } - // 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 + DebugBridgeManager.shared.start(appState: appState, recording: recording) } #endif @@ -46,10 +33,7 @@ func startGstackDebugBridge( // // init() { // #if DEBUG -// startGstackDebugBridge( -// appState: appState, -// register: MyAppStateAccessor.register -// ) +// startGstackDebugBridge(appState: appState) // #endif // } // diff --git a/ios-qa/templates/StateAccessor.swift.template b/ios-qa/templates/StateAccessor.swift.template index a9a87b6f8..07de99e19 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 preceded by `// @Snapshotable` are emitted. +// @Observable types. Only properties marked with @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 marked field +// {{ACCESSORS}} — generated register/read/write blocks per @Snapshotable field #if DEBUG @@ -21,16 +21,13 @@ public enum {{CLASS_NAME}}Accessor { StateServer.shared.register( buildId: "{{APP_BUILD_ID}}", accessorHash: "{{ACCESSOR_HASH}}", - atomicRestore: { keys, apply in - // Validate every key + type before mutating any state. Invalid - // input therefore cannot leave a partially restored model. + atomicRestore: { keys in + // Validate every key + type FIRST, then apply in one struct + // assignment so SwiftUI observers see exactly one change. var snapshot = state.snapshotable {{VALIDATION_BLOCK}} - // StateServer validates every model first, then invokes the - // same handlers with apply=true for a cross-model commit. - if apply { - state.snapshotable = snapshot - } + // Apply atomically. + state.snapshotable = snapshot return .ok } ) diff --git a/ios-qa/templates/StateServer.swift.template b/ios-qa/templates/StateServer.swift.template index b8441df16..803bedf31 100644 --- a/ios-qa/templates/StateServer.swift.template +++ b/ios-qa/templates/StateServer.swift.template @@ -59,20 +59,18 @@ public final class StateServer { private var writeHandlers: [String: WriteHandler] = [:] private var typeNames: [String: TypeName] = [:] - // 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 + // 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 public enum RestoreResult { case ok case missingKey(String) case typeMismatch(String) case schemaMismatch(expected: String, got: String) } - private var atomicRestores: [AtomicRestoreFn] = [] + private var atomicRestore: AtomicRestoreFn? // Snapshot schema hash — written by codegen, stable across builds with // identical accessor signatures. @@ -111,7 +109,7 @@ public final class StateServer { public func register(buildId: String, accessorHash: String, atomicRestore: @escaping AtomicRestoreFn) { self.appBuildId = buildId self.accessorHash = accessorHash - self.atomicRestores.append(atomicRestore) + self.atomicRestore = atomicRestore } public func registerAccessor(key: String, type: String, read: @escaping ReadHandler, write: @escaping WriteHandler) { @@ -286,7 +284,6 @@ public final class StateServer { "version": "1.0.0", "build": appBuildId, "accessor_hash": accessorHash, - "bundle_id": Bundle.main.bundleIdentifier ?? "unknown", ]) return } @@ -479,36 +476,22 @@ public final class StateServer { send(connection: connection, status: 400, body: ["error": "missing_keys"]) return } - guard !atomicRestores.isEmpty else { + guard let restore = atomicRestore else { send(connection: connection, status: 503, body: ["error": "atomic_restore_not_registered"]) return } - // 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 - } + // 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 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) @@ -539,19 +522,9 @@ public final class StateServer { // MARK: Response private func send(connection: NWConnection, status: Int, body: JSONDict) { - 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 json = (try? JSONSerialization.data(withJSONObject: body)) ?? Data("{}".utf8) let statusText: String - switch responseStatus { + switch status { case 200: statusText = "OK" case 400: statusText = "Bad Request" case 401: statusText = "Unauthorized" @@ -564,7 +537,7 @@ public final class StateServer { case 503: statusText = "Service Unavailable" default: statusText = "Status" } - let header = "HTTP/1.1 \(responseStatus) \(statusText)\r\nContent-Type: application/json\r\nContent-Length: \(json.count)\r\nConnection: close\r\n\r\n" + let header = "HTTP/1.1 \(status) \(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 95e0bca0a..2f689c4d6 100644 --- a/ios-sync/SKILL.md +++ b/ios-sync/SKILL.md @@ -806,53 +806,49 @@ 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` generator marker comment to a different field. +3. Move the `@Snapshotable` marker to a different field. This skill regenerates the relevant artifacts in place. -**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. +**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. ## 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_ROOT/VERSION`. +2. Read upstream version from `$GSTACK_HOME/ios-qa/.gstack-version` (or the + value baked into the installed gstack binary). 3. If versions match AND no new `@Observable` classes were added, exit early with "already up to date". ## Phase 2: Regenerate codegen output -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: +Run `gstack-ios-qa-regen` (or the underlying SwiftPM tool directly): ```bash -~/.claude/skills/gstack/bin/gstack-ios-qa-regen \ - --app-source "$APP_SOURCE_DIR" \ - --bridge-dir "$APP_SOURCE_DIR/DebugBridge" +swift run --package-path "$GSTACK_HOME/ios-qa/scripts/gen-accessors-tool" \ + gen-accessors --input "$APP_SOURCE_DIR" --output "$APP_SOURCE_DIR/DebugBridgeGenerated" ``` -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: Review the generated diff +## Phase 3: Update templated Swift files in place -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. +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). ## Phase 4: Verify @@ -866,6 +862,5 @@ and platform triple all match the cache, this is a ~50ms no-op. | Symptom | Action | |---|---| | Swift compile fails after regen | Revert via `git restore` + AskUserQuestion: surface the compile error | -| 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`. | +| 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` | diff --git a/ios-sync/SKILL.md.tmpl b/ios-sync/SKILL.md.tmpl index add54c3d8..156a33c4c 100644 --- a/ios-sync/SKILL.md.tmpl +++ b/ios-sync/SKILL.md.tmpl @@ -35,53 +35,49 @@ 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` generator marker comment to a different field. +3. Move the `@Snapshotable` marker to a different field. This skill regenerates the relevant artifacts in place. -**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. +**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. ## 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_ROOT/VERSION`. +2. Read upstream version from `$GSTACK_HOME/ios-qa/.gstack-version` (or the + value baked into the installed gstack binary). 3. If versions match AND no new `@Observable` classes were added, exit early with "already up to date". ## Phase 2: Regenerate codegen output -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: +Run `gstack-ios-qa-regen` (or the underlying SwiftPM tool directly): ```bash -~/.claude/skills/gstack/bin/gstack-ios-qa-regen \ - --app-source "$APP_SOURCE_DIR" \ - --bridge-dir "$APP_SOURCE_DIR/DebugBridge" +swift run --package-path "$GSTACK_HOME/ios-qa/scripts/gen-accessors-tool" \ + gen-accessors --input "$APP_SOURCE_DIR" --output "$APP_SOURCE_DIR/DebugBridgeGenerated" ``` -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: Review the generated diff +## Phase 3: Update templated Swift files in place -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. +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). ## Phase 4: Verify @@ -95,6 +91,5 @@ and platform triple all match the cache, this is a ~50ms no-op. | Symptom | Action | |---|---| | Swift compile fails after regen | Revert via `git restore` + AskUserQuestion: surface the compile error | -| 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`. | +| 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` | diff --git a/test/skill-e2e-ios-swift-build.test.ts b/test/skill-e2e-ios-swift-build.test.ts index d6e4bf0e1..4347e2c05 100644 --- a/test/skill-e2e-ios-swift-build.test.ts +++ b/test/skill-e2e-ios-swift-build.test.ts @@ -26,8 +26,31 @@ 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. 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); + }); + + 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.template declares all 3 DebugBridge targets', () => { const tmpl = readFileSync(join(TEMPLATES_PATH, 'Package.swift.template'), 'utf-8'); // Each target must be present as a library product AND a target definition. @@ -39,6 +62,19 @@ describe('template ↔ fixture parity', () => { 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 = readFileSync(join(TEMPLATES_PATH, 'Package.swift.template'), 'utf-8'); + expect(tmpl.split(/\r?\n/, 1)[0]).toBe('// swift-tools-version:5.9'); + }); + test('KIF-derived touch source preserves Apache-2.0 attribution', () => { const implementation = readFileSync(join(TEMPLATES_PATH, 'DebugBridgeTouch.m.template'), 'utf-8'); expect(implementation).toContain('Copyright 2011-2016 Square, Inc. and KIF contributors.'); @@ -146,18 +182,9 @@ describeIfSwift('swift build invariants', () => { const fixtureDir = join(workDir, 'FixtureApp'); try { cpSync(FIXTURE_PATH, fixtureDir, { recursive: true }); - writeFileSync( - join(fixtureDir, 'Package.swift'), - readFileSync(join(TEMPLATES_PATH, 'Package.swift.template'), 'utf-8'), - ); - writeFileSync( - join(fixtureDir, 'Sources/DebugBridgeTouch/DebugBridgeTouch.m'), - readFileSync(join(TEMPLATES_PATH, 'DebugBridgeTouch.m.template'), 'utf-8'), - ); - writeFileSync( - join(fixtureDir, 'Sources/DebugBridgeTouch/include/DebugBridgeTouch.h'), - readFileSync(join(TEMPLATES_PATH, 'DebugBridgeTouch.h.template'), 'utf-8'), - ); + writeFileSync(join(fixtureDir, 'Package.swift'), readFileSync(join(TEMPLATES_PATH, 'Package.swift.template'))); + writeFileSync(join(fixtureDir, 'Sources/DebugBridgeTouch/DebugBridgeTouch.m'), readFileSync(join(TEMPLATES_PATH, 'DebugBridgeTouch.m.template'))); + writeFileSync(join(fixtureDir, 'Sources/DebugBridgeTouch/include/DebugBridgeTouch.h'), readFileSync(join(TEMPLATES_PATH, 'DebugBridgeTouch.h.template'))); expect(spawnSync('xcodegen', [ 'generate', '--spec', join(fixtureDir, 'project.yml'), '--project', fixtureDir, '--project-root', fixtureDir, diff --git a/test/skill-e2e-ios.test.ts b/test/skill-e2e-ios.test.ts index 56211637a..8d8f09c56 100644 --- a/test/skill-e2e-ios.test.ts +++ b/test/skill-e2e-ios.test.ts @@ -171,12 +171,9 @@ 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] = [:] } `); @@ -192,8 +189,7 @@ 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('enum AppStateAccessor'); - expect(generatedSwift).not.toContain('public enum AppStateAccessor'); + expect(generatedSwift).toContain('public enum AppStateAccessor'); expect(generatedSwift).toContain('key: "isLoggedIn"'); expect(generatedSwift).toContain('key: "counter"'); expect(generatedSwift).not.toContain('key: "ephemeralCache"'); // not marked @Snapshotable