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-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).
+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-ios-qa-daemon` | **iOS QA daemon** — Mac-side broker between an agent and a connected iPhone over USB CoreDevice. Loopback by default; `--tailnet` opens a Tailscale-facing listener with identity-gated capability tiers. Single-instance via flock on `~/.gstack/ios-qa-daemon.pid`. See [docs/howto-ios-testing-with-gstack.md](docs/howto-ios-testing-with-gstack.md). |
| `gstack-ios-qa-mint` | **iOS allowlist manager** — owner-grant CLI for the tailnet allowlist. `grant`/`revoke`/`list` against `~/.gstack/ios-qa-allowlist.json` (mode 0600). Remote agents never auto-allowlist; this is the explicit-intent path. |
| `gstack-ios-qa-regen` | **iOS bridge regenerator** — deterministically installs the canonical DebugBridge package, generates typed state accessors, and records the installed gstack version. Safe to rerun after source changes or upgrades. |
### Continuous checkpoint mode (opt-in, local by default)
+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
CMD_LOWER=$(printf '%s' "$CMD" | tr '[:upper:]' '[:lower:]')
# --- Check for safe exceptions (rm -rf of build artifacts) ---
if printf '%s' "$CMD" | grep -qE 'rm\s+(-[a-zA-Z]*r[a-zA-Z]*\s+|--recursive\s+)' 2>/dev/null; then
SAFE_ONLY=true
RM_ARGS=$(printf '%s' "$CMD" | sed -E 's/.*rm[[:space:]]+(-[a-zA-Z]+[[:space:]]+)*//;s/--recursive[[:space:]]*//')
for target in $RM_ARGS; do
case "$target" in
*/node_modules|node_modules|*/\.next|\.next|*/dist|dist|*/__pycache__|__pycache__|*/\.cache|\.cache|*/build|build|*/\.turbo|\.turbo|*/coverage|coverage)
;; # safe target
-*)
;; # flag, skip
*)
SAFE_ONLY=false
break
;;
esac
done
if [ "$SAFE_ONLY" = true ]; then
echo '{}'
exit 0
fi
# --- Check for safe exceptions (one standalone rm of build artifacts) ---
# Match the complete command. Parsing only the last rm is unsafe because shell
# syntax or comments can hide an earlier destructive command, for example:
# rm -rf / # rm -rf node_modules
# Unknown syntax fails closed and falls through to the destructive checks.
if printf '%s' "$CMD" | grep -qE '^[[:space:]]*rm[[:space:]]+(-[a-zA-Z]*r[a-zA-Z]*[[:space:]]+|--recursive[[:space:]]+)(([^[:space:];&|#]*/)?(node_modules|\.next|dist|__pycache__|\.cache|build|\.turbo|coverage)[[:space:]]*)+$' 2>/dev/null; then
echo '{}'
exit 0
fi
# --- Destructive pattern checks ---
+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.
- A real iPhone running iOS 16 or later. Unlocked, paired with your Mac, with **Developer Mode** enabled in Settings → Privacy & Security.
- An Apple developer team — the free personal team works fine for live-device debug deploys. You'll need the team ID (e.g. `623FYQ2M88`), not the certificate ID. Find it in Xcode → Settings → Accounts → your Apple ID → team list. The setup signs the app for your device on first deploy via `-allowProvisioningUpdates -allowProvisioningDeviceRegistration`.
- gstack installed (`./setup` complete; `bin/gstack-ios-qa-daemon` must be on disk and executable).
- gstack installed (`./setup` complete; `gstack-ios-qa-regen` and `gstack-ios-qa-daemon` must be on PATH).
- Bun runtime on PATH (`bun --version`). The Mac-side daemon is a bun process.
For the optional remote-agent (Tailscale) mode, you'll additionally need Tailscale installed on the Mac with `/var/run/tailscale.sock` readable.
@@ -30,28 +30,49 @@ For the optional remote-agent (Tailscale) mode, you'll additionally need Tailsca
The iOS `StateServer` is loopback-only **always**, even in remote mode. Identity validation happens Mac-side because the iPhone has no way to validate a Tailscale identity.
## Step 1: Add the DebugBridge templates to your iOS app
## Step 1: Generate the DebugBridge package
The templates live at `~/.claude/skills/gstack/ios-qa/templates/` after `./setup`. The fastest install is to invoke the `/ios-qa` skill in Claude Code from your app's root — it reads your Swift source, codegens typed `@Observable` state accessors, and lays down the templates with your bundle ID. Or do it by hand:
Run `/ios-qa` from the app root, or invoke the same deterministic regenerator directly:
1. Copy these into a `DebugBridge/` SPM package inside your app workspace:
- `Sources/DebugBridgeCore/StateServer.swift` (from `StateServer.swift.template`)
- `Sources/DebugBridgeCore/DebugBridgeManager.swift` (from `DebugBridgeManager.swift.template`)
- `Sources/DebugBridgeTouch/DebugBridgeTouch.m` + `Sources/DebugBridgeTouch/include/DebugBridgeTouch.h` (from the two `.template` files)
- `Sources/DebugBridgeUI/Bridges.swift` (from `Bridges.swift.template`)
- `Sources/DebugBridgeUI/DebugOverlay.swift` (from `DebugOverlay.swift.template`)
- `Package.swift` (from `Package.swift.template`)
2. Add the package as a local dependency of your app. Depend on the `DebugBridgeUI` product with `condition: .when(configuration: .debug)`. `DebugBridgeCore` and `DebugBridgeTouch` come in transitively.
3. In your `@main` App init, gate the wiring on `#if DEBUG`:
```bash
gstack-ios-qa-regen \
--app-source "$PWD/Sources/YourApp" \
--bridge-dir "$PWD/DebugBridge"
```
The command copies an explicit allowlist of canonical templates into the local
`DebugBridge/` Swift package, generates
`DebugBridgeGenerated/StateAccessor.swift`, and writes the installed version to
`DebugBridgeGenerated/.gstack-version`. It excludes generated output from its
own schema hash, so rerunning it with unchanged source is a fast, byte-stable
cache hit. It also removes the explicit legacy generated-file set from older
flat harness layouts so stale bridge sources cannot shadow the package.
1. Add `DebugBridge/` as a local package dependency. Depend on the
`DebugBridgeUI` product only in Debug configuration; `DebugBridgeCore` and
`DebugBridgeTouch` come in transitively.
2. Add `DebugBridgeGenerated/StateAccessor.swift` to the app target.
3. In your `@main` App init, install the UIKit resolvers before starting the
server, then register the generated accessor. Replace the example
state/accessor names with the type the generator found:
```swift
#if DEBUG
import DebugBridgeCore
StateServer.shared.start()
#if canImport(UIKit)
import DebugBridgeUI
#endif
#endif
// Inside App.init(), after appState is initialized:
#if DEBUG
#if canImport(UIKit)
DebugBridgeUIWiring.installAll()
#endif
DebugBridgeManager.shared.start(
appState: appState,
register: AppStateAccessor.register
)
#endif
```
@@ -109,7 +130,16 @@ GSTACK_IOS_TARGET_BUNDLE_ID=com.yourorg.yourapp
GSTACK_IOS_DAEMON_PORT=9099 # loopback listener port; default 9099
```
If `GSTACK_IOS_TARGET_UDID` is unset, the daemon picks the first paired connected device.
If `GSTACK_IOS_TARGET_UDID` is unset, the daemon picks the best paired,
available iPhone.
Automatic selection is restricted to available iPhones and prefers a wired
phone. The daemon keeps a healthy rotated tunnel, then invalidates and
rebootstraps once on an app-relaunch 401 or recoverable CoreDevice connection
failure.
If a newly started daemon reaches an already-running target whose one-use boot
token was consumed by an earlier daemon, it verifies the bundle owner, force
relaunches that target once, waits for a fresh token, verifies ownership again,
and rotates normally.
## Step 4: Drive the device
@@ -123,15 +153,39 @@ Once the daemon is running, you have an HTTP surface at `http://127.0.0.1:9099`
| `POST /session/release` | Release the lock. | bearer + session |
| `GET /screenshot` | Capture a PNG of the active window. Returns `{png_base64: "..."}`. | bearer |
| `GET /elements` | Accessibility-tree snapshot. | bearer |
| `GET /state/snapshot` | Dump every `@Snapshotable` field as JSON. | bearer |
| `POST /state/restore` | Atomically restore a full snapshot. | bearer + session, mutate tier |
| `GET /state/snapshot` | Dump every `// @Snapshotable` field as JSON. | bearer |
| `POST /state/restore` | Validate the full snapshot, then restore it on MainActor. | bearer + session, mutate tier |
| `POST /tap` `{x,y}` | Synthesize a real UITouch at window coordinates. SwiftUI Buttons fire. | bearer + session, interact tier |
| `POST /swipe` `{from_x,from_y,to_x,to_y}` | Scroll the nearest enclosing UIScrollView. | bearer + session, interact tier |
| `POST /type` `{text}` | Set text on the current first responder. | bearer + session, interact tier |
Mutating requests require both an `Authorization: Bearer <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)
+2 -3
View File
@@ -821,9 +821,8 @@ Each item is reverted only after AskUserQuestion confirmation:
1. The `DebugBridge` SPM target from `Package.swift`.
2. The `#if DEBUG` block in the app's `@main` entry that calls
`DebugBridgeManager.shared.start()`.
3. Any `@Snapshotable` property wrappers on the canonical app state struct
(the codegen-detection markers — the wrapper file lives inside
DebugBridge so removing the SPM dep removes the wrapper too).
3. Any standalone `// @Snapshotable` generator marker comments on the
canonical app state class.
4. Generated `StateAccessor.swift` files anywhere under the app source.
5. The `gstack-ios-qa.token` file under `NSTemporaryDirectory()` on the
device (best-effort — only works if device is connected when /ios-clean
+2 -3
View File
@@ -50,9 +50,8 @@ Each item is reverted only after AskUserQuestion confirmation:
1. The `DebugBridge` SPM target from `Package.swift`.
2. The `#if DEBUG` block in the app's `@main` entry that calls
`DebugBridgeManager.shared.start()`.
3. Any `@Snapshotable` property wrappers on the canonical app state struct
(the codegen-detection markers — the wrapper file lives inside
DebugBridge so removing the SPM dep removes the wrapper too).
3. Any standalone `// @Snapshotable` generator marker comments on the
canonical app state class.
4. Generated `StateAccessor.swift` files anywhere under the app source.
5. The `gstack-ios-qa.token` file under `NSTemporaryDirectory()` on the
device (best-effort — only works if device is connected when /ios-clean
+39 -15
View File
@@ -869,17 +869,33 @@ fi
## Phase 1: Read source, plan codegen
1. Walk the app source (passed as `--source <dir>`) and identify all `@Observable`
classes. Note any property marked with the `@Snapshotable` wrapper — those
are the snapshot-eligible fields.
2. Run `swift run --package-path $GSTACK_HOME/ios-qa/scripts/gen-accessors-tool gen-accessors --input <source-dir>`.
First invocation builds the swift-syntax dependency tree (cold: 2-5 min).
Subsequent runs are content-hash-cached and finish in ~50ms.
3. Show the user the accessor list and ask whether to install the DebugBridge
classes. Note any property immediately preceded by the generator marker
comment `// @Snapshotable` — those are the snapshot-eligible fields. The
marker is a comment so it composes with the `@Observable` macro. Each
marked field must belong to a file-scope observable class and be a writable
instance `var` with an explicit type and an internal or public setter.
Snapshot types are JSON-native scalars (`String`, `Bool`, integer widths,
`Float`, `Double`, `CGFloat`), arrays, String-keyed dictionaries, and their
Optional compositions. Keys must be unique across observable classes.
Codegen stops with a source diagnostic instead of emitting a broken or
lossy harness when any of these constraints is violated.
2. Show the user the accessor list and ask whether to install the DebugBridge
SPM dependency into their `Package.swift` (one AskUserQuestion).
## Phase 2: Bootstrap the device bridge
1. Add the `DebugBridge` SPM dependency to the app's `Package.swift`. The package
1. Generate the canonical local bridge package, typed accessors, and installed
version marker with one deterministic command:
```bash
~/.claude/skills/gstack/bin/gstack-ios-qa-regen \
--app-source "<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:
- `DebugBridgeCore` (Swift, cross-platform) — StateServer + bridge protocols.
- `DebugBridgeTouch` (Objective-C, iOS-only) — KIF-derived in-process touch
@@ -889,27 +905,35 @@ fi
The app target depends on `DebugBridgeUI` with `.when(configuration: .debug)`
(transitively pulls in Core + Touch). Release builds refuse to link these
targets.
2. Wire the bridges from the `@main` App init, gated on `#if DEBUG`:
3. Wire the bridges from the `@main` App init, gated on `#if DEBUG`:
```swift
#if DEBUG
import DebugBridgeCore
StateServer.shared.start()
#if canImport(UIKit)
import DebugBridgeUI
// Install resolvers before StateServer opens its listener.
DebugBridgeUIWiring.installAll()
#endif
// Replace AppState/AppStateAccessor with the type discovered in Phase 1.
DebugBridgeManager.shared.start(
appState: appState,
register: AppStateAccessor.register
)
#endif
```
3. Build + deploy to the device with `xcodebuild -scheme <SchemeName>
4. Build + deploy to the device with `xcodebuild -scheme <SchemeName>
-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.
5. Spawn the Mac-side daemon (on-demand) — `gstack-ios-qa-daemon`. Daemon
6. Spawn the Mac-side daemon (on-demand) — `gstack-ios-qa-daemon`. Daemon
acquires an exclusive flock on `~/.gstack/ios-qa-daemon.pid`. If another
daemon is alive, the second invocation discovers its port and connects.
6. Daemon immediately calls `POST /auth/rotate` on the iOS StateServer with a
7. Daemon immediately calls `POST /auth/rotate` on the iOS StateServer with a
fresh in-memory-only token. The boot token becomes useless ~5s later.
Anything scraping `os_log` past this point sees a dead credential.
If a fresh daemon finds the app running after another daemon consumed that
one-use token, it verifies the bundle owner, relaunches the target once,
waits for the new token, verifies ownership again, and then rotates.
## Phase 3: Vision-driven agent loop
@@ -917,7 +941,7 @@ Each iteration:
1. `GET /screenshot` (via daemon) → save PNG.
2. `GET /elements` → accessibility tree.
3. `GET /state/snapshot` (only `@Snapshotable` fields) → current state.
3. `GET /state/snapshot` (only `// @Snapshotable` fields) → current state.
4. Decide next action based on what's on the screen vs the test goal.
5. `POST /session/acquire` to grab the device lock.
6. Execute `POST /tap`, `/swipe`, `/type`, or `POST /state/<key>` write.
@@ -981,7 +1005,7 @@ live.
| `curl: connection refused` to daemon | daemon crashed | Re-run `/ios-qa`; spawn-race lock will fail closed |
| `403 identity_not_allowed` from `/auth/mint` | identity missing from allowlist | Run `gstack-ios-qa-mint --remote <identity>` on the Mac |
| `409 schema_mismatch` on `/state/restore` | snapshot from older app build | Discard the snapshot; re-capture |
| `503 device_disconnected` from proxy | USB tunnel dropped | Reconnect device; daemon auto-reconnects within 30s |
| `503 device_disconnected` from proxy | USB route dropped or app relaunched | Daemon invalidates the stale tunnel and retries one fresh bootstrap; reconnect/unlock the iPhone if it persists |
| `429 rate_limited` from `/auth/mint` | >10 mints/min from one identity | Wait 60s; check audit log for anomalies |
| `413 body_too_large` on `/state/restore` | snapshot >1MB | Increase `--max-body` or trim snapshot |
+39 -15
View File
@@ -97,17 +97,33 @@ fi
## Phase 1: Read source, plan codegen
1. Walk the app source (passed as `--source <dir>`) and identify all `@Observable`
classes. Note any property marked with the `@Snapshotable` wrapper — those
are the snapshot-eligible fields.
2. Run `swift run --package-path $GSTACK_HOME/ios-qa/scripts/gen-accessors-tool gen-accessors --input <source-dir>`.
First invocation builds the swift-syntax dependency tree (cold: 2-5 min).
Subsequent runs are content-hash-cached and finish in ~50ms.
3. Show the user the accessor list and ask whether to install the DebugBridge
classes. Note any property immediately preceded by the generator marker
comment `// @Snapshotable` — those are the snapshot-eligible fields. The
marker is a comment so it composes with the `@Observable` macro. Each
marked field must belong to a file-scope observable class and be a writable
instance `var` with an explicit type and an internal or public setter.
Snapshot types are JSON-native scalars (`String`, `Bool`, integer widths,
`Float`, `Double`, `CGFloat`), arrays, String-keyed dictionaries, and their
Optional compositions. Keys must be unique across observable classes.
Codegen stops with a source diagnostic instead of emitting a broken or
lossy harness when any of these constraints is violated.
2. Show the user the accessor list and ask whether to install the DebugBridge
SPM dependency into their `Package.swift` (one AskUserQuestion).
## Phase 2: Bootstrap the device bridge
1. Add the `DebugBridge` SPM dependency to the app's `Package.swift`. The package
1. Generate the canonical local bridge package, typed accessors, and installed
version marker with one deterministic command:
```bash
~/.claude/skills/gstack/bin/gstack-ios-qa-regen \
--app-source "<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:
- `DebugBridgeCore` (Swift, cross-platform) — StateServer + bridge protocols.
- `DebugBridgeTouch` (Objective-C, iOS-only) — KIF-derived in-process touch
@@ -117,27 +133,35 @@ fi
The app target depends on `DebugBridgeUI` with `.when(configuration: .debug)`
(transitively pulls in Core + Touch). Release builds refuse to link these
targets.
2. Wire the bridges from the `@main` App init, gated on `#if DEBUG`:
3. Wire the bridges from the `@main` App init, gated on `#if DEBUG`:
```swift
#if DEBUG
import DebugBridgeCore
StateServer.shared.start()
#if canImport(UIKit)
import DebugBridgeUI
// Install resolvers before StateServer opens its listener.
DebugBridgeUIWiring.installAll()
#endif
// Replace AppState/AppStateAccessor with the type discovered in Phase 1.
DebugBridgeManager.shared.start(
appState: appState,
register: AppStateAccessor.register
)
#endif
```
3. Build + deploy to the device with `xcodebuild -scheme <SchemeName>
4. Build + deploy to the device with `xcodebuild -scheme <SchemeName>
-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.
5. Spawn the Mac-side daemon (on-demand) — `gstack-ios-qa-daemon`. Daemon
6. Spawn the Mac-side daemon (on-demand) — `gstack-ios-qa-daemon`. Daemon
acquires an exclusive flock on `~/.gstack/ios-qa-daemon.pid`. If another
daemon is alive, the second invocation discovers its port and connects.
6. Daemon immediately calls `POST /auth/rotate` on the iOS StateServer with a
7. Daemon immediately calls `POST /auth/rotate` on the iOS StateServer with a
fresh in-memory-only token. The boot token becomes useless ~5s later.
Anything scraping `os_log` past this point sees a dead credential.
If a fresh daemon finds the app running after another daemon consumed that
one-use token, it verifies the bundle owner, relaunches the target once,
waits for the new token, verifies ownership again, and then rotates.
## Phase 3: Vision-driven agent loop
@@ -145,7 +169,7 @@ Each iteration:
1. `GET /screenshot` (via daemon) → save PNG.
2. `GET /elements` → accessibility tree.
3. `GET /state/snapshot` (only `@Snapshotable` fields) → current state.
3. `GET /state/snapshot` (only `// @Snapshotable` fields) → current state.
4. Decide next action based on what's on the screen vs the test goal.
5. `POST /session/acquire` to grab the device lock.
6. Execute `POST /tap`, `/swipe`, `/type`, or `POST /state/<key>` write.
@@ -209,7 +233,7 @@ live.
| `curl: connection refused` to daemon | daemon crashed | Re-run `/ios-qa`; spawn-race lock will fail closed |
| `403 identity_not_allowed` from `/auth/mint` | identity missing from allowlist | Run `gstack-ios-qa-mint --remote <identity>` on the Mac |
| `409 schema_mismatch` on `/state/restore` | snapshot from older app build | Discard the snapshot; re-capture |
| `503 device_disconnected` from proxy | USB tunnel dropped | Reconnect device; daemon auto-reconnects within 30s |
| `503 device_disconnected` from proxy | USB route dropped or app relaunched | Daemon invalidates the stale tunnel and retries one fresh bootstrap; reconnect/unlock the iPhone if it persists |
| `429 rate_limited` from `/auth/mint` | >10 mints/min from one identity | Wait 60s; check audit log for anomalies |
| `413 body_too_large` on `/state/restore` | snapshot >1MB | Increase `--max-body` or trim snapshot |
+6
View File
@@ -13,7 +13,10 @@ export interface DeviceEntry {
identifier: string;
name: string;
model: string;
platform: string; // "iOS" | "iPadOS" | "visionOS" | ...
deviceType: string; // "iPhone" | "iPad" | "realityDevice" | ...
state: string; // "connected" | "available" | "available (paired)" | ...
transport: string; // "wired" on USB devices; empty for stale/unavailable entries
paired: boolean;
}
@@ -83,7 +86,10 @@ export function listDevices(spawn: SpawnImpl = defaultSpawn): DeviceEntry[] {
identifier: String(d.identifier ?? ''),
name: String(props?.name ?? 'unknown'),
model: String(hw?.productType ?? 'unknown'),
platform: String(hw?.platform ?? ''),
deviceType: String(hw?.deviceType ?? ''),
state: String(conn?.tunnelState ?? 'unknown'),
transport: String(conn?.transportType ?? ''),
paired: pairingState === 'paired',
};
});
+157 -27
View File
@@ -62,16 +62,36 @@ export async function startDaemon(opts: DaemonOptions): Promise<RunningDaemon |
const tokenStore = new SessionTokenStore();
let tunnel: DeviceTunnel | null = null;
let cachedTunnelAt = 0;
let tunnelInFlight: Promise<DeviceTunnel | null> | null = null;
const getTunnel = async (): Promise<DeviceTunnel | null> => {
// Cache the tunnel for 30s; refresh on demand.
if (tunnel && Date.now() - cachedTunnelAt < 30_000) return tunnel;
if (opts.tunnelProvider) {
tunnel = await opts.tunnelProvider();
cachedTunnelAt = Date.now();
// A successful bootstrap consumes and deletes the app's one-shot boot
// token. Keep that rotated tunnel for this daemon's lifetime instead of
// trying to bootstrap it again on a timer. Failed attempts are not cached.
if (tunnel) return tunnel;
if (!opts.tunnelProvider) return null;
// Multiple first requests can arrive before bootstrap completes. Share
// one provider call so they cannot race through independent rotations.
if (!tunnelInFlight) {
tunnelInFlight = Promise.resolve()
.then(() => opts.tunnelProvider!())
.then((candidate) => {
if (candidate) tunnel = candidate;
return candidate;
})
.finally(() => {
tunnelInFlight = null;
});
}
return tunnel;
return tunnelInFlight;
};
const invalidateTunnel = (failedTunnel: DeviceTunnel): void => {
// A late response from the old app must not evict a tunnel that another
// request has already refreshed. Object identity gives each bootstrap a
// cheap generation token without exposing generation state elsewhere.
if (tunnel === failedTunnel) tunnel = null;
};
// 2. Tailnet probe (fail-closed).
@@ -86,7 +106,7 @@ export async function startDaemon(opts: DaemonOptions): Promise<RunningDaemon |
// 3. Loopback listener (full surface).
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.
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
// fails (tests don't exercise ::1 explicitly).
const loopbackServerV6 = createServer(async (req, res) => {
await handleLoopback({ req, res, tokenStore, getTunnel });
await handleLoopback({ req, res, tokenStore, getTunnel, invalidateTunnel });
});
let v6Bound = false;
try {
@@ -118,6 +138,7 @@ export async function startDaemon(opts: DaemonOptions): Promise<RunningDaemon |
res,
tokenStore,
getTunnel,
invalidateTunnel,
auditPath: opts.auditPath,
attemptsPath: opts.attemptsPath,
allowlistPath: opts.allowlistPath,
@@ -181,12 +202,113 @@ interface HandlerCtx {
res: ServerResponse;
tokenStore: SessionTokenStore;
getTunnel: () => Promise<DeviceTunnel | null>;
invalidateTunnel: (failedTunnel: DeviceTunnel) => void;
// Explicit security-log + allowlist paths (default to env-derived when undefined).
auditPath?: string;
attemptsPath?: 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' }> {
return new Promise((resolve, reject) => {
const chunks: Buffer[] = [];
@@ -228,7 +350,7 @@ function sendJson(res: ServerResponse, status: number, body: unknown): void {
* loopback bind itself is the boundary).
*/
async function handleLoopback(ctx: HandlerCtx): Promise<void> {
const { req, res, tokenStore, getTunnel } = ctx;
const { req, res, tokenStore, getTunnel, invalidateTunnel } = ctx;
const url = parseUrl(req.url ?? '/');
const path = url.pathname ?? '/';
const method = req.method ?? 'GET';
@@ -262,16 +384,23 @@ async function handleLoopback(ctx: HandlerCtx): Promise<void> {
}
// Other endpoints — proxy to the device.
const tunnel = await getTunnel();
if (!tunnel) {
sendJson(res, 503, { error: 'device_not_connected' });
return;
}
const body = await readBody(req);
if ('error' in body) { sendJson(res, 413, body); return; }
const sessionId = (req.headers['x-session-id'] as string | undefined) ?? null;
const agentIdentity = (req.headers['x-agent-identity'] as string | undefined) ?? undefined;
const upstream = await proxyToDevice({ inbound: req, body, tunnel, sessionId, agentIdentity });
const proxied = await proxyWithTunnelRecovery({
inbound: req,
body,
sessionId,
agentIdentity,
getTunnel,
invalidateTunnel,
});
if (!proxied) {
sendJson(res, 503, { error: 'device_not_connected' });
return;
}
const { upstream } = proxied;
res.writeHead(upstream.status, upstream.headers);
res.end(upstream.body);
} catch (err) {
@@ -287,7 +416,7 @@ interface TailnetCtx extends HandlerCtx {
* Tailnet handler locked allowlist + capability tiers.
*/
async function handleTailnet(ctx: TailnetCtx): Promise<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 path = url.pathname ?? '/';
const method = req.method ?? 'GET';
@@ -375,19 +504,20 @@ async function handleTailnet(ctx: TailnetCtx): Promise<void> {
}
// Proxy to device.
const tunnel = await getTunnel();
if (!tunnel) {
const sessionId = (req.headers['x-session-id'] as string | undefined) ?? null;
const proxied = await proxyWithTunnelRecovery({
inbound: req,
body,
sessionId,
agentIdentity: session.identity,
getTunnel,
invalidateTunnel,
});
if (!proxied) {
sendJson(res, 503, { error: 'device_not_connected' });
return;
}
const sessionId = (req.headers['x-session-id'] as string | undefined) ?? null;
const upstream = await proxyToDevice({
inbound: req,
body,
tunnel,
sessionId,
agentIdentity: session.identity,
});
const { tunnel, upstream } = proxied;
// Audit the action (mutating endpoints only).
if (requiredCapability !== 'observe') {
+183 -19
View File
@@ -5,6 +5,8 @@
// 2. launch the app on it (no-op if already running)
// 3. wait briefly for the in-app StateServer to start
// 4. copy the boot token from the app's sandbox via devicectl copy from
// If an earlier daemon already consumed it, relaunch the app once to mint
// a fresh boot token, then verify the relaunched StateServer again.
// 5. POST /auth/rotate to swap boot token → fresh in-memory token
// 6. return a DeviceTunnel pointing at the device's IPv6 with the rotated
// bearer that subsequent proxied requests carry
@@ -14,6 +16,7 @@
// live token, which it scopes per-tailnet-session via /auth/mint.
import { randomBytes } from 'crypto';
import { spawnSync } from 'child_process';
import type { DeviceTunnel } from './proxy';
import {
listDevices,
@@ -21,12 +24,13 @@ import {
isAppRunning,
launchApp,
copyFileFromAppContainer,
type DeviceEntry,
type SpawnImpl,
type ResolveImpl,
} from './devicectl';
export interface BootstrapOptions {
/** Target device UDID. If null, picks the first connected paired device. */
/** Target iPhone UDID. If null, picks the best connected paired iPhone. */
udid?: string;
/** Bundle ID of the iOS app hosting the StateServer. */
bundleId: string;
@@ -53,10 +57,85 @@ export type BootstrapErrorReason =
| 'launch_failed'
| 'device_locked'
| 'state_server_unreachable'
| 'wrong_app'
| 'boot_token_unavailable'
| 'rotate_failed'
| 'resolve_failed';
function isIPhoneDevice(device: DeviceEntry): boolean {
const platform = device.platform.trim().toLowerCase();
const deviceType = device.deviceType.trim().toLowerCase();
const model = device.model.trim().toLowerCase();
// productType is present even on older CoreDevice versions. Prefer the
// explicit platform/type fields when available, but retain productType as
// a compatibility fallback. An explicit non-iOS platform always loses.
if (platform && platform !== 'ios') return false;
return deviceType === 'iphone' || model.startsWith('iphone');
}
function isAvailableDevice(device: Pick<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
* 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
? devices.find((d) => d.identifier === opts.udid)
: devices.find((d) => d.paired) ?? devices[0];
: pickDefaultDevice(devices);
if (!target) {
return { ok: false, error: 'device_not_found', detail: opts.udid };
if (opts.udid) {
return { ok: false, error: 'device_not_found', detail: opts.udid };
}
const pairedIPhone = devices.find((d) => d.paired && isIPhoneDevice(d));
if (pairedIPhone) {
return {
ok: false,
error: 'device_not_found',
detail: `paired iPhone ${pairedIPhone.name} (${pairedIPhone.identifier}) is ${pairedIPhone.state}; connect it over USB and unlock it`,
};
}
const firstIPhone = devices.find(isIPhoneDevice);
if (!firstIPhone) {
return {
ok: false,
error: 'device_not_found',
detail: 'no iPhone is connected; non-iOS devices are not eligible for iOS QA',
};
}
return {
ok: false,
error: 'no_paired_device',
detail: `device ${firstIPhone.name} (${firstIPhone.identifier}) is ${firstIPhone.state}; run \`xcrun devicectl manage pair --device ${firstIPhone.identifier}\` and tap Trust on the iPhone`,
};
}
if (!isIPhoneDevice(target)) {
return {
ok: false,
error: 'device_not_found',
detail: `device ${target.name} (${target.identifier}) is ${target.platform || target.model}, not an iPhone`,
};
}
if (!target.paired) {
return {
@@ -88,6 +197,13 @@ export async function bootstrapTunnel(opts: BootstrapOptions): Promise<Bootstrap
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)
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.
// Probe /healthz with retries (the listener can take a moment to bind).
const deadline = Date.now() + startupTimeoutMs;
let healthOK = false;
while (Date.now() < deadline) {
try {
const r = await fetchFn(`http://[${ipv6}]:${port}/healthz`, {
signal: AbortSignal.timeout(2_000),
});
if (r.ok) { healthOK = true; break; }
} catch { /* retry */ }
await new Promise((res) => setTimeout(res, 250));
}
if (!healthOK) {
return { ok: false, error: 'state_server_unreachable', detail: `no /healthz response from [${ipv6}]:${port} within ${startupTimeoutMs}ms` };
}
const waitForStateServer = async (): Promise<BootstrapResult | null> => {
const deadline = Date.now() + startupTimeoutMs;
while (Date.now() < deadline) {
try {
const r = await fetchFn(`http://[${ipv6}]:${port}/healthz`, {
signal: AbortSignal.timeout(2_000),
});
if (r.ok) {
const health = await r.json().catch(() => null) as { bundle_id?: string } | null;
// Older bridges did not identify their bundle. Preserve compatibility,
// but reject an explicit mismatch from current bridges: another debug
// app already owns the fixed StateServer port on this device.
if (health?.bundle_id && health.bundle_id !== opts.bundleId) {
return {
ok: false,
error: 'wrong_app',
detail: `expected ${opts.bundleId} but StateServer port ${port} belongs to ${health.bundle_id}; terminate the other debug app`,
};
}
return null;
}
} catch { /* retry */ }
await new Promise((res) => setTimeout(res, 250));
}
return {
ok: false,
error: 'state_server_unreachable',
detail: `no /healthz response from [${ipv6}]:${port} within ${startupTimeoutMs}ms`,
};
};
const bootToken = copyFileFromAppContainer({
const healthFailure = await waitForStateServer();
if (healthFailure) return healthFailure;
const readBootToken = () => copyFileFromAppContainer({
udid: target.identifier,
bundleId: opts.bundleId,
sourceRelativePath: tokenPath,
spawn,
});
let bootToken = readBootToken();
if (!bootToken) {
return { ok: false, error: 'boot_token_unavailable', detail: `couldn't read ${tokenPath} from ${opts.bundleId}` };
// A healthy running app can lack a boot token when an earlier daemon
// already rotated it. A new daemon has no way to recover that in-memory
// bearer, so restart exactly once to make StateServer mint a fresh one.
// The explicit bundle check above prevents disrupting an unrelated app
// that happens to own the fixed StateServer port.
const relaunched = relaunchApp(target.identifier, opts.bundleId, spawn);
if (!relaunched.ok) {
return { ok: false, error: relaunched.error, detail: relaunched.detail };
}
// The token is written before StateServer opens its listener. Waiting for
// it first prevents a stale response from the terminating process from
// being mistaken for readiness of the replacement process.
const tokenDeadline = Date.now() + startupTimeoutMs;
while (!bootToken && Date.now() < tokenDeadline) {
bootToken = readBootToken();
if (!bootToken) await new Promise((res) => setTimeout(res, 250));
}
if (!bootToken) {
return {
ok: false,
error: 'boot_token_unavailable',
detail: `couldn't read ${tokenPath} from ${opts.bundleId} after relaunch`,
};
}
const relaunchedHealthFailure = await waitForStateServer();
if (relaunchedHealthFailure) return relaunchedHealthFailure;
}
// Step 5: rotate the boot token to a fresh in-memory-only one.
@@ -132,6 +132,258 @@ describe('daemon — loopback listener', () => {
expect(lastReq?.headers['x-session-id']).toBe('sess-loopback-1');
});
test('concurrent first requests share one tunnel bootstrap', async () => {
let bootstraps = 0;
let releaseBootstrap!: () => void;
let markBootstrapStarted!: () => void;
const bootstrapStarted = new Promise<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 () => {
// Force tunnel provider to return null by closing + restarting with null provider.
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 () => {
const spawn = makeSpawn([
{
@@ -166,6 +379,48 @@ describe('bootstrapTunnel', () => {
if (!r.ok) expect(r.error).toBe('state_server_unreachable');
});
test('rejects a StateServer owned by a different app bundle', async () => {
const spawn = makeSpawn([
{
argsMatch: /devicectl list devices/,
jsonOutput: {
result: { devices: [{
identifier: 'TEST',
connectionProperties: { tunnelState: 'connected', pairingState: 'paired' },
deviceProperties: { name: 'Test' },
hardwareProperties: { productType: 'iPhone18,2' },
}] },
},
},
{
argsMatch: /devicectl device info processes/,
jsonOutput: { result: { runningProcesses: [] } },
},
{
argsMatch: /devicectl device process launch/,
},
{
argsMatch: /devicectl device info details/,
jsonOutput: { result: { connectionProperties: { tunnelIPAddress: 'fd00::9' } } },
},
]);
const r = await bootstrapTunnel({
bundleId: 'com.expected.app',
spawnImpl: spawn,
fetchImpl: (async () => new Response(
JSON.stringify({ bundle_id: 'com.other.debug-app' }),
{ status: 200, headers: { 'content-type': 'application/json' } },
)) as typeof fetch,
});
expect(r.ok).toBe(false);
if (!r.ok) {
expect(r.error).toBe('wrong_app');
expect(r.detail).toContain('com.other.debug-app');
}
});
test('happy path: returns DeviceTunnel with rotated token', async () => {
const spawn = makeSpawn([
{
@@ -234,6 +489,125 @@ describe('bootstrapTunnel', () => {
expect(fetchCalls[fetchCalls.length - 1]?.url).toContain('/auth/rotate');
});
test('relaunches once when a prior daemon consumed the running app boot token', async () => {
const spawn = makeSpawn([
{
argsMatch: /devicectl list devices/,
jsonOutput: {
result: { devices: [{
identifier: 'TEST-UDID',
connectionProperties: { tunnelState: 'connected', pairingState: 'paired' },
deviceProperties: { name: 'Test Device' },
hardwareProperties: { productType: 'iPhone18,2' },
}] },
},
},
{
argsMatch: /devicectl device info processes/,
jsonOutput: { result: { runningProcesses: [{ executable: 'file:\/\/\/var\/containers\/Bundle\/Application\/X\/com.test.app\/com.test' }] } },
},
{
argsMatch: /devicectl device info details/,
jsonOutput: { result: { connectionProperties: { tunnelIPAddress: 'fd99::beef' } } },
},
{
// A prior daemon rotated the one-use token, so StateServer deleted it.
argsMatch: /devicectl device copy from/,
exitCode: 1,
stderr: 'source does not exist',
},
{
argsMatch: /devicectl device process launch --device TEST-UDID --terminate-existing com\.test/,
},
{
argsMatch: /devicectl device copy from/,
destOutput: 'FRESH-BOOT-TOKEN\n',
},
]);
const fetchCalls: Array<{ url: string; authorization?: string }> = [];
const r = await bootstrapTunnel({
bundleId: 'com.test',
spawnImpl: spawn,
fetchImpl: (async (url, init) => {
const u = String(url);
const headers = init?.headers as Record<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 () => {
const spawn = makeSpawn([
{
@@ -1,8 +1,8 @@
// swift-tools-version:5.9
//
// gen-accessors-tool SwiftPM tool that reads an app's Swift source via
// swift-syntax, finds @Observable classes with @Snapshotable-marked fields,
// and emits StateAccessor.swift for each one.
// swift-syntax, finds @Observable classes with `// @Snapshotable`-marked
// fields, and emits StateAccessor.swift for each one.
//
// First build is 2-5 min on a cold machine (swift-syntax compile chain).
// Subsequent runs are content-hash-cached and finish in ~50ms.
@@ -31,10 +31,5 @@ let package = Package(
],
path: "Sources/GenAccessors"
),
.testTarget(
name: "GenAccessorsTests",
dependencies: ["GenAccessors"],
path: "Tests/GenAccessorsTests"
),
]
)
@@ -1,6 +1,7 @@
// gen-accessors entry point. Walks the input dir for *.swift files, parses
// each via SwiftParser, finds @Observable classes with @Snapshotable-marked
// properties, and emits StateAccessor.swift for each.
// each via SwiftParser, finds @Observable classes with `// @Snapshotable`
// source-marker comments (plus the legacy attribute form), and emits
// StateAccessor.swift for each.
//
// Output goes to --output (default: same dir as input). Cache key is
// computed from a composite hash and stored at
@@ -15,6 +16,8 @@ struct AccessorSpec {
let fields: [(name: String, typeText: String)]
}
private let generatorFormatVersion = "accessor-generator-v5"
@main
struct GenAccessors {
static func main() async {
@@ -32,38 +35,83 @@ struct GenAccessors {
}()
// Walk + collect *.swift files
guard let swiftFiles = collectSwiftFiles(at: inputDir) else {
guard let swiftFiles = collectSwiftFiles(at: inputDir, excluding: outputDir) else {
FileHandle.standardError.write(Data("input dir not found: \(inputDir)\n".utf8))
exit(3)
}
// Composite cache key codex catch (source content alone misses
// generator-logic changes).
let cacheKey = computeCacheKey(swiftFiles: swiftFiles)
let cacheDir = ("~/.gstack/cache/gen-accessors" as NSString).expandingTildeInPath
// Parse + validate before consulting the cache. Invalid marked fields
// must fail deterministically rather than being hidden by an older
// cached output.
var specs: [AccessorSpec] = []
var diagnostics: [String] = []
for path in swiftFiles {
guard let source = try? String(contentsOfFile: path, encoding: .utf8) else { continue }
let tree = Parser.parse(source: source)
let visitor = ObservableClassVisitor(sourcePath: path, viewMode: .sourceAccurate)
visitor.walk(tree)
specs.append(contentsOf: visitor.specs)
diagnostics.append(contentsOf: visitor.diagnostics)
}
var snapshotKeyOwners: [String: String] = [:]
for spec in specs {
for field in spec.fields {
if let previous = snapshotKeyOwners[field.name] {
diagnostics.append(
"snapshot key '\(field.name)' is declared by both \(previous) and \(spec.className); "
+ "keys must be unique across @Observable types"
)
} else {
snapshotKeyOwners[field.name] = spec.className
}
}
}
if !diagnostics.isEmpty {
let message = "gen-accessors: invalid @Snapshotable declaration(s):\n"
+ diagnostics.map { " - \($0)" }.joined(separator: "\n") + "\n"
FileHandle.standardError.write(Data(message.utf8))
exit(4)
}
let buildId = detectedBuildId()
let accessorHash = computeAccessorHash(specs: specs)
// Cache identity includes build provenance and generator ABI. The
// separately computed accessorHash is schema-only and remains stable
// across checkout paths, unrelated sources, and app rebuilds.
let cacheKey = computeCacheKey(swiftFiles: swiftFiles, buildId: buildId)
let cacheDir = getEnv("GSTACK_IOS_CACHE_ROOT")
?? ("~/.gstack/cache/gen-accessors" as NSString).expandingTildeInPath
let cachedOutput = "\(cacheDir)/\(cacheKey)/StateAccessor.swift"
do {
try FileManager.default.createDirectory(atPath: outputDir, withIntermediateDirectories: true)
} catch {
FileHandle.standardError.write(Data("gen-accessors: cannot create output directory: \(error)\n".utf8))
exit(5)
}
if FileManager.default.fileExists(atPath: cachedOutput) {
// Cache hit. Copy to output dir.
try? FileManager.default.removeItem(atPath: "\(outputDir)/StateAccessor.swift")
try? FileManager.default.copyItem(atPath: cachedOutput, toPath: "\(outputDir)/StateAccessor.swift")
let finalOutput = "\(outputDir)/StateAccessor.swift"
do {
if FileManager.default.fileExists(atPath: finalOutput) {
try FileManager.default.removeItem(atPath: finalOutput)
}
try FileManager.default.copyItem(atPath: cachedOutput, toPath: finalOutput)
} catch {
FileHandle.standardError.write(Data("gen-accessors: cannot restore cached output: \(error)\n".utf8))
exit(5)
}
print("gen-accessors: cache hit (\(cacheKey))")
return
}
// Parse + extract specs
var specs: [AccessorSpec] = []
for path in swiftFiles {
guard let source = try? String(contentsOfFile: path, encoding: .utf8) else { continue }
let tree = Parser.parse(source: source)
let visitor = ObservableClassVisitor(viewMode: .sourceAccurate)
visitor.walk(tree)
specs.append(contentsOf: visitor.specs)
}
// Emit
let output = render(specs: specs, buildId: getEnv("APP_BUILD_ID") ?? "unknown", accessorHash: cacheKey)
try? FileManager.default.createDirectory(atPath: outputDir, withIntermediateDirectories: true)
try? output.write(toFile: "\(outputDir)/StateAccessor.swift", atomically: true, encoding: .utf8)
let output = render(specs: specs, buildId: buildId, accessorHash: accessorHash)
do {
try output.write(toFile: "\(outputDir)/StateAccessor.swift", atomically: true, encoding: .utf8)
} catch {
FileHandle.standardError.write(Data("gen-accessors: cannot write output: \(error)\n".utf8))
exit(5)
}
// Populate cache
try? FileManager.default.createDirectory(atPath: "\(cacheDir)/\(cacheKey)", withIntermediateDirectories: true)
@@ -72,46 +120,154 @@ struct GenAccessors {
print("gen-accessors: wrote \(specs.count) accessor(s) to \(outputDir)/StateAccessor.swift")
}
static func collectSwiftFiles(at path: String) -> [String]? {
guard let enumerator = FileManager.default.enumerator(atPath: path) else { return nil }
static func collectSwiftFiles(at path: String, excluding outputPath: String) -> [String]? {
let inputURL = URL(fileURLWithPath: path).standardizedFileURL
let outputURL = URL(fileURLWithPath: outputPath).standardizedFileURL
var isDirectory: ObjCBool = false
guard FileManager.default.fileExists(atPath: inputURL.path, isDirectory: &isDirectory),
isDirectory.boolValue,
let enumerator = FileManager.default.enumerator(
at: inputURL,
includingPropertiesForKeys: [.isDirectoryKey],
options: []
) else { return nil }
// When --output is the input root, scan the root but still exclude
// StateAccessor.swift. For a nested output, skip the entire subtree.
let shouldExcludeOutputSubtree = outputURL.path != inputURL.path
var files: [String] = []
for case let f as String in enumerator {
if f.hasSuffix(".swift") { files.append("\(path)/\(f)") }
for case let fileURL as URL in enumerator {
let normalized = fileURL.standardizedFileURL
let values = try? normalized.resourceValues(forKeys: [.isDirectoryKey])
if values?.isDirectory == true {
if normalized.lastPathComponent == "DebugBridgeGenerated"
|| (shouldExcludeOutputSubtree && normalized.path == outputURL.path) {
enumerator.skipDescendants()
}
continue
}
guard normalized.pathExtension == "swift",
normalized.lastPathComponent != "StateAccessor.swift" else { continue }
files.append(normalized.path)
}
return files.sorted()
}
static func computeCacheKey(swiftFiles: [String]) -> String {
static func computeCacheKey(swiftFiles: [String], buildId: String) -> String {
// Codex-flagged: hash must include Swift version, tool git rev, platform.
let swiftVer = getEnv("SWIFT_VERSION") ?? "unknown"
let toolRev = getEnv("GEN_ACCESSORS_REV") ?? "dev"
let platform = "darwin-arm64" // simplified for the test harness
var combined = "swift=\(swiftVer)|tool=\(toolRev)|platform=\(platform)|"
let toolRev = getEnv("GEN_ACCESSORS_REV") ?? generatorFormatVersion
#if arch(arm64)
let platform = "darwin-arm64"
#elseif arch(x86_64)
let platform = "darwin-x86_64"
#else
let platform = "darwin-unknown"
#endif
var combined = Data("\(generatorFormatVersion)|swift=\(swiftVer)|tool=\(toolRev)|platform=\(platform)|build=\(buildId)|".utf8)
for path in swiftFiles {
if let data = try? Data(contentsOf: URL(fileURLWithPath: path)) {
combined += "\(path):\(data.count):\(data.sha256())|"
// Do not include absolute checkout paths in cache identity.
combined.append(Data("\(data.count):".utf8))
combined.append(data)
combined.append(Data("|".utf8))
}
}
return combined.data(using: .utf8)!.sha256()
return combined.sha256()
}
static func computeAccessorHash(specs: [AccessorSpec]) -> String {
var signature = "snapshot-schema-v1\n"
for spec in specs {
signature += "C\(spec.className.utf8.count):\(spec.className)\n"
for field in spec.fields {
signature += "F\(field.name.utf8.count):\(field.name)"
signature += "T\(field.typeText.utf8.count):\(field.typeText)\n"
}
signature += "E\n"
}
return signature.sha256()
}
static func render(specs: [AccessorSpec], buildId: String, accessorHash: String) -> String {
var out = "// AUTO-GENERATED — DO NOT EDIT. Regenerate with /ios-sync.\n"
out += "#if DEBUG\nimport Foundation\nimport DebugBridge\n\n"
out += "#if DEBUG\nimport Foundation\nimport DebugBridgeCore\n\n"
if !specs.isEmpty {
// JSONSerialization produces Foundation bridge objects. Direct
// `as?` casts let NSNumber cross-cast between Bool and numeric
// Swift types. Round-tripping through JSONDecoder enforces the
// declared Codable shape (including every collection element)
// and preserves a successful Optional nil as a double Optional.
out += "private enum _GStackDebugBridgeSnapshotJSON {\n"
out += " private struct Box<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 {
out += "@MainActor\npublic enum \(spec.className)Accessor {\n"
out += " public static func register(_ state: \(spec.className)) {\n"
// Accessors live in the app target beside its usually-internal
// state types. A public signature cannot expose an internal type.
out += "@MainActor\nenum \(spec.className)Accessor {\n"
out += " static func register(_ state: \(spec.className)) {\n"
out += " StateServer.shared.register(\n"
out += " buildId: \"\(buildId)\",\n"
out += " accessorHash: \"\(accessorHash)\",\n"
out += " atomicRestore: { _ in .ok }\n"
out += " buildId: {\n"
out += " let shortVersion = Bundle.main.object(forInfoDictionaryKey: \"CFBundleShortVersionString\") as? String\n"
out += " let bundleVersion = Bundle.main.object(forInfoDictionaryKey: \"CFBundleVersion\") as? String\n"
out += " if let shortVersion, let bundleVersion { return \"\\(shortVersion) (\\(bundleVersion))\" }\n"
out += " return shortVersion ?? bundleVersion ?? \(String(reflecting: buildId))\n"
out += " }(),\n"
out += " accessorHash: \(String(reflecting: accessorHash)),\n"
out += " atomicRestore: { keys, apply in\n"
out += " // Validate every key and value before assignment.\n"
out += " // StateServer invokes every model once with apply=false,\n"
out += " // then applies every validated model with apply=true.\n"
for (index, field) in spec.fields.enumerated() {
let (name, typeText) = field
out += " guard let raw\(index) = keys[\"\(name)\"] else {\n"
out += " return .missingKey(\"\(name)\")\n"
out += " }\n"
out += " guard let restored\(index): \(typeText) = _GStackDebugBridgeSnapshotJSON.decode(raw\(index), as: \(typeText).self) else {\n"
out += " return .typeMismatch(\"\(name)\")\n"
out += " }\n"
}
out += " if apply {\n"
for (index, field) in spec.fields.enumerated() {
out += " state.\(field.name) = restored\(index)\n"
}
out += " }\n"
out += " return .ok\n"
out += " }\n"
out += " )\n"
for (name, _) in spec.fields {
for (name, typeText) in spec.fields {
let wrapped = optionalWrappedType(typeText)
out += " StateServer.shared.registerAccessor(\n"
out += " key: \"\(name)\",\n"
out += " type: \"Any\",\n"
out += " read: { state.\(name) as Any? },\n"
out += " write: { _ in false }\n"
out += " type: \"\(typeText)\",\n"
if wrapped != nil {
out += " read: {\n"
out += " guard let value = state.\(name) else { return NSNull() }\n"
out += " return value as Any\n"
out += " },\n"
} else {
out += " read: { state.\(name) as Any? },\n"
}
out += " write: { value in\n"
out += " guard let typed: \(typeText) = _GStackDebugBridgeSnapshotJSON.decode(value, as: \(typeText).self) else { return false }\n"
out += " state.\(name) = typed\n"
out += " return true\n"
out += " }\n"
out += " )\n"
}
out += " }\n}\n\n"
@@ -123,6 +279,59 @@ struct GenAccessors {
final class ObservableClassVisitor: SyntaxVisitor {
var specs: [AccessorSpec] = []
var diagnostics: [String] = []
private let sourcePath: String
init(sourcePath: String, viewMode: SyntaxTreeViewMode) {
self.sourcePath = sourcePath
super.init(viewMode: viewMode)
}
private func hasSnapshotableMarker(_ declaration: VariableDeclSyntax) -> Bool {
// Preserve compatibility with source that defines a custom attribute
// or wrapper, even though that form conflicts with @Observable in
// ordinary app models.
if declaration.attributes.contains(where: { attribute in
guard let attribute = attribute.as(AttributeSyntax.self) else { return false }
return attribute.attributeName.trimmedDescription == "Snapshotable"
}) {
return true
}
// Match a standalone ordinary line-comment trivia piece exactly.
// Avoid declaration.description/contains: both would accept prose
// such as `// do not expose through @Snapshotable`.
return declaration.leadingTrivia.contains { piece in
guard case .lineComment(let text) = piece else { return false }
return text.dropFirst(2).trimmingCharacters(in: .whitespaces) == "@Snapshotable"
}
}
private func enclosingScopeDescription(for node: ClassDeclSyntax) -> String? {
var ancestor = Syntax(node).parent
while let current = ancestor {
if let declaration = current.as(ClassDeclSyntax.self) {
return "class \(declaration.name.text)"
}
if let declaration = current.as(StructDeclSyntax.self) {
return "struct \(declaration.name.text)"
}
if let declaration = current.as(EnumDeclSyntax.self) {
return "enum \(declaration.name.text)"
}
if let declaration = current.as(ActorDeclSyntax.self) {
return "actor \(declaration.name.text)"
}
if let declaration = current.as(ExtensionDeclSyntax.self) {
return "extension \(declaration.extendedType.trimmedDescription)"
}
if current.is(CodeBlockSyntax.self) {
return "a local scope"
}
ancestor = current.parent
}
return nil
}
override func visit(_ node: ClassDeclSyntax) -> SyntaxVisitorContinueKind {
// Look for @Observable attribute
@@ -133,23 +342,88 @@ final class ObservableClassVisitor: SyntaxVisitor {
guard isObservable else { return .visitChildren }
let className = node.name.text
let markedMembers = node.memberBlock.members.compactMap {
$0.decl.as(VariableDeclSyntax.self)
}.filter(hasSnapshotableMarker)
if !markedMembers.isEmpty, let enclosingScope = enclosingScopeDescription(for: node) {
diagnostics.append(
"\((sourcePath as NSString).lastPathComponent): nested @Observable class "
+ "\(className) inside \(enclosingScope) is unsupported; "
+ "move snapshot-enabled models to file scope"
)
// Continue walking so a more deeply nested marked model is also
// diagnosed rather than silently omitted.
return .visitChildren
}
var fields: [(String, String)] = []
for member in node.memberBlock.members {
guard let varDecl = member.decl.as(VariableDeclSyntax.self) else { continue }
// Field must be marked @Snapshotable to be included
let isSnapshotable = varDecl.attributes.contains(where: { attr in
guard let attr = attr.as(AttributeSyntax.self) else { return false }
return attr.attributeName.trimmedDescription == "Snapshotable"
})
guard isSnapshotable else { continue }
// Field must opt in with the source marker (or legacy attribute).
guard hasSnapshotableMarker(varDecl) else { continue }
let bindingName = varDecl.bindings.first?
.pattern.as(IdentifierPatternSyntax.self)?.identifier.text ?? "<unknown>"
let context = "\((sourcePath as NSString).lastPathComponent): \(className).\(bindingName)"
if varDecl.bindingSpecifier.text == "let" {
diagnostics.append("\(context) must be declared var, not let")
continue
}
let modifierNames = varDecl.modifiers.map { modifier -> String in
let detail = modifier.detail?.trimmedDescription ?? ""
return modifier.name.text + detail
}
if modifierNames.contains(where: {
$0 == "private" || $0 == "fileprivate"
|| $0 == "private(set)" || $0 == "fileprivate(set)"
}) {
diagnostics.append("\(context) cannot be private, fileprivate, private(set), or fileprivate(set)")
continue
}
if modifierNames.contains("static") || modifierNames.contains("class") {
diagnostics.append("\(context) must be an instance property")
continue
}
if varDecl.bindings.count != 1 {
diagnostics.append("\(context) declaration must contain exactly one binding")
continue
}
for binding in varDecl.bindings {
if let pattern = binding.pattern.as(IdentifierPatternSyntax.self) {
let name = pattern.identifier.text
let typeText = binding.typeAnnotation?.type.trimmedDescription ?? "Any"
fields.append((name, typeText))
guard let pattern = binding.pattern.as(IdentifierPatternSyntax.self) else {
diagnostics.append("\(context) only supports an identifier binding")
continue
}
// An initializer plus an accessor block is a stored property
// with observers. Without an initializer, conservatively
// reject the block rather than emitting a setter for a
// computed/read-only declaration.
if binding.accessorBlock != nil && binding.initializer == nil {
diagnostics.append("\(context) must be stored and writable")
continue
}
guard let annotation = binding.typeAnnotation else {
diagnostics.append("\(context) requires an explicit type annotation")
continue
}
let name = pattern.identifier.text
let typeText = annotation.type.trimmedDescription
.split(whereSeparator: { $0.isWhitespace })
.joined(separator: " ")
switch parseJSONSnapshotType(typeText) {
case .valid:
break
case .implicitlyUnwrappedOptional:
diagnostics.append("\(context) cannot use an implicitly unwrapped Optional type")
continue
case .nestedOptional:
diagnostics.append("\(context) cannot use a nested Optional type")
continue
case .unsupported:
diagnostics.append("\(context) uses unsupported non-JSON snapshot type '\(typeText)'")
continue
}
fields.append((name, typeText))
}
}
@@ -160,10 +434,233 @@ final class ObservableClassVisitor: SyntaxVisitor {
}
}
private indirect enum JSONSnapshotType {
case scalar
case optional(JSONSnapshotType)
case array(JSONSnapshotType)
case dictionary(JSONSnapshotType)
var isOptional: Bool {
if case .optional = self { return true }
return false
}
}
private enum JSONSnapshotTypeParseResult {
case valid(JSONSnapshotType)
case implicitlyUnwrappedOptional
case nestedOptional
case unsupported
}
/// Parse the deliberately small set of Swift types that can make a lossless
/// trip through JSONSerialization and JSONDecoder without application-defined
/// encoding behavior. Type aliases and arbitrary Codable models are rejected:
/// the bridge has no compiler/type-checker context in which to prove that they
/// are JSON-native.
private func parseJSONSnapshotType(_ source: String) -> JSONSnapshotTypeParseResult {
let type = source.trimmingCharacters(in: .whitespacesAndNewlines)
guard !type.isEmpty else { return .unsupported }
if type.hasSuffix("!") {
return .implicitlyUnwrappedOptional
}
if type.hasSuffix("?") {
let wrappedText = String(type.dropLast()).trimmingCharacters(in: .whitespacesAndNewlines)
switch parseJSONSnapshotType(wrappedText) {
case .valid(let wrapped):
return wrapped.isOptional ? .nestedOptional : .valid(.optional(wrapped))
case let issue:
return issue
}
}
if let bracketContents = outerDelimitedContents(type, open: "[", close: "]") {
let dictionaryParts = splitTopLevel(bracketContents, separator: ":")
if dictionaryParts.count == 1 {
switch parseJSONSnapshotType(dictionaryParts[0]) {
case .valid(let element): return .valid(.array(element))
case let issue: return issue
}
}
guard dictionaryParts.count == 2, isStringType(dictionaryParts[0]) else {
return .unsupported
}
switch parseJSONSnapshotType(dictionaryParts[1]) {
case .valid(let value): return .valid(.dictionary(value))
case let issue: return issue
}
}
if let generic = genericTypeParts(type) {
let base = compactTypeName(generic.base)
if base == "Optional" || base == "Swift.Optional" {
guard generic.arguments.count == 1 else { return .unsupported }
switch parseJSONSnapshotType(generic.arguments[0]) {
case .valid(let wrapped):
return wrapped.isOptional ? .nestedOptional : .valid(.optional(wrapped))
case let issue:
return issue
}
}
if base == "Array" || base == "Swift.Array" {
guard generic.arguments.count == 1 else { return .unsupported }
switch parseJSONSnapshotType(generic.arguments[0]) {
case .valid(let element): return .valid(.array(element))
case let issue: return issue
}
}
if base == "Dictionary" || base == "Swift.Dictionary" {
guard generic.arguments.count == 2, isStringType(generic.arguments[0]) else {
return .unsupported
}
switch parseJSONSnapshotType(generic.arguments[1]) {
case .valid(let value): return .valid(.dictionary(value))
case let issue: return issue
}
}
return .unsupported
}
let scalar = compactTypeName(type)
let supportedScalars: Set<String> = [
"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? {
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
extension Data {
+740 -19
View File
@@ -1,11 +1,14 @@
// 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;
// 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
import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
import { spawnSync } from 'child_process';
import { mkdtempSync, rmSync, writeFileSync, existsSync, readFileSync, mkdirSync, utimesSync } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
@@ -13,9 +16,11 @@ import {
collectSwiftFiles,
parseSwift,
computeCacheKey,
computeAccessorHash,
generate,
pruneCache,
render,
AccessorGenerationError,
type AccessorSpec,
} from './gen-accessors';
@@ -30,12 +35,14 @@ afterEach(() => {
});
describe('parseSwift — fork regex-failure-mode fixtures', () => {
test('parses @Observable class with simple @Snapshotable fields', () => {
test('parses @Observable class with source-marker comments', () => {
const src = `
@Observable
final class AppState {
@Snapshotable var isLoggedIn: Bool = false
@Snapshotable var username: String = ""
// @Snapshotable
var isLoggedIn: Bool = false
// @Snapshotable
var username: String = ""
var notSnapshotable: Int = 0
}
`;
@@ -46,12 +53,100 @@ final class AppState {
expect(specs[0]!.fields.find(f => f.name === 'isLoggedIn')!.typeText).toBe('Bool');
});
test('retains legacy @Snapshotable attribute parsing', () => {
const specs = parseSwift(`
@Observable
final class LegacyState {
@Snapshotable var counter: Int = 0
}
`);
expect(specs).toEqual([{
className: 'LegacyState',
fields: [{ name: 'counter', typeText: 'Int' }],
}]);
});
test('does not treat documentation prose as a source marker', () => {
const specs = parseSwift(`
@Observable
final class PrivateState {
/// Do not expose this field through @Snapshotable.
var token: String = "secret"
}
`);
expect(specs).toHaveLength(0);
});
test('does not let a trailing marker comment bleed into the next field', () => {
const specs = parseSwift(`
@Observable
final class PrivateState {
var oldValue: Int = 0 // @Snapshotable
var nextValue: Int = 1
}
`);
expect(specs).toHaveLength(0);
});
test('ignores exact-looking markers inside nested block comments', () => {
const specs = parseSwift(`
@Observable
final class PrivateState {
/*
/* // @Snapshotable */
// @Snapshotable
var leaked: String = "secret"
*/
// @Snapshotable
var visible: Int = 1
}
`);
expect(specs).toEqual([{
className: 'PrivateState',
fields: [{ name: 'visible', typeText: 'Int' }],
}]);
});
test('ignores declarations and markers inside multiline and raw strings', () => {
const specs = parseSwift(`
let ordinary = """
@Observable class FakeA {
// @Snapshotable
var leaked: Int = 0
}
"""
let raw = #"""
@Observable class FakeB { @Snapshotable var leaked: String = "" }
"""#
@Observable
final class RealState {
// @Snapshotable
var safe: Bool = true
}
`);
expect(specs).toEqual([{
className: 'RealState',
fields: [{ name: 'safe', typeText: 'Bool' }],
}]);
});
test('ignores marker words in standalone trailing prose', () => {
const specs = parseSwift(`
@Observable
final class PrivateState {
// @Snapshotable fields are intentionally disabled here.
var token: String = "secret"
}
`);
expect(specs).toHaveLength(0);
});
test('handles @Snapshotable on multi-line type signatures', () => {
const src = `
@Observable
class Cart {
@Snapshotable var items:
[CartItem<Detail>]
[Dictionary<String, [Int]>]
= []
var unrelated: Int = 0
}
@@ -60,20 +155,67 @@ class Cart {
expect(specs).toHaveLength(1);
expect(specs[0]!.fields).toHaveLength(1);
expect(specs[0]!.fields[0]!.name).toBe('items');
expect(specs[0]!.fields[0]!.typeText).toContain('CartItem');
expect(specs[0]!.fields[0]!.typeText).toContain('Dictionary');
});
test('handles generic types in property signatures', () => {
test('handles JSON-compatible generic types in property signatures', () => {
const src = `
@Observable
class Repo {
@Snapshotable var pages: Dictionary<String, [Result<Item, Error>]> = [:]
@Snapshotable var pages: Dictionary<String, [Optional<Int>]> = [:]
}
`;
const specs = parseSwift(src);
expect(specs).toHaveLength(1);
expect(specs[0]!.fields[0]!.typeText).toContain('Dictionary');
expect(specs[0]!.fields[0]!.typeText).toContain('Result');
expect(specs[0]!.fields[0]!.typeText).toContain('Optional');
});
test('accepts qualified JSON-native scalar and collection spellings', () => {
const specs = parseSwift(`
@Observable
class Metrics {
// @Snapshotable
var ratio: CoreGraphics.CGFloat = 0
// @Snapshotable
var counts: Swift.Array<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', () => {
@@ -114,10 +256,7 @@ class B {
expect(specs.map(s => s.className).sort()).toEqual(['A', 'B']);
});
test('skips fields with computed body braces', () => {
// Codex flagged "Properties with computed getters / didSet blocks" as a
// failure mode of the fork's regex. We deliberately exclude them here —
// computed properties are not snapshot-eligible.
test('diagnoses fields with computed body braces', () => {
const src = `
@Observable
class M {
@@ -127,9 +266,50 @@ class M {
}
}
`;
const specs = parseSwift(src);
expect(specs).toHaveLength(1);
expect(specs[0]!.fields.map(f => f.name)).toEqual(['snapshotted']);
expect(() => parseSwift(src)).toThrow("field 'computed' must be stored and writable");
});
test.each([
['let', '// @Snapshotable\n let immutable: Int = 1', 'must be declared var, not let'],
['private', '// @Snapshotable\n private var secret: String = ""', 'cannot be private'],
['fileprivate', '// @Snapshotable\n fileprivate var secret: String = ""', 'cannot be private'],
['private(set)', '// @Snapshotable\n public private(set) var count: Int = 0', 'cannot be private'],
['fileprivate(set)', '// @Snapshotable\n fileprivate(set) var count: Int = 0', 'cannot be private'],
['inferred type', '// @Snapshotable\n var inferred = 42', 'requires an explicit type annotation'],
['multiple bindings', '// @Snapshotable\n var a: Int = 1, b: Int = 2', 'exactly one binding'],
['static', '// @Snapshotable\n static var shared: Int = 0', 'must be an instance property'],
['nested Optional', '// @Snapshotable\n var nested: String?? = nil', 'cannot use a nested Optional'],
['nested Optional in collection', '// @Snapshotable\n var nestedItems: [String??] = []', 'cannot use a nested Optional'],
['implicitly unwrapped Optional', '// @Snapshotable\n var legacy: String! = nil', 'cannot use an implicitly unwrapped Optional'],
['custom type', '// @Snapshotable\n var custom: CustomValue = .init()', 'uses unsupported snapshot type'],
])('diagnoses invalid %s declarations', (_label, declaration, expected) => {
expect(() => parseSwift(`
@Observable
final class InvalidState {
${declaration}
}
`)).toThrow(expected);
});
test('aggregates invalid declaration diagnostics with class and line context', () => {
try {
parseSwift(`
@Observable
final class InvalidState {
// @Snapshotable
let first: Int = 1
// @Snapshotable
private var second: String = ""
}
`);
throw new Error('expected parseSwift to fail');
} catch (error) {
expect(error).toBeInstanceOf(AccessorGenerationError);
const generationError = error as AccessorGenerationError;
expect(generationError.diagnostics).toHaveLength(2);
expect(generationError.message).toContain('InvalidState (line 4)');
expect(generationError.message).toContain('InvalidState (line 6)');
}
});
});
@@ -244,6 +424,68 @@ describe('computeCacheKey', () => {
});
expect(k1).not.toBe(k2);
});
test('app build provenance invalidates the cache key', () => {
const f = join(workDir, 'a.swift');
writeFileSync(f, '@Observable class A {}');
const shared = {
swiftFiles: [f],
swiftVersion: '6.0.0',
toolGitRev: 'abc',
platformTriple: 'darwin-arm64',
};
expect(computeCacheKey({ ...shared, buildId: '100' })).not.toBe(
computeCacheKey({ ...shared, buildId: '101' }),
);
});
test('equivalent source content does not depend on its absolute checkout path', () => {
const firstDir = join(workDir, 'checkout-a');
const secondDir = join(workDir, 'checkout-b');
mkdirSync(firstDir);
mkdirSync(secondDir);
const first = join(firstDir, 'State.swift');
const second = join(secondDir, 'State.swift');
writeFileSync(first, '@Observable class A {}');
writeFileSync(second, '@Observable class A {}');
const versioning = { swiftVersion: '6', toolGitRev: 't', platformTriple: 'p' };
expect(computeCacheKey({ swiftFiles: [first], ...versioning })).toBe(
computeCacheKey({ swiftFiles: [second], ...versioning }),
);
});
});
describe('computeAccessorHash', () => {
const base: AccessorSpec[] = [{
className: 'AppState',
fields: [
{ name: 'count', typeText: 'Int' },
{ name: 'nickname', typeText: 'String?' },
],
}];
test('is deterministic for the same ordered accessor signatures', () => {
expect(computeAccessorHash(base)).toBe(computeAccessorHash(structuredClone(base)));
});
test('changes when field order, name, or type changes', () => {
const reordered: AccessorSpec[] = [{
className: 'AppState',
fields: [...base[0]!.fields].reverse(),
}];
const renamed: AccessorSpec[] = [{
className: 'AppState',
fields: [{ name: 'total', typeText: 'Int' }, base[0]!.fields[1]!],
}];
const retyped: AccessorSpec[] = [{
className: 'AppState',
fields: [{ name: 'count', typeText: 'Int64' }, base[0]!.fields[1]!],
}];
const hash = computeAccessorHash(base);
expect(computeAccessorHash(reordered)).not.toBe(hash);
expect(computeAccessorHash(renamed)).not.toBe(hash);
expect(computeAccessorHash(retyped)).not.toBe(hash);
});
});
describe('generate', () => {
@@ -283,6 +525,35 @@ class AppState {
expect(r1.cacheKey).toBe(r2.cacheKey);
});
test('custom nested output subtree cannot poison the second-run cache key', () => {
const inputDir = join(workDir, 'src');
const outputDir = join(inputDir, 'generated', 'accessors');
mkdirSync(inputDir);
writeFileSync(join(inputDir, 'state.swift'), '@Observable class A { @Snapshotable var x: Int = 0 }');
const cacheRoot = join(workDir, 'cache');
const r1 = generate({
inputDir,
outputDir,
cacheRoot,
swiftVersion: '6',
toolGitRev: 't',
platformTriple: 'p',
});
writeFileSync(join(outputDir, 'Ignored.swift'), '@Observable class Poison { @Snapshotable var bad: Int = 1 }');
const r2 = generate({
inputDir,
outputDir,
cacheRoot,
swiftVersion: '6',
toolGitRev: 't',
platformTriple: 'p',
});
expect(r2.cacheHit).toBe(true);
expect(r2.cacheKey).toBe(r1.cacheKey);
});
test('modifying source invalidates the cache', () => {
const inputDir = join(workDir, 'src');
mkdirSync(inputDir);
@@ -295,6 +566,62 @@ class AppState {
expect(r1.cacheKey).not.toBe(r2.cacheKey);
expect(r2.cacheHit).toBe(false);
});
test('unmarked source churn changes cache identity but not schema identity', () => {
const inputDir = join(workDir, 'src');
mkdirSync(inputDir);
const state = join(inputDir, 'state.swift');
const unrelated = join(inputDir, 'unrelated.swift');
writeFileSync(state, '@Observable class A { // @Snapshotable\n var x: Int = 0\n }');
writeFileSync(unrelated, 'struct Unrelated { let a = 1 }');
const cacheRoot = join(workDir, 'cache');
const options = { inputDir, cacheRoot, swiftVersion: '6', toolGitRev: 't', platformTriple: 'p' };
const first = generate(options);
writeFileSync(unrelated, 'struct Unrelated { let a = 2 }');
const second = generate(options);
expect(second.cacheKey).not.toBe(first.cacheKey);
expect(second.accessorHash).toBe(first.accessorHash);
});
test('buildId changes cannot reuse generated fallback provenance', () => {
const inputDir = join(workDir, 'src');
mkdirSync(inputDir);
writeFileSync(join(inputDir, 'state.swift'), '@Observable class A { @Snapshotable var x: Int = 0 }');
const cacheRoot = join(workDir, 'cache');
const shared = { inputDir, cacheRoot, swiftVersion: '6', toolGitRev: 't', platformTriple: 'p' };
const first = generate({ ...shared, buildId: 'build-100' });
const second = generate({ ...shared, buildId: 'build-101' });
expect(second.cacheKey).not.toBe(first.cacheKey);
expect(second.cacheHit).toBe(false);
expect(readFileSync(second.outputPath, 'utf8')).toContain('?? "build-101"');
});
test('CLI exits 4 with actionable diagnostics and no generated output', () => {
const inputDir = join(workDir, 'src');
const outputDir = join(workDir, 'generated');
mkdirSync(inputDir);
writeFileSync(join(inputDir, 'state.swift'), `
@Observable final class InvalidState {
// @Snapshotable
private let secret = "nope"
}
`);
const result = spawnSync('bun', [
join(import.meta.dir, 'gen-accessors.ts'),
'--input', inputDir,
'--output', outputDir,
], {
encoding: 'utf8',
env: { ...process.env, GSTACK_IOS_CACHE_ROOT: join(workDir, 'cache') },
});
expect(result.status).toBe(4);
// `let` is diagnosed first; either way the class/property is named and
// generation never emits an accessor that will fail in xcodebuild.
expect(result.stderr).toContain('InvalidState');
expect(result.stderr).toContain("field 'secret' must be declared var, not let");
expect(result.stderr).not.toContain('AccessorGenerationError:');
expect(existsSync(join(outputDir, 'StateAccessor.swift'))).toBe(false);
});
});
describe('pruneCache', () => {
@@ -332,14 +659,395 @@ describe('render', () => {
fields: [{ name: 'a', typeText: 'Int' }, { name: 'b', typeText: 'String' }],
}];
const out = render(specs, 'build-1.2.3', 'hash-abc');
expect(out).toContain('public enum AppStateAccessor');
expect(out).toContain('enum AppStateAccessor');
expect(out).not.toContain('public enum AppStateAccessor');
expect(out).toContain('static func register(_ state: AppState)');
expect(out).not.toContain('public static func register(_ state: AppState)');
expect(out).toContain('key: "a"');
expect(out).toContain('key: "b"');
expect(out).toContain('buildId: "build-1.2.3"');
expect(out).toContain('return .missingKey("a")');
expect(out).toContain('return .typeMismatch("b")');
expect(out).toContain('guard let restored0 = Self.decodeSnapshotValue(raw0, as: Int.self)');
expect(out).toContain('guard let restored1 = Self.decodeSnapshotValue(raw1, as: String.self)');
expect(out).toContain('atomicRestore: { keys, apply in');
expect(out).toContain('if apply {');
expect(out).toContain('state.a = restored0');
expect(out).toContain('state.b = restored1');
expect(out.indexOf('state.a = restored0')).toBeGreaterThan(out.indexOf('guard let restored1'));
expect(out).toContain('guard let typed = Self.decodeSnapshotValue(value, as: Int.self) else { return false }');
expect(out).toContain('state.a = typed');
expect(out).toContain('return true');
expect(out).not.toContain('atomicRestore: { _ in .ok }');
expect(out).not.toContain('write: { _ in false }');
expect(out).toContain('CFBundleShortVersionString');
expect(out).toContain('CFBundleVersion');
expect(out).toContain('return shortVersion ?? bundleVersion ?? "build-1.2.3"');
expect(out).toContain('accessorHash: "hash-abc"');
expect(out).toContain('import DebugBridgeCore');
expect(out).not.toContain('import DebugBridge\n');
expect(out).toContain('#if DEBUG');
expect(out).toContain('#endif');
});
test('emits explicit NSNull round-trip handling for Optional fields', () => {
const out = render([{
className: 'AppState',
fields: [
{ name: 'nickname', typeText: 'String?' },
{ name: 'selection', typeText: 'Optional<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', () => {
@@ -355,4 +1063,17 @@ describe('collectSwiftFiles', () => {
const files = collectSwiftFiles(workDir);
expect(files.sort()).toEqual([a, b].sort());
});
test('excludes the configured output subtree and every StateAccessor.swift', () => {
const source = join(workDir, 'App.swift');
const staleAccessor = join(workDir, 'old', 'StateAccessor.swift');
const outputDir = join(workDir, 'custom-output');
mkdirSync(join(workDir, 'old'));
mkdirSync(outputDir);
writeFileSync(source, 'struct App {}');
writeFileSync(staleAccessor, '// stale generated output');
writeFileSync(join(outputDir, 'Poison.swift'), '// must not be scanned');
expect(collectSwiftFiles(workDir, { outputDir })).toEqual([source]);
});
});
+659 -89
View File
@@ -5,15 +5,16 @@
// first time. Also exercised by tests so we can verify the cache + parse
// behavior without a Swift toolchain.
//
// The TS port uses a stricter regex than the fork's original — it understands:
// The TS fast path uses a lightweight Swift lexical scanner — it understands:
// - @Observable class declarations
// - @Snapshotable property markers (only marked fields are exported)
// - `// @Snapshotable` property markers (only marked fields are exported)
// - Legacy @Snapshotable attributes for existing integrations
// - Multi-line type signatures (collapses whitespace before matching)
// - Generic type parameters (matched as opaque text inside the type)
// - JSON-native generic arrays and String-keyed dictionaries
//
// What it does NOT handle (deferred to the SwiftPM tool):
// - Computed properties with bodies (regex can mis-parse braces)
// - Property wrappers other than @Snapshotable
// Invalid marked declarations (computed/immutable/inaccessible/untyped,
// nested, duplicate-keyed, or non-JSON) fail generation instead of producing
// Swift that only fails later in xcodebuild or at snapshot time.
//
// Composite cache key (codex-flagged): swift_version || tool_git_rev ||
// platform_triple || source_content_hash. Source-only hash misses generator
@@ -35,6 +36,16 @@ export interface AccessorSpec {
fields: AccessorField[];
}
export class AccessorGenerationError extends Error {
readonly diagnostics: string[];
constructor(diagnostics: string[]) {
super(`gen-accessors: invalid @Snapshotable declaration(s):\n${diagnostics.map(d => ` - ${d}`).join('\n')}`);
this.name = 'AccessorGenerationError';
this.diagnostics = diagnostics;
}
}
export interface GenInputs {
inputDir: string;
outputDir?: string;
@@ -48,56 +59,105 @@ export interface GenInputs {
export interface GenResult {
outputPath: string;
cacheKey: string;
accessorHash: string;
specs: AccessorSpec[];
cacheHit: boolean;
}
const FALLBACK_PLATFORM = process.platform === 'darwin' ? 'darwin-arm64' : `${process.platform}-${process.arch}`;
const GENERATOR_FORMAT_VERSION = 'accessor-generator-v5';
export function collectSwiftFiles(dir: string, opts: { excludeGenerated?: boolean } = {}): string[] {
const JSON_SCALAR_TYPES = new Set([
'String',
'Bool',
'Int', 'Int8', 'Int16', 'Int32', 'Int64',
'UInt', 'UInt8', 'UInt16', 'UInt32', 'UInt64',
'Float', 'Double', 'CGFloat',
]);
export function collectSwiftFiles(
dir: string,
opts: { excludeGenerated?: boolean; outputDir?: string } = {},
): string[] {
const out: string[] = [];
const excludeGenerated = opts.excludeGenerated ?? true;
for (const name of readdirSync(dir)) {
const full = join(dir, name);
const s = statSync(full);
if (s.isDirectory()) {
// Skip generated output dir (when it lives under the input dir)
if (excludeGenerated && name === 'DebugBridgeGenerated') continue;
out.push(...collectSwiftFiles(full, opts));
} else if (name.endsWith('.swift')) {
// Skip the codegen output file. Otherwise the second run picks it up,
// changes the cache key, and the cache never hits.
if (excludeGenerated && name === 'StateAccessor.swift') continue;
out.push(full);
const root = resolve(dir);
const outputDir = opts.outputDir ? resolve(opts.outputDir) : undefined;
function walk(currentDir: string): void {
for (const name of readdirSync(currentDir)) {
const full = join(currentDir, name);
const s = statSync(full);
if (s.isDirectory()) {
// The generated subtree must never participate in its own cache key.
// Keep the well-known-name guard for direct collectSwiftFiles callers,
// and use the actual --output path when generate() supplies one.
const isOutputSubtree = outputDir !== undefined
&& outputDir !== root
&& resolve(full) === outputDir;
if (excludeGenerated && (name === 'DebugBridgeGenerated' || isOutputSubtree)) continue;
walk(full);
} else if (name.endsWith('.swift')) {
// Skip generated accessor files wherever they live. Otherwise moving
// an old copy outside the output directory poisons the next cache key.
if (excludeGenerated && name === 'StateAccessor.swift') continue;
out.push(full);
}
}
}
walk(root);
return out.sort();
}
export function parseSwift(source: string): AccessorSpec[] {
const specs: AccessorSpec[] = [];
const diagnostics: string[] = [];
const masked = maskSwiftSource(source);
// Find `@Observable\n(public )?(final )?class <Name>` followed by a brace
// block. We then scan inside that block for @Snapshotable fields.
const classPattern = /@Observable\s*(?:(?:public|internal|fileprivate|private)\s+)?(?:final\s+)?class\s+(\w+)[^{]*\{/g;
let match: RegExpExecArray | null;
while ((match = classPattern.exec(source)) !== null) {
for (const match of masked.matchAll(classPattern)) {
const className = match[1]!;
const startIdx = classPattern.lastIndex;
const endIdx = findMatchingBrace(source, startIdx - 1);
const matchOffset = match.index!;
const openBraceOffset = matchOffset + match[0].lastIndexOf('{');
const startIdx = openBraceOffset + 1;
const endIdx = findMatchingBrace(masked, startIdx - 1);
if (endIdx === -1) continue;
const body = source.slice(startIdx, endIdx);
const body = masked.slice(startIdx, endIdx);
const fields = parseFields(body);
const parsed = parseFields(body, source, startIdx, className);
if (braceDepthAt(masked, matchOffset) !== 0) {
if (parsed.fields.length > 0 || parsed.diagnostics.length > 0) {
const line = source.slice(0, matchOffset).split(/\r?\n/).length;
diagnostics.push(`${className} (line ${line}): nested @Observable types are not supported; move the type to file scope`);
}
continue;
}
diagnostics.push(...parsed.diagnostics);
const fields = parsed.fields;
if (fields.length > 0) {
specs.push({ className, fields });
}
}
if (diagnostics.length > 0) throw new AccessorGenerationError(diagnostics);
return specs;
}
function braceDepthAt(masked: string, offset: number): number {
let depth = 0;
for (let i = 0; i < offset; i++) {
if (masked[i] === '{') depth++;
else if (masked[i] === '}') depth = Math.max(0, depth - 1);
}
return depth;
}
function findMatchingBrace(s: string, openIdx: number): number {
// openIdx points at '{'. Return idx of matching '}', or -1.
// Strings and comments have already been blanked by maskSwiftSource, so
// braces here are syntax rather than prose or literal content.
let depth = 0;
for (let i = openIdx; i < s.length; i++) {
const c = s[i];
@@ -105,48 +165,305 @@ function findMatchingBrace(s: string, openIdx: number): number {
else if (c === '}') {
depth--;
if (depth === 0) return i;
} else if (c === '"' || c === "'") {
// skip string literal
const quote = c;
i++;
while (i < s.length && s[i] !== quote) {
if (s[i] === '\\') i++;
i++;
}
} else if (c === '/' && s[i + 1] === '/') {
// skip line comment
while (i < s.length && s[i] !== '\n') i++;
} else if (c === '/' && s[i + 1] === '*') {
i += 2;
while (i < s.length - 1 && !(s[i] === '*' && s[i + 1] === '/')) i++;
i++;
}
}
return -1;
}
function parseFields(body: string): AccessorField[] {
// Look for @Snapshotable followed by var/let declarations. Allow attribute
// ordering: `@Snapshotable var name: Type` OR `@Snapshotable\n var name: Type`.
// Multi-line types are handled by greedy non-newline match in the type, but
// we collapse adjacent whitespace first to avoid false negatives.
const normalized = body.replace(/[\t ]*\r?\n[\t ]*/g, ' ');
const fieldPattern = /@Snapshotable\s+(?:(?:public|internal|fileprivate|private)\s+)?(?:var|let)\s+(\w+)\s*:\s*([^={]+?)(?=\s*(?:=|\{|@Snapshotable|\bvar\b|\blet\b|\bfunc\b|\}|$))/g;
const fields: AccessorField[] = [];
let m: RegExpExecArray | null;
while ((m = fieldPattern.exec(normalized)) !== null) {
// Codex catch: skip fields that have a computed body (`{ get ... }` or
// `{ didSet ... }` after the type). The match boundary stops before `{`,
// so we peek at what comes after the type in the original body.
const afterMatchIdx = m.index + m[0].length;
const afterMatch = normalized.slice(afterMatchIdx, afterMatchIdx + 4).trim();
// If the next non-space character is `{`, this is a computed property.
// We're conservative: snapshot-eligible fields must be stored properties
// (initialized with `=` or just declared).
if (afterMatch.startsWith('{')) continue;
fields.push({ name: m[1]!, typeText: m[2]!.trim() });
/**
* Blank comments and string literals while preserving byte offsets/newlines.
* A canonical standalone `// @Snapshotable` comment is rewritten to the
* equivalent attribute token. This is intentionally lexical rather than a
* whole-file regexp: markers inside nested block comments, normal/triple/raw
* strings, trailing comments, and prose comments must never opt a field in.
*/
function maskSwiftSource(source: string): string {
const out = source.split('');
const blank = (start: number, end: number): void => {
for (let j = start; j < end; j++) {
if (out[j] !== '\n' && out[j] !== '\r') out[j] = ' ';
}
};
let i = 0;
while (i < source.length) {
if (source[i] === '/' && source[i + 1] === '/') {
let end = i + 2;
while (end < source.length && source[end] !== '\n' && source[end] !== '\r') end++;
const lineStart = source.lastIndexOf('\n', i - 1) + 1;
const standalone = source.slice(lineStart, i).trim().length === 0;
const isMarker = standalone && source.slice(i + 2, end).trim() === '@Snapshotable';
blank(i, end);
if (isMarker) {
const marker = '@Snapshotable';
for (let j = 0; j < marker.length; j++) out[i + j] = marker[j]!;
}
i = end;
continue;
}
if (source[i] === '/' && source[i + 1] === '*') {
const start = i;
i += 2;
let depth = 1;
while (i < source.length && depth > 0) {
if (source[i] === '/' && source[i + 1] === '*') {
depth++;
i += 2;
} else if (source[i] === '*' && source[i + 1] === '/') {
depth--;
i += 2;
} else {
i++;
}
}
blank(start, i);
continue;
}
// Swift strings may be ordinary, multiline, or raw (`#"..."#` and
// `#"""..."""#`). Mask the entire literal, including interpolation;
// declarations cannot legally originate inside a literal.
let hashCount = 0;
while (source[i + hashCount] === '#') hashCount++;
const quoteIdx = i + hashCount;
if (source[quoteIdx] === '"') {
const start = i;
const triple = source.slice(quoteIdx, quoteIdx + 3) === '"""';
const quoteCount = triple ? 3 : 1;
i = quoteIdx + quoteCount;
while (i < source.length) {
if (hashCount === 0 && source[i] === '\\') {
i += 2;
continue;
}
const quoteRun = source.slice(i, i + quoteCount) === '"'.repeat(quoteCount);
const hashesMatch = source.slice(i + quoteCount, i + quoteCount + hashCount) === '#'.repeat(hashCount);
if (quoteRun && hashesMatch) {
i += quoteCount + hashCount;
break;
}
i++;
}
blank(start, i);
continue;
}
i++;
}
return fields;
return out.join('');
}
const DECL_MODIFIERS = new Set([
'public', 'internal', 'package', 'open', 'private', 'fileprivate',
'final', 'static', 'class', 'lazy', 'weak', 'unowned', 'override',
'nonisolated', 'isolated', 'borrowing', 'consuming',
]);
function parseFields(
body: string,
fullSource: string,
bodyOffset: number,
className: string,
): { fields: AccessorField[]; diagnostics: string[] } {
const fields: AccessorField[] = [];
const diagnostics: string[] = [];
let braceDepth = 0;
const lineFor = (localOffset: number): number => {
const absolute = bodyOffset + localOffset;
let line = 1;
for (let j = 0; j < absolute; j++) if (fullSource[j] === '\n') line++;
return line;
};
const fail = (offset: number, message: string): void => {
diagnostics.push(`${className} (line ${lineFor(offset)}): ${message}`);
};
const skipWhitespace = (start: number): number => {
let at = start;
while (at < body.length && /\s/.test(body[at]!)) at++;
return at;
};
const identifierAt = (start: number): { text: string; end: number } | undefined => {
const m = /^[A-Za-z_]\w*/.exec(body.slice(start));
return m ? { text: m[0], end: start + m[0].length } : undefined;
};
const skipBalancedParens = (start: number): number => {
if (body[start] !== '(') return start;
let depth = 0;
for (let at = start; at < body.length; at++) {
if (body[at] === '(') depth++;
else if (body[at] === ')' && --depth === 0) return at + 1;
}
return body.length;
};
for (let i = 0; i < body.length; i++) {
if (body[i] === '{') {
braceDepth++;
continue;
}
if (body[i] === '}') {
braceDepth--;
continue;
}
if (braceDepth !== 0 || !body.startsWith('@Snapshotable', i)) continue;
const before = i === 0 ? '' : body[i - 1]!;
const after = body[i + '@Snapshotable'.length] ?? '';
if (/\w/.test(before) || /\w/.test(after)) continue;
const markerOffset = i;
let at = skipWhitespace(i + '@Snapshotable'.length);
const modifiers: string[] = [];
let bindingKind: 'var' | 'let' | undefined;
// Permit other Swift attributes between the marker and declaration. They
// remain the compiler's responsibility; this scanner only owns the
// snapshot contract. Parenthesized attribute arguments are skipped.
while (body[at] === '@') {
const attribute = identifierAt(at + 1);
if (!attribute) break;
at = skipWhitespace(attribute.end);
if (body[at] === '(') at = skipBalancedParens(at);
at = skipWhitespace(at);
}
while (at < body.length) {
const token = identifierAt(at);
if (!token) break;
if (token.text === 'var' || token.text === 'let') {
bindingKind = token.text;
at = token.end;
break;
}
if (!DECL_MODIFIERS.has(token.text)) break;
let modifier = token.text;
at = skipWhitespace(token.end);
if (body[at] === '(') {
const end = skipBalancedParens(at);
modifier += body.slice(at, end).replace(/\s+/g, '');
at = skipWhitespace(end);
}
modifiers.push(modifier);
}
if (!bindingKind) {
fail(markerOffset, '@Snapshotable must immediately precede a stored property declaration');
continue;
}
at = skipWhitespace(at);
const identifier = identifierAt(at);
const fieldName = identifier?.text ?? '<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: {
@@ -154,35 +471,263 @@ export function computeCacheKey(inputs: {
swiftVersion: string;
toolGitRev: string;
platformTriple: string;
buildId?: string;
}): string {
const h = createHash('sha256');
h.update(`swift=${inputs.swiftVersion}|tool=${inputs.toolGitRev}|platform=${inputs.platformTriple}|`);
h.update(`${GENERATOR_FORMAT_VERSION}|swift=${inputs.swiftVersion}|tool=${inputs.toolGitRev}|platform=${inputs.platformTriple}|build=${inputs.buildId ?? 'unknown'}|`);
for (const f of inputs.swiftFiles) {
const content = readFileSync(f);
h.update(`${f}:${content.length}:`);
// Cache identity is content-based. Absolute checkout paths must not make
// equivalent source trees produce different generated output.
h.update(`${content.length}:`);
h.update(content);
h.update('|');
}
return h.digest('hex');
}
export function render(specs: AccessorSpec[], buildId: string, accessorHash: string): string {
let out = '// AUTO-GENERATED — DO NOT EDIT. Regenerate with /ios-sync.\n';
out += '#if DEBUG\nimport Foundation\nimport DebugBridge\n\n';
/** Stable snapshot-schema fingerprint, deliberately independent of cache ABI,
* source paths, app build provenance, and unmarked source. Field/class order is
* source order because restore payload compatibility is an ordered contract. */
export function computeAccessorHash(specs: AccessorSpec[]): string {
let signature = 'snapshot-schema-v1\n';
for (const spec of specs) {
out += `@MainActor\npublic enum ${spec.className}Accessor {\n`;
out += ` public static func register(_ state: ${spec.className}) {\n`;
signature += `C${Buffer.byteLength(spec.className)}:${spec.className}\n`;
for (const field of spec.fields) {
signature += `F${Buffer.byteLength(field.name)}:${field.name}`;
signature += `T${Buffer.byteLength(field.typeText)}:${field.typeText}\n`;
}
signature += 'E\n';
}
return createHash('sha256').update(signature).digest('hex');
}
/** Return the wrapped type for one top-level Optional spelling. */
export function optionalWrappedType(typeText: string): string | undefined {
const type = typeText.trim();
if (type.endsWith('?')) {
const wrapped = type.slice(0, -1).trim();
return wrapped.length > 0 ? wrapped : undefined;
}
const optional = /^(?:Swift\.)?Optional\s*</.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 += ` buildId: "${buildId}",\n`;
out += ` accessorHash: "${accessorHash}",\n`;
out += ` atomicRestore: { _ in .ok }\n`;
out += ` buildId: {\n`;
out += ` let shortVersion = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String\n`;
out += ` let bundleVersion = Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String\n`;
out += ` if let shortVersion, let bundleVersion { return "\\(shortVersion) (\\(bundleVersion))" }\n`;
out += ` return shortVersion ?? bundleVersion ?? ${swiftStringLiteral(buildId)}\n`;
out += ` }(),\n`;
out += ` accessorHash: ${swiftStringLiteral(accessorHash)},\n`;
out += ` atomicRestore: { keys, apply in\n`;
out += ` // Validate every key and value before assignment.\n`;
out += ` // Successful assignments are sequential on MainActor.\n`;
spec.fields.forEach((field, index) => {
out += ` guard let raw${index} = keys["${field.name}"] else {\n`;
out += ` return .missingKey("${field.name}")\n`;
out += ` }\n`;
const wrapped = optionalWrappedType(field.typeText);
if (wrapped !== undefined) {
out += ` let restored${index}: ${field.typeText}\n`;
out += ` if raw${index} is NSNull {\n`;
out += ` restored${index} = nil\n`;
out += ` } else if let typed = Self.decodeSnapshotValue(raw${index}, as: ${wrapped}.self) {\n`;
out += ` restored${index} = typed\n`;
out += ` } else {\n`;
out += ` return .typeMismatch("${field.name}")\n`;
out += ` }\n`;
} else {
out += ` guard let restored${index} = Self.decodeSnapshotValue(raw${index}, as: ${field.typeText}.self) else {\n`;
out += ` return .typeMismatch("${field.name}")\n`;
out += ` }\n`;
}
});
out += ` if apply {\n`;
spec.fields.forEach((field, index) => {
out += ` state.${field.name} = restored${index}\n`;
});
out += ` }\n`;
out += ` return .ok\n`;
out += ` }\n`;
out += ` )\n`;
for (const field of spec.fields) {
const wrapped = optionalWrappedType(field.typeText);
out += ` StateServer.shared.registerAccessor(\n`;
out += ` key: "${field.name}",\n`;
out += ` type: "${field.typeText}",\n`;
out += ` read: { state.${field.name} as Any? },\n`;
out += ` write: { _ in false }\n`;
if (wrapped !== undefined) {
out += ` read: {\n`;
out += ` guard let value = state.${field.name} else { return NSNull() }\n`;
out += ` return value as Any\n`;
out += ` },\n`;
} else {
out += ` read: { state.${field.name} as Any? },\n`;
}
out += ` write: { value in\n`;
if (wrapped !== undefined) {
out += ` if value is NSNull {\n`;
out += ` state.${field.name} = nil\n`;
out += ` return true\n`;
out += ` }\n`;
out += ` guard let typed = Self.decodeSnapshotValue(value, as: ${wrapped}.self) else { return false }\n`;
} else {
out += ` guard let typed = Self.decodeSnapshotValue(value, as: ${field.typeText}.self) else { return false }\n`;
}
out += ` state.${field.name} = typed\n`;
out += ` return true\n`;
out += ` }\n`;
out += ` )\n`;
}
out += ` }\n}\n\n`;
@@ -215,6 +760,15 @@ function detectToolGitRev(): string {
}
}
function detectBuildId(): string {
if (process.env.APP_BUILD_ID) return process.env.APP_BUILD_ID;
const marketing = process.env.MARKETING_VERSION;
const build = process.env.CURRENT_PROJECT_VERSION;
if (marketing && build) return `${marketing} (${build})`;
if (build) return build;
return 'unknown';
}
export function defaultCacheRoot(): string {
return process.env.GSTACK_IOS_CACHE_ROOT ?? join(homedir(), '.gstack', 'cache', 'gen-accessors');
}
@@ -223,13 +777,25 @@ export function generate(inputs: GenInputs): GenResult {
const inputDir = resolve(inputs.inputDir);
const outputDir = resolve(inputs.outputDir ?? inputDir);
const cacheRoot = inputs.cacheRoot ?? defaultCacheRoot();
const swiftFiles = collectSwiftFiles(inputDir);
const swiftFiles = collectSwiftFiles(inputDir, { outputDir });
const buildId = inputs.buildId ?? detectBuildId();
// Parse before cache lookup. This keeps diagnostics deterministic even when
// an older cache entry exists, and gives us the schema-only accessor hash.
const allSpecs: AccessorSpec[] = [];
for (const f of swiftFiles) {
const src = readFileSync(f, 'utf-8');
allSpecs.push(...parseSwift(src));
}
validateAccessorSpecs(allSpecs);
const accessorHash = computeAccessorHash(allSpecs);
const cacheKey = computeCacheKey({
swiftFiles,
swiftVersion: inputs.swiftVersion ?? detectSwiftVersion(),
toolGitRev: inputs.toolGitRev ?? detectToolGitRev(),
platformTriple: inputs.platformTriple ?? FALLBACK_PLATFORM,
buildId,
});
const cachedOutput = join(cacheRoot, cacheKey, 'StateAccessor.swift');
@@ -242,18 +808,13 @@ export function generate(inputs: GenInputs): GenResult {
return {
outputPath: finalOutput,
cacheKey,
specs: [], // intentionally empty on cache hit (no need to re-parse)
accessorHash,
specs: allSpecs,
cacheHit: true,
};
}
// Parse + render fresh
const allSpecs: AccessorSpec[] = [];
for (const f of swiftFiles) {
const src = readFileSync(f, 'utf-8');
allSpecs.push(...parseSwift(src));
}
const rendered = render(allSpecs, inputs.buildId ?? 'unknown', cacheKey);
const rendered = render(allSpecs, buildId, accessorHash);
writeFileSync(finalOutput, rendered);
// Populate cache (best-effort — cache failures don't break codegen).
@@ -267,6 +828,7 @@ export function generate(inputs: GenInputs): GenResult {
return {
outputPath: finalOutput,
cacheKey,
accessorHash,
specs: allSpecs,
cacheHit: false,
};
@@ -300,10 +862,18 @@ if (import.meta.main) {
const inputDir = args[inputIdx + 1]!;
const outputIdx = args.indexOf('--output');
const outputDir = outputIdx !== -1 ? args[outputIdx + 1] : undefined;
const result = generate({ inputDir, outputDir });
process.stdout.write(
result.cacheHit
? `gen-accessors: cache hit (${result.cacheKey.slice(0, 12)})\n`
: `gen-accessors: wrote ${result.specs.length} accessor(s) to ${result.outputPath}\n`,
);
try {
const result = generate({ inputDir, outputDir });
process.stdout.write(
result.cacheHit
? `gen-accessors: cache hit (${result.cacheKey.slice(0, 12)})\n`
: `gen-accessors: wrote ${result.specs.length} accessor(s) to ${result.outputPath}\n`,
);
} catch (error) {
if (error instanceof AccessorGenerationError) {
process.stderr.write(`${error.message}\n`);
process.exit(4);
}
throw error;
}
}
+205 -74
View File
@@ -27,12 +27,35 @@ public enum DebugBridgeUIWiring {
/// times reinstalls the same closures. Must be called on @MainActor
/// because every UIKit access requires the main actor.
public static func installAll() {
// KIF turns on accessibility automation before walking SwiftUI's AX
// tree. Without it SwiftUI exposes only the hosting shell and taps
// can report success without invoking Button.action.
DebugBridgeTouch.prepareForAutomation()
ScreenshotBridge.resolver = { ScreenshotBridgeImpl.capturePNG() }
ElementsBridge.resolver = { ElementsBridgeImpl.snapshot() }
MutationBridge.resolver = { op, payload in MutationBridgeImpl.dispatch(op: op, payload: payload) }
}
}
/// Return the children UIKit exposes specifically to accessibility automation.
/// iOS 17 added `automationElements`; unlike the older container APIs it
/// preserves identified SwiftUI descendants inside accessibility groups.
@MainActor
private func debugBridgeAccessibilityChildren(of element: NSObject) -> [NSObject] {
if #available(iOS 17.0, *),
let automation = element.automationElements,
!automation.isEmpty {
return automation.compactMap { $0 as? NSObject }
}
if let accessibility = element.accessibilityElements,
!accessibility.isEmpty {
return accessibility.compactMap { $0 as? NSObject }
}
let count = element.accessibilityElementCount()
guard count > 0, count < 512 else { return [] }
return (0..<count).compactMap { element.accessibilityElement(at: $0) as? NSObject }
}
// MARK: - ScreenshotBridge implementation
@MainActor
@@ -43,7 +66,11 @@ enum ScreenshotBridgeImpl {
static func capturePNG() -> Data? {
guard let scene = activeScene(), let window = activeKeyWindow(in: scene) else { return nil }
let bounds = window.bounds
let renderer = UIGraphicsImageRenderer(bounds: bounds)
let format = UIGraphicsImageRendererFormat.default()
// /tap consumes UIKit window points. Render at 1x so screenshot pixels
// use that same coordinate space on 2x/3x devices.
format.scale = 1
let renderer = UIGraphicsImageRenderer(bounds: bounds, format: format)
let image = renderer.image { _ in
// drawHierarchy is the documented way to snapshot real UIKit
// layers including layer-backed views. afterScreenUpdates: false
@@ -61,7 +88,10 @@ enum ScreenshotBridgeImpl {
}
private static func activeKeyWindow(in scene: UIWindowScene) -> UIWindow? {
scene.windows.first(where: { $0.isKeyWindow }) ?? scene.windows.first
let windows = scene.windows.filter { window in
!window.isHidden && !String(describing: type(of: window)).contains("PassThroughWindow")
}
return windows.first(where: { $0.isKeyWindow }) ?? windows.max(by: { $0.windowLevel < $1.windowLevel })
}
}
@@ -76,21 +106,40 @@ enum ElementsBridgeImpl {
static func snapshot() -> [JSONDict] {
guard let scene = activeScene(), let window = activeKeyWindow(in: scene) else { return [] }
var elements: [JSONDict] = []
collect(view: window, parentPath: "", windowBounds: window.bounds, into: &elements)
var visited = Set<ObjectIdentifier>()
var remaining = 2_048
collect(
view: window,
parentPath: "",
window: window,
visited: &visited,
remaining: &remaining,
into: &elements
)
return elements
}
private static func collect(view: UIView, parentPath: String, windowBounds: CGRect, into elements: inout [JSONDict]) {
private static func collect(
view: UIView,
parentPath: String,
window: UIWindow,
visited: inout Set<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.
if view.isHidden || view.alpha < 0.01 { return }
let frameInWindow = view.convert(view.bounds, to: nil)
if !windowBounds.intersects(frameInWindow) { return }
let frameInWindow = view.convert(view.bounds, to: window)
if !window.bounds.intersects(frameInWindow) { return }
let isAccessible = view.isAccessibilityElement
let label = view.accessibilityLabel ?? ""
let identifier = view.accessibilityIdentifier ?? ""
let traits = Int(view.accessibilityTraits.rawValue)
let traits = NSNumber(value: view.accessibilityTraits.rawValue)
let value = (view.accessibilityValue ?? "") as String
let className = String(describing: type(of: view))
let path = parentPath.isEmpty ? className : "\(parentPath) > \(className)"
@@ -120,66 +169,98 @@ enum ElementsBridgeImpl {
])
}
// Recurse into accessibility-elements first (some custom views vend
// synthetic children), then UIView subviews. SwiftUI's host views
// populate accessibilityElements lazily — many return nil before
// VoiceOver triggers them. Force population by reading accessibilityElementCount.
_ = view.accessibilityElementCount()
if let axElements = view.accessibilityElements {
for case let element as NSObject in axElements {
if let v = element as? UIView {
collect(view: v, parentPath: path, windowBounds: windowBounds, into: &elements)
} else {
// Synthetic accessibility element (no UIView). Capture frame in screen coords.
let af = (element.value(forKey: "accessibilityFrame") as? CGRect) ?? .zero
elements.append([
"path": "\(path) > <synthetic>",
"class": "AccessibilityElement",
"label": (element.value(forKey: "accessibilityLabel") as? String) ?? "",
"identifier": (element.value(forKey: "accessibilityIdentifier") as? String) ?? "",
"value": (element.value(forKey: "accessibilityValue") as? String) ?? "",
"traits": (element.value(forKey: "accessibilityTraits") as? NSNumber)?.intValue ?? 0,
"frame": [
"x": Int(af.origin.x),
"y": Int(af.origin.y),
"w": Int(af.size.width),
"h": Int(af.size.height),
],
"is_user_interaction_enabled": true,
])
}
}
} else {
// accessibilityElements is nil — iterate by index. SwiftUI uses
// this dynamic protocol pattern; many AX elements only respond
// to accessibilityElementCount + accessibilityElement(at:).
let count = view.accessibilityElementCount()
for i in 0..<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,
])
}
// Walk automation children before raw subviews. On iOS 17+ this
// exposes identified SwiftUI controls nested inside GroupBox/List.
for (index, element) in debugBridgeAccessibilityChildren(of: view).enumerated() {
if let child = element as? UIView {
collect(
view: child,
parentPath: path,
window: window,
visited: &visited,
remaining: &remaining,
into: &elements
)
} else {
appendSynthetic(
element,
path: "\(path) > <ax\(index)>",
window: window,
visited: &visited,
remaining: &remaining,
into: &elements
)
}
}
for sub in view.subviews {
collect(view: sub, parentPath: path, windowBounds: windowBounds, into: &elements)
collect(
view: sub,
parentPath: path,
window: window,
visited: &visited,
remaining: &remaining,
into: &elements
)
}
}
private static func appendSynthetic(
_ element: NSObject,
path: String,
window: UIWindow,
visited: inout Set<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? {
scene.windows.first(where: { $0.isKeyWindow }) ?? scene.windows.first
let windows = scene.windows.filter { window in
!window.isHidden && !String(describing: type(of: window)).contains("PassThroughWindow")
}
return windows.first(where: { $0.isKeyWindow }) ?? windows.max(by: { $0.windowLevel < $1.windowLevel })
}
}
@@ -210,21 +294,62 @@ enum MutationBridgeImpl {
}
}
/// Tap at (x, y) in window coordinates. Delegates to DebugBridgeTouch
/// (KIF-derived in-process touch synthesis). The Obj-C target builds a
/// real UITouch + IOHIDEvent + UIEvent and dispatches via
/// `UIApplication.sendEvent`, which is what UIKit uses for real touches.
/// This works for UIControl, SwiftUI Button (via iOS 18+
/// `_UIHitTestContext`), gesture recognizers, and anything else that
/// listens to the real event-dispatch path.
/// Tap at (x, y) in window coordinates. Prefer accessibility activation,
/// which is stable for SwiftUI buttons across OS releases, then fall back
/// to KIF-derived UITouch synthesis for gesture-only/custom controls.
private static func handleTap(_ payload: JSONDict) -> Bool {
guard let x = payload["x"] as? NSNumber,
let y = payload["y"] as? NSNumber else { return false }
let point = CGPoint(x: x.doubleValue, y: y.doubleValue)
guard let scene = activeScene(), let window = activeKeyWindow(in: scene) else { return false }
if let element = findActivatableAXElement(at: point, in: window),
element.accessibilityActivate() {
return true
}
return DebugBridgeTouch.sendTap(at: point, in: window)
}
private static func findActivatableAXElement(at point: CGPoint, in window: UIWindow) -> NSObject? {
let screenPoint = window.screen.coordinateSpace.convert(point, from: window.coordinateSpace)
var best: NSObject?
var bestArea: CGFloat = .infinity
var visited = Set<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.
private static func handleType(_ payload: JSONDict) -> Bool {
guard let text = payload["text"] as? String else { return false }
@@ -266,7 +391,10 @@ enum MutationBridgeImpl {
var off = scroll.contentOffset
off.x = max(0, min(scroll.contentSize.width - scroll.bounds.width, off.x + dx))
off.y = max(0, min(scroll.contentSize.height - scroll.bounds.height, off.y + dy))
scroll.setContentOffset(off, animated: true)
// Automation commands return synchronously; do not report
// success while the target is still moving underneath the
// next tap coordinate.
scroll.setContentOffset(off, animated: false)
return true
}
node = cur.superview
@@ -301,7 +429,10 @@ enum MutationBridgeImpl {
}
private static func activeKeyWindow(in scene: UIWindowScene) -> UIWindow? {
scene.windows.first(where: { $0.isKeyWindow }) ?? scene.windows.first
let windows = scene.windows.filter { window in
!window.isHidden && !String(describing: type(of: window)).contains("PassThroughWindow")
}
return windows.first(where: { $0.isKeyWindow }) ?? windows.max(by: { $0.windowLevel < $1.windowLevel })
}
}
@@ -14,12 +14,16 @@ import Foundation
public final class DebugBridgeManager {
public static let shared = DebugBridgeManager()
public func start(appState: AppState) {
// 1. Register the canonical AppState struct + accessor wiring.
// AppStateAccessor.register(_:) is generated by gen-accessors-tool.
AppStateAccessor.register(appState)
/// Register app-owned generated accessors, then start the server. The
/// registration closure is passed in from the consuming app because the
/// DebugBridgeCore package cannot import app-target types. On UIKit apps,
/// call DebugBridgeUIWiring.installAll() before this method so a warm
/// daemon cannot reach uninitialized resolvers during listener startup.
public func start<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()
// 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
@@ -23,6 +23,10 @@ NS_ASSUME_NONNULL_BEGIN
@interface DebugBridgeTouch : NSObject
/// Enable the in-process accessibility automation tree used by SwiftUI and
/// UIKit. Call once while installing the debug bridge, before /elements.
+ (void)prepareForAutomation;
/// Synthesize a single tap (TouchPhaseBegan + TouchPhaseEnded) at the given
/// window-coordinate point. Returns YES if the touch was delivered (a hit
/// view was found and the event passed through UIApplication.sendEvent).
+44 -2
View File
@@ -126,6 +126,36 @@ static BOOL DBT_LoadIOKit(void) {
return _IOKitLoaded;
}
// KIF enables accessibility automation before it asks UIKit/SwiftUI for the
// accessibility tree. Without this switch SwiftUI exposes only its hosting
// shell: /elements has no controls and a synthesized tap can return YES while
// Button.action never fires. Keep this DEBUG-only with the rest of this file.
typedef int (*DBTAXSAutomationEnabledFn)(void);
typedef void (*DBTAXSSetAutomationEnabledFn)(int);
static DBTAXSAutomationEnabledFn _DBTAXSAutomationEnabled;
static DBTAXSSetAutomationEnabledFn _DBTAXSSetAutomationEnabled;
static int _DBTInitialAutomationState = -1;
static void DBT_RestoreAccessibilityAutomation(void) {
if (_DBTAXSSetAutomationEnabled && _DBTInitialAutomationState >= 0) {
_DBTAXSSetAutomationEnabled(_DBTInitialAutomationState);
}
}
static void DBT_EnableAccessibilityAutomation(void) {
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
void *handle = dlopen("/usr/lib/libAccessibility.dylib", RTLD_NOW | RTLD_LOCAL);
if (!handle) return;
_DBTAXSAutomationEnabled = (DBTAXSAutomationEnabledFn)dlsym(handle, "_AXSAutomationEnabled");
_DBTAXSSetAutomationEnabled = (DBTAXSSetAutomationEnabledFn)dlsym(handle, "_AXSSetAutomationEnabled");
if (!_DBTAXSAutomationEnabled || !_DBTAXSSetAutomationEnabled) return;
_DBTInitialAutomationState = _DBTAXSAutomationEnabled();
if (!_DBTInitialAutomationState) _DBTAXSSetAutomationEnabled(1);
atexit(DBT_RestoreAccessibilityAutomation);
});
}
static IOHIDEventRef DBT_IOHIDEventWithTouch(UITouch *touch) CF_RETURNS_RETAINED;
static IOHIDEventRef DBT_IOHIDEventWithTouch(UITouch *touch) {
if (!DBT_LoadIOKit()) return NULL;
@@ -172,6 +202,7 @@ static IOHIDEventRef DBT_IOHIDEventWithTouch(UITouch *touch) {
- (void)setGestureView:(UIView *)view;
- (void)_setLocationInWindow:(CGPoint)location resetPrevious:(BOOL)resetPrevious;
- (void)_setIsFirstTouchForView:(BOOL)firstTouchForView;
- (void)_setIsTapToClick:(BOOL)tapToClick;
- (void)_setHidEvent:(IOHIDEventRef)event;
@end
@@ -229,6 +260,10 @@ static id DBT_HitTestView(UIWindow *window, CGPoint point) {
@implementation DebugBridgeTouch
+ (void)prepareForAutomation {
DBT_EnableAccessibilityAutomation();
}
+ (BOOL)sendTapAtPoint:(CGPoint)point inWindow:(UIWindow *)window {
if (!window) return NO;
@@ -247,10 +282,17 @@ static id DBT_HitTestView(UIWindow *window, CGPoint point) {
[touch setPhase:UITouchPhaseBegan];
if ([touch respondsToSelector:@selector(_setIsFirstTouchForView:)]) {
[touch _setIsFirstTouchForView:YES];
} else if ([touch respondsToSelector:@selector(_setIsTapToClick:)]) {
[touch _setIsTapToClick:YES];
Ivar flagsIvar = class_getInstanceVariable([UITouch class], "_touchFlags");
if (flagsIvar) {
ptrdiff_t offset = ivar_getOffset(flagsIvar);
char *flags = (__bridge void *)touch + offset;
*flags |= (char)0x01;
}
}
[touch setTimestamp:[[NSProcessInfo processInfo] systemUptime]];
if ([touch respondsToSelector:@selector(setGestureView:)] &&
[hit isKindOfClass:[UIView class]]) {
if ([touch respondsToSelector:@selector(setGestureView:)]) {
[touch setGestureView:(UIView *)hit];
}
@@ -6,22 +6,35 @@
// the DebugBridge target.
#if DEBUG
import DebugBridge
import Foundation
import DebugBridgeCore
#if canImport(UIKit)
import DebugBridgeUI
#endif
@MainActor
func startGstackDebugBridge(appState: AppState) {
func startGstackDebugBridge<State>(
appState: State,
register: (State) -> Void
) {
// Read --recording flag from launch arguments
let recording = ProcessInfo.processInfo.arguments.contains("--gstack-recording")
// Install accessibility + screenshot + mutation bridges before starting
// the server so the first authenticated request can use them.
ElementsBridge.resolver = { AccessibilityScanner.snapshot() }
ScreenshotBridge.resolver = { SnapshotCapture.capturePNG() }
MutationBridge.resolver = { op, payload in
MutationDispatcher.shared.run(op: op, payload: payload)
}
// Install the UI resolvers before opening the listener. A warm daemon can
// issue its first request as soon as StateServer starts; it must never see
// the default empty screenshot/elements/mutation handlers.
#if canImport(UIKit)
DebugBridgeUIWiring.installAll()
#endif
DebugBridgeManager.shared.start(appState: appState, recording: recording)
// Generated typed accessors live in the app target, so pass their register
// function into the package instead of asking DebugBridgeCore to import
// app-owned types.
DebugBridgeManager.shared.start(appState: appState, register: register)
#if canImport(UIKit)
DebugOverlayWindow.shared.install(recording: recording)
#endif
}
#endif
@@ -33,7 +46,10 @@ func startGstackDebugBridge(appState: AppState) {
//
// init() {
// #if DEBUG
// startGstackDebugBridge(appState: appState)
// startGstackDebugBridge(
// appState: appState,
// register: MyAppStateAccessor.register
// )
// #endif
// }
//
+1 -6
View File
@@ -1,3 +1,4 @@
// swift-tools-version:5.9
// AUTO-GENERATED from gstack/ios-qa/templates/Package.swift.template
//
// Drop-in SPM package definition for the DebugBridge stack. Three targets:
@@ -18,7 +19,6 @@
// CI invariant: `swift build -c release` + `nm -j build/Release/<binary>
// | grep -q DebugBridge && exit 1`.
// swift-tools-version:5.9
import PackageDescription
let package = Package(
@@ -58,10 +58,5 @@ let package = Package(
.define("DEBUG", .when(configuration: .debug)),
]
),
.testTarget(
name: "DebugBridgeCoreTests",
dependencies: ["DebugBridgeCore"],
path: "Tests/DebugBridgeCoreTests"
),
]
)
+10 -7
View File
@@ -3,12 +3,12 @@
//
// This file is a TEMPLATE that gen-accessors-tool fills in. The placeholders
// are filled per-class from swift-syntax AST inspection of the app's
// @Observable types. Only properties marked with @Snapshotable are emitted.
// @Observable types. Only properties preceded by `// @Snapshotable` are emitted.
//
// {{CLASS_NAME}} — the canonical AppState struct name
// {{APP_BUILD_ID}} — bundle short version + git SHA at codegen time
// {{ACCESSOR_HASH}} — sha256 of accessor signatures (snapshot schema fingerprint)
// {{ACCESSORS}} — generated register/read/write blocks per @Snapshotable field
// {{ACCESSORS}} — generated register/read/write blocks per marked field
#if DEBUG
@@ -21,13 +21,16 @@ public enum {{CLASS_NAME}}Accessor {
StateServer.shared.register(
buildId: "{{APP_BUILD_ID}}",
accessorHash: "{{ACCESSOR_HASH}}",
atomicRestore: { keys in
// Validate every key + type FIRST, then apply in one struct
// assignment so SwiftUI observers see exactly one change.
atomicRestore: { keys, apply in
// Validate every key + type before mutating any state. Invalid
// input therefore cannot leave a partially restored model.
var snapshot = state.snapshotable
{{VALIDATION_BLOCK}}
// Apply atomically.
state.snapshotable = snapshot
// StateServer validates every model first, then invokes the
// same handlers with apply=true for a cross-model commit.
if apply {
state.snapshotable = snapshot
}
return .ok
}
)
+49 -22
View File
@@ -59,18 +59,20 @@ public final class StateServer {
private var writeHandlers: [String: WriteHandler] = [:]
private var typeNames: [String: TypeName] = [:]
// Atomic-restore hook. Codegen wires this to the canonical AppState struct.
// Restore replaces the entire struct in one assignment so SwiftUI's Combine
// pipeline observes exactly one change notification — true observable
// atomicity. @MainActor alone doesn't guarantee that.
public typealias AtomicRestoreFn = (JSONDict) -> RestoreResult
// Validated-restore hooks. Every generated model registers one two-phase
// handler. The server validates all models before it lets any model mutate,
// so invalid input can never cause a cross-model partial restore.
// Valid restores apply properties on MainActor; observers may receive one
// change notification per property because arbitrary @Observable models do
// not expose a general single-assignment transaction API.
public typealias AtomicRestoreFn = (JSONDict, Bool) -> RestoreResult
public enum RestoreResult {
case ok
case missingKey(String)
case typeMismatch(String)
case schemaMismatch(expected: String, got: String)
}
private var atomicRestore: AtomicRestoreFn?
private var atomicRestores: [AtomicRestoreFn] = []
// Snapshot schema hash — written by codegen, stable across builds with
// identical accessor signatures.
@@ -109,7 +111,7 @@ public final class StateServer {
public func register(buildId: String, accessorHash: String, atomicRestore: @escaping AtomicRestoreFn) {
self.appBuildId = buildId
self.accessorHash = accessorHash
self.atomicRestore = atomicRestore
self.atomicRestores.append(atomicRestore)
}
public func registerAccessor(key: String, type: String, read: @escaping ReadHandler, write: @escaping WriteHandler) {
@@ -284,6 +286,7 @@ public final class StateServer {
"version": "1.0.0",
"build": appBuildId,
"accessor_hash": accessorHash,
"bundle_id": Bundle.main.bundleIdentifier ?? "unknown",
])
return
}
@@ -476,22 +479,36 @@ public final class StateServer {
send(connection: connection, status: 400, body: ["error": "missing_keys"])
return
}
guard let restore = atomicRestore else {
guard !atomicRestores.isEmpty else {
send(connection: connection, status: 503, body: ["error": "atomic_restore_not_registered"])
return
}
// Validate-then-apply via the codegen-supplied closure. The closure does
// a single struct-assignment so SwiftUI sees one change notification.
switch restore(keys) {
case .ok:
send(connection: connection, status: 200, body: ["ok": true])
case .missingKey(let k):
send(connection: connection, status: 400, body: ["error": "validation_failed", "key": k, "reason": "missing"])
case .typeMismatch(let k):
send(connection: connection, status: 400, body: ["error": "validation_failed", "key": k, "reason": "type-mismatch"])
case .schemaMismatch(let expected, let got):
send(connection: connection, status: 409, body: ["error": "schema_mismatch", "expected_hash": expected, "got_hash": got])
// Phase one validates every registered model without assignment.
for restore in atomicRestores {
switch restore(keys, false) {
case .ok:
continue
case .missingKey(let k):
send(connection: connection, status: 400, body: ["error": "validation_failed", "key": k, "reason": "missing"])
return
case .typeMismatch(let k):
send(connection: connection, status: 400, body: ["error": "validation_failed", "key": k, "reason": "type-mismatch"])
return
case .schemaMismatch(let expected, let got):
send(connection: connection, status: 409, body: ["error": "schema_mismatch", "expected_hash": expected, "got_hash": got])
return
}
}
// Phase two applies only after every model accepted the immutable input.
// A valid multi-field restore may notify once per property.
for restore in atomicRestores {
guard case .ok = restore(keys, true) else {
send(connection: connection, status: 500, body: ["error": "restore_apply_failed"])
return
}
}
send(connection: connection, status: 200, body: ["ok": true])
}
// MARK: Stubs (real impls live in DebugBridgeManager + UIKit)
@@ -522,9 +539,19 @@ public final class StateServer {
// MARK: Response
private func send(connection: NWConnection, status: Int, body: JSONDict) {
let json = (try? JSONSerialization.data(withJSONObject: body)) ?? Data("{}".utf8)
let responseStatus: Int
let json: Data
if JSONSerialization.isValidJSONObject(body),
let encoded = try? JSONSerialization.data(withJSONObject: body) {
responseStatus = status
json = encoded
} else {
logger.error("Refusing to send a non-JSON response body")
responseStatus = 500
json = Data("{\"error\":\"response_not_json_serializable\"}".utf8)
}
let statusText: String
switch status {
switch responseStatus {
case 200: statusText = "OK"
case 400: statusText = "Bad Request"
case 401: statusText = "Unauthorized"
@@ -537,7 +564,7 @@ public final class StateServer {
case 503: statusText = "Service Unavailable"
default: statusText = "Status"
}
let header = "HTTP/1.1 \(status) \(statusText)\r\nContent-Type: application/json\r\nContent-Length: \(json.count)\r\nConnection: close\r\n\r\n"
let header = "HTTP/1.1 \(responseStatus) \(statusText)\r\nContent-Type: application/json\r\nContent-Length: \(json.count)\r\nConnection: close\r\n\r\n"
var packet = Data(header.utf8)
packet.append(json)
connection.send(content: packet, completion: .contentProcessed { _ in
+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.
2. Upgrade gstack to a newer version with hardening fixes.
3. Move the `@Snapshotable` marker to a different field.
3. Move the `// @Snapshotable` generator marker comment to a different field.
This skill regenerates the relevant artifacts in place.
**Templates live in upstream gstack.** This skill resolves them from
`~/.claude/skills/gstack/ios-qa/templates/` (or the worktree's
`ios-qa/templates/` when developing gstack itself). The fork's HTTP-fetch
pattern is gone.
**Templates live in upstream gstack.** The installed
`gstack-ios-qa-regen` launcher resolves its own gstack root and copies only
the supported bridge files from `ios-qa/templates/`. The fork's HTTP-fetch
and wildcard-copy patterns are gone.
## Phase 1: Detect installed version
1. Read `<app>/DebugBridgeGenerated/.gstack-version` (written by /ios-qa
during install). If missing, treat the install as "unknown old version".
2. Read upstream version from `$GSTACK_HOME/ios-qa/.gstack-version` (or the
value baked into the installed gstack binary).
2. Read upstream version from `$GSTACK_ROOT/VERSION`.
3. If versions match AND no new `@Observable` classes were added, exit
early with "already up to date".
## Phase 2: Regenerate codegen output
Run `gstack-ios-qa-regen` (or the underlying SwiftPM tool directly):
Run the deterministic regenerator once. `--app-source` is the directory the
accessor scanner should inspect; `--bridge-dir` is the local Swift package
that the app links in Debug builds:
```bash
swift run --package-path "$GSTACK_HOME/ios-qa/scripts/gen-accessors-tool" \
gen-accessors --input "$APP_SOURCE_DIR" --output "$APP_SOURCE_DIR/DebugBridgeGenerated"
~/.claude/skills/gstack/bin/gstack-ios-qa-regen \
--app-source "$APP_SOURCE_DIR" \
--bridge-dir "$APP_SOURCE_DIR/DebugBridge"
```
The command removes only the known obsolete generated files from the former
flat `DebugBridgeGenerated/` layout before emitting the current accessor.
Generation accepts file-scope observable classes and JSON-native scalar,
array, String-keyed dictionary, and Optional field types. It rejects custom
types, implicitly unwrapped Optionals, nested observable classes, and duplicate
snapshot keys before writing a completion marker.
The composite-hash cache key handles whether anything actually needs
regenerating; if Swift version, generator git rev, lockfile, source content,
and platform triple all match the cache, this is a ~50ms no-op.
## Phase 3: Update templated Swift files in place
## Phase 3: Review the generated diff
For each file that comes from `ios-qa/templates/*.swift.template`:
1. Read the current installed file at
`<app>/DebugBridgeGenerated/<Name>.swift`.
2. Read the upstream template at
`$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).
1. Review changes under `<app>/DebugBridge/` and
`<app>/DebugBridgeGenerated/StateAccessor.swift`.
2. Confirm the command did not modify the app's handwritten Swift files.
3. Keep app-specific wiring in the app target; canonical bridge package files
are regenerated from upstream and should not be hand-edited.
## Phase 4: Verify
@@ -862,5 +866,6 @@ For each file that comes from `ios-qa/templates/*.swift.template`:
| Symptom | Action |
|---|---|
| Swift compile fails after regen | Revert via `git restore` + AskUserQuestion: surface the compile error |
| Schema hash unchanged after adding new @Observable | The new class isn't marked `@Snapshotable` — the codegen excludes it correctly. If the user wanted it snapshotted, add the wrapper. |
| `--input` source dir contains test fixtures | gen-accessors scans the input dir recursively; exclude test/ via `--exclude` |
| Codegen reports an invalid marked declaration | Use a file-scope observable class and a writable instance `var` with an explicit JSON-native type, internal/public setter, and a key unique across models; otherwise remove the `// @Snapshotable` marker. |
| Schema hash unchanged after adding new @Observable | No field has the standalone `// @Snapshotable` marker comment — codegen excludes unmarked state correctly. Add the comment immediately above each field that should be snapshotted. |
| Scanner sees generated bridge sources | Pass the narrow app source directory; the regenerator automatically excludes `DebugBridgeGenerated` and `StateAccessor.swift`. |
+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.
2. Upgrade gstack to a newer version with hardening fixes.
3. Move the `@Snapshotable` marker to a different field.
3. Move the `// @Snapshotable` generator marker comment to a different field.
This skill regenerates the relevant artifacts in place.
**Templates live in upstream gstack.** This skill resolves them from
`~/.claude/skills/gstack/ios-qa/templates/` (or the worktree's
`ios-qa/templates/` when developing gstack itself). The fork's HTTP-fetch
pattern is gone.
**Templates live in upstream gstack.** The installed
`gstack-ios-qa-regen` launcher resolves its own gstack root and copies only
the supported bridge files from `ios-qa/templates/`. The fork's HTTP-fetch
and wildcard-copy patterns are gone.
## Phase 1: Detect installed version
1. Read `<app>/DebugBridgeGenerated/.gstack-version` (written by /ios-qa
during install). If missing, treat the install as "unknown old version".
2. Read upstream version from `$GSTACK_HOME/ios-qa/.gstack-version` (or the
value baked into the installed gstack binary).
2. Read upstream version from `$GSTACK_ROOT/VERSION`.
3. If versions match AND no new `@Observable` classes were added, exit
early with "already up to date".
## Phase 2: Regenerate codegen output
Run `gstack-ios-qa-regen` (or the underlying SwiftPM tool directly):
Run the deterministic regenerator once. `--app-source` is the directory the
accessor scanner should inspect; `--bridge-dir` is the local Swift package
that the app links in Debug builds:
```bash
swift run --package-path "$GSTACK_HOME/ios-qa/scripts/gen-accessors-tool" \
gen-accessors --input "$APP_SOURCE_DIR" --output "$APP_SOURCE_DIR/DebugBridgeGenerated"
~/.claude/skills/gstack/bin/gstack-ios-qa-regen \
--app-source "$APP_SOURCE_DIR" \
--bridge-dir "$APP_SOURCE_DIR/DebugBridge"
```
The command removes only the known obsolete generated files from the former
flat `DebugBridgeGenerated/` layout before emitting the current accessor.
Generation accepts file-scope observable classes and JSON-native scalar,
array, String-keyed dictionary, and Optional field types. It rejects custom
types, implicitly unwrapped Optionals, nested observable classes, and duplicate
snapshot keys before writing a completion marker.
The composite-hash cache key handles whether anything actually needs
regenerating; if Swift version, generator git rev, lockfile, source content,
and platform triple all match the cache, this is a ~50ms no-op.
## Phase 3: Update templated Swift files in place
## Phase 3: Review the generated diff
For each file that comes from `ios-qa/templates/*.swift.template`:
1. Read the current installed file at
`<app>/DebugBridgeGenerated/<Name>.swift`.
2. Read the upstream template at
`$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).
1. Review changes under `<app>/DebugBridge/` and
`<app>/DebugBridgeGenerated/StateAccessor.swift`.
2. Confirm the command did not modify the app's handwritten Swift files.
3. Keep app-specific wiring in the app target; canonical bridge package files
are regenerated from upstream and should not be hand-edited.
## Phase 4: Verify
@@ -91,5 +95,6 @@ For each file that comes from `ios-qa/templates/*.swift.template`:
| Symptom | Action |
|---|---|
| Swift compile fails after regen | Revert via `git restore` + AskUserQuestion: surface the compile error |
| Schema hash unchanged after adding new @Observable | The new class isn't marked `@Snapshotable` — the codegen excludes it correctly. If the user wanted it snapshotted, add the wrapper. |
| `--input` source dir contains test fixtures | gen-accessors scans the input dir recursively; exclude test/ via `--exclude` |
| Codegen reports an invalid marked declaration | Use a file-scope observable class and a writable instance `var` with an explicit JSON-native type, internal/public setter, and a key unique across models; otherwise remove the `// @Snapshotable` marker. |
| Schema hash unchanged after adding new @Observable | No field has the standalone `// @Snapshotable` marker comment — codegen excludes unmarked state correctly. Add the comment immediately above each field that should be snapshotted. |
| Scanner sees generated bridge sources | Pass the narrow app source directory; the regenerator automatically excludes `DebugBridgeGenerated` and `StateAccessor.swift`. |
+42 -1
View File
@@ -106,9 +106,15 @@ export function canonicalizeRemote(url: string | null | undefined): string {
// strip user@ prefix on URL-style remotes
s = s.replace(/^[^@\/]+@/, "");
}
// strip trailing slash(es) first, so a URL written with a trailing slash
// still matches the `.git$` suffix below (e.g. ".../repo.git/" must
// canonicalize to ".../repo", not ".../repo.git").
s = s.replace(/\/+$/, "");
// strip trailing .git
s = s.replace(/\.git$/i, "");
// strip trailing slash
// re-strip trailing slash(es): a path remote ending in a `.git` directory
// component ("/repo/.git") exposes a new trailing slash once `.git` is
// stripped, which would split the repo into a second identity.
s = s.replace(/\/+$/, "");
// collapse multiple slashes (after path normalization)
s = s.replace(/\/{2,}/g, "/");
@@ -396,6 +402,7 @@ function extractGbrainBlock(frontmatter: string): GbrainManifest | null {
const globM = body.match(/(?:^|\n)\s*glob\s*:\s*"?([^"\n]+?)"?\s*$/m);
const sortM = body.match(/(?:^|\n)\s*sort\s*:\s*([^\n]+)/);
const tailM = body.match(/(?:^|\n)\s*tail\s*:\s*(\d+)/);
const filterMap = parseFilterMap(body);
if (idM) q.id = idM[1].trim();
if (kindM) {
@@ -408,6 +415,7 @@ function extractGbrainBlock(frontmatter: string): GbrainManifest | null {
if (globM) q.glob = globM[1].trim();
if (sortM) q.sort = sortM[1].trim();
if (tailM) q.tail = parseInt(tailM[1], 10);
if (filterMap) q.filter = filterMap;
if (q.id && q.kind && q.render_as) {
queries.push(q as GbrainManifestQuery);
@@ -418,6 +426,39 @@ function extractGbrainBlock(frontmatter: string): GbrainManifest | null {
return { schema, context_queries: queries };
}
/**
* Parse a nested `filter:` block map out of a single context_queries item body.
*
* The block is a YAML map nested under the `filter:` key:
*
* filter:
* type: timeline
* tags_contains: "repo:{repo_slug}"
*
* Each sub-key sits one indent level deeper than `filter:`. Surrounding quotes
* are stripped and template vars ({repo_slug}, now-7d, ...) are left intact for
* downstream substitution, matching how dispatchList stringifies each value
* into a `--filter k=v` argument. Returns undefined when there is no `filter:`
* block or it is empty.
*/
function parseFilterMap(body: string): Record<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 ──────────────────────────────────────────────
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 limit = 20;
function parseLimit(raw: string | undefined): number {
if (!raw || !/^[1-9]\d*$/.test(raw)) {
console.error('eval:list: --limit requires a positive integer');
process.exit(1);
}
const parsed = Number(raw);
if (!Number.isSafeInteger(parsed)) {
console.error('eval:list: --limit requires a positive integer');
process.exit(1);
}
return parsed;
}
for (let i = 0; i < args.length; i++) {
if (args[i] === '--branch' && args[i + 1]) { filterBranch = args[++i]; }
else if (args[i] === '--tier' && args[i + 1]) { filterTier = args[++i]; }
else if (args[i] === '--limit' && args[i + 1]) { limit = parseInt(args[++i], 10); }
else if (args[i] === '--limit') { limit = parseLimit(args[++i]); }
}
// Read eval files
+2 -1
View File
@@ -15,6 +15,7 @@
import { ALL_HOST_CONFIGS, getHostConfig, ALL_HOST_NAMES } from '../hosts/index';
import { validateAllConfigs } from './host-config';
import { RESOLVERS } from './resolvers';
import { execSync } from 'child_process';
const CLI_REGEX = /^[a-z][a-z0-9_-]*$/;
@@ -82,7 +83,7 @@ switch (command) {
}
case 'validate': {
const errors = validateAllConfigs(ALL_HOST_CONFIGS);
const errors = validateAllConfigs(ALL_HOST_CONFIGS, new Set(Object.keys(RESOLVERS)));
if (errors.length > 0) {
for (const error of errors) {
console.error(`ERROR: ${error}`);
+15 -3
View File
@@ -117,7 +117,7 @@ const NAME_REGEX = /^[a-z][a-z0-9-]*$/;
const PATH_REGEX = /^[a-zA-Z0-9_.\/${}~-]+$/;
const CLI_REGEX = /^[a-z][a-z0-9_-]*$/;
export function validateHostConfig(config: HostConfig): string[] {
export function validateHostConfig(config: HostConfig, validResolverNames?: ReadonlySet<string>): string[] {
const errors: string[] = [];
if (!NAME_REGEX.test(config.name)) {
@@ -152,15 +152,27 @@ export function validateHostConfig(config: HostConfig): string[] {
errors.push(`install.linkingStrategy must be 'real-dir-symlink' or 'symlink-generated'`);
}
// Cross-check suppressedResolvers against the known resolver names (injected to avoid a
// circular import on the resolver registry). A typo would otherwise silently no-op: the
// generator short-circuits suppressed names before the "unknown placeholder" throw, so an
// unknown entry never surfaces at generation time either.
if (validResolverNames && config.suppressedResolvers) {
for (const name of config.suppressedResolvers) {
if (!validResolverNames.has(name)) {
errors.push(`suppressedResolvers entry '${name}' is not a known resolver`);
}
}
}
return errors;
}
export function validateAllConfigs(configs: HostConfig[]): string[] {
export function validateAllConfigs(configs: HostConfig[], validResolverNames?: ReadonlySet<string>): string[] {
const errors: string[] = [];
// Per-config validation
for (const config of configs) {
const configErrors = validateHostConfig(config);
const configErrors = validateHostConfig(config, validResolverNames);
errors.push(...configErrors.map(e => `[${config.name}] ${e}`));
}
+6 -3
View File
@@ -62,9 +62,12 @@ const DESTRUCTIVE_PATTERNS: RegExp[] = [
/\bterraform\s+destroy\b/i,
/\brollback\b/i,
// Credentials / auth — allow filler words ("the", "my") between verb and noun
/\brevoke\s+[\w\s]*\b(api key|token|credential|access key|password)\b/i,
/\breset\s+[\w\s]*\b(api key|token|password|credential)\b/i,
// Credentials / auth — allow filler words ("the", "my") between verb and noun.
// Keep the noun alternation identical across revoke/reset/rotate so the three
// verbs stay parallel; a noun in one but not the others is a false-negative
// safety hole (e.g. "reset my secret" must be one-way just like "rotate my secret").
/\brevoke\s+[\w\s]*\b(api key|token|secret|credential|access key|password)\b/i,
/\breset\s+[\w\s]*\b(api key|token|secret|credential|access key|password)\b/i,
/\brotate\s+[\w\s]*\b(api key|token|secret|credential|access key|password)\b/i,
// Scope / architecture forks (reversible with effort — still deserve confirmation)
+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 static let shared = DebugBridgeManager()
public func start(appState: AppState) {
// 1. Register the canonical AppState struct + accessor wiring.
// AppStateAccessor.register(_:) is generated by gen-accessors-tool.
AppStateAccessor.register(appState)
/// Register app-owned generated accessors, then start the server. The
/// registration closure is passed in from the consuming app because the
/// DebugBridgeCore package cannot import app-target types. On UIKit apps,
/// call DebugBridgeUIWiring.installAll() before this method so a warm
/// daemon cannot reach uninitialized resolvers during listener startup.
public func start<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()
// 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
@@ -59,18 +59,20 @@ public final class StateServer {
private var writeHandlers: [String: WriteHandler] = [:]
private var typeNames: [String: TypeName] = [:]
// Atomic-restore hook. Codegen wires this to the canonical AppState struct.
// Restore replaces the entire struct in one assignment so SwiftUI's Combine
// pipeline observes exactly one change notification true observable
// atomicity. @MainActor alone doesn't guarantee that.
public typealias AtomicRestoreFn = (JSONDict) -> RestoreResult
// Validated-restore hooks. Every generated model registers one two-phase
// handler. The server validates all models before it lets any model mutate,
// so invalid input can never cause a cross-model partial restore.
// Valid restores apply properties on MainActor; observers may receive one
// change notification per property because arbitrary @Observable models do
// not expose a general single-assignment transaction API.
public typealias AtomicRestoreFn = (JSONDict, Bool) -> RestoreResult
public enum RestoreResult {
case ok
case missingKey(String)
case typeMismatch(String)
case schemaMismatch(expected: String, got: String)
}
private var atomicRestore: AtomicRestoreFn?
private var atomicRestores: [AtomicRestoreFn] = []
// Snapshot schema hash written by codegen, stable across builds with
// identical accessor signatures.
@@ -109,7 +111,7 @@ public final class StateServer {
public func register(buildId: String, accessorHash: String, atomicRestore: @escaping AtomicRestoreFn) {
self.appBuildId = buildId
self.accessorHash = accessorHash
self.atomicRestore = atomicRestore
self.atomicRestores.append(atomicRestore)
}
public func registerAccessor(key: String, type: String, read: @escaping ReadHandler, write: @escaping WriteHandler) {
@@ -284,6 +286,7 @@ public final class StateServer {
"version": "1.0.0",
"build": appBuildId,
"accessor_hash": accessorHash,
"bundle_id": Bundle.main.bundleIdentifier ?? "unknown",
])
return
}
@@ -476,22 +479,36 @@ public final class StateServer {
send(connection: connection, status: 400, body: ["error": "missing_keys"])
return
}
guard let restore = atomicRestore else {
guard !atomicRestores.isEmpty else {
send(connection: connection, status: 503, body: ["error": "atomic_restore_not_registered"])
return
}
// Validate-then-apply via the codegen-supplied closure. The closure does
// a single struct-assignment so SwiftUI sees one change notification.
switch restore(keys) {
case .ok:
send(connection: connection, status: 200, body: ["ok": true])
case .missingKey(let k):
send(connection: connection, status: 400, body: ["error": "validation_failed", "key": k, "reason": "missing"])
case .typeMismatch(let k):
send(connection: connection, status: 400, body: ["error": "validation_failed", "key": k, "reason": "type-mismatch"])
case .schemaMismatch(let expected, let got):
send(connection: connection, status: 409, body: ["error": "schema_mismatch", "expected_hash": expected, "got_hash": got])
// Phase one validates every registered model without assignment.
for restore in atomicRestores {
switch restore(keys, false) {
case .ok:
continue
case .missingKey(let k):
send(connection: connection, status: 400, body: ["error": "validation_failed", "key": k, "reason": "missing"])
return
case .typeMismatch(let k):
send(connection: connection, status: 400, body: ["error": "validation_failed", "key": k, "reason": "type-mismatch"])
return
case .schemaMismatch(let expected, let got):
send(connection: connection, status: 409, body: ["error": "schema_mismatch", "expected_hash": expected, "got_hash": got])
return
}
}
// Phase two applies only after every model accepted the immutable input.
// A valid multi-field restore may notify once per property.
for restore in atomicRestores {
guard case .ok = restore(keys, true) else {
send(connection: connection, status: 500, body: ["error": "restore_apply_failed"])
return
}
}
send(connection: connection, status: 200, body: ["ok": true])
}
// MARK: Stubs (real impls live in DebugBridgeManager + UIKit)
@@ -522,9 +539,19 @@ public final class StateServer {
// MARK: Response
private func send(connection: NWConnection, status: Int, body: JSONDict) {
let json = (try? JSONSerialization.data(withJSONObject: body)) ?? Data("{}".utf8)
let responseStatus: Int
let json: Data
if JSONSerialization.isValidJSONObject(body),
let encoded = try? JSONSerialization.data(withJSONObject: body) {
responseStatus = status
json = encoded
} else {
logger.error("Refusing to send a non-JSON response body")
responseStatus = 500
json = Data("{\"error\":\"response_not_json_serializable\"}".utf8)
}
let statusText: String
switch status {
switch responseStatus {
case 200: statusText = "OK"
case 400: statusText = "Bad Request"
case 401: statusText = "Unauthorized"
@@ -537,7 +564,7 @@ public final class StateServer {
case 503: statusText = "Service Unavailable"
default: statusText = "Status"
}
let header = "HTTP/1.1 \(status) \(statusText)\r\nContent-Type: application/json\r\nContent-Length: \(json.count)\r\nConnection: close\r\n\r\n"
let header = "HTTP/1.1 \(responseStatus) \(statusText)\r\nContent-Type: application/json\r\nContent-Length: \(json.count)\r\nConnection: close\r\n\r\n"
var packet = Data(header.utf8)
packet.append(json)
connection.send(content: packet, completion: .contentProcessed { _ in
@@ -126,6 +126,36 @@ static BOOL DBT_LoadIOKit(void) {
return _IOKitLoaded;
}
// KIF enables accessibility automation before it asks UIKit/SwiftUI for the
// accessibility tree. Without this switch SwiftUI exposes only its hosting
// shell: /elements has no controls and a synthesized tap can return YES while
// Button.action never fires. Keep this DEBUG-only with the rest of this file.
typedef int (*DBTAXSAutomationEnabledFn)(void);
typedef void (*DBTAXSSetAutomationEnabledFn)(int);
static DBTAXSAutomationEnabledFn _DBTAXSAutomationEnabled;
static DBTAXSSetAutomationEnabledFn _DBTAXSSetAutomationEnabled;
static int _DBTInitialAutomationState = -1;
static void DBT_RestoreAccessibilityAutomation(void) {
if (_DBTAXSSetAutomationEnabled && _DBTInitialAutomationState >= 0) {
_DBTAXSSetAutomationEnabled(_DBTInitialAutomationState);
}
}
static void DBT_EnableAccessibilityAutomation(void) {
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
void *handle = dlopen("/usr/lib/libAccessibility.dylib", RTLD_NOW | RTLD_LOCAL);
if (!handle) return;
_DBTAXSAutomationEnabled = (DBTAXSAutomationEnabledFn)dlsym(handle, "_AXSAutomationEnabled");
_DBTAXSSetAutomationEnabled = (DBTAXSSetAutomationEnabledFn)dlsym(handle, "_AXSSetAutomationEnabled");
if (!_DBTAXSAutomationEnabled || !_DBTAXSSetAutomationEnabled) return;
_DBTInitialAutomationState = _DBTAXSAutomationEnabled();
if (!_DBTInitialAutomationState) _DBTAXSSetAutomationEnabled(1);
atexit(DBT_RestoreAccessibilityAutomation);
});
}
static IOHIDEventRef DBT_IOHIDEventWithTouch(UITouch *touch) CF_RETURNS_RETAINED;
static IOHIDEventRef DBT_IOHIDEventWithTouch(UITouch *touch) {
if (!DBT_LoadIOKit()) return NULL;
@@ -172,6 +202,7 @@ static IOHIDEventRef DBT_IOHIDEventWithTouch(UITouch *touch) {
- (void)setGestureView:(UIView *)view;
- (void)_setLocationInWindow:(CGPoint)location resetPrevious:(BOOL)resetPrevious;
- (void)_setIsFirstTouchForView:(BOOL)firstTouchForView;
- (void)_setIsTapToClick:(BOOL)tapToClick;
- (void)_setHidEvent:(IOHIDEventRef)event;
@end
@@ -229,6 +260,10 @@ static id DBT_HitTestView(UIWindow *window, CGPoint point) {
@implementation DebugBridgeTouch
+ (void)prepareForAutomation {
DBT_EnableAccessibilityAutomation();
}
+ (BOOL)sendTapAtPoint:(CGPoint)point inWindow:(UIWindow *)window {
if (!window) return NO;
@@ -247,10 +282,17 @@ static id DBT_HitTestView(UIWindow *window, CGPoint point) {
[touch setPhase:UITouchPhaseBegan];
if ([touch respondsToSelector:@selector(_setIsFirstTouchForView:)]) {
[touch _setIsFirstTouchForView:YES];
} else if ([touch respondsToSelector:@selector(_setIsTapToClick:)]) {
[touch _setIsTapToClick:YES];
Ivar flagsIvar = class_getInstanceVariable([UITouch class], "_touchFlags");
if (flagsIvar) {
ptrdiff_t offset = ivar_getOffset(flagsIvar);
char *flags = (__bridge void *)touch + offset;
*flags |= (char)0x01;
}
}
[touch setTimestamp:[[NSProcessInfo processInfo] systemUptime]];
if ([touch respondsToSelector:@selector(setGestureView:)] &&
[hit isKindOfClass:[UIView class]]) {
if ([touch respondsToSelector:@selector(setGestureView:)]) {
[touch setGestureView:(UIView *)hit];
}
@@ -23,6 +23,10 @@ NS_ASSUME_NONNULL_BEGIN
@interface DebugBridgeTouch : NSObject
/// Enable the in-process accessibility automation tree used by SwiftUI and
/// UIKit. Call once while installing the debug bridge, before /elements.
+ (void)prepareForAutomation;
/// Synthesize a single tap (TouchPhaseBegan + TouchPhaseEnded) at the given
/// window-coordinate point. Returns YES if the touch was delivered (a hit
/// view was found and the event passed through UIApplication.sendEvent).
@@ -27,12 +27,35 @@ public enum DebugBridgeUIWiring {
/// times reinstalls the same closures. Must be called on @MainActor
/// because every UIKit access requires the main actor.
public static func installAll() {
// KIF turns on accessibility automation before walking SwiftUI's AX
// tree. Without it SwiftUI exposes only the hosting shell and taps
// can report success without invoking Button.action.
DebugBridgeTouch.prepareForAutomation()
ScreenshotBridge.resolver = { ScreenshotBridgeImpl.capturePNG() }
ElementsBridge.resolver = { ElementsBridgeImpl.snapshot() }
MutationBridge.resolver = { op, payload in MutationBridgeImpl.dispatch(op: op, payload: payload) }
}
}
/// Return the children UIKit exposes specifically to accessibility automation.
/// iOS 17 added `automationElements`; unlike the older container APIs it
/// preserves identified SwiftUI descendants inside accessibility groups.
@MainActor
private func debugBridgeAccessibilityChildren(of element: NSObject) -> [NSObject] {
if #available(iOS 17.0, *),
let automation = element.automationElements,
!automation.isEmpty {
return automation.compactMap { $0 as? NSObject }
}
if let accessibility = element.accessibilityElements,
!accessibility.isEmpty {
return accessibility.compactMap { $0 as? NSObject }
}
let count = element.accessibilityElementCount()
guard count > 0, count < 512 else { return [] }
return (0..<count).compactMap { element.accessibilityElement(at: $0) as? NSObject }
}
// MARK: - ScreenshotBridge implementation
@MainActor
@@ -43,7 +66,11 @@ enum ScreenshotBridgeImpl {
static func capturePNG() -> Data? {
guard let scene = activeScene(), let window = activeKeyWindow(in: scene) else { return nil }
let bounds = window.bounds
let renderer = UIGraphicsImageRenderer(bounds: bounds)
let format = UIGraphicsImageRendererFormat.default()
// /tap consumes UIKit window points. Render at 1x so screenshot pixels
// use that same coordinate space on 2x/3x devices.
format.scale = 1
let renderer = UIGraphicsImageRenderer(bounds: bounds, format: format)
let image = renderer.image { _ in
// drawHierarchy is the documented way to snapshot real UIKit
// layers including layer-backed views. afterScreenUpdates: false
@@ -61,7 +88,10 @@ enum ScreenshotBridgeImpl {
}
private static func activeKeyWindow(in scene: UIWindowScene) -> UIWindow? {
scene.windows.first(where: { $0.isKeyWindow }) ?? scene.windows.first
let windows = scene.windows.filter { window in
!window.isHidden && !String(describing: type(of: window)).contains("PassThroughWindow")
}
return windows.first(where: { $0.isKeyWindow }) ?? windows.max(by: { $0.windowLevel < $1.windowLevel })
}
}
@@ -76,21 +106,40 @@ enum ElementsBridgeImpl {
static func snapshot() -> [JSONDict] {
guard let scene = activeScene(), let window = activeKeyWindow(in: scene) else { return [] }
var elements: [JSONDict] = []
collect(view: window, parentPath: "", windowBounds: window.bounds, into: &elements)
var visited = Set<ObjectIdentifier>()
var remaining = 2_048
collect(
view: window,
parentPath: "",
window: window,
visited: &visited,
remaining: &remaining,
into: &elements
)
return elements
}
private static func collect(view: UIView, parentPath: String, windowBounds: CGRect, into elements: inout [JSONDict]) {
private static func collect(
view: UIView,
parentPath: String,
window: UIWindow,
visited: inout Set<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.
if view.isHidden || view.alpha < 0.01 { return }
let frameInWindow = view.convert(view.bounds, to: nil)
if !windowBounds.intersects(frameInWindow) { return }
let frameInWindow = view.convert(view.bounds, to: window)
if !window.bounds.intersects(frameInWindow) { return }
let isAccessible = view.isAccessibilityElement
let label = view.accessibilityLabel ?? ""
let identifier = view.accessibilityIdentifier ?? ""
let traits = Int(view.accessibilityTraits.rawValue)
let traits = NSNumber(value: view.accessibilityTraits.rawValue)
let value = (view.accessibilityValue ?? "") as String
let className = String(describing: type(of: view))
let path = parentPath.isEmpty ? className : "\(parentPath) > \(className)"
@@ -120,66 +169,98 @@ enum ElementsBridgeImpl {
])
}
// Recurse into accessibility-elements first (some custom views vend
// synthetic children), then UIView subviews. SwiftUI's host views
// populate accessibilityElements lazily many return nil before
// VoiceOver triggers them. Force population by reading accessibilityElementCount.
_ = view.accessibilityElementCount()
if let axElements = view.accessibilityElements {
for case let element as NSObject in axElements {
if let v = element as? UIView {
collect(view: v, parentPath: path, windowBounds: windowBounds, into: &elements)
} else {
// Synthetic accessibility element (no UIView). Capture frame in screen coords.
let af = (element.value(forKey: "accessibilityFrame") as? CGRect) ?? .zero
elements.append([
"path": "\(path) > <synthetic>",
"class": "AccessibilityElement",
"label": (element.value(forKey: "accessibilityLabel") as? String) ?? "",
"identifier": (element.value(forKey: "accessibilityIdentifier") as? String) ?? "",
"value": (element.value(forKey: "accessibilityValue") as? String) ?? "",
"traits": (element.value(forKey: "accessibilityTraits") as? NSNumber)?.intValue ?? 0,
"frame": [
"x": Int(af.origin.x),
"y": Int(af.origin.y),
"w": Int(af.size.width),
"h": Int(af.size.height),
],
"is_user_interaction_enabled": true,
])
}
}
} else {
// accessibilityElements is nil iterate by index. SwiftUI uses
// this dynamic protocol pattern; many AX elements only respond
// to accessibilityElementCount + accessibilityElement(at:).
let count = view.accessibilityElementCount()
for i in 0..<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,
])
}
// Walk automation children before raw subviews. On iOS 17+ this
// exposes identified SwiftUI controls nested inside GroupBox/List.
for (index, element) in debugBridgeAccessibilityChildren(of: view).enumerated() {
if let child = element as? UIView {
collect(
view: child,
parentPath: path,
window: window,
visited: &visited,
remaining: &remaining,
into: &elements
)
} else {
appendSynthetic(
element,
path: "\(path) > <ax\(index)>",
window: window,
visited: &visited,
remaining: &remaining,
into: &elements
)
}
}
for sub in view.subviews {
collect(view: sub, parentPath: path, windowBounds: windowBounds, into: &elements)
collect(
view: sub,
parentPath: path,
window: window,
visited: &visited,
remaining: &remaining,
into: &elements
)
}
}
private static func appendSynthetic(
_ element: NSObject,
path: String,
window: UIWindow,
visited: inout Set<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? {
scene.windows.first(where: { $0.isKeyWindow }) ?? scene.windows.first
let windows = scene.windows.filter { window in
!window.isHidden && !String(describing: type(of: window)).contains("PassThroughWindow")
}
return windows.first(where: { $0.isKeyWindow }) ?? windows.max(by: { $0.windowLevel < $1.windowLevel })
}
}
@@ -210,21 +294,62 @@ enum MutationBridgeImpl {
}
}
/// Tap at (x, y) in window coordinates. Delegates to DebugBridgeTouch
/// (KIF-derived in-process touch synthesis). The Obj-C target builds a
/// real UITouch + IOHIDEvent + UIEvent and dispatches via
/// `UIApplication.sendEvent`, which is what UIKit uses for real touches.
/// This works for UIControl, SwiftUI Button (via iOS 18+
/// `_UIHitTestContext`), gesture recognizers, and anything else that
/// listens to the real event-dispatch path.
/// Tap at (x, y) in window coordinates. Prefer accessibility activation,
/// which is stable for SwiftUI buttons across OS releases, then fall back
/// to KIF-derived UITouch synthesis for gesture-only/custom controls.
private static func handleTap(_ payload: JSONDict) -> Bool {
guard let x = payload["x"] as? NSNumber,
let y = payload["y"] as? NSNumber else { return false }
let point = CGPoint(x: x.doubleValue, y: y.doubleValue)
guard let scene = activeScene(), let window = activeKeyWindow(in: scene) else { return false }
if let element = findActivatableAXElement(at: point, in: window),
element.accessibilityActivate() {
return true
}
return DebugBridgeTouch.sendTap(at: point, in: window)
}
private static func findActivatableAXElement(at point: CGPoint, in window: UIWindow) -> NSObject? {
let screenPoint = window.screen.coordinateSpace.convert(point, from: window.coordinateSpace)
var best: NSObject?
var bestArea: CGFloat = .infinity
var visited = Set<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.
private static func handleType(_ payload: JSONDict) -> Bool {
guard let text = payload["text"] as? String else { return false }
@@ -266,7 +391,10 @@ enum MutationBridgeImpl {
var off = scroll.contentOffset
off.x = max(0, min(scroll.contentSize.width - scroll.bounds.width, off.x + dx))
off.y = max(0, min(scroll.contentSize.height - scroll.bounds.height, off.y + dy))
scroll.setContentOffset(off, animated: true)
// Automation commands return synchronously; do not report
// success while the target is still moving underneath the
// next tap coordinate.
scroll.setContentOffset(off, animated: false)
return true
}
node = cur.superview
@@ -301,7 +429,10 @@ enum MutationBridgeImpl {
}
private static func activeKeyWindow(in scene: UIWindowScene) -> UIWindow? {
scene.windows.first(where: { $0.isKeyWindow }) ?? scene.windows.first
let windows = scene.windows.filter { window in
!window.isHidden && !String(describing: type(of: window)).contains("PassThroughWindow")
}
return windows.first(where: { $0.isKeyWindow }) ?? windows.max(by: { $0.windowLevel < $1.windowLevel })
}
}
@@ -1,15 +1,21 @@
// FixtureApp minimal SwiftUI app used by the ios-qa device-path E2E test.
// FixtureApp interaction-rich SwiftUI app used by the ios-qa device-path
// E2E test. Every control exposes a stable accessibility identifier and writes
// to visible verification state so device-driven taps have an explicit oracle.
//
// On launch:
// 1. Boot StateServer (loopback :::1/127.0.0.1 + 9999)
// 2. Log boot token to os_log so devicectl + the Mac daemon can scrape it
// 3. Render a single ContentView so the app stays foreground
// 1. Install UI resolvers before any request can arrive
// 2. Register typed state and boot StateServer (::1/127.0.0.1 + 9999)
// 3. Log the one-use boot token, then render the interaction harness
//
// Everything ios-qa-related is gated #if DEBUG. Release builds compile this
// to a no-op app (no StateServer, no DebugBridge import, no overlay).
import SwiftUI
#if canImport(UIKit)
import UIKit
#endif
#if DEBUG
import DebugBridgeCore
#endif
@@ -20,14 +26,21 @@ import DebugBridgeUI
@main
struct FixtureAppApp: App {
#if DEBUG
private let appState = FixtureAppState()
#endif
init() {
#if DEBUG
StateServer.shared.start()
// Wire the three UIKit-backed bridges so /screenshot, /elements,
// /tap, /type, /swipe actually do something on the device.
// /tap, /type, /swipe are ready before the listener accepts requests.
#if canImport(UIKit)
DebugBridgeUIWiring.installAll()
#endif
DebugBridgeManager.shared.start(
appState: appState,
register: FixtureAppStateAccessor.register
)
#endif
}
@@ -39,22 +52,707 @@ struct FixtureAppApp: App {
}
struct ContentView: View {
@State private var counter: Int = 0
private enum HarnessTab: String, Hashable {
case controls
case inputs
case rows
var title: String { rawValue.capitalized }
}
private struct HarnessRow: Identifiable {
let id: String
let title: String
let symbol: String
}
private static let harnessRows = [
HarnessRow(id: "alpha", title: "Alpha row", symbol: "a.circle.fill"),
HarnessRow(id: "bravo", title: "Bravo row", symbol: "b.circle.fill"),
HarnessRow(id: "charlie", title: "Charlie row", symbol: "c.circle.fill"),
HarnessRow(id: "delta", title: "Delta row", symbol: "d.circle.fill"),
]
@State private var selectedTab: HarnessTab = .controls
@State private var lastAction = "Harness ready"
@State private var totalActions = 0
@State private var tabChangeCount = 0
@State private var primaryButtonCount = 0
@State private var borderedButtonCount = 0
@State private var plainButtonCount = 0
@State private var destructiveButtonCount = 0
@State private var toolbarRefreshCount = 0
@State private var menuAddCount = 0
@State private var menuArchiveCount = 0
@State private var detailOpenCount = 0
@State private var detailCloseCount = 0
@State private var detailButtonCount = 0
@State private var isShowingDetail = false
@State private var toggleValue = false
@State private var toggleChangeCount = 0
@State private var stepperValue = 0
@State private var stepperChangeCount = 0
@State private var selectedMode = "One"
@State private var pickerChangeCount = 0
@State private var draftText = ""
@FocusState private var isTextFieldFocused: Bool
@State private var textChangeCount = 0
@State private var textCommitCount = 0
@State private var uikitButtonCount = 0
@State private var rowTapCounts: [String: Int] = [:]
@State private var rowFlagCount = 0
@State private var rowArchiveCount = 0
@State private var rowToolbarCount = 0
var body: some View {
VStack(spacing: 24) {
Text("ios-qa fixture")
.font(.largeTitle.bold())
Text("StateServer should be on :9999")
.font(.subheadline)
.foregroundColor(.secondary)
Button("Tap (\(counter))") {
counter += 1
}
.buttonStyle(.borderedProminent)
.accessibilityIdentifier("tap-button")
TabView(selection: $selectedTab) {
controlsTab
.tabItem {
Label("Controls", systemImage: "hand.tap.fill")
.accessibilityIdentifier("tab-controls")
}
.tag(HarnessTab.controls)
inputsTab
.tabItem {
Label("Inputs", systemImage: "slider.horizontal.3")
.accessibilityIdentifier("tab-inputs")
}
.tag(HarnessTab.inputs)
rowsTab
.tabItem {
Label("Rows", systemImage: "list.bullet.rectangle")
.accessibilityIdentifier("tab-rows")
}
.tag(HarnessTab.rows)
}
.padding()
.accessibilityIdentifier("fixture-content")
.accessibilityIdentifier("fixture-tab-view")
.onChange(of: selectedTab) { newTab in
tabChangeCount += 1
record("Selected \(newTab.title) tab")
}
}
private var controlsTab: some View {
NavigationStack {
ScrollView {
VStack(spacing: 14) {
verificationPanel
GroupBox {
LazyVGrid(
columns: [GridItem(.flexible()), GridItem(.flexible())],
spacing: 10
) {
Button {
primaryButtonCount += 1
record("Primary button tapped")
} label: {
buttonLabel("Primary", count: primaryButtonCount, symbol: "bolt.fill")
}
.buttonStyle(.borderedProminent)
.accessibilityIdentifier("primary-button")
.accessibilityValue("\(primaryButtonCount) taps")
Button {
borderedButtonCount += 1
record("Bordered button tapped")
} label: {
buttonLabel("Bordered", count: borderedButtonCount, symbol: "square")
}
.buttonStyle(.bordered)
.accessibilityIdentifier("bordered-button")
.accessibilityValue("\(borderedButtonCount) taps")
Button {
plainButtonCount += 1
record("Plain button tapped")
} label: {
buttonLabel("Plain", count: plainButtonCount, symbol: "circle")
.padding(.vertical, 8)
.background(Color.accentColor.opacity(0.12))
.clipShape(RoundedRectangle(cornerRadius: 8))
}
.buttonStyle(.plain)
.accessibilityIdentifier("plain-button")
.accessibilityValue("\(plainButtonCount) taps")
Button(role: .destructive) {
destructiveButtonCount += 1
record("Destructive-style button tapped")
} label: {
buttonLabel(
"Destructive",
count: destructiveButtonCount,
symbol: "exclamationmark.triangle.fill"
)
}
.buttonStyle(.bordered)
.accessibilityIdentifier("destructive-button")
.accessibilityValue("\(destructiveButtonCount) taps")
}
} label: {
Label("SwiftUI button styles", systemImage: "rectangle.3.group")
.font(.headline)
}
.accessibilityIdentifier("button-styles-group")
GroupBox {
VStack(spacing: 10) {
verificationRow(
"Refresh",
value: toolbarRefreshCount,
identifier: "nav-refresh-count"
)
verificationRow(
"Menu add",
value: menuAddCount,
identifier: "menu-add-count"
)
verificationRow(
"Menu archive",
value: menuArchiveCount,
identifier: "menu-archive-count"
)
verificationRow(
"Detail opened / closed",
textValue: "\(detailOpenCount) / \(detailCloseCount)",
identifier: "detail-navigation-count"
)
}
} label: {
Label("Navigation and menu results", systemImage: "menubar.rectangle")
.font(.headline)
}
.accessibilityIdentifier("navigation-results-group")
Button {
detailOpenCount += 1
isShowingDetail = true
} label: {
Label("Open detail screen (\(detailOpenCount))", systemImage: "chevron.forward.circle.fill")
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.tint(.indigo)
.accessibilityIdentifier("open-detail-button")
.accessibilityValue("Opened \(detailOpenCount) times")
}
.padding()
}
.accessibilityIdentifier("controls-scroll-view")
.navigationTitle("Tap Lab")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button {
toolbarRefreshCount += 1
record("Navigation refresh tapped")
} label: {
Label("Refresh", systemImage: "arrow.clockwise")
}
.accessibilityIdentifier("nav-refresh-button")
.accessibilityValue("\(toolbarRefreshCount) refreshes")
}
ToolbarItem(placement: .navigationBarTrailing) {
Menu {
Button {
menuAddCount += 1
record("Add menu item selected")
} label: {
Label("Add item (\(menuAddCount))", systemImage: "plus")
}
.accessibilityIdentifier("menu-add-item")
Button {
menuArchiveCount += 1
record("Archive menu item selected")
} label: {
Label("Archive item (\(menuArchiveCount))", systemImage: "archivebox")
}
.accessibilityIdentifier("menu-archive-item")
} label: {
Image(systemName: "ellipsis.circle")
.accessibilityLabel("Harness actions menu")
}
.accessibilityIdentifier("toolbar-actions-menu")
.accessibilityValue("Add \(menuAddCount), archive \(menuArchiveCount)")
}
}
.navigationDestination(isPresented: $isShowingDetail) {
VStack(spacing: 18) {
verificationPanel
Image(systemName: "rectangle.stack.badge.play.fill")
.font(.system(size: 46))
.foregroundColor(.indigo)
.accessibilityIdentifier("detail-screen-artwork")
Text("Navigation destination")
.font(.title2.bold())
.accessibilityIdentifier("detail-screen-title")
Button {
detailButtonCount += 1
record("Detail button tapped")
} label: {
Label("Detail tap (\(detailButtonCount))", systemImage: "hand.tap")
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.accessibilityIdentifier("detail-action-button")
.accessibilityValue("\(detailButtonCount) taps")
Text("Detail count: \(detailButtonCount)")
.font(.headline.monospacedDigit())
.accessibilityIdentifier("detail-action-count")
}
.padding()
.navigationTitle("Detail")
.navigationBarTitleDisplayMode(.inline)
.navigationBarBackButtonHidden(true)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button {
isShowingDetail = false
} label: {
Label("Back to Tap Lab", systemImage: "chevron.backward")
}
.accessibilityIdentifier("detail-back-button")
}
}
}
.onChange(of: isShowingDetail) { isShowing in
if isShowing {
record("Opened detail screen")
} else {
detailCloseCount += 1
record("Closed detail screen")
}
}
}
.accessibilityIdentifier("controls-navigation-stack")
}
private var inputsTab: some View {
NavigationStack {
ScrollView {
VStack(spacing: 14) {
verificationPanel
GroupBox {
VStack(spacing: 14) {
Toggle(isOn: Binding(
get: { toggleValue },
set: { newValue in
toggleValue = newValue
toggleChangeCount += 1
record("Toggle changed to \(newValue ? "on" : "off")")
}
)) {
Label("Harness toggle", systemImage: toggleValue ? "checkmark.circle.fill" : "circle")
}
.accessibilityIdentifier("harness-toggle")
.accessibilityValue(toggleValue ? "On" : "Off")
verificationRow(
"Toggle changes",
value: toggleChangeCount,
identifier: "toggle-change-count"
)
Divider()
Stepper(
value: Binding(
get: { stepperValue },
set: { newValue in
let direction = newValue > stepperValue ? "up" : "down"
stepperValue = newValue
stepperChangeCount += 1
record("Stepper moved \(direction) to \(newValue)")
}
),
in: 0...9
) {
Label("Stepper value: \(stepperValue)", systemImage: "plusminus.circle")
.monospacedDigit()
}
.accessibilityIdentifier("harness-stepper")
.accessibilityValue("Value \(stepperValue), changed \(stepperChangeCount) times")
verificationRow(
"Stepper changes",
value: stepperChangeCount,
identifier: "stepper-change-count"
)
verificationRow(
"Stepper value",
value: stepperValue,
identifier: "stepper-value"
)
}
} label: {
Label("Toggle and stepper", systemImage: "switch.2")
.font(.headline)
}
.accessibilityIdentifier("toggle-stepper-group")
GroupBox {
VStack(alignment: .leading, spacing: 12) {
Picker("Harness mode", selection: Binding(
get: { selectedMode },
set: { newValue in
selectedMode = newValue
pickerChangeCount += 1
record("Segment selected: \(newValue)")
}
)) {
Text("One").tag("One")
Text("Two").tag("Two")
Text("Three").tag("Three")
}
.pickerStyle(.segmented)
.accessibilityIdentifier("harness-segmented-picker")
.accessibilityValue("Selected \(selectedMode)")
verificationRow(
"Selected segment",
textValue: selectedMode,
identifier: "picker-selection-value"
)
verificationRow(
"Segment changes",
value: pickerChangeCount,
identifier: "picker-change-count"
)
}
} label: {
Label("Segmented picker", systemImage: "rectangle.split.3x1")
.font(.headline)
}
.accessibilityIdentifier("picker-group")
GroupBox {
VStack(alignment: .leading, spacing: 10) {
TextField("Type a device message", text: $draftText)
.focused($isTextFieldFocused)
.textFieldStyle(.roundedBorder)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.submitLabel(.done)
.accessibilityIdentifier("harness-text-field")
.accessibilityValue(draftText)
.onSubmit {
commitText(source: "keyboard submit")
}
.onChange(of: draftText) { newValue in
textChangeCount += 1
record("Text changed to \(newValue.isEmpty ? "empty" : newValue)")
}
HStack {
Text("Echo: \(draftText.isEmpty ? "<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
// marked with the @Snapshotable property wrapper that the codegen tool
// detects via attribute scan.
//
// Note: we DON'T use @Observable here because the macro expansion converts
// stored properties into computed ones, which the @Snapshotable wrapper
// can't apply to. In production apps that need both observability AND
// snapshotting, the right pattern is:
// - Use ObservableObject + @Published (older API), or
// - Hold all @Snapshotable state in a nested struct + replace it
// wholesale on restore so SwiftUI sees a single change notification
// (the canonical-state-struct atomicity strategy from the plan).
// Canonical observable app state for the fixture. Snapshot eligibility is a
// generator-only source marker, not a property wrapper, so it composes with
// Observation's @Observable macro.
import Foundation
import Observation
public final class FixtureAppState {
@Snapshotable public var isLoggedIn: Bool = false
@Snapshotable public var username: String = ""
@Snapshotable public var tapCounter: Int = 0
@Observable
final class FixtureAppState {
// @Snapshotable
var isLoggedIn: Bool = false
// @Snapshotable
var username: String = ""
// @Snapshotable
var tapCounter: Int = 0
// @Snapshotable
var nickname: String? = nil
/// Not snapshotted ephemeral cache that should never leak via /state/snapshot.
public var ephemeralCache: [String: String] = [:]
var ephemeralCache: [String: String] = [:]
public init() {}
}
/// Property wrapper marker for snapshot-eligible state. The actual wrapper
/// is a no-op at runtime; codegen-tool detection happens via attribute scan.
@propertyWrapper
public struct Snapshotable<Value> {
public var wrappedValue: Value
public init(wrappedValue: Value) { self.wrappedValue = wrappedValue }
init() {}
}
+3 -3
View File
@@ -1,7 +1,7 @@
name: FixtureApp
options:
deploymentTarget:
iOS: "16.0"
iOS: "17.0"
bundleIdPrefix: com.gstack.iosqa
developmentLanguage: en
createIntermediateGroups: true
@@ -23,7 +23,7 @@ targets:
FixtureApp:
type: application
platform: iOS
deploymentTarget: "16.0"
deploymentTarget: "17.0"
sources:
- path: Sources/FixtureApp
dependencies:
@@ -45,5 +45,5 @@ targets:
CODE_SIGN_STYLE: Automatic
TARGETED_DEVICE_FAMILY: "1"
SWIFT_VERSION: "5.9"
IPHONEOS_DEPLOYMENT_TARGET: "16.0"
IPHONEOS_DEPLOYMENT_TARGET: "17.0"
ENABLE_PREVIEWS: YES
+80
View File
@@ -67,6 +67,30 @@ describe("canonicalizeRemote", () => {
it("collapses redundant slashes", () => {
expect(canonicalizeRemote("https://github.com//foo//bar")).toBe("github.com/foo/bar");
});
it("strips .git even when the URL has a trailing slash", () => {
// A remote configured with both a .git suffix and a trailing slash must
// canonicalize to the same key as one without — otherwise the same repo
// gets two dedup/source-id keys across machines.
expect(canonicalizeRemote("https://github.com/garrytan/gstack.git/")).toBe("github.com/garrytan/gstack");
expect(canonicalizeRemote("git@github.com:garrytan/gstack.git/")).toBe("github.com/garrytan/gstack");
expect(canonicalizeRemote("https://github.com/foo/bar.git///")).toBe("github.com/foo/bar");
});
it("produces the same key with or without a trailing slash", () => {
expect(canonicalizeRemote("https://github.com/garrytan/gstack.git/")).toBe(
canonicalizeRemote("https://github.com/garrytan/gstack.git")
);
});
it("canonicalizes a path remote ending in a .git directory component", () => {
// Stripping the `.git` suffix exposes a new trailing slash
// ("/repo/.git" → "/repo/") which must also be stripped, or the same
// repo splits into two identities.
expect(canonicalizeRemote("file:///Users/x/repo/.git")).toBe(
canonicalizeRemote("file:///Users/x/repo")
);
});
});
// ── secretScanFile ─────────────────────────────────────────────────────────
@@ -244,6 +268,62 @@ body
expect(m!.context_queries[0].id).toBe("complete");
rmSync(dir, { recursive: true, force: true });
});
it("parses a nested filter: block on a list query", () => {
const dir = mkdtempSync(join(tmpdir(), "gstack-test-"));
const file = join(dir, "filtered.md");
writeFileSync(
file,
`---
name: investigate
gbrain:
schema: 1
context_queries:
- id: prior-investigations
kind: list
filter:
type: timeline
tags_contains: "repo:{repo_slug}"
content_contains: "investigate"
sort: updated_at_desc
limit: 5
render_as: "## Prior investigations in this repo"
- id: recent-no-filter
kind: list
sort: created_at_desc
limit: 3
render_as: "## Recent (no filter)"
---
body
`
);
const m = parseSkillManifest(file);
expect(m).not.toBeNull();
expect(m!.context_queries).toHaveLength(2);
// The filter: sub-block is parsed into a key/value map, with quotes
// stripped and template vars left intact for downstream substitution.
const filtered = m!.context_queries[0];
expect(filtered.id).toBe("prior-investigations");
expect(filtered.filter).toEqual({
type: "timeline",
tags_contains: "repo:{repo_slug}",
content_contains: "investigate",
});
// Sibling fields on the same item still parse alongside the filter.
expect(filtered.sort).toBe("updated_at_desc");
expect(filtered.limit).toBe(5);
expect(filtered.render_as).toBe("## Prior investigations in this repo");
// A list query with no filter: leaves filter undefined (no regression).
expect(m!.context_queries[1].id).toBe("recent-no-filter");
expect(m!.context_queries[1].filter).toBeUndefined();
expect(m!.context_queries[1].sort).toBe("created_at_desc");
rmSync(dir, { recursive: true, force: true });
});
});
// ── withErrorContext ───────────────────────────────────────────────────────
+16 -16
View File
@@ -173,13 +173,9 @@ export async function runSkillTest(options: {
// restores operator MCP along with the operator env.
if (isHermeticEnabled()) args.push('--strict-mcp-config');
// Write prompt to a temp file OUTSIDE workingDirectory to avoid race conditions
// where afterAll cleanup deletes the dir before cat reads the file (especially
// with --concurrent --retry). Using os.tmpdir() + unique suffix keeps it stable.
const promptFile = path.join(os.tmpdir(), `.prompt-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}`);
fs.writeFileSync(promptFile, prompt);
const proc = Bun.spawn(['sh', '-c', `cat "${promptFile}" | claude ${args.map(a => `"${a}"`).join(' ')}`], {
// Spawn claude directly with array-form args (no shell interpolation).
// Prompt is piped via stdin using a Blob to avoid temp files and shell escaping.
const proc = Bun.spawn(['claude', ...args], {
cwd: workingDirectory,
// Hermetic by default (see test/helpers/hermetic-env.ts): operator
// session context (CONDUCTOR_*, CLAUDECODE, ~/.claude config, ~/.gstack)
@@ -189,6 +185,7 @@ export async function runSkillTest(options: {
// suite exercising the INTERACTIVE prose-fallback path opts out by passing
// `env: { GSTACK_HEADLESS: '' }` — extraEnv wins because it spreads last.
env: hermeticChildEnv({ GSTACK_HEADLESS: '1', ...extraEnv }),
stdin: new Blob([prompt]),
stdout: 'pipe',
stderr: 'pipe',
});
@@ -201,11 +198,12 @@ export async function runSkillTest(options: {
const timeoutId = setTimeout(() => {
timedOut = true;
proc.kill();
// proc.kill() only signals the `sh -c` wrapper. The claude child it
// spawned can survive as an orphan that inherited our stdout/stderr
// pipes, so without cancel() the read loop below blocks until the
// orphan finally exits (observed: a 600s timeout stretching past 1400s
// and tripping bun's per-test timeout instead of returning a result).
// proc.kill() signals claude itself (direct spawn, no shell wrapper),
// but tool subprocesses claude spawned can survive as orphans that
// inherited our stdout/stderr pipes, so without cancel() the read loop
// below blocks until the orphan finally exits (observed: a 600s timeout
// stretching past 1400s and tripping bun's per-test timeout instead of
// returning a result).
reader.cancel().catch(() => { /* stream already closed */ });
}, timeout);
@@ -233,6 +231,11 @@ export async function runSkillTest(options: {
if (!line.trim()) continue;
collectedLines.push(line);
// Track time to first NDJSON line (measures latency from spawn to first Claude response)
if (firstResponseMs === 0) {
firstResponseMs = Date.now() - startTime;
}
// Real-time progress to stderr + persistent logs
try {
const event = JSON.parse(line);
@@ -244,8 +247,7 @@ export async function runSkillTest(options: {
liveToolCount++;
const now = Date.now();
const elapsed = Math.round((now - startTime) / 1000);
// Track timing telemetry
if (firstResponseMs === 0) firstResponseMs = now - startTime;
// Track inter-turn latency (tool call to tool call)
if (lastToolTime > 0) {
const interTurn = now - lastToolTime;
if (interTurn > maxInterTurnMs) maxInterTurnMs = interTurn;
@@ -310,8 +312,6 @@ export async function runSkillTest(options: {
const exitCode = await proc.exited;
clearTimeout(timeoutId);
try { fs.unlinkSync(promptFile); } catch { /* non-fatal */ }
if (timedOut) {
exitReason = 'timeout';
} else if (exitCode === 0) {
+15
View File
@@ -96,6 +96,21 @@ describe('check-careful.sh', () => {
expect(output.permissionDecision).toBe('ask');
expect(output.message).toContain('recursive delete');
});
test.each([
'rm -rf /; rm -rf node_modules',
'rm -rf / && rm -rf node_modules',
'rm -rf / # rm -rf node_modules',
'rm -rf node_modules; rm -rf /',
'rm -rf node_modules || rm -rf /',
'echo ok && rm -rf /',
'rm -rf node_modules\nrm -rf /',
])('never lets a safe-looking target hide a destructive command: %s', (command) => {
const { exitCode, output } = runHook(CAREFUL_SCRIPT, carefulInput(command));
expect(exitCode).toBe(0);
expect(output.permissionDecision).toBe('ask');
expect(output.message).toContain('recursive delete');
});
});
// --- SQL destructive commands ---
+28 -1
View File
@@ -24,8 +24,10 @@ import {
openclaw,
} from '../hosts/index';
import { HOST_PATHS } from '../scripts/resolvers/types';
import { RESOLVERS } from '../scripts/resolvers';
const ROOT = path.resolve(import.meta.dir, '..');
const RESOLVER_NAMES = new Set(Object.keys(RESOLVERS));
// ─── hosts/index.ts ─────────────────────────────────────────
@@ -205,13 +207,32 @@ describe('validateHostConfig', () => {
c.cliCommand = 'opencode;rm -rf /';
expect(validateHostConfig(c).some(e => e.includes('cliCommand'))).toBe(true);
});
test('valid suppressedResolvers pass when resolver names provided', () => {
const c = makeValid();
c.suppressedResolvers = ['DESIGN_OUTSIDE_VOICES', 'REVIEW_ARMY'];
expect(validateHostConfig(c, RESOLVER_NAMES)).toEqual([]);
});
test('unknown suppressedResolvers entry is caught', () => {
const c = makeValid();
c.suppressedResolvers = ['DESIGN_OUTSIDE_VOICES', 'NONEXISTENT_RESOLVER'];
const errors = validateHostConfig(c, RESOLVER_NAMES);
expect(errors.some(e => e.includes('NONEXISTENT_RESOLVER'))).toBe(true);
});
test('suppressedResolvers unchecked when resolver names omitted', () => {
const c = makeValid();
c.suppressedResolvers = ['TYPO_RESOLVER'];
expect(validateHostConfig(c)).toEqual([]);
});
});
// ─── validateAllConfigs ─────────────────────────────────────
describe('validateAllConfigs', () => {
test('real configs all pass validation', () => {
const errors = validateAllConfigs(ALL_HOST_CONFIGS);
const errors = validateAllConfigs(ALL_HOST_CONFIGS, RESOLVER_NAMES);
expect(errors).toEqual([]);
});
@@ -233,6 +254,12 @@ describe('validateAllConfigs', () => {
expect(errors.some(e => e.includes('Duplicate globalRoot'))).toBe(true);
});
test('unknown suppressedResolvers entry surfaces with host-name prefix', () => {
const bad = { ...codex, name: 'bad-host', hostSubdir: '.bad', globalRoot: '.bad/skills/gstack', suppressedResolvers: ['BOGUS_RESOLVER'] } as HostConfig;
const errors = validateAllConfigs([bad], RESOLVER_NAMES);
expect(errors.some(e => e.startsWith('[bad-host]') && e.includes('BOGUS_RESOLVER'))).toBe(true);
});
test('per-config validation errors are prefixed with host name', () => {
const bad = { ...codex, name: 'BAD', cliCommand: 'also bad' } as HostConfig;
const errors = validateAllConfigs([bad]);
+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);
}
});
// 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
// - The iPhone is paired (user has tapped "Trust" on the trust dialog)
// - Developer Mode is enabled on the iPhone (Settings → Privacy → Developer Mode)
@@ -10,57 +14,91 @@
// 4. The fixture iOS SPM package builds with `swift build` for iOS target
// (verifies the templates compile against the iOS SDK, not just macOS)
//
// What it does NOT exercise (out of scope for this test):
// - Building + signing a full iOS app via xcodebuild (requires provisioning
// profile + dev team — environment-specific, not portable across CI)
// - Actually deploying + launching the StateServer on the device (same)
//
// The first three steps prove the CoreDevice path is wired end-to-end on the
// agent's side. The fourth proves the Swift templates compile against the
// iOS SDK, not just macOS — which catches UIKit/SwiftUI gating bugs before
// they reach a real app deployment.
// GSTACK_IOS_DEVICE_DEPLOY=1 additionally generates the fixture Xcode project,
// signs it with GSTACK_IOS_DEVELOPMENT_TEAM + GSTACK_IOS_BUNDLE_ID, installs
// and launches it, then proves screenshot/elements/tap through the real daemon.
// It remains skipped in normal CI because signing and a paired iPhone are
// intentionally machine-specific.
import { describe, test, expect } from 'bun:test';
import { spawnSync } from 'child_process';
import { cpSync, existsSync, mkdtempSync, readFileSync, rmSync, unlinkSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import { startDaemon, type RunningDaemon } from '../ios-qa/daemon/src/index';
import { startTunnelKeepalive } from '../ios-qa/daemon/src/devicectl';
import { bootstrapTunnel } from '../ios-qa/daemon/src/tunnel-bootstrap';
import type { DeviceTunnel } from '../ios-qa/daemon/src/proxy';
const ROOT = join(import.meta.dir, '..');
const FIXTURE_PATH = join(ROOT, 'test/fixtures/ios-qa/FixtureApp');
const HAS_DEVICE = process.env.GSTACK_HAS_IOS_DEVICE === '1';
const DEPLOY_TO_DEVICE = process.env.GSTACK_IOS_DEVICE_DEPLOY === '1';
const describeIfDevice = HAS_DEVICE ? describe : describe.skip;
const testIfDeploy = DEPLOY_TO_DEVICE ? test : test.skip;
interface DeviceListEntry {
identifier: string;
state: string; // "available" | "available (pairing)" | "unavailable" | ...
name: string;
model: string;
platform: string;
transport: string;
paired: boolean;
}
interface DeviceElement {
identifier?: string;
label?: string;
value?: string;
frame?: { x: number; y: number; w: number; h: number };
}
interface StateSnapshot {
_app_build_id?: string;
_accessor_hash?: string;
keys?: Record<string, unknown>;
}
function listDevices(): DeviceListEntry[] {
// devicectl JSON output requires --json-output to a path. Use a tempfile.
const tmp = `/tmp/devicectl-list-${process.pid}-${Date.now()}.json`;
const r = spawnSync('xcrun', ['devicectl', 'list', 'devices', '--json-output', tmp], {
stdio: 'pipe',
timeout: 30_000,
});
if (r.status !== 0) return [];
try {
const fs = require('fs');
const raw = fs.readFileSync(tmp, 'utf-8');
const r = spawnSync('xcrun', ['devicectl', 'list', 'devices', '--json-output', tmp], {
stdio: 'pipe',
timeout: 30_000,
});
if (r.status !== 0) return [];
const raw = readFileSync(tmp, 'utf-8');
const obj = JSON.parse(raw);
fs.unlinkSync(tmp);
return (obj.result?.devices ?? []).map((d: { identifier: string; connectionProperties: { tunnelState: string }; deviceProperties: { name: string }; hardwareProperties: { productType: string } }) => ({
return (obj.result?.devices ?? []).map((d: { identifier: string; connectionProperties: { tunnelState: string; pairingState?: string; transportType?: string }; deviceProperties: { name: string }; hardwareProperties: { productType: string; platform?: string } }) => ({
identifier: d.identifier,
state: d.connectionProperties?.tunnelState ?? 'unknown',
name: d.deviceProperties?.name ?? 'unknown',
model: d.hardwareProperties?.productType ?? 'unknown',
platform: d.hardwareProperties?.platform ?? 'unknown',
transport: d.connectionProperties?.transportType ?? '',
paired: d.connectionProperties?.pairingState === 'paired',
}));
} catch {
return [];
} finally {
try { unlinkSync(tmp); } catch { /* ignore */ }
}
}
function isAvailableIPhone(device: DeviceListEntry): boolean {
const state = device.state.trim().toLowerCase();
const available = state === 'connected'
|| state.startsWith('available')
|| (state === 'disconnected' && device.transport.trim().toLowerCase() === 'wired');
return available
&& device.paired
&& device.platform.toLowerCase() === 'ios'
&& device.model.toLowerCase().startsWith('iphone');
}
function isPaired(udid: string): boolean {
// devicectl device info processes returns a clean exit when paired.
const tmp = `/tmp/devicectl-info-${process.pid}-${Date.now()}.json`;
@@ -69,12 +107,146 @@ function isPaired(udid: string): boolean {
'-d', udid,
'--json-output', tmp,
], { stdio: 'pipe', timeout: 30_000 });
try { require('fs').unlinkSync(tmp); } catch { /* ignore */ }
try { unlinkSync(tmp); } catch { /* ignore */ }
// Pair-required errors surface on stderr with "must be paired" or
// CoreDeviceError 2. Treat any non-zero exit as not-paired.
return r.status === 0;
}
function requireDeployEnv(name: 'GSTACK_IOS_DEVELOPMENT_TEAM' | 'GSTACK_IOS_BUNDLE_ID'): string {
const value = process.env[name]?.trim();
if (!value) {
throw new Error(`${name} is required when GSTACK_IOS_DEVICE_DEPLOY=1`);
}
return value;
}
function runChecked(
command: string,
args: string[],
opts: { cwd?: string; timeout?: number } = {},
): string {
const result = spawnSync(command, args, {
cwd: opts.cwd,
env: process.env,
stdio: 'pipe',
timeout: opts.timeout ?? 60_000,
maxBuffer: 32 * 1024 * 1024,
});
const output = `${result.stdout?.toString() ?? ''}${result.stderr?.toString() ?? ''}`;
if (result.error || result.status !== 0) {
const tail = output.split('\n').slice(-120).join('\n');
throw new Error([
`${command} ${args.join(' ')} failed (${result.error?.message ?? `exit ${result.status}`})`,
tail,
].filter(Boolean).join('\n'));
}
return output;
}
async function daemonJson<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', () => {
test('devicectl lists at least one connected device', () => {
const devices = listDevices();
@@ -104,11 +276,9 @@ describeIfDevice('ios device path', () => {
expect(paired.length).toBeGreaterThan(0);
});
test('fixture Swift package compiles for iOS target', () => {
// Use xcrun --sdk iphoneos to get the iOS SDK path, then pass it through
// to swift build via SDKROOT. This validates that the Swift templates
// (StateServer, DebugBridgeManager, DebugOverlay) compile against the
// iOS SDK — catches UIKit/SwiftUI gating bugs that macOS-only builds miss.
test('fixture iOS SDK and UIKit compile guards are available', () => {
// This is an environment + source-guard preflight. The explicit deployment
// test below performs the real signed iOS xcodebuild before installation.
const sdkPath = spawnSync('xcrun', ['--sdk', 'iphoneos', '--show-sdk-path'], { stdio: 'pipe' });
if (sdkPath.status !== 0) {
console.error('iOS SDK not found. Install via Xcode.');
@@ -117,16 +287,8 @@ describeIfDevice('ios device path', () => {
const sdk = sdkPath.stdout.toString().trim();
expect(sdk).toContain('iPhoneOS');
// Build the DebugBridgeUI target specifically for iOS. We can't use
// `swift build --triple arm64-apple-ios` directly because SwiftPM
// doesn't ship an iOS toolchain out of the box. The xcodebuild path
// requires a project — skip if no .xcodeproj exists.
// Instead, verify the iOS-only code compiles by parsing the canImport
// guards: if the template's `#if canImport(UIKit)` is wrong, the macOS
// build would have failed in the swift-build invariant test. The iOS
// SDK path being present is sufficient signal that the toolchain is
// installed; the deeper iOS-target build belongs to xcodebuild + a real
// app target, which is the "deploy to device" path documented below.
// SwiftPM cannot directly cross-build this UIKit package with the standalone
// host command, so keep the static guard assertion honest and narrowly named.
const fs = require('fs') as typeof import('fs');
const overlay = fs.readFileSync(
join(FIXTURE_PATH, 'Sources/DebugBridgeUI/DebugOverlay.swift'),
@@ -137,26 +299,575 @@ describeIfDevice('ios device path', () => {
expect(overlay).toContain('#endif');
});
// Documented next step. Becomes a real test once we have:
// - test/fixtures/ios-qa/FixtureApp/FixtureApp.xcodeproj (or generated)
// - A signing certificate + provisioning profile on the test machine
// - GSTACK_IOS_DEVICE_DEPLOY=1 environment opt-in
//
// The flow would be:
// xcodebuild -scheme FixtureApp -destination 'platform=iOS,id=<UDID>' \
// -allowProvisioningUpdates build install
// xcrun devicectl device process launch -d <UDID> --console <bundle-id>
// # Scrape boot token from os_log
// curl http://[<corodevice-ipv6>]:9999/healthz
// # ... full smoke loop ...
test.skip('TODO(deploy): build + deploy fixture to device + smoke test full StateServer loop', () => {});
});
describe('ios device deployment (explicit opt-in)', () => {
testIfDeploy('generates, signs, installs, launches, and drives the fixture through the daemon', async () => {
const developmentTeam = requireDeployEnv('GSTACK_IOS_DEVELOPMENT_TEAM');
const bundleId = requireDeployEnv('GSTACK_IOS_BUNDLE_ID');
const devices = listDevices();
const device = devices.find((candidate) => isAvailableIPhone(candidate) && isPaired(candidate.identifier));
if (!device) {
const summary = devices.length > 0
? devices.map((d) => ` ${d.name} (${d.model}, ${d.platform}, ${d.identifier}): state=${d.state}, paired=${d.paired}`).join('\n')
: ' devicectl returned no devices';
throw new Error([
'GSTACK_IOS_DEVICE_DEPLOY=1 requires an available, paired iPhone; stale unavailable devices are never selected.',
summary,
].join('\n'));
}
const workDir = mkdtempSync(join(tmpdir(), 'gstack-ios-device-deploy-'));
const fixtureDir = join(workDir, 'FixtureApp');
const derivedData = join(workDir, 'DerivedData');
let daemon: RunningDaemon | undefined;
let keepalive: { stop: () => void } | undefined;
let sessionId: string | undefined;
try {
cpSync(FIXTURE_PATH, fixtureDir, { recursive: true });
// Exercise the same deterministic bootstrap that /ios-qa and /ios-sync
// install for users. This creates the app-owned typed accessor before
// XcodeGen discovers the fixture sources.
runChecked(join(ROOT, 'bin/gstack-ios-qa-regen'), [
'--app-source', join(fixtureDir, 'Sources/FixtureApp'),
'--bridge-dir', fixtureDir,
], { cwd: fixtureDir });
const generatedAccessor = join(
fixtureDir,
'Sources/FixtureApp/DebugBridgeGenerated/StateAccessor.swift',
);
expect(existsSync(generatedAccessor)).toBe(true);
expect(readFileSync(generatedAccessor, 'utf8')).toContain('enum FixtureAppStateAccessor');
runChecked('xcodegen', [
'generate',
'--spec', join(fixtureDir, 'project.yml'),
'--project', fixtureDir,
'--project-root', fixtureDir,
], { cwd: fixtureDir });
const projectPath = join(fixtureDir, 'FixtureApp.xcodeproj');
expect(existsSync(projectPath)).toBe(true);
runChecked('xcodebuild', [
'-project', projectPath,
'-scheme', 'FixtureApp',
'-configuration', 'Debug',
'-destination', `platform=iOS,id=${device.identifier}`,
'-derivedDataPath', derivedData,
'-allowProvisioningUpdates',
`DEVELOPMENT_TEAM=${developmentTeam}`,
`PRODUCT_BUNDLE_IDENTIFIER=${bundleId}`,
'CODE_SIGN_STYLE=Automatic',
'build',
], { cwd: fixtureDir, timeout: 300_000 });
const appBundle = join(derivedData, 'Build/Products/Debug-iphoneos/FixtureApp.app');
expect(existsSync(appBundle)).toBe(true);
const builtBundleId = runChecked('/usr/libexec/PlistBuddy', [
'-c', 'Print :CFBundleIdentifier',
join(appBundle, 'Info.plist'),
]).trim();
expect(builtBundleId).toBe(bundleId);
runChecked('xcrun', [
'devicectl', 'device', 'install', 'app',
'--device', device.identifier,
appBundle,
], { timeout: 120_000 });
runChecked('xcrun', [
'devicectl', 'device', 'process', 'launch',
'--device', device.identifier,
'--terminate-existing',
bundleId,
], { timeout: 60_000 });
keepalive = startTunnelKeepalive(device.identifier);
const bootstrap = await bootstrapTunnel({
udid: device.identifier,
bundleId,
startupTimeoutMs: 30_000,
});
if (!bootstrap.ok) {
throw new Error(`daemon tunnel bootstrap failed: ${bootstrap.error}${bootstrap.detail ? ` (${bootstrap.detail})` : ''}`);
}
// The first provider call consumes the already-rotated bootstrap. Later
// calls perform a fresh bootstrap so the same daemon can recover after
// this app is relaunched and its in-memory bearer changes.
let pendingTunnel: DeviceTunnel | undefined = bootstrap.tunnel;
let tunnelProviderCalls = 0;
const provideTunnel = async (): Promise<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
// the test is opted in via env var but the device isn't ready.
if (HAS_DEVICE) {
const devices = listDevices();
const unpaired = devices.filter(d => !isPaired(d.identifier));
const unpaired = devices.filter(d =>
d.platform.toLowerCase() === 'ios'
&& d.model.toLowerCase().startsWith('iphone')
&& !d.paired,
);
if (unpaired.length > 0) {
console.error('');
console.error('=== iOS DEVICE PAIRING REQUIRED ===');
+257 -21
View File
@@ -19,38 +19,90 @@
import { describe, test, expect } from 'bun:test';
import { spawnSync } from 'child_process';
import { existsSync, readFileSync } from 'fs';
import { readFileSync } from 'fs';
import { join } from 'path';
const ROOT = join(import.meta.dir, '..');
const FIXTURE_PATH = join(ROOT, 'test/fixtures/ios-qa/FixtureApp');
const TEMPLATES_PATH = join(ROOT, 'ios-qa/templates');
const GEN_ACCESSORS_PACKAGE = join(ROOT, 'ios-qa/scripts/gen-accessors-tool/Package.swift');
// Parity: canonical Obj-C touch templates must match the fixture's working
// copy. The fixture is the only place the .m / .h are exercised end-to-end
// on a real device, so any divergence means consuming apps would ship a
// stale, untested version of the SwiftUI hit-test fix.
const COPIED_BRIDGE_TEMPLATES = [
['StateServer.swift.template', 'Sources/DebugBridgeCore/StateServer.swift'],
['DebugBridgeManager.swift.template', 'Sources/DebugBridgeCore/DebugBridgeManager.swift'],
['DebugOverlay.swift.template', 'Sources/DebugBridgeUI/DebugOverlay.swift'],
['Bridges.swift.template', 'Sources/DebugBridgeUI/Bridges.swift'],
['DebugBridgeTouch.h.template', 'Sources/DebugBridgeTouch/include/DebugBridgeTouch.h'],
['DebugBridgeTouch.m.template', 'Sources/DebugBridgeTouch/DebugBridgeTouch.m'],
] as const;
function readTemplate(name: string): string {
return readFileSync(join(TEMPLATES_PATH, name), 'utf-8');
}
function normalizeBridgePackage(source: string): string {
// Package.swift.template has a generated-file prologue while the fixture has
// a fixture-specific one. The tools-version declaration must remain first in
// both real files, but neither header is part of the copied bridge surface.
const importOffset = source.indexOf('import PackageDescription');
expect(importOffset).toBeGreaterThanOrEqual(0);
let packageBody = source.slice(importOffset);
// The fixture deliberately has its own package identity and XCTest target.
// Normalize only those fixture concerns; all three bridge products, targets,
// dependencies, settings, and paths must otherwise stay in lockstep.
packageBody = packageBody.replace(
/(let package = Package\(\s*name:)\s*"[^"]+"/,
'$1 "<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', () => {
test('DebugBridgeTouch.h.template matches fixture include', () => {
const tmpl = readFileSync(join(TEMPLATES_PATH, 'DebugBridgeTouch.h.template'), 'utf-8');
const fixture = readFileSync(
join(FIXTURE_PATH, 'Sources/DebugBridgeTouch/include/DebugBridgeTouch.h'),
'utf-8',
);
expect(tmpl).toBe(fixture);
});
for (const [templateName, fixtureDestination] of COPIED_BRIDGE_TEMPLATES) {
test(`${templateName} matches ${fixtureDestination}`, () => {
expect(readTemplate(templateName)).toBe(
readFileSync(join(FIXTURE_PATH, fixtureDestination), 'utf-8'),
);
});
}
test('DebugBridgeTouch.m.template matches fixture .m', () => {
const tmpl = readFileSync(join(TEMPLATES_PATH, 'DebugBridgeTouch.m.template'), 'utf-8');
const fixture = readFileSync(
join(FIXTURE_PATH, 'Sources/DebugBridgeTouch/DebugBridgeTouch.m'),
'utf-8',
);
expect(tmpl).toBe(fixture);
test('Package.swift bridge declarations match after fixture-only normalization', () => {
const template = readTemplate('Package.swift.template');
const fixture = readFileSync(join(FIXTURE_PATH, 'Package.swift'), 'utf-8');
expect(normalizeBridgePackage(template)).toBe(normalizeBridgePackage(fixture));
});
test('Package.swift.template declares all 3 DebugBridge targets', () => {
const tmpl = readFileSync(join(TEMPLATES_PATH, 'Package.swift.template'), 'utf-8');
const tmpl = readTemplate('Package.swift.template');
// Each target must be present as a library product AND a target definition.
for (const name of ['DebugBridgeCore', 'DebugBridgeUI', 'DebugBridgeTouch']) {
expect(tmpl).toContain(`name: "${name}"`);
@@ -59,6 +111,190 @@ describe('template ↔ fixture parity', () => {
// app gets the transitive set with one dependency entry.
expect(tmpl).toMatch(/name:\s*"DebugBridgeUI"[\s\S]*?dependencies:\s*\["DebugBridgeCore",\s*"DebugBridgeTouch"\]/);
});
test('generated Swift packages only reference shipped test directories', () => {
const genAccessorsPackage = readFileSync(GEN_ACCESSORS_PACKAGE, 'utf-8');
const debugBridgePackage = readFileSync(join(TEMPLATES_PATH, 'Package.swift.template'), 'utf-8');
expect(genAccessorsPackage).not.toContain('Tests/GenAccessorsTests');
expect(debugBridgePackage).not.toContain('Tests/DebugBridgeCoreTests');
});
test('Package.swift.template keeps swift-tools-version on the first line', () => {
const tmpl = readTemplate('Package.swift.template');
expect(tmpl.split(/\r?\n/, 1)[0]).toBe('// swift-tools-version:5.9');
});
});
describe('iOS tap harness regressions', () => {
test('manager receives app-owned generated accessors instead of a no-op package stub', () => {
const manager = readTemplate('DebugBridgeManager.swift.template');
const wiring = readTemplate('DebugBridgeWiring.swift.template');
const fixtureApp = readFileSync(
join(FIXTURE_PATH, 'Sources/FixtureApp/FixtureAppApp.swift'),
'utf-8',
);
expect(manager).toContain('func start<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 {
+8 -4
View File
@@ -171,9 +171,12 @@ describe('ios-qa E2E (no-device path)', () => {
writeFileSync(join(srcDir, 'AppState.swift'), `
@Observable
class AppState {
@Snapshotable var isLoggedIn: Bool = false
@Snapshotable var username: String = ""
@Snapshotable var counter: Int = 0
// @Snapshotable
var isLoggedIn: Bool = false
// @Snapshotable
var username: String = ""
// @Snapshotable
var counter: Int = 0
var ephemeralCache: [String: Any] = [:]
}
`);
@@ -189,7 +192,8 @@ class AppState {
expect(result.specs).toHaveLength(1);
expect(result.specs[0]!.fields.map(f => f.name).sort()).toEqual(['counter', 'isLoggedIn', 'username']);
const generatedSwift = readFileSync(result.outputPath, 'utf-8');
expect(generatedSwift).toContain('public enum AppStateAccessor');
expect(generatedSwift).toContain('enum AppStateAccessor');
expect(generatedSwift).not.toContain('public enum AppStateAccessor');
expect(generatedSwift).toContain('key: "isLoggedIn"');
expect(generatedSwift).toContain('key: "counter"');
expect(generatedSwift).not.toContain('key: "ephemeralCache"'); // not marked @Snapshotable