Merge pull request #2264 from time-attack/time-attack/wave-test-infra-isolated-review

fix: harden test infrastructure and iOS device QA
This commit is contained in:
Joshua France
2026-07-14 18:33:46 -07:00
committed by GitHub
54 changed files with 6333 additions and 741 deletions
+1
View File
@@ -92,6 +92,7 @@ Companion CLIs (run on the Mac that's plugged into the device):
|---------|-------------| |---------|-------------|
| `gstack-ios-qa-daemon` | Mac-side broker. Loopback by default; `--tailnet` adds a Tailscale-facing listener with capability tiers and audit logging. | | `gstack-ios-qa-daemon` | Mac-side broker. Loopback by default; `--tailnet` adds a Tailscale-facing listener with capability tiers and audit logging. |
| `gstack-ios-qa-mint` | Owner-grant CLI for the tailnet allowlist (`grant`/`revoke`/`list`). | | `gstack-ios-qa-mint` | Owner-grant CLI for the tailnet allowlist (`grant`/`revoke`/`list`). |
| `gstack-ios-qa-regen` | Regenerate the canonical local DebugBridge package and typed accessors (`--app-source` / `--bridge-dir`). |
End-to-end walkthrough: [docs/howto-ios-testing-with-gstack.md](docs/howto-ios-testing-with-gstack.md). End-to-end walkthrough: [docs/howto-ios-testing-with-gstack.md](docs/howto-ios-testing-with-gstack.md).
+1
View File
@@ -245,6 +245,7 @@ Beyond the slash-command skills, gstack ships standalone CLIs for workflows that
| `gstack-taste-update` | **Design taste learning** — writes approvals and rejections from `/design-shotgun` into a persistent per-project taste profile. Decays 5%/week. Feeds back into future variant generation so the system learns what you actually pick. | | `gstack-taste-update` | **Design taste learning** — writes approvals and rejections from `/design-shotgun` into a persistent per-project taste profile. Decays 5%/week. Feeds back into future variant generation so the system learns what you actually pick. |
| `gstack-ios-qa-daemon` | **iOS QA daemon** — Mac-side broker between an agent and a connected iPhone over USB CoreDevice. Loopback by default; `--tailnet` opens a Tailscale-facing listener with identity-gated capability tiers. Single-instance via flock on `~/.gstack/ios-qa-daemon.pid`. See [docs/howto-ios-testing-with-gstack.md](docs/howto-ios-testing-with-gstack.md). | | `gstack-ios-qa-daemon` | **iOS QA daemon** — Mac-side broker between an agent and a connected iPhone over USB CoreDevice. Loopback by default; `--tailnet` opens a Tailscale-facing listener with identity-gated capability tiers. Single-instance via flock on `~/.gstack/ios-qa-daemon.pid`. See [docs/howto-ios-testing-with-gstack.md](docs/howto-ios-testing-with-gstack.md). |
| `gstack-ios-qa-mint` | **iOS allowlist manager** — owner-grant CLI for the tailnet allowlist. `grant`/`revoke`/`list` against `~/.gstack/ios-qa-allowlist.json` (mode 0600). Remote agents never auto-allowlist; this is the explicit-intent path. | | `gstack-ios-qa-mint` | **iOS allowlist manager** — owner-grant CLI for the tailnet allowlist. `grant`/`revoke`/`list` against `~/.gstack/ios-qa-allowlist.json` (mode 0600). Remote agents never auto-allowlist; this is the explicit-intent path. |
| `gstack-ios-qa-regen` | **iOS bridge regenerator** — deterministically installs the canonical DebugBridge package, generates typed state accessors, and records the installed gstack version. Safe to rerun after source changes or upgrades. |
### Continuous checkpoint mode (opt-in, local by default) ### Continuous checkpoint mode (opt-in, local by default)
+154
View File
@@ -0,0 +1,154 @@
#!/usr/bin/env bash
# gstack-ios-qa-regen — deterministically regenerate the iOS DebugBridge
# package and the app-owned typed state accessors.
set -euo pipefail
usage() {
cat <<'EOF'
Usage: gstack-ios-qa-regen --app-source <dir> --bridge-dir <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"
+8 -20
View File
@@ -25,26 +25,14 @@ fi
# Normalize: lowercase for case-insensitive SQL matching # Normalize: lowercase for case-insensitive SQL matching
CMD_LOWER=$(printf '%s' "$CMD" | tr '[:upper:]' '[:lower:]') CMD_LOWER=$(printf '%s' "$CMD" | tr '[:upper:]' '[:lower:]')
# --- Check for safe exceptions (rm -rf of build artifacts) --- # --- Check for safe exceptions (one standalone rm of build artifacts) ---
if printf '%s' "$CMD" | grep -qE 'rm\s+(-[a-zA-Z]*r[a-zA-Z]*\s+|--recursive\s+)' 2>/dev/null; then # Match the complete command. Parsing only the last rm is unsafe because shell
SAFE_ONLY=true # syntax or comments can hide an earlier destructive command, for example:
RM_ARGS=$(printf '%s' "$CMD" | sed -E 's/.*rm[[:space:]]+(-[a-zA-Z]+[[:space:]]+)*//;s/--recursive[[:space:]]*//') # rm -rf / # rm -rf node_modules
for target in $RM_ARGS; do # Unknown syntax fails closed and falls through to the destructive checks.
case "$target" in if printf '%s' "$CMD" | grep -qE '^[[:space:]]*rm[[:space:]]+(-[a-zA-Z]*r[a-zA-Z]*[[:space:]]+|--recursive[[:space:]]+)(([^[:space:];&|#]*/)?(node_modules|\.next|dist|__pycache__|\.cache|build|\.turbo|coverage)[[:space:]]*)+$' 2>/dev/null; then
*/node_modules|node_modules|*/\.next|\.next|*/dist|dist|*/__pycache__|__pycache__|*/\.cache|\.cache|*/build|build|*/\.turbo|\.turbo|*/coverage|coverage) echo '{}'
;; # safe target exit 0
-*)
;; # flag, skip
*)
SAFE_ONLY=false
break
;;
esac
done
if [ "$SAFE_ONLY" = true ]; then
echo '{}'
exit 0
fi
fi fi
# --- Destructive pattern checks --- # --- Destructive pattern checks ---
+71 -17
View File
@@ -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. - 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. - 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`. - An Apple developer team — the free personal team works fine for live-device debug deploys. You'll need the team ID (e.g. `623FYQ2M88`), not the certificate ID. Find it in Xcode → Settings → Accounts → your Apple ID → team list. The setup signs the app for your device on first deploy via `-allowProvisioningUpdates -allowProvisioningDeviceRegistration`.
- gstack installed (`./setup` complete; `bin/gstack-ios-qa-daemon` must be on disk and executable). - gstack installed (`./setup` complete; `gstack-ios-qa-regen` and `gstack-ios-qa-daemon` must be on PATH).
- Bun runtime on PATH (`bun --version`). The Mac-side daemon is a bun process. - 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. For the optional remote-agent (Tailscale) mode, you'll additionally need Tailscale installed on the Mac with `/var/run/tailscale.sock` readable.
@@ -30,28 +30,49 @@ For the optional remote-agent (Tailscale) mode, you'll additionally need Tailsca
The iOS `StateServer` is loopback-only **always**, even in remote mode. Identity validation happens Mac-side because the iPhone has no way to validate a Tailscale identity. The iOS `StateServer` is loopback-only **always**, even in remote mode. Identity validation happens Mac-side because the iPhone has no way to validate a Tailscale identity.
## Step 1: Add the DebugBridge templates to your iOS app ## Step 1: Generate the DebugBridge package
The templates live at `~/.claude/skills/gstack/ios-qa/templates/` after `./setup`. The fastest install is to invoke the `/ios-qa` skill in Claude Code from your app's root — it reads your Swift source, codegens typed `@Observable` state accessors, and lays down the templates with your bundle ID. Or do it by hand: Run `/ios-qa` from the app root, or invoke the same deterministic regenerator directly:
1. Copy these into a `DebugBridge/` SPM package inside your app workspace: ```bash
- `Sources/DebugBridgeCore/StateServer.swift` (from `StateServer.swift.template`) gstack-ios-qa-regen \
- `Sources/DebugBridgeCore/DebugBridgeManager.swift` (from `DebugBridgeManager.swift.template`) --app-source "$PWD/Sources/YourApp" \
- `Sources/DebugBridgeTouch/DebugBridgeTouch.m` + `Sources/DebugBridgeTouch/include/DebugBridgeTouch.h` (from the two `.template` files) --bridge-dir "$PWD/DebugBridge"
- `Sources/DebugBridgeUI/Bridges.swift` (from `Bridges.swift.template`) ```
- `Sources/DebugBridgeUI/DebugOverlay.swift` (from `DebugOverlay.swift.template`)
- `Package.swift` (from `Package.swift.template`) The command copies an explicit allowlist of canonical templates into the local
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. `DebugBridge/` Swift package, generates
3. In your `@main` App init, gate the wiring on `#if DEBUG`: `DebugBridgeGenerated/StateAccessor.swift`, and writes the installed version to
`DebugBridgeGenerated/.gstack-version`. It excludes generated output from its
own schema hash, so rerunning it with unchanged source is a fast, byte-stable
cache hit. It also removes the explicit legacy generated-file set from older
flat harness layouts so stale bridge sources cannot shadow the package.
1. Add `DebugBridge/` as a local package dependency. Depend on the
`DebugBridgeUI` product only in Debug configuration; `DebugBridgeCore` and
`DebugBridgeTouch` come in transitively.
2. Add `DebugBridgeGenerated/StateAccessor.swift` to the app target.
3. In your `@main` App init, install the UIKit resolvers before starting the
server, then register the generated accessor. Replace the example
state/accessor names with the type the generator found:
```swift ```swift
#if DEBUG #if DEBUG
import DebugBridgeCore import DebugBridgeCore
StateServer.shared.start()
#if canImport(UIKit) #if canImport(UIKit)
import DebugBridgeUI import DebugBridgeUI
#endif
#endif
// Inside App.init(), after appState is initialized:
#if DEBUG
#if canImport(UIKit)
DebugBridgeUIWiring.installAll() DebugBridgeUIWiring.installAll()
#endif #endif
DebugBridgeManager.shared.start(
appState: appState,
register: AppStateAccessor.register
)
#endif #endif
``` ```
@@ -109,7 +130,16 @@ GSTACK_IOS_TARGET_BUNDLE_ID=com.yourorg.yourapp
GSTACK_IOS_DAEMON_PORT=9099 # loopback listener port; default 9099 GSTACK_IOS_DAEMON_PORT=9099 # loopback listener port; default 9099
``` ```
If `GSTACK_IOS_TARGET_UDID` is unset, the daemon picks the first paired connected device. If `GSTACK_IOS_TARGET_UDID` is unset, the daemon picks the best paired,
available iPhone.
Automatic selection is restricted to available iPhones and prefers a wired
phone. The daemon keeps a healthy rotated tunnel, then invalidates and
rebootstraps once on an app-relaunch 401 or recoverable CoreDevice connection
failure.
If a newly started daemon reaches an already-running target whose one-use boot
token was consumed by an earlier daemon, it verifies the bundle owner, force
relaunches that target once, waits for a fresh token, verifies ownership again,
and rotates normally.
## Step 4: Drive the device ## Step 4: Drive the device
@@ -123,15 +153,39 @@ Once the daemon is running, you have an HTTP surface at `http://127.0.0.1:9099`
| `POST /session/release` | Release the lock. | bearer + session | | `POST /session/release` | Release the lock. | bearer + session |
| `GET /screenshot` | Capture a PNG of the active window. Returns `{png_base64: "..."}`. | bearer | | `GET /screenshot` | Capture a PNG of the active window. Returns `{png_base64: "..."}`. | bearer |
| `GET /elements` | Accessibility-tree snapshot. | bearer | | `GET /elements` | Accessibility-tree snapshot. | bearer |
| `GET /state/snapshot` | Dump every `@Snapshotable` field as JSON. | bearer | | `GET /state/snapshot` | Dump every `// @Snapshotable` field as JSON. | bearer |
| `POST /state/restore` | Atomically restore a full snapshot. | bearer + session, mutate tier | | `POST /state/restore` | Validate the full snapshot, then restore it on MainActor. | bearer + session, mutate tier |
| `POST /tap` `{x,y}` | Synthesize a real UITouch at window coordinates. SwiftUI Buttons fire. | bearer + session, interact tier | | `POST /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 /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 | | `POST /type` `{text}` | Set text on the current first responder. | bearer + session, interact tier |
Mutating requests require both an `Authorization: Bearer <token>` header AND an `X-Session-Id` header. Read endpoints (`/screenshot`, `/elements`, `GET /state/*`) only need the bearer. Mutating requests require both an `Authorization: Bearer <token>` header AND an `X-Session-Id` header. Read endpoints (`/screenshot`, `/elements`, `GET /state/*`) only need the bearer.
The state snapshot is opt-in per field via a `@Snapshotable` property wrapper on your canonical state struct. Fields you don't annotate never appear in the snapshot, which keeps tokens, PII, and auth state out of recorded fixtures by default. The state snapshot is opt-in per field via a standalone generator marker
comment immediately above a property. It is intentionally not a property
wrapper, so it compiles cleanly with Observation:
```swift
@Observable
final class AppState {
// @Snapshotable
var username: String = ""
var authToken: String = "" // never exported
}
```
Unmarked fields never appear in the snapshot, which keeps tokens, PII, and
auth state out of recorded fixtures by default. A marked field must be a
writable instance `var` on a file-scope observable class, with an explicit type
and an internal or public setter. Supported snapshot types are JSON-native
scalars (`String`, `Bool`, signed/unsigned integer widths, `Float`, `Double`,
`CGFloat`), arrays, String-keyed dictionaries, and Optional compositions of
those types. Snapshot keys must be unique across observable classes. The
generator reports and stops on invalid declarations, custom values, implicitly
unwrapped Optionals, nested observable classes, or duplicate keys instead of
emitting broken or lossy Swift. Restore uses two phases: every model validates
the complete input first, and only then are assignments applied on MainActor.
## Step 5: Make remote agents work (optional) ## Step 5: Make remote agents work (optional)
+2 -3
View File
@@ -821,9 +821,8 @@ Each item is reverted only after AskUserQuestion confirmation:
1. The `DebugBridge` SPM target from `Package.swift`. 1. The `DebugBridge` SPM target from `Package.swift`.
2. The `#if DEBUG` block in the app's `@main` entry that calls 2. The `#if DEBUG` block in the app's `@main` entry that calls
`DebugBridgeManager.shared.start()`. `DebugBridgeManager.shared.start()`.
3. Any `@Snapshotable` property wrappers on the canonical app state struct 3. Any standalone `// @Snapshotable` generator marker comments on the
(the codegen-detection markers — the wrapper file lives inside canonical app state class.
DebugBridge so removing the SPM dep removes the wrapper too).
4. Generated `StateAccessor.swift` files anywhere under the app source. 4. Generated `StateAccessor.swift` files anywhere under the app source.
5. The `gstack-ios-qa.token` file under `NSTemporaryDirectory()` on the 5. The `gstack-ios-qa.token` file under `NSTemporaryDirectory()` on the
device (best-effort — only works if device is connected when /ios-clean device (best-effort — only works if device is connected when /ios-clean
+2 -3
View File
@@ -50,9 +50,8 @@ Each item is reverted only after AskUserQuestion confirmation:
1. The `DebugBridge` SPM target from `Package.swift`. 1. The `DebugBridge` SPM target from `Package.swift`.
2. The `#if DEBUG` block in the app's `@main` entry that calls 2. The `#if DEBUG` block in the app's `@main` entry that calls
`DebugBridgeManager.shared.start()`. `DebugBridgeManager.shared.start()`.
3. Any `@Snapshotable` property wrappers on the canonical app state struct 3. Any standalone `// @Snapshotable` generator marker comments on the
(the codegen-detection markers — the wrapper file lives inside canonical app state class.
DebugBridge so removing the SPM dep removes the wrapper too).
4. Generated `StateAccessor.swift` files anywhere under the app source. 4. Generated `StateAccessor.swift` files anywhere under the app source.
5. The `gstack-ios-qa.token` file under `NSTemporaryDirectory()` on the 5. The `gstack-ios-qa.token` file under `NSTemporaryDirectory()` on the
device (best-effort — only works if device is connected when /ios-clean device (best-effort — only works if device is connected when /ios-clean
+39 -15
View File
@@ -869,17 +869,33 @@ fi
## Phase 1: Read source, plan codegen ## Phase 1: Read source, plan codegen
1. Walk the app source (passed as `--source <dir>`) and identify all `@Observable` 1. Walk the app source (passed as `--source <dir>`) and identify all `@Observable`
classes. Note any property marked with the `@Snapshotable` wrapper — those classes. Note any property immediately preceded by the generator marker
are the snapshot-eligible fields. comment `// @Snapshotable` — those are the snapshot-eligible fields. The
2. Run `swift run --package-path $GSTACK_HOME/ios-qa/scripts/gen-accessors-tool gen-accessors --input <source-dir>`. marker is a comment so it composes with the `@Observable` macro. Each
First invocation builds the swift-syntax dependency tree (cold: 2-5 min). marked field must belong to a file-scope observable class and be a writable
Subsequent runs are content-hash-cached and finish in ~50ms. instance `var` with an explicit type and an internal or public setter.
3. Show the user the accessor list and ask whether to install the DebugBridge Snapshot types are JSON-native scalars (`String`, `Bool`, integer widths,
`Float`, `Double`, `CGFloat`), arrays, String-keyed dictionaries, and their
Optional compositions. Keys must be unique across observable classes.
Codegen stops with a source diagnostic instead of emitting a broken or
lossy harness when any of these constraints is violated.
2. Show the user the accessor list and ask whether to install the DebugBridge
SPM dependency into their `Package.swift` (one AskUserQuestion). SPM dependency into their `Package.swift` (one AskUserQuestion).
## Phase 2: Bootstrap the device bridge ## Phase 2: Bootstrap the device bridge
1. Add the `DebugBridge` SPM dependency to the app's `Package.swift`. The package 1. Generate the canonical local bridge package, typed accessors, and installed
version marker with one deterministic command:
```bash
~/.claude/skills/gstack/bin/gstack-ios-qa-regen \
--app-source "<source-dir>" \
--bridge-dir "<source-dir>/DebugBridge"
```
The regenerator also removes the explicit obsolete flat-file set created by
older ios-sync versions, preventing a stale second harness from remaining
in the app target.
2. Add the generated `DebugBridge` local SPM dependency to the app's
`Package.swift`. The package
ships three Debug-config-only library products: ships three Debug-config-only library products:
- `DebugBridgeCore` (Swift, cross-platform) — StateServer + bridge protocols. - `DebugBridgeCore` (Swift, cross-platform) — StateServer + bridge protocols.
- `DebugBridgeTouch` (Objective-C, iOS-only) — KIF-derived in-process touch - `DebugBridgeTouch` (Objective-C, iOS-only) — KIF-derived in-process touch
@@ -889,27 +905,35 @@ fi
The app target depends on `DebugBridgeUI` with `.when(configuration: .debug)` The app target depends on `DebugBridgeUI` with `.when(configuration: .debug)`
(transitively pulls in Core + Touch). Release builds refuse to link these (transitively pulls in Core + Touch). Release builds refuse to link these
targets. targets.
2. Wire the bridges from the `@main` App init, gated on `#if DEBUG`: 3. Wire the bridges from the `@main` App init, gated on `#if DEBUG`:
```swift ```swift
#if DEBUG #if DEBUG
import DebugBridgeCore import DebugBridgeCore
StateServer.shared.start()
#if canImport(UIKit) #if canImport(UIKit)
import DebugBridgeUI import DebugBridgeUI
// Install resolvers before StateServer opens its listener.
DebugBridgeUIWiring.installAll() DebugBridgeUIWiring.installAll()
#endif #endif
// Replace AppState/AppStateAccessor with the type discovered in Phase 1.
DebugBridgeManager.shared.start(
appState: appState,
register: AppStateAccessor.register
)
#endif #endif
``` ```
3. Build + deploy to the device with `xcodebuild -scheme <SchemeName> 4. Build + deploy to the device with `xcodebuild -scheme <SchemeName>
-destination 'platform=iOS,id=<UDID>' build install`. -destination 'platform=iOS,id=<UDID>' build install`.
4. Launch via `devicectl device process launch --device <UDID> --console <bundle-id>`. 5. Launch via `devicectl device process launch --device <UDID> --console <bundle-id>`.
Capture the boot token printed to `os_log` on first run. Capture the boot token printed to `os_log` on first run.
5. Spawn the Mac-side daemon (on-demand) — `gstack-ios-qa-daemon`. Daemon 6. Spawn the Mac-side daemon (on-demand) — `gstack-ios-qa-daemon`. Daemon
acquires an exclusive flock on `~/.gstack/ios-qa-daemon.pid`. If another acquires an exclusive flock on `~/.gstack/ios-qa-daemon.pid`. If another
daemon is alive, the second invocation discovers its port and connects. daemon is alive, the second invocation discovers its port and connects.
6. Daemon immediately calls `POST /auth/rotate` on the iOS StateServer with a 7. Daemon immediately calls `POST /auth/rotate` on the iOS StateServer with a
fresh in-memory-only token. The boot token becomes useless ~5s later. fresh in-memory-only token. The boot token becomes useless ~5s later.
Anything scraping `os_log` past this point sees a dead credential. 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 ## Phase 3: Vision-driven agent loop
@@ -917,7 +941,7 @@ Each iteration:
1. `GET /screenshot` (via daemon) → save PNG. 1. `GET /screenshot` (via daemon) → save PNG.
2. `GET /elements` → accessibility tree. 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. 4. Decide next action based on what's on the screen vs the test goal.
5. `POST /session/acquire` to grab the device lock. 5. `POST /session/acquire` to grab the device lock.
6. Execute `POST /tap`, `/swipe`, `/type`, or `POST /state/<key>` write. 6. Execute `POST /tap`, `/swipe`, `/type`, or `POST /state/<key>` write.
@@ -981,7 +1005,7 @@ live.
| `curl: connection refused` to daemon | daemon crashed | Re-run `/ios-qa`; spawn-race lock will fail closed | | `curl: connection refused` to daemon | daemon crashed | Re-run `/ios-qa`; spawn-race lock will fail closed |
| `403 identity_not_allowed` from `/auth/mint` | identity missing from allowlist | Run `gstack-ios-qa-mint --remote <identity>` on the Mac | | `403 identity_not_allowed` from `/auth/mint` | identity missing from allowlist | Run `gstack-ios-qa-mint --remote <identity>` on the Mac |
| `409 schema_mismatch` on `/state/restore` | snapshot from older app build | Discard the snapshot; re-capture | | `409 schema_mismatch` on `/state/restore` | snapshot from older app build | Discard the snapshot; re-capture |
| `503 device_disconnected` from proxy | USB tunnel dropped | Reconnect device; daemon auto-reconnects within 30s | | `503 device_disconnected` from proxy | USB route dropped or app relaunched | Daemon invalidates the stale tunnel and retries one fresh bootstrap; reconnect/unlock the iPhone if it persists |
| `429 rate_limited` from `/auth/mint` | >10 mints/min from one identity | Wait 60s; check audit log for anomalies | | `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 | | `413 body_too_large` on `/state/restore` | snapshot >1MB | Increase `--max-body` or trim snapshot |
+39 -15
View File
@@ -97,17 +97,33 @@ fi
## Phase 1: Read source, plan codegen ## Phase 1: Read source, plan codegen
1. Walk the app source (passed as `--source <dir>`) and identify all `@Observable` 1. Walk the app source (passed as `--source <dir>`) and identify all `@Observable`
classes. Note any property marked with the `@Snapshotable` wrapper — those classes. Note any property immediately preceded by the generator marker
are the snapshot-eligible fields. comment `// @Snapshotable` — those are the snapshot-eligible fields. The
2. Run `swift run --package-path $GSTACK_HOME/ios-qa/scripts/gen-accessors-tool gen-accessors --input <source-dir>`. marker is a comment so it composes with the `@Observable` macro. Each
First invocation builds the swift-syntax dependency tree (cold: 2-5 min). marked field must belong to a file-scope observable class and be a writable
Subsequent runs are content-hash-cached and finish in ~50ms. instance `var` with an explicit type and an internal or public setter.
3. Show the user the accessor list and ask whether to install the DebugBridge Snapshot types are JSON-native scalars (`String`, `Bool`, integer widths,
`Float`, `Double`, `CGFloat`), arrays, String-keyed dictionaries, and their
Optional compositions. Keys must be unique across observable classes.
Codegen stops with a source diagnostic instead of emitting a broken or
lossy harness when any of these constraints is violated.
2. Show the user the accessor list and ask whether to install the DebugBridge
SPM dependency into their `Package.swift` (one AskUserQuestion). SPM dependency into their `Package.swift` (one AskUserQuestion).
## Phase 2: Bootstrap the device bridge ## Phase 2: Bootstrap the device bridge
1. Add the `DebugBridge` SPM dependency to the app's `Package.swift`. The package 1. Generate the canonical local bridge package, typed accessors, and installed
version marker with one deterministic command:
```bash
~/.claude/skills/gstack/bin/gstack-ios-qa-regen \
--app-source "<source-dir>" \
--bridge-dir "<source-dir>/DebugBridge"
```
The regenerator also removes the explicit obsolete flat-file set created by
older ios-sync versions, preventing a stale second harness from remaining
in the app target.
2. Add the generated `DebugBridge` local SPM dependency to the app's
`Package.swift`. The package
ships three Debug-config-only library products: ships three Debug-config-only library products:
- `DebugBridgeCore` (Swift, cross-platform) — StateServer + bridge protocols. - `DebugBridgeCore` (Swift, cross-platform) — StateServer + bridge protocols.
- `DebugBridgeTouch` (Objective-C, iOS-only) — KIF-derived in-process touch - `DebugBridgeTouch` (Objective-C, iOS-only) — KIF-derived in-process touch
@@ -117,27 +133,35 @@ fi
The app target depends on `DebugBridgeUI` with `.when(configuration: .debug)` The app target depends on `DebugBridgeUI` with `.when(configuration: .debug)`
(transitively pulls in Core + Touch). Release builds refuse to link these (transitively pulls in Core + Touch). Release builds refuse to link these
targets. targets.
2. Wire the bridges from the `@main` App init, gated on `#if DEBUG`: 3. Wire the bridges from the `@main` App init, gated on `#if DEBUG`:
```swift ```swift
#if DEBUG #if DEBUG
import DebugBridgeCore import DebugBridgeCore
StateServer.shared.start()
#if canImport(UIKit) #if canImport(UIKit)
import DebugBridgeUI import DebugBridgeUI
// Install resolvers before StateServer opens its listener.
DebugBridgeUIWiring.installAll() DebugBridgeUIWiring.installAll()
#endif #endif
// Replace AppState/AppStateAccessor with the type discovered in Phase 1.
DebugBridgeManager.shared.start(
appState: appState,
register: AppStateAccessor.register
)
#endif #endif
``` ```
3. Build + deploy to the device with `xcodebuild -scheme <SchemeName> 4. Build + deploy to the device with `xcodebuild -scheme <SchemeName>
-destination 'platform=iOS,id=<UDID>' build install`. -destination 'platform=iOS,id=<UDID>' build install`.
4. Launch via `devicectl device process launch --device <UDID> --console <bundle-id>`. 5. Launch via `devicectl device process launch --device <UDID> --console <bundle-id>`.
Capture the boot token printed to `os_log` on first run. Capture the boot token printed to `os_log` on first run.
5. Spawn the Mac-side daemon (on-demand) — `gstack-ios-qa-daemon`. Daemon 6. Spawn the Mac-side daemon (on-demand) — `gstack-ios-qa-daemon`. Daemon
acquires an exclusive flock on `~/.gstack/ios-qa-daemon.pid`. If another acquires an exclusive flock on `~/.gstack/ios-qa-daemon.pid`. If another
daemon is alive, the second invocation discovers its port and connects. daemon is alive, the second invocation discovers its port and connects.
6. Daemon immediately calls `POST /auth/rotate` on the iOS StateServer with a 7. Daemon immediately calls `POST /auth/rotate` on the iOS StateServer with a
fresh in-memory-only token. The boot token becomes useless ~5s later. fresh in-memory-only token. The boot token becomes useless ~5s later.
Anything scraping `os_log` past this point sees a dead credential. 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 ## Phase 3: Vision-driven agent loop
@@ -145,7 +169,7 @@ Each iteration:
1. `GET /screenshot` (via daemon) → save PNG. 1. `GET /screenshot` (via daemon) → save PNG.
2. `GET /elements` → accessibility tree. 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. 4. Decide next action based on what's on the screen vs the test goal.
5. `POST /session/acquire` to grab the device lock. 5. `POST /session/acquire` to grab the device lock.
6. Execute `POST /tap`, `/swipe`, `/type`, or `POST /state/<key>` write. 6. Execute `POST /tap`, `/swipe`, `/type`, or `POST /state/<key>` write.
@@ -209,7 +233,7 @@ live.
| `curl: connection refused` to daemon | daemon crashed | Re-run `/ios-qa`; spawn-race lock will fail closed | | `curl: connection refused` to daemon | daemon crashed | Re-run `/ios-qa`; spawn-race lock will fail closed |
| `403 identity_not_allowed` from `/auth/mint` | identity missing from allowlist | Run `gstack-ios-qa-mint --remote <identity>` on the Mac | | `403 identity_not_allowed` from `/auth/mint` | identity missing from allowlist | Run `gstack-ios-qa-mint --remote <identity>` on the Mac |
| `409 schema_mismatch` on `/state/restore` | snapshot from older app build | Discard the snapshot; re-capture | | `409 schema_mismatch` on `/state/restore` | snapshot from older app build | Discard the snapshot; re-capture |
| `503 device_disconnected` from proxy | USB tunnel dropped | Reconnect device; daemon auto-reconnects within 30s | | `503 device_disconnected` from proxy | USB route dropped or app relaunched | Daemon invalidates the stale tunnel and retries one fresh bootstrap; reconnect/unlock the iPhone if it persists |
| `429 rate_limited` from `/auth/mint` | >10 mints/min from one identity | Wait 60s; check audit log for anomalies | | `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 | | `413 body_too_large` on `/state/restore` | snapshot >1MB | Increase `--max-body` or trim snapshot |
+6
View File
@@ -13,7 +13,10 @@ export interface DeviceEntry {
identifier: string; identifier: string;
name: string; name: string;
model: string; model: string;
platform: string; // "iOS" | "iPadOS" | "visionOS" | ...
deviceType: string; // "iPhone" | "iPad" | "realityDevice" | ...
state: string; // "connected" | "available" | "available (paired)" | ... state: string; // "connected" | "available" | "available (paired)" | ...
transport: string; // "wired" on USB devices; empty for stale/unavailable entries
paired: boolean; paired: boolean;
} }
@@ -83,7 +86,10 @@ export function listDevices(spawn: SpawnImpl = defaultSpawn): DeviceEntry[] {
identifier: String(d.identifier ?? ''), identifier: String(d.identifier ?? ''),
name: String(props?.name ?? 'unknown'), name: String(props?.name ?? 'unknown'),
model: String(hw?.productType ?? 'unknown'), model: String(hw?.productType ?? 'unknown'),
platform: String(hw?.platform ?? ''),
deviceType: String(hw?.deviceType ?? ''),
state: String(conn?.tunnelState ?? 'unknown'), state: String(conn?.tunnelState ?? 'unknown'),
transport: String(conn?.transportType ?? ''),
paired: pairingState === 'paired', paired: pairingState === 'paired',
}; };
}); });
+157 -27
View File
@@ -62,16 +62,36 @@ export async function startDaemon(opts: DaemonOptions): Promise<RunningDaemon |
const tokenStore = new SessionTokenStore(); const tokenStore = new SessionTokenStore();
let tunnel: DeviceTunnel | null = null; let tunnel: DeviceTunnel | null = null;
let cachedTunnelAt = 0; let tunnelInFlight: Promise<DeviceTunnel | null> | null = null;
const getTunnel = async (): Promise<DeviceTunnel | null> => { const getTunnel = async (): Promise<DeviceTunnel | null> => {
// Cache the tunnel for 30s; refresh on demand. // A successful bootstrap consumes and deletes the app's one-shot boot
if (tunnel && Date.now() - cachedTunnelAt < 30_000) return tunnel; // token. Keep that rotated tunnel for this daemon's lifetime instead of
if (opts.tunnelProvider) { // trying to bootstrap it again on a timer. Failed attempts are not cached.
tunnel = await opts.tunnelProvider(); if (tunnel) return tunnel;
cachedTunnelAt = Date.now(); if (!opts.tunnelProvider) return null;
// Multiple first requests can arrive before bootstrap completes. Share
// one provider call so they cannot race through independent rotations.
if (!tunnelInFlight) {
tunnelInFlight = Promise.resolve()
.then(() => opts.tunnelProvider!())
.then((candidate) => {
if (candidate) tunnel = candidate;
return candidate;
})
.finally(() => {
tunnelInFlight = null;
});
} }
return tunnel; return tunnelInFlight;
};
const invalidateTunnel = (failedTunnel: DeviceTunnel): void => {
// A late response from the old app must not evict a tunnel that another
// request has already refreshed. Object identity gives each bootstrap a
// cheap generation token without exposing generation state elsewhere.
if (tunnel === failedTunnel) tunnel = null;
}; };
// 2. Tailnet probe (fail-closed). // 2. Tailnet probe (fail-closed).
@@ -86,7 +106,7 @@ export async function startDaemon(opts: DaemonOptions): Promise<RunningDaemon |
// 3. Loopback listener (full surface). // 3. Loopback listener (full surface).
const loopbackServer = createServer(async (req, res) => { const loopbackServer = createServer(async (req, res) => {
await handleLoopback({ req, res, tokenStore, getTunnel }); await handleLoopback({ req, res, tokenStore, getTunnel, invalidateTunnel });
}); });
// Use port 0 for OS-assigned port when test/random port collisions are a risk. // Use port 0 for OS-assigned port when test/random port collisions are a risk.
const requestedPort = opts.loopbackPort; const requestedPort = opts.loopbackPort;
@@ -97,7 +117,7 @@ export async function startDaemon(opts: DaemonOptions): Promise<RunningDaemon |
// mode this can collide; we try the actualPort first and skip ipv6 if it // mode this can collide; we try the actualPort first and skip ipv6 if it
// fails (tests don't exercise ::1 explicitly). // fails (tests don't exercise ::1 explicitly).
const loopbackServerV6 = createServer(async (req, res) => { const loopbackServerV6 = createServer(async (req, res) => {
await handleLoopback({ req, res, tokenStore, getTunnel }); await handleLoopback({ req, res, tokenStore, getTunnel, invalidateTunnel });
}); });
let v6Bound = false; let v6Bound = false;
try { try {
@@ -118,6 +138,7 @@ export async function startDaemon(opts: DaemonOptions): Promise<RunningDaemon |
res, res,
tokenStore, tokenStore,
getTunnel, getTunnel,
invalidateTunnel,
auditPath: opts.auditPath, auditPath: opts.auditPath,
attemptsPath: opts.attemptsPath, attemptsPath: opts.attemptsPath,
allowlistPath: opts.allowlistPath, allowlistPath: opts.allowlistPath,
@@ -181,12 +202,113 @@ interface HandlerCtx {
res: ServerResponse; res: ServerResponse;
tokenStore: SessionTokenStore; tokenStore: SessionTokenStore;
getTunnel: () => Promise<DeviceTunnel | null>; getTunnel: () => Promise<DeviceTunnel | null>;
invalidateTunnel: (failedTunnel: DeviceTunnel) => void;
// Explicit security-log + allowlist paths (default to env-derived when undefined). // Explicit security-log + allowlist paths (default to env-derived when undefined).
auditPath?: string; auditPath?: string;
attemptsPath?: string; attemptsPath?: string;
allowlistPath?: string; allowlistPath?: string;
} }
type DeviceProxyResponse = Awaited<ReturnType<typeof proxyToDevice>>;
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<typeof proxyToDevice>[0]): Promise<DeviceProxyResponse> {
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<Buffer | { error: 'body_too_large' }> { function readBody(req: IncomingMessage, maxBytes = 1_048_576): Promise<Buffer | { error: 'body_too_large' }> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const chunks: Buffer[] = []; const chunks: Buffer[] = [];
@@ -228,7 +350,7 @@ function sendJson(res: ServerResponse, status: number, body: unknown): void {
* loopback bind itself is the boundary). * loopback bind itself is the boundary).
*/ */
async function handleLoopback(ctx: HandlerCtx): Promise<void> { async function handleLoopback(ctx: HandlerCtx): Promise<void> {
const { req, res, tokenStore, getTunnel } = ctx; const { req, res, tokenStore, getTunnel, invalidateTunnel } = ctx;
const url = parseUrl(req.url ?? '/'); const url = parseUrl(req.url ?? '/');
const path = url.pathname ?? '/'; const path = url.pathname ?? '/';
const method = req.method ?? 'GET'; const method = req.method ?? 'GET';
@@ -262,16 +384,23 @@ async function handleLoopback(ctx: HandlerCtx): Promise<void> {
} }
// Other endpoints — proxy to the device. // 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); const body = await readBody(req);
if ('error' in body) { sendJson(res, 413, body); return; } if ('error' in body) { sendJson(res, 413, body); return; }
const sessionId = (req.headers['x-session-id'] as string | undefined) ?? null; const sessionId = (req.headers['x-session-id'] as string | undefined) ?? null;
const agentIdentity = (req.headers['x-agent-identity'] as string | undefined) ?? undefined; const agentIdentity = (req.headers['x-agent-identity'] as string | undefined) ?? undefined;
const upstream = await proxyToDevice({ inbound: req, body, tunnel, sessionId, agentIdentity }); const proxied = await proxyWithTunnelRecovery({
inbound: req,
body,
sessionId,
agentIdentity,
getTunnel,
invalidateTunnel,
});
if (!proxied) {
sendJson(res, 503, { error: 'device_not_connected' });
return;
}
const { upstream } = proxied;
res.writeHead(upstream.status, upstream.headers); res.writeHead(upstream.status, upstream.headers);
res.end(upstream.body); res.end(upstream.body);
} catch (err) { } catch (err) {
@@ -287,7 +416,7 @@ interface TailnetCtx extends HandlerCtx {
* Tailnet handler — locked allowlist + capability tiers. * Tailnet handler — locked allowlist + capability tiers.
*/ */
async function handleTailnet(ctx: TailnetCtx): Promise<void> { async function handleTailnet(ctx: TailnetCtx): Promise<void> {
const { req, res, tokenStore, getTunnel, whoIsImpl, auditPath, attemptsPath, allowlistPath } = ctx; const { req, res, tokenStore, getTunnel, invalidateTunnel, whoIsImpl, auditPath, attemptsPath, allowlistPath } = ctx;
const url = parseUrl(req.url ?? '/'); const url = parseUrl(req.url ?? '/');
const path = url.pathname ?? '/'; const path = url.pathname ?? '/';
const method = req.method ?? 'GET'; const method = req.method ?? 'GET';
@@ -375,19 +504,20 @@ async function handleTailnet(ctx: TailnetCtx): Promise<void> {
} }
// Proxy to device. // Proxy to device.
const tunnel = await getTunnel(); const sessionId = (req.headers['x-session-id'] as string | undefined) ?? null;
if (!tunnel) { const proxied = await proxyWithTunnelRecovery({
inbound: req,
body,
sessionId,
agentIdentity: session.identity,
getTunnel,
invalidateTunnel,
});
if (!proxied) {
sendJson(res, 503, { error: 'device_not_connected' }); sendJson(res, 503, { error: 'device_not_connected' });
return; return;
} }
const sessionId = (req.headers['x-session-id'] as string | undefined) ?? null; const { tunnel, upstream } = proxied;
const upstream = await proxyToDevice({
inbound: req,
body,
tunnel,
sessionId,
agentIdentity: session.identity,
});
// Audit the action (mutating endpoints only). // Audit the action (mutating endpoints only).
if (requiredCapability !== 'observe') { if (requiredCapability !== 'observe') {
+183 -19
View File
@@ -5,6 +5,8 @@
// 2. launch the app on it (no-op if already running) // 2. launch the app on it (no-op if already running)
// 3. wait briefly for the in-app StateServer to start // 3. wait briefly for the in-app StateServer to start
// 4. copy the boot token from the app's sandbox via devicectl copy from // 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 // 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 // 6. return a DeviceTunnel pointing at the device's IPv6 with the rotated
// bearer that subsequent proxied requests carry // bearer that subsequent proxied requests carry
@@ -14,6 +16,7 @@
// live token, which it scopes per-tailnet-session via /auth/mint. // live token, which it scopes per-tailnet-session via /auth/mint.
import { randomBytes } from 'crypto'; import { randomBytes } from 'crypto';
import { spawnSync } from 'child_process';
import type { DeviceTunnel } from './proxy'; import type { DeviceTunnel } from './proxy';
import { import {
listDevices, listDevices,
@@ -21,12 +24,13 @@ import {
isAppRunning, isAppRunning,
launchApp, launchApp,
copyFileFromAppContainer, copyFileFromAppContainer,
type DeviceEntry,
type SpawnImpl, type SpawnImpl,
type ResolveImpl, type ResolveImpl,
} from './devicectl'; } from './devicectl';
export interface BootstrapOptions { export interface BootstrapOptions {
/** Target device UDID. If null, picks the first connected paired device. */ /** Target iPhone UDID. If null, picks the best connected paired iPhone. */
udid?: string; udid?: string;
/** Bundle ID of the iOS app hosting the StateServer. */ /** Bundle ID of the iOS app hosting the StateServer. */
bundleId: string; bundleId: string;
@@ -53,10 +57,85 @@ export type BootstrapErrorReason =
| 'launch_failed' | 'launch_failed'
| 'device_locked' | 'device_locked'
| 'state_server_unreachable' | 'state_server_unreachable'
| 'wrong_app'
| 'boot_token_unavailable' | 'boot_token_unavailable'
| 'rotate_failed' | 'rotate_failed'
| 'resolve_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<DeviceEntry, 'state' | 'transport'>): 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 * 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 * the daemon's default tunnelProvider when GSTACK_IOS_TARGET_UDID is set
@@ -77,9 +156,39 @@ export async function bootstrapTunnel(opts: BootstrapOptions): Promise<Bootstrap
} }
const target = opts.udid const target = opts.udid
? devices.find((d) => d.identifier === opts.udid) ? devices.find((d) => d.identifier === opts.udid)
: devices.find((d) => d.paired) ?? devices[0]; : pickDefaultDevice(devices);
if (!target) { if (!target) {
return { ok: false, error: 'device_not_found', detail: opts.udid }; if (opts.udid) {
return { ok: false, error: 'device_not_found', detail: opts.udid };
}
const pairedIPhone = devices.find((d) => d.paired && isIPhoneDevice(d));
if (pairedIPhone) {
return {
ok: false,
error: 'device_not_found',
detail: `paired iPhone ${pairedIPhone.name} (${pairedIPhone.identifier}) is ${pairedIPhone.state}; connect it over USB and unlock it`,
};
}
const firstIPhone = devices.find(isIPhoneDevice);
if (!firstIPhone) {
return {
ok: false,
error: 'device_not_found',
detail: 'no iPhone is connected; non-iOS devices are not eligible for iOS QA',
};
}
return {
ok: false,
error: 'no_paired_device',
detail: `device ${firstIPhone.name} (${firstIPhone.identifier}) is ${firstIPhone.state}; run \`xcrun devicectl manage pair --device ${firstIPhone.identifier}\` and tap Trust on the iPhone`,
};
}
if (!isIPhoneDevice(target)) {
return {
ok: false,
error: 'device_not_found',
detail: `device ${target.name} (${target.identifier}) is ${target.platform || target.model}, not an iPhone`,
};
} }
if (!target.paired) { if (!target.paired) {
return { return {
@@ -88,6 +197,13 @@ export async function bootstrapTunnel(opts: BootstrapOptions): Promise<Bootstrap
detail: `device ${target.name} (${target.identifier}) is ${target.state}; run \`xcrun devicectl manage pair --device ${target.identifier}\` and tap Trust on the iPhone`, detail: `device ${target.name} (${target.identifier}) is ${target.state}; run \`xcrun devicectl manage pair --device ${target.identifier}\` and tap Trust on the iPhone`,
}; };
} }
if (!isAvailableDevice(target)) {
return {
ok: false,
error: 'device_not_found',
detail: `device ${target.name} (${target.identifier}) is ${target.state}; connect it over USB and unlock it`,
};
}
// Step 2: launch app (idempotent — devicectl returns success if already running) // Step 2: launch app (idempotent — devicectl returns success if already running)
if (!isAppRunning(target.identifier, opts.bundleId, spawn)) { if (!isAppRunning(target.identifier, opts.bundleId, spawn)) {
@@ -118,29 +234,77 @@ export async function bootstrapTunnel(opts: BootstrapOptions): Promise<Bootstrap
// Step 4: wait for StateServer to become reachable, then scrape boot token. // Step 4: wait for StateServer to become reachable, then scrape boot token.
// Probe /healthz with retries (the listener can take a moment to bind). // Probe /healthz with retries (the listener can take a moment to bind).
const deadline = Date.now() + startupTimeoutMs; const waitForStateServer = async (): Promise<BootstrapResult | null> => {
let healthOK = false; const deadline = Date.now() + startupTimeoutMs;
while (Date.now() < deadline) { while (Date.now() < deadline) {
try { try {
const r = await fetchFn(`http://[${ipv6}]:${port}/healthz`, { const r = await fetchFn(`http://[${ipv6}]:${port}/healthz`, {
signal: AbortSignal.timeout(2_000), signal: AbortSignal.timeout(2_000),
}); });
if (r.ok) { healthOK = true; break; } if (r.ok) {
} catch { /* retry */ } const health = await r.json().catch(() => null) as { bundle_id?: string } | null;
await new Promise((res) => setTimeout(res, 250)); // Older bridges did not identify their bundle. Preserve compatibility,
} // but reject an explicit mismatch from current bridges: another debug
if (!healthOK) { // app already owns the fixed StateServer port on this device.
return { ok: false, error: 'state_server_unreachable', detail: `no /healthz response from [${ipv6}]:${port} within ${startupTimeoutMs}ms` }; if (health?.bundle_id && health.bundle_id !== opts.bundleId) {
} return {
ok: false,
error: 'wrong_app',
detail: `expected ${opts.bundleId} but StateServer port ${port} belongs to ${health.bundle_id}; terminate the other debug app`,
};
}
return null;
}
} catch { /* retry */ }
await new Promise((res) => setTimeout(res, 250));
}
return {
ok: false,
error: 'state_server_unreachable',
detail: `no /healthz response from [${ipv6}]:${port} within ${startupTimeoutMs}ms`,
};
};
const bootToken = copyFileFromAppContainer({ const healthFailure = await waitForStateServer();
if (healthFailure) return healthFailure;
const readBootToken = () => copyFileFromAppContainer({
udid: target.identifier, udid: target.identifier,
bundleId: opts.bundleId, bundleId: opts.bundleId,
sourceRelativePath: tokenPath, sourceRelativePath: tokenPath,
spawn, spawn,
}); });
let bootToken = readBootToken();
if (!bootToken) { if (!bootToken) {
return { ok: false, error: 'boot_token_unavailable', detail: `couldn't read ${tokenPath} from ${opts.bundleId}` }; // A healthy running app can lack a boot token when an earlier daemon
// already rotated it. A new daemon has no way to recover that in-memory
// bearer, so restart exactly once to make StateServer mint a fresh one.
// The explicit bundle check above prevents disrupting an unrelated app
// that happens to own the fixed StateServer port.
const relaunched = relaunchApp(target.identifier, opts.bundleId, spawn);
if (!relaunched.ok) {
return { ok: false, error: relaunched.error, detail: relaunched.detail };
}
// The token is written before StateServer opens its listener. Waiting for
// it first prevents a stale response from the terminating process from
// being mistaken for readiness of the replacement process.
const tokenDeadline = Date.now() + startupTimeoutMs;
while (!bootToken && Date.now() < tokenDeadline) {
bootToken = readBootToken();
if (!bootToken) await new Promise((res) => setTimeout(res, 250));
}
if (!bootToken) {
return {
ok: false,
error: 'boot_token_unavailable',
detail: `couldn't read ${tokenPath} from ${opts.bundleId} after relaunch`,
};
}
const relaunchedHealthFailure = await waitForStateServer();
if (relaunchedHealthFailure) return relaunchedHealthFailure;
} }
// Step 5: rotate the boot token to a fresh in-memory-only one. // Step 5: rotate the boot token to a fresh in-memory-only one.
@@ -132,6 +132,258 @@ describe('daemon — loopback listener', () => {
expect(lastReq?.headers['x-session-id']).toBe('sess-loopback-1'); 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<void>((resolve) => { markBootstrapStarted = resolve; });
const bootstrapGate = new Promise<void>((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<void>((resolve) => { markRefreshStarted = resolve; });
const refreshGate = new Promise<void>((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<number>((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<number>((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 () => { test('returns 503 when no device tunnel is provided', async () => {
// Force tunnel provider to return null by closing + restarting with null provider. // Force tunnel provider to return null by closing + restarting with null provider.
await daemon.close(); await daemon.close();
+374
View File
@@ -105,6 +105,219 @@ describe('bootstrapTunnel', () => {
} }
}); });
test('never auto-selects a connected paired Vision Pro ahead of an iPhone', async () => {
const spawn = makeSpawn([
{
argsMatch: /devicectl list devices/,
jsonOutput: {
result: { devices: [
{
identifier: 'VISION',
connectionProperties: { tunnelState: 'connected', pairingState: 'paired' },
deviceProperties: { name: 'Vision Pro' },
hardwareProperties: {
productType: 'RealityDevice17,1',
platform: 'visionOS',
deviceType: 'realityDevice',
},
},
{
identifier: 'IPHONE',
connectionProperties: { tunnelState: 'connected', pairingState: 'paired', transportType: 'wired' },
deviceProperties: { name: 'USB iPhone' },
hardwareProperties: {
productType: 'iPhone17,1',
platform: 'iOS',
deviceType: 'iPhone',
},
},
] },
},
},
{
argsMatch: /devicectl device info processes -d IPHONE/,
jsonOutput: { result: { runningProcesses: [{ executable: 'file:///var/containers/Bundle/Application/X/com.test.app/com.test' }] } },
},
{
argsMatch: /devicectl device info details --device IPHONE/,
jsonOutput: { result: { connectionProperties: { tunnelIPAddress: 'fd00::17' } } },
},
{
argsMatch: /devicectl device copy from --device IPHONE/,
destOutput: 'TOKEN\n',
},
]);
const r = await bootstrapTunnel({
bundleId: 'com.test',
spawnImpl: spawn,
fetchImpl: (async () => new Response('{"ok":true}', { status: 200 })) as typeof fetch,
});
expect(r.ok).toBe(true);
if (r.ok) expect(r.tunnel.udid).toBe('IPHONE');
});
test('fails clearly when only non-iPhone platforms are connected', async () => {
const spawn = makeSpawn([
{
argsMatch: /devicectl list devices/,
jsonOutput: {
result: { devices: [
{
identifier: 'VISION',
connectionProperties: { tunnelState: 'connected', pairingState: 'paired' },
deviceProperties: { name: 'Vision Pro' },
hardwareProperties: {
productType: 'RealityDevice17,1',
platform: 'visionOS',
deviceType: 'realityDevice',
},
},
{
identifier: 'IPAD',
connectionProperties: { tunnelState: 'connected', pairingState: 'paired' },
deviceProperties: { name: 'iPad' },
hardwareProperties: {
productType: 'iPad16,6',
platform: 'iOS',
deviceType: 'iPad',
},
},
] },
},
},
]);
const r = await bootstrapTunnel({ bundleId: 'com.test', spawnImpl: spawn });
expect(r.ok).toBe(false);
if (!r.ok) {
expect(r.error).toBe('device_not_found');
expect(r.detail).toContain('no iPhone');
}
});
test('rejects an explicitly targeted non-iPhone device', async () => {
const spawn = makeSpawn([
{
argsMatch: /devicectl list devices/,
jsonOutput: {
result: { devices: [{
identifier: 'VISION',
connectionProperties: { tunnelState: 'connected', pairingState: 'paired' },
deviceProperties: { name: 'Vision Pro' },
hardwareProperties: {
productType: 'RealityDevice17,1',
platform: 'visionOS',
deviceType: 'realityDevice',
},
}] },
},
},
]);
const r = await bootstrapTunnel({ bundleId: 'com.test', udid: 'VISION', spawnImpl: spawn });
expect(r.ok).toBe(false);
if (!r.ok) {
expect(r.error).toBe('device_not_found');
expect(r.detail).toContain('not an iPhone');
}
});
test('skips an unavailable paired device for a connected or available paired device', async () => {
const spawn = makeSpawn([
{
argsMatch: /devicectl list devices/,
jsonOutput: {
result: { devices: [
{
identifier: 'STALE',
connectionProperties: { tunnelState: 'unavailable', pairingState: 'paired' },
deviceProperties: { name: 'Stale iPhone' },
hardwareProperties: { productType: 'iPhone17,1' },
},
{
identifier: 'WIRED',
connectionProperties: { tunnelState: 'available', pairingState: 'paired', transportType: 'wired' },
deviceProperties: { name: 'Wired iPhone' },
hardwareProperties: { productType: 'iPhone18,2' },
},
] },
},
},
{
argsMatch: /devicectl device info processes -d WIRED/,
jsonOutput: { result: { runningProcesses: [{ executable: 'file:///var/containers/Bundle/Application/X/com.test.app/com.test' }] } },
},
{
argsMatch: /devicectl device info details --device WIRED/,
jsonOutput: { result: { connectionProperties: { tunnelIPAddress: 'fd00::2' } } },
},
{
argsMatch: /devicectl device copy from --device WIRED/,
destOutput: 'TOKEN\n',
},
]);
const r = await bootstrapTunnel({
bundleId: 'com.test',
spawnImpl: spawn,
fetchImpl: (async () => new Response('{"ok":true}', { status: 200 })) as typeof fetch,
});
expect(r.ok).toBe(true);
if (r.ok) expect(r.tunnel.udid).toBe('WIRED');
});
test('accepts a wired iPhone whose tunnel is disconnected until devicectl wakes it', async () => {
const spawn = makeSpawn([
{
argsMatch: /devicectl list devices/,
jsonOutput: {
result: { devices: [
{
identifier: 'STALE-VISION',
connectionProperties: { tunnelState: 'unavailable', pairingState: 'paired' },
deviceProperties: { name: 'Stale Vision Pro' },
hardwareProperties: { productType: 'RealityDevice14,1' },
},
{
identifier: 'WIRED-DISCONNECTED',
connectionProperties: {
tunnelState: 'disconnected',
pairingState: 'paired',
transportType: 'wired',
},
deviceProperties: { name: 'USB iPhone' },
hardwareProperties: { productType: 'iPhone18,2' },
},
] },
},
},
{
argsMatch: /devicectl device info processes -d WIRED-DISCONNECTED/,
jsonOutput: { result: { runningProcesses: [{ executable: 'file:\/\/\/var\/containers\/Bundle\/Application\/X\/com.test.app\/com.test' }] } },
},
{
argsMatch: /devicectl device info details --device WIRED-DISCONNECTED/,
jsonOutput: { result: { connectionProperties: { tunnelIPAddress: 'fd00::27' } } },
},
{
argsMatch: /devicectl device copy from --device WIRED-DISCONNECTED/,
destOutput: 'TOKEN\n',
},
]);
const r = await bootstrapTunnel({
bundleId: 'com.test',
spawnImpl: spawn,
fetchImpl: (async () => new Response('{"ok":true}', { status: 200 })) as typeof fetch,
});
expect(r.ok).toBe(true);
if (r.ok) expect(r.tunnel.udid).toBe('WIRED-DISCONNECTED');
});
test('returns device_locked when launchApp errors due to lock', async () => { test('returns device_locked when launchApp errors due to lock', async () => {
const spawn = makeSpawn([ const spawn = makeSpawn([
{ {
@@ -166,6 +379,48 @@ describe('bootstrapTunnel', () => {
if (!r.ok) expect(r.error).toBe('state_server_unreachable'); 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 () => { test('happy path: returns DeviceTunnel with rotated token', async () => {
const spawn = makeSpawn([ const spawn = makeSpawn([
{ {
@@ -234,6 +489,125 @@ describe('bootstrapTunnel', () => {
expect(fetchCalls[fetchCalls.length - 1]?.url).toContain('/auth/rotate'); 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<string, string> | 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 () => { test('resolve_failed when hostname cant be resolved to an IPv6', async () => {
const spawn = makeSpawn([ const spawn = makeSpawn([
{ {
@@ -1,8 +1,8 @@
// swift-tools-version:5.9 // swift-tools-version:5.9
// //
// gen-accessors-tool SwiftPM tool that reads an app's Swift source via // gen-accessors-tool SwiftPM tool that reads an app's Swift source via
// swift-syntax, finds @Observable classes with @Snapshotable-marked fields, // swift-syntax, finds @Observable classes with `// @Snapshotable`-marked
// and emits StateAccessor.swift for each one. // fields, and emits StateAccessor.swift for each one.
// //
// First build is 2-5 min on a cold machine (swift-syntax compile chain). // First build is 2-5 min on a cold machine (swift-syntax compile chain).
// Subsequent runs are content-hash-cached and finish in ~50ms. // Subsequent runs are content-hash-cached and finish in ~50ms.
@@ -31,10 +31,5 @@ let package = Package(
], ],
path: "Sources/GenAccessors" path: "Sources/GenAccessors"
), ),
.testTarget(
name: "GenAccessorsTests",
dependencies: ["GenAccessors"],
path: "Tests/GenAccessorsTests"
),
] ]
) )
@@ -1,6 +1,7 @@
// gen-accessors entry point. Walks the input dir for *.swift files, parses // gen-accessors entry point. Walks the input dir for *.swift files, parses
// each via SwiftParser, finds @Observable classes with @Snapshotable-marked // each via SwiftParser, finds @Observable classes with `// @Snapshotable`
// properties, and emits StateAccessor.swift for each. // source-marker comments (plus the legacy attribute form), and emits
// StateAccessor.swift for each.
// //
// Output goes to --output (default: same dir as input). Cache key is // Output goes to --output (default: same dir as input). Cache key is
// computed from a composite hash and stored at // computed from a composite hash and stored at
@@ -15,6 +16,8 @@ struct AccessorSpec {
let fields: [(name: String, typeText: String)] let fields: [(name: String, typeText: String)]
} }
private let generatorFormatVersion = "accessor-generator-v5"
@main @main
struct GenAccessors { struct GenAccessors {
static func main() async { static func main() async {
@@ -32,38 +35,83 @@ struct GenAccessors {
}() }()
// Walk + collect *.swift files // Walk + collect *.swift files
guard let swiftFiles = collectSwiftFiles(at: inputDir) else { guard let swiftFiles = collectSwiftFiles(at: inputDir, excluding: outputDir) else {
FileHandle.standardError.write(Data("input dir not found: \(inputDir)\n".utf8)) FileHandle.standardError.write(Data("input dir not found: \(inputDir)\n".utf8))
exit(3) exit(3)
} }
// Composite cache key codex catch (source content alone misses // Parse + validate before consulting the cache. Invalid marked fields
// generator-logic changes). // must fail deterministically rather than being hidden by an older
let cacheKey = computeCacheKey(swiftFiles: swiftFiles) // cached output.
let cacheDir = ("~/.gstack/cache/gen-accessors" as NSString).expandingTildeInPath var specs: [AccessorSpec] = []
var diagnostics: [String] = []
for path in swiftFiles {
guard let source = try? String(contentsOfFile: path, encoding: .utf8) else { continue }
let tree = Parser.parse(source: source)
let visitor = ObservableClassVisitor(sourcePath: path, viewMode: .sourceAccurate)
visitor.walk(tree)
specs.append(contentsOf: visitor.specs)
diagnostics.append(contentsOf: visitor.diagnostics)
}
var snapshotKeyOwners: [String: String] = [:]
for spec in specs {
for field in spec.fields {
if let previous = snapshotKeyOwners[field.name] {
diagnostics.append(
"snapshot key '\(field.name)' is declared by both \(previous) and \(spec.className); "
+ "keys must be unique across @Observable types"
)
} else {
snapshotKeyOwners[field.name] = spec.className
}
}
}
if !diagnostics.isEmpty {
let message = "gen-accessors: invalid @Snapshotable declaration(s):\n"
+ diagnostics.map { " - \($0)" }.joined(separator: "\n") + "\n"
FileHandle.standardError.write(Data(message.utf8))
exit(4)
}
let buildId = detectedBuildId()
let accessorHash = computeAccessorHash(specs: specs)
// Cache identity includes build provenance and generator ABI. The
// separately computed accessorHash is schema-only and remains stable
// across checkout paths, unrelated sources, and app rebuilds.
let cacheKey = computeCacheKey(swiftFiles: swiftFiles, buildId: buildId)
let cacheDir = getEnv("GSTACK_IOS_CACHE_ROOT")
?? ("~/.gstack/cache/gen-accessors" as NSString).expandingTildeInPath
let cachedOutput = "\(cacheDir)/\(cacheKey)/StateAccessor.swift" 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) { if FileManager.default.fileExists(atPath: cachedOutput) {
// Cache hit. Copy to output dir. // Cache hit. Copy to output dir.
try? FileManager.default.removeItem(atPath: "\(outputDir)/StateAccessor.swift") let finalOutput = "\(outputDir)/StateAccessor.swift"
try? FileManager.default.copyItem(atPath: cachedOutput, toPath: "\(outputDir)/StateAccessor.swift") do {
if FileManager.default.fileExists(atPath: finalOutput) {
try FileManager.default.removeItem(atPath: finalOutput)
}
try FileManager.default.copyItem(atPath: cachedOutput, toPath: finalOutput)
} catch {
FileHandle.standardError.write(Data("gen-accessors: cannot restore cached output: \(error)\n".utf8))
exit(5)
}
print("gen-accessors: cache hit (\(cacheKey))") print("gen-accessors: cache hit (\(cacheKey))")
return return
} }
// Parse + extract specs
var specs: [AccessorSpec] = []
for path in swiftFiles {
guard let source = try? String(contentsOfFile: path, encoding: .utf8) else { continue }
let tree = Parser.parse(source: source)
let visitor = ObservableClassVisitor(viewMode: .sourceAccurate)
visitor.walk(tree)
specs.append(contentsOf: visitor.specs)
}
// Emit // Emit
let output = render(specs: specs, buildId: getEnv("APP_BUILD_ID") ?? "unknown", accessorHash: cacheKey) let output = render(specs: specs, buildId: buildId, accessorHash: accessorHash)
try? FileManager.default.createDirectory(atPath: outputDir, withIntermediateDirectories: true) do {
try? output.write(toFile: "\(outputDir)/StateAccessor.swift", atomically: true, encoding: .utf8) try output.write(toFile: "\(outputDir)/StateAccessor.swift", atomically: true, encoding: .utf8)
} catch {
FileHandle.standardError.write(Data("gen-accessors: cannot write output: \(error)\n".utf8))
exit(5)
}
// Populate cache // Populate cache
try? FileManager.default.createDirectory(atPath: "\(cacheDir)/\(cacheKey)", withIntermediateDirectories: true) try? FileManager.default.createDirectory(atPath: "\(cacheDir)/\(cacheKey)", withIntermediateDirectories: true)
@@ -72,46 +120,154 @@ struct GenAccessors {
print("gen-accessors: wrote \(specs.count) accessor(s) to \(outputDir)/StateAccessor.swift") print("gen-accessors: wrote \(specs.count) accessor(s) to \(outputDir)/StateAccessor.swift")
} }
static func collectSwiftFiles(at path: String) -> [String]? { static func collectSwiftFiles(at path: String, excluding outputPath: String) -> [String]? {
guard let enumerator = FileManager.default.enumerator(atPath: path) else { return nil } let inputURL = URL(fileURLWithPath: path).standardizedFileURL
let outputURL = URL(fileURLWithPath: outputPath).standardizedFileURL
var isDirectory: ObjCBool = false
guard FileManager.default.fileExists(atPath: inputURL.path, isDirectory: &isDirectory),
isDirectory.boolValue,
let enumerator = FileManager.default.enumerator(
at: inputURL,
includingPropertiesForKeys: [.isDirectoryKey],
options: []
) else { return nil }
// When --output is the input root, scan the root but still exclude
// StateAccessor.swift. For a nested output, skip the entire subtree.
let shouldExcludeOutputSubtree = outputURL.path != inputURL.path
var files: [String] = [] var files: [String] = []
for case let f as String in enumerator { for case let fileURL as URL in enumerator {
if f.hasSuffix(".swift") { files.append("\(path)/\(f)") } let normalized = fileURL.standardizedFileURL
let values = try? normalized.resourceValues(forKeys: [.isDirectoryKey])
if values?.isDirectory == true {
if normalized.lastPathComponent == "DebugBridgeGenerated"
|| (shouldExcludeOutputSubtree && normalized.path == outputURL.path) {
enumerator.skipDescendants()
}
continue
}
guard normalized.pathExtension == "swift",
normalized.lastPathComponent != "StateAccessor.swift" else { continue }
files.append(normalized.path)
} }
return files.sorted() return files.sorted()
} }
static func computeCacheKey(swiftFiles: [String]) -> String { static func computeCacheKey(swiftFiles: [String], buildId: String) -> String {
// Codex-flagged: hash must include Swift version, tool git rev, platform. // Codex-flagged: hash must include Swift version, tool git rev, platform.
let swiftVer = getEnv("SWIFT_VERSION") ?? "unknown" let swiftVer = getEnv("SWIFT_VERSION") ?? "unknown"
let toolRev = getEnv("GEN_ACCESSORS_REV") ?? "dev" let toolRev = getEnv("GEN_ACCESSORS_REV") ?? generatorFormatVersion
let platform = "darwin-arm64" // simplified for the test harness #if arch(arm64)
var combined = "swift=\(swiftVer)|tool=\(toolRev)|platform=\(platform)|" let platform = "darwin-arm64"
#elseif arch(x86_64)
let platform = "darwin-x86_64"
#else
let platform = "darwin-unknown"
#endif
var combined = Data("\(generatorFormatVersion)|swift=\(swiftVer)|tool=\(toolRev)|platform=\(platform)|build=\(buildId)|".utf8)
for path in swiftFiles { for path in swiftFiles {
if let data = try? Data(contentsOf: URL(fileURLWithPath: path)) { if let data = try? Data(contentsOf: URL(fileURLWithPath: path)) {
combined += "\(path):\(data.count):\(data.sha256())|" // Do not include absolute checkout paths in cache identity.
combined.append(Data("\(data.count):".utf8))
combined.append(data)
combined.append(Data("|".utf8))
} }
} }
return combined.data(using: .utf8)!.sha256() return combined.sha256()
}
static func computeAccessorHash(specs: [AccessorSpec]) -> String {
var signature = "snapshot-schema-v1\n"
for spec in specs {
signature += "C\(spec.className.utf8.count):\(spec.className)\n"
for field in spec.fields {
signature += "F\(field.name.utf8.count):\(field.name)"
signature += "T\(field.typeText.utf8.count):\(field.typeText)\n"
}
signature += "E\n"
}
return signature.sha256()
} }
static func render(specs: [AccessorSpec], buildId: String, accessorHash: String) -> String { static func render(specs: [AccessorSpec], buildId: String, accessorHash: String) -> String {
var out = "// AUTO-GENERATED — DO NOT EDIT. Regenerate with /ios-sync.\n" var out = "// AUTO-GENERATED — DO NOT EDIT. Regenerate with /ios-sync.\n"
out += "#if DEBUG\nimport Foundation\nimport DebugBridge\n\n" out += "#if DEBUG\nimport Foundation\nimport DebugBridgeCore\n\n"
if !specs.isEmpty {
// JSONSerialization produces Foundation bridge objects. Direct
// `as?` casts let NSNumber cross-cast between Bool and numeric
// Swift types. Round-tripping through JSONDecoder enforces the
// declared Codable shape (including every collection element)
// and preserves a successful Optional nil as a double Optional.
out += "private enum _GStackDebugBridgeSnapshotJSON {\n"
out += " private struct Box<Value: Decodable>: Decodable {\n"
out += " let value: Value\n"
out += " }\n\n"
out += " static func decode<Value: Decodable>(_ 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<Value>.self, from: data).value\n"
out += " } catch {\n"
out += " return nil\n"
out += " }\n"
out += " }\n"
out += "}\n\n"
}
for spec in specs { for spec in specs {
out += "@MainActor\npublic enum \(spec.className)Accessor {\n" // Accessors live in the app target beside its usually-internal
out += " public static func register(_ state: \(spec.className)) {\n" // state types. A public signature cannot expose an internal type.
out += "@MainActor\nenum \(spec.className)Accessor {\n"
out += " static func register(_ state: \(spec.className)) {\n"
out += " StateServer.shared.register(\n" out += " StateServer.shared.register(\n"
out += " buildId: \"\(buildId)\",\n" out += " buildId: {\n"
out += " accessorHash: \"\(accessorHash)\",\n" out += " let shortVersion = Bundle.main.object(forInfoDictionaryKey: \"CFBundleShortVersionString\") as? String\n"
out += " atomicRestore: { _ in .ok }\n" out += " let bundleVersion = Bundle.main.object(forInfoDictionaryKey: \"CFBundleVersion\") as? String\n"
out += " if let shortVersion, let bundleVersion { return \"\\(shortVersion) (\\(bundleVersion))\" }\n"
out += " return shortVersion ?? bundleVersion ?? \(String(reflecting: buildId))\n"
out += " }(),\n"
out += " accessorHash: \(String(reflecting: accessorHash)),\n"
out += " atomicRestore: { keys, apply in\n"
out += " // Validate every key and value before assignment.\n"
out += " // StateServer invokes every model once with apply=false,\n"
out += " // then applies every validated model with apply=true.\n"
for (index, field) in spec.fields.enumerated() {
let (name, typeText) = field
out += " guard let raw\(index) = keys[\"\(name)\"] else {\n"
out += " return .missingKey(\"\(name)\")\n"
out += " }\n"
out += " guard let restored\(index): \(typeText) = _GStackDebugBridgeSnapshotJSON.decode(raw\(index), as: \(typeText).self) else {\n"
out += " return .typeMismatch(\"\(name)\")\n"
out += " }\n"
}
out += " if apply {\n"
for (index, field) in spec.fields.enumerated() {
out += " state.\(field.name) = restored\(index)\n"
}
out += " }\n"
out += " return .ok\n"
out += " }\n"
out += " )\n" out += " )\n"
for (name, _) in spec.fields { for (name, typeText) in spec.fields {
let wrapped = optionalWrappedType(typeText)
out += " StateServer.shared.registerAccessor(\n" out += " StateServer.shared.registerAccessor(\n"
out += " key: \"\(name)\",\n" out += " key: \"\(name)\",\n"
out += " type: \"Any\",\n" out += " type: \"\(typeText)\",\n"
out += " read: { state.\(name) as Any? },\n" if wrapped != nil {
out += " write: { _ in false }\n" out += " read: {\n"
out += " guard let value = state.\(name) else { return NSNull() }\n"
out += " return value as Any\n"
out += " },\n"
} else {
out += " read: { state.\(name) as Any? },\n"
}
out += " write: { value in\n"
out += " guard let typed: \(typeText) = _GStackDebugBridgeSnapshotJSON.decode(value, as: \(typeText).self) else { return false }\n"
out += " state.\(name) = typed\n"
out += " return true\n"
out += " }\n"
out += " )\n" out += " )\n"
} }
out += " }\n}\n\n" out += " }\n}\n\n"
@@ -123,6 +279,59 @@ struct GenAccessors {
final class ObservableClassVisitor: SyntaxVisitor { final class ObservableClassVisitor: SyntaxVisitor {
var specs: [AccessorSpec] = [] 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 { override func visit(_ node: ClassDeclSyntax) -> SyntaxVisitorContinueKind {
// Look for @Observable attribute // Look for @Observable attribute
@@ -133,23 +342,88 @@ final class ObservableClassVisitor: SyntaxVisitor {
guard isObservable else { return .visitChildren } guard isObservable else { return .visitChildren }
let className = node.name.text 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)] = [] var fields: [(String, String)] = []
for member in node.memberBlock.members { for member in node.memberBlock.members {
guard let varDecl = member.decl.as(VariableDeclSyntax.self) else { continue } guard let varDecl = member.decl.as(VariableDeclSyntax.self) else { continue }
// Field must be marked @Snapshotable to be included // Field must opt in with the source marker (or legacy attribute).
let isSnapshotable = varDecl.attributes.contains(where: { attr in guard hasSnapshotableMarker(varDecl) else { continue }
guard let attr = attr.as(AttributeSyntax.self) else { return false }
return attr.attributeName.trimmedDescription == "Snapshotable" let bindingName = varDecl.bindings.first?
}) .pattern.as(IdentifierPatternSyntax.self)?.identifier.text ?? "<unknown>"
guard isSnapshotable else { continue } let context = "\((sourcePath as NSString).lastPathComponent): \(className).\(bindingName)"
if varDecl.bindingSpecifier.text == "let" {
diagnostics.append("\(context) must be declared var, not let")
continue
}
let modifierNames = varDecl.modifiers.map { modifier -> String in
let detail = modifier.detail?.trimmedDescription ?? ""
return modifier.name.text + detail
}
if modifierNames.contains(where: {
$0 == "private" || $0 == "fileprivate"
|| $0 == "private(set)" || $0 == "fileprivate(set)"
}) {
diagnostics.append("\(context) cannot be private, fileprivate, private(set), or fileprivate(set)")
continue
}
if modifierNames.contains("static") || modifierNames.contains("class") {
diagnostics.append("\(context) must be an instance property")
continue
}
if varDecl.bindings.count != 1 {
diagnostics.append("\(context) declaration must contain exactly one binding")
continue
}
for binding in varDecl.bindings { for binding in varDecl.bindings {
if let pattern = binding.pattern.as(IdentifierPatternSyntax.self) { guard let pattern = binding.pattern.as(IdentifierPatternSyntax.self) else {
let name = pattern.identifier.text diagnostics.append("\(context) only supports an identifier binding")
let typeText = binding.typeAnnotation?.type.trimmedDescription ?? "Any" continue
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))
} }
} }
@@ -160,10 +434,233 @@ final class ObservableClassVisitor: SyntaxVisitor {
} }
} }
private indirect enum JSONSnapshotType {
case scalar
case optional(JSONSnapshotType)
case array(JSONSnapshotType)
case dictionary(JSONSnapshotType)
var isOptional: Bool {
if case .optional = self { return true }
return false
}
}
private enum JSONSnapshotTypeParseResult {
case valid(JSONSnapshotType)
case implicitlyUnwrappedOptional
case nestedOptional
case unsupported
}
/// Parse the deliberately small set of Swift types that can make a lossless
/// trip through JSONSerialization and JSONDecoder without application-defined
/// encoding behavior. Type aliases and arbitrary Codable models are rejected:
/// the bridge has no compiler/type-checker context in which to prove that they
/// are JSON-native.
private func parseJSONSnapshotType(_ source: String) -> JSONSnapshotTypeParseResult {
let type = source.trimmingCharacters(in: .whitespacesAndNewlines)
guard !type.isEmpty else { return .unsupported }
if type.hasSuffix("!") {
return .implicitlyUnwrappedOptional
}
if type.hasSuffix("?") {
let wrappedText = String(type.dropLast()).trimmingCharacters(in: .whitespacesAndNewlines)
switch parseJSONSnapshotType(wrappedText) {
case .valid(let wrapped):
return wrapped.isOptional ? .nestedOptional : .valid(.optional(wrapped))
case let issue:
return issue
}
}
if let bracketContents = outerDelimitedContents(type, open: "[", close: "]") {
let dictionaryParts = splitTopLevel(bracketContents, separator: ":")
if dictionaryParts.count == 1 {
switch parseJSONSnapshotType(dictionaryParts[0]) {
case .valid(let element): return .valid(.array(element))
case let issue: return issue
}
}
guard dictionaryParts.count == 2, isStringType(dictionaryParts[0]) else {
return .unsupported
}
switch parseJSONSnapshotType(dictionaryParts[1]) {
case .valid(let value): return .valid(.dictionary(value))
case let issue: return issue
}
}
if let generic = genericTypeParts(type) {
let base = compactTypeName(generic.base)
if base == "Optional" || base == "Swift.Optional" {
guard generic.arguments.count == 1 else { return .unsupported }
switch parseJSONSnapshotType(generic.arguments[0]) {
case .valid(let wrapped):
return wrapped.isOptional ? .nestedOptional : .valid(.optional(wrapped))
case let issue:
return issue
}
}
if base == "Array" || base == "Swift.Array" {
guard generic.arguments.count == 1 else { return .unsupported }
switch parseJSONSnapshotType(generic.arguments[0]) {
case .valid(let element): return .valid(.array(element))
case let issue: return issue
}
}
if base == "Dictionary" || base == "Swift.Dictionary" {
guard generic.arguments.count == 2, isStringType(generic.arguments[0]) else {
return .unsupported
}
switch parseJSONSnapshotType(generic.arguments[1]) {
case .valid(let value): return .valid(.dictionary(value))
case let issue: return issue
}
}
return .unsupported
}
let scalar = compactTypeName(type)
let supportedScalars: Set<String> = [
"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..<characters.count {
if characters[index] == "<" {
depth += 1
} else if characters[index] == ">" {
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[..<openIndex]).trimmingCharacters(in: .whitespacesAndNewlines)
let argumentsText = String(characters[(openIndex + 1)..<closeIndex])
let arguments = splitTopLevel(argumentsText, separator: ",")
guard !base.isEmpty, !arguments.isEmpty else { return nil }
return (base, arguments)
}
private func splitTopLevel(_ source: String, separator: Character) -> [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..<index]).trimmingCharacters(in: .whitespacesAndNewlines)
)
start = index + 1
}
}
parts.append(String(characters[start...]).trimmingCharacters(in: .whitespacesAndNewlines))
return parts
}
func getEnv(_ key: String) -> String? { func getEnv(_ key: String) -> String? {
ProcessInfo.processInfo.environment[key] 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..<close]).trimmingCharacters(in: .whitespacesAndNewlines)
return wrapped.isEmpty ? nil : wrapped
}
}
}
return nil
}
import CryptoKit import CryptoKit
extension Data { extension Data {
+740 -19
View File
@@ -1,11 +1,14 @@
// Tests for the gen-accessors TS port. Covers: // Tests for the gen-accessors TS port. Covers:
// //
// - Parse: 3 regex-failure-mode fixtures from the fork (codex catch) // - Parse: lexical marker isolation + invalid declaration diagnostics
// - Cache: same input → same key; different swift version → different key; // - Cache: same input → same key; different swift version → different key;
// different tool rev → different key; file modified → different key // different tool rev/build provenance → different key
// - Schema: stable hash depends only on ordered accessor signatures
// - Optional: NSNull read/write/restore behavior and Swift type checking
// - Prune: >30d entries removed, recent kept // - Prune: >30d entries removed, recent kept
import { describe, test, expect, beforeEach, afterEach } from 'bun:test'; 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 { mkdtempSync, rmSync, writeFileSync, existsSync, readFileSync, mkdirSync, utimesSync } from 'fs';
import { join } from 'path'; import { join } from 'path';
import { tmpdir } from 'os'; import { tmpdir } from 'os';
@@ -13,9 +16,11 @@ import {
collectSwiftFiles, collectSwiftFiles,
parseSwift, parseSwift,
computeCacheKey, computeCacheKey,
computeAccessorHash,
generate, generate,
pruneCache, pruneCache,
render, render,
AccessorGenerationError,
type AccessorSpec, type AccessorSpec,
} from './gen-accessors'; } from './gen-accessors';
@@ -30,12 +35,14 @@ afterEach(() => {
}); });
describe('parseSwift — fork regex-failure-mode fixtures', () => { describe('parseSwift — fork regex-failure-mode fixtures', () => {
test('parses @Observable class with simple @Snapshotable fields', () => { test('parses @Observable class with source-marker comments', () => {
const src = ` const src = `
@Observable @Observable
final class AppState { final class AppState {
@Snapshotable var isLoggedIn: Bool = false // @Snapshotable
@Snapshotable var username: String = "" var isLoggedIn: Bool = false
// @Snapshotable
var username: String = ""
var notSnapshotable: Int = 0 var notSnapshotable: Int = 0
} }
`; `;
@@ -46,12 +53,100 @@ final class AppState {
expect(specs[0]!.fields.find(f => f.name === 'isLoggedIn')!.typeText).toBe('Bool'); 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', () => { test('handles @Snapshotable on multi-line type signatures', () => {
const src = ` const src = `
@Observable @Observable
class Cart { class Cart {
@Snapshotable var items: @Snapshotable var items:
[CartItem<Detail>] [Dictionary<String, [Int]>]
= [] = []
var unrelated: Int = 0 var unrelated: Int = 0
} }
@@ -60,20 +155,67 @@ class Cart {
expect(specs).toHaveLength(1); expect(specs).toHaveLength(1);
expect(specs[0]!.fields).toHaveLength(1); expect(specs[0]!.fields).toHaveLength(1);
expect(specs[0]!.fields[0]!.name).toBe('items'); expect(specs[0]!.fields[0]!.name).toBe('items');
expect(specs[0]!.fields[0]!.typeText).toContain('CartItem'); expect(specs[0]!.fields[0]!.typeText).toContain('Dictionary');
}); });
test('handles generic types in property signatures', () => { test('handles JSON-compatible generic types in property signatures', () => {
const src = ` const src = `
@Observable @Observable
class Repo { class Repo {
@Snapshotable var pages: Dictionary<String, [Result<Item, Error>]> = [:] @Snapshotable var pages: Dictionary<String, [Optional<Int>]> = [:]
} }
`; `;
const specs = parseSwift(src); const specs = parseSwift(src);
expect(specs).toHaveLength(1); expect(specs).toHaveLength(1);
expect(specs[0]!.fields[0]!.typeText).toContain('Dictionary'); expect(specs[0]!.fields[0]!.typeText).toContain('Dictionary');
expect(specs[0]!.fields[0]!.typeText).toContain('Result'); expect(specs[0]!.fields[0]!.typeText).toContain('Optional');
});
test('accepts qualified JSON-native scalar and collection spellings', () => {
const specs = parseSwift(`
@Observable
class Metrics {
// @Snapshotable
var ratio: CoreGraphics.CGFloat = 0
// @Snapshotable
var counts: Swift.Array<Swift.Int> = []
}
`);
expect(specs[0]!.fields.map((field) => field.typeText)).toEqual([
'CoreGraphics.CGFloat',
'Swift.Array<Swift.Int>',
]);
});
test('rejects custom values that Foundation JSON cannot serialize', () => {
expect(() => parseSwift(`
@Observable
class Repo {
// @Snapshotable
var pages: Dictionary<String, [Result<Item, Error>]> = [:]
}
`)).toThrow("unsupported snapshot type 'Result<Item,Error>'");
});
test('rejects nested observable types instead of emitting an unqualified reference', () => {
expect(() => parseSwift(`
enum Namespace {
@Observable
class State {
// @Snapshotable
var count: Int = 0
}
}
`)).toThrow('nested @Observable types are not supported');
});
test('ignores nested observable types that expose no snapshot fields', () => {
expect(parseSwift(`
enum Namespace {
@Observable
class State { var transient: Int = 0 }
}
`)).toEqual([]);
}); });
test('ignores fields without @Snapshotable marker', () => { test('ignores fields without @Snapshotable marker', () => {
@@ -114,10 +256,7 @@ class B {
expect(specs.map(s => s.className).sort()).toEqual(['A', 'B']); expect(specs.map(s => s.className).sort()).toEqual(['A', 'B']);
}); });
test('skips fields with computed body braces', () => { test('diagnoses 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 = ` const src = `
@Observable @Observable
class M { class M {
@@ -127,9 +266,50 @@ class M {
} }
} }
`; `;
const specs = parseSwift(src); expect(() => parseSwift(src)).toThrow("field 'computed' must be stored and writable");
expect(specs).toHaveLength(1); });
expect(specs[0]!.fields.map(f => f.name)).toEqual(['snapshotted']);
test.each([
['let', '// @Snapshotable\n let immutable: Int = 1', 'must be declared var, not let'],
['private', '// @Snapshotable\n private var secret: String = ""', 'cannot be private'],
['fileprivate', '// @Snapshotable\n fileprivate var secret: String = ""', 'cannot be private'],
['private(set)', '// @Snapshotable\n public private(set) var count: Int = 0', 'cannot be private'],
['fileprivate(set)', '// @Snapshotable\n fileprivate(set) var count: Int = 0', 'cannot be private'],
['inferred type', '// @Snapshotable\n var inferred = 42', 'requires an explicit type annotation'],
['multiple bindings', '// @Snapshotable\n var a: Int = 1, b: Int = 2', 'exactly one binding'],
['static', '// @Snapshotable\n static var shared: Int = 0', 'must be an instance property'],
['nested Optional', '// @Snapshotable\n var nested: String?? = nil', 'cannot use a nested Optional'],
['nested Optional in collection', '// @Snapshotable\n var nestedItems: [String??] = []', 'cannot use a nested Optional'],
['implicitly unwrapped Optional', '// @Snapshotable\n var legacy: String! = nil', 'cannot use an implicitly unwrapped Optional'],
['custom type', '// @Snapshotable\n var custom: CustomValue = .init()', 'uses unsupported snapshot type'],
])('diagnoses invalid %s declarations', (_label, declaration, expected) => {
expect(() => parseSwift(`
@Observable
final class InvalidState {
${declaration}
}
`)).toThrow(expected);
});
test('aggregates invalid declaration diagnostics with class and line context', () => {
try {
parseSwift(`
@Observable
final class InvalidState {
// @Snapshotable
let first: Int = 1
// @Snapshotable
private var second: String = ""
}
`);
throw new Error('expected parseSwift to fail');
} catch (error) {
expect(error).toBeInstanceOf(AccessorGenerationError);
const generationError = error as AccessorGenerationError;
expect(generationError.diagnostics).toHaveLength(2);
expect(generationError.message).toContain('InvalidState (line 4)');
expect(generationError.message).toContain('InvalidState (line 6)');
}
}); });
}); });
@@ -244,6 +424,68 @@ describe('computeCacheKey', () => {
}); });
expect(k1).not.toBe(k2); 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', () => { describe('generate', () => {
@@ -283,6 +525,35 @@ class AppState {
expect(r1.cacheKey).toBe(r2.cacheKey); 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', () => { test('modifying source invalidates the cache', () => {
const inputDir = join(workDir, 'src'); const inputDir = join(workDir, 'src');
mkdirSync(inputDir); mkdirSync(inputDir);
@@ -295,6 +566,62 @@ class AppState {
expect(r1.cacheKey).not.toBe(r2.cacheKey); expect(r1.cacheKey).not.toBe(r2.cacheKey);
expect(r2.cacheHit).toBe(false); 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', () => { describe('pruneCache', () => {
@@ -332,14 +659,395 @@ describe('render', () => {
fields: [{ name: 'a', typeText: 'Int' }, { name: 'b', typeText: 'String' }], fields: [{ name: 'a', typeText: 'Int' }, { name: 'b', typeText: 'String' }],
}]; }];
const out = render(specs, 'build-1.2.3', 'hash-abc'); const out = render(specs, 'build-1.2.3', 'hash-abc');
expect(out).toContain('public enum AppStateAccessor'); expect(out).toContain('enum AppStateAccessor');
expect(out).not.toContain('public enum AppStateAccessor');
expect(out).toContain('static func register(_ state: AppState)');
expect(out).not.toContain('public static func register(_ state: AppState)');
expect(out).toContain('key: "a"'); expect(out).toContain('key: "a"');
expect(out).toContain('key: "b"'); expect(out).toContain('key: "b"');
expect(out).toContain('buildId: "build-1.2.3"'); expect(out).toContain('return .missingKey("a")');
expect(out).toContain('return .typeMismatch("b")');
expect(out).toContain('guard let restored0 = Self.decodeSnapshotValue(raw0, as: Int.self)');
expect(out).toContain('guard let restored1 = Self.decodeSnapshotValue(raw1, as: String.self)');
expect(out).toContain('atomicRestore: { keys, apply in');
expect(out).toContain('if apply {');
expect(out).toContain('state.a = restored0');
expect(out).toContain('state.b = restored1');
expect(out.indexOf('state.a = restored0')).toBeGreaterThan(out.indexOf('guard let restored1'));
expect(out).toContain('guard let typed = Self.decodeSnapshotValue(value, as: Int.self) else { return false }');
expect(out).toContain('state.a = typed');
expect(out).toContain('return true');
expect(out).not.toContain('atomicRestore: { _ in .ok }');
expect(out).not.toContain('write: { _ in false }');
expect(out).toContain('CFBundleShortVersionString');
expect(out).toContain('CFBundleVersion');
expect(out).toContain('return shortVersion ?? bundleVersion ?? "build-1.2.3"');
expect(out).toContain('accessorHash: "hash-abc"'); expect(out).toContain('accessorHash: "hash-abc"');
expect(out).toContain('import DebugBridgeCore');
expect(out).not.toContain('import DebugBridge\n');
expect(out).toContain('#if DEBUG'); expect(out).toContain('#if DEBUG');
expect(out).toContain('#endif'); 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<Int>' },
],
}], '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<Int>');
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', () => { describe('collectSwiftFiles', () => {
@@ -355,4 +1063,17 @@ describe('collectSwiftFiles', () => {
const files = collectSwiftFiles(workDir); const files = collectSwiftFiles(workDir);
expect(files.sort()).toEqual([a, b].sort()); 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]);
});
}); });
+659 -89
View File
@@ -5,15 +5,16 @@
// first time. Also exercised by tests so we can verify the cache + parse // first time. Also exercised by tests so we can verify the cache + parse
// behavior without a Swift toolchain. // behavior without a Swift toolchain.
// //
// The TS port uses a stricter regex than the fork's original — it understands: // The TS fast path uses a lightweight Swift lexical scanner — it understands:
// - @Observable class declarations // - @Observable class declarations
// - @Snapshotable property markers (only marked fields are exported) // - `// @Snapshotable` property markers (only marked fields are exported)
// - Legacy @Snapshotable attributes for existing integrations
// - Multi-line type signatures (collapses whitespace before matching) // - Multi-line type signatures (collapses whitespace before matching)
// - Generic type parameters (matched as opaque text inside the type) // - JSON-native generic arrays and String-keyed dictionaries
// //
// What it does NOT handle (deferred to the SwiftPM tool): // Invalid marked declarations (computed/immutable/inaccessible/untyped,
// - Computed properties with bodies (regex can mis-parse braces) // nested, duplicate-keyed, or non-JSON) fail generation instead of producing
// - Property wrappers other than @Snapshotable // Swift that only fails later in xcodebuild or at snapshot time.
// //
// Composite cache key (codex-flagged): swift_version || tool_git_rev || // Composite cache key (codex-flagged): swift_version || tool_git_rev ||
// platform_triple || source_content_hash. Source-only hash misses generator // platform_triple || source_content_hash. Source-only hash misses generator
@@ -35,6 +36,16 @@ export interface AccessorSpec {
fields: AccessorField[]; 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 { export interface GenInputs {
inputDir: string; inputDir: string;
outputDir?: string; outputDir?: string;
@@ -48,56 +59,105 @@ export interface GenInputs {
export interface GenResult { export interface GenResult {
outputPath: string; outputPath: string;
cacheKey: string; cacheKey: string;
accessorHash: string;
specs: AccessorSpec[]; specs: AccessorSpec[];
cacheHit: boolean; cacheHit: boolean;
} }
const FALLBACK_PLATFORM = process.platform === 'darwin' ? 'darwin-arm64' : `${process.platform}-${process.arch}`; const FALLBACK_PLATFORM = process.platform === 'darwin' ? 'darwin-arm64' : `${process.platform}-${process.arch}`;
const GENERATOR_FORMAT_VERSION = 'accessor-generator-v5';
export function collectSwiftFiles(dir: string, opts: { excludeGenerated?: boolean } = {}): string[] { const JSON_SCALAR_TYPES = new Set([
'String',
'Bool',
'Int', 'Int8', 'Int16', 'Int32', 'Int64',
'UInt', 'UInt8', 'UInt16', 'UInt32', 'UInt64',
'Float', 'Double', 'CGFloat',
]);
export function collectSwiftFiles(
dir: string,
opts: { excludeGenerated?: boolean; outputDir?: string } = {},
): string[] {
const out: string[] = []; const out: string[] = [];
const excludeGenerated = opts.excludeGenerated ?? true; const excludeGenerated = opts.excludeGenerated ?? true;
for (const name of readdirSync(dir)) { const root = resolve(dir);
const full = join(dir, name); const outputDir = opts.outputDir ? resolve(opts.outputDir) : undefined;
const s = statSync(full);
if (s.isDirectory()) { function walk(currentDir: string): void {
// Skip generated output dir (when it lives under the input dir) for (const name of readdirSync(currentDir)) {
if (excludeGenerated && name === 'DebugBridgeGenerated') continue; const full = join(currentDir, name);
out.push(...collectSwiftFiles(full, opts)); const s = statSync(full);
} else if (name.endsWith('.swift')) { if (s.isDirectory()) {
// Skip the codegen output file. Otherwise the second run picks it up, // The generated subtree must never participate in its own cache key.
// changes the cache key, and the cache never hits. // Keep the well-known-name guard for direct collectSwiftFiles callers,
if (excludeGenerated && name === 'StateAccessor.swift') continue; // and use the actual --output path when generate() supplies one.
out.push(full); const isOutputSubtree = outputDir !== undefined
&& outputDir !== root
&& resolve(full) === outputDir;
if (excludeGenerated && (name === 'DebugBridgeGenerated' || isOutputSubtree)) continue;
walk(full);
} else if (name.endsWith('.swift')) {
// Skip generated accessor files wherever they live. Otherwise moving
// an old copy outside the output directory poisons the next cache key.
if (excludeGenerated && name === 'StateAccessor.swift') continue;
out.push(full);
}
} }
} }
walk(root);
return out.sort(); return out.sort();
} }
export function parseSwift(source: string): AccessorSpec[] { export function parseSwift(source: string): AccessorSpec[] {
const specs: AccessorSpec[] = []; const specs: AccessorSpec[] = [];
const diagnostics: string[] = [];
const masked = maskSwiftSource(source);
// Find `@Observable\n(public )?(final )?class <Name>` followed by a brace // Find `@Observable\n(public )?(final )?class <Name>` followed by a brace
// block. We then scan inside that block for @Snapshotable fields. // 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; const classPattern = /@Observable\s*(?:(?:public|internal|fileprivate|private)\s+)?(?:final\s+)?class\s+(\w+)[^{]*\{/g;
let match: RegExpExecArray | null; for (const match of masked.matchAll(classPattern)) {
while ((match = classPattern.exec(source)) !== null) {
const className = match[1]!; const className = match[1]!;
const startIdx = classPattern.lastIndex; const matchOffset = match.index!;
const endIdx = findMatchingBrace(source, startIdx - 1); const openBraceOffset = matchOffset + match[0].lastIndexOf('{');
const startIdx = openBraceOffset + 1;
const endIdx = findMatchingBrace(masked, startIdx - 1);
if (endIdx === -1) continue; if (endIdx === -1) continue;
const body = source.slice(startIdx, endIdx); const body = masked.slice(startIdx, endIdx);
const fields = parseFields(body); const parsed = parseFields(body, source, startIdx, className);
if (braceDepthAt(masked, matchOffset) !== 0) {
if (parsed.fields.length > 0 || parsed.diagnostics.length > 0) {
const line = source.slice(0, matchOffset).split(/\r?\n/).length;
diagnostics.push(`${className} (line ${line}): nested @Observable types are not supported; move the type to file scope`);
}
continue;
}
diagnostics.push(...parsed.diagnostics);
const fields = parsed.fields;
if (fields.length > 0) { if (fields.length > 0) {
specs.push({ className, fields }); specs.push({ className, fields });
} }
} }
if (diagnostics.length > 0) throw new AccessorGenerationError(diagnostics);
return specs; 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 { function findMatchingBrace(s: string, openIdx: number): number {
// openIdx points at '{'. Return idx of matching '}', or -1. // Strings and comments have already been blanked by maskSwiftSource, so
// braces here are syntax rather than prose or literal content.
let depth = 0; let depth = 0;
for (let i = openIdx; i < s.length; i++) { for (let i = openIdx; i < s.length; i++) {
const c = s[i]; const c = s[i];
@@ -105,48 +165,305 @@ function findMatchingBrace(s: string, openIdx: number): number {
else if (c === '}') { else if (c === '}') {
depth--; depth--;
if (depth === 0) return i; 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; return -1;
} }
function parseFields(body: string): AccessorField[] { /**
// Look for @Snapshotable followed by var/let declarations. Allow attribute * Blank comments and string literals while preserving byte offsets/newlines.
// ordering: `@Snapshotable var name: Type` OR `@Snapshotable\n var name: Type`. * A canonical standalone `// @Snapshotable` comment is rewritten to the
// Multi-line types are handled by greedy non-newline match in the type, but * equivalent attribute token. This is intentionally lexical rather than a
// we collapse adjacent whitespace first to avoid false negatives. * whole-file regexp: markers inside nested block comments, normal/triple/raw
const normalized = body.replace(/[\t ]*\r?\n[\t ]*/g, ' '); * strings, trailing comments, and prose comments must never opt a field in.
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[] = []; function maskSwiftSource(source: string): string {
let m: RegExpExecArray | null; const out = source.split('');
while ((m = fieldPattern.exec(normalized)) !== null) {
// Codex catch: skip fields that have a computed body (`{ get ... }` or const blank = (start: number, end: number): void => {
// `{ didSet ... }` after the type). The match boundary stops before `{`, for (let j = start; j < end; j++) {
// so we peek at what comes after the type in the original body. if (out[j] !== '\n' && out[j] !== '\r') out[j] = ' ';
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 let i = 0;
// (initialized with `=` or just declared). while (i < source.length) {
if (afterMatch.startsWith('{')) continue; if (source[i] === '/' && source[i + 1] === '/') {
fields.push({ name: m[1]!, typeText: m[2]!.trim() }); let end = i + 2;
while (end < source.length && source[end] !== '\n' && source[end] !== '\r') end++;
const lineStart = source.lastIndexOf('\n', i - 1) + 1;
const standalone = source.slice(lineStart, i).trim().length === 0;
const isMarker = standalone && source.slice(i + 2, end).trim() === '@Snapshotable';
blank(i, end);
if (isMarker) {
const marker = '@Snapshotable';
for (let j = 0; j < marker.length; j++) out[i + j] = marker[j]!;
}
i = end;
continue;
}
if (source[i] === '/' && source[i + 1] === '*') {
const start = i;
i += 2;
let depth = 1;
while (i < source.length && depth > 0) {
if (source[i] === '/' && source[i + 1] === '*') {
depth++;
i += 2;
} else if (source[i] === '*' && source[i + 1] === '/') {
depth--;
i += 2;
} else {
i++;
}
}
blank(start, i);
continue;
}
// Swift strings may be ordinary, multiline, or raw (`#"..."#` and
// `#"""..."""#`). Mask the entire literal, including interpolation;
// declarations cannot legally originate inside a literal.
let hashCount = 0;
while (source[i + hashCount] === '#') hashCount++;
const quoteIdx = i + hashCount;
if (source[quoteIdx] === '"') {
const start = i;
const triple = source.slice(quoteIdx, quoteIdx + 3) === '"""';
const quoteCount = triple ? 3 : 1;
i = quoteIdx + quoteCount;
while (i < source.length) {
if (hashCount === 0 && source[i] === '\\') {
i += 2;
continue;
}
const quoteRun = source.slice(i, i + quoteCount) === '"'.repeat(quoteCount);
const hashesMatch = source.slice(i + quoteCount, i + quoteCount + hashCount) === '#'.repeat(hashCount);
if (quoteRun && hashesMatch) {
i += quoteCount + hashCount;
break;
}
i++;
}
blank(start, i);
continue;
}
i++;
} }
return fields; return out.join('');
}
const DECL_MODIFIERS = new Set([
'public', 'internal', 'package', 'open', 'private', 'fileprivate',
'final', 'static', 'class', 'lazy', 'weak', 'unowned', 'override',
'nonisolated', 'isolated', 'borrowing', 'consuming',
]);
function parseFields(
body: string,
fullSource: string,
bodyOffset: number,
className: string,
): { fields: AccessorField[]; diagnostics: string[] } {
const fields: AccessorField[] = [];
const diagnostics: string[] = [];
let braceDepth = 0;
const lineFor = (localOffset: number): number => {
const absolute = bodyOffset + localOffset;
let line = 1;
for (let j = 0; j < absolute; j++) if (fullSource[j] === '\n') line++;
return line;
};
const fail = (offset: number, message: string): void => {
diagnostics.push(`${className} (line ${lineFor(offset)}): ${message}`);
};
const skipWhitespace = (start: number): number => {
let at = start;
while (at < body.length && /\s/.test(body[at]!)) at++;
return at;
};
const identifierAt = (start: number): { text: string; end: number } | undefined => {
const m = /^[A-Za-z_]\w*/.exec(body.slice(start));
return m ? { text: m[0], end: start + m[0].length } : undefined;
};
const skipBalancedParens = (start: number): number => {
if (body[start] !== '(') return start;
let depth = 0;
for (let at = start; at < body.length; at++) {
if (body[at] === '(') depth++;
else if (body[at] === ')' && --depth === 0) return at + 1;
}
return body.length;
};
for (let i = 0; i < body.length; i++) {
if (body[i] === '{') {
braceDepth++;
continue;
}
if (body[i] === '}') {
braceDepth--;
continue;
}
if (braceDepth !== 0 || !body.startsWith('@Snapshotable', i)) continue;
const before = i === 0 ? '' : body[i - 1]!;
const after = body[i + '@Snapshotable'.length] ?? '';
if (/\w/.test(before) || /\w/.test(after)) continue;
const markerOffset = i;
let at = skipWhitespace(i + '@Snapshotable'.length);
const modifiers: string[] = [];
let bindingKind: 'var' | 'let' | undefined;
// Permit other Swift attributes between the marker and declaration. They
// remain the compiler's responsibility; this scanner only owns the
// snapshot contract. Parenthesized attribute arguments are skipped.
while (body[at] === '@') {
const attribute = identifierAt(at + 1);
if (!attribute) break;
at = skipWhitespace(attribute.end);
if (body[at] === '(') at = skipBalancedParens(at);
at = skipWhitespace(at);
}
while (at < body.length) {
const token = identifierAt(at);
if (!token) break;
if (token.text === 'var' || token.text === 'let') {
bindingKind = token.text;
at = token.end;
break;
}
if (!DECL_MODIFIERS.has(token.text)) break;
let modifier = token.text;
at = skipWhitespace(token.end);
if (body[at] === '(') {
const end = skipBalancedParens(at);
modifier += body.slice(at, end).replace(/\s+/g, '');
at = skipWhitespace(end);
}
modifiers.push(modifier);
}
if (!bindingKind) {
fail(markerOffset, '@Snapshotable must immediately precede a stored property declaration');
continue;
}
at = skipWhitespace(at);
const identifier = identifierAt(at);
const fieldName = identifier?.text ?? '<unknown>';
if (!identifier) {
fail(markerOffset, '@Snapshotable only supports a single identifier binding');
continue;
}
at = skipWhitespace(identifier.end);
if (bindingKind === 'let') {
fail(markerOffset, `@Snapshotable field '${fieldName}' must be declared var, not let`);
continue;
}
if (modifiers.some(m => /^(?:private|fileprivate)(?:\(set\))?$/.test(m))) {
fail(markerOffset, `@Snapshotable field '${fieldName}' cannot be private, fileprivate, private(set), or fileprivate(set)`);
continue;
}
if (modifiers.some(m => m === 'static' || m === 'class')) {
fail(markerOffset, `@Snapshotable field '${fieldName}' must be an instance property`);
continue;
}
if (body[at] !== ':') {
fail(markerOffset, `@Snapshotable field '${fieldName}' requires an explicit type annotation`);
continue;
}
at = skipWhitespace(at + 1);
const typeStart = at;
let parens = 0;
let brackets = 0;
let angles = 0;
let typeEnd = at;
let delimiter = '';
for (; at < body.length; at++) {
const c = body[at]!;
if (c === '(') parens++;
else if (c === ')') parens--;
else if (c === '[') brackets++;
else if (c === ']') brackets--;
else if (c === '<') angles++;
else if (c === '>') angles = Math.max(0, angles - 1);
const topLevel = parens === 0 && brackets === 0 && angles === 0;
if (topLevel && (c === '=' || c === '{' || c === ',' || c === ';')) {
delimiter = c;
break;
}
if (topLevel && (c === '\n' || c === '\r')) {
const soFar = body.slice(typeStart, at).trim();
if (soFar.length > 0 && !/(?:->|[&.,])\s*$/.test(soFar)) break;
}
typeEnd = at + 1;
}
const typeText = body.slice(typeStart, typeEnd).replace(/\s+/g, ' ').trim();
if (typeText.length === 0) {
fail(markerOffset, `@Snapshotable field '${fieldName}' requires an explicit type annotation`);
continue;
}
if (delimiter === '{') {
fail(markerOffset, `@Snapshotable field '${fieldName}' must be stored and writable`);
continue;
}
if (delimiter === ',') {
fail(markerOffset, '@Snapshotable declarations must contain exactly one binding');
continue;
}
if (delimiter === '=') {
// A declaration such as `var a: Int = 1, b: Int = 2` reaches `=`
// before its binding comma. Scan the initializer at syntax depth zero
// so commas in arrays/calls/closures remain valid.
let initializerAt = at + 1;
let expressionParens = 0;
let expressionBrackets = 0;
let expressionBraces = 0;
let hasSecondBinding = false;
for (; initializerAt < body.length; initializerAt++) {
const c = body[initializerAt]!;
if (c === '(') expressionParens++;
else if (c === ')') expressionParens--;
else if (c === '[') expressionBrackets++;
else if (c === ']') expressionBrackets--;
else if (c === '{') expressionBraces++;
else if (c === '}') {
if (expressionBraces === 0) break;
expressionBraces--;
}
const topLevel = expressionParens === 0 && expressionBrackets === 0 && expressionBraces === 0;
if (topLevel && c === ',') {
hasSecondBinding = true;
break;
}
if (topLevel && (c === '\n' || c === '\r' || c === ';')) break;
}
if (hasSecondBinding) {
fail(markerOffset, '@Snapshotable declarations must contain exactly one binding');
continue;
}
}
const wrappedOptional = optionalWrappedType(typeText);
if (wrappedOptional !== undefined && optionalWrappedType(wrappedOptional) !== undefined) {
fail(markerOffset, `@Snapshotable field '${fieldName}' cannot use a nested Optional type`);
continue;
}
const typeIssue = snapshotTypeIssue(typeText);
if (typeIssue !== undefined) {
fail(markerOffset, `@Snapshotable field '${fieldName}' ${typeIssue}`);
continue;
}
fields.push({ name: fieldName, typeText });
}
return { fields, diagnostics };
} }
export function computeCacheKey(inputs: { export function computeCacheKey(inputs: {
@@ -154,35 +471,263 @@ export function computeCacheKey(inputs: {
swiftVersion: string; swiftVersion: string;
toolGitRev: string; toolGitRev: string;
platformTriple: string; platformTriple: string;
buildId?: string;
}): string { }): string {
const h = createHash('sha256'); const h = createHash('sha256');
h.update(`swift=${inputs.swiftVersion}|tool=${inputs.toolGitRev}|platform=${inputs.platformTriple}|`); h.update(`${GENERATOR_FORMAT_VERSION}|swift=${inputs.swiftVersion}|tool=${inputs.toolGitRev}|platform=${inputs.platformTriple}|build=${inputs.buildId ?? 'unknown'}|`);
for (const f of inputs.swiftFiles) { for (const f of inputs.swiftFiles) {
const content = readFileSync(f); const content = readFileSync(f);
h.update(`${f}:${content.length}:`); // Cache identity is content-based. Absolute checkout paths must not make
// equivalent source trees produce different generated output.
h.update(`${content.length}:`);
h.update(content); h.update(content);
h.update('|'); h.update('|');
} }
return h.digest('hex'); return h.digest('hex');
} }
export function render(specs: AccessorSpec[], buildId: string, accessorHash: string): string { /** Stable snapshot-schema fingerprint, deliberately independent of cache ABI,
let out = '// AUTO-GENERATED — DO NOT EDIT. Regenerate with /ios-sync.\n'; * source paths, app build provenance, and unmarked source. Field/class order is
out += '#if DEBUG\nimport Foundation\nimport DebugBridge\n\n'; * 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) { for (const spec of specs) {
out += `@MainActor\npublic enum ${spec.className}Accessor {\n`; signature += `C${Buffer.byteLength(spec.className)}:${spec.className}\n`;
out += ` public static func register(_ state: ${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*</.exec(type);
if (!optional) return undefined;
const open = type.indexOf('<', optional.index);
let depth = 0;
for (let i = open; i < type.length; i++) {
if (type[i] === '<') depth++;
else if (type[i] === '>') {
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<string, string>();
const diagnostics: string[] = [];
for (const spec of specs) {
for (const field of spec.fields) {
const previous = owners.get(field.name);
if (previous !== undefined) {
diagnostics.push(`snapshot key '${field.name}' is declared by both ${previous} and ${spec.className}; keys must be unique across @Observable types`);
} else {
owners.set(field.name, spec.className);
}
}
}
if (diagnostics.length > 0) throw new AccessorGenerationError(diagnostics);
}
function swiftStringLiteral(value: string): string {
return JSON.stringify(value);
}
export function render(specs: AccessorSpec[], buildId: string, accessorHash: string): string {
validateAccessorSpecs(specs);
let out = '// AUTO-GENERATED — DO NOT EDIT. Regenerate with /ios-sync.\n';
out += '#if DEBUG\nimport Foundation\nimport DebugBridgeCore\n\n';
for (const spec of specs) {
// Accessors compile in the app target beside its usually-internal state
// types. Making this API public would make valid internal models fail
// Swift type checking (a public signature cannot expose an internal type).
out += `@MainActor\nenum ${spec.className}Accessor {\n`;
out += ` private static func decodeSnapshotValue<T: Decodable>(_ value: Any, as type: T.Type) -> T? {\n`;
out += ` guard JSONSerialization.isValidJSONObject(["value": value]),\n`;
out += ` let data = try? JSONSerialization.data(withJSONObject: ["value": value]),\n`;
out += ` let decoded = try? JSONDecoder().decode([String: T].self, from: data) else { return nil }\n`;
out += ` return decoded["value"]\n`;
out += ` }\n\n`;
out += ` static func register(_ state: ${spec.className}) {\n`;
out += ` StateServer.shared.register(\n`; out += ` StateServer.shared.register(\n`;
out += ` buildId: "${buildId}",\n`; out += ` buildId: {\n`;
out += ` accessorHash: "${accessorHash}",\n`; out += ` let shortVersion = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String\n`;
out += ` atomicRestore: { _ in .ok }\n`; out += ` let bundleVersion = Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String\n`;
out += ` if let shortVersion, let bundleVersion { return "\\(shortVersion) (\\(bundleVersion))" }\n`;
out += ` return shortVersion ?? bundleVersion ?? ${swiftStringLiteral(buildId)}\n`;
out += ` }(),\n`;
out += ` accessorHash: ${swiftStringLiteral(accessorHash)},\n`;
out += ` atomicRestore: { keys, apply in\n`;
out += ` // Validate every key and value before assignment.\n`;
out += ` // Successful assignments are sequential on MainActor.\n`;
spec.fields.forEach((field, index) => {
out += ` guard let raw${index} = keys["${field.name}"] else {\n`;
out += ` return .missingKey("${field.name}")\n`;
out += ` }\n`;
const wrapped = optionalWrappedType(field.typeText);
if (wrapped !== undefined) {
out += ` let restored${index}: ${field.typeText}\n`;
out += ` if raw${index} is NSNull {\n`;
out += ` restored${index} = nil\n`;
out += ` } else if let typed = Self.decodeSnapshotValue(raw${index}, as: ${wrapped}.self) {\n`;
out += ` restored${index} = typed\n`;
out += ` } else {\n`;
out += ` return .typeMismatch("${field.name}")\n`;
out += ` }\n`;
} else {
out += ` guard let restored${index} = Self.decodeSnapshotValue(raw${index}, as: ${field.typeText}.self) else {\n`;
out += ` return .typeMismatch("${field.name}")\n`;
out += ` }\n`;
}
});
out += ` if apply {\n`;
spec.fields.forEach((field, index) => {
out += ` state.${field.name} = restored${index}\n`;
});
out += ` }\n`;
out += ` return .ok\n`;
out += ` }\n`;
out += ` )\n`; out += ` )\n`;
for (const field of spec.fields) { for (const field of spec.fields) {
const wrapped = optionalWrappedType(field.typeText);
out += ` StateServer.shared.registerAccessor(\n`; out += ` StateServer.shared.registerAccessor(\n`;
out += ` key: "${field.name}",\n`; out += ` key: "${field.name}",\n`;
out += ` type: "${field.typeText}",\n`; out += ` type: "${field.typeText}",\n`;
out += ` read: { state.${field.name} as Any? },\n`; if (wrapped !== undefined) {
out += ` write: { _ in false }\n`; out += ` read: {\n`;
out += ` guard let value = state.${field.name} else { return NSNull() }\n`;
out += ` return value as Any\n`;
out += ` },\n`;
} else {
out += ` read: { state.${field.name} as Any? },\n`;
}
out += ` write: { value in\n`;
if (wrapped !== undefined) {
out += ` if value is NSNull {\n`;
out += ` state.${field.name} = nil\n`;
out += ` return true\n`;
out += ` }\n`;
out += ` guard let typed = Self.decodeSnapshotValue(value, as: ${wrapped}.self) else { return false }\n`;
} else {
out += ` guard let typed = Self.decodeSnapshotValue(value, as: ${field.typeText}.self) else { return false }\n`;
}
out += ` state.${field.name} = typed\n`;
out += ` return true\n`;
out += ` }\n`;
out += ` )\n`; out += ` )\n`;
} }
out += ` }\n}\n\n`; out += ` }\n}\n\n`;
@@ -215,6 +760,15 @@ function detectToolGitRev(): string {
} }
} }
function detectBuildId(): string {
if (process.env.APP_BUILD_ID) return process.env.APP_BUILD_ID;
const marketing = process.env.MARKETING_VERSION;
const build = process.env.CURRENT_PROJECT_VERSION;
if (marketing && build) return `${marketing} (${build})`;
if (build) return build;
return 'unknown';
}
export function defaultCacheRoot(): string { export function defaultCacheRoot(): string {
return process.env.GSTACK_IOS_CACHE_ROOT ?? join(homedir(), '.gstack', 'cache', 'gen-accessors'); return process.env.GSTACK_IOS_CACHE_ROOT ?? join(homedir(), '.gstack', 'cache', 'gen-accessors');
} }
@@ -223,13 +777,25 @@ export function generate(inputs: GenInputs): GenResult {
const inputDir = resolve(inputs.inputDir); const inputDir = resolve(inputs.inputDir);
const outputDir = resolve(inputs.outputDir ?? inputDir); const outputDir = resolve(inputs.outputDir ?? inputDir);
const cacheRoot = inputs.cacheRoot ?? defaultCacheRoot(); const cacheRoot = inputs.cacheRoot ?? defaultCacheRoot();
const swiftFiles = collectSwiftFiles(inputDir); const swiftFiles = collectSwiftFiles(inputDir, { outputDir });
const buildId = inputs.buildId ?? detectBuildId();
// Parse before cache lookup. This keeps diagnostics deterministic even when
// an older cache entry exists, and gives us the schema-only accessor hash.
const allSpecs: AccessorSpec[] = [];
for (const f of swiftFiles) {
const src = readFileSync(f, 'utf-8');
allSpecs.push(...parseSwift(src));
}
validateAccessorSpecs(allSpecs);
const accessorHash = computeAccessorHash(allSpecs);
const cacheKey = computeCacheKey({ const cacheKey = computeCacheKey({
swiftFiles, swiftFiles,
swiftVersion: inputs.swiftVersion ?? detectSwiftVersion(), swiftVersion: inputs.swiftVersion ?? detectSwiftVersion(),
toolGitRev: inputs.toolGitRev ?? detectToolGitRev(), toolGitRev: inputs.toolGitRev ?? detectToolGitRev(),
platformTriple: inputs.platformTriple ?? FALLBACK_PLATFORM, platformTriple: inputs.platformTriple ?? FALLBACK_PLATFORM,
buildId,
}); });
const cachedOutput = join(cacheRoot, cacheKey, 'StateAccessor.swift'); const cachedOutput = join(cacheRoot, cacheKey, 'StateAccessor.swift');
@@ -242,18 +808,13 @@ export function generate(inputs: GenInputs): GenResult {
return { return {
outputPath: finalOutput, outputPath: finalOutput,
cacheKey, cacheKey,
specs: [], // intentionally empty on cache hit (no need to re-parse) accessorHash,
specs: allSpecs,
cacheHit: true, cacheHit: true,
}; };
} }
// Parse + render fresh const rendered = render(allSpecs, buildId, accessorHash);
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); writeFileSync(finalOutput, rendered);
// Populate cache (best-effort — cache failures don't break codegen). // Populate cache (best-effort — cache failures don't break codegen).
@@ -267,6 +828,7 @@ export function generate(inputs: GenInputs): GenResult {
return { return {
outputPath: finalOutput, outputPath: finalOutput,
cacheKey, cacheKey,
accessorHash,
specs: allSpecs, specs: allSpecs,
cacheHit: false, cacheHit: false,
}; };
@@ -300,10 +862,18 @@ if (import.meta.main) {
const inputDir = args[inputIdx + 1]!; const inputDir = args[inputIdx + 1]!;
const outputIdx = args.indexOf('--output'); const outputIdx = args.indexOf('--output');
const outputDir = outputIdx !== -1 ? args[outputIdx + 1] : undefined; const outputDir = outputIdx !== -1 ? args[outputIdx + 1] : undefined;
const result = generate({ inputDir, outputDir }); try {
process.stdout.write( const result = generate({ inputDir, outputDir });
result.cacheHit process.stdout.write(
? `gen-accessors: cache hit (${result.cacheKey.slice(0, 12)})\n` result.cacheHit
: `gen-accessors: wrote ${result.specs.length} accessor(s) to ${result.outputPath}\n`, ? `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;
}
} }
+205 -74
View File
@@ -27,12 +27,35 @@ public enum DebugBridgeUIWiring {
/// times reinstalls the same closures. Must be called on @MainActor /// times reinstalls the same closures. Must be called on @MainActor
/// because every UIKit access requires the main actor. /// because every UIKit access requires the main actor.
public static func installAll() { 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() } ScreenshotBridge.resolver = { ScreenshotBridgeImpl.capturePNG() }
ElementsBridge.resolver = { ElementsBridgeImpl.snapshot() } ElementsBridge.resolver = { ElementsBridgeImpl.snapshot() }
MutationBridge.resolver = { op, payload in MutationBridgeImpl.dispatch(op: op, payload: payload) } 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..<count).compactMap { element.accessibilityElement(at: $0) as? NSObject }
}
// MARK: - ScreenshotBridge implementation // MARK: - ScreenshotBridge implementation
@MainActor @MainActor
@@ -43,7 +66,11 @@ enum ScreenshotBridgeImpl {
static func capturePNG() -> Data? { static func capturePNG() -> Data? {
guard let scene = activeScene(), let window = activeKeyWindow(in: scene) else { return nil } guard let scene = activeScene(), let window = activeKeyWindow(in: scene) else { return nil }
let bounds = window.bounds let bounds = window.bounds
let renderer = UIGraphicsImageRenderer(bounds: bounds) let format = UIGraphicsImageRendererFormat.default()
// /tap consumes UIKit window points. Render at 1x so screenshot pixels
// use that same coordinate space on 2x/3x devices.
format.scale = 1
let renderer = UIGraphicsImageRenderer(bounds: bounds, format: format)
let image = renderer.image { _ in let image = renderer.image { _ in
// drawHierarchy is the documented way to snapshot real UIKit // drawHierarchy is the documented way to snapshot real UIKit
// layers including layer-backed views. afterScreenUpdates: false // layers including layer-backed views. afterScreenUpdates: false
@@ -61,7 +88,10 @@ enum ScreenshotBridgeImpl {
} }
private static func activeKeyWindow(in scene: UIWindowScene) -> UIWindow? { private static func activeKeyWindow(in scene: UIWindowScene) -> UIWindow? {
scene.windows.first(where: { $0.isKeyWindow }) ?? scene.windows.first let windows = scene.windows.filter { window in
!window.isHidden && !String(describing: type(of: window)).contains("PassThroughWindow")
}
return windows.first(where: { $0.isKeyWindow }) ?? windows.max(by: { $0.windowLevel < $1.windowLevel })
} }
} }
@@ -76,21 +106,40 @@ enum ElementsBridgeImpl {
static func snapshot() -> [JSONDict] { static func snapshot() -> [JSONDict] {
guard let scene = activeScene(), let window = activeKeyWindow(in: scene) else { return [] } guard let scene = activeScene(), let window = activeKeyWindow(in: scene) else { return [] }
var elements: [JSONDict] = [] var elements: [JSONDict] = []
collect(view: window, parentPath: "", windowBounds: window.bounds, into: &elements) var visited = Set<ObjectIdentifier>()
var remaining = 2_048
collect(
view: window,
parentPath: "",
window: window,
visited: &visited,
remaining: &remaining,
into: &elements
)
return elements return elements
} }
private static func collect(view: UIView, parentPath: String, windowBounds: CGRect, into elements: inout [JSONDict]) { private static func collect(
view: UIView,
parentPath: String,
window: UIWindow,
visited: inout Set<ObjectIdentifier>,
remaining: inout Int,
into elements: inout [JSONDict]
) {
guard remaining > 0, visited.insert(ObjectIdentifier(view)).inserted else { return }
remaining -= 1
// Skip hidden / zero-size / off-screen subtrees early. // Skip hidden / zero-size / off-screen subtrees early.
if view.isHidden || view.alpha < 0.01 { return } if view.isHidden || view.alpha < 0.01 { return }
let frameInWindow = view.convert(view.bounds, to: nil) let frameInWindow = view.convert(view.bounds, to: window)
if !windowBounds.intersects(frameInWindow) { return } if !window.bounds.intersects(frameInWindow) { return }
let isAccessible = view.isAccessibilityElement let isAccessible = view.isAccessibilityElement
let label = view.accessibilityLabel ?? "" let label = view.accessibilityLabel ?? ""
let identifier = view.accessibilityIdentifier ?? "" let identifier = view.accessibilityIdentifier ?? ""
let traits = Int(view.accessibilityTraits.rawValue) let traits = NSNumber(value: view.accessibilityTraits.rawValue)
let value = (view.accessibilityValue ?? "") as String let value = (view.accessibilityValue ?? "") as String
let className = String(describing: type(of: view)) let className = String(describing: type(of: view))
let path = parentPath.isEmpty ? className : "\(parentPath) > \(className)" let path = parentPath.isEmpty ? className : "\(parentPath) > \(className)"
@@ -120,66 +169,98 @@ enum ElementsBridgeImpl {
]) ])
} }
// Recurse into accessibility-elements first (some custom views vend // Walk automation children before raw subviews. On iOS 17+ this
// synthetic children), then UIView subviews. SwiftUI's host views // exposes identified SwiftUI controls nested inside GroupBox/List.
// populate accessibilityElements lazily — many return nil before for (index, element) in debugBridgeAccessibilityChildren(of: view).enumerated() {
// VoiceOver triggers them. Force population by reading accessibilityElementCount. if let child = element as? UIView {
_ = view.accessibilityElementCount() collect(
if let axElements = view.accessibilityElements { view: child,
for case let element as NSObject in axElements { parentPath: path,
if let v = element as? UIView { window: window,
collect(view: v, parentPath: path, windowBounds: windowBounds, into: &elements) visited: &visited,
} else { remaining: &remaining,
// Synthetic accessibility element (no UIView). Capture frame in screen coords. into: &elements
let af = (element.value(forKey: "accessibilityFrame") as? CGRect) ?? .zero )
elements.append([ } else {
"path": "\(path) > <synthetic>", appendSynthetic(
"class": "AccessibilityElement", element,
"label": (element.value(forKey: "accessibilityLabel") as? String) ?? "", path: "\(path) > <ax\(index)>",
"identifier": (element.value(forKey: "accessibilityIdentifier") as? String) ?? "", window: window,
"value": (element.value(forKey: "accessibilityValue") as? String) ?? "", visited: &visited,
"traits": (element.value(forKey: "accessibilityTraits") as? NSNumber)?.intValue ?? 0, remaining: &remaining,
"frame": [ into: &elements
"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..<count {
guard let element = view.accessibilityElement(at: i) as? NSObject else { continue }
if let v = element as? UIView {
collect(view: v, parentPath: path, windowBounds: windowBounds, into: &elements)
} else {
let af = (element.value(forKey: "accessibilityFrame") as? CGRect) ?? .zero
elements.append([
"path": "\(path) > <ax\(i)>",
"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 { for sub in view.subviews {
collect(view: sub, parentPath: path, windowBounds: windowBounds, into: &elements) collect(
view: sub,
parentPath: path,
window: window,
visited: &visited,
remaining: &remaining,
into: &elements
)
}
}
private static func appendSynthetic(
_ element: NSObject,
path: String,
window: UIWindow,
visited: inout Set<ObjectIdentifier>,
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) > <ax\(index)>",
window: window,
visited: &visited,
remaining: &remaining,
into: &elements
)
}
} }
} }
@@ -191,7 +272,10 @@ enum ElementsBridgeImpl {
} }
private static func activeKeyWindow(in scene: UIWindowScene) -> UIWindow? { private static func activeKeyWindow(in scene: UIWindowScene) -> UIWindow? {
scene.windows.first(where: { $0.isKeyWindow }) ?? scene.windows.first let windows = scene.windows.filter { window in
!window.isHidden && !String(describing: type(of: window)).contains("PassThroughWindow")
}
return windows.first(where: { $0.isKeyWindow }) ?? windows.max(by: { $0.windowLevel < $1.windowLevel })
} }
} }
@@ -210,21 +294,62 @@ enum MutationBridgeImpl {
} }
} }
/// Tap at (x, y) in window coordinates. Delegates to DebugBridgeTouch /// Tap at (x, y) in window coordinates. Prefer accessibility activation,
/// (KIF-derived in-process touch synthesis). The Obj-C target builds a /// which is stable for SwiftUI buttons across OS releases, then fall back
/// real UITouch + IOHIDEvent + UIEvent and dispatches via /// to KIF-derived UITouch synthesis for gesture-only/custom controls.
/// `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 { private static func handleTap(_ payload: JSONDict) -> Bool {
guard let x = payload["x"] as? NSNumber, guard let x = payload["x"] as? NSNumber,
let y = payload["y"] as? NSNumber else { return false } let y = payload["y"] as? NSNumber else { return false }
let point = CGPoint(x: x.doubleValue, y: y.doubleValue) let point = CGPoint(x: x.doubleValue, y: y.doubleValue)
guard let scene = activeScene(), let window = activeKeyWindow(in: scene) else { return false } 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) 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<ObjectIdentifier>()
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. /// Set text on the first responder if it's a UITextField or UITextView.
private static func handleType(_ payload: JSONDict) -> Bool { private static func handleType(_ payload: JSONDict) -> Bool {
guard let text = payload["text"] as? String else { return false } guard let text = payload["text"] as? String else { return false }
@@ -266,7 +391,10 @@ enum MutationBridgeImpl {
var off = scroll.contentOffset var off = scroll.contentOffset
off.x = max(0, min(scroll.contentSize.width - scroll.bounds.width, off.x + dx)) 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)) off.y = max(0, min(scroll.contentSize.height - scroll.bounds.height, off.y + dy))
scroll.setContentOffset(off, animated: true) // Automation commands return synchronously; do not report
// success while the target is still moving underneath the
// next tap coordinate.
scroll.setContentOffset(off, animated: false)
return true return true
} }
node = cur.superview node = cur.superview
@@ -301,7 +429,10 @@ enum MutationBridgeImpl {
} }
private static func activeKeyWindow(in scene: UIWindowScene) -> UIWindow? { private static func activeKeyWindow(in scene: UIWindowScene) -> UIWindow? {
scene.windows.first(where: { $0.isKeyWindow }) ?? scene.windows.first let windows = scene.windows.filter { window in
!window.isHidden && !String(describing: type(of: window)).contains("PassThroughWindow")
}
return windows.first(where: { $0.isKeyWindow }) ?? windows.max(by: { $0.windowLevel < $1.windowLevel })
} }
} }
@@ -14,12 +14,16 @@ import Foundation
public final class DebugBridgeManager { public final class DebugBridgeManager {
public static let shared = DebugBridgeManager() public static let shared = DebugBridgeManager()
public func start(appState: AppState) { /// Register app-owned generated accessors, then start the server. The
// 1. Register the canonical AppState struct + accessor wiring. /// registration closure is passed in from the consuming app because the
// AppStateAccessor.register(_:) is generated by gen-accessors-tool. /// DebugBridgeCore package cannot import app-target types. On UIKit apps,
AppStateAccessor.register(appState) /// call DebugBridgeUIWiring.installAll() before this method so a warm
/// daemon cannot reach uninitialized resolvers during listener startup.
public func start<State>(appState: State, register: (State) -> Void) {
register(appState)
// 2. Boot the StateServer. // Boot only after registration so the first snapshot has a real build
// id, schema hash, and key set.
StateServer.shared.start() StateServer.shared.start()
// 3. The consuming app installs DebugOverlayWindow separately. See // 3. The consuming app installs DebugOverlayWindow separately. See
@@ -31,19 +35,4 @@ public final class DebugBridgeManager {
} }
} }
// Placeholder. gen-accessors-tool emits the real `AppStateAccessor` enum next
// to the app's canonical state struct. Apps that haven't run codegen get a
// stub that registers no accessors (snapshot is empty, restore returns
// missing-key for every key).
@MainActor
public enum AppStateAccessor {
public static var register: (Any) -> Void = { _ in }
}
// Apps declare their canonical state struct; codegen reads it and emits
// AppStateAccessor.register. The app's struct must be `@Observable` and
// must hold all snapshot-eligible state in `@Snapshotable`-marked fields.
@MainActor
public protocol AppState: AnyObject {}
#endif // DEBUG #endif // DEBUG
@@ -23,6 +23,10 @@ NS_ASSUME_NONNULL_BEGIN
@interface DebugBridgeTouch : NSObject @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 /// Synthesize a single tap (TouchPhaseBegan + TouchPhaseEnded) at the given
/// window-coordinate point. Returns YES if the touch was delivered (a hit /// window-coordinate point. Returns YES if the touch was delivered (a hit
/// view was found and the event passed through UIApplication.sendEvent). /// view was found and the event passed through UIApplication.sendEvent).
+44 -2
View File
@@ -126,6 +126,36 @@ static BOOL DBT_LoadIOKit(void) {
return _IOKitLoaded; 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) CF_RETURNS_RETAINED;
static IOHIDEventRef DBT_IOHIDEventWithTouch(UITouch *touch) { static IOHIDEventRef DBT_IOHIDEventWithTouch(UITouch *touch) {
if (!DBT_LoadIOKit()) return NULL; if (!DBT_LoadIOKit()) return NULL;
@@ -172,6 +202,7 @@ static IOHIDEventRef DBT_IOHIDEventWithTouch(UITouch *touch) {
- (void)setGestureView:(UIView *)view; - (void)setGestureView:(UIView *)view;
- (void)_setLocationInWindow:(CGPoint)location resetPrevious:(BOOL)resetPrevious; - (void)_setLocationInWindow:(CGPoint)location resetPrevious:(BOOL)resetPrevious;
- (void)_setIsFirstTouchForView:(BOOL)firstTouchForView; - (void)_setIsFirstTouchForView:(BOOL)firstTouchForView;
- (void)_setIsTapToClick:(BOOL)tapToClick;
- (void)_setHidEvent:(IOHIDEventRef)event; - (void)_setHidEvent:(IOHIDEventRef)event;
@end @end
@@ -229,6 +260,10 @@ static id DBT_HitTestView(UIWindow *window, CGPoint point) {
@implementation DebugBridgeTouch @implementation DebugBridgeTouch
+ (void)prepareForAutomation {
DBT_EnableAccessibilityAutomation();
}
+ (BOOL)sendTapAtPoint:(CGPoint)point inWindow:(UIWindow *)window { + (BOOL)sendTapAtPoint:(CGPoint)point inWindow:(UIWindow *)window {
if (!window) return NO; if (!window) return NO;
@@ -247,10 +282,17 @@ static id DBT_HitTestView(UIWindow *window, CGPoint point) {
[touch setPhase:UITouchPhaseBegan]; [touch setPhase:UITouchPhaseBegan];
if ([touch respondsToSelector:@selector(_setIsFirstTouchForView:)]) { if ([touch respondsToSelector:@selector(_setIsFirstTouchForView:)]) {
[touch _setIsFirstTouchForView:YES]; [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]]; [touch setTimestamp:[[NSProcessInfo processInfo] systemUptime]];
if ([touch respondsToSelector:@selector(setGestureView:)] && if ([touch respondsToSelector:@selector(setGestureView:)]) {
[hit isKindOfClass:[UIView class]]) {
[touch setGestureView:(UIView *)hit]; [touch setGestureView:(UIView *)hit];
} }
@@ -6,22 +6,35 @@
// the DebugBridge target. // the DebugBridge target.
#if DEBUG #if DEBUG
import DebugBridge import Foundation
import DebugBridgeCore
#if canImport(UIKit)
import DebugBridgeUI
#endif
@MainActor @MainActor
func startGstackDebugBridge(appState: AppState) { func startGstackDebugBridge<State>(
appState: State,
register: (State) -> Void
) {
// Read --recording flag from launch arguments // Read --recording flag from launch arguments
let recording = ProcessInfo.processInfo.arguments.contains("--gstack-recording") let recording = ProcessInfo.processInfo.arguments.contains("--gstack-recording")
// Install accessibility + screenshot + mutation bridges before starting // Install the UI resolvers before opening the listener. A warm daemon can
// the server so the first authenticated request can use them. // issue its first request as soon as StateServer starts; it must never see
ElementsBridge.resolver = { AccessibilityScanner.snapshot() } // the default empty screenshot/elements/mutation handlers.
ScreenshotBridge.resolver = { SnapshotCapture.capturePNG() } #if canImport(UIKit)
MutationBridge.resolver = { op, payload in DebugBridgeUIWiring.installAll()
MutationDispatcher.shared.run(op: op, payload: payload) #endif
}
DebugBridgeManager.shared.start(appState: appState, recording: recording) // Generated typed accessors live in the app target, so pass their register
// function into the package instead of asking DebugBridgeCore to import
// app-owned types.
DebugBridgeManager.shared.start(appState: appState, register: register)
#if canImport(UIKit)
DebugOverlayWindow.shared.install(recording: recording)
#endif
} }
#endif #endif
@@ -33,7 +46,10 @@ func startGstackDebugBridge(appState: AppState) {
// //
// init() { // init() {
// #if DEBUG // #if DEBUG
// startGstackDebugBridge(appState: appState) // startGstackDebugBridge(
// appState: appState,
// register: MyAppStateAccessor.register
// )
// #endif // #endif
// } // }
// //
+1 -6
View File
@@ -1,3 +1,4 @@
// swift-tools-version:5.9
// AUTO-GENERATED from gstack/ios-qa/templates/Package.swift.template // AUTO-GENERATED from gstack/ios-qa/templates/Package.swift.template
// //
// Drop-in SPM package definition for the DebugBridge stack. Three targets: // Drop-in SPM package definition for the DebugBridge stack. Three targets:
@@ -18,7 +19,6 @@
// CI invariant: `swift build -c release` + `nm -j build/Release/<binary> // CI invariant: `swift build -c release` + `nm -j build/Release/<binary>
// | grep -q DebugBridge && exit 1`. // | grep -q DebugBridge && exit 1`.
// swift-tools-version:5.9
import PackageDescription import PackageDescription
let package = Package( let package = Package(
@@ -58,10 +58,5 @@ let package = Package(
.define("DEBUG", .when(configuration: .debug)), .define("DEBUG", .when(configuration: .debug)),
] ]
), ),
.testTarget(
name: "DebugBridgeCoreTests",
dependencies: ["DebugBridgeCore"],
path: "Tests/DebugBridgeCoreTests"
),
] ]
) )
+10 -7
View File
@@ -3,12 +3,12 @@
// //
// This file is a TEMPLATE that gen-accessors-tool fills in. The placeholders // 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 // are filled per-class from swift-syntax AST inspection of the app's
// @Observable types. Only properties marked with @Snapshotable are emitted. // @Observable types. Only properties preceded by `// @Snapshotable` are emitted.
// //
// {{CLASS_NAME}} — the canonical AppState struct name // {{CLASS_NAME}} — the canonical AppState struct name
// {{APP_BUILD_ID}} — bundle short version + git SHA at codegen time // {{APP_BUILD_ID}} — bundle short version + git SHA at codegen time
// {{ACCESSOR_HASH}} — sha256 of accessor signatures (snapshot schema fingerprint) // {{ACCESSOR_HASH}} — sha256 of accessor signatures (snapshot schema fingerprint)
// {{ACCESSORS}} — generated register/read/write blocks per @Snapshotable field // {{ACCESSORS}} — generated register/read/write blocks per marked field
#if DEBUG #if DEBUG
@@ -21,13 +21,16 @@ public enum {{CLASS_NAME}}Accessor {
StateServer.shared.register( StateServer.shared.register(
buildId: "{{APP_BUILD_ID}}", buildId: "{{APP_BUILD_ID}}",
accessorHash: "{{ACCESSOR_HASH}}", accessorHash: "{{ACCESSOR_HASH}}",
atomicRestore: { keys in atomicRestore: { keys, apply in
// Validate every key + type FIRST, then apply in one struct // Validate every key + type before mutating any state. Invalid
// assignment so SwiftUI observers see exactly one change. // input therefore cannot leave a partially restored model.
var snapshot = state.snapshotable var snapshot = state.snapshotable
{{VALIDATION_BLOCK}} {{VALIDATION_BLOCK}}
// Apply atomically. // StateServer validates every model first, then invokes the
state.snapshotable = snapshot // same handlers with apply=true for a cross-model commit.
if apply {
state.snapshotable = snapshot
}
return .ok return .ok
} }
) )
+49 -22
View File
@@ -59,18 +59,20 @@ public final class StateServer {
private var writeHandlers: [String: WriteHandler] = [:] private var writeHandlers: [String: WriteHandler] = [:]
private var typeNames: [String: TypeName] = [:] private var typeNames: [String: TypeName] = [:]
// Atomic-restore hook. Codegen wires this to the canonical AppState struct. // Validated-restore hooks. Every generated model registers one two-phase
// Restore replaces the entire struct in one assignment so SwiftUI's Combine // handler. The server validates all models before it lets any model mutate,
// pipeline observes exactly one change notification — true observable // so invalid input can never cause a cross-model partial restore.
// atomicity. @MainActor alone doesn't guarantee that. // Valid restores apply properties on MainActor; observers may receive one
public typealias AtomicRestoreFn = (JSONDict) -> RestoreResult // change notification per property because arbitrary @Observable models do
// not expose a general single-assignment transaction API.
public typealias AtomicRestoreFn = (JSONDict, Bool) -> RestoreResult
public enum RestoreResult { public enum RestoreResult {
case ok case ok
case missingKey(String) case missingKey(String)
case typeMismatch(String) case typeMismatch(String)
case schemaMismatch(expected: String, got: String) case schemaMismatch(expected: String, got: String)
} }
private var atomicRestore: AtomicRestoreFn? private var atomicRestores: [AtomicRestoreFn] = []
// Snapshot schema hash — written by codegen, stable across builds with // Snapshot schema hash — written by codegen, stable across builds with
// identical accessor signatures. // identical accessor signatures.
@@ -109,7 +111,7 @@ public final class StateServer {
public func register(buildId: String, accessorHash: String, atomicRestore: @escaping AtomicRestoreFn) { public func register(buildId: String, accessorHash: String, atomicRestore: @escaping AtomicRestoreFn) {
self.appBuildId = buildId self.appBuildId = buildId
self.accessorHash = accessorHash self.accessorHash = accessorHash
self.atomicRestore = atomicRestore self.atomicRestores.append(atomicRestore)
} }
public func registerAccessor(key: String, type: String, read: @escaping ReadHandler, write: @escaping WriteHandler) { public func registerAccessor(key: String, type: String, read: @escaping ReadHandler, write: @escaping WriteHandler) {
@@ -284,6 +286,7 @@ public final class StateServer {
"version": "1.0.0", "version": "1.0.0",
"build": appBuildId, "build": appBuildId,
"accessor_hash": accessorHash, "accessor_hash": accessorHash,
"bundle_id": Bundle.main.bundleIdentifier ?? "unknown",
]) ])
return return
} }
@@ -476,22 +479,36 @@ public final class StateServer {
send(connection: connection, status: 400, body: ["error": "missing_keys"]) send(connection: connection, status: 400, body: ["error": "missing_keys"])
return return
} }
guard let restore = atomicRestore else { guard !atomicRestores.isEmpty else {
send(connection: connection, status: 503, body: ["error": "atomic_restore_not_registered"]) send(connection: connection, status: 503, body: ["error": "atomic_restore_not_registered"])
return return
} }
// Validate-then-apply via the codegen-supplied closure. The closure does // Phase one validates every registered model without assignment.
// a single struct-assignment so SwiftUI sees one change notification. for restore in atomicRestores {
switch restore(keys) { switch restore(keys, false) {
case .ok: case .ok:
send(connection: connection, status: 200, body: ["ok": true]) continue
case .missingKey(let k): case .missingKey(let k):
send(connection: connection, status: 400, body: ["error": "validation_failed", "key": k, "reason": "missing"]) send(connection: connection, status: 400, body: ["error": "validation_failed", "key": k, "reason": "missing"])
case .typeMismatch(let k): return
send(connection: connection, status: 400, body: ["error": "validation_failed", "key": k, "reason": "type-mismatch"]) case .typeMismatch(let k):
case .schemaMismatch(let expected, let got): send(connection: connection, status: 400, body: ["error": "validation_failed", "key": k, "reason": "type-mismatch"])
send(connection: connection, status: 409, body: ["error": "schema_mismatch", "expected_hash": expected, "got_hash": got]) return
case .schemaMismatch(let expected, let got):
send(connection: connection, status: 409, body: ["error": "schema_mismatch", "expected_hash": expected, "got_hash": got])
return
}
} }
// Phase two applies only after every model accepted the immutable input.
// A valid multi-field restore may notify once per property.
for restore in atomicRestores {
guard case .ok = restore(keys, true) else {
send(connection: connection, status: 500, body: ["error": "restore_apply_failed"])
return
}
}
send(connection: connection, status: 200, body: ["ok": true])
} }
// MARK: Stubs (real impls live in DebugBridgeManager + UIKit) // MARK: Stubs (real impls live in DebugBridgeManager + UIKit)
@@ -522,9 +539,19 @@ public final class StateServer {
// MARK: Response // MARK: Response
private func send(connection: NWConnection, status: Int, body: JSONDict) { private func send(connection: NWConnection, status: Int, body: JSONDict) {
let json = (try? JSONSerialization.data(withJSONObject: body)) ?? Data("{}".utf8) let responseStatus: Int
let json: Data
if JSONSerialization.isValidJSONObject(body),
let encoded = try? JSONSerialization.data(withJSONObject: body) {
responseStatus = status
json = encoded
} else {
logger.error("Refusing to send a non-JSON response body")
responseStatus = 500
json = Data("{\"error\":\"response_not_json_serializable\"}".utf8)
}
let statusText: String let statusText: String
switch status { switch responseStatus {
case 200: statusText = "OK" case 200: statusText = "OK"
case 400: statusText = "Bad Request" case 400: statusText = "Bad Request"
case 401: statusText = "Unauthorized" case 401: statusText = "Unauthorized"
@@ -537,7 +564,7 @@ public final class StateServer {
case 503: statusText = "Service Unavailable" case 503: statusText = "Service Unavailable"
default: statusText = "Status" default: statusText = "Status"
} }
let header = "HTTP/1.1 \(status) \(statusText)\r\nContent-Type: application/json\r\nContent-Length: \(json.count)\r\nConnection: close\r\n\r\n" let header = "HTTP/1.1 \(responseStatus) \(statusText)\r\nContent-Type: application/json\r\nContent-Length: \(json.count)\r\nConnection: close\r\n\r\n"
var packet = Data(header.utf8) var packet = Data(header.utf8)
packet.append(json) packet.append(json)
connection.send(content: packet, completion: .contentProcessed { _ in connection.send(content: packet, completion: .contentProcessed { _ in
+28 -23
View File
@@ -806,49 +806,53 @@ After `/ios-qa` is installed in an app, the user may:
1. Add new `@Observable` classes or properties that need accessor coverage. 1. Add new `@Observable` classes or properties that need accessor coverage.
2. Upgrade gstack to a newer version with hardening fixes. 2. Upgrade gstack to a newer version with hardening fixes.
3. Move the `@Snapshotable` marker to a different field. 3. Move the `// @Snapshotable` generator marker comment to a different field.
This skill regenerates the relevant artifacts in place. This skill regenerates the relevant artifacts in place.
**Templates live in upstream gstack.** This skill resolves them from **Templates live in upstream gstack.** The installed
`~/.claude/skills/gstack/ios-qa/templates/` (or the worktree's `gstack-ios-qa-regen` launcher resolves its own gstack root and copies only
`ios-qa/templates/` when developing gstack itself). The fork's HTTP-fetch the supported bridge files from `ios-qa/templates/`. The fork's HTTP-fetch
pattern is gone. and wildcard-copy patterns are gone.
## Phase 1: Detect installed version ## Phase 1: Detect installed version
1. Read `<app>/DebugBridgeGenerated/.gstack-version` (written by /ios-qa 1. Read `<app>/DebugBridgeGenerated/.gstack-version` (written by /ios-qa
during install). If missing, treat the install as "unknown old version". during install). If missing, treat the install as "unknown old version".
2. Read upstream version from `$GSTACK_HOME/ios-qa/.gstack-version` (or the 2. Read upstream version from `$GSTACK_ROOT/VERSION`.
value baked into the installed gstack binary).
3. If versions match AND no new `@Observable` classes were added, exit 3. If versions match AND no new `@Observable` classes were added, exit
early with "already up to date". early with "already up to date".
## Phase 2: Regenerate codegen output ## Phase 2: Regenerate codegen output
Run `gstack-ios-qa-regen` (or the underlying SwiftPM tool directly): Run the deterministic regenerator once. `--app-source` is the directory the
accessor scanner should inspect; `--bridge-dir` is the local Swift package
that the app links in Debug builds:
```bash ```bash
swift run --package-path "$GSTACK_HOME/ios-qa/scripts/gen-accessors-tool" \ ~/.claude/skills/gstack/bin/gstack-ios-qa-regen \
gen-accessors --input "$APP_SOURCE_DIR" --output "$APP_SOURCE_DIR/DebugBridgeGenerated" --app-source "$APP_SOURCE_DIR" \
--bridge-dir "$APP_SOURCE_DIR/DebugBridge"
``` ```
The command removes only the known obsolete generated files from the former
flat `DebugBridgeGenerated/` layout before emitting the current accessor.
Generation accepts file-scope observable classes and JSON-native scalar,
array, String-keyed dictionary, and Optional field types. It rejects custom
types, implicitly unwrapped Optionals, nested observable classes, and duplicate
snapshot keys before writing a completion marker.
The composite-hash cache key handles whether anything actually needs The composite-hash cache key handles whether anything actually needs
regenerating; if Swift version, generator git rev, lockfile, source content, regenerating; if Swift version, generator git rev, lockfile, source content,
and platform triple all match the cache, this is a ~50ms no-op. and platform triple all match the cache, this is a ~50ms no-op.
## Phase 3: Update templated Swift files in place ## Phase 3: Review the generated diff
For each file that comes from `ios-qa/templates/*.swift.template`: 1. Review changes under `<app>/DebugBridge/` and
`<app>/DebugBridgeGenerated/StateAccessor.swift`.
1. Read the current installed file at 2. Confirm the command did not modify the app's handwritten Swift files.
`<app>/DebugBridgeGenerated/<Name>.swift`. 3. Keep app-specific wiring in the app target; canonical bridge package files
2. Read the upstream template at are regenerated from upstream and should not be hand-edited.
`$GSTACK_HOME/ios-qa/templates/<Name>.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 ## Phase 4: Verify
@@ -862,5 +866,6 @@ For each file that comes from `ios-qa/templates/*.swift.template`:
| Symptom | Action | | Symptom | Action |
|---|---| |---|---|
| Swift compile fails after regen | Revert via `git restore` + AskUserQuestion: surface the compile error | | Swift compile fails after regen | Revert via `git restore` + AskUserQuestion: surface the compile error |
| Schema hash unchanged after adding new @Observable | The new class isn't marked `@Snapshotable` — the codegen excludes it correctly. If the user wanted it snapshotted, add the wrapper. | | 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. |
| `--input` source dir contains test fixtures | gen-accessors scans the input dir recursively; exclude test/ via `--exclude` | | 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`. |
+28 -23
View File
@@ -35,49 +35,53 @@ After `/ios-qa` is installed in an app, the user may:
1. Add new `@Observable` classes or properties that need accessor coverage. 1. Add new `@Observable` classes or properties that need accessor coverage.
2. Upgrade gstack to a newer version with hardening fixes. 2. Upgrade gstack to a newer version with hardening fixes.
3. Move the `@Snapshotable` marker to a different field. 3. Move the `// @Snapshotable` generator marker comment to a different field.
This skill regenerates the relevant artifacts in place. This skill regenerates the relevant artifacts in place.
**Templates live in upstream gstack.** This skill resolves them from **Templates live in upstream gstack.** The installed
`~/.claude/skills/gstack/ios-qa/templates/` (or the worktree's `gstack-ios-qa-regen` launcher resolves its own gstack root and copies only
`ios-qa/templates/` when developing gstack itself). The fork's HTTP-fetch the supported bridge files from `ios-qa/templates/`. The fork's HTTP-fetch
pattern is gone. and wildcard-copy patterns are gone.
## Phase 1: Detect installed version ## Phase 1: Detect installed version
1. Read `<app>/DebugBridgeGenerated/.gstack-version` (written by /ios-qa 1. Read `<app>/DebugBridgeGenerated/.gstack-version` (written by /ios-qa
during install). If missing, treat the install as "unknown old version". during install). If missing, treat the install as "unknown old version".
2. Read upstream version from `$GSTACK_HOME/ios-qa/.gstack-version` (or the 2. Read upstream version from `$GSTACK_ROOT/VERSION`.
value baked into the installed gstack binary).
3. If versions match AND no new `@Observable` classes were added, exit 3. If versions match AND no new `@Observable` classes were added, exit
early with "already up to date". early with "already up to date".
## Phase 2: Regenerate codegen output ## Phase 2: Regenerate codegen output
Run `gstack-ios-qa-regen` (or the underlying SwiftPM tool directly): Run the deterministic regenerator once. `--app-source` is the directory the
accessor scanner should inspect; `--bridge-dir` is the local Swift package
that the app links in Debug builds:
```bash ```bash
swift run --package-path "$GSTACK_HOME/ios-qa/scripts/gen-accessors-tool" \ ~/.claude/skills/gstack/bin/gstack-ios-qa-regen \
gen-accessors --input "$APP_SOURCE_DIR" --output "$APP_SOURCE_DIR/DebugBridgeGenerated" --app-source "$APP_SOURCE_DIR" \
--bridge-dir "$APP_SOURCE_DIR/DebugBridge"
``` ```
The command removes only the known obsolete generated files from the former
flat `DebugBridgeGenerated/` layout before emitting the current accessor.
Generation accepts file-scope observable classes and JSON-native scalar,
array, String-keyed dictionary, and Optional field types. It rejects custom
types, implicitly unwrapped Optionals, nested observable classes, and duplicate
snapshot keys before writing a completion marker.
The composite-hash cache key handles whether anything actually needs The composite-hash cache key handles whether anything actually needs
regenerating; if Swift version, generator git rev, lockfile, source content, regenerating; if Swift version, generator git rev, lockfile, source content,
and platform triple all match the cache, this is a ~50ms no-op. and platform triple all match the cache, this is a ~50ms no-op.
## Phase 3: Update templated Swift files in place ## Phase 3: Review the generated diff
For each file that comes from `ios-qa/templates/*.swift.template`: 1. Review changes under `<app>/DebugBridge/` and
`<app>/DebugBridgeGenerated/StateAccessor.swift`.
1. Read the current installed file at 2. Confirm the command did not modify the app's handwritten Swift files.
`<app>/DebugBridgeGenerated/<Name>.swift`. 3. Keep app-specific wiring in the app target; canonical bridge package files
2. Read the upstream template at are regenerated from upstream and should not be hand-edited.
`$GSTACK_HOME/ios-qa/templates/<Name>.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 ## Phase 4: Verify
@@ -91,5 +95,6 @@ For each file that comes from `ios-qa/templates/*.swift.template`:
| Symptom | Action | | Symptom | Action |
|---|---| |---|---|
| Swift compile fails after regen | Revert via `git restore` + AskUserQuestion: surface the compile error | | Swift compile fails after regen | Revert via `git restore` + AskUserQuestion: surface the compile error |
| Schema hash unchanged after adding new @Observable | The new class isn't marked `@Snapshotable` — the codegen excludes it correctly. If the user wanted it snapshotted, add the wrapper. | | 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. |
| `--input` source dir contains test fixtures | gen-accessors scans the input dir recursively; exclude test/ via `--exclude` | | 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`. |
+42 -1
View File
@@ -106,9 +106,15 @@ export function canonicalizeRemote(url: string | null | undefined): string {
// strip user@ prefix on URL-style remotes // strip user@ prefix on URL-style remotes
s = s.replace(/^[^@\/]+@/, ""); s = s.replace(/^[^@\/]+@/, "");
} }
// strip trailing slash(es) first, so a URL written with a trailing slash
// still matches the `.git$` suffix below (e.g. ".../repo.git/" must
// canonicalize to ".../repo", not ".../repo.git").
s = s.replace(/\/+$/, "");
// strip trailing .git // strip trailing .git
s = s.replace(/\.git$/i, ""); s = s.replace(/\.git$/i, "");
// strip trailing slash // re-strip trailing slash(es): a path remote ending in a `.git` directory
// component ("/repo/.git") exposes a new trailing slash once `.git` is
// stripped, which would split the repo into a second identity.
s = s.replace(/\/+$/, ""); s = s.replace(/\/+$/, "");
// collapse multiple slashes (after path normalization) // collapse multiple slashes (after path normalization)
s = s.replace(/\/{2,}/g, "/"); s = s.replace(/\/{2,}/g, "/");
@@ -396,6 +402,7 @@ function extractGbrainBlock(frontmatter: string): GbrainManifest | null {
const globM = body.match(/(?:^|\n)\s*glob\s*:\s*"?([^"\n]+?)"?\s*$/m); const globM = body.match(/(?:^|\n)\s*glob\s*:\s*"?([^"\n]+?)"?\s*$/m);
const sortM = body.match(/(?:^|\n)\s*sort\s*:\s*([^\n]+)/); const sortM = body.match(/(?:^|\n)\s*sort\s*:\s*([^\n]+)/);
const tailM = body.match(/(?:^|\n)\s*tail\s*:\s*(\d+)/); const tailM = body.match(/(?:^|\n)\s*tail\s*:\s*(\d+)/);
const filterMap = parseFilterMap(body);
if (idM) q.id = idM[1].trim(); if (idM) q.id = idM[1].trim();
if (kindM) { if (kindM) {
@@ -408,6 +415,7 @@ function extractGbrainBlock(frontmatter: string): GbrainManifest | null {
if (globM) q.glob = globM[1].trim(); if (globM) q.glob = globM[1].trim();
if (sortM) q.sort = sortM[1].trim(); if (sortM) q.sort = sortM[1].trim();
if (tailM) q.tail = parseInt(tailM[1], 10); if (tailM) q.tail = parseInt(tailM[1], 10);
if (filterMap) q.filter = filterMap;
if (q.id && q.kind && q.render_as) { if (q.id && q.kind && q.render_as) {
queries.push(q as GbrainManifestQuery); queries.push(q as GbrainManifestQuery);
@@ -418,6 +426,39 @@ function extractGbrainBlock(frontmatter: string): GbrainManifest | null {
return { schema, context_queries: queries }; return { schema, context_queries: queries };
} }
/**
* Parse a nested `filter:` block map out of a single context_queries item body.
*
* The block is a YAML map nested under the `filter:` key:
*
* filter:
* type: timeline
* tags_contains: "repo:{repo_slug}"
*
* Each sub-key sits one indent level deeper than `filter:`. Surrounding quotes
* are stripped and template vars ({repo_slug}, now-7d, ...) are left intact for
* downstream substitution, matching how dispatchList stringifies each value
* into a `--filter k=v` argument. Returns undefined when there is no `filter:`
* block or it is empty.
*/
function parseFilterMap(body: string): Record<string, string> | undefined {
const lines = body.split("\n");
const filterIdx = lines.findIndex((l) => /^\s*filter\s*:\s*$/.test(l));
if (filterIdx === -1) return undefined;
const filterIndent = lines[filterIdx].match(/^\s*/)![0].length;
const filter: Record<string, string> = {};
for (let i = filterIdx + 1; i < lines.length; i++) {
const line = lines[i];
if (line.trim() === "") continue; // tolerate blank lines within the block
const indent = line.match(/^\s*/)![0].length;
if (indent <= filterIndent) break; // dedent to a sibling key ends the block
const kv = line.match(/^\s*([A-Za-z0-9_]+)\s*:\s*"?(.*?)"?\s*$/);
if (kv) filter[kv[1]] = kv[2].trim();
}
return Object.keys(filter).length > 0 ? filter : undefined;
}
// ── Public: withErrorContext ────────────────────────────────────────────── // ── Public: withErrorContext ──────────────────────────────────────────────
const ERROR_LOG_PATH = join(gstackHome(), ".gbrain-errors.jsonl"); const ERROR_LOG_PATH = join(gstackHome(), ".gbrain-errors.jsonl");
+14 -1
View File
@@ -18,10 +18,23 @@ let filterBranch: string | null = null;
let filterTier: string | null = null; let filterTier: string | null = null;
let limit = 20; let limit = 20;
function parseLimit(raw: string | undefined): number {
if (!raw || !/^[1-9]\d*$/.test(raw)) {
console.error('eval:list: --limit requires a positive integer');
process.exit(1);
}
const parsed = Number(raw);
if (!Number.isSafeInteger(parsed)) {
console.error('eval:list: --limit requires a positive integer');
process.exit(1);
}
return parsed;
}
for (let i = 0; i < args.length; i++) { for (let i = 0; i < args.length; i++) {
if (args[i] === '--branch' && args[i + 1]) { filterBranch = args[++i]; } if (args[i] === '--branch' && args[i + 1]) { filterBranch = args[++i]; }
else if (args[i] === '--tier' && args[i + 1]) { filterTier = args[++i]; } else if (args[i] === '--tier' && args[i + 1]) { filterTier = args[++i]; }
else if (args[i] === '--limit' && args[i + 1]) { limit = parseInt(args[++i], 10); } else if (args[i] === '--limit') { limit = parseLimit(args[++i]); }
} }
// Read eval files // Read eval files
+2 -1
View File
@@ -15,6 +15,7 @@
import { ALL_HOST_CONFIGS, getHostConfig, ALL_HOST_NAMES } from '../hosts/index'; import { ALL_HOST_CONFIGS, getHostConfig, ALL_HOST_NAMES } from '../hosts/index';
import { validateAllConfigs } from './host-config'; import { validateAllConfigs } from './host-config';
import { RESOLVERS } from './resolvers';
import { execSync } from 'child_process'; import { execSync } from 'child_process';
const CLI_REGEX = /^[a-z][a-z0-9_-]*$/; const CLI_REGEX = /^[a-z][a-z0-9_-]*$/;
@@ -82,7 +83,7 @@ switch (command) {
} }
case 'validate': { case 'validate': {
const errors = validateAllConfigs(ALL_HOST_CONFIGS); const errors = validateAllConfigs(ALL_HOST_CONFIGS, new Set(Object.keys(RESOLVERS)));
if (errors.length > 0) { if (errors.length > 0) {
for (const error of errors) { for (const error of errors) {
console.error(`ERROR: ${error}`); console.error(`ERROR: ${error}`);
+15 -3
View File
@@ -117,7 +117,7 @@ const NAME_REGEX = /^[a-z][a-z0-9-]*$/;
const PATH_REGEX = /^[a-zA-Z0-9_.\/${}~-]+$/; const PATH_REGEX = /^[a-zA-Z0-9_.\/${}~-]+$/;
const CLI_REGEX = /^[a-z][a-z0-9_-]*$/; const CLI_REGEX = /^[a-z][a-z0-9_-]*$/;
export function validateHostConfig(config: HostConfig): string[] { export function validateHostConfig(config: HostConfig, validResolverNames?: ReadonlySet<string>): string[] {
const errors: string[] = []; const errors: string[] = [];
if (!NAME_REGEX.test(config.name)) { if (!NAME_REGEX.test(config.name)) {
@@ -152,15 +152,27 @@ export function validateHostConfig(config: HostConfig): string[] {
errors.push(`install.linkingStrategy must be 'real-dir-symlink' or 'symlink-generated'`); errors.push(`install.linkingStrategy must be 'real-dir-symlink' or 'symlink-generated'`);
} }
// Cross-check suppressedResolvers against the known resolver names (injected to avoid a
// circular import on the resolver registry). A typo would otherwise silently no-op: the
// generator short-circuits suppressed names before the "unknown placeholder" throw, so an
// unknown entry never surfaces at generation time either.
if (validResolverNames && config.suppressedResolvers) {
for (const name of config.suppressedResolvers) {
if (!validResolverNames.has(name)) {
errors.push(`suppressedResolvers entry '${name}' is not a known resolver`);
}
}
}
return errors; return errors;
} }
export function validateAllConfigs(configs: HostConfig[]): string[] { export function validateAllConfigs(configs: HostConfig[], validResolverNames?: ReadonlySet<string>): string[] {
const errors: string[] = []; const errors: string[] = [];
// Per-config validation // Per-config validation
for (const config of configs) { for (const config of configs) {
const configErrors = validateHostConfig(config); const configErrors = validateHostConfig(config, validResolverNames);
errors.push(...configErrors.map(e => `[${config.name}] ${e}`)); errors.push(...configErrors.map(e => `[${config.name}] ${e}`));
} }
+6 -3
View File
@@ -62,9 +62,12 @@ const DESTRUCTIVE_PATTERNS: RegExp[] = [
/\bterraform\s+destroy\b/i, /\bterraform\s+destroy\b/i,
/\brollback\b/i, /\brollback\b/i,
// Credentials / auth — allow filler words ("the", "my") between verb and noun // Credentials / auth — allow filler words ("the", "my") between verb and noun.
/\brevoke\s+[\w\s]*\b(api key|token|credential|access key|password)\b/i, // Keep the noun alternation identical across revoke/reset/rotate so the three
/\breset\s+[\w\s]*\b(api key|token|password|credential)\b/i, // verbs stay parallel; a noun in one but not the others is a false-negative
// safety hole (e.g. "reset my secret" must be one-way just like "rotate my secret").
/\brevoke\s+[\w\s]*\b(api key|token|secret|credential|access key|password)\b/i,
/\breset\s+[\w\s]*\b(api key|token|secret|credential|access key|password)\b/i,
/\brotate\s+[\w\s]*\b(api key|token|secret|credential|access key|password)\b/i, /\brotate\s+[\w\s]*\b(api key|token|secret|credential|access key|password)\b/i,
// Scope / architecture forks (reversible with effort — still deserve confirmation) // Scope / architecture forks (reversible with effort — still deserve confirmation)
+89
View File
@@ -0,0 +1,89 @@
import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import { spawnSync } from 'child_process';
const ROOT = path.resolve(import.meta.dir, '..');
let tmpHome: string;
beforeEach(() => {
tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-eval-list-'));
const evalDir = path.join(tmpHome, '.gstack-dev', 'evals');
fs.mkdirSync(evalDir, { recursive: true });
writeEvalRun(evalDir, '2026-a.json', '2026-05-24T01:00:00Z', 2);
writeEvalRun(evalDir, '2026-b.json', '2026-05-24T02:00:00Z', 3);
});
afterEach(() => {
fs.rmSync(tmpHome, { recursive: true, force: true });
});
function writeEvalRun(evalDir: string, filename: string, timestamp: string, turns: number) {
fs.writeFileSync(
path.join(evalDir, filename),
JSON.stringify({
schema_version: 1,
version: '1.44.0.0',
branch: 'main',
git_sha: filename,
timestamp,
tier: 'e2e',
total_tests: 1,
passed: 1,
failed: 0,
total_cost_usd: 0,
total_duration_ms: 1000,
tests: [
{
name: filename,
suite: 'sample',
tier: 'e2e',
passed: true,
duration_ms: 1000,
cost_usd: 0,
turns_used: turns,
},
],
}),
);
}
function runEvalList(...args: string[]): { stdout: string; stderr: string; status: number } {
const result = spawnSync('bun', ['run', 'scripts/eval-list.ts', ...args], {
cwd: ROOT,
env: {
...process.env,
HOME: tmpHome,
GSTACK_HOME: path.join(tmpHome, '.gstack'),
},
encoding: 'utf-8',
});
return {
stdout: result.stdout ?? '',
stderr: result.stderr ?? '',
status: result.status ?? -1,
};
}
describe('eval:list CLI', () => {
test('limits displayed eval runs with a valid positive integer', () => {
const result = runEvalList('--limit', '1');
expect(result.status).toBe(0);
expect(result.stdout).toContain('Eval History (2 total runs)');
expect(result.stdout).toContain('Showing: 1');
expect(result.stdout).toContain('2026-05-24 02:00');
expect(result.stdout).not.toContain('2026-05-24 01:00');
});
test('rejects malformed limit values instead of silently slicing output', () => {
for (const value of ['1abc', 'nope', '0', '-1', '1.5']) {
const result = runEvalList('--limit', value);
expect(result.status).not.toBe(0);
expect(result.stderr).toContain('--limit requires a positive integer');
expect(result.stdout).toBe('');
}
});
});
+6
View File
@@ -0,0 +1,6 @@
{
"_schema_version": 1,
"_app_build_id": "uninitialized",
"_accessor_hash": "uninitialized",
"keys": {}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

@@ -14,12 +14,16 @@ import Foundation
public final class DebugBridgeManager { public final class DebugBridgeManager {
public static let shared = DebugBridgeManager() public static let shared = DebugBridgeManager()
public func start(appState: AppState) { /// Register app-owned generated accessors, then start the server. The
// 1. Register the canonical AppState struct + accessor wiring. /// registration closure is passed in from the consuming app because the
// AppStateAccessor.register(_:) is generated by gen-accessors-tool. /// DebugBridgeCore package cannot import app-target types. On UIKit apps,
AppStateAccessor.register(appState) /// call DebugBridgeUIWiring.installAll() before this method so a warm
/// daemon cannot reach uninitialized resolvers during listener startup.
public func start<State>(appState: State, register: (State) -> Void) {
register(appState)
// 2. Boot the StateServer. // Boot only after registration so the first snapshot has a real build
// id, schema hash, and key set.
StateServer.shared.start() StateServer.shared.start()
// 3. The consuming app installs DebugOverlayWindow separately. See // 3. The consuming app installs DebugOverlayWindow separately. See
@@ -31,19 +35,4 @@ public final class DebugBridgeManager {
} }
} }
// Placeholder. gen-accessors-tool emits the real `AppStateAccessor` enum next
// to the app's canonical state struct. Apps that haven't run codegen get a
// stub that registers no accessors (snapshot is empty, restore returns
// missing-key for every key).
@MainActor
public enum AppStateAccessor {
public static var register: (Any) -> Void = { _ in }
}
// Apps declare their canonical state struct; codegen reads it and emits
// AppStateAccessor.register. The app's struct must be `@Observable` and
// must hold all snapshot-eligible state in `@Snapshotable`-marked fields.
@MainActor
public protocol AppState: AnyObject {}
#endif // DEBUG #endif // DEBUG
@@ -59,18 +59,20 @@ public final class StateServer {
private var writeHandlers: [String: WriteHandler] = [:] private var writeHandlers: [String: WriteHandler] = [:]
private var typeNames: [String: TypeName] = [:] private var typeNames: [String: TypeName] = [:]
// Atomic-restore hook. Codegen wires this to the canonical AppState struct. // Validated-restore hooks. Every generated model registers one two-phase
// Restore replaces the entire struct in one assignment so SwiftUI's Combine // handler. The server validates all models before it lets any model mutate,
// pipeline observes exactly one change notification true observable // so invalid input can never cause a cross-model partial restore.
// atomicity. @MainActor alone doesn't guarantee that. // Valid restores apply properties on MainActor; observers may receive one
public typealias AtomicRestoreFn = (JSONDict) -> RestoreResult // change notification per property because arbitrary @Observable models do
// not expose a general single-assignment transaction API.
public typealias AtomicRestoreFn = (JSONDict, Bool) -> RestoreResult
public enum RestoreResult { public enum RestoreResult {
case ok case ok
case missingKey(String) case missingKey(String)
case typeMismatch(String) case typeMismatch(String)
case schemaMismatch(expected: String, got: String) case schemaMismatch(expected: String, got: String)
} }
private var atomicRestore: AtomicRestoreFn? private var atomicRestores: [AtomicRestoreFn] = []
// Snapshot schema hash written by codegen, stable across builds with // Snapshot schema hash written by codegen, stable across builds with
// identical accessor signatures. // identical accessor signatures.
@@ -109,7 +111,7 @@ public final class StateServer {
public func register(buildId: String, accessorHash: String, atomicRestore: @escaping AtomicRestoreFn) { public func register(buildId: String, accessorHash: String, atomicRestore: @escaping AtomicRestoreFn) {
self.appBuildId = buildId self.appBuildId = buildId
self.accessorHash = accessorHash self.accessorHash = accessorHash
self.atomicRestore = atomicRestore self.atomicRestores.append(atomicRestore)
} }
public func registerAccessor(key: String, type: String, read: @escaping ReadHandler, write: @escaping WriteHandler) { public func registerAccessor(key: String, type: String, read: @escaping ReadHandler, write: @escaping WriteHandler) {
@@ -284,6 +286,7 @@ public final class StateServer {
"version": "1.0.0", "version": "1.0.0",
"build": appBuildId, "build": appBuildId,
"accessor_hash": accessorHash, "accessor_hash": accessorHash,
"bundle_id": Bundle.main.bundleIdentifier ?? "unknown",
]) ])
return return
} }
@@ -476,22 +479,36 @@ public final class StateServer {
send(connection: connection, status: 400, body: ["error": "missing_keys"]) send(connection: connection, status: 400, body: ["error": "missing_keys"])
return return
} }
guard let restore = atomicRestore else { guard !atomicRestores.isEmpty else {
send(connection: connection, status: 503, body: ["error": "atomic_restore_not_registered"]) send(connection: connection, status: 503, body: ["error": "atomic_restore_not_registered"])
return return
} }
// Validate-then-apply via the codegen-supplied closure. The closure does // Phase one validates every registered model without assignment.
// a single struct-assignment so SwiftUI sees one change notification. for restore in atomicRestores {
switch restore(keys) { switch restore(keys, false) {
case .ok: case .ok:
send(connection: connection, status: 200, body: ["ok": true]) continue
case .missingKey(let k): case .missingKey(let k):
send(connection: connection, status: 400, body: ["error": "validation_failed", "key": k, "reason": "missing"]) send(connection: connection, status: 400, body: ["error": "validation_failed", "key": k, "reason": "missing"])
case .typeMismatch(let k): return
send(connection: connection, status: 400, body: ["error": "validation_failed", "key": k, "reason": "type-mismatch"]) case .typeMismatch(let k):
case .schemaMismatch(let expected, let got): send(connection: connection, status: 400, body: ["error": "validation_failed", "key": k, "reason": "type-mismatch"])
send(connection: connection, status: 409, body: ["error": "schema_mismatch", "expected_hash": expected, "got_hash": got]) return
case .schemaMismatch(let expected, let got):
send(connection: connection, status: 409, body: ["error": "schema_mismatch", "expected_hash": expected, "got_hash": got])
return
}
} }
// Phase two applies only after every model accepted the immutable input.
// A valid multi-field restore may notify once per property.
for restore in atomicRestores {
guard case .ok = restore(keys, true) else {
send(connection: connection, status: 500, body: ["error": "restore_apply_failed"])
return
}
}
send(connection: connection, status: 200, body: ["ok": true])
} }
// MARK: Stubs (real impls live in DebugBridgeManager + UIKit) // MARK: Stubs (real impls live in DebugBridgeManager + UIKit)
@@ -522,9 +539,19 @@ public final class StateServer {
// MARK: Response // MARK: Response
private func send(connection: NWConnection, status: Int, body: JSONDict) { private func send(connection: NWConnection, status: Int, body: JSONDict) {
let json = (try? JSONSerialization.data(withJSONObject: body)) ?? Data("{}".utf8) let responseStatus: Int
let json: Data
if JSONSerialization.isValidJSONObject(body),
let encoded = try? JSONSerialization.data(withJSONObject: body) {
responseStatus = status
json = encoded
} else {
logger.error("Refusing to send a non-JSON response body")
responseStatus = 500
json = Data("{\"error\":\"response_not_json_serializable\"}".utf8)
}
let statusText: String let statusText: String
switch status { switch responseStatus {
case 200: statusText = "OK" case 200: statusText = "OK"
case 400: statusText = "Bad Request" case 400: statusText = "Bad Request"
case 401: statusText = "Unauthorized" case 401: statusText = "Unauthorized"
@@ -537,7 +564,7 @@ public final class StateServer {
case 503: statusText = "Service Unavailable" case 503: statusText = "Service Unavailable"
default: statusText = "Status" default: statusText = "Status"
} }
let header = "HTTP/1.1 \(status) \(statusText)\r\nContent-Type: application/json\r\nContent-Length: \(json.count)\r\nConnection: close\r\n\r\n" let header = "HTTP/1.1 \(responseStatus) \(statusText)\r\nContent-Type: application/json\r\nContent-Length: \(json.count)\r\nConnection: close\r\n\r\n"
var packet = Data(header.utf8) var packet = Data(header.utf8)
packet.append(json) packet.append(json)
connection.send(content: packet, completion: .contentProcessed { _ in connection.send(content: packet, completion: .contentProcessed { _ in
@@ -126,6 +126,36 @@ static BOOL DBT_LoadIOKit(void) {
return _IOKitLoaded; 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) CF_RETURNS_RETAINED;
static IOHIDEventRef DBT_IOHIDEventWithTouch(UITouch *touch) { static IOHIDEventRef DBT_IOHIDEventWithTouch(UITouch *touch) {
if (!DBT_LoadIOKit()) return NULL; if (!DBT_LoadIOKit()) return NULL;
@@ -172,6 +202,7 @@ static IOHIDEventRef DBT_IOHIDEventWithTouch(UITouch *touch) {
- (void)setGestureView:(UIView *)view; - (void)setGestureView:(UIView *)view;
- (void)_setLocationInWindow:(CGPoint)location resetPrevious:(BOOL)resetPrevious; - (void)_setLocationInWindow:(CGPoint)location resetPrevious:(BOOL)resetPrevious;
- (void)_setIsFirstTouchForView:(BOOL)firstTouchForView; - (void)_setIsFirstTouchForView:(BOOL)firstTouchForView;
- (void)_setIsTapToClick:(BOOL)tapToClick;
- (void)_setHidEvent:(IOHIDEventRef)event; - (void)_setHidEvent:(IOHIDEventRef)event;
@end @end
@@ -229,6 +260,10 @@ static id DBT_HitTestView(UIWindow *window, CGPoint point) {
@implementation DebugBridgeTouch @implementation DebugBridgeTouch
+ (void)prepareForAutomation {
DBT_EnableAccessibilityAutomation();
}
+ (BOOL)sendTapAtPoint:(CGPoint)point inWindow:(UIWindow *)window { + (BOOL)sendTapAtPoint:(CGPoint)point inWindow:(UIWindow *)window {
if (!window) return NO; if (!window) return NO;
@@ -247,10 +282,17 @@ static id DBT_HitTestView(UIWindow *window, CGPoint point) {
[touch setPhase:UITouchPhaseBegan]; [touch setPhase:UITouchPhaseBegan];
if ([touch respondsToSelector:@selector(_setIsFirstTouchForView:)]) { if ([touch respondsToSelector:@selector(_setIsFirstTouchForView:)]) {
[touch _setIsFirstTouchForView:YES]; [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]]; [touch setTimestamp:[[NSProcessInfo processInfo] systemUptime]];
if ([touch respondsToSelector:@selector(setGestureView:)] && if ([touch respondsToSelector:@selector(setGestureView:)]) {
[hit isKindOfClass:[UIView class]]) {
[touch setGestureView:(UIView *)hit]; [touch setGestureView:(UIView *)hit];
} }
@@ -23,6 +23,10 @@ NS_ASSUME_NONNULL_BEGIN
@interface DebugBridgeTouch : NSObject @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 /// Synthesize a single tap (TouchPhaseBegan + TouchPhaseEnded) at the given
/// window-coordinate point. Returns YES if the touch was delivered (a hit /// window-coordinate point. Returns YES if the touch was delivered (a hit
/// view was found and the event passed through UIApplication.sendEvent). /// view was found and the event passed through UIApplication.sendEvent).
@@ -27,12 +27,35 @@ public enum DebugBridgeUIWiring {
/// times reinstalls the same closures. Must be called on @MainActor /// times reinstalls the same closures. Must be called on @MainActor
/// because every UIKit access requires the main actor. /// because every UIKit access requires the main actor.
public static func installAll() { 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() } ScreenshotBridge.resolver = { ScreenshotBridgeImpl.capturePNG() }
ElementsBridge.resolver = { ElementsBridgeImpl.snapshot() } ElementsBridge.resolver = { ElementsBridgeImpl.snapshot() }
MutationBridge.resolver = { op, payload in MutationBridgeImpl.dispatch(op: op, payload: payload) } 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..<count).compactMap { element.accessibilityElement(at: $0) as? NSObject }
}
// MARK: - ScreenshotBridge implementation // MARK: - ScreenshotBridge implementation
@MainActor @MainActor
@@ -43,7 +66,11 @@ enum ScreenshotBridgeImpl {
static func capturePNG() -> Data? { static func capturePNG() -> Data? {
guard let scene = activeScene(), let window = activeKeyWindow(in: scene) else { return nil } guard let scene = activeScene(), let window = activeKeyWindow(in: scene) else { return nil }
let bounds = window.bounds let bounds = window.bounds
let renderer = UIGraphicsImageRenderer(bounds: bounds) let format = UIGraphicsImageRendererFormat.default()
// /tap consumes UIKit window points. Render at 1x so screenshot pixels
// use that same coordinate space on 2x/3x devices.
format.scale = 1
let renderer = UIGraphicsImageRenderer(bounds: bounds, format: format)
let image = renderer.image { _ in let image = renderer.image { _ in
// drawHierarchy is the documented way to snapshot real UIKit // drawHierarchy is the documented way to snapshot real UIKit
// layers including layer-backed views. afterScreenUpdates: false // layers including layer-backed views. afterScreenUpdates: false
@@ -61,7 +88,10 @@ enum ScreenshotBridgeImpl {
} }
private static func activeKeyWindow(in scene: UIWindowScene) -> UIWindow? { private static func activeKeyWindow(in scene: UIWindowScene) -> UIWindow? {
scene.windows.first(where: { $0.isKeyWindow }) ?? scene.windows.first let windows = scene.windows.filter { window in
!window.isHidden && !String(describing: type(of: window)).contains("PassThroughWindow")
}
return windows.first(where: { $0.isKeyWindow }) ?? windows.max(by: { $0.windowLevel < $1.windowLevel })
} }
} }
@@ -76,21 +106,40 @@ enum ElementsBridgeImpl {
static func snapshot() -> [JSONDict] { static func snapshot() -> [JSONDict] {
guard let scene = activeScene(), let window = activeKeyWindow(in: scene) else { return [] } guard let scene = activeScene(), let window = activeKeyWindow(in: scene) else { return [] }
var elements: [JSONDict] = [] var elements: [JSONDict] = []
collect(view: window, parentPath: "", windowBounds: window.bounds, into: &elements) var visited = Set<ObjectIdentifier>()
var remaining = 2_048
collect(
view: window,
parentPath: "",
window: window,
visited: &visited,
remaining: &remaining,
into: &elements
)
return elements return elements
} }
private static func collect(view: UIView, parentPath: String, windowBounds: CGRect, into elements: inout [JSONDict]) { private static func collect(
view: UIView,
parentPath: String,
window: UIWindow,
visited: inout Set<ObjectIdentifier>,
remaining: inout Int,
into elements: inout [JSONDict]
) {
guard remaining > 0, visited.insert(ObjectIdentifier(view)).inserted else { return }
remaining -= 1
// Skip hidden / zero-size / off-screen subtrees early. // Skip hidden / zero-size / off-screen subtrees early.
if view.isHidden || view.alpha < 0.01 { return } if view.isHidden || view.alpha < 0.01 { return }
let frameInWindow = view.convert(view.bounds, to: nil) let frameInWindow = view.convert(view.bounds, to: window)
if !windowBounds.intersects(frameInWindow) { return } if !window.bounds.intersects(frameInWindow) { return }
let isAccessible = view.isAccessibilityElement let isAccessible = view.isAccessibilityElement
let label = view.accessibilityLabel ?? "" let label = view.accessibilityLabel ?? ""
let identifier = view.accessibilityIdentifier ?? "" let identifier = view.accessibilityIdentifier ?? ""
let traits = Int(view.accessibilityTraits.rawValue) let traits = NSNumber(value: view.accessibilityTraits.rawValue)
let value = (view.accessibilityValue ?? "") as String let value = (view.accessibilityValue ?? "") as String
let className = String(describing: type(of: view)) let className = String(describing: type(of: view))
let path = parentPath.isEmpty ? className : "\(parentPath) > \(className)" let path = parentPath.isEmpty ? className : "\(parentPath) > \(className)"
@@ -120,66 +169,98 @@ enum ElementsBridgeImpl {
]) ])
} }
// Recurse into accessibility-elements first (some custom views vend // Walk automation children before raw subviews. On iOS 17+ this
// synthetic children), then UIView subviews. SwiftUI's host views // exposes identified SwiftUI controls nested inside GroupBox/List.
// populate accessibilityElements lazily many return nil before for (index, element) in debugBridgeAccessibilityChildren(of: view).enumerated() {
// VoiceOver triggers them. Force population by reading accessibilityElementCount. if let child = element as? UIView {
_ = view.accessibilityElementCount() collect(
if let axElements = view.accessibilityElements { view: child,
for case let element as NSObject in axElements { parentPath: path,
if let v = element as? UIView { window: window,
collect(view: v, parentPath: path, windowBounds: windowBounds, into: &elements) visited: &visited,
} else { remaining: &remaining,
// Synthetic accessibility element (no UIView). Capture frame in screen coords. into: &elements
let af = (element.value(forKey: "accessibilityFrame") as? CGRect) ?? .zero )
elements.append([ } else {
"path": "\(path) > <synthetic>", appendSynthetic(
"class": "AccessibilityElement", element,
"label": (element.value(forKey: "accessibilityLabel") as? String) ?? "", path: "\(path) > <ax\(index)>",
"identifier": (element.value(forKey: "accessibilityIdentifier") as? String) ?? "", window: window,
"value": (element.value(forKey: "accessibilityValue") as? String) ?? "", visited: &visited,
"traits": (element.value(forKey: "accessibilityTraits") as? NSNumber)?.intValue ?? 0, remaining: &remaining,
"frame": [ into: &elements
"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..<count {
guard let element = view.accessibilityElement(at: i) as? NSObject else { continue }
if let v = element as? UIView {
collect(view: v, parentPath: path, windowBounds: windowBounds, into: &elements)
} else {
let af = (element.value(forKey: "accessibilityFrame") as? CGRect) ?? .zero
elements.append([
"path": "\(path) > <ax\(i)>",
"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 { for sub in view.subviews {
collect(view: sub, parentPath: path, windowBounds: windowBounds, into: &elements) collect(
view: sub,
parentPath: path,
window: window,
visited: &visited,
remaining: &remaining,
into: &elements
)
}
}
private static func appendSynthetic(
_ element: NSObject,
path: String,
window: UIWindow,
visited: inout Set<ObjectIdentifier>,
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) > <ax\(index)>",
window: window,
visited: &visited,
remaining: &remaining,
into: &elements
)
}
} }
} }
@@ -191,7 +272,10 @@ enum ElementsBridgeImpl {
} }
private static func activeKeyWindow(in scene: UIWindowScene) -> UIWindow? { private static func activeKeyWindow(in scene: UIWindowScene) -> UIWindow? {
scene.windows.first(where: { $0.isKeyWindow }) ?? scene.windows.first let windows = scene.windows.filter { window in
!window.isHidden && !String(describing: type(of: window)).contains("PassThroughWindow")
}
return windows.first(where: { $0.isKeyWindow }) ?? windows.max(by: { $0.windowLevel < $1.windowLevel })
} }
} }
@@ -210,21 +294,62 @@ enum MutationBridgeImpl {
} }
} }
/// Tap at (x, y) in window coordinates. Delegates to DebugBridgeTouch /// Tap at (x, y) in window coordinates. Prefer accessibility activation,
/// (KIF-derived in-process touch synthesis). The Obj-C target builds a /// which is stable for SwiftUI buttons across OS releases, then fall back
/// real UITouch + IOHIDEvent + UIEvent and dispatches via /// to KIF-derived UITouch synthesis for gesture-only/custom controls.
/// `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 { private static func handleTap(_ payload: JSONDict) -> Bool {
guard let x = payload["x"] as? NSNumber, guard let x = payload["x"] as? NSNumber,
let y = payload["y"] as? NSNumber else { return false } let y = payload["y"] as? NSNumber else { return false }
let point = CGPoint(x: x.doubleValue, y: y.doubleValue) let point = CGPoint(x: x.doubleValue, y: y.doubleValue)
guard let scene = activeScene(), let window = activeKeyWindow(in: scene) else { return false } 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) 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<ObjectIdentifier>()
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. /// Set text on the first responder if it's a UITextField or UITextView.
private static func handleType(_ payload: JSONDict) -> Bool { private static func handleType(_ payload: JSONDict) -> Bool {
guard let text = payload["text"] as? String else { return false } guard let text = payload["text"] as? String else { return false }
@@ -266,7 +391,10 @@ enum MutationBridgeImpl {
var off = scroll.contentOffset var off = scroll.contentOffset
off.x = max(0, min(scroll.contentSize.width - scroll.bounds.width, off.x + dx)) 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)) off.y = max(0, min(scroll.contentSize.height - scroll.bounds.height, off.y + dy))
scroll.setContentOffset(off, animated: true) // Automation commands return synchronously; do not report
// success while the target is still moving underneath the
// next tap coordinate.
scroll.setContentOffset(off, animated: false)
return true return true
} }
node = cur.superview node = cur.superview
@@ -301,7 +429,10 @@ enum MutationBridgeImpl {
} }
private static func activeKeyWindow(in scene: UIWindowScene) -> UIWindow? { private static func activeKeyWindow(in scene: UIWindowScene) -> UIWindow? {
scene.windows.first(where: { $0.isKeyWindow }) ?? scene.windows.first let windows = scene.windows.filter { window in
!window.isHidden && !String(describing: type(of: window)).contains("PassThroughWindow")
}
return windows.first(where: { $0.isKeyWindow }) ?? windows.max(by: { $0.windowLevel < $1.windowLevel })
} }
} }
@@ -1,15 +1,21 @@
// FixtureApp minimal SwiftUI app used by the ios-qa device-path E2E test. // FixtureApp interaction-rich SwiftUI app used by the ios-qa device-path
// E2E test. Every control exposes a stable accessibility identifier and writes
// to visible verification state so device-driven taps have an explicit oracle.
// //
// On launch: // On launch:
// 1. Boot StateServer (loopback :::1/127.0.0.1 + 9999) // 1. Install UI resolvers before any request can arrive
// 2. Log boot token to os_log so devicectl + the Mac daemon can scrape it // 2. Register typed state and boot StateServer (::1/127.0.0.1 + 9999)
// 3. Render a single ContentView so the app stays foreground // 3. Log the one-use boot token, then render the interaction harness
// //
// Everything ios-qa-related is gated #if DEBUG. Release builds compile this // Everything ios-qa-related is gated #if DEBUG. Release builds compile this
// to a no-op app (no StateServer, no DebugBridge import, no overlay). // to a no-op app (no StateServer, no DebugBridge import, no overlay).
import SwiftUI import SwiftUI
#if canImport(UIKit)
import UIKit
#endif
#if DEBUG #if DEBUG
import DebugBridgeCore import DebugBridgeCore
#endif #endif
@@ -20,14 +26,21 @@ import DebugBridgeUI
@main @main
struct FixtureAppApp: App { struct FixtureAppApp: App {
#if DEBUG
private let appState = FixtureAppState()
#endif
init() { init() {
#if DEBUG #if DEBUG
StateServer.shared.start()
// Wire the three UIKit-backed bridges so /screenshot, /elements, // Wire the three UIKit-backed bridges so /screenshot, /elements,
// /tap, /type, /swipe actually do something on the device. // /tap, /type, /swipe are ready before the listener accepts requests.
#if canImport(UIKit) #if canImport(UIKit)
DebugBridgeUIWiring.installAll() DebugBridgeUIWiring.installAll()
#endif #endif
DebugBridgeManager.shared.start(
appState: appState,
register: FixtureAppStateAccessor.register
)
#endif #endif
} }
@@ -39,22 +52,707 @@ struct FixtureAppApp: App {
} }
struct ContentView: View { struct ContentView: View {
@State private var counter: Int = 0 private enum HarnessTab: String, Hashable {
case controls
case inputs
case rows
var title: String { rawValue.capitalized }
}
private struct HarnessRow: Identifiable {
let id: String
let title: String
let symbol: String
}
private static let harnessRows = [
HarnessRow(id: "alpha", title: "Alpha row", symbol: "a.circle.fill"),
HarnessRow(id: "bravo", title: "Bravo row", symbol: "b.circle.fill"),
HarnessRow(id: "charlie", title: "Charlie row", symbol: "c.circle.fill"),
HarnessRow(id: "delta", title: "Delta row", symbol: "d.circle.fill"),
]
@State private var selectedTab: HarnessTab = .controls
@State private var lastAction = "Harness ready"
@State private var totalActions = 0
@State private var tabChangeCount = 0
@State private var primaryButtonCount = 0
@State private var borderedButtonCount = 0
@State private var plainButtonCount = 0
@State private var destructiveButtonCount = 0
@State private var toolbarRefreshCount = 0
@State private var menuAddCount = 0
@State private var menuArchiveCount = 0
@State private var detailOpenCount = 0
@State private var detailCloseCount = 0
@State private var detailButtonCount = 0
@State private var isShowingDetail = false
@State private var toggleValue = false
@State private var toggleChangeCount = 0
@State private var stepperValue = 0
@State private var stepperChangeCount = 0
@State private var selectedMode = "One"
@State private var pickerChangeCount = 0
@State private var draftText = ""
@FocusState private var isTextFieldFocused: Bool
@State private var textChangeCount = 0
@State private var textCommitCount = 0
@State private var uikitButtonCount = 0
@State private var rowTapCounts: [String: Int] = [:]
@State private var rowFlagCount = 0
@State private var rowArchiveCount = 0
@State private var rowToolbarCount = 0
var body: some View { var body: some View {
VStack(spacing: 24) { TabView(selection: $selectedTab) {
Text("ios-qa fixture") controlsTab
.font(.largeTitle.bold()) .tabItem {
Text("StateServer should be on :9999") Label("Controls", systemImage: "hand.tap.fill")
.font(.subheadline) .accessibilityIdentifier("tab-controls")
.foregroundColor(.secondary) }
Button("Tap (\(counter))") { .tag(HarnessTab.controls)
counter += 1
} inputsTab
.buttonStyle(.borderedProminent) .tabItem {
.accessibilityIdentifier("tap-button") Label("Inputs", systemImage: "slider.horizontal.3")
.accessibilityIdentifier("tab-inputs")
}
.tag(HarnessTab.inputs)
rowsTab
.tabItem {
Label("Rows", systemImage: "list.bullet.rectangle")
.accessibilityIdentifier("tab-rows")
}
.tag(HarnessTab.rows)
} }
.padding() .accessibilityIdentifier("fixture-tab-view")
.accessibilityIdentifier("fixture-content") .onChange(of: selectedTab) { newTab in
tabChangeCount += 1
record("Selected \(newTab.title) tab")
}
}
private var controlsTab: some View {
NavigationStack {
ScrollView {
VStack(spacing: 14) {
verificationPanel
GroupBox {
LazyVGrid(
columns: [GridItem(.flexible()), GridItem(.flexible())],
spacing: 10
) {
Button {
primaryButtonCount += 1
record("Primary button tapped")
} label: {
buttonLabel("Primary", count: primaryButtonCount, symbol: "bolt.fill")
}
.buttonStyle(.borderedProminent)
.accessibilityIdentifier("primary-button")
.accessibilityValue("\(primaryButtonCount) taps")
Button {
borderedButtonCount += 1
record("Bordered button tapped")
} label: {
buttonLabel("Bordered", count: borderedButtonCount, symbol: "square")
}
.buttonStyle(.bordered)
.accessibilityIdentifier("bordered-button")
.accessibilityValue("\(borderedButtonCount) taps")
Button {
plainButtonCount += 1
record("Plain button tapped")
} label: {
buttonLabel("Plain", count: plainButtonCount, symbol: "circle")
.padding(.vertical, 8)
.background(Color.accentColor.opacity(0.12))
.clipShape(RoundedRectangle(cornerRadius: 8))
}
.buttonStyle(.plain)
.accessibilityIdentifier("plain-button")
.accessibilityValue("\(plainButtonCount) taps")
Button(role: .destructive) {
destructiveButtonCount += 1
record("Destructive-style button tapped")
} label: {
buttonLabel(
"Destructive",
count: destructiveButtonCount,
symbol: "exclamationmark.triangle.fill"
)
}
.buttonStyle(.bordered)
.accessibilityIdentifier("destructive-button")
.accessibilityValue("\(destructiveButtonCount) taps")
}
} label: {
Label("SwiftUI button styles", systemImage: "rectangle.3.group")
.font(.headline)
}
.accessibilityIdentifier("button-styles-group")
GroupBox {
VStack(spacing: 10) {
verificationRow(
"Refresh",
value: toolbarRefreshCount,
identifier: "nav-refresh-count"
)
verificationRow(
"Menu add",
value: menuAddCount,
identifier: "menu-add-count"
)
verificationRow(
"Menu archive",
value: menuArchiveCount,
identifier: "menu-archive-count"
)
verificationRow(
"Detail opened / closed",
textValue: "\(detailOpenCount) / \(detailCloseCount)",
identifier: "detail-navigation-count"
)
}
} label: {
Label("Navigation and menu results", systemImage: "menubar.rectangle")
.font(.headline)
}
.accessibilityIdentifier("navigation-results-group")
Button {
detailOpenCount += 1
isShowingDetail = true
} label: {
Label("Open detail screen (\(detailOpenCount))", systemImage: "chevron.forward.circle.fill")
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.tint(.indigo)
.accessibilityIdentifier("open-detail-button")
.accessibilityValue("Opened \(detailOpenCount) times")
}
.padding()
}
.accessibilityIdentifier("controls-scroll-view")
.navigationTitle("Tap Lab")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button {
toolbarRefreshCount += 1
record("Navigation refresh tapped")
} label: {
Label("Refresh", systemImage: "arrow.clockwise")
}
.accessibilityIdentifier("nav-refresh-button")
.accessibilityValue("\(toolbarRefreshCount) refreshes")
}
ToolbarItem(placement: .navigationBarTrailing) {
Menu {
Button {
menuAddCount += 1
record("Add menu item selected")
} label: {
Label("Add item (\(menuAddCount))", systemImage: "plus")
}
.accessibilityIdentifier("menu-add-item")
Button {
menuArchiveCount += 1
record("Archive menu item selected")
} label: {
Label("Archive item (\(menuArchiveCount))", systemImage: "archivebox")
}
.accessibilityIdentifier("menu-archive-item")
} label: {
Image(systemName: "ellipsis.circle")
.accessibilityLabel("Harness actions menu")
}
.accessibilityIdentifier("toolbar-actions-menu")
.accessibilityValue("Add \(menuAddCount), archive \(menuArchiveCount)")
}
}
.navigationDestination(isPresented: $isShowingDetail) {
VStack(spacing: 18) {
verificationPanel
Image(systemName: "rectangle.stack.badge.play.fill")
.font(.system(size: 46))
.foregroundColor(.indigo)
.accessibilityIdentifier("detail-screen-artwork")
Text("Navigation destination")
.font(.title2.bold())
.accessibilityIdentifier("detail-screen-title")
Button {
detailButtonCount += 1
record("Detail button tapped")
} label: {
Label("Detail tap (\(detailButtonCount))", systemImage: "hand.tap")
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.accessibilityIdentifier("detail-action-button")
.accessibilityValue("\(detailButtonCount) taps")
Text("Detail count: \(detailButtonCount)")
.font(.headline.monospacedDigit())
.accessibilityIdentifier("detail-action-count")
}
.padding()
.navigationTitle("Detail")
.navigationBarTitleDisplayMode(.inline)
.navigationBarBackButtonHidden(true)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button {
isShowingDetail = false
} label: {
Label("Back to Tap Lab", systemImage: "chevron.backward")
}
.accessibilityIdentifier("detail-back-button")
}
}
}
.onChange(of: isShowingDetail) { isShowing in
if isShowing {
record("Opened detail screen")
} else {
detailCloseCount += 1
record("Closed detail screen")
}
}
}
.accessibilityIdentifier("controls-navigation-stack")
}
private var inputsTab: some View {
NavigationStack {
ScrollView {
VStack(spacing: 14) {
verificationPanel
GroupBox {
VStack(spacing: 14) {
Toggle(isOn: Binding(
get: { toggleValue },
set: { newValue in
toggleValue = newValue
toggleChangeCount += 1
record("Toggle changed to \(newValue ? "on" : "off")")
}
)) {
Label("Harness toggle", systemImage: toggleValue ? "checkmark.circle.fill" : "circle")
}
.accessibilityIdentifier("harness-toggle")
.accessibilityValue(toggleValue ? "On" : "Off")
verificationRow(
"Toggle changes",
value: toggleChangeCount,
identifier: "toggle-change-count"
)
Divider()
Stepper(
value: Binding(
get: { stepperValue },
set: { newValue in
let direction = newValue > stepperValue ? "up" : "down"
stepperValue = newValue
stepperChangeCount += 1
record("Stepper moved \(direction) to \(newValue)")
}
),
in: 0...9
) {
Label("Stepper value: \(stepperValue)", systemImage: "plusminus.circle")
.monospacedDigit()
}
.accessibilityIdentifier("harness-stepper")
.accessibilityValue("Value \(stepperValue), changed \(stepperChangeCount) times")
verificationRow(
"Stepper changes",
value: stepperChangeCount,
identifier: "stepper-change-count"
)
verificationRow(
"Stepper value",
value: stepperValue,
identifier: "stepper-value"
)
}
} label: {
Label("Toggle and stepper", systemImage: "switch.2")
.font(.headline)
}
.accessibilityIdentifier("toggle-stepper-group")
GroupBox {
VStack(alignment: .leading, spacing: 12) {
Picker("Harness mode", selection: Binding(
get: { selectedMode },
set: { newValue in
selectedMode = newValue
pickerChangeCount += 1
record("Segment selected: \(newValue)")
}
)) {
Text("One").tag("One")
Text("Two").tag("Two")
Text("Three").tag("Three")
}
.pickerStyle(.segmented)
.accessibilityIdentifier("harness-segmented-picker")
.accessibilityValue("Selected \(selectedMode)")
verificationRow(
"Selected segment",
textValue: selectedMode,
identifier: "picker-selection-value"
)
verificationRow(
"Segment changes",
value: pickerChangeCount,
identifier: "picker-change-count"
)
}
} label: {
Label("Segmented picker", systemImage: "rectangle.split.3x1")
.font(.headline)
}
.accessibilityIdentifier("picker-group")
GroupBox {
VStack(alignment: .leading, spacing: 10) {
TextField("Type a device message", text: $draftText)
.focused($isTextFieldFocused)
.textFieldStyle(.roundedBorder)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.submitLabel(.done)
.accessibilityIdentifier("harness-text-field")
.accessibilityValue(draftText)
.onSubmit {
commitText(source: "keyboard submit")
}
.onChange(of: draftText) { newValue in
textChangeCount += 1
record("Text changed to \(newValue.isEmpty ? "empty" : newValue)")
}
HStack {
Text("Echo: \(draftText.isEmpty ? "<empty>" : draftText)")
.font(.subheadline.monospaced())
.lineLimit(1)
.accessibilityIdentifier("text-echo-value")
.accessibilityValue(draftText)
Spacer()
Button("Commit") {
commitText(source: "commit button")
}
.buttonStyle(.bordered)
.accessibilityIdentifier("commit-text-button")
.accessibilityValue("Committed \(textCommitCount) times")
}
verificationRow(
"Text changes",
value: textChangeCount,
identifier: "text-change-count"
)
verificationRow(
"Text commits",
value: textCommitCount,
identifier: "text-commit-count"
)
}
} label: {
Label("Text entry", systemImage: "keyboard")
.font(.headline)
}
.accessibilityIdentifier("text-entry-group")
#if canImport(UIKit)
GroupBox {
VStack(spacing: 10) {
UIKitHarnessButton(count: uikitButtonCount) {
uikitButtonCount += 1
record("UIKit UIButton tapped")
}
.frame(maxWidth: .infinity, minHeight: 48)
verificationRow(
"UIKit taps",
value: uikitButtonCount,
identifier: "uikit-button-count"
)
}
} label: {
Label("Native UIKit control", systemImage: "iphone")
.font(.headline)
}
.accessibilityIdentifier("uikit-control-group")
#endif
}
.padding()
}
.accessibilityIdentifier("inputs-scroll-view")
.navigationTitle("Input Lab")
.navigationBarTitleDisplayMode(.inline)
}
.accessibilityIdentifier("inputs-navigation-stack")
}
private var rowsTab: some View {
NavigationStack {
List {
Section {
verificationPanel
.listRowInsets(EdgeInsets())
.listRowBackground(Color.clear)
}
Section("Tap a row") {
ForEach(Self.harnessRows) { row in
Button {
let count = rowTapCounts[row.id, default: 0] + 1
rowTapCounts[row.id] = count
record("\(row.title) tapped")
} label: {
HStack(spacing: 12) {
Image(systemName: row.symbol)
.foregroundColor(.accentColor)
Text(row.title)
Spacer()
Text("\(rowTapCounts[row.id, default: 0])")
.font(.headline.monospacedDigit())
.foregroundColor(.secondary)
.accessibilityIdentifier("row-\(row.id)-count")
}
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.accessibilityIdentifier("row-\(row.id)-button")
.accessibilityValue("\(rowTapCounts[row.id, default: 0]) taps")
.swipeActions(edge: .leading, allowsFullSwipe: false) {
Button {
rowFlagCount += 1
record("Flagged \(row.title)")
} label: {
Label("Flag", systemImage: "flag.fill")
}
.tint(.orange)
.accessibilityIdentifier("row-\(row.id)-flag-action")
}
.swipeActions(edge: .trailing, allowsFullSwipe: false) {
Button {
rowArchiveCount += 1
record("Archived \(row.title)")
} label: {
Label("Archive", systemImage: "archivebox.fill")
}
.tint(.indigo)
.accessibilityIdentifier("row-\(row.id)-archive-action")
}
}
}
Section("Row action results") {
verificationRow(
"Flags",
value: rowFlagCount,
identifier: "row-flag-count"
)
verificationRow(
"Archives",
value: rowArchiveCount,
identifier: "row-archive-count"
)
verificationRow(
"Toolbar checks",
value: rowToolbarCount,
identifier: "row-toolbar-count"
)
}
}
.listStyle(.insetGrouped)
.accessibilityIdentifier("rows-list")
.navigationTitle("Row Lab")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button {
rowToolbarCount += 1
record("Rows toolbar check tapped")
} label: {
Label("Check rows", systemImage: "checkmark.circle")
}
.accessibilityIdentifier("rows-toolbar-button")
.accessibilityValue("\(rowToolbarCount) checks")
}
}
}
.accessibilityIdentifier("rows-navigation-stack")
}
private var verificationPanel: some View {
VerificationPanel(
lastAction: lastAction,
totalActions: totalActions,
selectedTab: selectedTab.title,
tabChangeCount: tabChangeCount
)
}
private func buttonLabel(_ title: String, count: Int, symbol: String) -> some View {
VStack(spacing: 4) {
Label(title, systemImage: symbol)
.lineLimit(1)
Text("Count \(count)")
.font(.caption.monospacedDigit())
.accessibilityIdentifier("\(title.lowercased())-button-count")
}
.frame(maxWidth: .infinity, minHeight: 40)
}
private func verificationRow(_ label: String, value: Int, identifier: String) -> some View {
verificationRow(label, textValue: String(value), identifier: identifier)
}
private func verificationRow(_ label: String, textValue: String, identifier: String) -> some View {
HStack {
Text(label)
.foregroundColor(.secondary)
Spacer()
Text(textValue)
.font(.subheadline.bold().monospacedDigit())
.accessibilityIdentifier(identifier)
.accessibilityLabel(label)
.accessibilityValue(textValue)
}
}
private func commitText(source: String) {
textCommitCount += 1
isTextFieldFocused = false
record("Text committed from \(source): \(draftText.isEmpty ? "empty" : draftText)")
}
private func record(_ action: String) {
totalActions += 1
lastAction = action
} }
} }
private struct VerificationPanel: View {
let lastAction: String
let totalActions: Int
let selectedTab: String
let tabChangeCount: Int
var body: some View {
VStack(alignment: .leading, spacing: 8) {
HStack {
Label("LIVE", systemImage: "waveform.path.ecg")
.font(.caption.bold())
.foregroundColor(.green)
.accessibilityIdentifier("harness-live-indicator")
Spacer()
Text("Total \(totalActions)")
.font(.caption.bold().monospacedDigit())
.accessibilityIdentifier("total-action-count")
.accessibilityLabel("Total actions")
.accessibilityValue("\(totalActions)")
}
Text(lastAction)
.font(.subheadline.weight(.semibold))
.lineLimit(2)
.accessibilityIdentifier("last-action-status")
.accessibilityLabel("Last action")
.accessibilityValue(lastAction)
HStack {
Text("Tab: \(selectedTab)")
.accessibilityIdentifier("selected-tab-status")
.accessibilityValue(selectedTab)
Spacer()
Text("Tab changes: \(tabChangeCount)")
.monospacedDigit()
.accessibilityIdentifier("tab-change-count")
.accessibilityValue("\(tabChangeCount)")
}
.font(.caption)
.foregroundColor(.secondary)
}
.padding(12)
.background(Color.green.opacity(0.10))
.overlay {
RoundedRectangle(cornerRadius: 12)
.stroke(Color.green.opacity(0.35), lineWidth: 1)
}
.clipShape(RoundedRectangle(cornerRadius: 12))
.accessibilityElement(children: .contain)
.accessibilityIdentifier("verification-panel")
}
}
#if canImport(UIKit)
private struct UIKitHarnessButton: UIViewRepresentable {
let count: Int
let action: () -> Void
final class Coordinator: NSObject {
var action: () -> Void
init(action: @escaping () -> Void) {
self.action = action
}
@objc func tapped() {
action()
}
}
func makeCoordinator() -> Coordinator {
Coordinator(action: action)
}
func makeUIView(context: Context) -> UIButton {
let button = UIButton(type: .system)
var configuration = UIButton.Configuration.filled()
configuration.cornerStyle = .medium
configuration.image = UIImage(systemName: "hand.tap.fill")
configuration.imagePadding = 8
button.configuration = configuration
button.accessibilityIdentifier = "uikit-button"
button.accessibilityLabel = "UIKit button"
button.addTarget(context.coordinator, action: #selector(Coordinator.tapped), for: .touchUpInside)
return button
}
func updateUIView(_ button: UIButton, context: Context) {
context.coordinator.action = action
var configuration = button.configuration ?? UIButton.Configuration.filled()
configuration.title = "UIKit Tap (\(count))"
button.configuration = configuration
button.accessibilityValue = "\(count) taps"
}
}
#endif
@@ -1,32 +1,22 @@
// Canonical app state for the fixture. Every snapshot-eligible field is // Canonical observable app state for the fixture. Snapshot eligibility is a
// marked with the @Snapshotable property wrapper that the codegen tool // generator-only source marker, not a property wrapper, so it composes with
// detects via attribute scan. // Observation's @Observable macro.
//
// Note: we DON'T use @Observable here because the macro expansion converts
// stored properties into computed ones, which the @Snapshotable wrapper
// can't apply to. In production apps that need both observability AND
// snapshotting, the right pattern is:
// - Use ObservableObject + @Published (older API), or
// - Hold all @Snapshotable state in a nested struct + replace it
// wholesale on restore so SwiftUI sees a single change notification
// (the canonical-state-struct atomicity strategy from the plan).
import Foundation import Foundation
import Observation
public final class FixtureAppState { @Observable
@Snapshotable public var isLoggedIn: Bool = false final class FixtureAppState {
@Snapshotable public var username: String = "" // @Snapshotable
@Snapshotable public var tapCounter: Int = 0 var isLoggedIn: Bool = false
// @Snapshotable
var username: String = ""
// @Snapshotable
var tapCounter: Int = 0
// @Snapshotable
var nickname: String? = nil
/// Not snapshotted ephemeral cache that should never leak via /state/snapshot. /// Not snapshotted ephemeral cache that should never leak via /state/snapshot.
public var ephemeralCache: [String: String] = [:] var ephemeralCache: [String: String] = [:]
public init() {} init() {}
}
/// Property wrapper marker for snapshot-eligible state. The actual wrapper
/// is a no-op at runtime; codegen-tool detection happens via attribute scan.
@propertyWrapper
public struct Snapshotable<Value> {
public var wrappedValue: Value
public init(wrappedValue: Value) { self.wrappedValue = wrappedValue }
} }
+3 -3
View File
@@ -1,7 +1,7 @@
name: FixtureApp name: FixtureApp
options: options:
deploymentTarget: deploymentTarget:
iOS: "16.0" iOS: "17.0"
bundleIdPrefix: com.gstack.iosqa bundleIdPrefix: com.gstack.iosqa
developmentLanguage: en developmentLanguage: en
createIntermediateGroups: true createIntermediateGroups: true
@@ -23,7 +23,7 @@ targets:
FixtureApp: FixtureApp:
type: application type: application
platform: iOS platform: iOS
deploymentTarget: "16.0" deploymentTarget: "17.0"
sources: sources:
- path: Sources/FixtureApp - path: Sources/FixtureApp
dependencies: dependencies:
@@ -45,5 +45,5 @@ targets:
CODE_SIGN_STYLE: Automatic CODE_SIGN_STYLE: Automatic
TARGETED_DEVICE_FAMILY: "1" TARGETED_DEVICE_FAMILY: "1"
SWIFT_VERSION: "5.9" SWIFT_VERSION: "5.9"
IPHONEOS_DEPLOYMENT_TARGET: "16.0" IPHONEOS_DEPLOYMENT_TARGET: "17.0"
ENABLE_PREVIEWS: YES ENABLE_PREVIEWS: YES
+80
View File
@@ -67,6 +67,30 @@ describe("canonicalizeRemote", () => {
it("collapses redundant slashes", () => { it("collapses redundant slashes", () => {
expect(canonicalizeRemote("https://github.com//foo//bar")).toBe("github.com/foo/bar"); expect(canonicalizeRemote("https://github.com//foo//bar")).toBe("github.com/foo/bar");
}); });
it("strips .git even when the URL has a trailing slash", () => {
// A remote configured with both a .git suffix and a trailing slash must
// canonicalize to the same key as one without — otherwise the same repo
// gets two dedup/source-id keys across machines.
expect(canonicalizeRemote("https://github.com/garrytan/gstack.git/")).toBe("github.com/garrytan/gstack");
expect(canonicalizeRemote("git@github.com:garrytan/gstack.git/")).toBe("github.com/garrytan/gstack");
expect(canonicalizeRemote("https://github.com/foo/bar.git///")).toBe("github.com/foo/bar");
});
it("produces the same key with or without a trailing slash", () => {
expect(canonicalizeRemote("https://github.com/garrytan/gstack.git/")).toBe(
canonicalizeRemote("https://github.com/garrytan/gstack.git")
);
});
it("canonicalizes a path remote ending in a .git directory component", () => {
// Stripping the `.git` suffix exposes a new trailing slash
// ("/repo/.git" → "/repo/") which must also be stripped, or the same
// repo splits into two identities.
expect(canonicalizeRemote("file:///Users/x/repo/.git")).toBe(
canonicalizeRemote("file:///Users/x/repo")
);
});
}); });
// ── secretScanFile ───────────────────────────────────────────────────────── // ── secretScanFile ─────────────────────────────────────────────────────────
@@ -244,6 +268,62 @@ body
expect(m!.context_queries[0].id).toBe("complete"); expect(m!.context_queries[0].id).toBe("complete");
rmSync(dir, { recursive: true, force: true }); rmSync(dir, { recursive: true, force: true });
}); });
it("parses a nested filter: block on a list query", () => {
const dir = mkdtempSync(join(tmpdir(), "gstack-test-"));
const file = join(dir, "filtered.md");
writeFileSync(
file,
`---
name: investigate
gbrain:
schema: 1
context_queries:
- id: prior-investigations
kind: list
filter:
type: timeline
tags_contains: "repo:{repo_slug}"
content_contains: "investigate"
sort: updated_at_desc
limit: 5
render_as: "## Prior investigations in this repo"
- id: recent-no-filter
kind: list
sort: created_at_desc
limit: 3
render_as: "## Recent (no filter)"
---
body
`
);
const m = parseSkillManifest(file);
expect(m).not.toBeNull();
expect(m!.context_queries).toHaveLength(2);
// The filter: sub-block is parsed into a key/value map, with quotes
// stripped and template vars left intact for downstream substitution.
const filtered = m!.context_queries[0];
expect(filtered.id).toBe("prior-investigations");
expect(filtered.filter).toEqual({
type: "timeline",
tags_contains: "repo:{repo_slug}",
content_contains: "investigate",
});
// Sibling fields on the same item still parse alongside the filter.
expect(filtered.sort).toBe("updated_at_desc");
expect(filtered.limit).toBe(5);
expect(filtered.render_as).toBe("## Prior investigations in this repo");
// A list query with no filter: leaves filter undefined (no regression).
expect(m!.context_queries[1].id).toBe("recent-no-filter");
expect(m!.context_queries[1].filter).toBeUndefined();
expect(m!.context_queries[1].sort).toBe("created_at_desc");
rmSync(dir, { recursive: true, force: true });
});
}); });
// ── withErrorContext ─────────────────────────────────────────────────────── // ── withErrorContext ───────────────────────────────────────────────────────
+16 -16
View File
@@ -173,13 +173,9 @@ export async function runSkillTest(options: {
// restores operator MCP along with the operator env. // restores operator MCP along with the operator env.
if (isHermeticEnabled()) args.push('--strict-mcp-config'); if (isHermeticEnabled()) args.push('--strict-mcp-config');
// Write prompt to a temp file OUTSIDE workingDirectory to avoid race conditions // Spawn claude directly with array-form args (no shell interpolation).
// where afterAll cleanup deletes the dir before cat reads the file (especially // Prompt is piped via stdin using a Blob to avoid temp files and shell escaping.
// with --concurrent --retry). Using os.tmpdir() + unique suffix keeps it stable. const proc = Bun.spawn(['claude', ...args], {
const promptFile = path.join(os.tmpdir(), `.prompt-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}`);
fs.writeFileSync(promptFile, prompt);
const proc = Bun.spawn(['sh', '-c', `cat "${promptFile}" | claude ${args.map(a => `"${a}"`).join(' ')}`], {
cwd: workingDirectory, cwd: workingDirectory,
// Hermetic by default (see test/helpers/hermetic-env.ts): operator // Hermetic by default (see test/helpers/hermetic-env.ts): operator
// session context (CONDUCTOR_*, CLAUDECODE, ~/.claude config, ~/.gstack) // session context (CONDUCTOR_*, CLAUDECODE, ~/.claude config, ~/.gstack)
@@ -189,6 +185,7 @@ export async function runSkillTest(options: {
// suite exercising the INTERACTIVE prose-fallback path opts out by passing // suite exercising the INTERACTIVE prose-fallback path opts out by passing
// `env: { GSTACK_HEADLESS: '' }` — extraEnv wins because it spreads last. // `env: { GSTACK_HEADLESS: '' }` — extraEnv wins because it spreads last.
env: hermeticChildEnv({ GSTACK_HEADLESS: '1', ...extraEnv }), env: hermeticChildEnv({ GSTACK_HEADLESS: '1', ...extraEnv }),
stdin: new Blob([prompt]),
stdout: 'pipe', stdout: 'pipe',
stderr: 'pipe', stderr: 'pipe',
}); });
@@ -201,11 +198,12 @@ export async function runSkillTest(options: {
const timeoutId = setTimeout(() => { const timeoutId = setTimeout(() => {
timedOut = true; timedOut = true;
proc.kill(); proc.kill();
// proc.kill() only signals the `sh -c` wrapper. The claude child it // proc.kill() signals claude itself (direct spawn, no shell wrapper),
// spawned can survive as an orphan that inherited our stdout/stderr // but tool subprocesses claude spawned can survive as orphans that
// pipes, so without cancel() the read loop below blocks until the // inherited our stdout/stderr pipes, so without cancel() the read loop
// orphan finally exits (observed: a 600s timeout stretching past 1400s // below blocks until the orphan finally exits (observed: a 600s timeout
// and tripping bun's per-test timeout instead of returning a result). // stretching past 1400s and tripping bun's per-test timeout instead of
// returning a result).
reader.cancel().catch(() => { /* stream already closed */ }); reader.cancel().catch(() => { /* stream already closed */ });
}, timeout); }, timeout);
@@ -233,6 +231,11 @@ export async function runSkillTest(options: {
if (!line.trim()) continue; if (!line.trim()) continue;
collectedLines.push(line); collectedLines.push(line);
// Track time to first NDJSON line (measures latency from spawn to first Claude response)
if (firstResponseMs === 0) {
firstResponseMs = Date.now() - startTime;
}
// Real-time progress to stderr + persistent logs // Real-time progress to stderr + persistent logs
try { try {
const event = JSON.parse(line); const event = JSON.parse(line);
@@ -244,8 +247,7 @@ export async function runSkillTest(options: {
liveToolCount++; liveToolCount++;
const now = Date.now(); const now = Date.now();
const elapsed = Math.round((now - startTime) / 1000); const elapsed = Math.round((now - startTime) / 1000);
// Track timing telemetry // Track inter-turn latency (tool call to tool call)
if (firstResponseMs === 0) firstResponseMs = now - startTime;
if (lastToolTime > 0) { if (lastToolTime > 0) {
const interTurn = now - lastToolTime; const interTurn = now - lastToolTime;
if (interTurn > maxInterTurnMs) maxInterTurnMs = interTurn; if (interTurn > maxInterTurnMs) maxInterTurnMs = interTurn;
@@ -310,8 +312,6 @@ export async function runSkillTest(options: {
const exitCode = await proc.exited; const exitCode = await proc.exited;
clearTimeout(timeoutId); clearTimeout(timeoutId);
try { fs.unlinkSync(promptFile); } catch { /* non-fatal */ }
if (timedOut) { if (timedOut) {
exitReason = 'timeout'; exitReason = 'timeout';
} else if (exitCode === 0) { } else if (exitCode === 0) {
+15
View File
@@ -96,6 +96,21 @@ describe('check-careful.sh', () => {
expect(output.permissionDecision).toBe('ask'); expect(output.permissionDecision).toBe('ask');
expect(output.message).toContain('recursive delete'); expect(output.message).toContain('recursive delete');
}); });
test.each([
'rm -rf /; rm -rf node_modules',
'rm -rf / && rm -rf node_modules',
'rm -rf / # rm -rf node_modules',
'rm -rf node_modules; rm -rf /',
'rm -rf node_modules || rm -rf /',
'echo ok && rm -rf /',
'rm -rf node_modules\nrm -rf /',
])('never lets a safe-looking target hide a destructive command: %s', (command) => {
const { exitCode, output } = runHook(CAREFUL_SCRIPT, carefulInput(command));
expect(exitCode).toBe(0);
expect(output.permissionDecision).toBe('ask');
expect(output.message).toContain('recursive delete');
});
}); });
// --- SQL destructive commands --- // --- SQL destructive commands ---
+28 -1
View File
@@ -24,8 +24,10 @@ import {
openclaw, openclaw,
} from '../hosts/index'; } from '../hosts/index';
import { HOST_PATHS } from '../scripts/resolvers/types'; import { HOST_PATHS } from '../scripts/resolvers/types';
import { RESOLVERS } from '../scripts/resolvers';
const ROOT = path.resolve(import.meta.dir, '..'); const ROOT = path.resolve(import.meta.dir, '..');
const RESOLVER_NAMES = new Set(Object.keys(RESOLVERS));
// ─── hosts/index.ts ───────────────────────────────────────── // ─── hosts/index.ts ─────────────────────────────────────────
@@ -205,13 +207,32 @@ describe('validateHostConfig', () => {
c.cliCommand = 'opencode;rm -rf /'; c.cliCommand = 'opencode;rm -rf /';
expect(validateHostConfig(c).some(e => e.includes('cliCommand'))).toBe(true); expect(validateHostConfig(c).some(e => e.includes('cliCommand'))).toBe(true);
}); });
test('valid suppressedResolvers pass when resolver names provided', () => {
const c = makeValid();
c.suppressedResolvers = ['DESIGN_OUTSIDE_VOICES', 'REVIEW_ARMY'];
expect(validateHostConfig(c, RESOLVER_NAMES)).toEqual([]);
});
test('unknown suppressedResolvers entry is caught', () => {
const c = makeValid();
c.suppressedResolvers = ['DESIGN_OUTSIDE_VOICES', 'NONEXISTENT_RESOLVER'];
const errors = validateHostConfig(c, RESOLVER_NAMES);
expect(errors.some(e => e.includes('NONEXISTENT_RESOLVER'))).toBe(true);
});
test('suppressedResolvers unchecked when resolver names omitted', () => {
const c = makeValid();
c.suppressedResolvers = ['TYPO_RESOLVER'];
expect(validateHostConfig(c)).toEqual([]);
});
}); });
// ─── validateAllConfigs ───────────────────────────────────── // ─── validateAllConfigs ─────────────────────────────────────
describe('validateAllConfigs', () => { describe('validateAllConfigs', () => {
test('real configs all pass validation', () => { test('real configs all pass validation', () => {
const errors = validateAllConfigs(ALL_HOST_CONFIGS); const errors = validateAllConfigs(ALL_HOST_CONFIGS, RESOLVER_NAMES);
expect(errors).toEqual([]); expect(errors).toEqual([]);
}); });
@@ -233,6 +254,12 @@ describe('validateAllConfigs', () => {
expect(errors.some(e => e.includes('Duplicate globalRoot'))).toBe(true); expect(errors.some(e => e.includes('Duplicate globalRoot'))).toBe(true);
}); });
test('unknown suppressedResolvers entry surfaces with host-name prefix', () => {
const bad = { ...codex, name: 'bad-host', hostSubdir: '.bad', globalRoot: '.bad/skills/gstack', suppressedResolvers: ['BOGUS_RESOLVER'] } as HostConfig;
const errors = validateAllConfigs([bad], RESOLVER_NAMES);
expect(errors.some(e => e.startsWith('[bad-host]') && e.includes('BOGUS_RESOLVER'))).toBe(true);
});
test('per-config validation errors are prefixed with host name', () => { test('per-config validation errors are prefixed with host name', () => {
const bad = { ...codex, name: 'BAD', cliCommand: 'also bad' } as HostConfig; const bad = { ...codex, name: 'BAD', cliCommand: 'also bad' } as HostConfig;
const errors = validateAllConfigs([bad]); const errors = validateAllConfigs([bad]);
+258
View File
@@ -0,0 +1,258 @@
import { afterEach, describe, expect, test } from 'bun:test';
import { createHash } from 'crypto';
import { spawnSync } from 'child_process';
import {
chmodSync,
copyFileSync,
existsSync,
mkdirSync,
mkdtempSync,
readFileSync,
readdirSync,
rmSync,
statSync,
writeFileSync,
} from 'fs';
import { tmpdir } from 'os';
import { join, relative } from 'path';
const ROOT = join(import.meta.dir, '..');
const SAFE_TEMPLATE_MAP = [
['Package.swift.template', 'Package.swift'],
['StateServer.swift.template', 'Sources/DebugBridgeCore/StateServer.swift'],
['DebugBridgeManager.swift.template', 'Sources/DebugBridgeCore/DebugBridgeManager.swift'],
['Bridges.swift.template', 'Sources/DebugBridgeUI/Bridges.swift'],
['DebugOverlay.swift.template', 'Sources/DebugBridgeUI/DebugOverlay.swift'],
['DebugBridgeTouch.m.template', 'Sources/DebugBridgeTouch/DebugBridgeTouch.m'],
['DebugBridgeTouch.h.template', 'Sources/DebugBridgeTouch/include/DebugBridgeTouch.h'],
] as const;
const workDirs: string[] = [];
afterEach(() => {
for (const dir of workDirs.splice(0)) {
rmSync(dir, { recursive: true, force: true });
}
});
function copyIntoFakeInstall(workDir: string): { root: string; launcher: string } {
const root = join(workDir, 'fake gstack install');
const binDir = join(root, 'bin');
const scriptsDir = join(root, 'ios-qa', 'scripts');
const templatesDir = join(root, 'ios-qa', 'templates');
mkdirSync(binDir, { recursive: true });
mkdirSync(scriptsDir, { recursive: true });
mkdirSync(templatesDir, { recursive: true });
const launcher = join(binDir, 'gstack-ios-qa-regen');
copyFileSync(join(ROOT, 'bin', 'gstack-ios-qa-regen'), launcher);
chmodSync(launcher, 0o755);
copyFileSync(join(ROOT, 'ios-qa', 'scripts', 'gen-accessors.ts'), join(scriptsDir, 'gen-accessors.ts'));
for (const [template] of SAFE_TEMPLATE_MAP) {
copyFileSync(join(ROOT, 'ios-qa', 'templates', template), join(templatesDir, template));
}
// These files deliberately exist in the discovered template directory. If
// the launcher ever regresses to wildcard copying, their sentinel content
// will escape into the app and fail the absence assertions below.
writeFileSync(join(templatesDir, 'DebugBridgeWiring.swift.template'), '// FORBIDDEN-WIRING-SENTINEL\n');
writeFileSync(join(templatesDir, 'StateAccessor.swift.template'), '// FORBIDDEN-STATE-SENTINEL\n');
writeFileSync(join(root, 'VERSION'), '9.8.7.6\n');
return { root, launcher };
}
function treeHash(...roots: string[]): string {
const hash = createHash('sha256');
for (const root of roots) {
const visit = (dir: string): void => {
for (const name of readdirSync(dir).sort()) {
const path = join(dir, name);
const stat = statSync(path);
if (stat.isDirectory()) {
visit(path);
} else {
hash.update(relative(root, path));
hash.update('\0');
hash.update(readFileSync(path));
hash.update('\0');
}
}
};
visit(root);
}
return hash.digest('hex');
}
function allFileContents(root: string): string {
let contents = '';
const visit = (dir: string): void => {
for (const name of readdirSync(dir)) {
const path = join(dir, name);
if (statSync(path).isDirectory()) visit(path);
else contents += readFileSync(path, 'utf8');
}
};
visit(root);
return contents;
}
describe('gstack-ios-qa-regen', () => {
test('repository launcher is executable', () => {
expect(statSync(join(ROOT, 'bin', 'gstack-ios-qa-regen')).mode & 0o111).not.toBe(0);
});
test('requires the documented app-source and bridge-dir contract', () => {
const workDir = mkdtempSync(join(tmpdir(), 'ios-qa-regen-'));
workDirs.push(workDir);
const { launcher } = copyIntoFakeInstall(workDir);
const result = spawnSync('bash', [launcher, '--app-source', workDir], { encoding: 'utf8' });
expect(result.status).toBe(2);
expect(result.stderr).toContain('both --app-source and --bridge-dir are required');
});
test('leaves no completion marker when accessor generation fails', () => {
const workDir = mkdtempSync(join(tmpdir(), 'ios-qa-regen-'));
workDirs.push(workDir);
const { launcher } = copyIntoFakeInstall(workDir);
const appSource = join(workDir, 'app-source');
const bridgeDir = join(workDir, 'bridge');
const generatedDir = join(appSource, 'DebugBridgeGenerated');
const fakeBin = join(workDir, 'fake-bin');
mkdirSync(generatedDir, { recursive: true });
mkdirSync(fakeBin, { recursive: true });
writeFileSync(join(appSource, 'AppState.swift'), '@Observable final class AppState {}\n');
writeFileSync(join(generatedDir, '.gstack-version'), 'stale-complete-marker\n');
const fakeBun = join(fakeBin, 'bun');
writeFileSync(fakeBun, '#!/bin/sh\nexit 17\n');
chmodSync(fakeBun, 0o755);
const result = spawnSync('bash', [
launcher,
'--app-source', appSource,
'--bridge-dir', bridgeDir,
], {
encoding: 'utf8',
env: { ...process.env, PATH: `${fakeBin}:${process.env.PATH ?? ''}` },
});
expect(result.status).toBe(17);
expect(existsSync(join(generatedDir, '.gstack-version'))).toBe(false);
});
test('regenerates the allowlisted package and accessors idempotently', () => {
const workDir = mkdtempSync(join(tmpdir(), 'ios-qa-regen-'));
workDirs.push(workDir);
const { root, launcher } = copyIntoFakeInstall(workDir);
const appSource = join(workDir, 'app source');
const bridgeDir = join(appSource, 'DebugBridge');
const generatedDir = join(appSource, 'DebugBridgeGenerated');
const cacheRoot = join(workDir, 'isolated cache');
mkdirSync(appSource, { recursive: true });
writeFileSync(join(appSource, 'AppState.swift'), `
@Observable
final class AppState {
// @Snapshotable
var counter: Int = 0
}
`);
// Seed the flat legacy layout that old ios-sync versions produced. A
// correct regeneration must remove these known generated artifacts rather
// than letting Xcode compile a second, stale harness implementation.
mkdirSync(generatedDir, { recursive: true });
for (const obsolete of [
join(bridgeDir, 'DebugBridgeWiring.swift'),
join(bridgeDir, 'StateAccessor.swift'),
join(generatedDir, 'Package.swift'),
join(generatedDir, 'StateServer.swift'),
join(generatedDir, 'DebugBridgeManager.swift'),
join(generatedDir, 'Bridges.swift'),
join(generatedDir, 'DebugOverlay.swift'),
join(generatedDir, 'DebugBridgeTouch.m'),
join(generatedDir, 'DebugBridgeTouch.h'),
join(generatedDir, 'DebugBridgeWiring.swift'),
]) {
mkdirSync(join(obsolete, '..'), { recursive: true });
writeFileSync(obsolete, '// OBSOLETE-HARNESS-SENTINEL\n');
}
const env = {
...process.env,
GSTACK_IOS_CACHE_ROOT: cacheRoot,
SWIFT_VERSION: '6.3.3',
GEN_ACCESSORS_REV: 'regen-test',
};
const args = [launcher, '--app-source', appSource, '--bridge-dir', bridgeDir];
const first = spawnSync('bash', args, { encoding: 'utf8', env });
expect(first.status).toBe(0);
expect(first.stderr).toBe('');
// Every installed package file must be byte-identical to its explicit
// source template: this is the durable template/output parity contract.
for (const [template, destination] of SAFE_TEMPLATE_MAP) {
expect(readFileSync(join(bridgeDir, destination))).toEqual(
readFileSync(join(root, 'ios-qa', 'templates', template)),
);
}
const accessorPath = join(generatedDir, 'StateAccessor.swift');
const accessor = readFileSync(accessorPath, 'utf8');
expect(accessor).toContain('import DebugBridgeCore');
expect(accessor).toContain('enum AppStateAccessor');
expect(accessor).not.toContain('public enum AppStateAccessor');
expect(accessor).not.toContain('import DebugBridge\n');
expect(accessor).toContain('guard let restored0 = Self.decodeSnapshotValue(raw0, as: Int.self)');
expect(accessor).toContain('atomicRestore: { keys, apply in');
expect(accessor).toContain('state.counter = restored0');
expect(accessor).toContain('state.counter = typed');
expect(accessor).toContain('return true');
expect(accessor).not.toContain('atomicRestore: { _ in .ok }');
expect(accessor).not.toContain('write: { _ in false }');
expect(readFileSync(join(generatedDir, '.gstack-version'), 'utf8')).toBe(
readFileSync(join(root, 'VERSION'), 'utf8'),
);
expect(existsSync(join(bridgeDir, 'DebugBridgeWiring.swift'))).toBe(false);
expect(existsSync(join(bridgeDir, 'StateAccessor.swift'))).toBe(false);
for (const obsoleteName of [
'Package.swift',
'StateServer.swift',
'DebugBridgeManager.swift',
'Bridges.swift',
'DebugOverlay.swift',
'DebugBridgeTouch.m',
'DebugBridgeTouch.h',
'DebugBridgeWiring.swift',
]) {
expect(existsSync(join(generatedDir, obsoleteName))).toBe(false);
}
const installedContents = allFileContents(bridgeDir) + allFileContents(generatedDir);
expect(installedContents).not.toContain('FORBIDDEN-WIRING-SENTINEL');
expect(installedContents).not.toContain('FORBIDDEN-STATE-SENTINEL');
expect(installedContents).not.toContain('OBSOLETE-HARNESS-SENTINEL');
const swiftAvailable = spawnSync('swift', ['--version'], { encoding: 'utf8' }).status === 0;
if (swiftAvailable) {
const dump = spawnSync('swift', ['package', 'dump-package', '--package-path', bridgeDir], {
encoding: 'utf8',
});
expect(dump.status).toBe(0);
const manifest = JSON.parse(dump.stdout) as { targets: Array<{ name: string }> };
expect(manifest.targets.map(target => target.name).sort()).toEqual([
'DebugBridgeCore',
'DebugBridgeTouch',
'DebugBridgeUI',
]);
}
const firstHash = treeHash(bridgeDir, generatedDir);
const firstAccessorHash = accessor.match(/accessorHash: "([a-f0-9]+)"/)?.[1];
const second = spawnSync('bash', args, { encoding: 'utf8', env });
expect(second.status).toBe(0);
expect(second.stderr).toBe('');
expect(second.stdout).toContain('gen-accessors: cache hit');
expect(treeHash(bridgeDir, generatedDir)).toBe(firstHash);
expect(readFileSync(accessorPath, 'utf8').match(/accessorHash: "([a-f0-9]+)"/)?.[1]).toBe(firstAccessorHash);
});
});
@@ -0,0 +1,32 @@
import { describe, expect, test } from 'bun:test';
import { readFileSync } from 'fs';
import { join } from 'path';
const ROOT = join(import.meta.dir, '..');
const PRE_FIXTURE = join(ROOT, 'test/fixtures/ios-fix/ios-qa-swiftui-tap-pre.json');
const PRE_SCREENSHOT = join(ROOT, 'test/fixtures/ios-fix/ios-qa-swiftui-tap-pre.png');
describe('ios-fix regression fixture — SwiftUI taps reported success without acting', () => {
test('preserves the pre-fix state and physical-device screenshot', () => {
const state = JSON.parse(readFileSync(PRE_FIXTURE, 'utf8'));
expect(state).toEqual({
_schema_version: 1,
_app_build_id: 'uninitialized',
_accessor_hash: 'uninitialized',
keys: {},
});
const png = readFileSync(PRE_SCREENSHOT);
expect([...png.subarray(0, 8)]).toEqual([137, 80, 78, 71, 13, 10, 26, 10]);
expect(png.readUInt32BE(16)).toBe(1206);
expect(png.readUInt32BE(20)).toBe(2622);
});
test('keeps the physical-device deploy/tap test opt-in and executable', () => {
const deviceTest = readFileSync(join(ROOT, 'test/skill-e2e-ios-device.test.ts'), 'utf8');
expect(deviceTest).toContain("process.env.GSTACK_IOS_DEVICE_DEPLOY === '1'");
expect(deviceTest).toContain("'primary-button'");
expect(deviceTest).toContain("'/tap'");
expect(deviceTest).not.toContain("test.skip('TODO(deploy)");
});
});
+13
View File
@@ -29,4 +29,17 @@ describe("one-way-door credential keyword net (#1839)", () => {
expect(classifyQuestion({ summary: `rotate my ${noun}` }).oneWay).toBe(true); expect(classifyQuestion({ summary: `rotate my ${noun}` }).oneWay).toBe(true);
} }
}); });
// revoke/reset/rotate must all share the same credential noun list. Previously
// "secret" was only in rotate (missing from revoke and reset) and "access key"
// was missing from reset, so "revoke my secret" / "reset my secret" /
// "reset my access key" leaked through as two-way (auto-decidable).
test("revoke/reset/rotate are all parallel for every credential noun", () => {
for (const verb of ["revoke", "reset", "rotate"]) {
for (const noun of ["api key", "token", "secret", "credential", "access key", "password"]) {
const r = classifyQuestion({ summary: `${verb} my ${noun}` });
expect(r.oneWay, `${verb} my ${noun} should be one-way`).toBe(true);
}
}
});
}); });
+760 -49
View File
@@ -1,4 +1,8 @@
// GSTACK_HAS_IOS_DEVICE=1 device-path test. Runs only when: // Real-device tests. The lightweight CoreDevice checks run with
// GSTACK_HAS_IOS_DEVICE=1; the signing/install/interaction smoke test has the
// separate, explicit GSTACK_IOS_DEVICE_DEPLOY=1 opt-in.
//
// Runs only when:
// - An iPhone is connected via USB and reachable through CoreDevice // - An iPhone is connected via USB and reachable through CoreDevice
// - The iPhone is paired (user has tapped "Trust" on the trust dialog) // - The iPhone is paired (user has tapped "Trust" on the trust dialog)
// - Developer Mode is enabled on the iPhone (Settings → Privacy → Developer Mode) // - Developer Mode is enabled on the iPhone (Settings → Privacy → Developer Mode)
@@ -10,57 +14,91 @@
// 4. The fixture iOS SPM package builds with `swift build` for iOS target // 4. The fixture iOS SPM package builds with `swift build` for iOS target
// (verifies the templates compile against the iOS SDK, not just macOS) // (verifies the templates compile against the iOS SDK, not just macOS)
// //
// What it does NOT exercise (out of scope for this test): // GSTACK_IOS_DEVICE_DEPLOY=1 additionally generates the fixture Xcode project,
// - Building + signing a full iOS app via xcodebuild (requires provisioning // signs it with GSTACK_IOS_DEVELOPMENT_TEAM + GSTACK_IOS_BUNDLE_ID, installs
// profile + dev team — environment-specific, not portable across CI) // and launches it, then proves screenshot/elements/tap through the real daemon.
// - Actually deploying + launching the StateServer on the device (same) // It remains skipped in normal CI because signing and a paired iPhone are
// // intentionally machine-specific.
// The first three steps prove the CoreDevice path is wired end-to-end on the
// agent's side. The fourth proves the Swift templates compile against the
// iOS SDK, not just macOS — which catches UIKit/SwiftUI gating bugs before
// they reach a real app deployment.
import { describe, test, expect } from 'bun:test'; import { describe, test, expect } from 'bun:test';
import { spawnSync } from 'child_process'; import { spawnSync } from 'child_process';
import { cpSync, existsSync, mkdtempSync, readFileSync, rmSync, unlinkSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path'; import { join } from 'path';
import { startDaemon, type RunningDaemon } from '../ios-qa/daemon/src/index';
import { startTunnelKeepalive } from '../ios-qa/daemon/src/devicectl';
import { bootstrapTunnel } from '../ios-qa/daemon/src/tunnel-bootstrap';
import type { DeviceTunnel } from '../ios-qa/daemon/src/proxy';
const ROOT = join(import.meta.dir, '..'); const ROOT = join(import.meta.dir, '..');
const FIXTURE_PATH = join(ROOT, 'test/fixtures/ios-qa/FixtureApp'); const FIXTURE_PATH = join(ROOT, 'test/fixtures/ios-qa/FixtureApp');
const HAS_DEVICE = process.env.GSTACK_HAS_IOS_DEVICE === '1'; const HAS_DEVICE = process.env.GSTACK_HAS_IOS_DEVICE === '1';
const DEPLOY_TO_DEVICE = process.env.GSTACK_IOS_DEVICE_DEPLOY === '1';
const describeIfDevice = HAS_DEVICE ? describe : describe.skip; const describeIfDevice = HAS_DEVICE ? describe : describe.skip;
const testIfDeploy = DEPLOY_TO_DEVICE ? test : test.skip;
interface DeviceListEntry { interface DeviceListEntry {
identifier: string; identifier: string;
state: string; // "available" | "available (pairing)" | "unavailable" | ... state: string; // "available" | "available (pairing)" | "unavailable" | ...
name: string; name: string;
model: string; model: string;
platform: string;
transport: string;
paired: boolean;
}
interface DeviceElement {
identifier?: string;
label?: string;
value?: string;
frame?: { x: number; y: number; w: number; h: number };
}
interface StateSnapshot {
_app_build_id?: string;
_accessor_hash?: string;
keys?: Record<string, unknown>;
} }
function listDevices(): DeviceListEntry[] { function listDevices(): DeviceListEntry[] {
// devicectl JSON output requires --json-output to a path. Use a tempfile. // devicectl JSON output requires --json-output to a path. Use a tempfile.
const tmp = `/tmp/devicectl-list-${process.pid}-${Date.now()}.json`; const tmp = `/tmp/devicectl-list-${process.pid}-${Date.now()}.json`;
const r = spawnSync('xcrun', ['devicectl', 'list', 'devices', '--json-output', tmp], {
stdio: 'pipe',
timeout: 30_000,
});
if (r.status !== 0) return [];
try { try {
const fs = require('fs'); const r = spawnSync('xcrun', ['devicectl', 'list', 'devices', '--json-output', tmp], {
const raw = fs.readFileSync(tmp, 'utf-8'); stdio: 'pipe',
timeout: 30_000,
});
if (r.status !== 0) return [];
const raw = readFileSync(tmp, 'utf-8');
const obj = JSON.parse(raw); const obj = JSON.parse(raw);
fs.unlinkSync(tmp); return (obj.result?.devices ?? []).map((d: { identifier: string; connectionProperties: { tunnelState: string; pairingState?: string; transportType?: string }; deviceProperties: { name: string }; hardwareProperties: { productType: string; platform?: string } }) => ({
return (obj.result?.devices ?? []).map((d: { identifier: string; connectionProperties: { tunnelState: string }; deviceProperties: { name: string }; hardwareProperties: { productType: string } }) => ({
identifier: d.identifier, identifier: d.identifier,
state: d.connectionProperties?.tunnelState ?? 'unknown', state: d.connectionProperties?.tunnelState ?? 'unknown',
name: d.deviceProperties?.name ?? 'unknown', name: d.deviceProperties?.name ?? 'unknown',
model: d.hardwareProperties?.productType ?? 'unknown', model: d.hardwareProperties?.productType ?? 'unknown',
platform: d.hardwareProperties?.platform ?? 'unknown',
transport: d.connectionProperties?.transportType ?? '',
paired: d.connectionProperties?.pairingState === 'paired',
})); }));
} catch { } catch {
return []; return [];
} finally {
try { unlinkSync(tmp); } catch { /* ignore */ }
} }
} }
function isAvailableIPhone(device: DeviceListEntry): boolean {
const state = device.state.trim().toLowerCase();
const available = state === 'connected'
|| state.startsWith('available')
|| (state === 'disconnected' && device.transport.trim().toLowerCase() === 'wired');
return available
&& device.paired
&& device.platform.toLowerCase() === 'ios'
&& device.model.toLowerCase().startsWith('iphone');
}
function isPaired(udid: string): boolean { function isPaired(udid: string): boolean {
// devicectl device info processes returns a clean exit when paired. // devicectl device info processes returns a clean exit when paired.
const tmp = `/tmp/devicectl-info-${process.pid}-${Date.now()}.json`; const tmp = `/tmp/devicectl-info-${process.pid}-${Date.now()}.json`;
@@ -69,12 +107,146 @@ function isPaired(udid: string): boolean {
'-d', udid, '-d', udid,
'--json-output', tmp, '--json-output', tmp,
], { stdio: 'pipe', timeout: 30_000 }); ], { stdio: 'pipe', timeout: 30_000 });
try { require('fs').unlinkSync(tmp); } catch { /* ignore */ } try { unlinkSync(tmp); } catch { /* ignore */ }
// Pair-required errors surface on stderr with "must be paired" or // Pair-required errors surface on stderr with "must be paired" or
// CoreDeviceError 2. Treat any non-zero exit as not-paired. // CoreDeviceError 2. Treat any non-zero exit as not-paired.
return r.status === 0; return r.status === 0;
} }
function requireDeployEnv(name: 'GSTACK_IOS_DEVELOPMENT_TEAM' | 'GSTACK_IOS_BUNDLE_ID'): string {
const value = process.env[name]?.trim();
if (!value) {
throw new Error(`${name} is required when GSTACK_IOS_DEVICE_DEPLOY=1`);
}
return value;
}
function runChecked(
command: string,
args: string[],
opts: { cwd?: string; timeout?: number } = {},
): string {
const result = spawnSync(command, args, {
cwd: opts.cwd,
env: process.env,
stdio: 'pipe',
timeout: opts.timeout ?? 60_000,
maxBuffer: 32 * 1024 * 1024,
});
const output = `${result.stdout?.toString() ?? ''}${result.stderr?.toString() ?? ''}`;
if (result.error || result.status !== 0) {
const tail = output.split('\n').slice(-120).join('\n');
throw new Error([
`${command} ${args.join(' ')} failed (${result.error?.message ?? `exit ${result.status}`})`,
tail,
].filter(Boolean).join('\n'));
}
return output;
}
async function daemonJson<T>(
baseURL: string,
path: string,
init: RequestInit = {},
): Promise<{ status: number; body: T; raw: string }> {
const response = await fetch(`${baseURL}${path}`, {
...init,
signal: AbortSignal.timeout(60_000),
});
const raw = await response.text();
let body: T;
try {
body = JSON.parse(raw) as T;
} catch {
throw new Error(`${init.method ?? 'GET'} ${path} returned non-JSON HTTP ${response.status}: ${raw.slice(0, 500)}`);
}
return { status: response.status, body, raw };
}
function findElement(elements: DeviceElement[], identifier: string): DeviceElement | undefined {
return elements.find((element) =>
element.identifier === identifier
&& (element.frame?.w ?? 0) > 0
&& (element.frame?.h ?? 0) > 0,
);
}
type DeviceElementPredicate = (element: DeviceElement) => boolean;
interface DeviceViewport {
w: number;
h: number;
}
function isInsideViewport(element: DeviceElement, viewport: DeviceViewport): boolean {
const frame = element.frame;
if (!frame || frame.w <= 0 || frame.h <= 0) return false;
const centerX = frame.x + frame.w / 2;
const centerY = frame.y + frame.h / 2;
return centerX >= 0 && centerX <= viewport.w && centerY >= 0 && centerY <= viewport.h;
}
async function readDeviceElements(baseURL: string): Promise<DeviceElement[]> {
const result = await daemonJson<{ elements?: DeviceElement[] }>(baseURL, '/elements');
if (result.status !== 200 || !Array.isArray(result.body.elements)) {
throw new Error(`GET /elements failed with HTTP ${result.status}: ${result.raw.slice(0, 500)}`);
}
return result.body.elements;
}
async function waitForDeviceElement(
baseURL: string,
predicate: DeviceElementPredicate,
description: string,
options: {
condition?: DeviceElementPredicate;
tappableIn?: DeviceViewport;
timeoutMs?: number;
} = {},
): Promise<DeviceElement> {
const deadline = Date.now() + (options.timeoutMs ?? 10_000);
let lastElements: DeviceElement[] = [];
while (Date.now() < deadline) {
lastElements = await readDeviceElements(baseURL);
const match = lastElements.find((element) =>
predicate(element)
&& (options.condition?.(element) ?? true)
&& (!options.tappableIn || isInsideViewport(element, options.tappableIn)),
);
if (match) return match;
await new Promise((resolve) => setTimeout(resolve, 200));
}
const visible = lastElements
.filter((element) => element.identifier || element.label)
.slice(0, 80)
.map((element) => element.identifier ?? element.label)
.join(', ');
throw new Error(`timed out waiting for ${description}; last elements: ${visible}`);
}
async function tapDeviceElement(
baseURL: string,
sessionId: string,
element: DeviceElement,
): Promise<void> {
const frame = element.frame;
if (!frame) throw new Error('cannot tap an element without a frame');
const tapped = await daemonJson<{ ok?: boolean; op?: string }>(baseURL, '/tap', {
method: 'POST',
headers: {
'content-type': 'application/json',
'x-session-id': sessionId,
},
body: JSON.stringify({
x: frame.x + frame.w / 2,
y: frame.y + frame.h / 2,
}),
});
if (tapped.status !== 200 || tapped.body.ok !== true) {
throw new Error(`tap failed with HTTP ${tapped.status}: ${tapped.raw.slice(0, 500)}`);
}
}
describeIfDevice('ios device path', () => { describeIfDevice('ios device path', () => {
test('devicectl lists at least one connected device', () => { test('devicectl lists at least one connected device', () => {
const devices = listDevices(); const devices = listDevices();
@@ -104,11 +276,9 @@ describeIfDevice('ios device path', () => {
expect(paired.length).toBeGreaterThan(0); expect(paired.length).toBeGreaterThan(0);
}); });
test('fixture Swift package compiles for iOS target', () => { test('fixture iOS SDK and UIKit compile guards are available', () => {
// Use xcrun --sdk iphoneos to get the iOS SDK path, then pass it through // This is an environment + source-guard preflight. The explicit deployment
// to swift build via SDKROOT. This validates that the Swift templates // test below performs the real signed iOS xcodebuild before installation.
// (StateServer, DebugBridgeManager, DebugOverlay) compile against the
// iOS SDK — catches UIKit/SwiftUI gating bugs that macOS-only builds miss.
const sdkPath = spawnSync('xcrun', ['--sdk', 'iphoneos', '--show-sdk-path'], { stdio: 'pipe' }); const sdkPath = spawnSync('xcrun', ['--sdk', 'iphoneos', '--show-sdk-path'], { stdio: 'pipe' });
if (sdkPath.status !== 0) { if (sdkPath.status !== 0) {
console.error('iOS SDK not found. Install via Xcode.'); console.error('iOS SDK not found. Install via Xcode.');
@@ -117,16 +287,8 @@ describeIfDevice('ios device path', () => {
const sdk = sdkPath.stdout.toString().trim(); const sdk = sdkPath.stdout.toString().trim();
expect(sdk).toContain('iPhoneOS'); expect(sdk).toContain('iPhoneOS');
// Build the DebugBridgeUI target specifically for iOS. We can't use // SwiftPM cannot directly cross-build this UIKit package with the standalone
// `swift build --triple arm64-apple-ios` directly because SwiftPM // host command, so keep the static guard assertion honest and narrowly named.
// doesn't ship an iOS toolchain out of the box. The xcodebuild path
// requires a project — skip if no .xcodeproj exists.
// Instead, verify the iOS-only code compiles by parsing the canImport
// guards: if the template's `#if canImport(UIKit)` is wrong, the macOS
// build would have failed in the swift-build invariant test. The iOS
// SDK path being present is sufficient signal that the toolchain is
// installed; the deeper iOS-target build belongs to xcodebuild + a real
// app target, which is the "deploy to device" path documented below.
const fs = require('fs') as typeof import('fs'); const fs = require('fs') as typeof import('fs');
const overlay = fs.readFileSync( const overlay = fs.readFileSync(
join(FIXTURE_PATH, 'Sources/DebugBridgeUI/DebugOverlay.swift'), join(FIXTURE_PATH, 'Sources/DebugBridgeUI/DebugOverlay.swift'),
@@ -137,26 +299,575 @@ describeIfDevice('ios device path', () => {
expect(overlay).toContain('#endif'); expect(overlay).toContain('#endif');
}); });
// Documented next step. Becomes a real test once we have: });
// - test/fixtures/ios-qa/FixtureApp/FixtureApp.xcodeproj (or generated)
// - A signing certificate + provisioning profile on the test machine describe('ios device deployment (explicit opt-in)', () => {
// - GSTACK_IOS_DEVICE_DEPLOY=1 environment opt-in testIfDeploy('generates, signs, installs, launches, and drives the fixture through the daemon', async () => {
// const developmentTeam = requireDeployEnv('GSTACK_IOS_DEVELOPMENT_TEAM');
// The flow would be: const bundleId = requireDeployEnv('GSTACK_IOS_BUNDLE_ID');
// xcodebuild -scheme FixtureApp -destination 'platform=iOS,id=<UDID>' \ const devices = listDevices();
// -allowProvisioningUpdates build install const device = devices.find((candidate) => isAvailableIPhone(candidate) && isPaired(candidate.identifier));
// xcrun devicectl device process launch -d <UDID> --console <bundle-id> if (!device) {
// # Scrape boot token from os_log const summary = devices.length > 0
// curl http://[<corodevice-ipv6>]:9999/healthz ? devices.map((d) => ` ${d.name} (${d.model}, ${d.platform}, ${d.identifier}): state=${d.state}, paired=${d.paired}`).join('\n')
// # ... full smoke loop ... : ' devicectl returned no devices';
test.skip('TODO(deploy): build + deploy fixture to device + smoke test full StateServer loop', () => {}); throw new Error([
'GSTACK_IOS_DEVICE_DEPLOY=1 requires an available, paired iPhone; stale unavailable devices are never selected.',
summary,
].join('\n'));
}
const workDir = mkdtempSync(join(tmpdir(), 'gstack-ios-device-deploy-'));
const fixtureDir = join(workDir, 'FixtureApp');
const derivedData = join(workDir, 'DerivedData');
let daemon: RunningDaemon | undefined;
let keepalive: { stop: () => void } | undefined;
let sessionId: string | undefined;
try {
cpSync(FIXTURE_PATH, fixtureDir, { recursive: true });
// Exercise the same deterministic bootstrap that /ios-qa and /ios-sync
// install for users. This creates the app-owned typed accessor before
// XcodeGen discovers the fixture sources.
runChecked(join(ROOT, 'bin/gstack-ios-qa-regen'), [
'--app-source', join(fixtureDir, 'Sources/FixtureApp'),
'--bridge-dir', fixtureDir,
], { cwd: fixtureDir });
const generatedAccessor = join(
fixtureDir,
'Sources/FixtureApp/DebugBridgeGenerated/StateAccessor.swift',
);
expect(existsSync(generatedAccessor)).toBe(true);
expect(readFileSync(generatedAccessor, 'utf8')).toContain('enum FixtureAppStateAccessor');
runChecked('xcodegen', [
'generate',
'--spec', join(fixtureDir, 'project.yml'),
'--project', fixtureDir,
'--project-root', fixtureDir,
], { cwd: fixtureDir });
const projectPath = join(fixtureDir, 'FixtureApp.xcodeproj');
expect(existsSync(projectPath)).toBe(true);
runChecked('xcodebuild', [
'-project', projectPath,
'-scheme', 'FixtureApp',
'-configuration', 'Debug',
'-destination', `platform=iOS,id=${device.identifier}`,
'-derivedDataPath', derivedData,
'-allowProvisioningUpdates',
`DEVELOPMENT_TEAM=${developmentTeam}`,
`PRODUCT_BUNDLE_IDENTIFIER=${bundleId}`,
'CODE_SIGN_STYLE=Automatic',
'build',
], { cwd: fixtureDir, timeout: 300_000 });
const appBundle = join(derivedData, 'Build/Products/Debug-iphoneos/FixtureApp.app');
expect(existsSync(appBundle)).toBe(true);
const builtBundleId = runChecked('/usr/libexec/PlistBuddy', [
'-c', 'Print :CFBundleIdentifier',
join(appBundle, 'Info.plist'),
]).trim();
expect(builtBundleId).toBe(bundleId);
runChecked('xcrun', [
'devicectl', 'device', 'install', 'app',
'--device', device.identifier,
appBundle,
], { timeout: 120_000 });
runChecked('xcrun', [
'devicectl', 'device', 'process', 'launch',
'--device', device.identifier,
'--terminate-existing',
bundleId,
], { timeout: 60_000 });
keepalive = startTunnelKeepalive(device.identifier);
const bootstrap = await bootstrapTunnel({
udid: device.identifier,
bundleId,
startupTimeoutMs: 30_000,
});
if (!bootstrap.ok) {
throw new Error(`daemon tunnel bootstrap failed: ${bootstrap.error}${bootstrap.detail ? ` (${bootstrap.detail})` : ''}`);
}
// The first provider call consumes the already-rotated bootstrap. Later
// calls perform a fresh bootstrap so the same daemon can recover after
// this app is relaunched and its in-memory bearer changes.
let pendingTunnel: DeviceTunnel | undefined = bootstrap.tunnel;
let tunnelProviderCalls = 0;
const provideTunnel = async (): Promise<DeviceTunnel | null> => {
tunnelProviderCalls += 1;
if (pendingTunnel) {
const first = pendingTunnel;
pendingTunnel = undefined;
return first;
}
const refreshed = await bootstrapTunnel({
udid: device.identifier,
bundleId,
startupTimeoutMs: 30_000,
});
if (!refreshed.ok) {
throw new Error(`daemon rebootstrap failed: ${refreshed.error}${refreshed.detail ? ` (${refreshed.detail})` : ''}`);
}
return refreshed.tunnel;
};
const started = await startDaemon({
loopbackPort: 0,
tailnetEnabled: false,
pidfilePath: join(workDir, 'daemon.pid'),
tunnelProvider: provideTunnel,
});
if ('error' in started) {
throw new Error(`daemon failed to start: ${started.error}${started.reason ? ` (${started.reason})` : ''}`);
}
daemon = started;
const baseURL = `http://127.0.0.1:${daemon.loopbackPort}`;
const initialState = await daemonJson<StateSnapshot>(baseURL, '/state/snapshot');
expect(initialState.status).toBe(200);
expect(typeof initialState.body._app_build_id).toBe('string');
expect(initialState.body._app_build_id).not.toBe('unknown');
expect(initialState.body._app_build_id).not.toBe('uninitialized');
expect(initialState.body._accessor_hash).toMatch(/^[a-f0-9]{64}$/);
expect(initialState.body._accessor_hash).not.toBe('uninitialized');
expect(Object.keys(initialState.body.keys ?? {}).sort()).toEqual([
'isLoggedIn',
'nickname',
'tapCounter',
'username',
]);
expect(initialState.body.keys?.nickname).toBeNull();
const screenshot = await daemonJson<{ png_base64?: string; error?: string }>(baseURL, '/screenshot');
expect(screenshot.status).toBe(200);
expect(typeof screenshot.body.png_base64).toBe('string');
const png = Buffer.from(screenshot.body.png_base64!, 'base64');
expect(png.length).toBeGreaterThan(1_000);
expect([...png.subarray(0, 8)]).toEqual([137, 80, 78, 71, 13, 10, 26, 10]);
const requiredIdentifiers = [
'primary-button',
'toolbar-actions-menu',
'open-detail-button',
'tab-controls',
'tab-inputs',
'tab-rows',
];
let elementsBefore: DeviceElement[] = [];
let identifiers = new Set<string>();
const elementsDeadline = Date.now() + 10_000;
while (Date.now() < elementsDeadline) {
const before = await daemonJson<{ elements?: DeviceElement[] }>(baseURL, '/elements');
expect(before.status).toBe(200);
expect(Array.isArray(before.body.elements)).toBe(true);
elementsBefore = before.body.elements ?? [];
identifiers = new Set(
elementsBefore.map((element) => element.identifier).filter((value): value is string => Boolean(value)),
);
if (requiredIdentifiers.every((identifier) => identifiers.has(identifier))) break;
await new Promise((resolve) => setTimeout(resolve, 250));
}
expect(elementsBefore.length).toBeGreaterThan(30);
expect(requiredIdentifiers.filter((identifier) => !identifiers.has(identifier))).toEqual([]);
const appFrame = findElement(elementsBefore, 'fixture-tab-view')?.frame;
expect(appFrame).toBeDefined();
// Screenshot pixels and /tap coordinates must share UIKit's point
// space. A 3x PNG here recreates the original missed-tap bug.
expect(png.readUInt32BE(16)).toBe(appFrame!.w);
expect(png.readUInt32BE(20)).toBe(appFrame!.h);
const buttonBefore = findElement(elementsBefore, 'primary-button');
expect(buttonBefore).toBeDefined();
expect(typeof buttonBefore!.value).toBe('string');
const acquired = await daemonJson<{ session_id?: string }>(baseURL, '/session/acquire', { method: 'POST' });
expect(acquired.status).toBe(200);
expect(typeof acquired.body.session_id).toBe('string');
sessionId = acquired.body.session_id!;
const rejectedBooleanAsInteger = await daemonJson<{ error?: string }>(baseURL, '/state/tapCounter', {
method: 'POST',
headers: {
'content-type': 'application/json',
'x-session-id': sessionId,
},
body: JSON.stringify({ value: true }),
});
expect(rejectedBooleanAsInteger.status).toBe(400);
expect(rejectedBooleanAsInteger.body.error).toBe('type_mismatch');
const rejectedIntegerAsBoolean = await daemonJson<{ error?: string }>(baseURL, '/state/isLoggedIn', {
method: 'POST',
headers: {
'content-type': 'application/json',
'x-session-id': sessionId,
},
body: JSON.stringify({ value: 1 }),
});
expect(rejectedIntegerAsBoolean.status).toBe(400);
expect(rejectedIntegerAsBoolean.body.error).toBe('type_mismatch');
const afterRejectedCoercions = await daemonJson<StateSnapshot>(baseURL, '/state/snapshot');
expect(afterRejectedCoercions.body.keys?.tapCounter).toBe(0);
expect(afterRejectedCoercions.body.keys?.isLoggedIn).toBe(false);
const wroteState = await daemonJson<{ ok?: boolean }>(baseURL, '/state/tapCounter', {
method: 'POST',
headers: {
'content-type': 'application/json',
'x-session-id': sessionId,
},
body: JSON.stringify({ value: 7 }),
});
expect(wroteState.status).toBe(200);
expect(wroteState.body).toEqual({ ok: true });
const updatedState = await daemonJson<StateSnapshot>(baseURL, '/state/snapshot');
expect(updatedState.status).toBe(200);
expect(updatedState.body.keys?.tapCounter).toBe(7);
const wroteOptional = await daemonJson<{ ok?: boolean }>(baseURL, '/state/nickname', {
method: 'POST',
headers: {
'content-type': 'application/json',
'x-session-id': sessionId,
},
body: JSON.stringify({ value: 'Device' }),
});
expect(wroteOptional.status).toBe(200);
const optionalValue = await daemonJson<StateSnapshot>(baseURL, '/state/snapshot');
expect(optionalValue.body.keys?.nickname).toBe('Device');
const clearedOptional = await daemonJson<{ ok?: boolean }>(baseURL, '/state/nickname', {
method: 'POST',
headers: {
'content-type': 'application/json',
'x-session-id': sessionId,
},
body: JSON.stringify({ value: null }),
});
expect(clearedOptional.status).toBe(200);
const clearedValue = await daemonJson<StateSnapshot>(baseURL, '/state/snapshot');
expect(clearedValue.body.keys?.nickname).toBeNull();
const restoredState = await daemonJson<{ ok?: boolean }>(baseURL, '/state/restore', {
method: 'POST',
headers: {
'content-type': 'application/json',
'x-session-id': sessionId,
},
body: JSON.stringify(initialState.body),
});
expect(restoredState.status).toBe(200);
expect(restoredState.body).toEqual({ ok: true });
const afterRestore = await daemonJson<StateSnapshot>(baseURL, '/state/snapshot');
expect(afterRestore.body.keys?.tapCounter).toBe(0);
expect(afterRestore.body.keys?.nickname).toBeNull();
const viewport = { w: appFrame!.w, h: appFrame!.h };
const byIdentifier = (identifier: string): DeviceElementPredicate =>
(element) => element.identifier === identifier;
const byLabel = (label: string): DeviceElementPredicate =>
(element) => element.label?.trim() === label;
const tapAndWaitForValueChange = async (
target: DeviceElementPredicate,
oracle: DeviceElementPredicate,
description: string,
): Promise<DeviceElement> => {
const before = await waitForDeviceElement(
baseURL,
oracle,
`${description} oracle before tap`,
{ condition: (element) => typeof element.value === 'string' },
);
const targetElement = await waitForDeviceElement(
baseURL,
target,
`${description} target`,
{ tappableIn: viewport },
);
await tapDeviceElement(baseURL, sessionId!, targetElement);
const after = await waitForDeviceElement(
baseURL,
oracle,
`${description} value change`,
{ condition: (element) => typeof element.value === 'string' && element.value !== before.value },
);
expect(after.value).not.toBe(before.value);
return after;
};
const firstInteger = (value: string | undefined): number | undefined => {
const match = value?.match(/-?\d+/);
return match ? Number(match[0]) : undefined;
};
const tapAndWaitForCountIncrement = async (
target: DeviceElementPredicate,
oracle: DeviceElementPredicate,
description: string,
): Promise<DeviceElement> => {
const before = await waitForDeviceElement(
baseURL,
oracle,
`${description} counter before tap`,
{ condition: (element) => firstInteger(element.value) !== undefined },
);
const beforeCount = firstInteger(before.value)!;
const targetElement = await waitForDeviceElement(
baseURL,
target,
`${description} target`,
{ tappableIn: viewport },
);
await tapDeviceElement(baseURL, sessionId!, targetElement);
const after = await waitForDeviceElement(
baseURL,
oracle,
`${description} exactly-once counter increment`,
{ condition: (element) => firstInteger(element.value) === beforeCount + 1 },
);
expect(firstInteger(after.value)).toBe(beforeCount + 1);
return after;
};
// SwiftUI button styles and both navigation-bar controls.
for (const identifier of [
'primary-button',
'bordered-button',
'plain-button',
'destructive-button',
'nav-refresh-button',
]) {
await tapAndWaitForCountIncrement(
byIdentifier(identifier),
byIdentifier(identifier),
identifier,
);
}
// Menu presentation and both menu commands. The menu's own value is the
// stable oracle after each transient command element disappears.
for (const commandIdentifier of ['menu-add-item', 'menu-archive-item']) {
const menuBefore = await waitForDeviceElement(
baseURL,
byIdentifier('toolbar-actions-menu'),
'toolbar menu value',
{ condition: (element) => typeof element.value === 'string' },
);
const menu = await waitForDeviceElement(
baseURL,
byIdentifier('toolbar-actions-menu'),
'toolbar actions menu',
{ tappableIn: viewport },
);
await tapDeviceElement(baseURL, sessionId, menu);
const command = await waitForDeviceElement(
baseURL,
byIdentifier(commandIdentifier),
commandIdentifier,
{ tappableIn: viewport },
);
await tapDeviceElement(baseURL, sessionId, command);
const beforeCounts = [...(menuBefore.value?.matchAll(/\d+/g) ?? [])].map((match) => Number(match[0]));
expect(beforeCounts).toHaveLength(2);
const changedIndex = commandIdentifier === 'menu-add-item' ? 0 : 1;
const expectedCounts = beforeCounts.map((count, index) => count + (index === changedIndex ? 1 : 0));
const menuAfter = await waitForDeviceElement(
baseURL,
byIdentifier('toolbar-actions-menu'),
`${commandIdentifier} exactly-once result`,
{
condition: (element) => {
const counts = [...(element.value?.matchAll(/\d+/g) ?? [])].map((match) => Number(match[0]));
return counts.length === 2 && counts.every((count, index) => count === expectedCounts[index]);
},
},
);
const afterCounts = [...(menuAfter.value?.matchAll(/\d+/g) ?? [])].map((match) => Number(match[0]));
expect(afterCounts).toEqual(expectedCounts);
}
// Push, interact with, and pop the explicit navigation destination.
const openDetail = await waitForDeviceElement(
baseURL,
byIdentifier('open-detail-button'),
'open detail button',
{ tappableIn: viewport },
);
await tapDeviceElement(baseURL, sessionId, openDetail);
await waitForDeviceElement(baseURL, byIdentifier('detail-screen-title'), 'detail destination');
await tapAndWaitForCountIncrement(
byIdentifier('detail-action-button'),
byIdentifier('detail-action-button'),
'detail action button',
);
const back = await waitForDeviceElement(
baseURL,
byIdentifier('detail-back-button'),
'detail back button',
{ tappableIn: viewport },
);
await tapDeviceElement(baseURL, sessionId, back);
await waitForDeviceElement(baseURL, byIdentifier('primary-button'), 'controls after detail pop');
// Tab navigation plus native toggle, stepper, segmented picker, text
// input/commit, and a UIKit UIButton.
const inputsTab = await waitForDeviceElement(
baseURL,
byIdentifier('tab-inputs'),
'Inputs tab',
{ tappableIn: viewport },
);
await tapDeviceElement(baseURL, sessionId, inputsTab);
await waitForDeviceElement(baseURL, byIdentifier('harness-toggle'), 'Inputs controls');
await tapAndWaitForValueChange(
byIdentifier('harness-toggle'),
byIdentifier('harness-toggle'),
'toggle',
);
await tapAndWaitForCountIncrement(
byIdentifier('harness-stepper-Increment'),
byIdentifier('harness-stepper'),
'stepper increment',
);
const segmentTwo = await waitForDeviceElement(
baseURL,
byLabel('Two'),
'segmented picker option Two',
{ tappableIn: viewport },
);
await tapDeviceElement(baseURL, sessionId, segmentTwo);
const selectedTwo = await waitForDeviceElement(
baseURL,
byIdentifier('harness-segmented-picker'),
'segmented picker selection',
{ condition: (element) => element.value?.includes('Two') === true },
);
expect(selectedTwo.value).toContain('Two');
const textField = await waitForDeviceElement(
baseURL,
byIdentifier('harness-text-field'),
'text field',
{ tappableIn: viewport },
);
await tapDeviceElement(baseURL, sessionId, textField);
const typed = await daemonJson<{ ok?: boolean; op?: string }>(baseURL, '/type', {
method: 'POST',
headers: {
'content-type': 'application/json',
'x-session-id': sessionId,
},
body: JSON.stringify({ text: 'device matrix' }),
});
expect(typed.status).toBe(200);
expect(typed.body).toMatchObject({ op: 'type', ok: true });
await waitForDeviceElement(
baseURL,
byIdentifier('harness-text-field'),
'typed text value',
{ condition: (element) => element.value === 'device matrix' },
);
await tapAndWaitForCountIncrement(
byIdentifier('commit-text-button'),
byIdentifier('commit-text-button'),
'text commit button',
);
// Commit clears FocusState; let the keyboard dismissal animation finish
// before choosing a scroll-view hit point for the UIKit control below.
await new Promise((resolve) => setTimeout(resolve, 500));
const scrollInputs = await daemonJson<{ ok?: boolean; op?: string }>(baseURL, '/swipe', {
method: 'POST',
headers: {
'content-type': 'application/json',
'x-session-id': sessionId,
},
body: JSON.stringify({ from_x: 200, from_y: 650, to_x: 200, to_y: 220 }),
});
expect(scrollInputs.status).toBe(200);
expect(scrollInputs.body).toMatchObject({ op: 'swipe', ok: true });
await tapAndWaitForCountIncrement(
byIdentifier('uikit-button'),
byIdentifier('uikit-button'),
'UIKit button',
);
// All four list rows and the row navigation-bar action.
const rowsTab = await waitForDeviceElement(
baseURL,
byIdentifier('tab-rows'),
'Rows tab',
{ tappableIn: viewport },
);
await tapDeviceElement(baseURL, sessionId, rowsTab);
await waitForDeviceElement(baseURL, byIdentifier('row-alpha-button'), 'Rows list');
for (const row of ['alpha', 'bravo', 'charlie', 'delta']) {
await tapAndWaitForCountIncrement(
byIdentifier(`row-${row}-button`),
byIdentifier(`row-${row}-button`),
`${row} row`,
);
}
await tapAndWaitForCountIncrement(
byIdentifier('rows-toolbar-button'),
byIdentifier('rows-toolbar-button'),
'rows toolbar button',
);
const controlsTab = await waitForDeviceElement(
baseURL,
byIdentifier('tab-controls'),
'Controls tab',
{ tappableIn: viewport },
);
await tapDeviceElement(baseURL, sessionId, controlsTab);
await waitForDeviceElement(baseURL, byIdentifier('primary-button'), 'Controls tab restored');
// Keep the daemon alive while the app process gets a new boot token.
// The first proxied request must observe the stale bearer, invalidate
// only that tunnel, single-flight a fresh bootstrap, and retry once.
runChecked('xcrun', [
'devicectl', 'device', 'process', 'launch',
'--device', device.identifier,
'--terminate-existing',
bundleId,
], { timeout: 60_000 });
await new Promise((resolve) => setTimeout(resolve, 500));
const afterRelaunch = await daemonJson<{ png_base64?: string; error?: string }>(baseURL, '/screenshot');
expect(afterRelaunch.status).toBe(200);
expect(Buffer.from(afterRelaunch.body.png_base64 ?? '', 'base64').length).toBeGreaterThan(1_000);
expect(tunnelProviderCalls).toBe(2);
const stateAfterRelaunch = await daemonJson<StateSnapshot>(baseURL, '/state/snapshot');
expect(stateAfterRelaunch.status).toBe(200);
expect(stateAfterRelaunch.body._accessor_hash).toBe(initialState.body._accessor_hash);
} finally {
if (daemon && sessionId) {
await daemonJson(`http://127.0.0.1:${daemon.loopbackPort}`, '/session/release', { method: 'POST' }).catch(() => undefined);
}
if (daemon) await daemon.close();
keepalive?.stop();
rmSync(workDir, { recursive: true, force: true });
}
}, 600_000);
}); });
// Always-on instructions if not paired. Surfaces actionable steps even when // Always-on instructions if not paired. Surfaces actionable steps even when
// the test is opted in via env var but the device isn't ready. // the test is opted in via env var but the device isn't ready.
if (HAS_DEVICE) { if (HAS_DEVICE) {
const devices = listDevices(); const devices = listDevices();
const unpaired = devices.filter(d => !isPaired(d.identifier)); const unpaired = devices.filter(d =>
d.platform.toLowerCase() === 'ios'
&& d.model.toLowerCase().startsWith('iphone')
&& !d.paired,
);
if (unpaired.length > 0) { if (unpaired.length > 0) {
console.error(''); console.error('');
console.error('=== iOS DEVICE PAIRING REQUIRED ==='); console.error('=== iOS DEVICE PAIRING REQUIRED ===');
+257 -21
View File
@@ -19,38 +19,90 @@
import { describe, test, expect } from 'bun:test'; import { describe, test, expect } from 'bun:test';
import { spawnSync } from 'child_process'; import { spawnSync } from 'child_process';
import { existsSync, readFileSync } from 'fs'; import { readFileSync } from 'fs';
import { join } from 'path'; import { join } from 'path';
const ROOT = join(import.meta.dir, '..'); const ROOT = join(import.meta.dir, '..');
const FIXTURE_PATH = join(ROOT, 'test/fixtures/ios-qa/FixtureApp'); const FIXTURE_PATH = join(ROOT, 'test/fixtures/ios-qa/FixtureApp');
const TEMPLATES_PATH = join(ROOT, 'ios-qa/templates'); 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 const COPIED_BRIDGE_TEMPLATES = [
// copy. The fixture is the only place the .m / .h are exercised end-to-end ['StateServer.swift.template', 'Sources/DebugBridgeCore/StateServer.swift'],
// on a real device, so any divergence means consuming apps would ship a ['DebugBridgeManager.swift.template', 'Sources/DebugBridgeCore/DebugBridgeManager.swift'],
// stale, untested version of the SwiftUI hit-test fix. ['DebugOverlay.swift.template', 'Sources/DebugBridgeUI/DebugOverlay.swift'],
['Bridges.swift.template', 'Sources/DebugBridgeUI/Bridges.swift'],
['DebugBridgeTouch.h.template', 'Sources/DebugBridgeTouch/include/DebugBridgeTouch.h'],
['DebugBridgeTouch.m.template', 'Sources/DebugBridgeTouch/DebugBridgeTouch.m'],
] as const;
function readTemplate(name: string): string {
return readFileSync(join(TEMPLATES_PATH, name), 'utf-8');
}
function normalizeBridgePackage(source: string): string {
// Package.swift.template has a generated-file prologue while the fixture has
// a fixture-specific one. The tools-version declaration must remain first in
// both real files, but neither header is part of the copied bridge surface.
const importOffset = source.indexOf('import PackageDescription');
expect(importOffset).toBeGreaterThanOrEqual(0);
let packageBody = source.slice(importOffset);
// The fixture deliberately has its own package identity and XCTest target.
// Normalize only those fixture concerns; all three bridge products, targets,
// dependencies, settings, and paths must otherwise stay in lockstep.
packageBody = packageBody.replace(
/(let package = Package\(\s*name:)\s*"[^"]+"/,
'$1 "<bridge-package>"',
);
packageBody = packageBody.replace(
/\n\s*\.testTarget\(\s*\n\s*name:\s*"DebugBridgeCoreTests",[\s\S]*?\n\s{8}\),?/,
'',
);
// Ignore prose and formatting so a template-only explanatory comment does
// not conceal a meaningful manifest mismatch.
return packageBody
.replace(/\/\/.*$/gm, '')
.replace(/\s+/g, '')
.replace(/,([\])])/g, '$1');
}
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function bracedBlock(source: string, openBraceOffset: number): string {
let depth = 0;
for (let offset = openBraceOffset; offset < source.length; offset++) {
if (source[offset] === '{') depth++;
if (source[offset] !== '}') continue;
depth--;
if (depth === 0) return source.slice(openBraceOffset, offset + 1);
}
return '';
}
// The fixture is where the bridge is compiled and exercised end-to-end. Every
// source copied into consuming apps must therefore be the canonical template,
// or device QA can pass against code that /ios-qa never installs.
describe('template ↔ fixture parity', () => { describe('template ↔ fixture parity', () => {
test('DebugBridgeTouch.h.template matches fixture include', () => { for (const [templateName, fixtureDestination] of COPIED_BRIDGE_TEMPLATES) {
const tmpl = readFileSync(join(TEMPLATES_PATH, 'DebugBridgeTouch.h.template'), 'utf-8'); test(`${templateName} matches ${fixtureDestination}`, () => {
const fixture = readFileSync( expect(readTemplate(templateName)).toBe(
join(FIXTURE_PATH, 'Sources/DebugBridgeTouch/include/DebugBridgeTouch.h'), readFileSync(join(FIXTURE_PATH, fixtureDestination), 'utf-8'),
'utf-8', );
); });
expect(tmpl).toBe(fixture); }
});
test('DebugBridgeTouch.m.template matches fixture .m', () => { test('Package.swift bridge declarations match after fixture-only normalization', () => {
const tmpl = readFileSync(join(TEMPLATES_PATH, 'DebugBridgeTouch.m.template'), 'utf-8'); const template = readTemplate('Package.swift.template');
const fixture = readFileSync( const fixture = readFileSync(join(FIXTURE_PATH, 'Package.swift'), 'utf-8');
join(FIXTURE_PATH, 'Sources/DebugBridgeTouch/DebugBridgeTouch.m'), expect(normalizeBridgePackage(template)).toBe(normalizeBridgePackage(fixture));
'utf-8',
);
expect(tmpl).toBe(fixture);
}); });
test('Package.swift.template declares all 3 DebugBridge targets', () => { test('Package.swift.template declares all 3 DebugBridge targets', () => {
const tmpl = readFileSync(join(TEMPLATES_PATH, 'Package.swift.template'), 'utf-8'); const tmpl = readTemplate('Package.swift.template');
// Each target must be present as a library product AND a target definition. // Each target must be present as a library product AND a target definition.
for (const name of ['DebugBridgeCore', 'DebugBridgeUI', 'DebugBridgeTouch']) { for (const name of ['DebugBridgeCore', 'DebugBridgeUI', 'DebugBridgeTouch']) {
expect(tmpl).toContain(`name: "${name}"`); expect(tmpl).toContain(`name: "${name}"`);
@@ -59,6 +111,190 @@ describe('template ↔ fixture parity', () => {
// app gets the transitive set with one dependency entry. // app gets the transitive set with one dependency entry.
expect(tmpl).toMatch(/name:\s*"DebugBridgeUI"[\s\S]*?dependencies:\s*\["DebugBridgeCore",\s*"DebugBridgeTouch"\]/); expect(tmpl).toMatch(/name:\s*"DebugBridgeUI"[\s\S]*?dependencies:\s*\["DebugBridgeCore",\s*"DebugBridgeTouch"\]/);
}); });
test('generated Swift packages only reference shipped test directories', () => {
const genAccessorsPackage = readFileSync(GEN_ACCESSORS_PACKAGE, 'utf-8');
const debugBridgePackage = readFileSync(join(TEMPLATES_PATH, 'Package.swift.template'), 'utf-8');
expect(genAccessorsPackage).not.toContain('Tests/GenAccessorsTests');
expect(debugBridgePackage).not.toContain('Tests/DebugBridgeCoreTests');
});
test('Package.swift.template keeps swift-tools-version on the first line', () => {
const tmpl = readTemplate('Package.swift.template');
expect(tmpl.split(/\r?\n/, 1)[0]).toBe('// swift-tools-version:5.9');
});
});
describe('iOS tap harness regressions', () => {
test('manager receives app-owned generated accessors instead of a no-op package stub', () => {
const manager = readTemplate('DebugBridgeManager.swift.template');
const wiring = readTemplate('DebugBridgeWiring.swift.template');
const fixtureApp = readFileSync(
join(FIXTURE_PATH, 'Sources/FixtureApp/FixtureAppApp.swift'),
'utf-8',
);
expect(manager).toContain('func start<State>');
expect(manager).toContain('register: (State) -> Void');
expect(manager).toContain('register(appState)');
expect(manager).not.toContain('public enum AppStateAccessor');
expect(manager).not.toContain('protocol AppState');
expect(wiring).toContain('import DebugBridgeCore');
expect(wiring).toContain('import DebugBridgeUI');
expect(wiring).toContain('DebugBridgeUIWiring.installAll()');
expect(wiring.indexOf('DebugBridgeUIWiring.installAll()')).toBeLessThan(
wiring.indexOf('DebugBridgeManager.shared.start'),
);
expect(fixtureApp.indexOf('DebugBridgeUIWiring.installAll()')).toBeLessThan(
fixtureApp.indexOf('DebugBridgeManager.shared.start'),
);
expect(wiring).not.toContain('import DebugBridge\n');
expect(wiring).not.toContain('AccessibilityScanner');
expect(wiring).not.toContain('MutationDispatcher');
});
test('fixture uses an @Observable-compatible source marker, not a property wrapper', () => {
const state = readFileSync(
join(FIXTURE_PATH, 'Sources/FixtureApp/FixtureAppState.swift'),
'utf-8',
);
expect(state).toContain('@Observable');
expect(state.match(/\/\/ @Snapshotable/g)?.length).toBe(4);
expect(state).not.toContain('@propertyWrapper');
expect(state).not.toMatch(/^[\t ]*@Snapshotable[\t ]+(?:public[\t ]+)?var/m);
});
test('recurses through iOS automation elements to expose nested SwiftUI controls', () => {
const bridges = readTemplate('Bridges.swift.template');
expect(bridges).toContain('element.automationElements');
expect(bridges).toContain('debugBridgeAccessibilityChildren(of: element)');
expect(bridges).toContain('visited.insert(ObjectIdentifier(element)).inserted');
expect(bridges).toContain('var remaining = 2_048');
});
test('enables accessibility automation before SwiftUI AX is installed', () => {
const implementation = readTemplate('DebugBridgeTouch.m.template');
const bridges = readTemplate('Bridges.swift.template');
const helper = [...implementation.matchAll(
/static\s+void\s+([A-Za-z_]\w*)\s*\(\s*void\s*\)\s*\{/g,
)].find((candidate) => {
const body = bracedBlock(
implementation,
candidate.index! + candidate[0].lastIndexOf('{'),
);
return body.includes('_AXSAutomationEnabled') && body.includes('_AXSSetAutomationEnabled');
});
expect(helper).toBeDefined();
const helperName = helper![1];
const helperBody = bracedBlock(
implementation,
helper!.index! + helper![0].lastIndexOf('{'),
);
expect(helperBody).toContain('_AXSAutomationEnabled');
expect(helperBody).toContain('_AXSSetAutomationEnabled');
// Accept either Objective-C's eager +load hook or an explicit public
// bootstrap selector, but require the enabling helper to be called before
// Swift installs the resolver that walks SwiftUI's accessibility tree.
const bootstrap = [...implementation.matchAll(/\+\s*\(void\)\s*([A-Za-z_]\w*)\s*\{/g)]
.find((candidate) => bracedBlock(
implementation,
candidate.index! + candidate[0].lastIndexOf('{'),
).match(new RegExp(`\\b${escapeRegExp(helperName)}\\s*\\(`)));
expect(bootstrap).toBeDefined();
if (bootstrap![1] === 'load') {
expect(bootstrap!.index!).toBeLessThan(implementation.indexOf('+ (BOOL)sendTapAtPoint:'));
} else {
const bootstrapCall = bridges.search(
new RegExp(`DebugBridgeTouch\\.${escapeRegExp(bootstrap![1])}\\s*\\(`),
);
expect(bootstrapCall).toBeGreaterThanOrEqual(0);
expect(bootstrapCall).toBeLessThan(bridges.indexOf('ElementsBridge.resolver'));
}
});
test('renders screenshots at one pixel per window point', () => {
const bridges = readTemplate('Bridges.swift.template');
const declaration = bridges.match(
/(?:let|var)\s+([A-Za-z_]\w*)\s*=\s*UIGraphicsImageRendererFormat(?:\.default)?\(\)/,
);
expect(declaration).not.toBeNull();
const formatName = declaration![1];
const scalePattern = new RegExp(`\\b${escapeRegExp(formatName)}\\.scale\\s*=\\s*1(?:\\.0)?\\b`);
const rendererPattern = new RegExp(
`UIGraphicsImageRenderer\\(\\s*bounds:\\s*bounds,\\s*format:\\s*${escapeRegExp(formatName)}\\s*\\)`,
);
const declarationOffset = declaration!.index!;
const scaleOffset = bridges.search(scalePattern);
const rendererOffset = bridges.search(rendererPattern);
expect(scaleOffset).toBeGreaterThan(declarationOffset);
expect(rendererOffset).toBeGreaterThan(scaleOffset);
});
test('uses accessibilityActivate for SwiftUI while retaining synthesized-touch delivery', () => {
const bridges = readTemplate('Bridges.swift.template');
const tapStart = bridges.indexOf('private static func handleTap');
const tapEnd = bridges.indexOf('private static func handleType', tapStart);
expect(tapStart).toBeGreaterThanOrEqual(0);
expect(tapEnd).toBeGreaterThan(tapStart);
const handleTap = bridges.slice(tapStart, tapEnd);
const synthesizedTouchOffset = handleTap.indexOf('DebugBridgeTouch.sendTap');
const fallbackCall = handleTap.match(
/\b([A-Za-z_]\w*)\s*\(\s*at:\s*point\s*,\s*in:\s*window\s*\)/,
);
const activationOffset = handleTap.indexOf('.accessibilityActivate()');
expect(synthesizedTouchOffset).toBeGreaterThanOrEqual(0);
expect(fallbackCall).not.toBeNull();
expect(activationOffset).toBeGreaterThan(fallbackCall!.index!);
const fallbackName = fallbackCall![1];
expect(bridges).toMatch(
new RegExp(`(?:private\\s+)?static\\s+func\\s+${escapeRegExp(fallbackName)}\\s*\\(`),
);
});
test('finishes programmatic scrolls before returning success to the next tap', () => {
const bridges = readTemplate('Bridges.swift.template');
expect(bridges).toContain('setContentOffset(off, animated: false)');
expect(bridges).not.toContain('setContentOffset(off, animated: true)');
});
test('serializes accessibility traits without signed Int truncation', () => {
const bridges = readTemplate('Bridges.swift.template');
expect(bridges).toMatch(/\btraits\s*:\s*UInt64\b/);
expect(bridges).toContain('.uint64Value');
expect(bridges).not.toMatch(/\bInt(?:64)?\s*\(\s*view\.accessibilityTraits\.rawValue\s*\)/);
expect(bridges).not.toMatch(/accessibilityTraits[\s\S]{0,120}?\.intValue\b/);
});
test('validates every generated model before applying any snapshot state', () => {
const server = readTemplate('StateServer.swift.template');
const validationLoop = server.indexOf('for restore in atomicRestores');
const applyComment = server.indexOf('Phase two applies only after every model accepted');
const applyLoop = server.indexOf('for restore in atomicRestores', validationLoop + 1);
expect(server).toContain('typealias AtomicRestoreFn = (JSONDict, Bool) -> RestoreResult');
expect(server).toContain('restore(keys, false)');
expect(server).toContain('restore(keys, true)');
expect(validationLoop).toBeGreaterThanOrEqual(0);
expect(applyComment).toBeGreaterThan(validationLoop);
expect(applyLoop).toBeGreaterThan(applyComment);
});
test('turns non-JSON response bodies into an explicit HTTP 500', () => {
const server = readTemplate('StateServer.swift.template');
expect(server).toContain('JSONSerialization.isValidJSONObject(body)');
expect(server).toContain('responseStatus = 500');
expect(server).toContain('response_not_json_serializable');
expect(server).not.toContain('?? Data("{}".utf8)');
});
}); });
function hasSwift(): boolean { function hasSwift(): boolean {
+8 -4
View File
@@ -171,9 +171,12 @@ describe('ios-qa E2E (no-device path)', () => {
writeFileSync(join(srcDir, 'AppState.swift'), ` writeFileSync(join(srcDir, 'AppState.swift'), `
@Observable @Observable
class AppState { class AppState {
@Snapshotable var isLoggedIn: Bool = false // @Snapshotable
@Snapshotable var username: String = "" var isLoggedIn: Bool = false
@Snapshotable var counter: Int = 0 // @Snapshotable
var username: String = ""
// @Snapshotable
var counter: Int = 0
var ephemeralCache: [String: Any] = [:] var ephemeralCache: [String: Any] = [:]
} }
`); `);
@@ -189,7 +192,8 @@ class AppState {
expect(result.specs).toHaveLength(1); expect(result.specs).toHaveLength(1);
expect(result.specs[0]!.fields.map(f => f.name).sort()).toEqual(['counter', 'isLoggedIn', 'username']); expect(result.specs[0]!.fields.map(f => f.name).sort()).toEqual(['counter', 'isLoggedIn', 'username']);
const generatedSwift = readFileSync(result.outputPath, 'utf-8'); const generatedSwift = readFileSync(result.outputPath, 'utf-8');
expect(generatedSwift).toContain('public enum AppStateAccessor'); expect(generatedSwift).toContain('enum AppStateAccessor');
expect(generatedSwift).not.toContain('public enum AppStateAccessor');
expect(generatedSwift).toContain('key: "isLoggedIn"'); expect(generatedSwift).toContain('key: "isLoggedIn"');
expect(generatedSwift).toContain('key: "counter"'); expect(generatedSwift).toContain('key: "counter"');
expect(generatedSwift).not.toContain('key: "ephemeralCache"'); // not marked @Snapshotable expect(generatedSwift).not.toContain('key: "ephemeralCache"'); // not marked @Snapshotable