revert(ios-qa): remove oversized wave expansion

This commit is contained in:
t 2026-07-14 18:52:31 -07:00
parent c02df4aff4
commit 55efeb6436
26 changed files with 483 additions and 3650 deletions

View File

@ -1,154 +0,0 @@
#!/usr/bin/env bash
# gstack-ios-qa-regen — deterministically regenerate the iOS DebugBridge
# package and the app-owned typed state accessors.
set -euo pipefail
usage() {
cat <<'EOF'
Usage: gstack-ios-qa-regen --app-source <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"

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; `gstack-ios-qa-regen` and `gstack-ios-qa-daemon` must be on PATH).
- gstack installed (`./setup` complete; `bin/gstack-ios-qa-daemon` must be on disk and executable).
- Bun runtime on PATH (`bun --version`). The Mac-side daemon is a bun process.
For the optional remote-agent (Tailscale) mode, you'll additionally need Tailscale installed on the Mac with `/var/run/tailscale.sock` readable.
@ -30,49 +30,28 @@ For the optional remote-agent (Tailscale) mode, you'll additionally need Tailsca
The iOS `StateServer` is loopback-only **always**, even in remote mode. Identity validation happens Mac-side because the iPhone has no way to validate a Tailscale identity.
## Step 1: Generate the DebugBridge package
## Step 1: Add the DebugBridge templates to your iOS app
Run `/ios-qa` from the app root, or invoke the same deterministic regenerator directly:
The templates live at `~/.claude/skills/gstack/ios-qa/templates/` after `./setup`. The fastest install is to invoke the `/ios-qa` skill in Claude Code from your app's root — it reads your Swift source, codegens typed `@Observable` state accessors, and lays down the templates with your bundle ID. Or do it by hand:
```bash
gstack-ios-qa-regen \
--app-source "$PWD/Sources/YourApp" \
--bridge-dir "$PWD/DebugBridge"
```
The command copies an explicit allowlist of canonical templates into the local
`DebugBridge/` Swift package, generates
`DebugBridgeGenerated/StateAccessor.swift`, and writes the installed version to
`DebugBridgeGenerated/.gstack-version`. It excludes generated output from its
own schema hash, so rerunning it with unchanged source is a fast, byte-stable
cache hit. It also removes the explicit legacy generated-file set from older
flat harness layouts so stale bridge sources cannot shadow the package.
1. Add `DebugBridge/` as a local package dependency. Depend on the
`DebugBridgeUI` product only in Debug configuration; `DebugBridgeCore` and
`DebugBridgeTouch` come in transitively.
2. Add `DebugBridgeGenerated/StateAccessor.swift` to the app target.
3. In your `@main` App init, install the UIKit resolvers before starting the
server, then register the generated accessor. Replace the example
state/accessor names with the type the generator found:
1. Copy these into a `DebugBridge/` SPM package inside your app workspace:
- `Sources/DebugBridgeCore/StateServer.swift` (from `StateServer.swift.template`)
- `Sources/DebugBridgeCore/DebugBridgeManager.swift` (from `DebugBridgeManager.swift.template`)
- `Sources/DebugBridgeTouch/DebugBridgeTouch.m` + `Sources/DebugBridgeTouch/include/DebugBridgeTouch.h` (from the two `.template` files)
- `Sources/DebugBridgeUI/Bridges.swift` (from `Bridges.swift.template`)
- `Sources/DebugBridgeUI/DebugOverlay.swift` (from `DebugOverlay.swift.template`)
- `Package.swift` (from `Package.swift.template`)
2. Add the package as a local dependency of your app. Depend on the `DebugBridgeUI` product with `condition: .when(configuration: .debug)`. `DebugBridgeCore` and `DebugBridgeTouch` come in transitively.
3. In your `@main` App init, gate the wiring on `#if DEBUG`:
```swift
#if DEBUG
import DebugBridgeCore
StateServer.shared.start()
#if canImport(UIKit)
import DebugBridgeUI
#endif
#endif
// Inside App.init(), after appState is initialized:
#if DEBUG
#if canImport(UIKit)
DebugBridgeUIWiring.installAll()
#endif
DebugBridgeManager.shared.start(
appState: appState,
register: AppStateAccessor.register
)
#endif
```
@ -130,16 +109,7 @@ GSTACK_IOS_TARGET_BUNDLE_ID=com.yourorg.yourapp
GSTACK_IOS_DAEMON_PORT=9099 # loopback listener port; default 9099
```
If `GSTACK_IOS_TARGET_UDID` is unset, the daemon picks the best paired,
available iPhone.
Automatic selection is restricted to available iPhones and prefers a wired
phone. The daemon keeps a healthy rotated tunnel, then invalidates and
rebootstraps once on an app-relaunch 401 or recoverable CoreDevice connection
failure.
If a newly started daemon reaches an already-running target whose one-use boot
token was consumed by an earlier daemon, it verifies the bundle owner, force
relaunches that target once, waits for a fresh token, verifies ownership again,
and rotates normally.
If `GSTACK_IOS_TARGET_UDID` is unset, the daemon picks the first paired connected device.
## Step 4: Drive the device
@ -153,39 +123,15 @@ Once the daemon is running, you have an HTTP surface at `http://127.0.0.1:9099`
| `POST /session/release` | Release the lock. | bearer + session |
| `GET /screenshot` | Capture a PNG of the active window. Returns `{png_base64: "..."}`. | bearer |
| `GET /elements` | Accessibility-tree snapshot. | bearer |
| `GET /state/snapshot` | Dump every `// @Snapshotable` field as JSON. | bearer |
| `POST /state/restore` | Validate the full snapshot, then restore it on MainActor. | bearer + session, mutate tier |
| `GET /state/snapshot` | Dump every `@Snapshotable` field as JSON. | bearer |
| `POST /state/restore` | Atomically restore a full snapshot. | bearer + session, mutate tier |
| `POST /tap` `{x,y}` | Synthesize a real UITouch at window coordinates. SwiftUI Buttons fire. | bearer + session, interact tier |
| `POST /swipe` `{from_x,from_y,to_x,to_y}` | Scroll the nearest enclosing UIScrollView. | bearer + session, interact tier |
| `POST /type` `{text}` | Set text on the current first responder. | bearer + session, interact tier |
Mutating requests require both an `Authorization: Bearer <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 standalone generator marker
comment immediately above a property. It is intentionally not a property
wrapper, so it compiles cleanly with Observation:
```swift
@Observable
final class AppState {
// @Snapshotable
var username: String = ""
var authToken: String = "" // never exported
}
```
Unmarked fields never appear in the snapshot, which keeps tokens, PII, and
auth state out of recorded fixtures by default. A marked field must be a
writable instance `var` on a file-scope observable class, with an explicit type
and an internal or public setter. Supported snapshot types are JSON-native
scalars (`String`, `Bool`, signed/unsigned integer widths, `Float`, `Double`,
`CGFloat`), arrays, String-keyed dictionaries, and Optional compositions of
those types. Snapshot keys must be unique across observable classes. The
generator reports and stops on invalid declarations, custom values, implicitly
unwrapped Optionals, nested observable classes, or duplicate keys instead of
emitting broken or lossy Swift. Restore uses two phases: every model validates
the complete input first, and only then are assignments applied on MainActor.
The state snapshot is opt-in per field via a `@Snapshotable` property wrapper on your canonical state struct. Fields you don't annotate never appear in the snapshot, which keeps tokens, PII, and auth state out of recorded fixtures by default.
## Step 5: Make remote agents work (optional)

View File

@ -821,8 +821,9 @@ Each item is reverted only after AskUserQuestion confirmation:
1. The `DebugBridge` SPM target from `Package.swift`.
2. The `#if DEBUG` block in the app's `@main` entry that calls
`DebugBridgeManager.shared.start()`.
3. Any standalone `// @Snapshotable` generator marker comments on the
canonical app state class.
3. Any `@Snapshotable` property wrappers on the canonical app state struct
(the codegen-detection markers — the wrapper file lives inside
DebugBridge so removing the SPM dep removes the wrapper too).
4. Generated `StateAccessor.swift` files anywhere under the app source.
5. The `gstack-ios-qa.token` file under `NSTemporaryDirectory()` on the
device (best-effort — only works if device is connected when /ios-clean

View File

@ -50,8 +50,9 @@ Each item is reverted only after AskUserQuestion confirmation:
1. The `DebugBridge` SPM target from `Package.swift`.
2. The `#if DEBUG` block in the app's `@main` entry that calls
`DebugBridgeManager.shared.start()`.
3. Any standalone `// @Snapshotable` generator marker comments on the
canonical app state class.
3. Any `@Snapshotable` property wrappers on the canonical app state struct
(the codegen-detection markers — the wrapper file lives inside
DebugBridge so removing the SPM dep removes the wrapper too).
4. Generated `StateAccessor.swift` files anywhere under the app source.
5. The `gstack-ios-qa.token` file under `NSTemporaryDirectory()` on the
device (best-effort — only works if device is connected when /ios-clean

View File

@ -869,33 +869,17 @@ fi
## Phase 1: Read source, plan codegen
1. Walk the app source (passed as `--source <dir>`) and identify all `@Observable`
classes. Note any property immediately preceded by the generator marker
comment `// @Snapshotable` — those are the snapshot-eligible fields. The
marker is a comment so it composes with the `@Observable` macro. Each
marked field must belong to a file-scope observable class and be a writable
instance `var` with an explicit type and an internal or public setter.
Snapshot types are JSON-native scalars (`String`, `Bool`, integer widths,
`Float`, `Double`, `CGFloat`), arrays, String-keyed dictionaries, and their
Optional compositions. Keys must be unique across observable classes.
Codegen stops with a source diagnostic instead of emitting a broken or
lossy harness when any of these constraints is violated.
2. Show the user the accessor list and ask whether to install the DebugBridge
classes. Note any property marked with the `@Snapshotable` wrapper — those
are the snapshot-eligible fields.
2. Run `swift run --package-path $GSTACK_HOME/ios-qa/scripts/gen-accessors-tool gen-accessors --input <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
SPM dependency into their `Package.swift` (one AskUserQuestion).
## Phase 2: Bootstrap the device bridge
1. Generate the canonical local bridge package, typed accessors, and installed
version marker with one deterministic command:
```bash
~/.claude/skills/gstack/bin/gstack-ios-qa-regen \
--app-source "<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
1. Add the `DebugBridge` SPM dependency to the app's `Package.swift`. The package
ships three Debug-config-only library products:
- `DebugBridgeCore` (Swift, cross-platform) — StateServer + bridge protocols.
- `DebugBridgeTouch` (Objective-C, iOS-only) — KIF-derived in-process touch
@ -905,35 +889,27 @@ fi
The app target depends on `DebugBridgeUI` with `.when(configuration: .debug)`
(transitively pulls in Core + Touch). Release builds refuse to link these
targets.
3. Wire the bridges from the `@main` App init, gated on `#if DEBUG`:
2. Wire the bridges from the `@main` App init, gated on `#if DEBUG`:
```swift
#if DEBUG
import DebugBridgeCore
StateServer.shared.start()
#if canImport(UIKit)
import DebugBridgeUI
// Install resolvers before StateServer opens its listener.
DebugBridgeUIWiring.installAll()
#endif
// Replace AppState/AppStateAccessor with the type discovered in Phase 1.
DebugBridgeManager.shared.start(
appState: appState,
register: AppStateAccessor.register
)
#endif
```
4. Build + deploy to the device with `xcodebuild -scheme <SchemeName>
3. Build + deploy to the device with `xcodebuild -scheme <SchemeName>
-destination 'platform=iOS,id=<UDID>' build install`.
5. Launch via `devicectl device process launch --device <UDID> --console <bundle-id>`.
4. Launch via `devicectl device process launch --device <UDID> --console <bundle-id>`.
Capture the boot token printed to `os_log` on first run.
6. Spawn the Mac-side daemon (on-demand) — `gstack-ios-qa-daemon`. Daemon
5. Spawn the Mac-side daemon (on-demand) — `gstack-ios-qa-daemon`. Daemon
acquires an exclusive flock on `~/.gstack/ios-qa-daemon.pid`. If another
daemon is alive, the second invocation discovers its port and connects.
7. Daemon immediately calls `POST /auth/rotate` on the iOS StateServer with a
6. Daemon immediately calls `POST /auth/rotate` on the iOS StateServer with a
fresh in-memory-only token. The boot token becomes useless ~5s later.
Anything scraping `os_log` past this point sees a dead credential.
If a fresh daemon finds the app running after another daemon consumed that
one-use token, it verifies the bundle owner, relaunches the target once,
waits for the new token, verifies ownership again, and then rotates.
## Phase 3: Vision-driven agent loop
@ -941,7 +917,7 @@ Each iteration:
1. `GET /screenshot` (via daemon) → save PNG.
2. `GET /elements` → accessibility tree.
3. `GET /state/snapshot` (only `// @Snapshotable` fields) → current state.
3. `GET /state/snapshot` (only `@Snapshotable` fields) → current state.
4. Decide next action based on what's on the screen vs the test goal.
5. `POST /session/acquire` to grab the device lock.
6. Execute `POST /tap`, `/swipe`, `/type`, or `POST /state/<key>` write.
@ -1005,7 +981,7 @@ live.
| `curl: connection refused` to daemon | daemon crashed | Re-run `/ios-qa`; spawn-race lock will fail closed |
| `403 identity_not_allowed` from `/auth/mint` | identity missing from allowlist | Run `gstack-ios-qa-mint --remote <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 route dropped or app relaunched | Daemon invalidates the stale tunnel and retries one fresh bootstrap; reconnect/unlock the iPhone if it persists |
| `503 device_disconnected` from proxy | USB tunnel dropped | Reconnect device; daemon auto-reconnects within 30s |
| `429 rate_limited` from `/auth/mint` | >10 mints/min from one identity | Wait 60s; check audit log for anomalies |
| `413 body_too_large` on `/state/restore` | snapshot >1MB | Increase `--max-body` or trim snapshot |

View File

@ -97,33 +97,17 @@ fi
## Phase 1: Read source, plan codegen
1. Walk the app source (passed as `--source <dir>`) and identify all `@Observable`
classes. Note any property immediately preceded by the generator marker
comment `// @Snapshotable` — those are the snapshot-eligible fields. The
marker is a comment so it composes with the `@Observable` macro. Each
marked field must belong to a file-scope observable class and be a writable
instance `var` with an explicit type and an internal or public setter.
Snapshot types are JSON-native scalars (`String`, `Bool`, integer widths,
`Float`, `Double`, `CGFloat`), arrays, String-keyed dictionaries, and their
Optional compositions. Keys must be unique across observable classes.
Codegen stops with a source diagnostic instead of emitting a broken or
lossy harness when any of these constraints is violated.
2. Show the user the accessor list and ask whether to install the DebugBridge
classes. Note any property marked with the `@Snapshotable` wrapper — those
are the snapshot-eligible fields.
2. Run `swift run --package-path $GSTACK_HOME/ios-qa/scripts/gen-accessors-tool gen-accessors --input <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
SPM dependency into their `Package.swift` (one AskUserQuestion).
## Phase 2: Bootstrap the device bridge
1. Generate the canonical local bridge package, typed accessors, and installed
version marker with one deterministic command:
```bash
~/.claude/skills/gstack/bin/gstack-ios-qa-regen \
--app-source "<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
1. Add the `DebugBridge` SPM dependency to the app's `Package.swift`. The package
ships three Debug-config-only library products:
- `DebugBridgeCore` (Swift, cross-platform) — StateServer + bridge protocols.
- `DebugBridgeTouch` (Objective-C, iOS-only) — KIF-derived in-process touch
@ -133,35 +117,27 @@ fi
The app target depends on `DebugBridgeUI` with `.when(configuration: .debug)`
(transitively pulls in Core + Touch). Release builds refuse to link these
targets.
3. Wire the bridges from the `@main` App init, gated on `#if DEBUG`:
2. Wire the bridges from the `@main` App init, gated on `#if DEBUG`:
```swift
#if DEBUG
import DebugBridgeCore
StateServer.shared.start()
#if canImport(UIKit)
import DebugBridgeUI
// Install resolvers before StateServer opens its listener.
DebugBridgeUIWiring.installAll()
#endif
// Replace AppState/AppStateAccessor with the type discovered in Phase 1.
DebugBridgeManager.shared.start(
appState: appState,
register: AppStateAccessor.register
)
#endif
```
4. Build + deploy to the device with `xcodebuild -scheme <SchemeName>
3. Build + deploy to the device with `xcodebuild -scheme <SchemeName>
-destination 'platform=iOS,id=<UDID>' build install`.
5. Launch via `devicectl device process launch --device <UDID> --console <bundle-id>`.
4. Launch via `devicectl device process launch --device <UDID> --console <bundle-id>`.
Capture the boot token printed to `os_log` on first run.
6. Spawn the Mac-side daemon (on-demand) — `gstack-ios-qa-daemon`. Daemon
5. Spawn the Mac-side daemon (on-demand) — `gstack-ios-qa-daemon`. Daemon
acquires an exclusive flock on `~/.gstack/ios-qa-daemon.pid`. If another
daemon is alive, the second invocation discovers its port and connects.
7. Daemon immediately calls `POST /auth/rotate` on the iOS StateServer with a
6. Daemon immediately calls `POST /auth/rotate` on the iOS StateServer with a
fresh in-memory-only token. The boot token becomes useless ~5s later.
Anything scraping `os_log` past this point sees a dead credential.
If a fresh daemon finds the app running after another daemon consumed that
one-use token, it verifies the bundle owner, relaunches the target once,
waits for the new token, verifies ownership again, and then rotates.
## Phase 3: Vision-driven agent loop
@ -169,7 +145,7 @@ Each iteration:
1. `GET /screenshot` (via daemon) → save PNG.
2. `GET /elements` → accessibility tree.
3. `GET /state/snapshot` (only `// @Snapshotable` fields) → current state.
3. `GET /state/snapshot` (only `@Snapshotable` fields) → current state.
4. Decide next action based on what's on the screen vs the test goal.
5. `POST /session/acquire` to grab the device lock.
6. Execute `POST /tap`, `/swipe`, `/type`, or `POST /state/<key>` write.
@ -233,7 +209,7 @@ live.
| `curl: connection refused` to daemon | daemon crashed | Re-run `/ios-qa`; spawn-race lock will fail closed |
| `403 identity_not_allowed` from `/auth/mint` | identity missing from allowlist | Run `gstack-ios-qa-mint --remote <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 route dropped or app relaunched | Daemon invalidates the stale tunnel and retries one fresh bootstrap; reconnect/unlock the iPhone if it persists |
| `503 device_disconnected` from proxy | USB tunnel dropped | Reconnect device; daemon auto-reconnects within 30s |
| `429 rate_limited` from `/auth/mint` | >10 mints/min from one identity | Wait 60s; check audit log for anomalies |
| `413 body_too_large` on `/state/restore` | snapshot >1MB | Increase `--max-body` or trim snapshot |

View File

@ -13,10 +13,7 @@ export interface DeviceEntry {
identifier: string;
name: string;
model: string;
platform: string; // "iOS" | "iPadOS" | "visionOS" | ...
deviceType: string; // "iPhone" | "iPad" | "realityDevice" | ...
state: string; // "connected" | "available" | "available (paired)" | ...
transport: string; // "wired" on USB devices; empty for stale/unavailable entries
paired: boolean;
}
@ -86,10 +83,7 @@ export function listDevices(spawn: SpawnImpl = defaultSpawn): DeviceEntry[] {
identifier: String(d.identifier ?? ''),
name: String(props?.name ?? 'unknown'),
model: String(hw?.productType ?? 'unknown'),
platform: String(hw?.platform ?? ''),
deviceType: String(hw?.deviceType ?? ''),
state: String(conn?.tunnelState ?? 'unknown'),
transport: String(conn?.transportType ?? ''),
paired: pairingState === 'paired',
};
});

View File

@ -62,36 +62,16 @@ export async function startDaemon(opts: DaemonOptions): Promise<RunningDaemon |
const tokenStore = new SessionTokenStore();
let tunnel: DeviceTunnel | null = null;
let tunnelInFlight: Promise<DeviceTunnel | null> | null = null;
let cachedTunnelAt = 0;
const getTunnel = async (): Promise<DeviceTunnel | null> => {
// A successful bootstrap consumes and deletes the app's one-shot boot
// token. Keep that rotated tunnel for this daemon's lifetime instead of
// trying to bootstrap it again on a timer. Failed attempts are not cached.
if (tunnel) return tunnel;
if (!opts.tunnelProvider) return null;
// Multiple first requests can arrive before bootstrap completes. Share
// one provider call so they cannot race through independent rotations.
if (!tunnelInFlight) {
tunnelInFlight = Promise.resolve()
.then(() => opts.tunnelProvider!())
.then((candidate) => {
if (candidate) tunnel = candidate;
return candidate;
})
.finally(() => {
tunnelInFlight = null;
});
// Cache the tunnel for 30s; refresh on demand.
if (tunnel && Date.now() - cachedTunnelAt < 30_000) return tunnel;
if (opts.tunnelProvider) {
tunnel = await opts.tunnelProvider();
cachedTunnelAt = Date.now();
}
return tunnelInFlight;
};
const invalidateTunnel = (failedTunnel: DeviceTunnel): void => {
// A late response from the old app must not evict a tunnel that another
// request has already refreshed. Object identity gives each bootstrap a
// cheap generation token without exposing generation state elsewhere.
if (tunnel === failedTunnel) tunnel = null;
return tunnel;
};
// 2. Tailnet probe (fail-closed).
@ -106,7 +86,7 @@ export async function startDaemon(opts: DaemonOptions): Promise<RunningDaemon |
// 3. Loopback listener (full surface).
const loopbackServer = createServer(async (req, res) => {
await handleLoopback({ req, res, tokenStore, getTunnel, invalidateTunnel });
await handleLoopback({ req, res, tokenStore, getTunnel });
});
// Use port 0 for OS-assigned port when test/random port collisions are a risk.
const requestedPort = opts.loopbackPort;
@ -117,7 +97,7 @@ export async function startDaemon(opts: DaemonOptions): Promise<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, invalidateTunnel });
await handleLoopback({ req, res, tokenStore, getTunnel });
});
let v6Bound = false;
try {
@ -138,7 +118,6 @@ export async function startDaemon(opts: DaemonOptions): Promise<RunningDaemon |
res,
tokenStore,
getTunnel,
invalidateTunnel,
auditPath: opts.auditPath,
attemptsPath: opts.attemptsPath,
allowlistPath: opts.allowlistPath,
@ -202,113 +181,12 @@ 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[] = [];
@ -350,7 +228,7 @@ function sendJson(res: ServerResponse, status: number, body: unknown): void {
* loopback bind itself is the boundary).
*/
async function handleLoopback(ctx: HandlerCtx): Promise<void> {
const { req, res, tokenStore, getTunnel, invalidateTunnel } = ctx;
const { req, res, tokenStore, getTunnel } = ctx;
const url = parseUrl(req.url ?? '/');
const path = url.pathname ?? '/';
const method = req.method ?? 'GET';
@ -384,23 +262,16 @@ async function handleLoopback(ctx: HandlerCtx): Promise<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 proxied = await proxyWithTunnelRecovery({
inbound: req,
body,
sessionId,
agentIdentity,
getTunnel,
invalidateTunnel,
});
if (!proxied) {
sendJson(res, 503, { error: 'device_not_connected' });
return;
}
const { upstream } = proxied;
const upstream = await proxyToDevice({ inbound: req, body, tunnel, sessionId, agentIdentity });
res.writeHead(upstream.status, upstream.headers);
res.end(upstream.body);
} catch (err) {
@ -416,7 +287,7 @@ interface TailnetCtx extends HandlerCtx {
* Tailnet handler locked allowlist + capability tiers.
*/
async function handleTailnet(ctx: TailnetCtx): Promise<void> {
const { req, res, tokenStore, getTunnel, invalidateTunnel, whoIsImpl, auditPath, attemptsPath, allowlistPath } = ctx;
const { req, res, tokenStore, getTunnel, whoIsImpl, auditPath, attemptsPath, allowlistPath } = ctx;
const url = parseUrl(req.url ?? '/');
const path = url.pathname ?? '/';
const method = req.method ?? 'GET';
@ -504,20 +375,19 @@ async function handleTailnet(ctx: TailnetCtx): Promise<void> {
}
// Proxy to device.
const sessionId = (req.headers['x-session-id'] as string | undefined) ?? null;
const proxied = await proxyWithTunnelRecovery({
inbound: req,
body,
sessionId,
agentIdentity: session.identity,
getTunnel,
invalidateTunnel,
});
if (!proxied) {
const tunnel = await getTunnel();
if (!tunnel) {
sendJson(res, 503, { error: 'device_not_connected' });
return;
}
const { tunnel, upstream } = proxied;
const sessionId = (req.headers['x-session-id'] as string | undefined) ?? null;
const upstream = await proxyToDevice({
inbound: req,
body,
tunnel,
sessionId,
agentIdentity: session.identity,
});
// Audit the action (mutating endpoints only).
if (requiredCapability !== 'observe') {

View File

@ -5,8 +5,6 @@
// 2. launch the app on it (no-op if already running)
// 3. wait briefly for the in-app StateServer to start
// 4. copy the boot token from the app's sandbox via devicectl copy from
// If an earlier daemon already consumed it, relaunch the app once to mint
// a fresh boot token, then verify the relaunched StateServer again.
// 5. POST /auth/rotate to swap boot token → fresh in-memory token
// 6. return a DeviceTunnel pointing at the device's IPv6 with the rotated
// bearer that subsequent proxied requests carry
@ -16,7 +14,6 @@
// live token, which it scopes per-tailnet-session via /auth/mint.
import { randomBytes } from 'crypto';
import { spawnSync } from 'child_process';
import type { DeviceTunnel } from './proxy';
import {
listDevices,
@ -24,13 +21,12 @@ import {
isAppRunning,
launchApp,
copyFileFromAppContainer,
type DeviceEntry,
type SpawnImpl,
type ResolveImpl,
} from './devicectl';
export interface BootstrapOptions {
/** Target iPhone UDID. If null, picks the best connected paired iPhone. */
/** Target device UDID. If null, picks the first connected paired device. */
udid?: string;
/** Bundle ID of the iOS app hosting the StateServer. */
bundleId: string;
@ -57,85 +53,10 @@ export type BootstrapErrorReason =
| 'launch_failed'
| 'device_locked'
| 'state_server_unreachable'
| 'wrong_app'
| 'boot_token_unavailable'
| 'rotate_failed'
| 'resolve_failed';
function isIPhoneDevice(device: DeviceEntry): boolean {
const platform = device.platform.trim().toLowerCase();
const deviceType = device.deviceType.trim().toLowerCase();
const model = device.model.trim().toLowerCase();
// productType is present even on older CoreDevice versions. Prefer the
// explicit platform/type fields when available, but retain productType as
// a compatibility fallback. An explicit non-iOS platform always loses.
if (platform && platform !== 'ios') return false;
return deviceType === 'iphone' || model.startsWith('iphone');
}
function isAvailableDevice(device: Pick<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
@ -156,39 +77,9 @@ export async function bootstrapTunnel(opts: BootstrapOptions): Promise<Bootstrap
}
const target = opts.udid
? devices.find((d) => d.identifier === opts.udid)
: pickDefaultDevice(devices);
: devices.find((d) => d.paired) ?? devices[0];
if (!target) {
if (opts.udid) {
return { ok: false, error: 'device_not_found', detail: opts.udid };
}
const pairedIPhone = devices.find((d) => d.paired && isIPhoneDevice(d));
if (pairedIPhone) {
return {
ok: false,
error: 'device_not_found',
detail: `paired iPhone ${pairedIPhone.name} (${pairedIPhone.identifier}) is ${pairedIPhone.state}; connect it over USB and unlock it`,
};
}
const firstIPhone = devices.find(isIPhoneDevice);
if (!firstIPhone) {
return {
ok: false,
error: 'device_not_found',
detail: 'no iPhone is connected; non-iOS devices are not eligible for iOS QA',
};
}
return {
ok: false,
error: 'no_paired_device',
detail: `device ${firstIPhone.name} (${firstIPhone.identifier}) is ${firstIPhone.state}; run \`xcrun devicectl manage pair --device ${firstIPhone.identifier}\` and tap Trust on the iPhone`,
};
}
if (!isIPhoneDevice(target)) {
return {
ok: false,
error: 'device_not_found',
detail: `device ${target.name} (${target.identifier}) is ${target.platform || target.model}, not an iPhone`,
};
return { ok: false, error: 'device_not_found', detail: opts.udid };
}
if (!target.paired) {
return {
@ -197,13 +88,6 @@ export async function bootstrapTunnel(opts: BootstrapOptions): Promise<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)) {
@ -234,77 +118,29 @@ 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 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 deadline = Date.now() + startupTimeoutMs;
let healthOK = false;
while (Date.now() < deadline) {
try {
const r = await fetchFn(`http://[${ipv6}]:${port}/healthz`, {
signal: AbortSignal.timeout(2_000),
});
if (r.ok) { healthOK = true; break; }
} catch { /* retry */ }
await new Promise((res) => setTimeout(res, 250));
}
if (!healthOK) {
return { ok: false, error: 'state_server_unreachable', detail: `no /healthz response from [${ipv6}]:${port} within ${startupTimeoutMs}ms` };
}
const healthFailure = await waitForStateServer();
if (healthFailure) return healthFailure;
const readBootToken = () => copyFileFromAppContainer({
const bootToken = copyFileFromAppContainer({
udid: target.identifier,
bundleId: opts.bundleId,
sourceRelativePath: tokenPath,
spawn,
});
let bootToken = readBootToken();
if (!bootToken) {
// A healthy running app can lack a boot token when an earlier daemon
// already rotated it. A new daemon has no way to recover that in-memory
// bearer, so restart exactly once to make StateServer mint a fresh one.
// The explicit bundle check above prevents disrupting an unrelated app
// that happens to own the fixed StateServer port.
const relaunched = relaunchApp(target.identifier, opts.bundleId, spawn);
if (!relaunched.ok) {
return { ok: false, error: relaunched.error, detail: relaunched.detail };
}
// The token is written before StateServer opens its listener. Waiting for
// it first prevents a stale response from the terminating process from
// being mistaken for readiness of the replacement process.
const tokenDeadline = Date.now() + startupTimeoutMs;
while (!bootToken && Date.now() < tokenDeadline) {
bootToken = readBootToken();
if (!bootToken) await new Promise((res) => setTimeout(res, 250));
}
if (!bootToken) {
return {
ok: false,
error: 'boot_token_unavailable',
detail: `couldn't read ${tokenPath} from ${opts.bundleId} after relaunch`,
};
}
const relaunchedHealthFailure = await waitForStateServer();
if (relaunchedHealthFailure) return relaunchedHealthFailure;
return { ok: false, error: 'boot_token_unavailable', detail: `couldn't read ${tokenPath} from ${opts.bundleId}` };
}
// Step 5: rotate the boot token to a fresh in-memory-only one.

View File

@ -132,258 +132,6 @@ describe('daemon — loopback listener', () => {
expect(lastReq?.headers['x-session-id']).toBe('sess-loopback-1');
});
test('concurrent first requests share one tunnel bootstrap', async () => {
let bootstraps = 0;
let releaseBootstrap!: () => void;
let markBootstrapStarted!: () => void;
const bootstrapStarted = new Promise<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();

View File

@ -105,219 +105,6 @@ describe('bootstrapTunnel', () => {
}
});
test('never auto-selects a connected paired Vision Pro ahead of an iPhone', async () => {
const spawn = makeSpawn([
{
argsMatch: /devicectl list devices/,
jsonOutput: {
result: { devices: [
{
identifier: 'VISION',
connectionProperties: { tunnelState: 'connected', pairingState: 'paired' },
deviceProperties: { name: 'Vision Pro' },
hardwareProperties: {
productType: 'RealityDevice17,1',
platform: 'visionOS',
deviceType: 'realityDevice',
},
},
{
identifier: 'IPHONE',
connectionProperties: { tunnelState: 'connected', pairingState: 'paired', transportType: 'wired' },
deviceProperties: { name: 'USB iPhone' },
hardwareProperties: {
productType: 'iPhone17,1',
platform: 'iOS',
deviceType: 'iPhone',
},
},
] },
},
},
{
argsMatch: /devicectl device info processes -d IPHONE/,
jsonOutput: { result: { runningProcesses: [{ executable: 'file:///var/containers/Bundle/Application/X/com.test.app/com.test' }] } },
},
{
argsMatch: /devicectl device info details --device IPHONE/,
jsonOutput: { result: { connectionProperties: { tunnelIPAddress: 'fd00::17' } } },
},
{
argsMatch: /devicectl device copy from --device IPHONE/,
destOutput: 'TOKEN\n',
},
]);
const r = await bootstrapTunnel({
bundleId: 'com.test',
spawnImpl: spawn,
fetchImpl: (async () => new Response('{"ok":true}', { status: 200 })) as typeof fetch,
});
expect(r.ok).toBe(true);
if (r.ok) expect(r.tunnel.udid).toBe('IPHONE');
});
test('fails clearly when only non-iPhone platforms are connected', async () => {
const spawn = makeSpawn([
{
argsMatch: /devicectl list devices/,
jsonOutput: {
result: { devices: [
{
identifier: 'VISION',
connectionProperties: { tunnelState: 'connected', pairingState: 'paired' },
deviceProperties: { name: 'Vision Pro' },
hardwareProperties: {
productType: 'RealityDevice17,1',
platform: 'visionOS',
deviceType: 'realityDevice',
},
},
{
identifier: 'IPAD',
connectionProperties: { tunnelState: 'connected', pairingState: 'paired' },
deviceProperties: { name: 'iPad' },
hardwareProperties: {
productType: 'iPad16,6',
platform: 'iOS',
deviceType: 'iPad',
},
},
] },
},
},
]);
const r = await bootstrapTunnel({ bundleId: 'com.test', spawnImpl: spawn });
expect(r.ok).toBe(false);
if (!r.ok) {
expect(r.error).toBe('device_not_found');
expect(r.detail).toContain('no iPhone');
}
});
test('rejects an explicitly targeted non-iPhone device', async () => {
const spawn = makeSpawn([
{
argsMatch: /devicectl list devices/,
jsonOutput: {
result: { devices: [{
identifier: 'VISION',
connectionProperties: { tunnelState: 'connected', pairingState: 'paired' },
deviceProperties: { name: 'Vision Pro' },
hardwareProperties: {
productType: 'RealityDevice17,1',
platform: 'visionOS',
deviceType: 'realityDevice',
},
}] },
},
},
]);
const r = await bootstrapTunnel({ bundleId: 'com.test', udid: 'VISION', spawnImpl: spawn });
expect(r.ok).toBe(false);
if (!r.ok) {
expect(r.error).toBe('device_not_found');
expect(r.detail).toContain('not an iPhone');
}
});
test('skips an unavailable paired device for a connected or available paired device', async () => {
const spawn = makeSpawn([
{
argsMatch: /devicectl list devices/,
jsonOutput: {
result: { devices: [
{
identifier: 'STALE',
connectionProperties: { tunnelState: 'unavailable', pairingState: 'paired' },
deviceProperties: { name: 'Stale iPhone' },
hardwareProperties: { productType: 'iPhone17,1' },
},
{
identifier: 'WIRED',
connectionProperties: { tunnelState: 'available', pairingState: 'paired', transportType: 'wired' },
deviceProperties: { name: 'Wired iPhone' },
hardwareProperties: { productType: 'iPhone18,2' },
},
] },
},
},
{
argsMatch: /devicectl device info processes -d WIRED/,
jsonOutput: { result: { runningProcesses: [{ executable: 'file:///var/containers/Bundle/Application/X/com.test.app/com.test' }] } },
},
{
argsMatch: /devicectl device info details --device WIRED/,
jsonOutput: { result: { connectionProperties: { tunnelIPAddress: 'fd00::2' } } },
},
{
argsMatch: /devicectl device copy from --device WIRED/,
destOutput: 'TOKEN\n',
},
]);
const r = await bootstrapTunnel({
bundleId: 'com.test',
spawnImpl: spawn,
fetchImpl: (async () => new Response('{"ok":true}', { status: 200 })) as typeof fetch,
});
expect(r.ok).toBe(true);
if (r.ok) expect(r.tunnel.udid).toBe('WIRED');
});
test('accepts a wired iPhone whose tunnel is disconnected until devicectl wakes it', async () => {
const spawn = makeSpawn([
{
argsMatch: /devicectl list devices/,
jsonOutput: {
result: { devices: [
{
identifier: 'STALE-VISION',
connectionProperties: { tunnelState: 'unavailable', pairingState: 'paired' },
deviceProperties: { name: 'Stale Vision Pro' },
hardwareProperties: { productType: 'RealityDevice14,1' },
},
{
identifier: 'WIRED-DISCONNECTED',
connectionProperties: {
tunnelState: 'disconnected',
pairingState: 'paired',
transportType: 'wired',
},
deviceProperties: { name: 'USB iPhone' },
hardwareProperties: { productType: 'iPhone18,2' },
},
] },
},
},
{
argsMatch: /devicectl device info processes -d WIRED-DISCONNECTED/,
jsonOutput: { result: { runningProcesses: [{ executable: 'file:\/\/\/var\/containers\/Bundle\/Application\/X\/com.test.app\/com.test' }] } },
},
{
argsMatch: /devicectl device info details --device WIRED-DISCONNECTED/,
jsonOutput: { result: { connectionProperties: { tunnelIPAddress: 'fd00::27' } } },
},
{
argsMatch: /devicectl device copy from --device WIRED-DISCONNECTED/,
destOutput: 'TOKEN\n',
},
]);
const r = await bootstrapTunnel({
bundleId: 'com.test',
spawnImpl: spawn,
fetchImpl: (async () => new Response('{"ok":true}', { status: 200 })) as typeof fetch,
});
expect(r.ok).toBe(true);
if (r.ok) expect(r.tunnel.udid).toBe('WIRED-DISCONNECTED');
});
test('returns device_locked when launchApp errors due to lock', async () => {
const spawn = makeSpawn([
{
@ -379,48 +166,6 @@ describe('bootstrapTunnel', () => {
if (!r.ok) expect(r.error).toBe('state_server_unreachable');
});
test('rejects a StateServer owned by a different app bundle', async () => {
const spawn = makeSpawn([
{
argsMatch: /devicectl list devices/,
jsonOutput: {
result: { devices: [{
identifier: 'TEST',
connectionProperties: { tunnelState: 'connected', pairingState: 'paired' },
deviceProperties: { name: 'Test' },
hardwareProperties: { productType: 'iPhone18,2' },
}] },
},
},
{
argsMatch: /devicectl device info processes/,
jsonOutput: { result: { runningProcesses: [] } },
},
{
argsMatch: /devicectl device process launch/,
},
{
argsMatch: /devicectl device info details/,
jsonOutput: { result: { connectionProperties: { tunnelIPAddress: 'fd00::9' } } },
},
]);
const r = await bootstrapTunnel({
bundleId: 'com.expected.app',
spawnImpl: spawn,
fetchImpl: (async () => new Response(
JSON.stringify({ bundle_id: 'com.other.debug-app' }),
{ status: 200, headers: { 'content-type': 'application/json' } },
)) as typeof fetch,
});
expect(r.ok).toBe(false);
if (!r.ok) {
expect(r.error).toBe('wrong_app');
expect(r.detail).toContain('com.other.debug-app');
}
});
test('happy path: returns DeviceTunnel with rotated token', async () => {
const spawn = makeSpawn([
{
@ -489,125 +234,6 @@ describe('bootstrapTunnel', () => {
expect(fetchCalls[fetchCalls.length - 1]?.url).toContain('/auth/rotate');
});
test('relaunches once when a prior daemon consumed the running app boot token', async () => {
const spawn = makeSpawn([
{
argsMatch: /devicectl list devices/,
jsonOutput: {
result: { devices: [{
identifier: 'TEST-UDID',
connectionProperties: { tunnelState: 'connected', pairingState: 'paired' },
deviceProperties: { name: 'Test Device' },
hardwareProperties: { productType: 'iPhone18,2' },
}] },
},
},
{
argsMatch: /devicectl device info processes/,
jsonOutput: { result: { runningProcesses: [{ executable: 'file:\/\/\/var\/containers\/Bundle\/Application\/X\/com.test.app\/com.test' }] } },
},
{
argsMatch: /devicectl device info details/,
jsonOutput: { result: { connectionProperties: { tunnelIPAddress: 'fd99::beef' } } },
},
{
// A prior daemon rotated the one-use token, so StateServer deleted it.
argsMatch: /devicectl device copy from/,
exitCode: 1,
stderr: 'source does not exist',
},
{
argsMatch: /devicectl device process launch --device TEST-UDID --terminate-existing com\.test/,
},
{
argsMatch: /devicectl device copy from/,
destOutput: 'FRESH-BOOT-TOKEN\n',
},
]);
const fetchCalls: Array<{ url: string; authorization?: string }> = [];
const r = await bootstrapTunnel({
bundleId: 'com.test',
spawnImpl: spawn,
fetchImpl: (async (url, init) => {
const u = String(url);
const headers = init?.headers as Record<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([
{

View File

@ -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.

View File

@ -1,7 +1,6 @@
// gen-accessors entry point. Walks the input dir for *.swift files, parses
// each via SwiftParser, finds @Observable classes with `// @Snapshotable`
// source-marker comments (plus the legacy attribute form), and emits
// StateAccessor.swift for each.
// each via SwiftParser, finds @Observable classes with @Snapshotable-marked
// properties, and emits StateAccessor.swift for each.
//
// Output goes to --output (default: same dir as input). Cache key is
// computed from a composite hash and stored at
@ -16,8 +15,6 @@ struct AccessorSpec {
let fields: [(name: String, typeText: String)]
}
private let generatorFormatVersion = "accessor-generator-v5"
@main
struct GenAccessors {
static func main() async {
@ -35,84 +32,39 @@ struct GenAccessors {
}()
// Walk + collect *.swift files
guard let swiftFiles = collectSwiftFiles(at: inputDir, excluding: outputDir) else {
guard let swiftFiles = collectSwiftFiles(at: inputDir) else {
FileHandle.standardError.write(Data("input dir not found: \(inputDir)\n".utf8))
exit(3)
}
// Parse + validate before consulting the cache. Invalid marked fields
// must fail deterministically rather than being hidden by an older
// cached output.
var specs: [AccessorSpec] = []
var diagnostics: [String] = []
for path in swiftFiles {
guard let source = try? String(contentsOfFile: path, encoding: .utf8) else { continue }
let tree = Parser.parse(source: source)
let visitor = ObservableClassVisitor(sourcePath: path, viewMode: .sourceAccurate)
visitor.walk(tree)
specs.append(contentsOf: visitor.specs)
diagnostics.append(contentsOf: visitor.diagnostics)
}
var snapshotKeyOwners: [String: String] = [:]
for spec in specs {
for field in spec.fields {
if let previous = snapshotKeyOwners[field.name] {
diagnostics.append(
"snapshot key '\(field.name)' is declared by both \(previous) and \(spec.className); "
+ "keys must be unique across @Observable types"
)
} else {
snapshotKeyOwners[field.name] = spec.className
}
}
}
if !diagnostics.isEmpty {
let message = "gen-accessors: invalid @Snapshotable declaration(s):\n"
+ diagnostics.map { " - \($0)" }.joined(separator: "\n") + "\n"
FileHandle.standardError.write(Data(message.utf8))
exit(4)
}
let buildId = detectedBuildId()
let accessorHash = computeAccessorHash(specs: specs)
// Cache identity includes build provenance and generator ABI. The
// separately computed accessorHash is schema-only and remains stable
// across checkout paths, unrelated sources, and app rebuilds.
let cacheKey = computeCacheKey(swiftFiles: swiftFiles, buildId: buildId)
let cacheDir = getEnv("GSTACK_IOS_CACHE_ROOT")
?? ("~/.gstack/cache/gen-accessors" as NSString).expandingTildeInPath
// Composite cache key codex catch (source content alone misses
// generator-logic changes).
let cacheKey = computeCacheKey(swiftFiles: swiftFiles)
let cacheDir = ("~/.gstack/cache/gen-accessors" as NSString).expandingTildeInPath
let cachedOutput = "\(cacheDir)/\(cacheKey)/StateAccessor.swift"
do {
try FileManager.default.createDirectory(atPath: outputDir, withIntermediateDirectories: true)
} catch {
FileHandle.standardError.write(Data("gen-accessors: cannot create output directory: \(error)\n".utf8))
exit(5)
}
if FileManager.default.fileExists(atPath: cachedOutput) {
// Cache hit. Copy to output dir.
let finalOutput = "\(outputDir)/StateAccessor.swift"
do {
if FileManager.default.fileExists(atPath: finalOutput) {
try FileManager.default.removeItem(atPath: finalOutput)
}
try FileManager.default.copyItem(atPath: cachedOutput, toPath: finalOutput)
} catch {
FileHandle.standardError.write(Data("gen-accessors: cannot restore cached output: \(error)\n".utf8))
exit(5)
}
try? FileManager.default.removeItem(atPath: "\(outputDir)/StateAccessor.swift")
try? FileManager.default.copyItem(atPath: cachedOutput, toPath: "\(outputDir)/StateAccessor.swift")
print("gen-accessors: cache hit (\(cacheKey))")
return
}
// Emit
let output = render(specs: specs, buildId: buildId, accessorHash: accessorHash)
do {
try output.write(toFile: "\(outputDir)/StateAccessor.swift", atomically: true, encoding: .utf8)
} catch {
FileHandle.standardError.write(Data("gen-accessors: cannot write output: \(error)\n".utf8))
exit(5)
// Parse + extract specs
var specs: [AccessorSpec] = []
for path in swiftFiles {
guard let source = try? String(contentsOfFile: path, encoding: .utf8) else { continue }
let tree = Parser.parse(source: source)
let visitor = ObservableClassVisitor(viewMode: .sourceAccurate)
visitor.walk(tree)
specs.append(contentsOf: visitor.specs)
}
// Emit
let output = render(specs: specs, buildId: getEnv("APP_BUILD_ID") ?? "unknown", accessorHash: cacheKey)
try? FileManager.default.createDirectory(atPath: outputDir, withIntermediateDirectories: true)
try? output.write(toFile: "\(outputDir)/StateAccessor.swift", atomically: true, encoding: .utf8)
// Populate cache
try? FileManager.default.createDirectory(atPath: "\(cacheDir)/\(cacheKey)", withIntermediateDirectories: true)
try? output.write(toFile: cachedOutput, atomically: true, encoding: .utf8)
@ -120,154 +72,46 @@ struct GenAccessors {
print("gen-accessors: wrote \(specs.count) accessor(s) to \(outputDir)/StateAccessor.swift")
}
static func collectSwiftFiles(at path: String, excluding outputPath: String) -> [String]? {
let inputURL = URL(fileURLWithPath: path).standardizedFileURL
let outputURL = URL(fileURLWithPath: outputPath).standardizedFileURL
var isDirectory: ObjCBool = false
guard FileManager.default.fileExists(atPath: inputURL.path, isDirectory: &isDirectory),
isDirectory.boolValue,
let enumerator = FileManager.default.enumerator(
at: inputURL,
includingPropertiesForKeys: [.isDirectoryKey],
options: []
) else { return nil }
// When --output is the input root, scan the root but still exclude
// StateAccessor.swift. For a nested output, skip the entire subtree.
let shouldExcludeOutputSubtree = outputURL.path != inputURL.path
static func collectSwiftFiles(at path: String) -> [String]? {
guard let enumerator = FileManager.default.enumerator(atPath: path) else { return nil }
var files: [String] = []
for case let fileURL as URL in enumerator {
let normalized = fileURL.standardizedFileURL
let values = try? normalized.resourceValues(forKeys: [.isDirectoryKey])
if values?.isDirectory == true {
if normalized.lastPathComponent == "DebugBridgeGenerated"
|| (shouldExcludeOutputSubtree && normalized.path == outputURL.path) {
enumerator.skipDescendants()
}
continue
}
guard normalized.pathExtension == "swift",
normalized.lastPathComponent != "StateAccessor.swift" else { continue }
files.append(normalized.path)
for case let f as String in enumerator {
if f.hasSuffix(".swift") { files.append("\(path)/\(f)") }
}
return files.sorted()
}
static func computeCacheKey(swiftFiles: [String], buildId: String) -> String {
static func computeCacheKey(swiftFiles: [String]) -> String {
// Codex-flagged: hash must include Swift version, tool git rev, platform.
let swiftVer = getEnv("SWIFT_VERSION") ?? "unknown"
let toolRev = getEnv("GEN_ACCESSORS_REV") ?? generatorFormatVersion
#if arch(arm64)
let platform = "darwin-arm64"
#elseif arch(x86_64)
let platform = "darwin-x86_64"
#else
let platform = "darwin-unknown"
#endif
var combined = Data("\(generatorFormatVersion)|swift=\(swiftVer)|tool=\(toolRev)|platform=\(platform)|build=\(buildId)|".utf8)
let toolRev = getEnv("GEN_ACCESSORS_REV") ?? "dev"
let platform = "darwin-arm64" // simplified for the test harness
var combined = "swift=\(swiftVer)|tool=\(toolRev)|platform=\(platform)|"
for path in swiftFiles {
if let data = try? Data(contentsOf: URL(fileURLWithPath: path)) {
// Do not include absolute checkout paths in cache identity.
combined.append(Data("\(data.count):".utf8))
combined.append(data)
combined.append(Data("|".utf8))
combined += "\(path):\(data.count):\(data.sha256())|"
}
}
return combined.sha256()
}
static func computeAccessorHash(specs: [AccessorSpec]) -> String {
var signature = "snapshot-schema-v1\n"
for spec in specs {
signature += "C\(spec.className.utf8.count):\(spec.className)\n"
for field in spec.fields {
signature += "F\(field.name.utf8.count):\(field.name)"
signature += "T\(field.typeText.utf8.count):\(field.typeText)\n"
}
signature += "E\n"
}
return signature.sha256()
return combined.data(using: .utf8)!.sha256()
}
static func render(specs: [AccessorSpec], buildId: String, accessorHash: String) -> String {
var out = "// AUTO-GENERATED — DO NOT EDIT. Regenerate with /ios-sync.\n"
out += "#if DEBUG\nimport Foundation\nimport DebugBridgeCore\n\n"
if !specs.isEmpty {
// JSONSerialization produces Foundation bridge objects. Direct
// `as?` casts let NSNumber cross-cast between Bool and numeric
// Swift types. Round-tripping through JSONDecoder enforces the
// declared Codable shape (including every collection element)
// and preserves a successful Optional nil as a double Optional.
out += "private enum _GStackDebugBridgeSnapshotJSON {\n"
out += " private struct Box<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"
}
out += "#if DEBUG\nimport Foundation\nimport DebugBridge\n\n"
for spec in specs {
// Accessors live in the app target beside its usually-internal
// state types. A public signature cannot expose an internal type.
out += "@MainActor\nenum \(spec.className)Accessor {\n"
out += " static func register(_ state: \(spec.className)) {\n"
out += "@MainActor\npublic enum \(spec.className)Accessor {\n"
out += " public static func register(_ state: \(spec.className)) {\n"
out += " StateServer.shared.register(\n"
out += " buildId: {\n"
out += " let shortVersion = Bundle.main.object(forInfoDictionaryKey: \"CFBundleShortVersionString\") as? String\n"
out += " let bundleVersion = Bundle.main.object(forInfoDictionaryKey: \"CFBundleVersion\") as? String\n"
out += " if let shortVersion, let bundleVersion { return \"\\(shortVersion) (\\(bundleVersion))\" }\n"
out += " return shortVersion ?? bundleVersion ?? \(String(reflecting: buildId))\n"
out += " }(),\n"
out += " accessorHash: \(String(reflecting: accessorHash)),\n"
out += " atomicRestore: { keys, apply in\n"
out += " // Validate every key and value before assignment.\n"
out += " // StateServer invokes every model once with apply=false,\n"
out += " // then applies every validated model with apply=true.\n"
for (index, field) in spec.fields.enumerated() {
let (name, typeText) = field
out += " guard let raw\(index) = keys[\"\(name)\"] else {\n"
out += " return .missingKey(\"\(name)\")\n"
out += " }\n"
out += " guard let restored\(index): \(typeText) = _GStackDebugBridgeSnapshotJSON.decode(raw\(index), as: \(typeText).self) else {\n"
out += " return .typeMismatch(\"\(name)\")\n"
out += " }\n"
}
out += " if apply {\n"
for (index, field) in spec.fields.enumerated() {
out += " state.\(field.name) = restored\(index)\n"
}
out += " }\n"
out += " return .ok\n"
out += " }\n"
out += " buildId: \"\(buildId)\",\n"
out += " accessorHash: \"\(accessorHash)\",\n"
out += " atomicRestore: { _ in .ok }\n"
out += " )\n"
for (name, typeText) in spec.fields {
let wrapped = optionalWrappedType(typeText)
for (name, _) in spec.fields {
out += " StateServer.shared.registerAccessor(\n"
out += " key: \"\(name)\",\n"
out += " type: \"\(typeText)\",\n"
if wrapped != nil {
out += " read: {\n"
out += " guard let value = state.\(name) else { return NSNull() }\n"
out += " return value as Any\n"
out += " },\n"
} else {
out += " read: { state.\(name) as Any? },\n"
}
out += " write: { value in\n"
out += " guard let typed: \(typeText) = _GStackDebugBridgeSnapshotJSON.decode(value, as: \(typeText).self) else { return false }\n"
out += " state.\(name) = typed\n"
out += " return true\n"
out += " }\n"
out += " type: \"Any\",\n"
out += " read: { state.\(name) as Any? },\n"
out += " write: { _ in false }\n"
out += " )\n"
}
out += " }\n}\n\n"
@ -279,59 +123,6 @@ struct GenAccessors {
final class ObservableClassVisitor: SyntaxVisitor {
var specs: [AccessorSpec] = []
var diagnostics: [String] = []
private let sourcePath: String
init(sourcePath: String, viewMode: SyntaxTreeViewMode) {
self.sourcePath = sourcePath
super.init(viewMode: viewMode)
}
private func hasSnapshotableMarker(_ declaration: VariableDeclSyntax) -> Bool {
// Preserve compatibility with source that defines a custom attribute
// or wrapper, even though that form conflicts with @Observable in
// ordinary app models.
if declaration.attributes.contains(where: { attribute in
guard let attribute = attribute.as(AttributeSyntax.self) else { return false }
return attribute.attributeName.trimmedDescription == "Snapshotable"
}) {
return true
}
// Match a standalone ordinary line-comment trivia piece exactly.
// Avoid declaration.description/contains: both would accept prose
// such as `// do not expose through @Snapshotable`.
return declaration.leadingTrivia.contains { piece in
guard case .lineComment(let text) = piece else { return false }
return text.dropFirst(2).trimmingCharacters(in: .whitespaces) == "@Snapshotable"
}
}
private func enclosingScopeDescription(for node: ClassDeclSyntax) -> String? {
var ancestor = Syntax(node).parent
while let current = ancestor {
if let declaration = current.as(ClassDeclSyntax.self) {
return "class \(declaration.name.text)"
}
if let declaration = current.as(StructDeclSyntax.self) {
return "struct \(declaration.name.text)"
}
if let declaration = current.as(EnumDeclSyntax.self) {
return "enum \(declaration.name.text)"
}
if let declaration = current.as(ActorDeclSyntax.self) {
return "actor \(declaration.name.text)"
}
if let declaration = current.as(ExtensionDeclSyntax.self) {
return "extension \(declaration.extendedType.trimmedDescription)"
}
if current.is(CodeBlockSyntax.self) {
return "a local scope"
}
ancestor = current.parent
}
return nil
}
override func visit(_ node: ClassDeclSyntax) -> SyntaxVisitorContinueKind {
// Look for @Observable attribute
@ -342,88 +133,23 @@ final class ObservableClassVisitor: SyntaxVisitor {
guard isObservable else { return .visitChildren }
let className = node.name.text
let markedMembers = node.memberBlock.members.compactMap {
$0.decl.as(VariableDeclSyntax.self)
}.filter(hasSnapshotableMarker)
if !markedMembers.isEmpty, let enclosingScope = enclosingScopeDescription(for: node) {
diagnostics.append(
"\((sourcePath as NSString).lastPathComponent): nested @Observable class "
+ "\(className) inside \(enclosingScope) is unsupported; "
+ "move snapshot-enabled models to file scope"
)
// Continue walking so a more deeply nested marked model is also
// diagnosed rather than silently omitted.
return .visitChildren
}
var fields: [(String, String)] = []
for member in node.memberBlock.members {
guard let varDecl = member.decl.as(VariableDeclSyntax.self) else { continue }
// Field must opt in with the source marker (or legacy attribute).
guard hasSnapshotableMarker(varDecl) else { continue }
let bindingName = varDecl.bindings.first?
.pattern.as(IdentifierPatternSyntax.self)?.identifier.text ?? "<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
}
// Field must be marked @Snapshotable to be included
let isSnapshotable = varDecl.attributes.contains(where: { attr in
guard let attr = attr.as(AttributeSyntax.self) else { return false }
return attr.attributeName.trimmedDescription == "Snapshotable"
})
guard isSnapshotable else { continue }
for binding in varDecl.bindings {
guard let pattern = binding.pattern.as(IdentifierPatternSyntax.self) else {
diagnostics.append("\(context) only supports an identifier binding")
continue
if let pattern = binding.pattern.as(IdentifierPatternSyntax.self) {
let name = pattern.identifier.text
let typeText = binding.typeAnnotation?.type.trimmedDescription ?? "Any"
fields.append((name, typeText))
}
// An initializer plus an accessor block is a stored property
// with observers. Without an initializer, conservatively
// reject the block rather than emitting a setter for a
// computed/read-only declaration.
if binding.accessorBlock != nil && binding.initializer == nil {
diagnostics.append("\(context) must be stored and writable")
continue
}
guard let annotation = binding.typeAnnotation else {
diagnostics.append("\(context) requires an explicit type annotation")
continue
}
let name = pattern.identifier.text
let typeText = annotation.type.trimmedDescription
.split(whereSeparator: { $0.isWhitespace })
.joined(separator: " ")
switch parseJSONSnapshotType(typeText) {
case .valid:
break
case .implicitlyUnwrappedOptional:
diagnostics.append("\(context) cannot use an implicitly unwrapped Optional type")
continue
case .nestedOptional:
diagnostics.append("\(context) cannot use a nested Optional type")
continue
case .unsupported:
diagnostics.append("\(context) uses unsupported non-JSON snapshot type '\(typeText)'")
continue
}
fields.append((name, typeText))
}
}
@ -434,233 +160,10 @@ final class ObservableClassVisitor: SyntaxVisitor {
}
}
private indirect enum JSONSnapshotType {
case scalar
case optional(JSONSnapshotType)
case array(JSONSnapshotType)
case dictionary(JSONSnapshotType)
var isOptional: Bool {
if case .optional = self { return true }
return false
}
}
private enum JSONSnapshotTypeParseResult {
case valid(JSONSnapshotType)
case implicitlyUnwrappedOptional
case nestedOptional
case unsupported
}
/// Parse the deliberately small set of Swift types that can make a lossless
/// trip through JSONSerialization and JSONDecoder without application-defined
/// encoding behavior. Type aliases and arbitrary Codable models are rejected:
/// the bridge has no compiler/type-checker context in which to prove that they
/// are JSON-native.
private func parseJSONSnapshotType(_ source: String) -> JSONSnapshotTypeParseResult {
let type = source.trimmingCharacters(in: .whitespacesAndNewlines)
guard !type.isEmpty else { return .unsupported }
if type.hasSuffix("!") {
return .implicitlyUnwrappedOptional
}
if type.hasSuffix("?") {
let wrappedText = String(type.dropLast()).trimmingCharacters(in: .whitespacesAndNewlines)
switch parseJSONSnapshotType(wrappedText) {
case .valid(let wrapped):
return wrapped.isOptional ? .nestedOptional : .valid(.optional(wrapped))
case let issue:
return issue
}
}
if let bracketContents = outerDelimitedContents(type, open: "[", close: "]") {
let dictionaryParts = splitTopLevel(bracketContents, separator: ":")
if dictionaryParts.count == 1 {
switch parseJSONSnapshotType(dictionaryParts[0]) {
case .valid(let element): return .valid(.array(element))
case let issue: return issue
}
}
guard dictionaryParts.count == 2, isStringType(dictionaryParts[0]) else {
return .unsupported
}
switch parseJSONSnapshotType(dictionaryParts[1]) {
case .valid(let value): return .valid(.dictionary(value))
case let issue: return issue
}
}
if let generic = genericTypeParts(type) {
let base = compactTypeName(generic.base)
if base == "Optional" || base == "Swift.Optional" {
guard generic.arguments.count == 1 else { return .unsupported }
switch parseJSONSnapshotType(generic.arguments[0]) {
case .valid(let wrapped):
return wrapped.isOptional ? .nestedOptional : .valid(.optional(wrapped))
case let issue:
return issue
}
}
if base == "Array" || base == "Swift.Array" {
guard generic.arguments.count == 1 else { return .unsupported }
switch parseJSONSnapshotType(generic.arguments[0]) {
case .valid(let element): return .valid(.array(element))
case let issue: return issue
}
}
if base == "Dictionary" || base == "Swift.Dictionary" {
guard generic.arguments.count == 2, isStringType(generic.arguments[0]) else {
return .unsupported
}
switch parseJSONSnapshotType(generic.arguments[1]) {
case .valid(let value): return .valid(.dictionary(value))
case let issue: return issue
}
}
return .unsupported
}
let scalar = compactTypeName(type)
let supportedScalars: Set<String> = [
"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 {

View File

@ -1,14 +1,11 @@
// Tests for the gen-accessors TS port. Covers:
//
// - Parse: lexical marker isolation + invalid declaration diagnostics
// - Parse: 3 regex-failure-mode fixtures from the fork (codex catch)
// - Cache: same input → same key; different swift version → 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
// different tool rev → different key; file modified → different key
// - 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';
@ -16,11 +13,9 @@ import {
collectSwiftFiles,
parseSwift,
computeCacheKey,
computeAccessorHash,
generate,
pruneCache,
render,
AccessorGenerationError,
type AccessorSpec,
} from './gen-accessors';
@ -35,14 +30,12 @@ afterEach(() => {
});
describe('parseSwift — fork regex-failure-mode fixtures', () => {
test('parses @Observable class with source-marker comments', () => {
test('parses @Observable class with simple @Snapshotable fields', () => {
const src = `
@Observable
final class AppState {
// @Snapshotable
var isLoggedIn: Bool = false
// @Snapshotable
var username: String = ""
@Snapshotable var isLoggedIn: Bool = false
@Snapshotable var username: String = ""
var notSnapshotable: Int = 0
}
`;
@ -53,100 +46,12 @@ final class AppState {
expect(specs[0]!.fields.find(f => f.name === 'isLoggedIn')!.typeText).toBe('Bool');
});
test('retains legacy @Snapshotable attribute parsing', () => {
const specs = parseSwift(`
@Observable
final class LegacyState {
@Snapshotable var counter: Int = 0
}
`);
expect(specs).toEqual([{
className: 'LegacyState',
fields: [{ name: 'counter', typeText: 'Int' }],
}]);
});
test('does not treat documentation prose as a source marker', () => {
const specs = parseSwift(`
@Observable
final class PrivateState {
/// Do not expose this field through @Snapshotable.
var token: String = "secret"
}
`);
expect(specs).toHaveLength(0);
});
test('does not let a trailing marker comment bleed into the next field', () => {
const specs = parseSwift(`
@Observable
final class PrivateState {
var oldValue: Int = 0 // @Snapshotable
var nextValue: Int = 1
}
`);
expect(specs).toHaveLength(0);
});
test('ignores exact-looking markers inside nested block comments', () => {
const specs = parseSwift(`
@Observable
final class PrivateState {
/*
/* // @Snapshotable */
// @Snapshotable
var leaked: String = "secret"
*/
// @Snapshotable
var visible: Int = 1
}
`);
expect(specs).toEqual([{
className: 'PrivateState',
fields: [{ name: 'visible', typeText: 'Int' }],
}]);
});
test('ignores declarations and markers inside multiline and raw strings', () => {
const specs = parseSwift(`
let ordinary = """
@Observable class FakeA {
// @Snapshotable
var leaked: Int = 0
}
"""
let raw = #"""
@Observable class FakeB { @Snapshotable var leaked: String = "" }
"""#
@Observable
final class RealState {
// @Snapshotable
var safe: Bool = true
}
`);
expect(specs).toEqual([{
className: 'RealState',
fields: [{ name: 'safe', typeText: 'Bool' }],
}]);
});
test('ignores marker words in standalone trailing prose', () => {
const specs = parseSwift(`
@Observable
final class PrivateState {
// @Snapshotable fields are intentionally disabled here.
var token: String = "secret"
}
`);
expect(specs).toHaveLength(0);
});
test('handles @Snapshotable on multi-line type signatures', () => {
const src = `
@Observable
class Cart {
@Snapshotable var items:
[Dictionary<String, [Int]>]
[CartItem<Detail>]
= []
var unrelated: Int = 0
}
@ -155,67 +60,20 @@ class Cart {
expect(specs).toHaveLength(1);
expect(specs[0]!.fields).toHaveLength(1);
expect(specs[0]!.fields[0]!.name).toBe('items');
expect(specs[0]!.fields[0]!.typeText).toContain('Dictionary');
expect(specs[0]!.fields[0]!.typeText).toContain('CartItem');
});
test('handles JSON-compatible generic types in property signatures', () => {
test('handles generic types in property signatures', () => {
const src = `
@Observable
class Repo {
@Snapshotable var pages: Dictionary<String, [Optional<Int>]> = [:]
@Snapshotable var pages: Dictionary<String, [Result<Item, Error>]> = [:]
}
`;
const specs = parseSwift(src);
expect(specs).toHaveLength(1);
expect(specs[0]!.fields[0]!.typeText).toContain('Dictionary');
expect(specs[0]!.fields[0]!.typeText).toContain('Optional');
});
test('accepts qualified JSON-native scalar and collection spellings', () => {
const specs = parseSwift(`
@Observable
class Metrics {
// @Snapshotable
var ratio: CoreGraphics.CGFloat = 0
// @Snapshotable
var counts: Swift.Array<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([]);
expect(specs[0]!.fields[0]!.typeText).toContain('Result');
});
test('ignores fields without @Snapshotable marker', () => {
@ -256,7 +114,10 @@ class B {
expect(specs.map(s => s.className).sort()).toEqual(['A', 'B']);
});
test('diagnoses fields with computed body braces', () => {
test('skips fields with computed body braces', () => {
// Codex flagged "Properties with computed getters / didSet blocks" as a
// failure mode of the fork's regex. We deliberately exclude them here —
// computed properties are not snapshot-eligible.
const src = `
@Observable
class M {
@ -266,50 +127,9 @@ class M {
}
}
`;
expect(() => parseSwift(src)).toThrow("field 'computed' must be stored and writable");
});
test.each([
['let', '// @Snapshotable\n let immutable: Int = 1', 'must be declared var, not let'],
['private', '// @Snapshotable\n private var secret: String = ""', 'cannot be private'],
['fileprivate', '// @Snapshotable\n fileprivate var secret: String = ""', 'cannot be private'],
['private(set)', '// @Snapshotable\n public private(set) var count: Int = 0', 'cannot be private'],
['fileprivate(set)', '// @Snapshotable\n fileprivate(set) var count: Int = 0', 'cannot be private'],
['inferred type', '// @Snapshotable\n var inferred = 42', 'requires an explicit type annotation'],
['multiple bindings', '// @Snapshotable\n var a: Int = 1, b: Int = 2', 'exactly one binding'],
['static', '// @Snapshotable\n static var shared: Int = 0', 'must be an instance property'],
['nested Optional', '// @Snapshotable\n var nested: String?? = nil', 'cannot use a nested Optional'],
['nested Optional in collection', '// @Snapshotable\n var nestedItems: [String??] = []', 'cannot use a nested Optional'],
['implicitly unwrapped Optional', '// @Snapshotable\n var legacy: String! = nil', 'cannot use an implicitly unwrapped Optional'],
['custom type', '// @Snapshotable\n var custom: CustomValue = .init()', 'uses unsupported snapshot type'],
])('diagnoses invalid %s declarations', (_label, declaration, expected) => {
expect(() => parseSwift(`
@Observable
final class InvalidState {
${declaration}
}
`)).toThrow(expected);
});
test('aggregates invalid declaration diagnostics with class and line context', () => {
try {
parseSwift(`
@Observable
final class InvalidState {
// @Snapshotable
let first: Int = 1
// @Snapshotable
private var second: String = ""
}
`);
throw new Error('expected parseSwift to fail');
} catch (error) {
expect(error).toBeInstanceOf(AccessorGenerationError);
const generationError = error as AccessorGenerationError;
expect(generationError.diagnostics).toHaveLength(2);
expect(generationError.message).toContain('InvalidState (line 4)');
expect(generationError.message).toContain('InvalidState (line 6)');
}
const specs = parseSwift(src);
expect(specs).toHaveLength(1);
expect(specs[0]!.fields.map(f => f.name)).toEqual(['snapshotted']);
});
});
@ -424,68 +244,6 @@ describe('computeCacheKey', () => {
});
expect(k1).not.toBe(k2);
});
test('app build provenance invalidates the cache key', () => {
const f = join(workDir, 'a.swift');
writeFileSync(f, '@Observable class A {}');
const shared = {
swiftFiles: [f],
swiftVersion: '6.0.0',
toolGitRev: 'abc',
platformTriple: 'darwin-arm64',
};
expect(computeCacheKey({ ...shared, buildId: '100' })).not.toBe(
computeCacheKey({ ...shared, buildId: '101' }),
);
});
test('equivalent source content does not depend on its absolute checkout path', () => {
const firstDir = join(workDir, 'checkout-a');
const secondDir = join(workDir, 'checkout-b');
mkdirSync(firstDir);
mkdirSync(secondDir);
const first = join(firstDir, 'State.swift');
const second = join(secondDir, 'State.swift');
writeFileSync(first, '@Observable class A {}');
writeFileSync(second, '@Observable class A {}');
const versioning = { swiftVersion: '6', toolGitRev: 't', platformTriple: 'p' };
expect(computeCacheKey({ swiftFiles: [first], ...versioning })).toBe(
computeCacheKey({ swiftFiles: [second], ...versioning }),
);
});
});
describe('computeAccessorHash', () => {
const base: AccessorSpec[] = [{
className: 'AppState',
fields: [
{ name: 'count', typeText: 'Int' },
{ name: 'nickname', typeText: 'String?' },
],
}];
test('is deterministic for the same ordered accessor signatures', () => {
expect(computeAccessorHash(base)).toBe(computeAccessorHash(structuredClone(base)));
});
test('changes when field order, name, or type changes', () => {
const reordered: AccessorSpec[] = [{
className: 'AppState',
fields: [...base[0]!.fields].reverse(),
}];
const renamed: AccessorSpec[] = [{
className: 'AppState',
fields: [{ name: 'total', typeText: 'Int' }, base[0]!.fields[1]!],
}];
const retyped: AccessorSpec[] = [{
className: 'AppState',
fields: [{ name: 'count', typeText: 'Int64' }, base[0]!.fields[1]!],
}];
const hash = computeAccessorHash(base);
expect(computeAccessorHash(reordered)).not.toBe(hash);
expect(computeAccessorHash(renamed)).not.toBe(hash);
expect(computeAccessorHash(retyped)).not.toBe(hash);
});
});
describe('generate', () => {
@ -525,35 +283,6 @@ class AppState {
expect(r1.cacheKey).toBe(r2.cacheKey);
});
test('custom nested output subtree cannot poison the second-run cache key', () => {
const inputDir = join(workDir, 'src');
const outputDir = join(inputDir, 'generated', 'accessors');
mkdirSync(inputDir);
writeFileSync(join(inputDir, 'state.swift'), '@Observable class A { @Snapshotable var x: Int = 0 }');
const cacheRoot = join(workDir, 'cache');
const r1 = generate({
inputDir,
outputDir,
cacheRoot,
swiftVersion: '6',
toolGitRev: 't',
platformTriple: 'p',
});
writeFileSync(join(outputDir, 'Ignored.swift'), '@Observable class Poison { @Snapshotable var bad: Int = 1 }');
const r2 = generate({
inputDir,
outputDir,
cacheRoot,
swiftVersion: '6',
toolGitRev: 't',
platformTriple: 'p',
});
expect(r2.cacheHit).toBe(true);
expect(r2.cacheKey).toBe(r1.cacheKey);
});
test('modifying source invalidates the cache', () => {
const inputDir = join(workDir, 'src');
mkdirSync(inputDir);
@ -566,62 +295,6 @@ class AppState {
expect(r1.cacheKey).not.toBe(r2.cacheKey);
expect(r2.cacheHit).toBe(false);
});
test('unmarked source churn changes cache identity but not schema identity', () => {
const inputDir = join(workDir, 'src');
mkdirSync(inputDir);
const state = join(inputDir, 'state.swift');
const unrelated = join(inputDir, 'unrelated.swift');
writeFileSync(state, '@Observable class A { // @Snapshotable\n var x: Int = 0\n }');
writeFileSync(unrelated, 'struct Unrelated { let a = 1 }');
const cacheRoot = join(workDir, 'cache');
const options = { inputDir, cacheRoot, swiftVersion: '6', toolGitRev: 't', platformTriple: 'p' };
const first = generate(options);
writeFileSync(unrelated, 'struct Unrelated { let a = 2 }');
const second = generate(options);
expect(second.cacheKey).not.toBe(first.cacheKey);
expect(second.accessorHash).toBe(first.accessorHash);
});
test('buildId changes cannot reuse generated fallback provenance', () => {
const inputDir = join(workDir, 'src');
mkdirSync(inputDir);
writeFileSync(join(inputDir, 'state.swift'), '@Observable class A { @Snapshotable var x: Int = 0 }');
const cacheRoot = join(workDir, 'cache');
const shared = { inputDir, cacheRoot, swiftVersion: '6', toolGitRev: 't', platformTriple: 'p' };
const first = generate({ ...shared, buildId: 'build-100' });
const second = generate({ ...shared, buildId: 'build-101' });
expect(second.cacheKey).not.toBe(first.cacheKey);
expect(second.cacheHit).toBe(false);
expect(readFileSync(second.outputPath, 'utf8')).toContain('?? "build-101"');
});
test('CLI exits 4 with actionable diagnostics and no generated output', () => {
const inputDir = join(workDir, 'src');
const outputDir = join(workDir, 'generated');
mkdirSync(inputDir);
writeFileSync(join(inputDir, 'state.swift'), `
@Observable final class InvalidState {
// @Snapshotable
private let secret = "nope"
}
`);
const result = spawnSync('bun', [
join(import.meta.dir, 'gen-accessors.ts'),
'--input', inputDir,
'--output', outputDir,
], {
encoding: 'utf8',
env: { ...process.env, GSTACK_IOS_CACHE_ROOT: join(workDir, 'cache') },
});
expect(result.status).toBe(4);
// `let` is diagnosed first; either way the class/property is named and
// generation never emits an accessor that will fail in xcodebuild.
expect(result.stderr).toContain('InvalidState');
expect(result.stderr).toContain("field 'secret' must be declared var, not let");
expect(result.stderr).not.toContain('AccessorGenerationError:');
expect(existsSync(join(outputDir, 'StateAccessor.swift'))).toBe(false);
});
});
describe('pruneCache', () => {
@ -659,395 +332,14 @@ describe('render', () => {
fields: [{ name: 'a', typeText: 'Int' }, { name: 'b', typeText: 'String' }],
}];
const out = render(specs, 'build-1.2.3', 'hash-abc');
expect(out).toContain('enum AppStateAccessor');
expect(out).not.toContain('public enum AppStateAccessor');
expect(out).toContain('static func register(_ state: AppState)');
expect(out).not.toContain('public static func register(_ state: AppState)');
expect(out).toContain('public enum AppStateAccessor');
expect(out).toContain('key: "a"');
expect(out).toContain('key: "b"');
expect(out).toContain('return .missingKey("a")');
expect(out).toContain('return .typeMismatch("b")');
expect(out).toContain('guard let restored0 = Self.decodeSnapshotValue(raw0, as: Int.self)');
expect(out).toContain('guard let restored1 = Self.decodeSnapshotValue(raw1, as: String.self)');
expect(out).toContain('atomicRestore: { keys, apply in');
expect(out).toContain('if apply {');
expect(out).toContain('state.a = restored0');
expect(out).toContain('state.b = restored1');
expect(out.indexOf('state.a = restored0')).toBeGreaterThan(out.indexOf('guard let restored1'));
expect(out).toContain('guard let typed = Self.decodeSnapshotValue(value, as: Int.self) else { return false }');
expect(out).toContain('state.a = typed');
expect(out).toContain('return true');
expect(out).not.toContain('atomicRestore: { _ in .ok }');
expect(out).not.toContain('write: { _ in false }');
expect(out).toContain('CFBundleShortVersionString');
expect(out).toContain('CFBundleVersion');
expect(out).toContain('return shortVersion ?? bundleVersion ?? "build-1.2.3"');
expect(out).toContain('buildId: "build-1.2.3"');
expect(out).toContain('accessorHash: "hash-abc"');
expect(out).toContain('import DebugBridgeCore');
expect(out).not.toContain('import DebugBridge\n');
expect(out).toContain('#if DEBUG');
expect(out).toContain('#endif');
});
test('emits explicit NSNull round-trip handling for Optional fields', () => {
const out = render([{
className: 'AppState',
fields: [
{ name: 'nickname', typeText: 'String?' },
{ name: 'selection', typeText: 'Optional<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', () => {
@ -1063,17 +355,4 @@ describe('collectSwiftFiles', () => {
const files = collectSwiftFiles(workDir);
expect(files.sort()).toEqual([a, b].sort());
});
test('excludes the configured output subtree and every StateAccessor.swift', () => {
const source = join(workDir, 'App.swift');
const staleAccessor = join(workDir, 'old', 'StateAccessor.swift');
const outputDir = join(workDir, 'custom-output');
mkdirSync(join(workDir, 'old'));
mkdirSync(outputDir);
writeFileSync(source, 'struct App {}');
writeFileSync(staleAccessor, '// stale generated output');
writeFileSync(join(outputDir, 'Poison.swift'), '// must not be scanned');
expect(collectSwiftFiles(workDir, { outputDir })).toEqual([source]);
});
});

View File

@ -5,16 +5,15 @@
// first time. Also exercised by tests so we can verify the cache + parse
// behavior without a Swift toolchain.
//
// The TS fast path uses a lightweight Swift lexical scanner — it understands:
// The TS port uses a stricter regex than the fork's original — it understands:
// - @Observable class declarations
// - `// @Snapshotable` property markers (only marked fields are exported)
// - Legacy @Snapshotable attributes for existing integrations
// - @Snapshotable property markers (only marked fields are exported)
// - Multi-line type signatures (collapses whitespace before matching)
// - JSON-native generic arrays and String-keyed dictionaries
// - Generic type parameters (matched as opaque text inside the type)
//
// Invalid marked declarations (computed/immutable/inaccessible/untyped,
// nested, duplicate-keyed, or non-JSON) fail generation instead of producing
// Swift that only fails later in xcodebuild or at snapshot time.
// What it does NOT handle (deferred to the SwiftPM tool):
// - Computed properties with bodies (regex can mis-parse braces)
// - Property wrappers other than @Snapshotable
//
// Composite cache key (codex-flagged): swift_version || tool_git_rev ||
// platform_triple || source_content_hash. Source-only hash misses generator
@ -36,16 +35,6 @@ export interface AccessorSpec {
fields: AccessorField[];
}
export class AccessorGenerationError extends Error {
readonly diagnostics: string[];
constructor(diagnostics: string[]) {
super(`gen-accessors: invalid @Snapshotable declaration(s):\n${diagnostics.map(d => ` - ${d}`).join('\n')}`);
this.name = 'AccessorGenerationError';
this.diagnostics = diagnostics;
}
}
export interface GenInputs {
inputDir: string;
outputDir?: string;
@ -59,105 +48,56 @@ export interface GenInputs {
export interface GenResult {
outputPath: string;
cacheKey: string;
accessorHash: string;
specs: AccessorSpec[];
cacheHit: boolean;
}
const FALLBACK_PLATFORM = process.platform === 'darwin' ? 'darwin-arm64' : `${process.platform}-${process.arch}`;
const GENERATOR_FORMAT_VERSION = 'accessor-generator-v5';
const JSON_SCALAR_TYPES = new Set([
'String',
'Bool',
'Int', 'Int8', 'Int16', 'Int32', 'Int64',
'UInt', 'UInt8', 'UInt16', 'UInt32', 'UInt64',
'Float', 'Double', 'CGFloat',
]);
export function collectSwiftFiles(
dir: string,
opts: { excludeGenerated?: boolean; outputDir?: string } = {},
): string[] {
export function collectSwiftFiles(dir: string, opts: { excludeGenerated?: boolean } = {}): string[] {
const out: string[] = [];
const excludeGenerated = opts.excludeGenerated ?? true;
const root = resolve(dir);
const outputDir = opts.outputDir ? resolve(opts.outputDir) : undefined;
function walk(currentDir: string): void {
for (const name of readdirSync(currentDir)) {
const full = join(currentDir, name);
const s = statSync(full);
if (s.isDirectory()) {
// The generated subtree must never participate in its own cache key.
// Keep the well-known-name guard for direct collectSwiftFiles callers,
// and use the actual --output path when generate() supplies one.
const isOutputSubtree = outputDir !== undefined
&& outputDir !== root
&& resolve(full) === outputDir;
if (excludeGenerated && (name === 'DebugBridgeGenerated' || isOutputSubtree)) continue;
walk(full);
} else if (name.endsWith('.swift')) {
// Skip generated accessor files wherever they live. Otherwise moving
// an old copy outside the output directory poisons the next cache key.
if (excludeGenerated && name === 'StateAccessor.swift') continue;
out.push(full);
}
for (const name of readdirSync(dir)) {
const full = join(dir, name);
const s = statSync(full);
if (s.isDirectory()) {
// Skip generated output dir (when it lives under the input dir)
if (excludeGenerated && name === 'DebugBridgeGenerated') continue;
out.push(...collectSwiftFiles(full, opts));
} else if (name.endsWith('.swift')) {
// Skip the codegen output file. Otherwise the second run picks it up,
// changes the cache key, and the cache never hits.
if (excludeGenerated && name === 'StateAccessor.swift') continue;
out.push(full);
}
}
walk(root);
return out.sort();
}
export function parseSwift(source: string): AccessorSpec[] {
const specs: AccessorSpec[] = [];
const diagnostics: string[] = [];
const masked = maskSwiftSource(source);
// Find `@Observable\n(public )?(final )?class <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;
for (const match of masked.matchAll(classPattern)) {
let match: RegExpExecArray | null;
while ((match = classPattern.exec(source)) !== null) {
const className = match[1]!;
const matchOffset = match.index!;
const openBraceOffset = matchOffset + match[0].lastIndexOf('{');
const startIdx = openBraceOffset + 1;
const endIdx = findMatchingBrace(masked, startIdx - 1);
const startIdx = classPattern.lastIndex;
const endIdx = findMatchingBrace(source, startIdx - 1);
if (endIdx === -1) continue;
const body = masked.slice(startIdx, endIdx);
const body = source.slice(startIdx, endIdx);
const parsed = parseFields(body, source, startIdx, className);
if (braceDepthAt(masked, matchOffset) !== 0) {
if (parsed.fields.length > 0 || parsed.diagnostics.length > 0) {
const line = source.slice(0, matchOffset).split(/\r?\n/).length;
diagnostics.push(`${className} (line ${line}): nested @Observable types are not supported; move the type to file scope`);
}
continue;
}
diagnostics.push(...parsed.diagnostics);
const fields = parsed.fields;
const fields = parseFields(body);
if (fields.length > 0) {
specs.push({ className, fields });
}
}
if (diagnostics.length > 0) throw new AccessorGenerationError(diagnostics);
return specs;
}
function braceDepthAt(masked: string, offset: number): number {
let depth = 0;
for (let i = 0; i < offset; i++) {
if (masked[i] === '{') depth++;
else if (masked[i] === '}') depth = Math.max(0, depth - 1);
}
return depth;
}
function findMatchingBrace(s: string, openIdx: number): number {
// Strings and comments have already been blanked by maskSwiftSource, so
// braces here are syntax rather than prose or literal content.
// openIdx points at '{'. Return idx of matching '}', or -1.
let depth = 0;
for (let i = openIdx; i < s.length; i++) {
const c = s[i];
@ -165,305 +105,48 @@ function findMatchingBrace(s: string, openIdx: number): number {
else if (c === '}') {
depth--;
if (depth === 0) return i;
} else if (c === '"' || c === "'") {
// skip string literal
const quote = c;
i++;
while (i < s.length && s[i] !== quote) {
if (s[i] === '\\') i++;
i++;
}
} else if (c === '/' && s[i + 1] === '/') {
// skip line comment
while (i < s.length && s[i] !== '\n') i++;
} else if (c === '/' && s[i + 1] === '*') {
i += 2;
while (i < s.length - 1 && !(s[i] === '*' && s[i + 1] === '/')) i++;
i++;
}
}
return -1;
}
/**
* Blank comments and string literals while preserving byte offsets/newlines.
* A canonical standalone `// @Snapshotable` comment is rewritten to the
* equivalent attribute token. This is intentionally lexical rather than a
* whole-file regexp: markers inside nested block comments, normal/triple/raw
* strings, trailing comments, and prose comments must never opt a field in.
*/
function maskSwiftSource(source: string): string {
const out = source.split('');
const blank = (start: number, end: number): void => {
for (let j = start; j < end; j++) {
if (out[j] !== '\n' && out[j] !== '\r') out[j] = ' ';
}
};
let i = 0;
while (i < source.length) {
if (source[i] === '/' && source[i + 1] === '/') {
let end = i + 2;
while (end < source.length && source[end] !== '\n' && source[end] !== '\r') end++;
const lineStart = source.lastIndexOf('\n', i - 1) + 1;
const standalone = source.slice(lineStart, i).trim().length === 0;
const isMarker = standalone && source.slice(i + 2, end).trim() === '@Snapshotable';
blank(i, end);
if (isMarker) {
const marker = '@Snapshotable';
for (let j = 0; j < marker.length; j++) out[i + j] = marker[j]!;
}
i = end;
continue;
}
if (source[i] === '/' && source[i + 1] === '*') {
const start = i;
i += 2;
let depth = 1;
while (i < source.length && depth > 0) {
if (source[i] === '/' && source[i + 1] === '*') {
depth++;
i += 2;
} else if (source[i] === '*' && source[i + 1] === '/') {
depth--;
i += 2;
} else {
i++;
}
}
blank(start, i);
continue;
}
// Swift strings may be ordinary, multiline, or raw (`#"..."#` and
// `#"""..."""#`). Mask the entire literal, including interpolation;
// declarations cannot legally originate inside a literal.
let hashCount = 0;
while (source[i + hashCount] === '#') hashCount++;
const quoteIdx = i + hashCount;
if (source[quoteIdx] === '"') {
const start = i;
const triple = source.slice(quoteIdx, quoteIdx + 3) === '"""';
const quoteCount = triple ? 3 : 1;
i = quoteIdx + quoteCount;
while (i < source.length) {
if (hashCount === 0 && source[i] === '\\') {
i += 2;
continue;
}
const quoteRun = source.slice(i, i + quoteCount) === '"'.repeat(quoteCount);
const hashesMatch = source.slice(i + quoteCount, i + quoteCount + hashCount) === '#'.repeat(hashCount);
if (quoteRun && hashesMatch) {
i += quoteCount + hashCount;
break;
}
i++;
}
blank(start, i);
continue;
}
i++;
}
return out.join('');
}
const DECL_MODIFIERS = new Set([
'public', 'internal', 'package', 'open', 'private', 'fileprivate',
'final', 'static', 'class', 'lazy', 'weak', 'unowned', 'override',
'nonisolated', 'isolated', 'borrowing', 'consuming',
]);
function parseFields(
body: string,
fullSource: string,
bodyOffset: number,
className: string,
): { fields: AccessorField[]; diagnostics: string[] } {
function parseFields(body: string): AccessorField[] {
// Look for @Snapshotable followed by var/let declarations. Allow attribute
// ordering: `@Snapshotable var name: Type` OR `@Snapshotable\n var name: Type`.
// Multi-line types are handled by greedy non-newline match in the type, but
// we collapse adjacent whitespace first to avoid false negatives.
const normalized = body.replace(/[\t ]*\r?\n[\t ]*/g, ' ');
const fieldPattern = /@Snapshotable\s+(?:(?:public|internal|fileprivate|private)\s+)?(?:var|let)\s+(\w+)\s*:\s*([^={]+?)(?=\s*(?:=|\{|@Snapshotable|\bvar\b|\blet\b|\bfunc\b|\}|$))/g;
const fields: AccessorField[] = [];
const diagnostics: string[] = [];
let braceDepth = 0;
const lineFor = (localOffset: number): number => {
const absolute = bodyOffset + localOffset;
let line = 1;
for (let j = 0; j < absolute; j++) if (fullSource[j] === '\n') line++;
return line;
};
const fail = (offset: number, message: string): void => {
diagnostics.push(`${className} (line ${lineFor(offset)}): ${message}`);
};
const skipWhitespace = (start: number): number => {
let at = start;
while (at < body.length && /\s/.test(body[at]!)) at++;
return at;
};
const identifierAt = (start: number): { text: string; end: number } | undefined => {
const m = /^[A-Za-z_]\w*/.exec(body.slice(start));
return m ? { text: m[0], end: start + m[0].length } : undefined;
};
const skipBalancedParens = (start: number): number => {
if (body[start] !== '(') return start;
let depth = 0;
for (let at = start; at < body.length; at++) {
if (body[at] === '(') depth++;
else if (body[at] === ')' && --depth === 0) return at + 1;
}
return body.length;
};
for (let i = 0; i < body.length; i++) {
if (body[i] === '{') {
braceDepth++;
continue;
}
if (body[i] === '}') {
braceDepth--;
continue;
}
if (braceDepth !== 0 || !body.startsWith('@Snapshotable', i)) continue;
const before = i === 0 ? '' : body[i - 1]!;
const after = body[i + '@Snapshotable'.length] ?? '';
if (/\w/.test(before) || /\w/.test(after)) continue;
const markerOffset = i;
let at = skipWhitespace(i + '@Snapshotable'.length);
const modifiers: string[] = [];
let bindingKind: 'var' | 'let' | undefined;
// Permit other Swift attributes between the marker and declaration. They
// remain the compiler's responsibility; this scanner only owns the
// snapshot contract. Parenthesized attribute arguments are skipped.
while (body[at] === '@') {
const attribute = identifierAt(at + 1);
if (!attribute) break;
at = skipWhitespace(attribute.end);
if (body[at] === '(') at = skipBalancedParens(at);
at = skipWhitespace(at);
}
while (at < body.length) {
const token = identifierAt(at);
if (!token) break;
if (token.text === 'var' || token.text === 'let') {
bindingKind = token.text;
at = token.end;
break;
}
if (!DECL_MODIFIERS.has(token.text)) break;
let modifier = token.text;
at = skipWhitespace(token.end);
if (body[at] === '(') {
const end = skipBalancedParens(at);
modifier += body.slice(at, end).replace(/\s+/g, '');
at = skipWhitespace(end);
}
modifiers.push(modifier);
}
if (!bindingKind) {
fail(markerOffset, '@Snapshotable must immediately precede a stored property declaration');
continue;
}
at = skipWhitespace(at);
const identifier = identifierAt(at);
const fieldName = identifier?.text ?? '<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 });
let m: RegExpExecArray | null;
while ((m = fieldPattern.exec(normalized)) !== null) {
// Codex catch: skip fields that have a computed body (`{ get ... }` or
// `{ didSet ... }` after the type). The match boundary stops before `{`,
// so we peek at what comes after the type in the original body.
const afterMatchIdx = m.index + m[0].length;
const afterMatch = normalized.slice(afterMatchIdx, afterMatchIdx + 4).trim();
// If the next non-space character is `{`, this is a computed property.
// We're conservative: snapshot-eligible fields must be stored properties
// (initialized with `=` or just declared).
if (afterMatch.startsWith('{')) continue;
fields.push({ name: m[1]!, typeText: m[2]!.trim() });
}
return { fields, diagnostics };
return fields;
}
export function computeCacheKey(inputs: {
@ -471,263 +154,35 @@ export function computeCacheKey(inputs: {
swiftVersion: string;
toolGitRev: string;
platformTriple: string;
buildId?: string;
}): string {
const h = createHash('sha256');
h.update(`${GENERATOR_FORMAT_VERSION}|swift=${inputs.swiftVersion}|tool=${inputs.toolGitRev}|platform=${inputs.platformTriple}|build=${inputs.buildId ?? 'unknown'}|`);
h.update(`swift=${inputs.swiftVersion}|tool=${inputs.toolGitRev}|platform=${inputs.platformTriple}|`);
for (const f of inputs.swiftFiles) {
const content = readFileSync(f);
// Cache identity is content-based. Absolute checkout paths must not make
// equivalent source trees produce different generated output.
h.update(`${content.length}:`);
h.update(`${f}:${content.length}:`);
h.update(content);
h.update('|');
}
return h.digest('hex');
}
/** Stable snapshot-schema fingerprint, deliberately independent of cache ABI,
* source paths, app build provenance, and unmarked source. Field/class order is
* source order because restore payload compatibility is an ordered contract. */
export function computeAccessorHash(specs: AccessorSpec[]): string {
let signature = 'snapshot-schema-v1\n';
for (const spec of specs) {
signature += `C${Buffer.byteLength(spec.className)}:${spec.className}\n`;
for (const field of spec.fields) {
signature += `F${Buffer.byteLength(field.name)}:${field.name}`;
signature += `T${Buffer.byteLength(field.typeText)}:${field.typeText}\n`;
}
signature += 'E\n';
}
return createHash('sha256').update(signature).digest('hex');
}
/** Return the wrapped type for one top-level Optional spelling. */
export function optionalWrappedType(typeText: string): string | undefined {
const type = typeText.trim();
if (type.endsWith('?')) {
const wrapped = type.slice(0, -1).trim();
return wrapped.length > 0 ? wrapped : undefined;
}
const optional = /^(?:Swift\.)?Optional\s*</.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';
out += '#if DEBUG\nimport Foundation\nimport DebugBridge\n\n';
for (const spec of specs) {
// Accessors compile in the app target beside its usually-internal state
// types. Making this API public would make valid internal models fail
// Swift type checking (a public signature cannot expose an internal type).
out += `@MainActor\nenum ${spec.className}Accessor {\n`;
out += ` private static func decodeSnapshotValue<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 += `@MainActor\npublic enum ${spec.className}Accessor {\n`;
out += ` public static func register(_ state: ${spec.className}) {\n`;
out += ` StateServer.shared.register(\n`;
out += ` buildId: {\n`;
out += ` let shortVersion = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String\n`;
out += ` let bundleVersion = Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String\n`;
out += ` if let shortVersion, let bundleVersion { return "\\(shortVersion) (\\(bundleVersion))" }\n`;
out += ` return shortVersion ?? bundleVersion ?? ${swiftStringLiteral(buildId)}\n`;
out += ` }(),\n`;
out += ` accessorHash: ${swiftStringLiteral(accessorHash)},\n`;
out += ` atomicRestore: { keys, apply in\n`;
out += ` // Validate every key and value before assignment.\n`;
out += ` // Successful assignments are sequential on MainActor.\n`;
spec.fields.forEach((field, index) => {
out += ` guard let raw${index} = keys["${field.name}"] else {\n`;
out += ` return .missingKey("${field.name}")\n`;
out += ` }\n`;
const wrapped = optionalWrappedType(field.typeText);
if (wrapped !== undefined) {
out += ` let restored${index}: ${field.typeText}\n`;
out += ` if raw${index} is NSNull {\n`;
out += ` restored${index} = nil\n`;
out += ` } else if let typed = Self.decodeSnapshotValue(raw${index}, as: ${wrapped}.self) {\n`;
out += ` restored${index} = typed\n`;
out += ` } else {\n`;
out += ` return .typeMismatch("${field.name}")\n`;
out += ` }\n`;
} else {
out += ` guard let restored${index} = Self.decodeSnapshotValue(raw${index}, as: ${field.typeText}.self) else {\n`;
out += ` return .typeMismatch("${field.name}")\n`;
out += ` }\n`;
}
});
out += ` if apply {\n`;
spec.fields.forEach((field, index) => {
out += ` state.${field.name} = restored${index}\n`;
});
out += ` }\n`;
out += ` return .ok\n`;
out += ` }\n`;
out += ` buildId: "${buildId}",\n`;
out += ` accessorHash: "${accessorHash}",\n`;
out += ` atomicRestore: { _ in .ok }\n`;
out += ` )\n`;
for (const field of spec.fields) {
const wrapped = optionalWrappedType(field.typeText);
out += ` StateServer.shared.registerAccessor(\n`;
out += ` key: "${field.name}",\n`;
out += ` type: "${field.typeText}",\n`;
if (wrapped !== undefined) {
out += ` read: {\n`;
out += ` guard let value = state.${field.name} else { return NSNull() }\n`;
out += ` return value as Any\n`;
out += ` },\n`;
} else {
out += ` read: { state.${field.name} as Any? },\n`;
}
out += ` write: { value in\n`;
if (wrapped !== undefined) {
out += ` if value is NSNull {\n`;
out += ` state.${field.name} = nil\n`;
out += ` return true\n`;
out += ` }\n`;
out += ` guard let typed = Self.decodeSnapshotValue(value, as: ${wrapped}.self) else { return false }\n`;
} else {
out += ` guard let typed = Self.decodeSnapshotValue(value, as: ${field.typeText}.self) else { return false }\n`;
}
out += ` state.${field.name} = typed\n`;
out += ` return true\n`;
out += ` }\n`;
out += ` read: { state.${field.name} as Any? },\n`;
out += ` write: { _ in false }\n`;
out += ` )\n`;
}
out += ` }\n}\n\n`;
@ -760,15 +215,6 @@ function detectToolGitRev(): string {
}
}
function detectBuildId(): string {
if (process.env.APP_BUILD_ID) return process.env.APP_BUILD_ID;
const marketing = process.env.MARKETING_VERSION;
const build = process.env.CURRENT_PROJECT_VERSION;
if (marketing && build) return `${marketing} (${build})`;
if (build) return build;
return 'unknown';
}
export function defaultCacheRoot(): string {
return process.env.GSTACK_IOS_CACHE_ROOT ?? join(homedir(), '.gstack', 'cache', 'gen-accessors');
}
@ -777,25 +223,13 @@ export function generate(inputs: GenInputs): GenResult {
const inputDir = resolve(inputs.inputDir);
const outputDir = resolve(inputs.outputDir ?? inputDir);
const cacheRoot = inputs.cacheRoot ?? defaultCacheRoot();
const swiftFiles = collectSwiftFiles(inputDir, { outputDir });
const buildId = inputs.buildId ?? detectBuildId();
// Parse before cache lookup. This keeps diagnostics deterministic even when
// an older cache entry exists, and gives us the schema-only accessor hash.
const allSpecs: AccessorSpec[] = [];
for (const f of swiftFiles) {
const src = readFileSync(f, 'utf-8');
allSpecs.push(...parseSwift(src));
}
validateAccessorSpecs(allSpecs);
const accessorHash = computeAccessorHash(allSpecs);
const swiftFiles = collectSwiftFiles(inputDir);
const cacheKey = computeCacheKey({
swiftFiles,
swiftVersion: inputs.swiftVersion ?? detectSwiftVersion(),
toolGitRev: inputs.toolGitRev ?? detectToolGitRev(),
platformTriple: inputs.platformTriple ?? FALLBACK_PLATFORM,
buildId,
});
const cachedOutput = join(cacheRoot, cacheKey, 'StateAccessor.swift');
@ -808,13 +242,18 @@ export function generate(inputs: GenInputs): GenResult {
return {
outputPath: finalOutput,
cacheKey,
accessorHash,
specs: allSpecs,
specs: [], // intentionally empty on cache hit (no need to re-parse)
cacheHit: true,
};
}
const rendered = render(allSpecs, buildId, accessorHash);
// Parse + render fresh
const allSpecs: AccessorSpec[] = [];
for (const f of swiftFiles) {
const src = readFileSync(f, 'utf-8');
allSpecs.push(...parseSwift(src));
}
const rendered = render(allSpecs, inputs.buildId ?? 'unknown', cacheKey);
writeFileSync(finalOutput, rendered);
// Populate cache (best-effort — cache failures don't break codegen).
@ -828,7 +267,6 @@ export function generate(inputs: GenInputs): GenResult {
return {
outputPath: finalOutput,
cacheKey,
accessorHash,
specs: allSpecs,
cacheHit: false,
};
@ -862,18 +300,10 @@ if (import.meta.main) {
const inputDir = args[inputIdx + 1]!;
const outputIdx = args.indexOf('--output');
const outputDir = outputIdx !== -1 ? args[outputIdx + 1] : undefined;
try {
const result = generate({ inputDir, outputDir });
process.stdout.write(
result.cacheHit
? `gen-accessors: cache hit (${result.cacheKey.slice(0, 12)})\n`
: `gen-accessors: wrote ${result.specs.length} accessor(s) to ${result.outputPath}\n`,
);
} catch (error) {
if (error instanceof AccessorGenerationError) {
process.stderr.write(`${error.message}\n`);
process.exit(4);
}
throw error;
}
const result = generate({ inputDir, outputDir });
process.stdout.write(
result.cacheHit
? `gen-accessors: cache hit (${result.cacheKey.slice(0, 12)})\n`
: `gen-accessors: wrote ${result.specs.length} accessor(s) to ${result.outputPath}\n`,
);
}

View File

@ -27,35 +27,12 @@ public enum DebugBridgeUIWiring {
/// times reinstalls the same closures. Must be called on @MainActor
/// because every UIKit access requires the main actor.
public static func installAll() {
// KIF turns on accessibility automation before walking SwiftUI's AX
// tree. Without it SwiftUI exposes only the hosting shell and taps
// can report success without invoking Button.action.
DebugBridgeTouch.prepareForAutomation()
ScreenshotBridge.resolver = { ScreenshotBridgeImpl.capturePNG() }
ElementsBridge.resolver = { ElementsBridgeImpl.snapshot() }
MutationBridge.resolver = { op, payload in MutationBridgeImpl.dispatch(op: op, payload: payload) }
}
}
/// Return the children UIKit exposes specifically to accessibility automation.
/// iOS 17 added `automationElements`; unlike the older container APIs it
/// preserves identified SwiftUI descendants inside accessibility groups.
@MainActor
private func debugBridgeAccessibilityChildren(of element: NSObject) -> [NSObject] {
if #available(iOS 17.0, *),
let automation = element.automationElements,
!automation.isEmpty {
return automation.compactMap { $0 as? NSObject }
}
if let accessibility = element.accessibilityElements,
!accessibility.isEmpty {
return accessibility.compactMap { $0 as? NSObject }
}
let count = element.accessibilityElementCount()
guard count > 0, count < 512 else { return [] }
return (0..<count).compactMap { element.accessibilityElement(at: $0) as? NSObject }
}
// MARK: - ScreenshotBridge implementation
@MainActor
@ -66,11 +43,7 @@ enum ScreenshotBridgeImpl {
static func capturePNG() -> Data? {
guard let scene = activeScene(), let window = activeKeyWindow(in: scene) else { return nil }
let bounds = window.bounds
let format = UIGraphicsImageRendererFormat.default()
// /tap consumes UIKit window points. Render at 1x so screenshot pixels
// use that same coordinate space on 2x/3x devices.
format.scale = 1
let renderer = UIGraphicsImageRenderer(bounds: bounds, format: format)
let renderer = UIGraphicsImageRenderer(bounds: bounds)
let image = renderer.image { _ in
// drawHierarchy is the documented way to snapshot real UIKit
// layers including layer-backed views. afterScreenUpdates: false
@ -88,10 +61,7 @@ enum ScreenshotBridgeImpl {
}
private static func activeKeyWindow(in scene: UIWindowScene) -> UIWindow? {
let windows = scene.windows.filter { window in
!window.isHidden && !String(describing: type(of: window)).contains("PassThroughWindow")
}
return windows.first(where: { $0.isKeyWindow }) ?? windows.max(by: { $0.windowLevel < $1.windowLevel })
scene.windows.first(where: { $0.isKeyWindow }) ?? scene.windows.first
}
}
@ -106,40 +76,21 @@ enum ElementsBridgeImpl {
static func snapshot() -> [JSONDict] {
guard let scene = activeScene(), let window = activeKeyWindow(in: scene) else { return [] }
var elements: [JSONDict] = []
var visited = Set<ObjectIdentifier>()
var remaining = 2_048
collect(
view: window,
parentPath: "",
window: window,
visited: &visited,
remaining: &remaining,
into: &elements
)
collect(view: window, parentPath: "", windowBounds: window.bounds, into: &elements)
return elements
}
private static func collect(
view: UIView,
parentPath: String,
window: UIWindow,
visited: inout Set<ObjectIdentifier>,
remaining: inout Int,
into elements: inout [JSONDict]
) {
guard remaining > 0, visited.insert(ObjectIdentifier(view)).inserted else { return }
remaining -= 1
private static func collect(view: UIView, parentPath: String, windowBounds: CGRect, into elements: inout [JSONDict]) {
// Skip hidden / zero-size / off-screen subtrees early.
if view.isHidden || view.alpha < 0.01 { return }
let frameInWindow = view.convert(view.bounds, to: window)
if !window.bounds.intersects(frameInWindow) { return }
let frameInWindow = view.convert(view.bounds, to: nil)
if !windowBounds.intersects(frameInWindow) { return }
let isAccessible = view.isAccessibilityElement
let label = view.accessibilityLabel ?? ""
let identifier = view.accessibilityIdentifier ?? ""
let traits = NSNumber(value: view.accessibilityTraits.rawValue)
let traits = Int(view.accessibilityTraits.rawValue)
let value = (view.accessibilityValue ?? "") as String
let className = String(describing: type(of: view))
let path = parentPath.isEmpty ? className : "\(parentPath) > \(className)"
@ -169,98 +120,66 @@ enum ElementsBridgeImpl {
])
}
// Walk automation children before raw subviews. On iOS 17+ this
// exposes identified SwiftUI controls nested inside GroupBox/List.
for (index, element) in debugBridgeAccessibilityChildren(of: view).enumerated() {
if let child = element as? UIView {
collect(
view: child,
parentPath: path,
window: window,
visited: &visited,
remaining: &remaining,
into: &elements
)
} else {
appendSynthetic(
element,
path: "\(path) > <ax\(index)>",
window: window,
visited: &visited,
remaining: &remaining,
into: &elements
)
// Recurse into accessibility-elements first (some custom views vend
// synthetic children), then UIView subviews. SwiftUI's host views
// populate accessibilityElements lazily — many return nil before
// VoiceOver triggers them. Force population by reading accessibilityElementCount.
_ = view.accessibilityElementCount()
if let axElements = view.accessibilityElements {
for case let element as NSObject in axElements {
if let v = element as? UIView {
collect(view: v, parentPath: path, windowBounds: windowBounds, into: &elements)
} else {
// Synthetic accessibility element (no UIView). Capture frame in screen coords.
let af = (element.value(forKey: "accessibilityFrame") as? CGRect) ?? .zero
elements.append([
"path": "\(path) > <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,
])
}
}
}
for sub in view.subviews {
collect(
view: sub,
parentPath: path,
window: window,
visited: &visited,
remaining: &remaining,
into: &elements
)
}
}
private static func appendSynthetic(
_ element: NSObject,
path: String,
window: UIWindow,
visited: inout Set<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
)
}
collect(view: sub, parentPath: path, windowBounds: windowBounds, into: &elements)
}
}
@ -272,10 +191,7 @@ enum ElementsBridgeImpl {
}
private static func activeKeyWindow(in scene: UIWindowScene) -> UIWindow? {
let windows = scene.windows.filter { window in
!window.isHidden && !String(describing: type(of: window)).contains("PassThroughWindow")
}
return windows.first(where: { $0.isKeyWindow }) ?? windows.max(by: { $0.windowLevel < $1.windowLevel })
scene.windows.first(where: { $0.isKeyWindow }) ?? scene.windows.first
}
}
@ -294,62 +210,21 @@ enum MutationBridgeImpl {
}
}
/// Tap at (x, y) in window coordinates. Prefer accessibility activation,
/// which is stable for SwiftUI buttons across OS releases, then fall back
/// to KIF-derived UITouch synthesis for gesture-only/custom controls.
/// Tap at (x, y) in window coordinates. Delegates to DebugBridgeTouch
/// (KIF-derived in-process touch synthesis). The Obj-C target builds a
/// real UITouch + IOHIDEvent + UIEvent and dispatches via
/// `UIApplication.sendEvent`, which is what UIKit uses for real touches.
/// This works for UIControl, SwiftUI Button (via iOS 18+
/// `_UIHitTestContext`), gesture recognizers, and anything else that
/// listens to the real event-dispatch path.
private static func handleTap(_ payload: JSONDict) -> Bool {
guard let x = payload["x"] as? NSNumber,
let y = payload["y"] as? NSNumber else { return false }
let point = CGPoint(x: x.doubleValue, y: y.doubleValue)
guard let scene = activeScene(), let window = activeKeyWindow(in: scene) else { return false }
if let element = findActivatableAXElement(at: point, in: window),
element.accessibilityActivate() {
return true
}
return DebugBridgeTouch.sendTap(at: point, in: window)
}
private static func findActivatableAXElement(at point: CGPoint, in window: UIWindow) -> NSObject? {
let screenPoint = window.screen.coordinateSpace.convert(point, from: window.coordinateSpace)
var best: NSObject?
var bestArea: CGFloat = .infinity
var visited = Set<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 }
@ -391,10 +266,7 @@ enum MutationBridgeImpl {
var off = scroll.contentOffset
off.x = max(0, min(scroll.contentSize.width - scroll.bounds.width, off.x + dx))
off.y = max(0, min(scroll.contentSize.height - scroll.bounds.height, off.y + dy))
// Automation commands return synchronously; do not report
// success while the target is still moving underneath the
// next tap coordinate.
scroll.setContentOffset(off, animated: false)
scroll.setContentOffset(off, animated: true)
return true
}
node = cur.superview
@ -429,10 +301,7 @@ enum MutationBridgeImpl {
}
private static func activeKeyWindow(in scene: UIWindowScene) -> UIWindow? {
let windows = scene.windows.filter { window in
!window.isHidden && !String(describing: type(of: window)).contains("PassThroughWindow")
}
return windows.first(where: { $0.isKeyWindow }) ?? windows.max(by: { $0.windowLevel < $1.windowLevel })
scene.windows.first(where: { $0.isKeyWindow }) ?? scene.windows.first
}
}

View File

@ -14,16 +14,12 @@ import Foundation
public final class DebugBridgeManager {
public static let shared = DebugBridgeManager()
/// Register app-owned generated accessors, then start the server. The
/// registration closure is passed in from the consuming app because the
/// DebugBridgeCore package cannot import app-target types. On UIKit apps,
/// call DebugBridgeUIWiring.installAll() before this method so a warm
/// daemon cannot reach uninitialized resolvers during listener startup.
public func start<State>(appState: State, register: (State) -> Void) {
register(appState)
public func start(appState: AppState) {
// 1. Register the canonical AppState struct + accessor wiring.
// AppStateAccessor.register(_:) is generated by gen-accessors-tool.
AppStateAccessor.register(appState)
// Boot only after registration so the first snapshot has a real build
// id, schema hash, and key set.
// 2. Boot the StateServer.
StateServer.shared.start()
// 3. The consuming app installs DebugOverlayWindow separately. See
@ -35,4 +31,19 @@ public final class DebugBridgeManager {
}
}
// Placeholder. gen-accessors-tool emits the real `AppStateAccessor` enum next
// to the app's canonical state struct. Apps that haven't run codegen get a
// stub that registers no accessors (snapshot is empty, restore returns
// missing-key for every key).
@MainActor
public enum AppStateAccessor {
public static var register: (Any) -> Void = { _ in }
}
// Apps declare their canonical state struct; codegen reads it and emits
// AppStateAccessor.register. The app's struct must be `@Observable` and
// must hold all snapshot-eligible state in `@Snapshotable`-marked fields.
@MainActor
public protocol AppState: AnyObject {}
#endif // DEBUG

View File

@ -23,10 +23,6 @@ NS_ASSUME_NONNULL_BEGIN
@interface DebugBridgeTouch : NSObject
/// Enable the in-process accessibility automation tree used by SwiftUI and
/// UIKit. Call once while installing the debug bridge, before /elements.
+ (void)prepareForAutomation;
/// Synthesize a single tap (TouchPhaseBegan + TouchPhaseEnded) at the given
/// window-coordinate point. Returns YES if the touch was delivered (a hit
/// view was found and the event passed through UIApplication.sendEvent).

View File

@ -131,36 +131,6 @@ static BOOL DBT_LoadIOKit(void) {
return _IOKitLoaded;
}
// KIF enables accessibility automation before it asks UIKit/SwiftUI for the
// accessibility tree. Without this switch SwiftUI exposes only its hosting
// shell: /elements has no controls and a synthesized tap can return YES while
// Button.action never fires. Keep this DEBUG-only with the rest of this file.
typedef int (*DBTAXSAutomationEnabledFn)(void);
typedef void (*DBTAXSSetAutomationEnabledFn)(int);
static DBTAXSAutomationEnabledFn _DBTAXSAutomationEnabled;
static DBTAXSSetAutomationEnabledFn _DBTAXSSetAutomationEnabled;
static int _DBTInitialAutomationState = -1;
static void DBT_RestoreAccessibilityAutomation(void) {
if (_DBTAXSSetAutomationEnabled && _DBTInitialAutomationState >= 0) {
_DBTAXSSetAutomationEnabled(_DBTInitialAutomationState);
}
}
static void DBT_EnableAccessibilityAutomation(void) {
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
void *handle = dlopen("/usr/lib/libAccessibility.dylib", RTLD_NOW | RTLD_LOCAL);
if (!handle) return;
_DBTAXSAutomationEnabled = (DBTAXSAutomationEnabledFn)dlsym(handle, "_AXSAutomationEnabled");
_DBTAXSSetAutomationEnabled = (DBTAXSSetAutomationEnabledFn)dlsym(handle, "_AXSSetAutomationEnabled");
if (!_DBTAXSAutomationEnabled || !_DBTAXSSetAutomationEnabled) return;
_DBTInitialAutomationState = _DBTAXSAutomationEnabled();
if (!_DBTInitialAutomationState) _DBTAXSSetAutomationEnabled(1);
atexit(DBT_RestoreAccessibilityAutomation);
});
}
static IOHIDEventRef DBT_IOHIDEventWithTouch(UITouch *touch) CF_RETURNS_RETAINED;
static IOHIDEventRef DBT_IOHIDEventWithTouch(UITouch *touch) {
if (!DBT_LoadIOKit()) return NULL;
@ -207,7 +177,6 @@ static IOHIDEventRef DBT_IOHIDEventWithTouch(UITouch *touch) {
- (void)setGestureView:(UIView *)view;
- (void)_setLocationInWindow:(CGPoint)location resetPrevious:(BOOL)resetPrevious;
- (void)_setIsFirstTouchForView:(BOOL)firstTouchForView;
- (void)_setIsTapToClick:(BOOL)tapToClick;
- (void)_setHidEvent:(IOHIDEventRef)event;
@end
@ -265,10 +234,6 @@ static id DBT_HitTestView(UIWindow *window, CGPoint point) {
@implementation DebugBridgeTouch
+ (void)prepareForAutomation {
DBT_EnableAccessibilityAutomation();
}
+ (BOOL)sendTapAtPoint:(CGPoint)point inWindow:(UIWindow *)window {
if (!window) return NO;
@ -287,17 +252,10 @@ static id DBT_HitTestView(UIWindow *window, CGPoint point) {
[touch setPhase:UITouchPhaseBegan];
if ([touch respondsToSelector:@selector(_setIsFirstTouchForView:)]) {
[touch _setIsFirstTouchForView:YES];
} else if ([touch respondsToSelector:@selector(_setIsTapToClick:)]) {
[touch _setIsTapToClick:YES];
Ivar flagsIvar = class_getInstanceVariable([UITouch class], "_touchFlags");
if (flagsIvar) {
ptrdiff_t offset = ivar_getOffset(flagsIvar);
char *flags = (__bridge void *)touch + offset;
*flags |= (char)0x01;
}
}
[touch setTimestamp:[[NSProcessInfo processInfo] systemUptime]];
if ([touch respondsToSelector:@selector(setGestureView:)]) {
if ([touch respondsToSelector:@selector(setGestureView:)] &&
[hit isKindOfClass:[UIView class]]) {
[touch setGestureView:(UIView *)hit];
}

View File

@ -6,35 +6,22 @@
// the DebugBridge target.
#if DEBUG
import Foundation
import DebugBridgeCore
#if canImport(UIKit)
import DebugBridgeUI
#endif
import DebugBridge
@MainActor
func startGstackDebugBridge<State>(
appState: State,
register: (State) -> Void
) {
func startGstackDebugBridge(appState: AppState) {
// Read --recording flag from launch arguments
let recording = ProcessInfo.processInfo.arguments.contains("--gstack-recording")
// Install the UI resolvers before opening the listener. A warm daemon can
// issue its first request as soon as StateServer starts; it must never see
// the default empty screenshot/elements/mutation handlers.
#if canImport(UIKit)
DebugBridgeUIWiring.installAll()
#endif
// Install accessibility + screenshot + mutation bridges before starting
// the server so the first authenticated request can use them.
ElementsBridge.resolver = { AccessibilityScanner.snapshot() }
ScreenshotBridge.resolver = { SnapshotCapture.capturePNG() }
MutationBridge.resolver = { op, payload in
MutationDispatcher.shared.run(op: op, payload: payload)
}
// Generated typed accessors live in the app target, so pass their register
// function into the package instead of asking DebugBridgeCore to import
// app-owned types.
DebugBridgeManager.shared.start(appState: appState, register: register)
#if canImport(UIKit)
DebugOverlayWindow.shared.install(recording: recording)
#endif
DebugBridgeManager.shared.start(appState: appState, recording: recording)
}
#endif
@ -46,10 +33,7 @@ func startGstackDebugBridge<State>(
//
// init() {
// #if DEBUG
// startGstackDebugBridge(
// appState: appState,
// register: MyAppStateAccessor.register
// )
// startGstackDebugBridge(appState: appState)
// #endif
// }
//

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 preceded by `// @Snapshotable` are emitted.
// @Observable types. Only properties marked with @Snapshotable are emitted.
//
// {{CLASS_NAME}} — the canonical AppState struct name
// {{APP_BUILD_ID}} — bundle short version + git SHA at codegen time
// {{ACCESSOR_HASH}} — sha256 of accessor signatures (snapshot schema fingerprint)
// {{ACCESSORS}} — generated register/read/write blocks per marked field
// {{ACCESSORS}} — generated register/read/write blocks per @Snapshotable field
#if DEBUG
@ -21,16 +21,13 @@ public enum {{CLASS_NAME}}Accessor {
StateServer.shared.register(
buildId: "{{APP_BUILD_ID}}",
accessorHash: "{{ACCESSOR_HASH}}",
atomicRestore: { keys, apply in
// Validate every key + type before mutating any state. Invalid
// input therefore cannot leave a partially restored model.
atomicRestore: { keys in
// Validate every key + type FIRST, then apply in one struct
// assignment so SwiftUI observers see exactly one change.
var snapshot = state.snapshotable
{{VALIDATION_BLOCK}}
// StateServer validates every model first, then invokes the
// same handlers with apply=true for a cross-model commit.
if apply {
state.snapshotable = snapshot
}
// Apply atomically.
state.snapshotable = snapshot
return .ok
}
)

View File

@ -59,20 +59,18 @@ public final class StateServer {
private var writeHandlers: [String: WriteHandler] = [:]
private var typeNames: [String: TypeName] = [:]
// Validated-restore hooks. Every generated model registers one two-phase
// handler. The server validates all models before it lets any model mutate,
// so invalid input can never cause a cross-model partial restore.
// Valid restores apply properties on MainActor; observers may receive one
// change notification per property because arbitrary @Observable models do
// not expose a general single-assignment transaction API.
public typealias AtomicRestoreFn = (JSONDict, Bool) -> RestoreResult
// Atomic-restore hook. Codegen wires this to the canonical AppState struct.
// Restore replaces the entire struct in one assignment so SwiftUI's Combine
// pipeline observes exactly one change notification — true observable
// atomicity. @MainActor alone doesn't guarantee that.
public typealias AtomicRestoreFn = (JSONDict) -> RestoreResult
public enum RestoreResult {
case ok
case missingKey(String)
case typeMismatch(String)
case schemaMismatch(expected: String, got: String)
}
private var atomicRestores: [AtomicRestoreFn] = []
private var atomicRestore: AtomicRestoreFn?
// Snapshot schema hash — written by codegen, stable across builds with
// identical accessor signatures.
@ -111,7 +109,7 @@ public final class StateServer {
public func register(buildId: String, accessorHash: String, atomicRestore: @escaping AtomicRestoreFn) {
self.appBuildId = buildId
self.accessorHash = accessorHash
self.atomicRestores.append(atomicRestore)
self.atomicRestore = atomicRestore
}
public func registerAccessor(key: String, type: String, read: @escaping ReadHandler, write: @escaping WriteHandler) {
@ -286,7 +284,6 @@ public final class StateServer {
"version": "1.0.0",
"build": appBuildId,
"accessor_hash": accessorHash,
"bundle_id": Bundle.main.bundleIdentifier ?? "unknown",
])
return
}
@ -479,36 +476,22 @@ public final class StateServer {
send(connection: connection, status: 400, body: ["error": "missing_keys"])
return
}
guard !atomicRestores.isEmpty else {
guard let restore = atomicRestore else {
send(connection: connection, status: 503, body: ["error": "atomic_restore_not_registered"])
return
}
// Phase one validates every registered model without assignment.
for restore in atomicRestores {
switch restore(keys, false) {
case .ok:
continue
case .missingKey(let k):
send(connection: connection, status: 400, body: ["error": "validation_failed", "key": k, "reason": "missing"])
return
case .typeMismatch(let k):
send(connection: connection, status: 400, body: ["error": "validation_failed", "key": k, "reason": "type-mismatch"])
return
case .schemaMismatch(let expected, let got):
send(connection: connection, status: 409, body: ["error": "schema_mismatch", "expected_hash": expected, "got_hash": got])
return
}
// Validate-then-apply via the codegen-supplied closure. The closure does
// a single struct-assignment so SwiftUI sees one change notification.
switch restore(keys) {
case .ok:
send(connection: connection, status: 200, body: ["ok": true])
case .missingKey(let k):
send(connection: connection, status: 400, body: ["error": "validation_failed", "key": k, "reason": "missing"])
case .typeMismatch(let k):
send(connection: connection, status: 400, body: ["error": "validation_failed", "key": k, "reason": "type-mismatch"])
case .schemaMismatch(let expected, let got):
send(connection: connection, status: 409, body: ["error": "schema_mismatch", "expected_hash": expected, "got_hash": got])
}
// Phase two applies only after every model accepted the immutable input.
// A valid multi-field restore may notify once per property.
for restore in atomicRestores {
guard case .ok = restore(keys, true) else {
send(connection: connection, status: 500, body: ["error": "restore_apply_failed"])
return
}
}
send(connection: connection, status: 200, body: ["ok": true])
}
// MARK: Stubs (real impls live in DebugBridgeManager + UIKit)
@ -539,19 +522,9 @@ public final class StateServer {
// MARK: Response
private func send(connection: NWConnection, status: Int, body: JSONDict) {
let responseStatus: Int
let json: Data
if JSONSerialization.isValidJSONObject(body),
let encoded = try? JSONSerialization.data(withJSONObject: body) {
responseStatus = status
json = encoded
} else {
logger.error("Refusing to send a non-JSON response body")
responseStatus = 500
json = Data("{\"error\":\"response_not_json_serializable\"}".utf8)
}
let json = (try? JSONSerialization.data(withJSONObject: body)) ?? Data("{}".utf8)
let statusText: String
switch responseStatus {
switch status {
case 200: statusText = "OK"
case 400: statusText = "Bad Request"
case 401: statusText = "Unauthorized"
@ -564,7 +537,7 @@ public final class StateServer {
case 503: statusText = "Service Unavailable"
default: statusText = "Status"
}
let header = "HTTP/1.1 \(responseStatus) \(statusText)\r\nContent-Type: application/json\r\nContent-Length: \(json.count)\r\nConnection: close\r\n\r\n"
let header = "HTTP/1.1 \(status) \(statusText)\r\nContent-Type: application/json\r\nContent-Length: \(json.count)\r\nConnection: close\r\n\r\n"
var packet = Data(header.utf8)
packet.append(json)
connection.send(content: packet, completion: .contentProcessed { _ in

View File

@ -806,53 +806,49 @@ After `/ios-qa` is installed in an app, the user may:
1. Add new `@Observable` classes or properties that need accessor coverage.
2. Upgrade gstack to a newer version with hardening fixes.
3. Move the `// @Snapshotable` generator marker comment to a different field.
3. Move the `@Snapshotable` marker to a different field.
This skill regenerates the relevant artifacts in place.
**Templates live in upstream gstack.** The installed
`gstack-ios-qa-regen` launcher resolves its own gstack root and copies only
the supported bridge files from `ios-qa/templates/`. The fork's HTTP-fetch
and wildcard-copy patterns are gone.
**Templates live in upstream gstack.** This skill resolves them from
`~/.claude/skills/gstack/ios-qa/templates/` (or the worktree's
`ios-qa/templates/` when developing gstack itself). The fork's HTTP-fetch
pattern is gone.
## Phase 1: Detect installed version
1. Read `<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_ROOT/VERSION`.
2. Read upstream version from `$GSTACK_HOME/ios-qa/.gstack-version` (or the
value baked into the installed gstack binary).
3. If versions match AND no new `@Observable` classes were added, exit
early with "already up to date".
## Phase 2: Regenerate codegen output
Run the deterministic regenerator once. `--app-source` is the directory the
accessor scanner should inspect; `--bridge-dir` is the local Swift package
that the app links in Debug builds:
Run `gstack-ios-qa-regen` (or the underlying SwiftPM tool directly):
```bash
~/.claude/skills/gstack/bin/gstack-ios-qa-regen \
--app-source "$APP_SOURCE_DIR" \
--bridge-dir "$APP_SOURCE_DIR/DebugBridge"
swift run --package-path "$GSTACK_HOME/ios-qa/scripts/gen-accessors-tool" \
gen-accessors --input "$APP_SOURCE_DIR" --output "$APP_SOURCE_DIR/DebugBridgeGenerated"
```
The command removes only the known obsolete generated files from the former
flat `DebugBridgeGenerated/` layout before emitting the current accessor.
Generation accepts file-scope observable classes and JSON-native scalar,
array, String-keyed dictionary, and Optional field types. It rejects custom
types, implicitly unwrapped Optionals, nested observable classes, and duplicate
snapshot keys before writing a completion marker.
The composite-hash cache key handles whether anything actually needs
regenerating; if Swift version, generator git rev, lockfile, source content,
and platform triple all match the cache, this is a ~50ms no-op.
## Phase 3: Review the generated diff
## Phase 3: Update templated Swift files in place
1. Review changes under `<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.
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).
## Phase 4: Verify
@ -866,6 +862,5 @@ and platform triple all match the cache, this is a ~50ms no-op.
| Symptom | Action |
|---|---|
| Swift compile fails after regen | Revert via `git restore` + AskUserQuestion: surface the compile error |
| Codegen reports an invalid marked declaration | Use a file-scope observable class and a writable instance `var` with an explicit JSON-native type, internal/public setter, and a key unique across models; otherwise remove the `// @Snapshotable` marker. |
| Schema hash unchanged after adding new @Observable | No field has the standalone `// @Snapshotable` marker comment — codegen excludes unmarked state correctly. Add the comment immediately above each field that should be snapshotted. |
| Scanner sees generated bridge sources | Pass the narrow app source directory; the regenerator automatically excludes `DebugBridgeGenerated` and `StateAccessor.swift`. |
| Schema hash unchanged after adding new @Observable | The new class isn't marked `@Snapshotable` — the codegen excludes it correctly. If the user wanted it snapshotted, add the wrapper. |
| `--input` source dir contains test fixtures | gen-accessors scans the input dir recursively; exclude test/ via `--exclude` |

View File

@ -35,53 +35,49 @@ After `/ios-qa` is installed in an app, the user may:
1. Add new `@Observable` classes or properties that need accessor coverage.
2. Upgrade gstack to a newer version with hardening fixes.
3. Move the `// @Snapshotable` generator marker comment to a different field.
3. Move the `@Snapshotable` marker to a different field.
This skill regenerates the relevant artifacts in place.
**Templates live in upstream gstack.** The installed
`gstack-ios-qa-regen` launcher resolves its own gstack root and copies only
the supported bridge files from `ios-qa/templates/`. The fork's HTTP-fetch
and wildcard-copy patterns are gone.
**Templates live in upstream gstack.** This skill resolves them from
`~/.claude/skills/gstack/ios-qa/templates/` (or the worktree's
`ios-qa/templates/` when developing gstack itself). The fork's HTTP-fetch
pattern is gone.
## Phase 1: Detect installed version
1. Read `<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_ROOT/VERSION`.
2. Read upstream version from `$GSTACK_HOME/ios-qa/.gstack-version` (or the
value baked into the installed gstack binary).
3. If versions match AND no new `@Observable` classes were added, exit
early with "already up to date".
## Phase 2: Regenerate codegen output
Run the deterministic regenerator once. `--app-source` is the directory the
accessor scanner should inspect; `--bridge-dir` is the local Swift package
that the app links in Debug builds:
Run `gstack-ios-qa-regen` (or the underlying SwiftPM tool directly):
```bash
~/.claude/skills/gstack/bin/gstack-ios-qa-regen \
--app-source "$APP_SOURCE_DIR" \
--bridge-dir "$APP_SOURCE_DIR/DebugBridge"
swift run --package-path "$GSTACK_HOME/ios-qa/scripts/gen-accessors-tool" \
gen-accessors --input "$APP_SOURCE_DIR" --output "$APP_SOURCE_DIR/DebugBridgeGenerated"
```
The command removes only the known obsolete generated files from the former
flat `DebugBridgeGenerated/` layout before emitting the current accessor.
Generation accepts file-scope observable classes and JSON-native scalar,
array, String-keyed dictionary, and Optional field types. It rejects custom
types, implicitly unwrapped Optionals, nested observable classes, and duplicate
snapshot keys before writing a completion marker.
The composite-hash cache key handles whether anything actually needs
regenerating; if Swift version, generator git rev, lockfile, source content,
and platform triple all match the cache, this is a ~50ms no-op.
## Phase 3: Review the generated diff
## Phase 3: Update templated Swift files in place
1. Review changes under `<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.
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).
## Phase 4: Verify
@ -95,6 +91,5 @@ and platform triple all match the cache, this is a ~50ms no-op.
| Symptom | Action |
|---|---|
| Swift compile fails after regen | Revert via `git restore` + AskUserQuestion: surface the compile error |
| Codegen reports an invalid marked declaration | Use a file-scope observable class and a writable instance `var` with an explicit JSON-native type, internal/public setter, and a key unique across models; otherwise remove the `// @Snapshotable` marker. |
| Schema hash unchanged after adding new @Observable | No field has the standalone `// @Snapshotable` marker comment — codegen excludes unmarked state correctly. Add the comment immediately above each field that should be snapshotted. |
| Scanner sees generated bridge sources | Pass the narrow app source directory; the regenerator automatically excludes `DebugBridgeGenerated` and `StateAccessor.swift`. |
| Schema hash unchanged after adding new @Observable | The new class isn't marked `@Snapshotable` — the codegen excludes it correctly. If the user wanted it snapshotted, add the wrapper. |
| `--input` source dir contains test fixtures | gen-accessors scans the input dir recursively; exclude test/ via `--exclude` |

View File

@ -26,8 +26,31 @@ import { join } from 'path';
const ROOT = join(import.meta.dir, '..');
const FIXTURE_PATH = join(ROOT, 'test/fixtures/ios-qa/FixtureApp');
const TEMPLATES_PATH = join(ROOT, 'ios-qa/templates');
const GEN_ACCESSORS_PACKAGE = join(ROOT, 'ios-qa/scripts/gen-accessors-tool/Package.swift');
// Parity: canonical Obj-C touch templates must match the fixture's working
// copy. The fixture is the only place the .m / .h are exercised end-to-end
// on a real device, so any divergence means consuming apps would ship a
// stale, untested version of the SwiftUI hit-test fix.
describe('template ↔ fixture parity', () => {
test('DebugBridgeTouch.h.template matches fixture include', () => {
const tmpl = readFileSync(join(TEMPLATES_PATH, 'DebugBridgeTouch.h.template'), 'utf-8');
const fixture = readFileSync(
join(FIXTURE_PATH, 'Sources/DebugBridgeTouch/include/DebugBridgeTouch.h'),
'utf-8',
);
expect(tmpl).toBe(fixture);
});
test('DebugBridgeTouch.m.template matches fixture .m', () => {
const tmpl = readFileSync(join(TEMPLATES_PATH, 'DebugBridgeTouch.m.template'), 'utf-8');
const fixture = readFileSync(
join(FIXTURE_PATH, 'Sources/DebugBridgeTouch/DebugBridgeTouch.m'),
'utf-8',
);
expect(tmpl).toBe(fixture);
});
test('Package.swift.template declares all 3 DebugBridge targets', () => {
const tmpl = readFileSync(join(TEMPLATES_PATH, 'Package.swift.template'), 'utf-8');
// Each target must be present as a library product AND a target definition.
@ -39,6 +62,19 @@ describe('template ↔ fixture parity', () => {
expect(tmpl).toMatch(/name:\s*"DebugBridgeUI"[\s\S]*?dependencies:\s*\["DebugBridgeCore",\s*"DebugBridgeTouch"\]/);
});
test('generated Swift packages only reference shipped test directories', () => {
const genAccessorsPackage = readFileSync(GEN_ACCESSORS_PACKAGE, 'utf-8');
const debugBridgePackage = readFileSync(join(TEMPLATES_PATH, 'Package.swift.template'), 'utf-8');
expect(genAccessorsPackage).not.toContain('Tests/GenAccessorsTests');
expect(debugBridgePackage).not.toContain('Tests/DebugBridgeCoreTests');
});
test('Package.swift.template keeps swift-tools-version on the first line', () => {
const tmpl = readFileSync(join(TEMPLATES_PATH, 'Package.swift.template'), 'utf-8');
expect(tmpl.split(/\r?\n/, 1)[0]).toBe('// swift-tools-version:5.9');
});
test('KIF-derived touch source preserves Apache-2.0 attribution', () => {
const implementation = readFileSync(join(TEMPLATES_PATH, 'DebugBridgeTouch.m.template'), 'utf-8');
expect(implementation).toContain('Copyright 2011-2016 Square, Inc. and KIF contributors.');
@ -146,18 +182,9 @@ describeIfSwift('swift build invariants', () => {
const fixtureDir = join(workDir, 'FixtureApp');
try {
cpSync(FIXTURE_PATH, fixtureDir, { recursive: true });
writeFileSync(
join(fixtureDir, 'Package.swift'),
readFileSync(join(TEMPLATES_PATH, 'Package.swift.template'), 'utf-8'),
);
writeFileSync(
join(fixtureDir, 'Sources/DebugBridgeTouch/DebugBridgeTouch.m'),
readFileSync(join(TEMPLATES_PATH, 'DebugBridgeTouch.m.template'), 'utf-8'),
);
writeFileSync(
join(fixtureDir, 'Sources/DebugBridgeTouch/include/DebugBridgeTouch.h'),
readFileSync(join(TEMPLATES_PATH, 'DebugBridgeTouch.h.template'), 'utf-8'),
);
writeFileSync(join(fixtureDir, 'Package.swift'), readFileSync(join(TEMPLATES_PATH, 'Package.swift.template')));
writeFileSync(join(fixtureDir, 'Sources/DebugBridgeTouch/DebugBridgeTouch.m'), readFileSync(join(TEMPLATES_PATH, 'DebugBridgeTouch.m.template')));
writeFileSync(join(fixtureDir, 'Sources/DebugBridgeTouch/include/DebugBridgeTouch.h'), readFileSync(join(TEMPLATES_PATH, 'DebugBridgeTouch.h.template')));
expect(spawnSync('xcodegen', [
'generate', '--spec', join(fixtureDir, 'project.yml'),
'--project', fixtureDir, '--project-root', fixtureDir,

View File

@ -171,12 +171,9 @@ describe('ios-qa E2E (no-device path)', () => {
writeFileSync(join(srcDir, 'AppState.swift'), `
@Observable
class AppState {
// @Snapshotable
var isLoggedIn: Bool = false
// @Snapshotable
var username: String = ""
// @Snapshotable
var counter: Int = 0
@Snapshotable var isLoggedIn: Bool = false
@Snapshotable var username: String = ""
@Snapshotable var counter: Int = 0
var ephemeralCache: [String: Any] = [:]
}
`);
@ -192,8 +189,7 @@ class AppState {
expect(result.specs).toHaveLength(1);
expect(result.specs[0]!.fields.map(f => f.name).sort()).toEqual(['counter', 'isLoggedIn', 'username']);
const generatedSwift = readFileSync(result.outputPath, 'utf-8');
expect(generatedSwift).toContain('enum AppStateAccessor');
expect(generatedSwift).not.toContain('public enum AppStateAccessor');
expect(generatedSwift).toContain('public enum AppStateAccessor');
expect(generatedSwift).toContain('key: "isLoggedIn"');
expect(generatedSwift).toContain('key: "counter"');
expect(generatedSwift).not.toContain('key: "ephemeralCache"'); // not marked @Snapshotable