mirror of
https://github.com/garrytan/gstack.git
synced 2026-07-18 13:37:23 +02:00
Merge pull request #2264 from time-attack/time-attack/wave-test-infra-isolated-review
fix: harden test infrastructure and iOS device QA
This commit is contained in:
@@ -92,6 +92,7 @@ Companion CLIs (run on the Mac that's plugged into the device):
|
||||
|---------|-------------|
|
||||
| `gstack-ios-qa-daemon` | Mac-side broker. Loopback by default; `--tailnet` adds a Tailscale-facing listener with capability tiers and audit logging. |
|
||||
| `gstack-ios-qa-mint` | Owner-grant CLI for the tailnet allowlist (`grant`/`revoke`/`list`). |
|
||||
| `gstack-ios-qa-regen` | Regenerate the canonical local DebugBridge package and typed accessors (`--app-source` / `--bridge-dir`). |
|
||||
|
||||
End-to-end walkthrough: [docs/howto-ios-testing-with-gstack.md](docs/howto-ios-testing-with-gstack.md).
|
||||
|
||||
|
||||
@@ -245,6 +245,7 @@ Beyond the slash-command skills, gstack ships standalone CLIs for workflows that
|
||||
| `gstack-taste-update` | **Design taste learning** — writes approvals and rejections from `/design-shotgun` into a persistent per-project taste profile. Decays 5%/week. Feeds back into future variant generation so the system learns what you actually pick. |
|
||||
| `gstack-ios-qa-daemon` | **iOS QA daemon** — Mac-side broker between an agent and a connected iPhone over USB CoreDevice. Loopback by default; `--tailnet` opens a Tailscale-facing listener with identity-gated capability tiers. Single-instance via flock on `~/.gstack/ios-qa-daemon.pid`. See [docs/howto-ios-testing-with-gstack.md](docs/howto-ios-testing-with-gstack.md). |
|
||||
| `gstack-ios-qa-mint` | **iOS allowlist manager** — owner-grant CLI for the tailnet allowlist. `grant`/`revoke`/`list` against `~/.gstack/ios-qa-allowlist.json` (mode 0600). Remote agents never auto-allowlist; this is the explicit-intent path. |
|
||||
| `gstack-ios-qa-regen` | **iOS bridge regenerator** — deterministically installs the canonical DebugBridge package, generates typed state accessors, and records the installed gstack version. Safe to rerun after source changes or upgrades. |
|
||||
|
||||
### Continuous checkpoint mode (opt-in, local by default)
|
||||
|
||||
|
||||
Executable
+154
@@ -0,0 +1,154 @@
|
||||
#!/usr/bin/env bash
|
||||
# gstack-ios-qa-regen — deterministically regenerate the iOS DebugBridge
|
||||
# package and the app-owned typed state accessors.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage: gstack-ios-qa-regen --app-source <dir> --bridge-dir <dir>
|
||||
|
||||
--app-source Swift source tree to scan for @Observable state
|
||||
--bridge-dir Destination for the generated local DebugBridge package
|
||||
EOF
|
||||
}
|
||||
|
||||
APP_SOURCE=""
|
||||
BRIDGE_DIR=""
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--app-source)
|
||||
[[ $# -ge 2 ]] || { echo "gstack-ios-qa-regen: --app-source requires a value" >&2; exit 2; }
|
||||
APP_SOURCE="$2"
|
||||
shift 2
|
||||
;;
|
||||
--bridge-dir)
|
||||
[[ $# -ge 2 ]] || { echo "gstack-ios-qa-regen: --bridge-dir requires a value" >&2; exit 2; }
|
||||
BRIDGE_DIR="$2"
|
||||
shift 2
|
||||
;;
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "gstack-ios-qa-regen: unknown argument: $1" >&2
|
||||
usage >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ -z "$APP_SOURCE" || -z "$BRIDGE_DIR" ]]; then
|
||||
echo "gstack-ios-qa-regen: both --app-source and --bridge-dir are required" >&2
|
||||
usage >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
if [[ ! -d "$APP_SOURCE" ]]; then
|
||||
echo "gstack-ios-qa-regen: app source directory not found: $APP_SOURCE" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! command -v bun >/dev/null 2>&1; then
|
||||
echo "gstack-ios-qa-regen: bun runtime not on PATH — install from https://bun.sh" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
GSTACK_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
TEMPLATE_DIR="$GSTACK_ROOT/ios-qa/templates"
|
||||
GENERATOR="$GSTACK_ROOT/ios-qa/scripts/gen-accessors.ts"
|
||||
VERSION_FILE="$GSTACK_ROOT/VERSION"
|
||||
GENERATED_DIR="$APP_SOURCE/DebugBridgeGenerated"
|
||||
|
||||
for required in "$GENERATOR" "$VERSION_FILE"; do
|
||||
if [[ ! -f "$required" ]]; then
|
||||
echo "gstack-ios-qa-regen: missing required gstack file: $required" >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
TMP_FILE=""
|
||||
cleanup() {
|
||||
if [[ -n "$TMP_FILE" ]]; then
|
||||
rm -f "$TMP_FILE"
|
||||
fi
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
# Copy through a sibling temporary file so interruption never leaves a
|
||||
# truncated generated source. Preserve an unchanged destination byte-for-byte
|
||||
# and metadata-for-metadata on repeated runs.
|
||||
install_file() {
|
||||
local source="$1"
|
||||
local destination="$2"
|
||||
|
||||
if [[ ! -f "$source" ]]; then
|
||||
echo "gstack-ios-qa-regen: missing template: $source" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ -f "$destination" ]] && cmp -s "$source" "$destination"; then
|
||||
return
|
||||
fi
|
||||
|
||||
mkdir -p "$(dirname "$destination")"
|
||||
TMP_FILE="${destination}.tmp.$$"
|
||||
cp "$source" "$TMP_FILE"
|
||||
mv "$TMP_FILE" "$destination"
|
||||
TMP_FILE=""
|
||||
}
|
||||
|
||||
# Invalidate the completion marker before changing any package source. A
|
||||
# failed or interrupted regeneration must never look current to ios-sync.
|
||||
mkdir -p "$GENERATED_DIR"
|
||||
rm -f -- "$GENERATED_DIR/.gstack-version"
|
||||
|
||||
# This is intentionally an allowlist, not a template glob. Wiring belongs to
|
||||
# the consuming app and StateAccessor.swift is emitted by the parser below.
|
||||
install_file "$TEMPLATE_DIR/Package.swift.template" \
|
||||
"$BRIDGE_DIR/Package.swift"
|
||||
install_file "$TEMPLATE_DIR/StateServer.swift.template" \
|
||||
"$BRIDGE_DIR/Sources/DebugBridgeCore/StateServer.swift"
|
||||
install_file "$TEMPLATE_DIR/DebugBridgeManager.swift.template" \
|
||||
"$BRIDGE_DIR/Sources/DebugBridgeCore/DebugBridgeManager.swift"
|
||||
install_file "$TEMPLATE_DIR/Bridges.swift.template" \
|
||||
"$BRIDGE_DIR/Sources/DebugBridgeUI/Bridges.swift"
|
||||
install_file "$TEMPLATE_DIR/DebugOverlay.swift.template" \
|
||||
"$BRIDGE_DIR/Sources/DebugBridgeUI/DebugOverlay.swift"
|
||||
install_file "$TEMPLATE_DIR/DebugBridgeTouch.m.template" \
|
||||
"$BRIDGE_DIR/Sources/DebugBridgeTouch/DebugBridgeTouch.m"
|
||||
install_file "$TEMPLATE_DIR/DebugBridgeTouch.h.template" \
|
||||
"$BRIDGE_DIR/Sources/DebugBridgeTouch/include/DebugBridgeTouch.h"
|
||||
|
||||
# Older ios-sync versions copied the entire template set flat into the app's
|
||||
# generated-source directory. Those files can shadow the package modules or
|
||||
# make Xcode compile two harness implementations. Remove only the explicit
|
||||
# obsolete generated paths; handwritten app sources are never touched.
|
||||
for obsolete in \
|
||||
"$BRIDGE_DIR/DebugBridgeWiring.swift" \
|
||||
"$BRIDGE_DIR/StateAccessor.swift" \
|
||||
"$GENERATED_DIR/Package.swift" \
|
||||
"$GENERATED_DIR/StateServer.swift" \
|
||||
"$GENERATED_DIR/DebugBridgeManager.swift" \
|
||||
"$GENERATED_DIR/Bridges.swift" \
|
||||
"$GENERATED_DIR/DebugOverlay.swift" \
|
||||
"$GENERATED_DIR/DebugBridgeTouch.m" \
|
||||
"$GENERATED_DIR/DebugBridgeTouch.h" \
|
||||
"$GENERATED_DIR/DebugBridgeWiring.swift"
|
||||
do
|
||||
if [[ -f "$obsolete" || -L "$obsolete" ]]; then
|
||||
rm -f -- "$obsolete"
|
||||
echo "gstack-ios-qa-regen: removed obsolete generated file $obsolete"
|
||||
fi
|
||||
done
|
||||
|
||||
bun run "$GENERATOR" --input "$APP_SOURCE" --output "$GENERATED_DIR"
|
||||
|
||||
# Stamp only after successful accessor generation. ios-sync uses this marker
|
||||
# to distinguish a complete current install from an interrupted regeneration.
|
||||
install_file "$VERSION_FILE" "$GENERATED_DIR/.gstack-version"
|
||||
|
||||
echo "gstack-ios-qa-regen: bridge package ready at $BRIDGE_DIR"
|
||||
echo "gstack-ios-qa-regen: accessors ready at $GENERATED_DIR/StateAccessor.swift"
|
||||
@@ -25,26 +25,14 @@ fi
|
||||
# Normalize: lowercase for case-insensitive SQL matching
|
||||
CMD_LOWER=$(printf '%s' "$CMD" | tr '[:upper:]' '[:lower:]')
|
||||
|
||||
# --- Check for safe exceptions (rm -rf of build artifacts) ---
|
||||
if printf '%s' "$CMD" | grep -qE 'rm\s+(-[a-zA-Z]*r[a-zA-Z]*\s+|--recursive\s+)' 2>/dev/null; then
|
||||
SAFE_ONLY=true
|
||||
RM_ARGS=$(printf '%s' "$CMD" | sed -E 's/.*rm[[:space:]]+(-[a-zA-Z]+[[:space:]]+)*//;s/--recursive[[:space:]]*//')
|
||||
for target in $RM_ARGS; do
|
||||
case "$target" in
|
||||
*/node_modules|node_modules|*/\.next|\.next|*/dist|dist|*/__pycache__|__pycache__|*/\.cache|\.cache|*/build|build|*/\.turbo|\.turbo|*/coverage|coverage)
|
||||
;; # safe target
|
||||
-*)
|
||||
;; # flag, skip
|
||||
*)
|
||||
SAFE_ONLY=false
|
||||
break
|
||||
;;
|
||||
esac
|
||||
done
|
||||
if [ "$SAFE_ONLY" = true ]; then
|
||||
echo '{}'
|
||||
exit 0
|
||||
fi
|
||||
# --- Check for safe exceptions (one standalone rm of build artifacts) ---
|
||||
# Match the complete command. Parsing only the last rm is unsafe because shell
|
||||
# syntax or comments can hide an earlier destructive command, for example:
|
||||
# rm -rf / # rm -rf node_modules
|
||||
# Unknown syntax fails closed and falls through to the destructive checks.
|
||||
if printf '%s' "$CMD" | grep -qE '^[[:space:]]*rm[[:space:]]+(-[a-zA-Z]*r[a-zA-Z]*[[:space:]]+|--recursive[[:space:]]+)(([^[:space:];&|#]*/)?(node_modules|\.next|dist|__pycache__|\.cache|build|\.turbo|coverage)[[:space:]]*)+$' 2>/dev/null; then
|
||||
echo '{}'
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# --- Destructive pattern checks ---
|
||||
|
||||
@@ -9,7 +9,7 @@ Everything below has been verified end-to-end on a real iPhone 17 Pro Max runnin
|
||||
- macOS with Xcode 16.0+ installed (`xcrun devicectl --version` must succeed). Xcode 16 ships the CoreDevice tunnel `devicectl` uses to reach the device over USB.
|
||||
- A real iPhone running iOS 16 or later. Unlocked, paired with your Mac, with **Developer Mode** enabled in Settings → Privacy & Security.
|
||||
- An Apple developer team — the free personal team works fine for live-device debug deploys. You'll need the team ID (e.g. `623FYQ2M88`), not the certificate ID. Find it in Xcode → Settings → Accounts → your Apple ID → team list. The setup signs the app for your device on first deploy via `-allowProvisioningUpdates -allowProvisioningDeviceRegistration`.
|
||||
- gstack installed (`./setup` complete; `bin/gstack-ios-qa-daemon` must be on disk and executable).
|
||||
- gstack installed (`./setup` complete; `gstack-ios-qa-regen` and `gstack-ios-qa-daemon` must be on PATH).
|
||||
- Bun runtime on PATH (`bun --version`). The Mac-side daemon is a bun process.
|
||||
|
||||
For the optional remote-agent (Tailscale) mode, you'll additionally need Tailscale installed on the Mac with `/var/run/tailscale.sock` readable.
|
||||
@@ -30,28 +30,49 @@ For the optional remote-agent (Tailscale) mode, you'll additionally need Tailsca
|
||||
|
||||
The iOS `StateServer` is loopback-only **always**, even in remote mode. Identity validation happens Mac-side because the iPhone has no way to validate a Tailscale identity.
|
||||
|
||||
## Step 1: Add the DebugBridge templates to your iOS app
|
||||
## Step 1: Generate the DebugBridge package
|
||||
|
||||
The templates live at `~/.claude/skills/gstack/ios-qa/templates/` after `./setup`. The fastest install is to invoke the `/ios-qa` skill in Claude Code from your app's root — it reads your Swift source, codegens typed `@Observable` state accessors, and lays down the templates with your bundle ID. Or do it by hand:
|
||||
Run `/ios-qa` from the app root, or invoke the same deterministic regenerator directly:
|
||||
|
||||
1. Copy these into a `DebugBridge/` SPM package inside your app workspace:
|
||||
- `Sources/DebugBridgeCore/StateServer.swift` (from `StateServer.swift.template`)
|
||||
- `Sources/DebugBridgeCore/DebugBridgeManager.swift` (from `DebugBridgeManager.swift.template`)
|
||||
- `Sources/DebugBridgeTouch/DebugBridgeTouch.m` + `Sources/DebugBridgeTouch/include/DebugBridgeTouch.h` (from the two `.template` files)
|
||||
- `Sources/DebugBridgeUI/Bridges.swift` (from `Bridges.swift.template`)
|
||||
- `Sources/DebugBridgeUI/DebugOverlay.swift` (from `DebugOverlay.swift.template`)
|
||||
- `Package.swift` (from `Package.swift.template`)
|
||||
2. Add the package as a local dependency of your app. Depend on the `DebugBridgeUI` product with `condition: .when(configuration: .debug)`. `DebugBridgeCore` and `DebugBridgeTouch` come in transitively.
|
||||
3. In your `@main` App init, gate the wiring on `#if DEBUG`:
|
||||
```bash
|
||||
gstack-ios-qa-regen \
|
||||
--app-source "$PWD/Sources/YourApp" \
|
||||
--bridge-dir "$PWD/DebugBridge"
|
||||
```
|
||||
|
||||
The command copies an explicit allowlist of canonical templates into the local
|
||||
`DebugBridge/` Swift package, generates
|
||||
`DebugBridgeGenerated/StateAccessor.swift`, and writes the installed version to
|
||||
`DebugBridgeGenerated/.gstack-version`. It excludes generated output from its
|
||||
own schema hash, so rerunning it with unchanged source is a fast, byte-stable
|
||||
cache hit. It also removes the explicit legacy generated-file set from older
|
||||
flat harness layouts so stale bridge sources cannot shadow the package.
|
||||
|
||||
1. Add `DebugBridge/` as a local package dependency. Depend on the
|
||||
`DebugBridgeUI` product only in Debug configuration; `DebugBridgeCore` and
|
||||
`DebugBridgeTouch` come in transitively.
|
||||
2. Add `DebugBridgeGenerated/StateAccessor.swift` to the app target.
|
||||
3. In your `@main` App init, install the UIKit resolvers before starting the
|
||||
server, then register the generated accessor. Replace the example
|
||||
state/accessor names with the type the generator found:
|
||||
|
||||
```swift
|
||||
#if DEBUG
|
||||
import DebugBridgeCore
|
||||
StateServer.shared.start()
|
||||
#if canImport(UIKit)
|
||||
import DebugBridgeUI
|
||||
#endif
|
||||
#endif
|
||||
|
||||
// Inside App.init(), after appState is initialized:
|
||||
#if DEBUG
|
||||
#if canImport(UIKit)
|
||||
DebugBridgeUIWiring.installAll()
|
||||
#endif
|
||||
DebugBridgeManager.shared.start(
|
||||
appState: appState,
|
||||
register: AppStateAccessor.register
|
||||
)
|
||||
#endif
|
||||
```
|
||||
|
||||
@@ -109,7 +130,16 @@ GSTACK_IOS_TARGET_BUNDLE_ID=com.yourorg.yourapp
|
||||
GSTACK_IOS_DAEMON_PORT=9099 # loopback listener port; default 9099
|
||||
```
|
||||
|
||||
If `GSTACK_IOS_TARGET_UDID` is unset, the daemon picks the first paired connected device.
|
||||
If `GSTACK_IOS_TARGET_UDID` is unset, the daemon picks the best paired,
|
||||
available iPhone.
|
||||
Automatic selection is restricted to available iPhones and prefers a wired
|
||||
phone. The daemon keeps a healthy rotated tunnel, then invalidates and
|
||||
rebootstraps once on an app-relaunch 401 or recoverable CoreDevice connection
|
||||
failure.
|
||||
If a newly started daemon reaches an already-running target whose one-use boot
|
||||
token was consumed by an earlier daemon, it verifies the bundle owner, force
|
||||
relaunches that target once, waits for a fresh token, verifies ownership again,
|
||||
and rotates normally.
|
||||
|
||||
## Step 4: Drive the device
|
||||
|
||||
@@ -123,15 +153,39 @@ Once the daemon is running, you have an HTTP surface at `http://127.0.0.1:9099`
|
||||
| `POST /session/release` | Release the lock. | bearer + session |
|
||||
| `GET /screenshot` | Capture a PNG of the active window. Returns `{png_base64: "..."}`. | bearer |
|
||||
| `GET /elements` | Accessibility-tree snapshot. | bearer |
|
||||
| `GET /state/snapshot` | Dump every `@Snapshotable` field as JSON. | bearer |
|
||||
| `POST /state/restore` | Atomically restore a full snapshot. | bearer + session, mutate tier |
|
||||
| `GET /state/snapshot` | Dump every `// @Snapshotable` field as JSON. | bearer |
|
||||
| `POST /state/restore` | Validate the full snapshot, then restore it on MainActor. | bearer + session, mutate tier |
|
||||
| `POST /tap` `{x,y}` | Synthesize a real UITouch at window coordinates. SwiftUI Buttons fire. | bearer + session, interact tier |
|
||||
| `POST /swipe` `{from_x,from_y,to_x,to_y}` | Scroll the nearest enclosing UIScrollView. | bearer + session, interact tier |
|
||||
| `POST /type` `{text}` | Set text on the current first responder. | bearer + session, interact tier |
|
||||
|
||||
Mutating requests require both an `Authorization: Bearer <token>` header AND an `X-Session-Id` header. Read endpoints (`/screenshot`, `/elements`, `GET /state/*`) only need the bearer.
|
||||
|
||||
The state snapshot is opt-in per field via a `@Snapshotable` property wrapper on your canonical state struct. Fields you don't annotate never appear in the snapshot, which keeps tokens, PII, and auth state out of recorded fixtures by default.
|
||||
The state snapshot is opt-in per field via a standalone generator marker
|
||||
comment immediately above a property. It is intentionally not a property
|
||||
wrapper, so it compiles cleanly with Observation:
|
||||
|
||||
```swift
|
||||
@Observable
|
||||
final class AppState {
|
||||
// @Snapshotable
|
||||
var username: String = ""
|
||||
|
||||
var authToken: String = "" // never exported
|
||||
}
|
||||
```
|
||||
|
||||
Unmarked fields never appear in the snapshot, which keeps tokens, PII, and
|
||||
auth state out of recorded fixtures by default. A marked field must be a
|
||||
writable instance `var` on a file-scope observable class, with an explicit type
|
||||
and an internal or public setter. Supported snapshot types are JSON-native
|
||||
scalars (`String`, `Bool`, signed/unsigned integer widths, `Float`, `Double`,
|
||||
`CGFloat`), arrays, String-keyed dictionaries, and Optional compositions of
|
||||
those types. Snapshot keys must be unique across observable classes. The
|
||||
generator reports and stops on invalid declarations, custom values, implicitly
|
||||
unwrapped Optionals, nested observable classes, or duplicate keys instead of
|
||||
emitting broken or lossy Swift. Restore uses two phases: every model validates
|
||||
the complete input first, and only then are assignments applied on MainActor.
|
||||
|
||||
## Step 5: Make remote agents work (optional)
|
||||
|
||||
|
||||
+2
-3
@@ -821,9 +821,8 @@ Each item is reverted only after AskUserQuestion confirmation:
|
||||
1. The `DebugBridge` SPM target from `Package.swift`.
|
||||
2. The `#if DEBUG` block in the app's `@main` entry that calls
|
||||
`DebugBridgeManager.shared.start()`.
|
||||
3. Any `@Snapshotable` property wrappers on the canonical app state struct
|
||||
(the codegen-detection markers — the wrapper file lives inside
|
||||
DebugBridge so removing the SPM dep removes the wrapper too).
|
||||
3. Any standalone `// @Snapshotable` generator marker comments on the
|
||||
canonical app state class.
|
||||
4. Generated `StateAccessor.swift` files anywhere under the app source.
|
||||
5. The `gstack-ios-qa.token` file under `NSTemporaryDirectory()` on the
|
||||
device (best-effort — only works if device is connected when /ios-clean
|
||||
|
||||
@@ -50,9 +50,8 @@ Each item is reverted only after AskUserQuestion confirmation:
|
||||
1. The `DebugBridge` SPM target from `Package.swift`.
|
||||
2. The `#if DEBUG` block in the app's `@main` entry that calls
|
||||
`DebugBridgeManager.shared.start()`.
|
||||
3. Any `@Snapshotable` property wrappers on the canonical app state struct
|
||||
(the codegen-detection markers — the wrapper file lives inside
|
||||
DebugBridge so removing the SPM dep removes the wrapper too).
|
||||
3. Any standalone `// @Snapshotable` generator marker comments on the
|
||||
canonical app state class.
|
||||
4. Generated `StateAccessor.swift` files anywhere under the app source.
|
||||
5. The `gstack-ios-qa.token` file under `NSTemporaryDirectory()` on the
|
||||
device (best-effort — only works if device is connected when /ios-clean
|
||||
|
||||
+39
-15
@@ -869,17 +869,33 @@ fi
|
||||
## Phase 1: Read source, plan codegen
|
||||
|
||||
1. Walk the app source (passed as `--source <dir>`) and identify all `@Observable`
|
||||
classes. Note any property marked with the `@Snapshotable` wrapper — those
|
||||
are the snapshot-eligible fields.
|
||||
2. Run `swift run --package-path $GSTACK_HOME/ios-qa/scripts/gen-accessors-tool gen-accessors --input <source-dir>`.
|
||||
First invocation builds the swift-syntax dependency tree (cold: 2-5 min).
|
||||
Subsequent runs are content-hash-cached and finish in ~50ms.
|
||||
3. Show the user the accessor list and ask whether to install the DebugBridge
|
||||
classes. Note any property immediately preceded by the generator marker
|
||||
comment `// @Snapshotable` — those are the snapshot-eligible fields. The
|
||||
marker is a comment so it composes with the `@Observable` macro. Each
|
||||
marked field must belong to a file-scope observable class and be a writable
|
||||
instance `var` with an explicit type and an internal or public setter.
|
||||
Snapshot types are JSON-native scalars (`String`, `Bool`, integer widths,
|
||||
`Float`, `Double`, `CGFloat`), arrays, String-keyed dictionaries, and their
|
||||
Optional compositions. Keys must be unique across observable classes.
|
||||
Codegen stops with a source diagnostic instead of emitting a broken or
|
||||
lossy harness when any of these constraints is violated.
|
||||
2. Show the user the accessor list and ask whether to install the DebugBridge
|
||||
SPM dependency into their `Package.swift` (one AskUserQuestion).
|
||||
|
||||
## Phase 2: Bootstrap the device bridge
|
||||
|
||||
1. Add the `DebugBridge` SPM dependency to the app's `Package.swift`. The package
|
||||
1. Generate the canonical local bridge package, typed accessors, and installed
|
||||
version marker with one deterministic command:
|
||||
```bash
|
||||
~/.claude/skills/gstack/bin/gstack-ios-qa-regen \
|
||||
--app-source "<source-dir>" \
|
||||
--bridge-dir "<source-dir>/DebugBridge"
|
||||
```
|
||||
The regenerator also removes the explicit obsolete flat-file set created by
|
||||
older ios-sync versions, preventing a stale second harness from remaining
|
||||
in the app target.
|
||||
2. Add the generated `DebugBridge` local SPM dependency to the app's
|
||||
`Package.swift`. The package
|
||||
ships three Debug-config-only library products:
|
||||
- `DebugBridgeCore` (Swift, cross-platform) — StateServer + bridge protocols.
|
||||
- `DebugBridgeTouch` (Objective-C, iOS-only) — KIF-derived in-process touch
|
||||
@@ -889,27 +905,35 @@ fi
|
||||
The app target depends on `DebugBridgeUI` with `.when(configuration: .debug)`
|
||||
(transitively pulls in Core + Touch). Release builds refuse to link these
|
||||
targets.
|
||||
2. Wire the bridges from the `@main` App init, gated on `#if DEBUG`:
|
||||
3. Wire the bridges from the `@main` App init, gated on `#if DEBUG`:
|
||||
```swift
|
||||
#if DEBUG
|
||||
import DebugBridgeCore
|
||||
StateServer.shared.start()
|
||||
#if canImport(UIKit)
|
||||
import DebugBridgeUI
|
||||
// Install resolvers before StateServer opens its listener.
|
||||
DebugBridgeUIWiring.installAll()
|
||||
#endif
|
||||
// Replace AppState/AppStateAccessor with the type discovered in Phase 1.
|
||||
DebugBridgeManager.shared.start(
|
||||
appState: appState,
|
||||
register: AppStateAccessor.register
|
||||
)
|
||||
#endif
|
||||
```
|
||||
3. Build + deploy to the device with `xcodebuild -scheme <SchemeName>
|
||||
4. Build + deploy to the device with `xcodebuild -scheme <SchemeName>
|
||||
-destination 'platform=iOS,id=<UDID>' build install`.
|
||||
4. Launch via `devicectl device process launch --device <UDID> --console <bundle-id>`.
|
||||
5. Launch via `devicectl device process launch --device <UDID> --console <bundle-id>`.
|
||||
Capture the boot token printed to `os_log` on first run.
|
||||
5. Spawn the Mac-side daemon (on-demand) — `gstack-ios-qa-daemon`. Daemon
|
||||
6. Spawn the Mac-side daemon (on-demand) — `gstack-ios-qa-daemon`. Daemon
|
||||
acquires an exclusive flock on `~/.gstack/ios-qa-daemon.pid`. If another
|
||||
daemon is alive, the second invocation discovers its port and connects.
|
||||
6. Daemon immediately calls `POST /auth/rotate` on the iOS StateServer with a
|
||||
7. Daemon immediately calls `POST /auth/rotate` on the iOS StateServer with a
|
||||
fresh in-memory-only token. The boot token becomes useless ~5s later.
|
||||
Anything scraping `os_log` past this point sees a dead credential.
|
||||
If a fresh daemon finds the app running after another daemon consumed that
|
||||
one-use token, it verifies the bundle owner, relaunches the target once,
|
||||
waits for the new token, verifies ownership again, and then rotates.
|
||||
|
||||
## Phase 3: Vision-driven agent loop
|
||||
|
||||
@@ -917,7 +941,7 @@ Each iteration:
|
||||
|
||||
1. `GET /screenshot` (via daemon) → save PNG.
|
||||
2. `GET /elements` → accessibility tree.
|
||||
3. `GET /state/snapshot` (only `@Snapshotable` fields) → current state.
|
||||
3. `GET /state/snapshot` (only `// @Snapshotable` fields) → current state.
|
||||
4. Decide next action based on what's on the screen vs the test goal.
|
||||
5. `POST /session/acquire` to grab the device lock.
|
||||
6. Execute `POST /tap`, `/swipe`, `/type`, or `POST /state/<key>` write.
|
||||
@@ -981,7 +1005,7 @@ live.
|
||||
| `curl: connection refused` to daemon | daemon crashed | Re-run `/ios-qa`; spawn-race lock will fail closed |
|
||||
| `403 identity_not_allowed` from `/auth/mint` | identity missing from allowlist | Run `gstack-ios-qa-mint --remote <identity>` on the Mac |
|
||||
| `409 schema_mismatch` on `/state/restore` | snapshot from older app build | Discard the snapshot; re-capture |
|
||||
| `503 device_disconnected` from proxy | USB tunnel dropped | Reconnect device; daemon auto-reconnects within 30s |
|
||||
| `503 device_disconnected` from proxy | USB route dropped or app relaunched | Daemon invalidates the stale tunnel and retries one fresh bootstrap; reconnect/unlock the iPhone if it persists |
|
||||
| `429 rate_limited` from `/auth/mint` | >10 mints/min from one identity | Wait 60s; check audit log for anomalies |
|
||||
| `413 body_too_large` on `/state/restore` | snapshot >1MB | Increase `--max-body` or trim snapshot |
|
||||
|
||||
|
||||
+39
-15
@@ -97,17 +97,33 @@ fi
|
||||
## Phase 1: Read source, plan codegen
|
||||
|
||||
1. Walk the app source (passed as `--source <dir>`) and identify all `@Observable`
|
||||
classes. Note any property marked with the `@Snapshotable` wrapper — those
|
||||
are the snapshot-eligible fields.
|
||||
2. Run `swift run --package-path $GSTACK_HOME/ios-qa/scripts/gen-accessors-tool gen-accessors --input <source-dir>`.
|
||||
First invocation builds the swift-syntax dependency tree (cold: 2-5 min).
|
||||
Subsequent runs are content-hash-cached and finish in ~50ms.
|
||||
3. Show the user the accessor list and ask whether to install the DebugBridge
|
||||
classes. Note any property immediately preceded by the generator marker
|
||||
comment `// @Snapshotable` — those are the snapshot-eligible fields. The
|
||||
marker is a comment so it composes with the `@Observable` macro. Each
|
||||
marked field must belong to a file-scope observable class and be a writable
|
||||
instance `var` with an explicit type and an internal or public setter.
|
||||
Snapshot types are JSON-native scalars (`String`, `Bool`, integer widths,
|
||||
`Float`, `Double`, `CGFloat`), arrays, String-keyed dictionaries, and their
|
||||
Optional compositions. Keys must be unique across observable classes.
|
||||
Codegen stops with a source diagnostic instead of emitting a broken or
|
||||
lossy harness when any of these constraints is violated.
|
||||
2. Show the user the accessor list and ask whether to install the DebugBridge
|
||||
SPM dependency into their `Package.swift` (one AskUserQuestion).
|
||||
|
||||
## Phase 2: Bootstrap the device bridge
|
||||
|
||||
1. Add the `DebugBridge` SPM dependency to the app's `Package.swift`. The package
|
||||
1. Generate the canonical local bridge package, typed accessors, and installed
|
||||
version marker with one deterministic command:
|
||||
```bash
|
||||
~/.claude/skills/gstack/bin/gstack-ios-qa-regen \
|
||||
--app-source "<source-dir>" \
|
||||
--bridge-dir "<source-dir>/DebugBridge"
|
||||
```
|
||||
The regenerator also removes the explicit obsolete flat-file set created by
|
||||
older ios-sync versions, preventing a stale second harness from remaining
|
||||
in the app target.
|
||||
2. Add the generated `DebugBridge` local SPM dependency to the app's
|
||||
`Package.swift`. The package
|
||||
ships three Debug-config-only library products:
|
||||
- `DebugBridgeCore` (Swift, cross-platform) — StateServer + bridge protocols.
|
||||
- `DebugBridgeTouch` (Objective-C, iOS-only) — KIF-derived in-process touch
|
||||
@@ -117,27 +133,35 @@ fi
|
||||
The app target depends on `DebugBridgeUI` with `.when(configuration: .debug)`
|
||||
(transitively pulls in Core + Touch). Release builds refuse to link these
|
||||
targets.
|
||||
2. Wire the bridges from the `@main` App init, gated on `#if DEBUG`:
|
||||
3. Wire the bridges from the `@main` App init, gated on `#if DEBUG`:
|
||||
```swift
|
||||
#if DEBUG
|
||||
import DebugBridgeCore
|
||||
StateServer.shared.start()
|
||||
#if canImport(UIKit)
|
||||
import DebugBridgeUI
|
||||
// Install resolvers before StateServer opens its listener.
|
||||
DebugBridgeUIWiring.installAll()
|
||||
#endif
|
||||
// Replace AppState/AppStateAccessor with the type discovered in Phase 1.
|
||||
DebugBridgeManager.shared.start(
|
||||
appState: appState,
|
||||
register: AppStateAccessor.register
|
||||
)
|
||||
#endif
|
||||
```
|
||||
3. Build + deploy to the device with `xcodebuild -scheme <SchemeName>
|
||||
4. Build + deploy to the device with `xcodebuild -scheme <SchemeName>
|
||||
-destination 'platform=iOS,id=<UDID>' build install`.
|
||||
4. Launch via `devicectl device process launch --device <UDID> --console <bundle-id>`.
|
||||
5. Launch via `devicectl device process launch --device <UDID> --console <bundle-id>`.
|
||||
Capture the boot token printed to `os_log` on first run.
|
||||
5. Spawn the Mac-side daemon (on-demand) — `gstack-ios-qa-daemon`. Daemon
|
||||
6. Spawn the Mac-side daemon (on-demand) — `gstack-ios-qa-daemon`. Daemon
|
||||
acquires an exclusive flock on `~/.gstack/ios-qa-daemon.pid`. If another
|
||||
daemon is alive, the second invocation discovers its port and connects.
|
||||
6. Daemon immediately calls `POST /auth/rotate` on the iOS StateServer with a
|
||||
7. Daemon immediately calls `POST /auth/rotate` on the iOS StateServer with a
|
||||
fresh in-memory-only token. The boot token becomes useless ~5s later.
|
||||
Anything scraping `os_log` past this point sees a dead credential.
|
||||
If a fresh daemon finds the app running after another daemon consumed that
|
||||
one-use token, it verifies the bundle owner, relaunches the target once,
|
||||
waits for the new token, verifies ownership again, and then rotates.
|
||||
|
||||
## Phase 3: Vision-driven agent loop
|
||||
|
||||
@@ -145,7 +169,7 @@ Each iteration:
|
||||
|
||||
1. `GET /screenshot` (via daemon) → save PNG.
|
||||
2. `GET /elements` → accessibility tree.
|
||||
3. `GET /state/snapshot` (only `@Snapshotable` fields) → current state.
|
||||
3. `GET /state/snapshot` (only `// @Snapshotable` fields) → current state.
|
||||
4. Decide next action based on what's on the screen vs the test goal.
|
||||
5. `POST /session/acquire` to grab the device lock.
|
||||
6. Execute `POST /tap`, `/swipe`, `/type`, or `POST /state/<key>` write.
|
||||
@@ -209,7 +233,7 @@ live.
|
||||
| `curl: connection refused` to daemon | daemon crashed | Re-run `/ios-qa`; spawn-race lock will fail closed |
|
||||
| `403 identity_not_allowed` from `/auth/mint` | identity missing from allowlist | Run `gstack-ios-qa-mint --remote <identity>` on the Mac |
|
||||
| `409 schema_mismatch` on `/state/restore` | snapshot from older app build | Discard the snapshot; re-capture |
|
||||
| `503 device_disconnected` from proxy | USB tunnel dropped | Reconnect device; daemon auto-reconnects within 30s |
|
||||
| `503 device_disconnected` from proxy | USB route dropped or app relaunched | Daemon invalidates the stale tunnel and retries one fresh bootstrap; reconnect/unlock the iPhone if it persists |
|
||||
| `429 rate_limited` from `/auth/mint` | >10 mints/min from one identity | Wait 60s; check audit log for anomalies |
|
||||
| `413 body_too_large` on `/state/restore` | snapshot >1MB | Increase `--max-body` or trim snapshot |
|
||||
|
||||
|
||||
@@ -13,7 +13,10 @@ export interface DeviceEntry {
|
||||
identifier: string;
|
||||
name: string;
|
||||
model: string;
|
||||
platform: string; // "iOS" | "iPadOS" | "visionOS" | ...
|
||||
deviceType: string; // "iPhone" | "iPad" | "realityDevice" | ...
|
||||
state: string; // "connected" | "available" | "available (paired)" | ...
|
||||
transport: string; // "wired" on USB devices; empty for stale/unavailable entries
|
||||
paired: boolean;
|
||||
}
|
||||
|
||||
@@ -83,7 +86,10 @@ export function listDevices(spawn: SpawnImpl = defaultSpawn): DeviceEntry[] {
|
||||
identifier: String(d.identifier ?? ''),
|
||||
name: String(props?.name ?? 'unknown'),
|
||||
model: String(hw?.productType ?? 'unknown'),
|
||||
platform: String(hw?.platform ?? ''),
|
||||
deviceType: String(hw?.deviceType ?? ''),
|
||||
state: String(conn?.tunnelState ?? 'unknown'),
|
||||
transport: String(conn?.transportType ?? ''),
|
||||
paired: pairingState === 'paired',
|
||||
};
|
||||
});
|
||||
|
||||
+157
-27
@@ -62,16 +62,36 @@ export async function startDaemon(opts: DaemonOptions): Promise<RunningDaemon |
|
||||
|
||||
const tokenStore = new SessionTokenStore();
|
||||
let tunnel: DeviceTunnel | null = null;
|
||||
let cachedTunnelAt = 0;
|
||||
let tunnelInFlight: Promise<DeviceTunnel | null> | null = null;
|
||||
|
||||
const getTunnel = async (): Promise<DeviceTunnel | null> => {
|
||||
// Cache the tunnel for 30s; refresh on demand.
|
||||
if (tunnel && Date.now() - cachedTunnelAt < 30_000) return tunnel;
|
||||
if (opts.tunnelProvider) {
|
||||
tunnel = await opts.tunnelProvider();
|
||||
cachedTunnelAt = Date.now();
|
||||
// A successful bootstrap consumes and deletes the app's one-shot boot
|
||||
// token. Keep that rotated tunnel for this daemon's lifetime instead of
|
||||
// trying to bootstrap it again on a timer. Failed attempts are not cached.
|
||||
if (tunnel) return tunnel;
|
||||
if (!opts.tunnelProvider) return null;
|
||||
|
||||
// Multiple first requests can arrive before bootstrap completes. Share
|
||||
// one provider call so they cannot race through independent rotations.
|
||||
if (!tunnelInFlight) {
|
||||
tunnelInFlight = Promise.resolve()
|
||||
.then(() => opts.tunnelProvider!())
|
||||
.then((candidate) => {
|
||||
if (candidate) tunnel = candidate;
|
||||
return candidate;
|
||||
})
|
||||
.finally(() => {
|
||||
tunnelInFlight = null;
|
||||
});
|
||||
}
|
||||
return tunnel;
|
||||
return tunnelInFlight;
|
||||
};
|
||||
|
||||
const invalidateTunnel = (failedTunnel: DeviceTunnel): void => {
|
||||
// A late response from the old app must not evict a tunnel that another
|
||||
// request has already refreshed. Object identity gives each bootstrap a
|
||||
// cheap generation token without exposing generation state elsewhere.
|
||||
if (tunnel === failedTunnel) tunnel = null;
|
||||
};
|
||||
|
||||
// 2. Tailnet probe (fail-closed).
|
||||
@@ -86,7 +106,7 @@ export async function startDaemon(opts: DaemonOptions): Promise<RunningDaemon |
|
||||
|
||||
// 3. Loopback listener (full surface).
|
||||
const loopbackServer = createServer(async (req, res) => {
|
||||
await handleLoopback({ req, res, tokenStore, getTunnel });
|
||||
await handleLoopback({ req, res, tokenStore, getTunnel, invalidateTunnel });
|
||||
});
|
||||
// Use port 0 for OS-assigned port when test/random port collisions are a risk.
|
||||
const requestedPort = opts.loopbackPort;
|
||||
@@ -97,7 +117,7 @@ export async function startDaemon(opts: DaemonOptions): Promise<RunningDaemon |
|
||||
// mode this can collide; we try the actualPort first and skip ipv6 if it
|
||||
// fails (tests don't exercise ::1 explicitly).
|
||||
const loopbackServerV6 = createServer(async (req, res) => {
|
||||
await handleLoopback({ req, res, tokenStore, getTunnel });
|
||||
await handleLoopback({ req, res, tokenStore, getTunnel, invalidateTunnel });
|
||||
});
|
||||
let v6Bound = false;
|
||||
try {
|
||||
@@ -118,6 +138,7 @@ export async function startDaemon(opts: DaemonOptions): Promise<RunningDaemon |
|
||||
res,
|
||||
tokenStore,
|
||||
getTunnel,
|
||||
invalidateTunnel,
|
||||
auditPath: opts.auditPath,
|
||||
attemptsPath: opts.attemptsPath,
|
||||
allowlistPath: opts.allowlistPath,
|
||||
@@ -181,12 +202,113 @@ interface HandlerCtx {
|
||||
res: ServerResponse;
|
||||
tokenStore: SessionTokenStore;
|
||||
getTunnel: () => Promise<DeviceTunnel | null>;
|
||||
invalidateTunnel: (failedTunnel: DeviceTunnel) => void;
|
||||
// Explicit security-log + allowlist paths (default to env-derived when undefined).
|
||||
auditPath?: string;
|
||||
attemptsPath?: string;
|
||||
allowlistPath?: string;
|
||||
}
|
||||
|
||||
type DeviceProxyResponse = Awaited<ReturnType<typeof proxyToDevice>>;
|
||||
const RECOVERABLE_SOCKET_ERRORS = new Set([
|
||||
'ECONNABORTED',
|
||||
'ECONNREFUSED',
|
||||
'ECONNRESET',
|
||||
'EHOSTUNREACH',
|
||||
'ENETUNREACH',
|
||||
'EPIPE',
|
||||
'ETIMEDOUT',
|
||||
]);
|
||||
|
||||
function localProxyError(status: number, error: string): DeviceProxyResponse {
|
||||
const body = Buffer.from(JSON.stringify({ error }, sanitizeReplacer));
|
||||
return {
|
||||
status,
|
||||
headers: { 'content-type': 'application/json', 'content-length': String(body.length) },
|
||||
body,
|
||||
};
|
||||
}
|
||||
|
||||
async function proxyAttempt(opts: Parameters<typeof proxyToDevice>[0]): Promise<DeviceProxyResponse> {
|
||||
try {
|
||||
return await proxyToDevice(opts);
|
||||
} catch (err) {
|
||||
const code = (err as { code?: string }).code;
|
||||
// CoreDevice can surface the same stale route as several different socket
|
||||
// failures while an app is being relaunched or replaced. Normalize those
|
||||
// failures so the cache recovery path below can handle all of them.
|
||||
if (code && RECOVERABLE_SOCKET_ERRORS.has(code)) {
|
||||
return localProxyError(503, 'device_disconnected');
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
function shouldRefreshTunnel(upstream: DeviceProxyResponse): boolean {
|
||||
// A relaunched app has a new in-memory bearer and rejects the daemon's old
|
||||
// rotated token. A redeploy can instead leave the old CoreDevice route
|
||||
// refusing connections or timing out. Both cases require a fresh bootstrap.
|
||||
if (upstream.status === 401) return true;
|
||||
if (upstream.status !== 503 && upstream.status !== 504) return false;
|
||||
try {
|
||||
const body = JSON.parse(upstream.body.toString('utf-8')) as { error?: string };
|
||||
return body.error === 'device_disconnected' || body.error === 'upstream_timeout';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function canReplayAfterRefresh(inbound: IncomingMessage, upstream: DeviceProxyResponse): boolean {
|
||||
// A 401 proves the stale bearer was rejected before StateServer dispatched
|
||||
// the operation, so retrying is safe even for a mutation. Connection loss
|
||||
// is ambiguous: the app may have applied a tap/write before its response was
|
||||
// lost. Replay only read-only requests in that case to prevent double taps.
|
||||
if (upstream.status === 401) return true;
|
||||
const method = inbound.method ?? 'GET';
|
||||
return method === 'GET' || method === 'HEAD' || method === 'OPTIONS';
|
||||
}
|
||||
|
||||
async function proxyWithTunnelRecovery(opts: {
|
||||
inbound: IncomingMessage;
|
||||
body: Buffer;
|
||||
sessionId: string | null;
|
||||
agentIdentity?: string;
|
||||
getTunnel: HandlerCtx['getTunnel'];
|
||||
invalidateTunnel: HandlerCtx['invalidateTunnel'];
|
||||
}): Promise<{ tunnel: DeviceTunnel; upstream: DeviceProxyResponse } | null> {
|
||||
let tunnel = await opts.getTunnel();
|
||||
if (!tunnel) return null;
|
||||
|
||||
const makeAttempt = (candidate: DeviceTunnel) => proxyAttempt({
|
||||
inbound: opts.inbound,
|
||||
body: opts.body,
|
||||
tunnel: candidate,
|
||||
sessionId: opts.sessionId,
|
||||
agentIdentity: opts.agentIdentity,
|
||||
});
|
||||
|
||||
let upstream = await makeAttempt(tunnel);
|
||||
if (!shouldRefreshTunnel(upstream)) return { tunnel, upstream };
|
||||
|
||||
const failedTunnel = tunnel;
|
||||
const replaySafe = canReplayAfterRefresh(opts.inbound, upstream);
|
||||
opts.invalidateTunnel(tunnel);
|
||||
const refreshed = await opts.getTunnel();
|
||||
if (!refreshed) return replaySafe ? null : { tunnel: failedTunnel, upstream };
|
||||
|
||||
// The replacement is now cached for the next request, but never replay an
|
||||
// ambiguous mutation whose response was lost: doing so could double-tap or
|
||||
// apply a state transition twice.
|
||||
if (!replaySafe) return { tunnel: failedTunnel, upstream };
|
||||
|
||||
tunnel = refreshed;
|
||||
upstream = await makeAttempt(tunnel);
|
||||
// Do not loop forever if the replacement app is itself unavailable. Leave
|
||||
// the cache empty so the next independent request can bootstrap again.
|
||||
if (shouldRefreshTunnel(upstream)) opts.invalidateTunnel(tunnel);
|
||||
return { tunnel, upstream };
|
||||
}
|
||||
|
||||
function readBody(req: IncomingMessage, maxBytes = 1_048_576): Promise<Buffer | { error: 'body_too_large' }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const chunks: Buffer[] = [];
|
||||
@@ -228,7 +350,7 @@ function sendJson(res: ServerResponse, status: number, body: unknown): void {
|
||||
* loopback bind itself is the boundary).
|
||||
*/
|
||||
async function handleLoopback(ctx: HandlerCtx): Promise<void> {
|
||||
const { req, res, tokenStore, getTunnel } = ctx;
|
||||
const { req, res, tokenStore, getTunnel, invalidateTunnel } = ctx;
|
||||
const url = parseUrl(req.url ?? '/');
|
||||
const path = url.pathname ?? '/';
|
||||
const method = req.method ?? 'GET';
|
||||
@@ -262,16 +384,23 @@ async function handleLoopback(ctx: HandlerCtx): Promise<void> {
|
||||
}
|
||||
|
||||
// Other endpoints — proxy to the device.
|
||||
const tunnel = await getTunnel();
|
||||
if (!tunnel) {
|
||||
sendJson(res, 503, { error: 'device_not_connected' });
|
||||
return;
|
||||
}
|
||||
const body = await readBody(req);
|
||||
if ('error' in body) { sendJson(res, 413, body); return; }
|
||||
const sessionId = (req.headers['x-session-id'] as string | undefined) ?? null;
|
||||
const agentIdentity = (req.headers['x-agent-identity'] as string | undefined) ?? undefined;
|
||||
const upstream = await proxyToDevice({ inbound: req, body, tunnel, sessionId, agentIdentity });
|
||||
const proxied = await proxyWithTunnelRecovery({
|
||||
inbound: req,
|
||||
body,
|
||||
sessionId,
|
||||
agentIdentity,
|
||||
getTunnel,
|
||||
invalidateTunnel,
|
||||
});
|
||||
if (!proxied) {
|
||||
sendJson(res, 503, { error: 'device_not_connected' });
|
||||
return;
|
||||
}
|
||||
const { upstream } = proxied;
|
||||
res.writeHead(upstream.status, upstream.headers);
|
||||
res.end(upstream.body);
|
||||
} catch (err) {
|
||||
@@ -287,7 +416,7 @@ interface TailnetCtx extends HandlerCtx {
|
||||
* Tailnet handler — locked allowlist + capability tiers.
|
||||
*/
|
||||
async function handleTailnet(ctx: TailnetCtx): Promise<void> {
|
||||
const { req, res, tokenStore, getTunnel, whoIsImpl, auditPath, attemptsPath, allowlistPath } = ctx;
|
||||
const { req, res, tokenStore, getTunnel, invalidateTunnel, whoIsImpl, auditPath, attemptsPath, allowlistPath } = ctx;
|
||||
const url = parseUrl(req.url ?? '/');
|
||||
const path = url.pathname ?? '/';
|
||||
const method = req.method ?? 'GET';
|
||||
@@ -375,19 +504,20 @@ async function handleTailnet(ctx: TailnetCtx): Promise<void> {
|
||||
}
|
||||
|
||||
// Proxy to device.
|
||||
const tunnel = await getTunnel();
|
||||
if (!tunnel) {
|
||||
const sessionId = (req.headers['x-session-id'] as string | undefined) ?? null;
|
||||
const proxied = await proxyWithTunnelRecovery({
|
||||
inbound: req,
|
||||
body,
|
||||
sessionId,
|
||||
agentIdentity: session.identity,
|
||||
getTunnel,
|
||||
invalidateTunnel,
|
||||
});
|
||||
if (!proxied) {
|
||||
sendJson(res, 503, { error: 'device_not_connected' });
|
||||
return;
|
||||
}
|
||||
const sessionId = (req.headers['x-session-id'] as string | undefined) ?? null;
|
||||
const upstream = await proxyToDevice({
|
||||
inbound: req,
|
||||
body,
|
||||
tunnel,
|
||||
sessionId,
|
||||
agentIdentity: session.identity,
|
||||
});
|
||||
const { tunnel, upstream } = proxied;
|
||||
|
||||
// Audit the action (mutating endpoints only).
|
||||
if (requiredCapability !== 'observe') {
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
// 2. launch the app on it (no-op if already running)
|
||||
// 3. wait briefly for the in-app StateServer to start
|
||||
// 4. copy the boot token from the app's sandbox via devicectl copy from
|
||||
// If an earlier daemon already consumed it, relaunch the app once to mint
|
||||
// a fresh boot token, then verify the relaunched StateServer again.
|
||||
// 5. POST /auth/rotate to swap boot token → fresh in-memory token
|
||||
// 6. return a DeviceTunnel pointing at the device's IPv6 with the rotated
|
||||
// bearer that subsequent proxied requests carry
|
||||
@@ -14,6 +16,7 @@
|
||||
// live token, which it scopes per-tailnet-session via /auth/mint.
|
||||
|
||||
import { randomBytes } from 'crypto';
|
||||
import { spawnSync } from 'child_process';
|
||||
import type { DeviceTunnel } from './proxy';
|
||||
import {
|
||||
listDevices,
|
||||
@@ -21,12 +24,13 @@ import {
|
||||
isAppRunning,
|
||||
launchApp,
|
||||
copyFileFromAppContainer,
|
||||
type DeviceEntry,
|
||||
type SpawnImpl,
|
||||
type ResolveImpl,
|
||||
} from './devicectl';
|
||||
|
||||
export interface BootstrapOptions {
|
||||
/** Target device UDID. If null, picks the first connected paired device. */
|
||||
/** Target iPhone UDID. If null, picks the best connected paired iPhone. */
|
||||
udid?: string;
|
||||
/** Bundle ID of the iOS app hosting the StateServer. */
|
||||
bundleId: string;
|
||||
@@ -53,10 +57,85 @@ export type BootstrapErrorReason =
|
||||
| 'launch_failed'
|
||||
| 'device_locked'
|
||||
| 'state_server_unreachable'
|
||||
| 'wrong_app'
|
||||
| 'boot_token_unavailable'
|
||||
| 'rotate_failed'
|
||||
| 'resolve_failed';
|
||||
|
||||
function isIPhoneDevice(device: DeviceEntry): boolean {
|
||||
const platform = device.platform.trim().toLowerCase();
|
||||
const deviceType = device.deviceType.trim().toLowerCase();
|
||||
const model = device.model.trim().toLowerCase();
|
||||
|
||||
// productType is present even on older CoreDevice versions. Prefer the
|
||||
// explicit platform/type fields when available, but retain productType as
|
||||
// a compatibility fallback. An explicit non-iOS platform always loses.
|
||||
if (platform && platform !== 'ios') return false;
|
||||
return deviceType === 'iphone' || model.startsWith('iphone');
|
||||
}
|
||||
|
||||
function isAvailableDevice(device: Pick<DeviceEntry, 'state' | 'transport'>): boolean {
|
||||
const state = device.state.trim().toLowerCase();
|
||||
const transport = device.transport.trim().toLowerCase();
|
||||
// Xcode 26.6 / iOS 27 beta can report a USB-reachable iPhone as
|
||||
// tunnelState=disconnected until the next devicectl command establishes
|
||||
// the CoreDevice tunnel. The wired transport is the authoritative signal
|
||||
// in that transitional state. Stale devices have no wired transport.
|
||||
return state === 'connected'
|
||||
|| state.startsWith('available')
|
||||
|| (state === 'disconnected' && transport === 'wired');
|
||||
}
|
||||
|
||||
function defaultDeviceRank(device: DeviceEntry): number {
|
||||
if (!device.paired || !isIPhoneDevice(device) || !isAvailableDevice(device)) return -1;
|
||||
|
||||
const state = device.state.trim().toLowerCase();
|
||||
const transport = device.transport.trim().toLowerCase();
|
||||
// Prefer the USB-connected phone the user is actively working with. Then
|
||||
// prefer an established CoreDevice tunnel over a merely available device.
|
||||
return (transport === 'wired' ? 100 : 0)
|
||||
+ (state === 'connected' ? 10 : 0)
|
||||
+ (state.startsWith('available') ? 1 : 0);
|
||||
}
|
||||
|
||||
function pickDefaultDevice(devices: DeviceEntry[]): DeviceEntry | undefined {
|
||||
let best: DeviceEntry | undefined;
|
||||
let bestRank = -1;
|
||||
for (const device of devices) {
|
||||
const rank = defaultDeviceRank(device);
|
||||
if (rank > bestRank) {
|
||||
best = device;
|
||||
bestRank = rank;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
const defaultSpawn: SpawnImpl = (cmd, args) => spawnSync(cmd, args, {
|
||||
stdio: 'pipe',
|
||||
timeout: 60_000,
|
||||
});
|
||||
|
||||
function relaunchApp(
|
||||
udid: string,
|
||||
bundleId: string,
|
||||
spawn: SpawnImpl = defaultSpawn,
|
||||
): { ok: true } | { ok: false; error: 'device_locked' | 'launch_failed'; detail?: string } {
|
||||
const r = spawn('xcrun', [
|
||||
'devicectl', 'device', 'process', 'launch',
|
||||
'--device', udid,
|
||||
'--terminate-existing',
|
||||
bundleId,
|
||||
]);
|
||||
if (r.status === 0) return { ok: true };
|
||||
|
||||
const detail = `${r.stderr?.toString() ?? ''}${r.stdout?.toString() ?? ''}`.trim();
|
||||
if (detail.includes('was not, or could not be, unlocked')) {
|
||||
return { ok: false, error: 'device_locked', detail };
|
||||
}
|
||||
return { ok: false, error: 'launch_failed', detail };
|
||||
}
|
||||
|
||||
/**
|
||||
* Bootstrap a real CoreDevice tunnel to an iOS app's StateServer. Used by
|
||||
* the daemon's default tunnelProvider when GSTACK_IOS_TARGET_UDID is set
|
||||
@@ -77,9 +156,39 @@ export async function bootstrapTunnel(opts: BootstrapOptions): Promise<Bootstrap
|
||||
}
|
||||
const target = opts.udid
|
||||
? devices.find((d) => d.identifier === opts.udid)
|
||||
: devices.find((d) => d.paired) ?? devices[0];
|
||||
: pickDefaultDevice(devices);
|
||||
if (!target) {
|
||||
return { ok: false, error: 'device_not_found', detail: opts.udid };
|
||||
if (opts.udid) {
|
||||
return { ok: false, error: 'device_not_found', detail: opts.udid };
|
||||
}
|
||||
const pairedIPhone = devices.find((d) => d.paired && isIPhoneDevice(d));
|
||||
if (pairedIPhone) {
|
||||
return {
|
||||
ok: false,
|
||||
error: 'device_not_found',
|
||||
detail: `paired iPhone ${pairedIPhone.name} (${pairedIPhone.identifier}) is ${pairedIPhone.state}; connect it over USB and unlock it`,
|
||||
};
|
||||
}
|
||||
const firstIPhone = devices.find(isIPhoneDevice);
|
||||
if (!firstIPhone) {
|
||||
return {
|
||||
ok: false,
|
||||
error: 'device_not_found',
|
||||
detail: 'no iPhone is connected; non-iOS devices are not eligible for iOS QA',
|
||||
};
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
error: 'no_paired_device',
|
||||
detail: `device ${firstIPhone.name} (${firstIPhone.identifier}) is ${firstIPhone.state}; run \`xcrun devicectl manage pair --device ${firstIPhone.identifier}\` and tap Trust on the iPhone`,
|
||||
};
|
||||
}
|
||||
if (!isIPhoneDevice(target)) {
|
||||
return {
|
||||
ok: false,
|
||||
error: 'device_not_found',
|
||||
detail: `device ${target.name} (${target.identifier}) is ${target.platform || target.model}, not an iPhone`,
|
||||
};
|
||||
}
|
||||
if (!target.paired) {
|
||||
return {
|
||||
@@ -88,6 +197,13 @@ export async function bootstrapTunnel(opts: BootstrapOptions): Promise<Bootstrap
|
||||
detail: `device ${target.name} (${target.identifier}) is ${target.state}; run \`xcrun devicectl manage pair --device ${target.identifier}\` and tap Trust on the iPhone`,
|
||||
};
|
||||
}
|
||||
if (!isAvailableDevice(target)) {
|
||||
return {
|
||||
ok: false,
|
||||
error: 'device_not_found',
|
||||
detail: `device ${target.name} (${target.identifier}) is ${target.state}; connect it over USB and unlock it`,
|
||||
};
|
||||
}
|
||||
|
||||
// Step 2: launch app (idempotent — devicectl returns success if already running)
|
||||
if (!isAppRunning(target.identifier, opts.bundleId, spawn)) {
|
||||
@@ -118,29 +234,77 @@ export async function bootstrapTunnel(opts: BootstrapOptions): Promise<Bootstrap
|
||||
|
||||
// Step 4: wait for StateServer to become reachable, then scrape boot token.
|
||||
// Probe /healthz with retries (the listener can take a moment to bind).
|
||||
const deadline = Date.now() + startupTimeoutMs;
|
||||
let healthOK = false;
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
const r = await fetchFn(`http://[${ipv6}]:${port}/healthz`, {
|
||||
signal: AbortSignal.timeout(2_000),
|
||||
});
|
||||
if (r.ok) { healthOK = true; break; }
|
||||
} catch { /* retry */ }
|
||||
await new Promise((res) => setTimeout(res, 250));
|
||||
}
|
||||
if (!healthOK) {
|
||||
return { ok: false, error: 'state_server_unreachable', detail: `no /healthz response from [${ipv6}]:${port} within ${startupTimeoutMs}ms` };
|
||||
}
|
||||
const waitForStateServer = async (): Promise<BootstrapResult | null> => {
|
||||
const deadline = Date.now() + startupTimeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
const r = await fetchFn(`http://[${ipv6}]:${port}/healthz`, {
|
||||
signal: AbortSignal.timeout(2_000),
|
||||
});
|
||||
if (r.ok) {
|
||||
const health = await r.json().catch(() => null) as { bundle_id?: string } | null;
|
||||
// Older bridges did not identify their bundle. Preserve compatibility,
|
||||
// but reject an explicit mismatch from current bridges: another debug
|
||||
// app already owns the fixed StateServer port on this device.
|
||||
if (health?.bundle_id && health.bundle_id !== opts.bundleId) {
|
||||
return {
|
||||
ok: false,
|
||||
error: 'wrong_app',
|
||||
detail: `expected ${opts.bundleId} but StateServer port ${port} belongs to ${health.bundle_id}; terminate the other debug app`,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
} catch { /* retry */ }
|
||||
await new Promise((res) => setTimeout(res, 250));
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
error: 'state_server_unreachable',
|
||||
detail: `no /healthz response from [${ipv6}]:${port} within ${startupTimeoutMs}ms`,
|
||||
};
|
||||
};
|
||||
|
||||
const bootToken = copyFileFromAppContainer({
|
||||
const healthFailure = await waitForStateServer();
|
||||
if (healthFailure) return healthFailure;
|
||||
|
||||
const readBootToken = () => copyFileFromAppContainer({
|
||||
udid: target.identifier,
|
||||
bundleId: opts.bundleId,
|
||||
sourceRelativePath: tokenPath,
|
||||
spawn,
|
||||
});
|
||||
|
||||
let bootToken = readBootToken();
|
||||
if (!bootToken) {
|
||||
return { ok: false, error: 'boot_token_unavailable', detail: `couldn't read ${tokenPath} from ${opts.bundleId}` };
|
||||
// A healthy running app can lack a boot token when an earlier daemon
|
||||
// already rotated it. A new daemon has no way to recover that in-memory
|
||||
// bearer, so restart exactly once to make StateServer mint a fresh one.
|
||||
// The explicit bundle check above prevents disrupting an unrelated app
|
||||
// that happens to own the fixed StateServer port.
|
||||
const relaunched = relaunchApp(target.identifier, opts.bundleId, spawn);
|
||||
if (!relaunched.ok) {
|
||||
return { ok: false, error: relaunched.error, detail: relaunched.detail };
|
||||
}
|
||||
|
||||
// The token is written before StateServer opens its listener. Waiting for
|
||||
// it first prevents a stale response from the terminating process from
|
||||
// being mistaken for readiness of the replacement process.
|
||||
const tokenDeadline = Date.now() + startupTimeoutMs;
|
||||
while (!bootToken && Date.now() < tokenDeadline) {
|
||||
bootToken = readBootToken();
|
||||
if (!bootToken) await new Promise((res) => setTimeout(res, 250));
|
||||
}
|
||||
if (!bootToken) {
|
||||
return {
|
||||
ok: false,
|
||||
error: 'boot_token_unavailable',
|
||||
detail: `couldn't read ${tokenPath} from ${opts.bundleId} after relaunch`,
|
||||
};
|
||||
}
|
||||
|
||||
const relaunchedHealthFailure = await waitForStateServer();
|
||||
if (relaunchedHealthFailure) return relaunchedHealthFailure;
|
||||
}
|
||||
|
||||
// Step 5: rotate the boot token to a fresh in-memory-only one.
|
||||
|
||||
@@ -132,6 +132,258 @@ describe('daemon — loopback listener', () => {
|
||||
expect(lastReq?.headers['x-session-id']).toBe('sess-loopback-1');
|
||||
});
|
||||
|
||||
test('concurrent first requests share one tunnel bootstrap', async () => {
|
||||
let bootstraps = 0;
|
||||
let releaseBootstrap!: () => void;
|
||||
let markBootstrapStarted!: () => void;
|
||||
const bootstrapStarted = new Promise<void>((resolve) => { markBootstrapStarted = resolve; });
|
||||
const bootstrapGate = new Promise<void>((resolve) => { releaseBootstrap = resolve; });
|
||||
const tunnel: DeviceTunnel = {
|
||||
udid: 'STUB-UDID',
|
||||
ipv6Addr: '127.0.0.1',
|
||||
port: stub.port,
|
||||
bootTokenRotated: STATE_SERVER_TOKEN,
|
||||
};
|
||||
const d = await startDaemon({
|
||||
loopbackPort: 0,
|
||||
tailnetEnabled: false,
|
||||
pidfilePath: join(workDir, 'daemon-concurrent-bootstrap.pid'),
|
||||
tunnelProvider: async () => {
|
||||
bootstraps++;
|
||||
markBootstrapStarted();
|
||||
await bootstrapGate;
|
||||
return tunnel;
|
||||
},
|
||||
});
|
||||
if ('error' in d) throw new Error(d.error);
|
||||
|
||||
try {
|
||||
const base = `http://127.0.0.1:${d.loopbackPort}`;
|
||||
const requests = [
|
||||
fetchWith('GET', `${base}/screenshot`),
|
||||
fetchWith('GET', `${base}/screenshot`),
|
||||
fetchWith('GET', `${base}/screenshot`),
|
||||
];
|
||||
await bootstrapStarted;
|
||||
await new Promise((resolve) => setTimeout(resolve, 25));
|
||||
releaseBootstrap();
|
||||
const responses = await Promise.all(requests);
|
||||
expect(responses.map((response) => response.status)).toEqual([200, 200, 200]);
|
||||
expect(bootstraps).toBe(1);
|
||||
} finally {
|
||||
releaseBootstrap();
|
||||
await d.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('reuses a healthy rotated tunnel beyond the old 30-second boundary', async () => {
|
||||
let bootstraps = 0;
|
||||
const tunnel: DeviceTunnel = {
|
||||
udid: 'STUB-UDID',
|
||||
ipv6Addr: '127.0.0.1',
|
||||
port: stub.port,
|
||||
bootTokenRotated: STATE_SERVER_TOKEN,
|
||||
};
|
||||
const d = await startDaemon({
|
||||
loopbackPort: 0,
|
||||
tailnetEnabled: false,
|
||||
pidfilePath: join(workDir, 'daemon-one-shot-bootstrap.pid'),
|
||||
tunnelProvider: async () => {
|
||||
bootstraps++;
|
||||
if (bootstraps > 1) throw new Error('one-shot boot token was already consumed');
|
||||
return tunnel;
|
||||
},
|
||||
});
|
||||
if ('error' in d) throw new Error(d.error);
|
||||
|
||||
const realNow = Date.now;
|
||||
const firstRequestAt = realNow();
|
||||
try {
|
||||
const base = `http://127.0.0.1:${d.loopbackPort}`;
|
||||
const first = await fetchWith('GET', `${base}/screenshot`);
|
||||
expect(first.status).toBe(200);
|
||||
|
||||
Date.now = () => firstRequestAt + 30_001;
|
||||
const later = await fetchWith('GET', `${base}/screenshot`);
|
||||
expect(later.status).toBe(200);
|
||||
expect(bootstraps).toBe(1);
|
||||
} finally {
|
||||
Date.now = realNow;
|
||||
await d.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('401 after app relaunch invalidates the token and concurrent requests share one rebootstrap', async () => {
|
||||
let bootstraps = 0;
|
||||
let markRefreshStarted!: () => void;
|
||||
let releaseRefresh!: () => void;
|
||||
const refreshStarted = new Promise<void>((resolve) => { markRefreshStarted = resolve; });
|
||||
const refreshGate = new Promise<void>((resolve) => { releaseRefresh = resolve; });
|
||||
const staleTunnel: DeviceTunnel = {
|
||||
udid: 'STUB-UDID',
|
||||
ipv6Addr: '127.0.0.1',
|
||||
port: stub.port,
|
||||
bootTokenRotated: 'expired-after-relaunch',
|
||||
};
|
||||
const refreshedTunnel: DeviceTunnel = {
|
||||
...staleTunnel,
|
||||
bootTokenRotated: STATE_SERVER_TOKEN,
|
||||
};
|
||||
const d = await startDaemon({
|
||||
loopbackPort: 0,
|
||||
tailnetEnabled: false,
|
||||
pidfilePath: join(workDir, 'daemon-relaunch-refresh.pid'),
|
||||
tunnelProvider: async () => {
|
||||
bootstraps++;
|
||||
if (bootstraps === 1) return staleTunnel;
|
||||
if (bootstraps === 2) {
|
||||
markRefreshStarted();
|
||||
await refreshGate;
|
||||
return refreshedTunnel;
|
||||
}
|
||||
throw new Error('concurrent 401s caused duplicate bootstraps');
|
||||
},
|
||||
});
|
||||
if ('error' in d) throw new Error(d.error);
|
||||
|
||||
const requestStart = stub.receivedRequests.length;
|
||||
try {
|
||||
const base = `http://127.0.0.1:${d.loopbackPort}`;
|
||||
const requests = [
|
||||
fetchWith('GET', `${base}/screenshot`),
|
||||
fetchWith('GET', `${base}/screenshot`),
|
||||
fetchWith('GET', `${base}/screenshot`),
|
||||
];
|
||||
await refreshStarted;
|
||||
await new Promise((resolve) => setTimeout(resolve, 25));
|
||||
expect(bootstraps).toBe(2);
|
||||
releaseRefresh();
|
||||
|
||||
const responses = await Promise.all(requests);
|
||||
expect(responses.map((response) => response.status)).toEqual([200, 200, 200]);
|
||||
const attempts = stub.receivedRequests.slice(requestStart);
|
||||
expect(attempts.filter((request) => request.headers.authorization === 'Bearer expired-after-relaunch')).toHaveLength(3);
|
||||
expect(attempts.filter((request) => request.headers.authorization === `Bearer ${STATE_SERVER_TOKEN}`)).toHaveLength(3);
|
||||
|
||||
const healthyReuse = await fetchWith('GET', `${base}/screenshot`);
|
||||
expect(healthyReuse.status).toBe(200);
|
||||
expect(bootstraps).toBe(2);
|
||||
} finally {
|
||||
releaseRefresh();
|
||||
await d.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('connection failure after redeploy reboots the tunnel once and keeps the replacement cached', async () => {
|
||||
const deadPort = await new Promise<number>((resolve, reject) => {
|
||||
const reservation = createServer();
|
||||
reservation.once('error', reject);
|
||||
reservation.listen(0, '127.0.0.1', () => {
|
||||
const address = reservation.address();
|
||||
const port = typeof address === 'object' && address ? address.port : 0;
|
||||
reservation.close((err) => err ? reject(err) : resolve(port));
|
||||
});
|
||||
});
|
||||
let bootstraps = 0;
|
||||
const d = await startDaemon({
|
||||
loopbackPort: 0,
|
||||
tailnetEnabled: false,
|
||||
pidfilePath: join(workDir, 'daemon-redeploy-refresh.pid'),
|
||||
tunnelProvider: async () => {
|
||||
bootstraps++;
|
||||
if (bootstraps === 1) {
|
||||
return {
|
||||
udid: 'STUB-UDID',
|
||||
ipv6Addr: '127.0.0.1',
|
||||
port: deadPort,
|
||||
bootTokenRotated: 'old-deploy-token',
|
||||
};
|
||||
}
|
||||
if (bootstraps === 2) {
|
||||
return {
|
||||
udid: 'STUB-UDID',
|
||||
ipv6Addr: '127.0.0.1',
|
||||
port: stub.port,
|
||||
bootTokenRotated: STATE_SERVER_TOKEN,
|
||||
};
|
||||
}
|
||||
throw new Error('healthy replacement tunnel was not reused');
|
||||
},
|
||||
});
|
||||
if ('error' in d) throw new Error(d.error);
|
||||
|
||||
try {
|
||||
const base = `http://127.0.0.1:${d.loopbackPort}`;
|
||||
const recovered = await fetchWith('GET', `${base}/screenshot`);
|
||||
expect(recovered.status).toBe(200);
|
||||
expect(JSON.parse(recovered.bodyText)).toEqual({ png_base64: 'abc=' });
|
||||
expect(bootstraps).toBe(2);
|
||||
|
||||
const healthyReuse = await fetchWith('GET', `${base}/screenshot`);
|
||||
expect(healthyReuse.status).toBe(200);
|
||||
expect(bootstraps).toBe(2);
|
||||
} finally {
|
||||
await d.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('connection failure refreshes but never replays an ambiguous tap', async () => {
|
||||
const deadPort = await new Promise<number>((resolve, reject) => {
|
||||
const reservation = createServer();
|
||||
reservation.once('error', reject);
|
||||
reservation.listen(0, '127.0.0.1', () => {
|
||||
const address = reservation.address();
|
||||
const port = typeof address === 'object' && address ? address.port : 0;
|
||||
reservation.close((err) => err ? reject(err) : resolve(port));
|
||||
});
|
||||
});
|
||||
let bootstraps = 0;
|
||||
const d = await startDaemon({
|
||||
loopbackPort: 0,
|
||||
tailnetEnabled: false,
|
||||
pidfilePath: join(workDir, 'daemon-mutation-no-replay.pid'),
|
||||
tunnelProvider: async () => {
|
||||
bootstraps++;
|
||||
return bootstraps === 1
|
||||
? {
|
||||
udid: 'STUB-UDID',
|
||||
ipv6Addr: '127.0.0.1',
|
||||
port: deadPort,
|
||||
bootTokenRotated: 'old-deploy-token',
|
||||
}
|
||||
: {
|
||||
udid: 'STUB-UDID',
|
||||
ipv6Addr: '127.0.0.1',
|
||||
port: stub.port,
|
||||
bootTokenRotated: STATE_SERVER_TOKEN,
|
||||
};
|
||||
},
|
||||
});
|
||||
if ('error' in d) throw new Error(d.error);
|
||||
|
||||
const beforeTaps = stub.receivedRequests.filter((request) => request.path === '/tap').length;
|
||||
try {
|
||||
const base = `http://127.0.0.1:${d.loopbackPort}`;
|
||||
const ambiguous = await fetchWith('POST', `${base}/tap`, {
|
||||
headers: { 'x-session-id': 'old-session', 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ x: 10, y: 20 }),
|
||||
});
|
||||
expect(ambiguous.status).toBe(503);
|
||||
expect(bootstraps).toBe(2);
|
||||
expect(stub.receivedRequests.filter((request) => request.path === '/tap')).toHaveLength(beforeTaps);
|
||||
|
||||
const explicitRetry = await fetchWith('POST', `${base}/tap`, {
|
||||
headers: { 'x-session-id': 'new-session', 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ x: 10, y: 20 }),
|
||||
});
|
||||
expect(explicitRetry.status).toBe(200);
|
||||
expect(stub.receivedRequests.filter((request) => request.path === '/tap')).toHaveLength(beforeTaps + 1);
|
||||
expect(bootstraps).toBe(2);
|
||||
} finally {
|
||||
await d.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('returns 503 when no device tunnel is provided', async () => {
|
||||
// Force tunnel provider to return null by closing + restarting with null provider.
|
||||
await daemon.close();
|
||||
|
||||
@@ -105,6 +105,219 @@ describe('bootstrapTunnel', () => {
|
||||
}
|
||||
});
|
||||
|
||||
test('never auto-selects a connected paired Vision Pro ahead of an iPhone', async () => {
|
||||
const spawn = makeSpawn([
|
||||
{
|
||||
argsMatch: /devicectl list devices/,
|
||||
jsonOutput: {
|
||||
result: { devices: [
|
||||
{
|
||||
identifier: 'VISION',
|
||||
connectionProperties: { tunnelState: 'connected', pairingState: 'paired' },
|
||||
deviceProperties: { name: 'Vision Pro' },
|
||||
hardwareProperties: {
|
||||
productType: 'RealityDevice17,1',
|
||||
platform: 'visionOS',
|
||||
deviceType: 'realityDevice',
|
||||
},
|
||||
},
|
||||
{
|
||||
identifier: 'IPHONE',
|
||||
connectionProperties: { tunnelState: 'connected', pairingState: 'paired', transportType: 'wired' },
|
||||
deviceProperties: { name: 'USB iPhone' },
|
||||
hardwareProperties: {
|
||||
productType: 'iPhone17,1',
|
||||
platform: 'iOS',
|
||||
deviceType: 'iPhone',
|
||||
},
|
||||
},
|
||||
] },
|
||||
},
|
||||
},
|
||||
{
|
||||
argsMatch: /devicectl device info processes -d IPHONE/,
|
||||
jsonOutput: { result: { runningProcesses: [{ executable: 'file:///var/containers/Bundle/Application/X/com.test.app/com.test' }] } },
|
||||
},
|
||||
{
|
||||
argsMatch: /devicectl device info details --device IPHONE/,
|
||||
jsonOutput: { result: { connectionProperties: { tunnelIPAddress: 'fd00::17' } } },
|
||||
},
|
||||
{
|
||||
argsMatch: /devicectl device copy from --device IPHONE/,
|
||||
destOutput: 'TOKEN\n',
|
||||
},
|
||||
]);
|
||||
|
||||
const r = await bootstrapTunnel({
|
||||
bundleId: 'com.test',
|
||||
spawnImpl: spawn,
|
||||
fetchImpl: (async () => new Response('{"ok":true}', { status: 200 })) as typeof fetch,
|
||||
});
|
||||
|
||||
expect(r.ok).toBe(true);
|
||||
if (r.ok) expect(r.tunnel.udid).toBe('IPHONE');
|
||||
});
|
||||
|
||||
test('fails clearly when only non-iPhone platforms are connected', async () => {
|
||||
const spawn = makeSpawn([
|
||||
{
|
||||
argsMatch: /devicectl list devices/,
|
||||
jsonOutput: {
|
||||
result: { devices: [
|
||||
{
|
||||
identifier: 'VISION',
|
||||
connectionProperties: { tunnelState: 'connected', pairingState: 'paired' },
|
||||
deviceProperties: { name: 'Vision Pro' },
|
||||
hardwareProperties: {
|
||||
productType: 'RealityDevice17,1',
|
||||
platform: 'visionOS',
|
||||
deviceType: 'realityDevice',
|
||||
},
|
||||
},
|
||||
{
|
||||
identifier: 'IPAD',
|
||||
connectionProperties: { tunnelState: 'connected', pairingState: 'paired' },
|
||||
deviceProperties: { name: 'iPad' },
|
||||
hardwareProperties: {
|
||||
productType: 'iPad16,6',
|
||||
platform: 'iOS',
|
||||
deviceType: 'iPad',
|
||||
},
|
||||
},
|
||||
] },
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const r = await bootstrapTunnel({ bundleId: 'com.test', spawnImpl: spawn });
|
||||
expect(r.ok).toBe(false);
|
||||
if (!r.ok) {
|
||||
expect(r.error).toBe('device_not_found');
|
||||
expect(r.detail).toContain('no iPhone');
|
||||
}
|
||||
});
|
||||
|
||||
test('rejects an explicitly targeted non-iPhone device', async () => {
|
||||
const spawn = makeSpawn([
|
||||
{
|
||||
argsMatch: /devicectl list devices/,
|
||||
jsonOutput: {
|
||||
result: { devices: [{
|
||||
identifier: 'VISION',
|
||||
connectionProperties: { tunnelState: 'connected', pairingState: 'paired' },
|
||||
deviceProperties: { name: 'Vision Pro' },
|
||||
hardwareProperties: {
|
||||
productType: 'RealityDevice17,1',
|
||||
platform: 'visionOS',
|
||||
deviceType: 'realityDevice',
|
||||
},
|
||||
}] },
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const r = await bootstrapTunnel({ bundleId: 'com.test', udid: 'VISION', spawnImpl: spawn });
|
||||
expect(r.ok).toBe(false);
|
||||
if (!r.ok) {
|
||||
expect(r.error).toBe('device_not_found');
|
||||
expect(r.detail).toContain('not an iPhone');
|
||||
}
|
||||
});
|
||||
|
||||
test('skips an unavailable paired device for a connected or available paired device', async () => {
|
||||
const spawn = makeSpawn([
|
||||
{
|
||||
argsMatch: /devicectl list devices/,
|
||||
jsonOutput: {
|
||||
result: { devices: [
|
||||
{
|
||||
identifier: 'STALE',
|
||||
connectionProperties: { tunnelState: 'unavailable', pairingState: 'paired' },
|
||||
deviceProperties: { name: 'Stale iPhone' },
|
||||
hardwareProperties: { productType: 'iPhone17,1' },
|
||||
},
|
||||
{
|
||||
identifier: 'WIRED',
|
||||
connectionProperties: { tunnelState: 'available', pairingState: 'paired', transportType: 'wired' },
|
||||
deviceProperties: { name: 'Wired iPhone' },
|
||||
hardwareProperties: { productType: 'iPhone18,2' },
|
||||
},
|
||||
] },
|
||||
},
|
||||
},
|
||||
{
|
||||
argsMatch: /devicectl device info processes -d WIRED/,
|
||||
jsonOutput: { result: { runningProcesses: [{ executable: 'file:///var/containers/Bundle/Application/X/com.test.app/com.test' }] } },
|
||||
},
|
||||
{
|
||||
argsMatch: /devicectl device info details --device WIRED/,
|
||||
jsonOutput: { result: { connectionProperties: { tunnelIPAddress: 'fd00::2' } } },
|
||||
},
|
||||
{
|
||||
argsMatch: /devicectl device copy from --device WIRED/,
|
||||
destOutput: 'TOKEN\n',
|
||||
},
|
||||
]);
|
||||
|
||||
const r = await bootstrapTunnel({
|
||||
bundleId: 'com.test',
|
||||
spawnImpl: spawn,
|
||||
fetchImpl: (async () => new Response('{"ok":true}', { status: 200 })) as typeof fetch,
|
||||
});
|
||||
|
||||
expect(r.ok).toBe(true);
|
||||
if (r.ok) expect(r.tunnel.udid).toBe('WIRED');
|
||||
});
|
||||
|
||||
test('accepts a wired iPhone whose tunnel is disconnected until devicectl wakes it', async () => {
|
||||
const spawn = makeSpawn([
|
||||
{
|
||||
argsMatch: /devicectl list devices/,
|
||||
jsonOutput: {
|
||||
result: { devices: [
|
||||
{
|
||||
identifier: 'STALE-VISION',
|
||||
connectionProperties: { tunnelState: 'unavailable', pairingState: 'paired' },
|
||||
deviceProperties: { name: 'Stale Vision Pro' },
|
||||
hardwareProperties: { productType: 'RealityDevice14,1' },
|
||||
},
|
||||
{
|
||||
identifier: 'WIRED-DISCONNECTED',
|
||||
connectionProperties: {
|
||||
tunnelState: 'disconnected',
|
||||
pairingState: 'paired',
|
||||
transportType: 'wired',
|
||||
},
|
||||
deviceProperties: { name: 'USB iPhone' },
|
||||
hardwareProperties: { productType: 'iPhone18,2' },
|
||||
},
|
||||
] },
|
||||
},
|
||||
},
|
||||
{
|
||||
argsMatch: /devicectl device info processes -d WIRED-DISCONNECTED/,
|
||||
jsonOutput: { result: { runningProcesses: [{ executable: 'file:\/\/\/var\/containers\/Bundle\/Application\/X\/com.test.app\/com.test' }] } },
|
||||
},
|
||||
{
|
||||
argsMatch: /devicectl device info details --device WIRED-DISCONNECTED/,
|
||||
jsonOutput: { result: { connectionProperties: { tunnelIPAddress: 'fd00::27' } } },
|
||||
},
|
||||
{
|
||||
argsMatch: /devicectl device copy from --device WIRED-DISCONNECTED/,
|
||||
destOutput: 'TOKEN\n',
|
||||
},
|
||||
]);
|
||||
|
||||
const r = await bootstrapTunnel({
|
||||
bundleId: 'com.test',
|
||||
spawnImpl: spawn,
|
||||
fetchImpl: (async () => new Response('{"ok":true}', { status: 200 })) as typeof fetch,
|
||||
});
|
||||
|
||||
expect(r.ok).toBe(true);
|
||||
if (r.ok) expect(r.tunnel.udid).toBe('WIRED-DISCONNECTED');
|
||||
});
|
||||
|
||||
test('returns device_locked when launchApp errors due to lock', async () => {
|
||||
const spawn = makeSpawn([
|
||||
{
|
||||
@@ -166,6 +379,48 @@ describe('bootstrapTunnel', () => {
|
||||
if (!r.ok) expect(r.error).toBe('state_server_unreachable');
|
||||
});
|
||||
|
||||
test('rejects a StateServer owned by a different app bundle', async () => {
|
||||
const spawn = makeSpawn([
|
||||
{
|
||||
argsMatch: /devicectl list devices/,
|
||||
jsonOutput: {
|
||||
result: { devices: [{
|
||||
identifier: 'TEST',
|
||||
connectionProperties: { tunnelState: 'connected', pairingState: 'paired' },
|
||||
deviceProperties: { name: 'Test' },
|
||||
hardwareProperties: { productType: 'iPhone18,2' },
|
||||
}] },
|
||||
},
|
||||
},
|
||||
{
|
||||
argsMatch: /devicectl device info processes/,
|
||||
jsonOutput: { result: { runningProcesses: [] } },
|
||||
},
|
||||
{
|
||||
argsMatch: /devicectl device process launch/,
|
||||
},
|
||||
{
|
||||
argsMatch: /devicectl device info details/,
|
||||
jsonOutput: { result: { connectionProperties: { tunnelIPAddress: 'fd00::9' } } },
|
||||
},
|
||||
]);
|
||||
|
||||
const r = await bootstrapTunnel({
|
||||
bundleId: 'com.expected.app',
|
||||
spawnImpl: spawn,
|
||||
fetchImpl: (async () => new Response(
|
||||
JSON.stringify({ bundle_id: 'com.other.debug-app' }),
|
||||
{ status: 200, headers: { 'content-type': 'application/json' } },
|
||||
)) as typeof fetch,
|
||||
});
|
||||
|
||||
expect(r.ok).toBe(false);
|
||||
if (!r.ok) {
|
||||
expect(r.error).toBe('wrong_app');
|
||||
expect(r.detail).toContain('com.other.debug-app');
|
||||
}
|
||||
});
|
||||
|
||||
test('happy path: returns DeviceTunnel with rotated token', async () => {
|
||||
const spawn = makeSpawn([
|
||||
{
|
||||
@@ -234,6 +489,125 @@ describe('bootstrapTunnel', () => {
|
||||
expect(fetchCalls[fetchCalls.length - 1]?.url).toContain('/auth/rotate');
|
||||
});
|
||||
|
||||
test('relaunches once when a prior daemon consumed the running app boot token', async () => {
|
||||
const spawn = makeSpawn([
|
||||
{
|
||||
argsMatch: /devicectl list devices/,
|
||||
jsonOutput: {
|
||||
result: { devices: [{
|
||||
identifier: 'TEST-UDID',
|
||||
connectionProperties: { tunnelState: 'connected', pairingState: 'paired' },
|
||||
deviceProperties: { name: 'Test Device' },
|
||||
hardwareProperties: { productType: 'iPhone18,2' },
|
||||
}] },
|
||||
},
|
||||
},
|
||||
{
|
||||
argsMatch: /devicectl device info processes/,
|
||||
jsonOutput: { result: { runningProcesses: [{ executable: 'file:\/\/\/var\/containers\/Bundle\/Application\/X\/com.test.app\/com.test' }] } },
|
||||
},
|
||||
{
|
||||
argsMatch: /devicectl device info details/,
|
||||
jsonOutput: { result: { connectionProperties: { tunnelIPAddress: 'fd99::beef' } } },
|
||||
},
|
||||
{
|
||||
// A prior daemon rotated the one-use token, so StateServer deleted it.
|
||||
argsMatch: /devicectl device copy from/,
|
||||
exitCode: 1,
|
||||
stderr: 'source does not exist',
|
||||
},
|
||||
{
|
||||
argsMatch: /devicectl device process launch --device TEST-UDID --terminate-existing com\.test/,
|
||||
},
|
||||
{
|
||||
argsMatch: /devicectl device copy from/,
|
||||
destOutput: 'FRESH-BOOT-TOKEN\n',
|
||||
},
|
||||
]);
|
||||
const fetchCalls: Array<{ url: string; authorization?: string }> = [];
|
||||
|
||||
const r = await bootstrapTunnel({
|
||||
bundleId: 'com.test',
|
||||
spawnImpl: spawn,
|
||||
fetchImpl: (async (url, init) => {
|
||||
const u = String(url);
|
||||
const headers = init?.headers as Record<string, string> | undefined;
|
||||
fetchCalls.push({ url: u, authorization: headers?.Authorization });
|
||||
if (u.endsWith('/healthz')) {
|
||||
return new Response(JSON.stringify({ bundle_id: 'com.test' }), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
}
|
||||
if (u.endsWith('/auth/rotate') && headers?.Authorization === 'Bearer FRESH-BOOT-TOKEN') {
|
||||
return new Response('{"ok":true}', { status: 200 });
|
||||
}
|
||||
return new Response('unauthorized', { status: 401 });
|
||||
}) as typeof fetch,
|
||||
startupTimeoutMs: 1_000,
|
||||
});
|
||||
|
||||
expect(r.ok).toBe(true);
|
||||
expect(fetchCalls.filter((call) => call.url.endsWith('/healthz'))).toHaveLength(2);
|
||||
expect(fetchCalls.at(-1)?.authorization).toBe('Bearer FRESH-BOOT-TOKEN');
|
||||
});
|
||||
|
||||
test('rechecks bundle ownership after recovering a consumed boot token', async () => {
|
||||
const spawn = makeSpawn([
|
||||
{
|
||||
argsMatch: /devicectl list devices/,
|
||||
jsonOutput: {
|
||||
result: { devices: [{
|
||||
identifier: 'TEST-UDID',
|
||||
connectionProperties: { tunnelState: 'connected', pairingState: 'paired' },
|
||||
deviceProperties: { name: 'Test Device' },
|
||||
hardwareProperties: { productType: 'iPhone18,2' },
|
||||
}] },
|
||||
},
|
||||
},
|
||||
{
|
||||
argsMatch: /devicectl device info processes/,
|
||||
jsonOutput: { result: { runningProcesses: [{ executable: 'file:\/\/\/var\/containers\/Bundle\/Application\/X\/com.test.app\/com.test' }] } },
|
||||
},
|
||||
{
|
||||
argsMatch: /devicectl device info details/,
|
||||
jsonOutput: { result: { connectionProperties: { tunnelIPAddress: 'fd99::beef' } } },
|
||||
},
|
||||
{ argsMatch: /devicectl device copy from/, exitCode: 1 },
|
||||
{
|
||||
argsMatch: /devicectl device process launch --device TEST-UDID --terminate-existing com\.test/,
|
||||
},
|
||||
{ argsMatch: /devicectl device copy from/, destOutput: 'FRESH-BOOT-TOKEN\n' },
|
||||
]);
|
||||
let healthChecks = 0;
|
||||
let rotateCalls = 0;
|
||||
|
||||
const r = await bootstrapTunnel({
|
||||
bundleId: 'com.test',
|
||||
spawnImpl: spawn,
|
||||
fetchImpl: (async (url) => {
|
||||
const u = String(url);
|
||||
if (u.endsWith('/healthz')) {
|
||||
healthChecks++;
|
||||
return new Response(JSON.stringify({
|
||||
bundle_id: healthChecks === 1 ? 'com.test' : 'com.other.debug-app',
|
||||
}), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
}
|
||||
rotateCalls++;
|
||||
return new Response('{"ok":true}', { status: 200 });
|
||||
}) as typeof fetch,
|
||||
startupTimeoutMs: 1_000,
|
||||
});
|
||||
|
||||
expect(r.ok).toBe(false);
|
||||
if (!r.ok) expect(r.error).toBe('wrong_app');
|
||||
expect(healthChecks).toBe(2);
|
||||
expect(rotateCalls).toBe(0);
|
||||
});
|
||||
|
||||
test('resolve_failed when hostname cant be resolved to an IPv6', async () => {
|
||||
const spawn = makeSpawn([
|
||||
{
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
// swift-tools-version:5.9
|
||||
//
|
||||
// gen-accessors-tool — SwiftPM tool that reads an app's Swift source via
|
||||
// swift-syntax, finds @Observable classes with @Snapshotable-marked fields,
|
||||
// and emits StateAccessor.swift for each one.
|
||||
// swift-syntax, finds @Observable classes with `// @Snapshotable`-marked
|
||||
// fields, and emits StateAccessor.swift for each one.
|
||||
//
|
||||
// First build is 2-5 min on a cold machine (swift-syntax compile chain).
|
||||
// Subsequent runs are content-hash-cached and finish in ~50ms.
|
||||
@@ -31,10 +31,5 @@ let package = Package(
|
||||
],
|
||||
path: "Sources/GenAccessors"
|
||||
),
|
||||
.testTarget(
|
||||
name: "GenAccessorsTests",
|
||||
dependencies: ["GenAccessors"],
|
||||
path: "Tests/GenAccessorsTests"
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// gen-accessors entry point. Walks the input dir for *.swift files, parses
|
||||
// each via SwiftParser, finds @Observable classes with @Snapshotable-marked
|
||||
// properties, and emits StateAccessor.swift for each.
|
||||
// each via SwiftParser, finds @Observable classes with `// @Snapshotable`
|
||||
// source-marker comments (plus the legacy attribute form), and emits
|
||||
// StateAccessor.swift for each.
|
||||
//
|
||||
// Output goes to --output (default: same dir as input). Cache key is
|
||||
// computed from a composite hash and stored at
|
||||
@@ -15,6 +16,8 @@ struct AccessorSpec {
|
||||
let fields: [(name: String, typeText: String)]
|
||||
}
|
||||
|
||||
private let generatorFormatVersion = "accessor-generator-v5"
|
||||
|
||||
@main
|
||||
struct GenAccessors {
|
||||
static func main() async {
|
||||
@@ -32,38 +35,83 @@ struct GenAccessors {
|
||||
}()
|
||||
|
||||
// Walk + collect *.swift files
|
||||
guard let swiftFiles = collectSwiftFiles(at: inputDir) else {
|
||||
guard let swiftFiles = collectSwiftFiles(at: inputDir, excluding: outputDir) else {
|
||||
FileHandle.standardError.write(Data("input dir not found: \(inputDir)\n".utf8))
|
||||
exit(3)
|
||||
}
|
||||
|
||||
// Composite cache key — codex catch (source content alone misses
|
||||
// generator-logic changes).
|
||||
let cacheKey = computeCacheKey(swiftFiles: swiftFiles)
|
||||
let cacheDir = ("~/.gstack/cache/gen-accessors" as NSString).expandingTildeInPath
|
||||
// Parse + validate before consulting the cache. Invalid marked fields
|
||||
// must fail deterministically rather than being hidden by an older
|
||||
// cached output.
|
||||
var specs: [AccessorSpec] = []
|
||||
var diagnostics: [String] = []
|
||||
for path in swiftFiles {
|
||||
guard let source = try? String(contentsOfFile: path, encoding: .utf8) else { continue }
|
||||
let tree = Parser.parse(source: source)
|
||||
let visitor = ObservableClassVisitor(sourcePath: path, viewMode: .sourceAccurate)
|
||||
visitor.walk(tree)
|
||||
specs.append(contentsOf: visitor.specs)
|
||||
diagnostics.append(contentsOf: visitor.diagnostics)
|
||||
}
|
||||
var snapshotKeyOwners: [String: String] = [:]
|
||||
for spec in specs {
|
||||
for field in spec.fields {
|
||||
if let previous = snapshotKeyOwners[field.name] {
|
||||
diagnostics.append(
|
||||
"snapshot key '\(field.name)' is declared by both \(previous) and \(spec.className); "
|
||||
+ "keys must be unique across @Observable types"
|
||||
)
|
||||
} else {
|
||||
snapshotKeyOwners[field.name] = spec.className
|
||||
}
|
||||
}
|
||||
}
|
||||
if !diagnostics.isEmpty {
|
||||
let message = "gen-accessors: invalid @Snapshotable declaration(s):\n"
|
||||
+ diagnostics.map { " - \($0)" }.joined(separator: "\n") + "\n"
|
||||
FileHandle.standardError.write(Data(message.utf8))
|
||||
exit(4)
|
||||
}
|
||||
|
||||
let buildId = detectedBuildId()
|
||||
let accessorHash = computeAccessorHash(specs: specs)
|
||||
// Cache identity includes build provenance and generator ABI. The
|
||||
// separately computed accessorHash is schema-only and remains stable
|
||||
// across checkout paths, unrelated sources, and app rebuilds.
|
||||
let cacheKey = computeCacheKey(swiftFiles: swiftFiles, buildId: buildId)
|
||||
let cacheDir = getEnv("GSTACK_IOS_CACHE_ROOT")
|
||||
?? ("~/.gstack/cache/gen-accessors" as NSString).expandingTildeInPath
|
||||
let cachedOutput = "\(cacheDir)/\(cacheKey)/StateAccessor.swift"
|
||||
do {
|
||||
try FileManager.default.createDirectory(atPath: outputDir, withIntermediateDirectories: true)
|
||||
} catch {
|
||||
FileHandle.standardError.write(Data("gen-accessors: cannot create output directory: \(error)\n".utf8))
|
||||
exit(5)
|
||||
}
|
||||
if FileManager.default.fileExists(atPath: cachedOutput) {
|
||||
// Cache hit. Copy to output dir.
|
||||
try? FileManager.default.removeItem(atPath: "\(outputDir)/StateAccessor.swift")
|
||||
try? FileManager.default.copyItem(atPath: cachedOutput, toPath: "\(outputDir)/StateAccessor.swift")
|
||||
let finalOutput = "\(outputDir)/StateAccessor.swift"
|
||||
do {
|
||||
if FileManager.default.fileExists(atPath: finalOutput) {
|
||||
try FileManager.default.removeItem(atPath: finalOutput)
|
||||
}
|
||||
try FileManager.default.copyItem(atPath: cachedOutput, toPath: finalOutput)
|
||||
} catch {
|
||||
FileHandle.standardError.write(Data("gen-accessors: cannot restore cached output: \(error)\n".utf8))
|
||||
exit(5)
|
||||
}
|
||||
print("gen-accessors: cache hit (\(cacheKey))")
|
||||
return
|
||||
}
|
||||
|
||||
// Parse + extract specs
|
||||
var specs: [AccessorSpec] = []
|
||||
for path in swiftFiles {
|
||||
guard let source = try? String(contentsOfFile: path, encoding: .utf8) else { continue }
|
||||
let tree = Parser.parse(source: source)
|
||||
let visitor = ObservableClassVisitor(viewMode: .sourceAccurate)
|
||||
visitor.walk(tree)
|
||||
specs.append(contentsOf: visitor.specs)
|
||||
}
|
||||
|
||||
// Emit
|
||||
let output = render(specs: specs, buildId: getEnv("APP_BUILD_ID") ?? "unknown", accessorHash: cacheKey)
|
||||
try? FileManager.default.createDirectory(atPath: outputDir, withIntermediateDirectories: true)
|
||||
try? output.write(toFile: "\(outputDir)/StateAccessor.swift", atomically: true, encoding: .utf8)
|
||||
let output = render(specs: specs, buildId: buildId, accessorHash: accessorHash)
|
||||
do {
|
||||
try output.write(toFile: "\(outputDir)/StateAccessor.swift", atomically: true, encoding: .utf8)
|
||||
} catch {
|
||||
FileHandle.standardError.write(Data("gen-accessors: cannot write output: \(error)\n".utf8))
|
||||
exit(5)
|
||||
}
|
||||
|
||||
// Populate cache
|
||||
try? FileManager.default.createDirectory(atPath: "\(cacheDir)/\(cacheKey)", withIntermediateDirectories: true)
|
||||
@@ -72,46 +120,154 @@ struct GenAccessors {
|
||||
print("gen-accessors: wrote \(specs.count) accessor(s) to \(outputDir)/StateAccessor.swift")
|
||||
}
|
||||
|
||||
static func collectSwiftFiles(at path: String) -> [String]? {
|
||||
guard let enumerator = FileManager.default.enumerator(atPath: path) else { return nil }
|
||||
static func collectSwiftFiles(at path: String, excluding outputPath: String) -> [String]? {
|
||||
let inputURL = URL(fileURLWithPath: path).standardizedFileURL
|
||||
let outputURL = URL(fileURLWithPath: outputPath).standardizedFileURL
|
||||
var isDirectory: ObjCBool = false
|
||||
guard FileManager.default.fileExists(atPath: inputURL.path, isDirectory: &isDirectory),
|
||||
isDirectory.boolValue,
|
||||
let enumerator = FileManager.default.enumerator(
|
||||
at: inputURL,
|
||||
includingPropertiesForKeys: [.isDirectoryKey],
|
||||
options: []
|
||||
) else { return nil }
|
||||
|
||||
// When --output is the input root, scan the root but still exclude
|
||||
// StateAccessor.swift. For a nested output, skip the entire subtree.
|
||||
let shouldExcludeOutputSubtree = outputURL.path != inputURL.path
|
||||
var files: [String] = []
|
||||
for case let f as String in enumerator {
|
||||
if f.hasSuffix(".swift") { files.append("\(path)/\(f)") }
|
||||
for case let fileURL as URL in enumerator {
|
||||
let normalized = fileURL.standardizedFileURL
|
||||
let values = try? normalized.resourceValues(forKeys: [.isDirectoryKey])
|
||||
if values?.isDirectory == true {
|
||||
if normalized.lastPathComponent == "DebugBridgeGenerated"
|
||||
|| (shouldExcludeOutputSubtree && normalized.path == outputURL.path) {
|
||||
enumerator.skipDescendants()
|
||||
}
|
||||
continue
|
||||
}
|
||||
guard normalized.pathExtension == "swift",
|
||||
normalized.lastPathComponent != "StateAccessor.swift" else { continue }
|
||||
files.append(normalized.path)
|
||||
}
|
||||
return files.sorted()
|
||||
}
|
||||
|
||||
static func computeCacheKey(swiftFiles: [String]) -> String {
|
||||
static func computeCacheKey(swiftFiles: [String], buildId: String) -> String {
|
||||
// Codex-flagged: hash must include Swift version, tool git rev, platform.
|
||||
let swiftVer = getEnv("SWIFT_VERSION") ?? "unknown"
|
||||
let toolRev = getEnv("GEN_ACCESSORS_REV") ?? "dev"
|
||||
let platform = "darwin-arm64" // simplified for the test harness
|
||||
var combined = "swift=\(swiftVer)|tool=\(toolRev)|platform=\(platform)|"
|
||||
let toolRev = getEnv("GEN_ACCESSORS_REV") ?? generatorFormatVersion
|
||||
#if arch(arm64)
|
||||
let platform = "darwin-arm64"
|
||||
#elseif arch(x86_64)
|
||||
let platform = "darwin-x86_64"
|
||||
#else
|
||||
let platform = "darwin-unknown"
|
||||
#endif
|
||||
var combined = Data("\(generatorFormatVersion)|swift=\(swiftVer)|tool=\(toolRev)|platform=\(platform)|build=\(buildId)|".utf8)
|
||||
for path in swiftFiles {
|
||||
if let data = try? Data(contentsOf: URL(fileURLWithPath: path)) {
|
||||
combined += "\(path):\(data.count):\(data.sha256())|"
|
||||
// Do not include absolute checkout paths in cache identity.
|
||||
combined.append(Data("\(data.count):".utf8))
|
||||
combined.append(data)
|
||||
combined.append(Data("|".utf8))
|
||||
}
|
||||
}
|
||||
return combined.data(using: .utf8)!.sha256()
|
||||
return combined.sha256()
|
||||
}
|
||||
|
||||
static func computeAccessorHash(specs: [AccessorSpec]) -> String {
|
||||
var signature = "snapshot-schema-v1\n"
|
||||
for spec in specs {
|
||||
signature += "C\(spec.className.utf8.count):\(spec.className)\n"
|
||||
for field in spec.fields {
|
||||
signature += "F\(field.name.utf8.count):\(field.name)"
|
||||
signature += "T\(field.typeText.utf8.count):\(field.typeText)\n"
|
||||
}
|
||||
signature += "E\n"
|
||||
}
|
||||
return signature.sha256()
|
||||
}
|
||||
|
||||
static func render(specs: [AccessorSpec], buildId: String, accessorHash: String) -> String {
|
||||
var out = "// AUTO-GENERATED — DO NOT EDIT. Regenerate with /ios-sync.\n"
|
||||
out += "#if DEBUG\nimport Foundation\nimport DebugBridge\n\n"
|
||||
out += "#if DEBUG\nimport Foundation\nimport DebugBridgeCore\n\n"
|
||||
if !specs.isEmpty {
|
||||
// JSONSerialization produces Foundation bridge objects. Direct
|
||||
// `as?` casts let NSNumber cross-cast between Bool and numeric
|
||||
// Swift types. Round-tripping through JSONDecoder enforces the
|
||||
// declared Codable shape (including every collection element)
|
||||
// and preserves a successful Optional nil as a double Optional.
|
||||
out += "private enum _GStackDebugBridgeSnapshotJSON {\n"
|
||||
out += " private struct Box<Value: Decodable>: Decodable {\n"
|
||||
out += " let value: Value\n"
|
||||
out += " }\n\n"
|
||||
out += " static func decode<Value: Decodable>(_ value: Any, as _: Value.Type) -> Value? {\n"
|
||||
out += " let object: [String: Any] = [\"value\": value]\n"
|
||||
out += " guard JSONSerialization.isValidJSONObject(object),\n"
|
||||
out += " let data = try? JSONSerialization.data(withJSONObject: object) else {\n"
|
||||
out += " return nil\n"
|
||||
out += " }\n"
|
||||
out += " do {\n"
|
||||
out += " return try JSONDecoder().decode(Box<Value>.self, from: data).value\n"
|
||||
out += " } catch {\n"
|
||||
out += " return nil\n"
|
||||
out += " }\n"
|
||||
out += " }\n"
|
||||
out += "}\n\n"
|
||||
}
|
||||
for spec in specs {
|
||||
out += "@MainActor\npublic enum \(spec.className)Accessor {\n"
|
||||
out += " public static func register(_ state: \(spec.className)) {\n"
|
||||
// Accessors live in the app target beside its usually-internal
|
||||
// state types. A public signature cannot expose an internal type.
|
||||
out += "@MainActor\nenum \(spec.className)Accessor {\n"
|
||||
out += " static func register(_ state: \(spec.className)) {\n"
|
||||
out += " StateServer.shared.register(\n"
|
||||
out += " buildId: \"\(buildId)\",\n"
|
||||
out += " accessorHash: \"\(accessorHash)\",\n"
|
||||
out += " atomicRestore: { _ in .ok }\n"
|
||||
out += " buildId: {\n"
|
||||
out += " let shortVersion = Bundle.main.object(forInfoDictionaryKey: \"CFBundleShortVersionString\") as? String\n"
|
||||
out += " let bundleVersion = Bundle.main.object(forInfoDictionaryKey: \"CFBundleVersion\") as? String\n"
|
||||
out += " if let shortVersion, let bundleVersion { return \"\\(shortVersion) (\\(bundleVersion))\" }\n"
|
||||
out += " return shortVersion ?? bundleVersion ?? \(String(reflecting: buildId))\n"
|
||||
out += " }(),\n"
|
||||
out += " accessorHash: \(String(reflecting: accessorHash)),\n"
|
||||
out += " atomicRestore: { keys, apply in\n"
|
||||
out += " // Validate every key and value before assignment.\n"
|
||||
out += " // StateServer invokes every model once with apply=false,\n"
|
||||
out += " // then applies every validated model with apply=true.\n"
|
||||
for (index, field) in spec.fields.enumerated() {
|
||||
let (name, typeText) = field
|
||||
out += " guard let raw\(index) = keys[\"\(name)\"] else {\n"
|
||||
out += " return .missingKey(\"\(name)\")\n"
|
||||
out += " }\n"
|
||||
out += " guard let restored\(index): \(typeText) = _GStackDebugBridgeSnapshotJSON.decode(raw\(index), as: \(typeText).self) else {\n"
|
||||
out += " return .typeMismatch(\"\(name)\")\n"
|
||||
out += " }\n"
|
||||
}
|
||||
out += " if apply {\n"
|
||||
for (index, field) in spec.fields.enumerated() {
|
||||
out += " state.\(field.name) = restored\(index)\n"
|
||||
}
|
||||
out += " }\n"
|
||||
out += " return .ok\n"
|
||||
out += " }\n"
|
||||
out += " )\n"
|
||||
for (name, _) in spec.fields {
|
||||
for (name, typeText) in spec.fields {
|
||||
let wrapped = optionalWrappedType(typeText)
|
||||
out += " StateServer.shared.registerAccessor(\n"
|
||||
out += " key: \"\(name)\",\n"
|
||||
out += " type: \"Any\",\n"
|
||||
out += " read: { state.\(name) as Any? },\n"
|
||||
out += " write: { _ in false }\n"
|
||||
out += " type: \"\(typeText)\",\n"
|
||||
if wrapped != nil {
|
||||
out += " read: {\n"
|
||||
out += " guard let value = state.\(name) else { return NSNull() }\n"
|
||||
out += " return value as Any\n"
|
||||
out += " },\n"
|
||||
} else {
|
||||
out += " read: { state.\(name) as Any? },\n"
|
||||
}
|
||||
out += " write: { value in\n"
|
||||
out += " guard let typed: \(typeText) = _GStackDebugBridgeSnapshotJSON.decode(value, as: \(typeText).self) else { return false }\n"
|
||||
out += " state.\(name) = typed\n"
|
||||
out += " return true\n"
|
||||
out += " }\n"
|
||||
out += " )\n"
|
||||
}
|
||||
out += " }\n}\n\n"
|
||||
@@ -123,6 +279,59 @@ struct GenAccessors {
|
||||
|
||||
final class ObservableClassVisitor: SyntaxVisitor {
|
||||
var specs: [AccessorSpec] = []
|
||||
var diagnostics: [String] = []
|
||||
private let sourcePath: String
|
||||
|
||||
init(sourcePath: String, viewMode: SyntaxTreeViewMode) {
|
||||
self.sourcePath = sourcePath
|
||||
super.init(viewMode: viewMode)
|
||||
}
|
||||
|
||||
private func hasSnapshotableMarker(_ declaration: VariableDeclSyntax) -> Bool {
|
||||
// Preserve compatibility with source that defines a custom attribute
|
||||
// or wrapper, even though that form conflicts with @Observable in
|
||||
// ordinary app models.
|
||||
if declaration.attributes.contains(where: { attribute in
|
||||
guard let attribute = attribute.as(AttributeSyntax.self) else { return false }
|
||||
return attribute.attributeName.trimmedDescription == "Snapshotable"
|
||||
}) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Match a standalone ordinary line-comment trivia piece exactly.
|
||||
// Avoid declaration.description/contains: both would accept prose
|
||||
// such as `// do not expose through @Snapshotable`.
|
||||
return declaration.leadingTrivia.contains { piece in
|
||||
guard case .lineComment(let text) = piece else { return false }
|
||||
return text.dropFirst(2).trimmingCharacters(in: .whitespaces) == "@Snapshotable"
|
||||
}
|
||||
}
|
||||
|
||||
private func enclosingScopeDescription(for node: ClassDeclSyntax) -> String? {
|
||||
var ancestor = Syntax(node).parent
|
||||
while let current = ancestor {
|
||||
if let declaration = current.as(ClassDeclSyntax.self) {
|
||||
return "class \(declaration.name.text)"
|
||||
}
|
||||
if let declaration = current.as(StructDeclSyntax.self) {
|
||||
return "struct \(declaration.name.text)"
|
||||
}
|
||||
if let declaration = current.as(EnumDeclSyntax.self) {
|
||||
return "enum \(declaration.name.text)"
|
||||
}
|
||||
if let declaration = current.as(ActorDeclSyntax.self) {
|
||||
return "actor \(declaration.name.text)"
|
||||
}
|
||||
if let declaration = current.as(ExtensionDeclSyntax.self) {
|
||||
return "extension \(declaration.extendedType.trimmedDescription)"
|
||||
}
|
||||
if current.is(CodeBlockSyntax.self) {
|
||||
return "a local scope"
|
||||
}
|
||||
ancestor = current.parent
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
override func visit(_ node: ClassDeclSyntax) -> SyntaxVisitorContinueKind {
|
||||
// Look for @Observable attribute
|
||||
@@ -133,23 +342,88 @@ final class ObservableClassVisitor: SyntaxVisitor {
|
||||
guard isObservable else { return .visitChildren }
|
||||
|
||||
let className = node.name.text
|
||||
let markedMembers = node.memberBlock.members.compactMap {
|
||||
$0.decl.as(VariableDeclSyntax.self)
|
||||
}.filter(hasSnapshotableMarker)
|
||||
if !markedMembers.isEmpty, let enclosingScope = enclosingScopeDescription(for: node) {
|
||||
diagnostics.append(
|
||||
"\((sourcePath as NSString).lastPathComponent): nested @Observable class "
|
||||
+ "\(className) inside \(enclosingScope) is unsupported; "
|
||||
+ "move snapshot-enabled models to file scope"
|
||||
)
|
||||
// Continue walking so a more deeply nested marked model is also
|
||||
// diagnosed rather than silently omitted.
|
||||
return .visitChildren
|
||||
}
|
||||
var fields: [(String, String)] = []
|
||||
|
||||
for member in node.memberBlock.members {
|
||||
guard let varDecl = member.decl.as(VariableDeclSyntax.self) else { continue }
|
||||
// Field must be marked @Snapshotable to be included
|
||||
let isSnapshotable = varDecl.attributes.contains(where: { attr in
|
||||
guard let attr = attr.as(AttributeSyntax.self) else { return false }
|
||||
return attr.attributeName.trimmedDescription == "Snapshotable"
|
||||
})
|
||||
guard isSnapshotable else { continue }
|
||||
// Field must opt in with the source marker (or legacy attribute).
|
||||
guard hasSnapshotableMarker(varDecl) else { continue }
|
||||
|
||||
let bindingName = varDecl.bindings.first?
|
||||
.pattern.as(IdentifierPatternSyntax.self)?.identifier.text ?? "<unknown>"
|
||||
let context = "\((sourcePath as NSString).lastPathComponent): \(className).\(bindingName)"
|
||||
if varDecl.bindingSpecifier.text == "let" {
|
||||
diagnostics.append("\(context) must be declared var, not let")
|
||||
continue
|
||||
}
|
||||
let modifierNames = varDecl.modifiers.map { modifier -> String in
|
||||
let detail = modifier.detail?.trimmedDescription ?? ""
|
||||
return modifier.name.text + detail
|
||||
}
|
||||
if modifierNames.contains(where: {
|
||||
$0 == "private" || $0 == "fileprivate"
|
||||
|| $0 == "private(set)" || $0 == "fileprivate(set)"
|
||||
}) {
|
||||
diagnostics.append("\(context) cannot be private, fileprivate, private(set), or fileprivate(set)")
|
||||
continue
|
||||
}
|
||||
if modifierNames.contains("static") || modifierNames.contains("class") {
|
||||
diagnostics.append("\(context) must be an instance property")
|
||||
continue
|
||||
}
|
||||
if varDecl.bindings.count != 1 {
|
||||
diagnostics.append("\(context) declaration must contain exactly one binding")
|
||||
continue
|
||||
}
|
||||
|
||||
for binding in varDecl.bindings {
|
||||
if let pattern = binding.pattern.as(IdentifierPatternSyntax.self) {
|
||||
let name = pattern.identifier.text
|
||||
let typeText = binding.typeAnnotation?.type.trimmedDescription ?? "Any"
|
||||
fields.append((name, typeText))
|
||||
guard let pattern = binding.pattern.as(IdentifierPatternSyntax.self) else {
|
||||
diagnostics.append("\(context) only supports an identifier binding")
|
||||
continue
|
||||
}
|
||||
// An initializer plus an accessor block is a stored property
|
||||
// with observers. Without an initializer, conservatively
|
||||
// reject the block rather than emitting a setter for a
|
||||
// computed/read-only declaration.
|
||||
if binding.accessorBlock != nil && binding.initializer == nil {
|
||||
diagnostics.append("\(context) must be stored and writable")
|
||||
continue
|
||||
}
|
||||
guard let annotation = binding.typeAnnotation else {
|
||||
diagnostics.append("\(context) requires an explicit type annotation")
|
||||
continue
|
||||
}
|
||||
let name = pattern.identifier.text
|
||||
let typeText = annotation.type.trimmedDescription
|
||||
.split(whereSeparator: { $0.isWhitespace })
|
||||
.joined(separator: " ")
|
||||
switch parseJSONSnapshotType(typeText) {
|
||||
case .valid:
|
||||
break
|
||||
case .implicitlyUnwrappedOptional:
|
||||
diagnostics.append("\(context) cannot use an implicitly unwrapped Optional type")
|
||||
continue
|
||||
case .nestedOptional:
|
||||
diagnostics.append("\(context) cannot use a nested Optional type")
|
||||
continue
|
||||
case .unsupported:
|
||||
diagnostics.append("\(context) uses unsupported non-JSON snapshot type '\(typeText)'")
|
||||
continue
|
||||
}
|
||||
fields.append((name, typeText))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -160,10 +434,233 @@ final class ObservableClassVisitor: SyntaxVisitor {
|
||||
}
|
||||
}
|
||||
|
||||
private indirect enum JSONSnapshotType {
|
||||
case scalar
|
||||
case optional(JSONSnapshotType)
|
||||
case array(JSONSnapshotType)
|
||||
case dictionary(JSONSnapshotType)
|
||||
|
||||
var isOptional: Bool {
|
||||
if case .optional = self { return true }
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private enum JSONSnapshotTypeParseResult {
|
||||
case valid(JSONSnapshotType)
|
||||
case implicitlyUnwrappedOptional
|
||||
case nestedOptional
|
||||
case unsupported
|
||||
}
|
||||
|
||||
/// Parse the deliberately small set of Swift types that can make a lossless
|
||||
/// trip through JSONSerialization and JSONDecoder without application-defined
|
||||
/// encoding behavior. Type aliases and arbitrary Codable models are rejected:
|
||||
/// the bridge has no compiler/type-checker context in which to prove that they
|
||||
/// are JSON-native.
|
||||
private func parseJSONSnapshotType(_ source: String) -> JSONSnapshotTypeParseResult {
|
||||
let type = source.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !type.isEmpty else { return .unsupported }
|
||||
|
||||
if type.hasSuffix("!") {
|
||||
return .implicitlyUnwrappedOptional
|
||||
}
|
||||
|
||||
if type.hasSuffix("?") {
|
||||
let wrappedText = String(type.dropLast()).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
switch parseJSONSnapshotType(wrappedText) {
|
||||
case .valid(let wrapped):
|
||||
return wrapped.isOptional ? .nestedOptional : .valid(.optional(wrapped))
|
||||
case let issue:
|
||||
return issue
|
||||
}
|
||||
}
|
||||
|
||||
if let bracketContents = outerDelimitedContents(type, open: "[", close: "]") {
|
||||
let dictionaryParts = splitTopLevel(bracketContents, separator: ":")
|
||||
if dictionaryParts.count == 1 {
|
||||
switch parseJSONSnapshotType(dictionaryParts[0]) {
|
||||
case .valid(let element): return .valid(.array(element))
|
||||
case let issue: return issue
|
||||
}
|
||||
}
|
||||
guard dictionaryParts.count == 2, isStringType(dictionaryParts[0]) else {
|
||||
return .unsupported
|
||||
}
|
||||
switch parseJSONSnapshotType(dictionaryParts[1]) {
|
||||
case .valid(let value): return .valid(.dictionary(value))
|
||||
case let issue: return issue
|
||||
}
|
||||
}
|
||||
|
||||
if let generic = genericTypeParts(type) {
|
||||
let base = compactTypeName(generic.base)
|
||||
if base == "Optional" || base == "Swift.Optional" {
|
||||
guard generic.arguments.count == 1 else { return .unsupported }
|
||||
switch parseJSONSnapshotType(generic.arguments[0]) {
|
||||
case .valid(let wrapped):
|
||||
return wrapped.isOptional ? .nestedOptional : .valid(.optional(wrapped))
|
||||
case let issue:
|
||||
return issue
|
||||
}
|
||||
}
|
||||
if base == "Array" || base == "Swift.Array" {
|
||||
guard generic.arguments.count == 1 else { return .unsupported }
|
||||
switch parseJSONSnapshotType(generic.arguments[0]) {
|
||||
case .valid(let element): return .valid(.array(element))
|
||||
case let issue: return issue
|
||||
}
|
||||
}
|
||||
if base == "Dictionary" || base == "Swift.Dictionary" {
|
||||
guard generic.arguments.count == 2, isStringType(generic.arguments[0]) else {
|
||||
return .unsupported
|
||||
}
|
||||
switch parseJSONSnapshotType(generic.arguments[1]) {
|
||||
case .valid(let value): return .valid(.dictionary(value))
|
||||
case let issue: return issue
|
||||
}
|
||||
}
|
||||
return .unsupported
|
||||
}
|
||||
|
||||
let scalar = compactTypeName(type)
|
||||
let supportedScalars: Set<String> = [
|
||||
"String", "Swift.String",
|
||||
"Bool", "Swift.Bool",
|
||||
"Int", "Swift.Int", "Int8", "Swift.Int8", "Int16", "Swift.Int16",
|
||||
"Int32", "Swift.Int32", "Int64", "Swift.Int64",
|
||||
"UInt", "Swift.UInt", "UInt8", "Swift.UInt8", "UInt16", "Swift.UInt16",
|
||||
"UInt32", "Swift.UInt32", "UInt64", "Swift.UInt64",
|
||||
"Float", "Swift.Float", "Double", "Swift.Double",
|
||||
"CGFloat", "CoreGraphics.CGFloat",
|
||||
]
|
||||
return supportedScalars.contains(scalar) ? .valid(.scalar) : .unsupported
|
||||
}
|
||||
|
||||
private func isStringType(_ source: String) -> Bool {
|
||||
let type = compactTypeName(source)
|
||||
return type == "String" || type == "Swift.String"
|
||||
}
|
||||
|
||||
private func compactTypeName(_ source: String) -> String {
|
||||
source.filter { !$0.isWhitespace }
|
||||
}
|
||||
|
||||
private func outerDelimitedContents(_ source: String, open: Character, close: Character) -> String? {
|
||||
let characters = Array(source)
|
||||
guard characters.first == open, characters.last == close else { return nil }
|
||||
var depth = 0
|
||||
for (index, character) in characters.enumerated() {
|
||||
if character == open {
|
||||
depth += 1
|
||||
} else if character == close {
|
||||
depth -= 1
|
||||
guard depth >= 0 else { return nil }
|
||||
if depth == 0, index != characters.count - 1 { return nil }
|
||||
}
|
||||
}
|
||||
guard depth == 0 else { return nil }
|
||||
return String(characters.dropFirst().dropLast())
|
||||
}
|
||||
|
||||
private func genericTypeParts(_ source: String) -> (base: String, arguments: [String])? {
|
||||
let characters = Array(source)
|
||||
guard let openIndex = characters.firstIndex(of: "<") else { return nil }
|
||||
var depth = 0
|
||||
var closeIndex: Int?
|
||||
for index in openIndex..<characters.count {
|
||||
if characters[index] == "<" {
|
||||
depth += 1
|
||||
} else if characters[index] == ">" {
|
||||
depth -= 1
|
||||
guard depth >= 0 else { return nil }
|
||||
if depth == 0 {
|
||||
closeIndex = index
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
guard let closeIndex, closeIndex == characters.count - 1 else { return nil }
|
||||
let base = String(characters[..<openIndex]).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let argumentsText = String(characters[(openIndex + 1)..<closeIndex])
|
||||
let arguments = splitTopLevel(argumentsText, separator: ",")
|
||||
guard !base.isEmpty, !arguments.isEmpty else { return nil }
|
||||
return (base, arguments)
|
||||
}
|
||||
|
||||
private func splitTopLevel(_ source: String, separator: Character) -> [String] {
|
||||
let characters = Array(source)
|
||||
var angleDepth = 0
|
||||
var squareDepth = 0
|
||||
var parenDepth = 0
|
||||
var start = 0
|
||||
var parts: [String] = []
|
||||
|
||||
for (index, character) in characters.enumerated() {
|
||||
switch character {
|
||||
case "<": angleDepth += 1
|
||||
case ">": angleDepth -= 1
|
||||
case "[": squareDepth += 1
|
||||
case "]": squareDepth -= 1
|
||||
case "(": parenDepth += 1
|
||||
case ")": parenDepth -= 1
|
||||
default: break
|
||||
}
|
||||
if character == separator, angleDepth == 0, squareDepth == 0, parenDepth == 0 {
|
||||
parts.append(
|
||||
String(characters[start..<index]).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
)
|
||||
start = index + 1
|
||||
}
|
||||
}
|
||||
parts.append(String(characters[start...]).trimmingCharacters(in: .whitespacesAndNewlines))
|
||||
return parts
|
||||
}
|
||||
|
||||
func getEnv(_ key: String) -> String? {
|
||||
ProcessInfo.processInfo.environment[key]
|
||||
}
|
||||
|
||||
func detectedBuildId() -> String {
|
||||
if let explicit = getEnv("APP_BUILD_ID"), !explicit.isEmpty { return explicit }
|
||||
let marketing = getEnv("MARKETING_VERSION")
|
||||
let build = getEnv("CURRENT_PROJECT_VERSION")
|
||||
if let marketing, let build, !marketing.isEmpty, !build.isEmpty {
|
||||
return "\(marketing) (\(build))"
|
||||
}
|
||||
if let build, !build.isEmpty { return build }
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
func optionalWrappedType(_ typeText: String) -> String? {
|
||||
let type = typeText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if type.hasSuffix("?") {
|
||||
let wrapped = String(type.dropLast()).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return wrapped.isEmpty ? nil : wrapped
|
||||
}
|
||||
|
||||
guard let optionalRange = type.range(of: #"^Optional\s*<"#, options: .regularExpression),
|
||||
let open = type[optionalRange].lastIndex(of: "<") else { return nil }
|
||||
let openIndex = type.distance(from: type.startIndex, to: open)
|
||||
var depth = 0
|
||||
for (offset, char) in type.enumerated() where offset >= openIndex {
|
||||
if char == "<" { depth += 1 }
|
||||
else if char == ">" {
|
||||
depth -= 1
|
||||
if depth == 0 {
|
||||
let close = type.index(type.startIndex, offsetBy: offset)
|
||||
guard type[type.index(after: close)...].trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
|
||||
return nil
|
||||
}
|
||||
let wrappedStart = type.index(after: open)
|
||||
let wrapped = String(type[wrappedStart..<close]).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return wrapped.isEmpty ? nil : wrapped
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
import CryptoKit
|
||||
|
||||
extension Data {
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
// Tests for the gen-accessors TS port. Covers:
|
||||
//
|
||||
// - Parse: 3 regex-failure-mode fixtures from the fork (codex catch)
|
||||
// - Parse: lexical marker isolation + invalid declaration diagnostics
|
||||
// - Cache: same input → same key; different swift version → different key;
|
||||
// different tool rev → different key; file modified → different key
|
||||
// different tool rev/build provenance → different key
|
||||
// - Schema: stable hash depends only on ordered accessor signatures
|
||||
// - Optional: NSNull read/write/restore behavior and Swift type checking
|
||||
// - Prune: >30d entries removed, recent kept
|
||||
|
||||
import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
|
||||
import { spawnSync } from 'child_process';
|
||||
import { mkdtempSync, rmSync, writeFileSync, existsSync, readFileSync, mkdirSync, utimesSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { tmpdir } from 'os';
|
||||
@@ -13,9 +16,11 @@ import {
|
||||
collectSwiftFiles,
|
||||
parseSwift,
|
||||
computeCacheKey,
|
||||
computeAccessorHash,
|
||||
generate,
|
||||
pruneCache,
|
||||
render,
|
||||
AccessorGenerationError,
|
||||
type AccessorSpec,
|
||||
} from './gen-accessors';
|
||||
|
||||
@@ -30,12 +35,14 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe('parseSwift — fork regex-failure-mode fixtures', () => {
|
||||
test('parses @Observable class with simple @Snapshotable fields', () => {
|
||||
test('parses @Observable class with source-marker comments', () => {
|
||||
const src = `
|
||||
@Observable
|
||||
final class AppState {
|
||||
@Snapshotable var isLoggedIn: Bool = false
|
||||
@Snapshotable var username: String = ""
|
||||
// @Snapshotable
|
||||
var isLoggedIn: Bool = false
|
||||
// @Snapshotable
|
||||
var username: String = ""
|
||||
var notSnapshotable: Int = 0
|
||||
}
|
||||
`;
|
||||
@@ -46,12 +53,100 @@ final class AppState {
|
||||
expect(specs[0]!.fields.find(f => f.name === 'isLoggedIn')!.typeText).toBe('Bool');
|
||||
});
|
||||
|
||||
test('retains legacy @Snapshotable attribute parsing', () => {
|
||||
const specs = parseSwift(`
|
||||
@Observable
|
||||
final class LegacyState {
|
||||
@Snapshotable var counter: Int = 0
|
||||
}
|
||||
`);
|
||||
expect(specs).toEqual([{
|
||||
className: 'LegacyState',
|
||||
fields: [{ name: 'counter', typeText: 'Int' }],
|
||||
}]);
|
||||
});
|
||||
|
||||
test('does not treat documentation prose as a source marker', () => {
|
||||
const specs = parseSwift(`
|
||||
@Observable
|
||||
final class PrivateState {
|
||||
/// Do not expose this field through @Snapshotable.
|
||||
var token: String = "secret"
|
||||
}
|
||||
`);
|
||||
expect(specs).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('does not let a trailing marker comment bleed into the next field', () => {
|
||||
const specs = parseSwift(`
|
||||
@Observable
|
||||
final class PrivateState {
|
||||
var oldValue: Int = 0 // @Snapshotable
|
||||
var nextValue: Int = 1
|
||||
}
|
||||
`);
|
||||
expect(specs).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('ignores exact-looking markers inside nested block comments', () => {
|
||||
const specs = parseSwift(`
|
||||
@Observable
|
||||
final class PrivateState {
|
||||
/*
|
||||
/* // @Snapshotable */
|
||||
// @Snapshotable
|
||||
var leaked: String = "secret"
|
||||
*/
|
||||
// @Snapshotable
|
||||
var visible: Int = 1
|
||||
}
|
||||
`);
|
||||
expect(specs).toEqual([{
|
||||
className: 'PrivateState',
|
||||
fields: [{ name: 'visible', typeText: 'Int' }],
|
||||
}]);
|
||||
});
|
||||
|
||||
test('ignores declarations and markers inside multiline and raw strings', () => {
|
||||
const specs = parseSwift(`
|
||||
let ordinary = """
|
||||
@Observable class FakeA {
|
||||
// @Snapshotable
|
||||
var leaked: Int = 0
|
||||
}
|
||||
"""
|
||||
let raw = #"""
|
||||
@Observable class FakeB { @Snapshotable var leaked: String = "" }
|
||||
"""#
|
||||
@Observable
|
||||
final class RealState {
|
||||
// @Snapshotable
|
||||
var safe: Bool = true
|
||||
}
|
||||
`);
|
||||
expect(specs).toEqual([{
|
||||
className: 'RealState',
|
||||
fields: [{ name: 'safe', typeText: 'Bool' }],
|
||||
}]);
|
||||
});
|
||||
|
||||
test('ignores marker words in standalone trailing prose', () => {
|
||||
const specs = parseSwift(`
|
||||
@Observable
|
||||
final class PrivateState {
|
||||
// @Snapshotable fields are intentionally disabled here.
|
||||
var token: String = "secret"
|
||||
}
|
||||
`);
|
||||
expect(specs).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('handles @Snapshotable on multi-line type signatures', () => {
|
||||
const src = `
|
||||
@Observable
|
||||
class Cart {
|
||||
@Snapshotable var items:
|
||||
[CartItem<Detail>]
|
||||
[Dictionary<String, [Int]>]
|
||||
= []
|
||||
var unrelated: Int = 0
|
||||
}
|
||||
@@ -60,20 +155,67 @@ class Cart {
|
||||
expect(specs).toHaveLength(1);
|
||||
expect(specs[0]!.fields).toHaveLength(1);
|
||||
expect(specs[0]!.fields[0]!.name).toBe('items');
|
||||
expect(specs[0]!.fields[0]!.typeText).toContain('CartItem');
|
||||
expect(specs[0]!.fields[0]!.typeText).toContain('Dictionary');
|
||||
});
|
||||
|
||||
test('handles generic types in property signatures', () => {
|
||||
test('handles JSON-compatible generic types in property signatures', () => {
|
||||
const src = `
|
||||
@Observable
|
||||
class Repo {
|
||||
@Snapshotable var pages: Dictionary<String, [Result<Item, Error>]> = [:]
|
||||
@Snapshotable var pages: Dictionary<String, [Optional<Int>]> = [:]
|
||||
}
|
||||
`;
|
||||
const specs = parseSwift(src);
|
||||
expect(specs).toHaveLength(1);
|
||||
expect(specs[0]!.fields[0]!.typeText).toContain('Dictionary');
|
||||
expect(specs[0]!.fields[0]!.typeText).toContain('Result');
|
||||
expect(specs[0]!.fields[0]!.typeText).toContain('Optional');
|
||||
});
|
||||
|
||||
test('accepts qualified JSON-native scalar and collection spellings', () => {
|
||||
const specs = parseSwift(`
|
||||
@Observable
|
||||
class Metrics {
|
||||
// @Snapshotable
|
||||
var ratio: CoreGraphics.CGFloat = 0
|
||||
// @Snapshotable
|
||||
var counts: Swift.Array<Swift.Int> = []
|
||||
}
|
||||
`);
|
||||
expect(specs[0]!.fields.map((field) => field.typeText)).toEqual([
|
||||
'CoreGraphics.CGFloat',
|
||||
'Swift.Array<Swift.Int>',
|
||||
]);
|
||||
});
|
||||
|
||||
test('rejects custom values that Foundation JSON cannot serialize', () => {
|
||||
expect(() => parseSwift(`
|
||||
@Observable
|
||||
class Repo {
|
||||
// @Snapshotable
|
||||
var pages: Dictionary<String, [Result<Item, Error>]> = [:]
|
||||
}
|
||||
`)).toThrow("unsupported snapshot type 'Result<Item,Error>'");
|
||||
});
|
||||
|
||||
test('rejects nested observable types instead of emitting an unqualified reference', () => {
|
||||
expect(() => parseSwift(`
|
||||
enum Namespace {
|
||||
@Observable
|
||||
class State {
|
||||
// @Snapshotable
|
||||
var count: Int = 0
|
||||
}
|
||||
}
|
||||
`)).toThrow('nested @Observable types are not supported');
|
||||
});
|
||||
|
||||
test('ignores nested observable types that expose no snapshot fields', () => {
|
||||
expect(parseSwift(`
|
||||
enum Namespace {
|
||||
@Observable
|
||||
class State { var transient: Int = 0 }
|
||||
}
|
||||
`)).toEqual([]);
|
||||
});
|
||||
|
||||
test('ignores fields without @Snapshotable marker', () => {
|
||||
@@ -114,10 +256,7 @@ class B {
|
||||
expect(specs.map(s => s.className).sort()).toEqual(['A', 'B']);
|
||||
});
|
||||
|
||||
test('skips fields with computed body braces', () => {
|
||||
// Codex flagged "Properties with computed getters / didSet blocks" as a
|
||||
// failure mode of the fork's regex. We deliberately exclude them here —
|
||||
// computed properties are not snapshot-eligible.
|
||||
test('diagnoses fields with computed body braces', () => {
|
||||
const src = `
|
||||
@Observable
|
||||
class M {
|
||||
@@ -127,9 +266,50 @@ class M {
|
||||
}
|
||||
}
|
||||
`;
|
||||
const specs = parseSwift(src);
|
||||
expect(specs).toHaveLength(1);
|
||||
expect(specs[0]!.fields.map(f => f.name)).toEqual(['snapshotted']);
|
||||
expect(() => parseSwift(src)).toThrow("field 'computed' must be stored and writable");
|
||||
});
|
||||
|
||||
test.each([
|
||||
['let', '// @Snapshotable\n let immutable: Int = 1', 'must be declared var, not let'],
|
||||
['private', '// @Snapshotable\n private var secret: String = ""', 'cannot be private'],
|
||||
['fileprivate', '// @Snapshotable\n fileprivate var secret: String = ""', 'cannot be private'],
|
||||
['private(set)', '// @Snapshotable\n public private(set) var count: Int = 0', 'cannot be private'],
|
||||
['fileprivate(set)', '// @Snapshotable\n fileprivate(set) var count: Int = 0', 'cannot be private'],
|
||||
['inferred type', '// @Snapshotable\n var inferred = 42', 'requires an explicit type annotation'],
|
||||
['multiple bindings', '// @Snapshotable\n var a: Int = 1, b: Int = 2', 'exactly one binding'],
|
||||
['static', '// @Snapshotable\n static var shared: Int = 0', 'must be an instance property'],
|
||||
['nested Optional', '// @Snapshotable\n var nested: String?? = nil', 'cannot use a nested Optional'],
|
||||
['nested Optional in collection', '// @Snapshotable\n var nestedItems: [String??] = []', 'cannot use a nested Optional'],
|
||||
['implicitly unwrapped Optional', '// @Snapshotable\n var legacy: String! = nil', 'cannot use an implicitly unwrapped Optional'],
|
||||
['custom type', '// @Snapshotable\n var custom: CustomValue = .init()', 'uses unsupported snapshot type'],
|
||||
])('diagnoses invalid %s declarations', (_label, declaration, expected) => {
|
||||
expect(() => parseSwift(`
|
||||
@Observable
|
||||
final class InvalidState {
|
||||
${declaration}
|
||||
}
|
||||
`)).toThrow(expected);
|
||||
});
|
||||
|
||||
test('aggregates invalid declaration diagnostics with class and line context', () => {
|
||||
try {
|
||||
parseSwift(`
|
||||
@Observable
|
||||
final class InvalidState {
|
||||
// @Snapshotable
|
||||
let first: Int = 1
|
||||
// @Snapshotable
|
||||
private var second: String = ""
|
||||
}
|
||||
`);
|
||||
throw new Error('expected parseSwift to fail');
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(AccessorGenerationError);
|
||||
const generationError = error as AccessorGenerationError;
|
||||
expect(generationError.diagnostics).toHaveLength(2);
|
||||
expect(generationError.message).toContain('InvalidState (line 4)');
|
||||
expect(generationError.message).toContain('InvalidState (line 6)');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -244,6 +424,68 @@ describe('computeCacheKey', () => {
|
||||
});
|
||||
expect(k1).not.toBe(k2);
|
||||
});
|
||||
|
||||
test('app build provenance invalidates the cache key', () => {
|
||||
const f = join(workDir, 'a.swift');
|
||||
writeFileSync(f, '@Observable class A {}');
|
||||
const shared = {
|
||||
swiftFiles: [f],
|
||||
swiftVersion: '6.0.0',
|
||||
toolGitRev: 'abc',
|
||||
platformTriple: 'darwin-arm64',
|
||||
};
|
||||
expect(computeCacheKey({ ...shared, buildId: '100' })).not.toBe(
|
||||
computeCacheKey({ ...shared, buildId: '101' }),
|
||||
);
|
||||
});
|
||||
|
||||
test('equivalent source content does not depend on its absolute checkout path', () => {
|
||||
const firstDir = join(workDir, 'checkout-a');
|
||||
const secondDir = join(workDir, 'checkout-b');
|
||||
mkdirSync(firstDir);
|
||||
mkdirSync(secondDir);
|
||||
const first = join(firstDir, 'State.swift');
|
||||
const second = join(secondDir, 'State.swift');
|
||||
writeFileSync(first, '@Observable class A {}');
|
||||
writeFileSync(second, '@Observable class A {}');
|
||||
const versioning = { swiftVersion: '6', toolGitRev: 't', platformTriple: 'p' };
|
||||
expect(computeCacheKey({ swiftFiles: [first], ...versioning })).toBe(
|
||||
computeCacheKey({ swiftFiles: [second], ...versioning }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('computeAccessorHash', () => {
|
||||
const base: AccessorSpec[] = [{
|
||||
className: 'AppState',
|
||||
fields: [
|
||||
{ name: 'count', typeText: 'Int' },
|
||||
{ name: 'nickname', typeText: 'String?' },
|
||||
],
|
||||
}];
|
||||
|
||||
test('is deterministic for the same ordered accessor signatures', () => {
|
||||
expect(computeAccessorHash(base)).toBe(computeAccessorHash(structuredClone(base)));
|
||||
});
|
||||
|
||||
test('changes when field order, name, or type changes', () => {
|
||||
const reordered: AccessorSpec[] = [{
|
||||
className: 'AppState',
|
||||
fields: [...base[0]!.fields].reverse(),
|
||||
}];
|
||||
const renamed: AccessorSpec[] = [{
|
||||
className: 'AppState',
|
||||
fields: [{ name: 'total', typeText: 'Int' }, base[0]!.fields[1]!],
|
||||
}];
|
||||
const retyped: AccessorSpec[] = [{
|
||||
className: 'AppState',
|
||||
fields: [{ name: 'count', typeText: 'Int64' }, base[0]!.fields[1]!],
|
||||
}];
|
||||
const hash = computeAccessorHash(base);
|
||||
expect(computeAccessorHash(reordered)).not.toBe(hash);
|
||||
expect(computeAccessorHash(renamed)).not.toBe(hash);
|
||||
expect(computeAccessorHash(retyped)).not.toBe(hash);
|
||||
});
|
||||
});
|
||||
|
||||
describe('generate', () => {
|
||||
@@ -283,6 +525,35 @@ class AppState {
|
||||
expect(r1.cacheKey).toBe(r2.cacheKey);
|
||||
});
|
||||
|
||||
test('custom nested output subtree cannot poison the second-run cache key', () => {
|
||||
const inputDir = join(workDir, 'src');
|
||||
const outputDir = join(inputDir, 'generated', 'accessors');
|
||||
mkdirSync(inputDir);
|
||||
writeFileSync(join(inputDir, 'state.swift'), '@Observable class A { @Snapshotable var x: Int = 0 }');
|
||||
const cacheRoot = join(workDir, 'cache');
|
||||
|
||||
const r1 = generate({
|
||||
inputDir,
|
||||
outputDir,
|
||||
cacheRoot,
|
||||
swiftVersion: '6',
|
||||
toolGitRev: 't',
|
||||
platformTriple: 'p',
|
||||
});
|
||||
writeFileSync(join(outputDir, 'Ignored.swift'), '@Observable class Poison { @Snapshotable var bad: Int = 1 }');
|
||||
const r2 = generate({
|
||||
inputDir,
|
||||
outputDir,
|
||||
cacheRoot,
|
||||
swiftVersion: '6',
|
||||
toolGitRev: 't',
|
||||
platformTriple: 'p',
|
||||
});
|
||||
|
||||
expect(r2.cacheHit).toBe(true);
|
||||
expect(r2.cacheKey).toBe(r1.cacheKey);
|
||||
});
|
||||
|
||||
test('modifying source invalidates the cache', () => {
|
||||
const inputDir = join(workDir, 'src');
|
||||
mkdirSync(inputDir);
|
||||
@@ -295,6 +566,62 @@ class AppState {
|
||||
expect(r1.cacheKey).not.toBe(r2.cacheKey);
|
||||
expect(r2.cacheHit).toBe(false);
|
||||
});
|
||||
|
||||
test('unmarked source churn changes cache identity but not schema identity', () => {
|
||||
const inputDir = join(workDir, 'src');
|
||||
mkdirSync(inputDir);
|
||||
const state = join(inputDir, 'state.swift');
|
||||
const unrelated = join(inputDir, 'unrelated.swift');
|
||||
writeFileSync(state, '@Observable class A { // @Snapshotable\n var x: Int = 0\n }');
|
||||
writeFileSync(unrelated, 'struct Unrelated { let a = 1 }');
|
||||
const cacheRoot = join(workDir, 'cache');
|
||||
const options = { inputDir, cacheRoot, swiftVersion: '6', toolGitRev: 't', platformTriple: 'p' };
|
||||
const first = generate(options);
|
||||
writeFileSync(unrelated, 'struct Unrelated { let a = 2 }');
|
||||
const second = generate(options);
|
||||
expect(second.cacheKey).not.toBe(first.cacheKey);
|
||||
expect(second.accessorHash).toBe(first.accessorHash);
|
||||
});
|
||||
|
||||
test('buildId changes cannot reuse generated fallback provenance', () => {
|
||||
const inputDir = join(workDir, 'src');
|
||||
mkdirSync(inputDir);
|
||||
writeFileSync(join(inputDir, 'state.swift'), '@Observable class A { @Snapshotable var x: Int = 0 }');
|
||||
const cacheRoot = join(workDir, 'cache');
|
||||
const shared = { inputDir, cacheRoot, swiftVersion: '6', toolGitRev: 't', platformTriple: 'p' };
|
||||
const first = generate({ ...shared, buildId: 'build-100' });
|
||||
const second = generate({ ...shared, buildId: 'build-101' });
|
||||
expect(second.cacheKey).not.toBe(first.cacheKey);
|
||||
expect(second.cacheHit).toBe(false);
|
||||
expect(readFileSync(second.outputPath, 'utf8')).toContain('?? "build-101"');
|
||||
});
|
||||
|
||||
test('CLI exits 4 with actionable diagnostics and no generated output', () => {
|
||||
const inputDir = join(workDir, 'src');
|
||||
const outputDir = join(workDir, 'generated');
|
||||
mkdirSync(inputDir);
|
||||
writeFileSync(join(inputDir, 'state.swift'), `
|
||||
@Observable final class InvalidState {
|
||||
// @Snapshotable
|
||||
private let secret = "nope"
|
||||
}
|
||||
`);
|
||||
const result = spawnSync('bun', [
|
||||
join(import.meta.dir, 'gen-accessors.ts'),
|
||||
'--input', inputDir,
|
||||
'--output', outputDir,
|
||||
], {
|
||||
encoding: 'utf8',
|
||||
env: { ...process.env, GSTACK_IOS_CACHE_ROOT: join(workDir, 'cache') },
|
||||
});
|
||||
expect(result.status).toBe(4);
|
||||
// `let` is diagnosed first; either way the class/property is named and
|
||||
// generation never emits an accessor that will fail in xcodebuild.
|
||||
expect(result.stderr).toContain('InvalidState');
|
||||
expect(result.stderr).toContain("field 'secret' must be declared var, not let");
|
||||
expect(result.stderr).not.toContain('AccessorGenerationError:');
|
||||
expect(existsSync(join(outputDir, 'StateAccessor.swift'))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('pruneCache', () => {
|
||||
@@ -332,14 +659,395 @@ describe('render', () => {
|
||||
fields: [{ name: 'a', typeText: 'Int' }, { name: 'b', typeText: 'String' }],
|
||||
}];
|
||||
const out = render(specs, 'build-1.2.3', 'hash-abc');
|
||||
expect(out).toContain('public enum AppStateAccessor');
|
||||
expect(out).toContain('enum AppStateAccessor');
|
||||
expect(out).not.toContain('public enum AppStateAccessor');
|
||||
expect(out).toContain('static func register(_ state: AppState)');
|
||||
expect(out).not.toContain('public static func register(_ state: AppState)');
|
||||
expect(out).toContain('key: "a"');
|
||||
expect(out).toContain('key: "b"');
|
||||
expect(out).toContain('buildId: "build-1.2.3"');
|
||||
expect(out).toContain('return .missingKey("a")');
|
||||
expect(out).toContain('return .typeMismatch("b")');
|
||||
expect(out).toContain('guard let restored0 = Self.decodeSnapshotValue(raw0, as: Int.self)');
|
||||
expect(out).toContain('guard let restored1 = Self.decodeSnapshotValue(raw1, as: String.self)');
|
||||
expect(out).toContain('atomicRestore: { keys, apply in');
|
||||
expect(out).toContain('if apply {');
|
||||
expect(out).toContain('state.a = restored0');
|
||||
expect(out).toContain('state.b = restored1');
|
||||
expect(out.indexOf('state.a = restored0')).toBeGreaterThan(out.indexOf('guard let restored1'));
|
||||
expect(out).toContain('guard let typed = Self.decodeSnapshotValue(value, as: Int.self) else { return false }');
|
||||
expect(out).toContain('state.a = typed');
|
||||
expect(out).toContain('return true');
|
||||
expect(out).not.toContain('atomicRestore: { _ in .ok }');
|
||||
expect(out).not.toContain('write: { _ in false }');
|
||||
expect(out).toContain('CFBundleShortVersionString');
|
||||
expect(out).toContain('CFBundleVersion');
|
||||
expect(out).toContain('return shortVersion ?? bundleVersion ?? "build-1.2.3"');
|
||||
expect(out).toContain('accessorHash: "hash-abc"');
|
||||
expect(out).toContain('import DebugBridgeCore');
|
||||
expect(out).not.toContain('import DebugBridge\n');
|
||||
expect(out).toContain('#if DEBUG');
|
||||
expect(out).toContain('#endif');
|
||||
});
|
||||
|
||||
test('emits explicit NSNull round-trip handling for Optional fields', () => {
|
||||
const out = render([{
|
||||
className: 'AppState',
|
||||
fields: [
|
||||
{ name: 'nickname', typeText: 'String?' },
|
||||
{ name: 'selection', typeText: 'Optional<Int>' },
|
||||
],
|
||||
}], 'build', 'schema');
|
||||
|
||||
expect(out).toContain('let restored0: String?');
|
||||
expect(out).toContain('if raw0 is NSNull');
|
||||
expect(out).toContain('else if let typed = Self.decodeSnapshotValue(raw0, as: String.self)');
|
||||
expect(out).toContain('let restored1: Optional<Int>');
|
||||
expect(out).toContain('else if let typed = Self.decodeSnapshotValue(raw1, as: Int.self)');
|
||||
expect(out).toContain('guard let value = state.nickname else { return NSNull() }');
|
||||
expect(out).toContain('if value is NSNull');
|
||||
expect(out).toContain('state.nickname = nil');
|
||||
expect(out).not.toContain('value as? String?');
|
||||
});
|
||||
|
||||
test('rejects duplicate snapshot keys across observable models', () => {
|
||||
expect(() => render([
|
||||
{ className: 'FirstState', fields: [{ name: 'count', typeText: 'Int' }] },
|
||||
{ className: 'SecondState', fields: [{ name: 'count', typeText: 'Int' }] },
|
||||
], 'build', 'schema')).toThrow("snapshot key 'count' is declared by both FirstState and SecondState");
|
||||
});
|
||||
|
||||
test('typechecks beside an internal @Observable app state using a comment marker', () => {
|
||||
if (spawnSync('swiftc', ['--version'], { encoding: 'utf8' }).status !== 0) return;
|
||||
|
||||
const coreSource = join(workDir, 'DebugBridgeCore.swift');
|
||||
const coreModule = join(workDir, 'DebugBridgeCore.swiftmodule');
|
||||
writeFileSync(coreSource, `
|
||||
public typealias JSONDict = [String: Any]
|
||||
|
||||
@MainActor
|
||||
public final class StateServer {
|
||||
public static let shared = StateServer()
|
||||
public enum RestoreResult {
|
||||
case ok
|
||||
case missingKey(String)
|
||||
case typeMismatch(String)
|
||||
}
|
||||
private init() {}
|
||||
public func register(
|
||||
buildId: String,
|
||||
accessorHash: String,
|
||||
atomicRestore: @escaping (JSONDict, Bool) -> RestoreResult
|
||||
) {}
|
||||
public func registerAccessor(
|
||||
key: String,
|
||||
type: String,
|
||||
read: @escaping () -> Any?,
|
||||
write: @escaping (Any) -> Bool
|
||||
) {}
|
||||
}
|
||||
`);
|
||||
const emitModule = spawnSync('swiftc', [
|
||||
'-emit-module',
|
||||
'-parse-as-library',
|
||||
'-module-name', 'DebugBridgeCore',
|
||||
coreSource,
|
||||
'-emit-module-path', coreModule,
|
||||
], { encoding: 'utf8' });
|
||||
if (emitModule.status !== 0) {
|
||||
throw new Error(`failed to build DebugBridgeCore test stub:\n${emitModule.stderr}`);
|
||||
}
|
||||
|
||||
const appSource = join(workDir, 'AppState.swift');
|
||||
writeFileSync(appSource, `import Observation
|
||||
|
||||
@Observable
|
||||
final class AppState {
|
||||
// @Snapshotable
|
||||
var counter: Int = 0
|
||||
}
|
||||
|
||||
${render([{
|
||||
className: 'AppState',
|
||||
fields: [{ name: 'counter', typeText: 'Int' }],
|
||||
}], 'build-test', 'hash-test')}`);
|
||||
const typecheck = spawnSync('swiftc', [
|
||||
'-typecheck',
|
||||
'-D', 'DEBUG',
|
||||
'-I', workDir,
|
||||
appSource,
|
||||
], { encoding: 'utf8' });
|
||||
if (typecheck.status !== 0) {
|
||||
throw new Error(`generated accessor failed Swift type checking:\n${typecheck.stderr}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('strict JSON typing and cross-model validate-before-apply restore run correctly', () => {
|
||||
if (process.platform !== 'darwin') return;
|
||||
if (spawnSync('swiftc', ['--version'], { encoding: 'utf8' }).status !== 0) return;
|
||||
|
||||
const coreSource = join(workDir, 'DebugBridgeCore.swift');
|
||||
const coreModule = join(workDir, 'DebugBridgeCore.swiftmodule');
|
||||
const coreLibrary = join(workDir, 'libDebugBridgeCore.dylib');
|
||||
writeFileSync(coreSource, `
|
||||
public typealias JSONDict = [String: Any]
|
||||
|
||||
@MainActor
|
||||
public final class StateServer {
|
||||
public typealias Restore = (JSONDict, Bool) -> RestoreResult
|
||||
public enum RestoreResult { case ok, missingKey(String), typeMismatch(String) }
|
||||
public static let shared = StateServer()
|
||||
public var restores: [Restore] = []
|
||||
public var reads: [String: () -> Any?] = [:]
|
||||
public var writes: [String: (Any) -> Bool] = [:]
|
||||
private init() {}
|
||||
public func register(buildId: String, accessorHash: String, atomicRestore: @escaping Restore) {
|
||||
restores.append(atomicRestore)
|
||||
}
|
||||
public func registerAccessor(
|
||||
key: String,
|
||||
type: String,
|
||||
read: @escaping () -> Any?,
|
||||
write: @escaping (Any) -> Bool
|
||||
) {
|
||||
reads[key] = read
|
||||
writes[key] = write
|
||||
}
|
||||
public func restoreAll(_ keys: JSONDict) -> RestoreResult {
|
||||
for restore in restores {
|
||||
let result = restore(keys, false)
|
||||
guard case .ok = result else { return result }
|
||||
}
|
||||
for restore in restores {
|
||||
let result = restore(keys, true)
|
||||
guard case .ok = result else { return result }
|
||||
}
|
||||
return .ok
|
||||
}
|
||||
}
|
||||
`);
|
||||
const emitCore = spawnSync('swiftc', [
|
||||
'-emit-library', '-emit-module', '-parse-as-library',
|
||||
'-module-name', 'DebugBridgeCore', coreSource,
|
||||
'-emit-module-path', coreModule,
|
||||
'-o', coreLibrary,
|
||||
], { encoding: 'utf8' });
|
||||
if (emitCore.status !== 0) throw new Error(`failed to build runtime stub:\n${emitCore.stderr}`);
|
||||
|
||||
const appSource = join(workDir, 'OptionalRoundTrip.swift');
|
||||
writeFileSync(appSource, `
|
||||
import Foundation
|
||||
import Observation
|
||||
import DebugBridgeCore
|
||||
|
||||
@Observable
|
||||
final class AppState {
|
||||
// @Snapshotable
|
||||
var nickname: String? = nil
|
||||
// @Snapshotable
|
||||
var count: Int = 1
|
||||
}
|
||||
|
||||
@Observable
|
||||
final class FeatureState {
|
||||
// @Snapshotable
|
||||
var enabled: Bool = false
|
||||
}
|
||||
|
||||
${render([
|
||||
{
|
||||
className: 'AppState',
|
||||
fields: [
|
||||
{ name: 'nickname', typeText: 'String?' },
|
||||
{ name: 'count', typeText: 'Int' },
|
||||
],
|
||||
},
|
||||
{
|
||||
className: 'FeatureState',
|
||||
fields: [{ name: 'enabled', typeText: 'Bool' }],
|
||||
},
|
||||
], 'fallback-build', 'schema-hash')}
|
||||
|
||||
@main
|
||||
struct Runner {
|
||||
@MainActor static func main() {
|
||||
let state = AppState()
|
||||
let feature = FeatureState()
|
||||
AppStateAccessor.register(state)
|
||||
FeatureStateAccessor.register(feature)
|
||||
guard StateServer.shared.reads["nickname"]?() is NSNull else { fatalError("nil read") }
|
||||
guard StateServer.shared.writes["nickname"]?("Ada") == true, state.nickname == "Ada" else {
|
||||
fatalError("optional write")
|
||||
}
|
||||
guard StateServer.shared.writes["nickname"]?(NSNull()) == true, state.nickname == nil else {
|
||||
fatalError("null write")
|
||||
}
|
||||
guard StateServer.shared.writes["count"]?(true) == false, state.count == 1 else {
|
||||
fatalError("boolean must not coerce to integer")
|
||||
}
|
||||
guard StateServer.shared.writes["enabled"]?(1) == false, feature.enabled == false else {
|
||||
fatalError("integer must not coerce to boolean")
|
||||
}
|
||||
let valid = try! JSONSerialization.jsonObject(
|
||||
with: Data(#"{"nickname":"Grace","count":7,"enabled":true}"#.utf8)
|
||||
) as! JSONDict
|
||||
switch StateServer.shared.restoreAll(valid) {
|
||||
case .ok: break
|
||||
default: fatalError("valid restore")
|
||||
}
|
||||
guard state.nickname == "Grace", state.count == 7, feature.enabled else { fatalError("restore values") }
|
||||
switch StateServer.shared.restoreAll(["nickname": NSNull(), "count": 8, "enabled": false]) {
|
||||
case .ok: break
|
||||
default: fatalError("null restore")
|
||||
}
|
||||
guard state.nickname == nil, state.count == 8, !feature.enabled else { fatalError("null restore values") }
|
||||
state.nickname = "unchanged"
|
||||
state.count = 9
|
||||
feature.enabled = false
|
||||
switch StateServer.shared.restoreAll(["nickname": "would-partially-apply", "count": 10, "enabled": 1]) {
|
||||
case .typeMismatch("enabled"): break
|
||||
default: fatalError("expected mismatch")
|
||||
}
|
||||
guard state.nickname == "unchanged", state.count == 9, !feature.enabled else { fatalError("cross-model partial mutation") }
|
||||
}
|
||||
}
|
||||
`);
|
||||
const executable = join(workDir, 'optional-round-trip');
|
||||
const compile = spawnSync('swiftc', [
|
||||
'-D', 'DEBUG', '-I', workDir, '-L', workDir, '-lDebugBridgeCore',
|
||||
'-parse-as-library', appSource, '-o', executable,
|
||||
], { encoding: 'utf8' });
|
||||
if (compile.status !== 0) throw new Error(`generated Optional accessor failed compilation:\n${compile.stderr}`);
|
||||
const run = spawnSync(executable, [], {
|
||||
encoding: 'utf8',
|
||||
env: { ...process.env, DYLD_LIBRARY_PATH: workDir },
|
||||
});
|
||||
if (run.status !== 0) throw new Error(`generated Optional accessor failed at runtime:\n${run.stderr}`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('SwiftSyntax generator parity', () => {
|
||||
test('isolates canonical markers and rejects inaccessible fields', () => {
|
||||
if (process.platform !== 'darwin') return;
|
||||
if (spawnSync('swift', ['--version'], { encoding: 'utf8' }).status !== 0) return;
|
||||
|
||||
const packageDir = join(import.meta.dir, 'gen-accessors-tool');
|
||||
const inputDir = join(workDir, 'swift-syntax-input');
|
||||
const outputDir = join(workDir, 'swift-syntax-output');
|
||||
const cacheRoot = join(workDir, 'swift-syntax-cache');
|
||||
mkdirSync(inputDir);
|
||||
writeFileSync(join(inputDir, 'State.swift'), `
|
||||
import Observation
|
||||
@Observable
|
||||
final class ToolState {
|
||||
let documentation = """
|
||||
// @Snapshotable
|
||||
var stringLeak: String = "no"
|
||||
"""
|
||||
/* // @Snapshotable */
|
||||
var blockLeak: Int = 1
|
||||
var trailingLeak: Int = 2 // @Snapshotable
|
||||
// prose about @Snapshotable must not opt in
|
||||
var proseLeak: Int = 3
|
||||
// @Snapshotable
|
||||
var nickname: String? = nil
|
||||
// @Snapshotable
|
||||
var names:
|
||||
[String]
|
||||
= []
|
||||
// @Snapshotable
|
||||
var count: Int = 0
|
||||
// @Snapshotable
|
||||
var enabled: Bool = false
|
||||
}
|
||||
`);
|
||||
const env = {
|
||||
...process.env,
|
||||
GSTACK_IOS_CACHE_ROOT: cacheRoot,
|
||||
APP_BUILD_ID: 'syntax-test-build',
|
||||
GEN_ACCESSORS_REV: 'syntax-test-v5',
|
||||
};
|
||||
const run = spawnSync('swift', [
|
||||
'run', '--package-path', packageDir, 'gen-accessors',
|
||||
'--input', inputDir, '--output', outputDir,
|
||||
], { encoding: 'utf8', env, timeout: 180_000 });
|
||||
if (run.status !== 0) throw new Error(`SwiftSyntax generator failed:\n${run.stderr}`);
|
||||
const output = readFileSync(join(outputDir, 'StateAccessor.swift'), 'utf8');
|
||||
expect(output).toContain('key: "nickname"');
|
||||
expect(output).toContain('key: "names"');
|
||||
expect(output).toContain('key: "count"');
|
||||
expect(output).toContain('key: "enabled"');
|
||||
expect(output).toContain('_GStackDebugBridgeSnapshotJSON.decode(raw0');
|
||||
expect(output).toContain('atomicRestore: { keys, apply in');
|
||||
expect(output).toContain('if apply {');
|
||||
expect(output).not.toMatch(/raw\d+ as\?/);
|
||||
expect(output).toContain('CFBundleShortVersionString');
|
||||
expect(output).toContain(`accessorHash: "${computeAccessorHash([{
|
||||
className: 'ToolState',
|
||||
fields: [
|
||||
{ name: 'nickname', typeText: 'String?' },
|
||||
{ name: 'names', typeText: '[String]' },
|
||||
{ name: 'count', typeText: 'Int' },
|
||||
{ name: 'enabled', typeText: 'Bool' },
|
||||
],
|
||||
}])}"`);
|
||||
expect(output).not.toContain('key: "stringLeak"');
|
||||
expect(output).not.toContain('key: "blockLeak"');
|
||||
expect(output).not.toContain('key: "trailingLeak"');
|
||||
expect(output).not.toContain('key: "proseLeak"');
|
||||
|
||||
const invalidInput = join(workDir, 'swift-syntax-invalid');
|
||||
const invalidOutput = join(workDir, 'swift-syntax-invalid-output');
|
||||
mkdirSync(invalidInput);
|
||||
writeFileSync(join(invalidInput, 'Invalid.swift'), `
|
||||
import Observation
|
||||
@Observable
|
||||
final class InvalidState {
|
||||
// @Snapshotable
|
||||
public private(set) var token: String = "secret"
|
||||
// @Snapshotable
|
||||
let immutable: Int = 1
|
||||
// @Snapshotable
|
||||
var inferred = 2
|
||||
// @Snapshotable
|
||||
var legacy: String! = nil
|
||||
// @Snapshotable
|
||||
var custom: Date = .now
|
||||
}
|
||||
|
||||
enum Namespace {
|
||||
@Observable
|
||||
final class NestedState {
|
||||
// @Snapshotable
|
||||
var nestedValue: Int = 0
|
||||
}
|
||||
}
|
||||
|
||||
@Observable
|
||||
final class FirstState {
|
||||
// @Snapshotable
|
||||
var shared: String = "first"
|
||||
}
|
||||
|
||||
@Observable
|
||||
final class DuplicateState {
|
||||
// @Snapshotable
|
||||
var shared: String = "duplicate"
|
||||
}
|
||||
`);
|
||||
const invalid = spawnSync('swift', [
|
||||
'run', '--package-path', packageDir, 'gen-accessors',
|
||||
'--input', invalidInput, '--output', invalidOutput,
|
||||
], { encoding: 'utf8', env, timeout: 180_000 });
|
||||
expect(invalid.status).toBe(4);
|
||||
expect(invalid.stderr).toContain('InvalidState.token cannot be private');
|
||||
expect(invalid.stderr).toContain('InvalidState.immutable must be declared var, not let');
|
||||
expect(invalid.stderr).toContain('InvalidState.inferred requires an explicit type annotation');
|
||||
expect(invalid.stderr).toContain('InvalidState.legacy cannot use an implicitly unwrapped Optional type');
|
||||
expect(invalid.stderr).toContain("InvalidState.custom uses unsupported non-JSON snapshot type 'Date'");
|
||||
expect(invalid.stderr).toContain('nested @Observable class NestedState');
|
||||
expect(invalid.stderr).toContain("snapshot key 'shared' is declared by both FirstState and DuplicateState");
|
||||
expect(existsSync(join(invalidOutput, 'StateAccessor.swift'))).toBe(false);
|
||||
}, 180_000);
|
||||
});
|
||||
|
||||
describe('collectSwiftFiles', () => {
|
||||
@@ -355,4 +1063,17 @@ describe('collectSwiftFiles', () => {
|
||||
const files = collectSwiftFiles(workDir);
|
||||
expect(files.sort()).toEqual([a, b].sort());
|
||||
});
|
||||
|
||||
test('excludes the configured output subtree and every StateAccessor.swift', () => {
|
||||
const source = join(workDir, 'App.swift');
|
||||
const staleAccessor = join(workDir, 'old', 'StateAccessor.swift');
|
||||
const outputDir = join(workDir, 'custom-output');
|
||||
mkdirSync(join(workDir, 'old'));
|
||||
mkdirSync(outputDir);
|
||||
writeFileSync(source, 'struct App {}');
|
||||
writeFileSync(staleAccessor, '// stale generated output');
|
||||
writeFileSync(join(outputDir, 'Poison.swift'), '// must not be scanned');
|
||||
|
||||
expect(collectSwiftFiles(workDir, { outputDir })).toEqual([source]);
|
||||
});
|
||||
});
|
||||
|
||||
+659
-89
@@ -5,15 +5,16 @@
|
||||
// first time. Also exercised by tests so we can verify the cache + parse
|
||||
// behavior without a Swift toolchain.
|
||||
//
|
||||
// The TS port uses a stricter regex than the fork's original — it understands:
|
||||
// The TS fast path uses a lightweight Swift lexical scanner — it understands:
|
||||
// - @Observable class declarations
|
||||
// - @Snapshotable property markers (only marked fields are exported)
|
||||
// - `// @Snapshotable` property markers (only marked fields are exported)
|
||||
// - Legacy @Snapshotable attributes for existing integrations
|
||||
// - Multi-line type signatures (collapses whitespace before matching)
|
||||
// - Generic type parameters (matched as opaque text inside the type)
|
||||
// - JSON-native generic arrays and String-keyed dictionaries
|
||||
//
|
||||
// What it does NOT handle (deferred to the SwiftPM tool):
|
||||
// - Computed properties with bodies (regex can mis-parse braces)
|
||||
// - Property wrappers other than @Snapshotable
|
||||
// Invalid marked declarations (computed/immutable/inaccessible/untyped,
|
||||
// nested, duplicate-keyed, or non-JSON) fail generation instead of producing
|
||||
// Swift that only fails later in xcodebuild or at snapshot time.
|
||||
//
|
||||
// Composite cache key (codex-flagged): swift_version || tool_git_rev ||
|
||||
// platform_triple || source_content_hash. Source-only hash misses generator
|
||||
@@ -35,6 +36,16 @@ export interface AccessorSpec {
|
||||
fields: AccessorField[];
|
||||
}
|
||||
|
||||
export class AccessorGenerationError extends Error {
|
||||
readonly diagnostics: string[];
|
||||
|
||||
constructor(diagnostics: string[]) {
|
||||
super(`gen-accessors: invalid @Snapshotable declaration(s):\n${diagnostics.map(d => ` - ${d}`).join('\n')}`);
|
||||
this.name = 'AccessorGenerationError';
|
||||
this.diagnostics = diagnostics;
|
||||
}
|
||||
}
|
||||
|
||||
export interface GenInputs {
|
||||
inputDir: string;
|
||||
outputDir?: string;
|
||||
@@ -48,56 +59,105 @@ export interface GenInputs {
|
||||
export interface GenResult {
|
||||
outputPath: string;
|
||||
cacheKey: string;
|
||||
accessorHash: string;
|
||||
specs: AccessorSpec[];
|
||||
cacheHit: boolean;
|
||||
}
|
||||
|
||||
const FALLBACK_PLATFORM = process.platform === 'darwin' ? 'darwin-arm64' : `${process.platform}-${process.arch}`;
|
||||
const GENERATOR_FORMAT_VERSION = 'accessor-generator-v5';
|
||||
|
||||
export function collectSwiftFiles(dir: string, opts: { excludeGenerated?: boolean } = {}): string[] {
|
||||
const JSON_SCALAR_TYPES = new Set([
|
||||
'String',
|
||||
'Bool',
|
||||
'Int', 'Int8', 'Int16', 'Int32', 'Int64',
|
||||
'UInt', 'UInt8', 'UInt16', 'UInt32', 'UInt64',
|
||||
'Float', 'Double', 'CGFloat',
|
||||
]);
|
||||
|
||||
export function collectSwiftFiles(
|
||||
dir: string,
|
||||
opts: { excludeGenerated?: boolean; outputDir?: string } = {},
|
||||
): string[] {
|
||||
const out: string[] = [];
|
||||
const excludeGenerated = opts.excludeGenerated ?? true;
|
||||
for (const name of readdirSync(dir)) {
|
||||
const full = join(dir, name);
|
||||
const s = statSync(full);
|
||||
if (s.isDirectory()) {
|
||||
// Skip generated output dir (when it lives under the input dir)
|
||||
if (excludeGenerated && name === 'DebugBridgeGenerated') continue;
|
||||
out.push(...collectSwiftFiles(full, opts));
|
||||
} else if (name.endsWith('.swift')) {
|
||||
// Skip the codegen output file. Otherwise the second run picks it up,
|
||||
// changes the cache key, and the cache never hits.
|
||||
if (excludeGenerated && name === 'StateAccessor.swift') continue;
|
||||
out.push(full);
|
||||
const root = resolve(dir);
|
||||
const outputDir = opts.outputDir ? resolve(opts.outputDir) : undefined;
|
||||
|
||||
function walk(currentDir: string): void {
|
||||
for (const name of readdirSync(currentDir)) {
|
||||
const full = join(currentDir, name);
|
||||
const s = statSync(full);
|
||||
if (s.isDirectory()) {
|
||||
// The generated subtree must never participate in its own cache key.
|
||||
// Keep the well-known-name guard for direct collectSwiftFiles callers,
|
||||
// and use the actual --output path when generate() supplies one.
|
||||
const isOutputSubtree = outputDir !== undefined
|
||||
&& outputDir !== root
|
||||
&& resolve(full) === outputDir;
|
||||
if (excludeGenerated && (name === 'DebugBridgeGenerated' || isOutputSubtree)) continue;
|
||||
walk(full);
|
||||
} else if (name.endsWith('.swift')) {
|
||||
// Skip generated accessor files wherever they live. Otherwise moving
|
||||
// an old copy outside the output directory poisons the next cache key.
|
||||
if (excludeGenerated && name === 'StateAccessor.swift') continue;
|
||||
out.push(full);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
walk(root);
|
||||
return out.sort();
|
||||
}
|
||||
|
||||
export function parseSwift(source: string): AccessorSpec[] {
|
||||
const specs: AccessorSpec[] = [];
|
||||
const diagnostics: string[] = [];
|
||||
const masked = maskSwiftSource(source);
|
||||
// Find `@Observable\n(public )?(final )?class <Name>` followed by a brace
|
||||
// block. We then scan inside that block for @Snapshotable fields.
|
||||
const classPattern = /@Observable\s*(?:(?:public|internal|fileprivate|private)\s+)?(?:final\s+)?class\s+(\w+)[^{]*\{/g;
|
||||
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = classPattern.exec(source)) !== null) {
|
||||
for (const match of masked.matchAll(classPattern)) {
|
||||
const className = match[1]!;
|
||||
const startIdx = classPattern.lastIndex;
|
||||
const endIdx = findMatchingBrace(source, startIdx - 1);
|
||||
const matchOffset = match.index!;
|
||||
const openBraceOffset = matchOffset + match[0].lastIndexOf('{');
|
||||
const startIdx = openBraceOffset + 1;
|
||||
const endIdx = findMatchingBrace(masked, startIdx - 1);
|
||||
if (endIdx === -1) continue;
|
||||
const body = source.slice(startIdx, endIdx);
|
||||
const body = masked.slice(startIdx, endIdx);
|
||||
|
||||
const fields = parseFields(body);
|
||||
const parsed = parseFields(body, source, startIdx, className);
|
||||
if (braceDepthAt(masked, matchOffset) !== 0) {
|
||||
if (parsed.fields.length > 0 || parsed.diagnostics.length > 0) {
|
||||
const line = source.slice(0, matchOffset).split(/\r?\n/).length;
|
||||
diagnostics.push(`${className} (line ${line}): nested @Observable types are not supported; move the type to file scope`);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
diagnostics.push(...parsed.diagnostics);
|
||||
const fields = parsed.fields;
|
||||
if (fields.length > 0) {
|
||||
specs.push({ className, fields });
|
||||
}
|
||||
}
|
||||
|
||||
if (diagnostics.length > 0) throw new AccessorGenerationError(diagnostics);
|
||||
return specs;
|
||||
}
|
||||
|
||||
function braceDepthAt(masked: string, offset: number): number {
|
||||
let depth = 0;
|
||||
for (let i = 0; i < offset; i++) {
|
||||
if (masked[i] === '{') depth++;
|
||||
else if (masked[i] === '}') depth = Math.max(0, depth - 1);
|
||||
}
|
||||
return depth;
|
||||
}
|
||||
|
||||
function findMatchingBrace(s: string, openIdx: number): number {
|
||||
// openIdx points at '{'. Return idx of matching '}', or -1.
|
||||
// Strings and comments have already been blanked by maskSwiftSource, so
|
||||
// braces here are syntax rather than prose or literal content.
|
||||
let depth = 0;
|
||||
for (let i = openIdx; i < s.length; i++) {
|
||||
const c = s[i];
|
||||
@@ -105,48 +165,305 @@ function findMatchingBrace(s: string, openIdx: number): number {
|
||||
else if (c === '}') {
|
||||
depth--;
|
||||
if (depth === 0) return i;
|
||||
} else if (c === '"' || c === "'") {
|
||||
// skip string literal
|
||||
const quote = c;
|
||||
i++;
|
||||
while (i < s.length && s[i] !== quote) {
|
||||
if (s[i] === '\\') i++;
|
||||
i++;
|
||||
}
|
||||
} else if (c === '/' && s[i + 1] === '/') {
|
||||
// skip line comment
|
||||
while (i < s.length && s[i] !== '\n') i++;
|
||||
} else if (c === '/' && s[i + 1] === '*') {
|
||||
i += 2;
|
||||
while (i < s.length - 1 && !(s[i] === '*' && s[i + 1] === '/')) i++;
|
||||
i++;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
function parseFields(body: string): AccessorField[] {
|
||||
// Look for @Snapshotable followed by var/let declarations. Allow attribute
|
||||
// ordering: `@Snapshotable var name: Type` OR `@Snapshotable\n var name: Type`.
|
||||
// Multi-line types are handled by greedy non-newline match in the type, but
|
||||
// we collapse adjacent whitespace first to avoid false negatives.
|
||||
const normalized = body.replace(/[\t ]*\r?\n[\t ]*/g, ' ');
|
||||
const fieldPattern = /@Snapshotable\s+(?:(?:public|internal|fileprivate|private)\s+)?(?:var|let)\s+(\w+)\s*:\s*([^={]+?)(?=\s*(?:=|\{|@Snapshotable|\bvar\b|\blet\b|\bfunc\b|\}|$))/g;
|
||||
const fields: AccessorField[] = [];
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = fieldPattern.exec(normalized)) !== null) {
|
||||
// Codex catch: skip fields that have a computed body (`{ get ... }` or
|
||||
// `{ didSet ... }` after the type). The match boundary stops before `{`,
|
||||
// so we peek at what comes after the type in the original body.
|
||||
const afterMatchIdx = m.index + m[0].length;
|
||||
const afterMatch = normalized.slice(afterMatchIdx, afterMatchIdx + 4).trim();
|
||||
// If the next non-space character is `{`, this is a computed property.
|
||||
// We're conservative: snapshot-eligible fields must be stored properties
|
||||
// (initialized with `=` or just declared).
|
||||
if (afterMatch.startsWith('{')) continue;
|
||||
fields.push({ name: m[1]!, typeText: m[2]!.trim() });
|
||||
/**
|
||||
* Blank comments and string literals while preserving byte offsets/newlines.
|
||||
* A canonical standalone `// @Snapshotable` comment is rewritten to the
|
||||
* equivalent attribute token. This is intentionally lexical rather than a
|
||||
* whole-file regexp: markers inside nested block comments, normal/triple/raw
|
||||
* strings, trailing comments, and prose comments must never opt a field in.
|
||||
*/
|
||||
function maskSwiftSource(source: string): string {
|
||||
const out = source.split('');
|
||||
|
||||
const blank = (start: number, end: number): void => {
|
||||
for (let j = start; j < end; j++) {
|
||||
if (out[j] !== '\n' && out[j] !== '\r') out[j] = ' ';
|
||||
}
|
||||
};
|
||||
|
||||
let i = 0;
|
||||
while (i < source.length) {
|
||||
if (source[i] === '/' && source[i + 1] === '/') {
|
||||
let end = i + 2;
|
||||
while (end < source.length && source[end] !== '\n' && source[end] !== '\r') end++;
|
||||
const lineStart = source.lastIndexOf('\n', i - 1) + 1;
|
||||
const standalone = source.slice(lineStart, i).trim().length === 0;
|
||||
const isMarker = standalone && source.slice(i + 2, end).trim() === '@Snapshotable';
|
||||
blank(i, end);
|
||||
if (isMarker) {
|
||||
const marker = '@Snapshotable';
|
||||
for (let j = 0; j < marker.length; j++) out[i + j] = marker[j]!;
|
||||
}
|
||||
i = end;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (source[i] === '/' && source[i + 1] === '*') {
|
||||
const start = i;
|
||||
i += 2;
|
||||
let depth = 1;
|
||||
while (i < source.length && depth > 0) {
|
||||
if (source[i] === '/' && source[i + 1] === '*') {
|
||||
depth++;
|
||||
i += 2;
|
||||
} else if (source[i] === '*' && source[i + 1] === '/') {
|
||||
depth--;
|
||||
i += 2;
|
||||
} else {
|
||||
i++;
|
||||
}
|
||||
}
|
||||
blank(start, i);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Swift strings may be ordinary, multiline, or raw (`#"..."#` and
|
||||
// `#"""..."""#`). Mask the entire literal, including interpolation;
|
||||
// declarations cannot legally originate inside a literal.
|
||||
let hashCount = 0;
|
||||
while (source[i + hashCount] === '#') hashCount++;
|
||||
const quoteIdx = i + hashCount;
|
||||
if (source[quoteIdx] === '"') {
|
||||
const start = i;
|
||||
const triple = source.slice(quoteIdx, quoteIdx + 3) === '"""';
|
||||
const quoteCount = triple ? 3 : 1;
|
||||
i = quoteIdx + quoteCount;
|
||||
while (i < source.length) {
|
||||
if (hashCount === 0 && source[i] === '\\') {
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
const quoteRun = source.slice(i, i + quoteCount) === '"'.repeat(quoteCount);
|
||||
const hashesMatch = source.slice(i + quoteCount, i + quoteCount + hashCount) === '#'.repeat(hashCount);
|
||||
if (quoteRun && hashesMatch) {
|
||||
i += quoteCount + hashCount;
|
||||
break;
|
||||
}
|
||||
i++;
|
||||
}
|
||||
blank(start, i);
|
||||
continue;
|
||||
}
|
||||
|
||||
i++;
|
||||
}
|
||||
return fields;
|
||||
return out.join('');
|
||||
}
|
||||
|
||||
const DECL_MODIFIERS = new Set([
|
||||
'public', 'internal', 'package', 'open', 'private', 'fileprivate',
|
||||
'final', 'static', 'class', 'lazy', 'weak', 'unowned', 'override',
|
||||
'nonisolated', 'isolated', 'borrowing', 'consuming',
|
||||
]);
|
||||
|
||||
function parseFields(
|
||||
body: string,
|
||||
fullSource: string,
|
||||
bodyOffset: number,
|
||||
className: string,
|
||||
): { fields: AccessorField[]; diagnostics: string[] } {
|
||||
const fields: AccessorField[] = [];
|
||||
const diagnostics: string[] = [];
|
||||
let braceDepth = 0;
|
||||
|
||||
const lineFor = (localOffset: number): number => {
|
||||
const absolute = bodyOffset + localOffset;
|
||||
let line = 1;
|
||||
for (let j = 0; j < absolute; j++) if (fullSource[j] === '\n') line++;
|
||||
return line;
|
||||
};
|
||||
const fail = (offset: number, message: string): void => {
|
||||
diagnostics.push(`${className} (line ${lineFor(offset)}): ${message}`);
|
||||
};
|
||||
const skipWhitespace = (start: number): number => {
|
||||
let at = start;
|
||||
while (at < body.length && /\s/.test(body[at]!)) at++;
|
||||
return at;
|
||||
};
|
||||
const identifierAt = (start: number): { text: string; end: number } | undefined => {
|
||||
const m = /^[A-Za-z_]\w*/.exec(body.slice(start));
|
||||
return m ? { text: m[0], end: start + m[0].length } : undefined;
|
||||
};
|
||||
const skipBalancedParens = (start: number): number => {
|
||||
if (body[start] !== '(') return start;
|
||||
let depth = 0;
|
||||
for (let at = start; at < body.length; at++) {
|
||||
if (body[at] === '(') depth++;
|
||||
else if (body[at] === ')' && --depth === 0) return at + 1;
|
||||
}
|
||||
return body.length;
|
||||
};
|
||||
|
||||
for (let i = 0; i < body.length; i++) {
|
||||
if (body[i] === '{') {
|
||||
braceDepth++;
|
||||
continue;
|
||||
}
|
||||
if (body[i] === '}') {
|
||||
braceDepth--;
|
||||
continue;
|
||||
}
|
||||
if (braceDepth !== 0 || !body.startsWith('@Snapshotable', i)) continue;
|
||||
const before = i === 0 ? '' : body[i - 1]!;
|
||||
const after = body[i + '@Snapshotable'.length] ?? '';
|
||||
if (/\w/.test(before) || /\w/.test(after)) continue;
|
||||
|
||||
const markerOffset = i;
|
||||
let at = skipWhitespace(i + '@Snapshotable'.length);
|
||||
const modifiers: string[] = [];
|
||||
let bindingKind: 'var' | 'let' | undefined;
|
||||
|
||||
// Permit other Swift attributes between the marker and declaration. They
|
||||
// remain the compiler's responsibility; this scanner only owns the
|
||||
// snapshot contract. Parenthesized attribute arguments are skipped.
|
||||
while (body[at] === '@') {
|
||||
const attribute = identifierAt(at + 1);
|
||||
if (!attribute) break;
|
||||
at = skipWhitespace(attribute.end);
|
||||
if (body[at] === '(') at = skipBalancedParens(at);
|
||||
at = skipWhitespace(at);
|
||||
}
|
||||
|
||||
while (at < body.length) {
|
||||
const token = identifierAt(at);
|
||||
if (!token) break;
|
||||
if (token.text === 'var' || token.text === 'let') {
|
||||
bindingKind = token.text;
|
||||
at = token.end;
|
||||
break;
|
||||
}
|
||||
if (!DECL_MODIFIERS.has(token.text)) break;
|
||||
let modifier = token.text;
|
||||
at = skipWhitespace(token.end);
|
||||
if (body[at] === '(') {
|
||||
const end = skipBalancedParens(at);
|
||||
modifier += body.slice(at, end).replace(/\s+/g, '');
|
||||
at = skipWhitespace(end);
|
||||
}
|
||||
modifiers.push(modifier);
|
||||
}
|
||||
|
||||
if (!bindingKind) {
|
||||
fail(markerOffset, '@Snapshotable must immediately precede a stored property declaration');
|
||||
continue;
|
||||
}
|
||||
|
||||
at = skipWhitespace(at);
|
||||
const identifier = identifierAt(at);
|
||||
const fieldName = identifier?.text ?? '<unknown>';
|
||||
if (!identifier) {
|
||||
fail(markerOffset, '@Snapshotable only supports a single identifier binding');
|
||||
continue;
|
||||
}
|
||||
at = skipWhitespace(identifier.end);
|
||||
|
||||
if (bindingKind === 'let') {
|
||||
fail(markerOffset, `@Snapshotable field '${fieldName}' must be declared var, not let`);
|
||||
continue;
|
||||
}
|
||||
if (modifiers.some(m => /^(?:private|fileprivate)(?:\(set\))?$/.test(m))) {
|
||||
fail(markerOffset, `@Snapshotable field '${fieldName}' cannot be private, fileprivate, private(set), or fileprivate(set)`);
|
||||
continue;
|
||||
}
|
||||
if (modifiers.some(m => m === 'static' || m === 'class')) {
|
||||
fail(markerOffset, `@Snapshotable field '${fieldName}' must be an instance property`);
|
||||
continue;
|
||||
}
|
||||
if (body[at] !== ':') {
|
||||
fail(markerOffset, `@Snapshotable field '${fieldName}' requires an explicit type annotation`);
|
||||
continue;
|
||||
}
|
||||
|
||||
at = skipWhitespace(at + 1);
|
||||
const typeStart = at;
|
||||
let parens = 0;
|
||||
let brackets = 0;
|
||||
let angles = 0;
|
||||
let typeEnd = at;
|
||||
let delimiter = '';
|
||||
for (; at < body.length; at++) {
|
||||
const c = body[at]!;
|
||||
if (c === '(') parens++;
|
||||
else if (c === ')') parens--;
|
||||
else if (c === '[') brackets++;
|
||||
else if (c === ']') brackets--;
|
||||
else if (c === '<') angles++;
|
||||
else if (c === '>') angles = Math.max(0, angles - 1);
|
||||
const topLevel = parens === 0 && brackets === 0 && angles === 0;
|
||||
if (topLevel && (c === '=' || c === '{' || c === ',' || c === ';')) {
|
||||
delimiter = c;
|
||||
break;
|
||||
}
|
||||
if (topLevel && (c === '\n' || c === '\r')) {
|
||||
const soFar = body.slice(typeStart, at).trim();
|
||||
if (soFar.length > 0 && !/(?:->|[&.,])\s*$/.test(soFar)) break;
|
||||
}
|
||||
typeEnd = at + 1;
|
||||
}
|
||||
const typeText = body.slice(typeStart, typeEnd).replace(/\s+/g, ' ').trim();
|
||||
if (typeText.length === 0) {
|
||||
fail(markerOffset, `@Snapshotable field '${fieldName}' requires an explicit type annotation`);
|
||||
continue;
|
||||
}
|
||||
if (delimiter === '{') {
|
||||
fail(markerOffset, `@Snapshotable field '${fieldName}' must be stored and writable`);
|
||||
continue;
|
||||
}
|
||||
if (delimiter === ',') {
|
||||
fail(markerOffset, '@Snapshotable declarations must contain exactly one binding');
|
||||
continue;
|
||||
}
|
||||
if (delimiter === '=') {
|
||||
// A declaration such as `var a: Int = 1, b: Int = 2` reaches `=`
|
||||
// before its binding comma. Scan the initializer at syntax depth zero
|
||||
// so commas in arrays/calls/closures remain valid.
|
||||
let initializerAt = at + 1;
|
||||
let expressionParens = 0;
|
||||
let expressionBrackets = 0;
|
||||
let expressionBraces = 0;
|
||||
let hasSecondBinding = false;
|
||||
for (; initializerAt < body.length; initializerAt++) {
|
||||
const c = body[initializerAt]!;
|
||||
if (c === '(') expressionParens++;
|
||||
else if (c === ')') expressionParens--;
|
||||
else if (c === '[') expressionBrackets++;
|
||||
else if (c === ']') expressionBrackets--;
|
||||
else if (c === '{') expressionBraces++;
|
||||
else if (c === '}') {
|
||||
if (expressionBraces === 0) break;
|
||||
expressionBraces--;
|
||||
}
|
||||
const topLevel = expressionParens === 0 && expressionBrackets === 0 && expressionBraces === 0;
|
||||
if (topLevel && c === ',') {
|
||||
hasSecondBinding = true;
|
||||
break;
|
||||
}
|
||||
if (topLevel && (c === '\n' || c === '\r' || c === ';')) break;
|
||||
}
|
||||
if (hasSecondBinding) {
|
||||
fail(markerOffset, '@Snapshotable declarations must contain exactly one binding');
|
||||
continue;
|
||||
}
|
||||
}
|
||||
const wrappedOptional = optionalWrappedType(typeText);
|
||||
if (wrappedOptional !== undefined && optionalWrappedType(wrappedOptional) !== undefined) {
|
||||
fail(markerOffset, `@Snapshotable field '${fieldName}' cannot use a nested Optional type`);
|
||||
continue;
|
||||
}
|
||||
const typeIssue = snapshotTypeIssue(typeText);
|
||||
if (typeIssue !== undefined) {
|
||||
fail(markerOffset, `@Snapshotable field '${fieldName}' ${typeIssue}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
fields.push({ name: fieldName, typeText });
|
||||
}
|
||||
return { fields, diagnostics };
|
||||
}
|
||||
|
||||
export function computeCacheKey(inputs: {
|
||||
@@ -154,35 +471,263 @@ export function computeCacheKey(inputs: {
|
||||
swiftVersion: string;
|
||||
toolGitRev: string;
|
||||
platformTriple: string;
|
||||
buildId?: string;
|
||||
}): string {
|
||||
const h = createHash('sha256');
|
||||
h.update(`swift=${inputs.swiftVersion}|tool=${inputs.toolGitRev}|platform=${inputs.platformTriple}|`);
|
||||
h.update(`${GENERATOR_FORMAT_VERSION}|swift=${inputs.swiftVersion}|tool=${inputs.toolGitRev}|platform=${inputs.platformTriple}|build=${inputs.buildId ?? 'unknown'}|`);
|
||||
for (const f of inputs.swiftFiles) {
|
||||
const content = readFileSync(f);
|
||||
h.update(`${f}:${content.length}:`);
|
||||
// Cache identity is content-based. Absolute checkout paths must not make
|
||||
// equivalent source trees produce different generated output.
|
||||
h.update(`${content.length}:`);
|
||||
h.update(content);
|
||||
h.update('|');
|
||||
}
|
||||
return h.digest('hex');
|
||||
}
|
||||
|
||||
export function render(specs: AccessorSpec[], buildId: string, accessorHash: string): string {
|
||||
let out = '// AUTO-GENERATED — DO NOT EDIT. Regenerate with /ios-sync.\n';
|
||||
out += '#if DEBUG\nimport Foundation\nimport DebugBridge\n\n';
|
||||
/** Stable snapshot-schema fingerprint, deliberately independent of cache ABI,
|
||||
* source paths, app build provenance, and unmarked source. Field/class order is
|
||||
* source order because restore payload compatibility is an ordered contract. */
|
||||
export function computeAccessorHash(specs: AccessorSpec[]): string {
|
||||
let signature = 'snapshot-schema-v1\n';
|
||||
for (const spec of specs) {
|
||||
out += `@MainActor\npublic enum ${spec.className}Accessor {\n`;
|
||||
out += ` public static func register(_ state: ${spec.className}) {\n`;
|
||||
signature += `C${Buffer.byteLength(spec.className)}:${spec.className}\n`;
|
||||
for (const field of spec.fields) {
|
||||
signature += `F${Buffer.byteLength(field.name)}:${field.name}`;
|
||||
signature += `T${Buffer.byteLength(field.typeText)}:${field.typeText}\n`;
|
||||
}
|
||||
signature += 'E\n';
|
||||
}
|
||||
return createHash('sha256').update(signature).digest('hex');
|
||||
}
|
||||
|
||||
/** Return the wrapped type for one top-level Optional spelling. */
|
||||
export function optionalWrappedType(typeText: string): string | undefined {
|
||||
const type = typeText.trim();
|
||||
if (type.endsWith('?')) {
|
||||
const wrapped = type.slice(0, -1).trim();
|
||||
return wrapped.length > 0 ? wrapped : undefined;
|
||||
}
|
||||
|
||||
const optional = /^(?:Swift\.)?Optional\s*</.exec(type);
|
||||
if (!optional) return undefined;
|
||||
const open = type.indexOf('<', optional.index);
|
||||
let depth = 0;
|
||||
for (let i = open; i < type.length; i++) {
|
||||
if (type[i] === '<') depth++;
|
||||
else if (type[i] === '>') {
|
||||
depth--;
|
||||
if (depth === 0) {
|
||||
if (type.slice(i + 1).trim().length > 0) return undefined;
|
||||
const wrapped = type.slice(open + 1, i).trim();
|
||||
return wrapped.length > 0 ? wrapped : undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function splitTopLevel(type: string, delimiter: string): string[] {
|
||||
const parts: string[] = [];
|
||||
let start = 0;
|
||||
let angle = 0;
|
||||
let square = 0;
|
||||
let paren = 0;
|
||||
for (let i = 0; i < type.length; i++) {
|
||||
const char = type[i]!;
|
||||
if (char === '<') angle++;
|
||||
else if (char === '>') angle--;
|
||||
else if (char === '[') square++;
|
||||
else if (char === ']') square--;
|
||||
else if (char === '(') paren++;
|
||||
else if (char === ')') paren--;
|
||||
else if (char === delimiter && angle === 0 && square === 0 && paren === 0) {
|
||||
parts.push(type.slice(start, i));
|
||||
start = i + 1;
|
||||
}
|
||||
}
|
||||
parts.push(type.slice(start));
|
||||
return parts;
|
||||
}
|
||||
|
||||
function genericArgument(type: string, name: string): string | undefined {
|
||||
const prefix = `${name}<`;
|
||||
if (!type.startsWith(prefix) || !type.endsWith('>')) return undefined;
|
||||
let depth = 0;
|
||||
for (let i = name.length; i < type.length; i++) {
|
||||
if (type[i] === '<') depth++;
|
||||
else if (type[i] === '>') {
|
||||
depth--;
|
||||
if (depth === 0 && i !== type.length - 1) return undefined;
|
||||
if (depth < 0) return undefined;
|
||||
}
|
||||
}
|
||||
return depth === 0 ? type.slice(prefix.length, -1) : undefined;
|
||||
}
|
||||
|
||||
/** Return a user-facing suffix when a Swift type cannot round-trip through
|
||||
* Foundation JSON without custom encoding. The accepted grammar is deliberately
|
||||
* narrow: JSON scalar values, arrays, string-keyed dictionaries, and Optional
|
||||
* compositions of those types. */
|
||||
export function snapshotTypeIssue(typeText: string): string | undefined {
|
||||
const type = typeText.replace(/\s+/g, '');
|
||||
if (type.length === 0) return 'requires a JSON-compatible type';
|
||||
if (type.endsWith('!')) {
|
||||
return 'cannot use an implicitly unwrapped Optional; use T? instead';
|
||||
}
|
||||
|
||||
if (type.endsWith('?')) {
|
||||
const wrapped = type.slice(0, -1);
|
||||
if (wrapped.endsWith('?')
|
||||
|| genericArgument(wrapped, 'Optional') !== undefined
|
||||
|| genericArgument(wrapped, 'Swift.Optional') !== undefined) {
|
||||
return 'cannot use a nested Optional type';
|
||||
}
|
||||
return snapshotTypeIssue(wrapped);
|
||||
}
|
||||
const optional = genericArgument(type, 'Optional') ?? genericArgument(type, 'Swift.Optional');
|
||||
if (optional !== undefined) {
|
||||
if (optional.endsWith('?')
|
||||
|| genericArgument(optional, 'Optional') !== undefined
|
||||
|| genericArgument(optional, 'Swift.Optional') !== undefined) {
|
||||
return 'cannot use a nested Optional type';
|
||||
}
|
||||
return snapshotTypeIssue(optional);
|
||||
}
|
||||
|
||||
const scalar = type === 'CoreGraphics.CGFloat'
|
||||
? 'CGFloat'
|
||||
: (type.startsWith('Swift.') ? type.slice('Swift.'.length) : type);
|
||||
if (JSON_SCALAR_TYPES.has(scalar)) return undefined;
|
||||
|
||||
if (type.startsWith('[') && type.endsWith(']')) {
|
||||
const inner = type.slice(1, -1);
|
||||
const dictionaryParts = splitTopLevel(inner, ':');
|
||||
if (dictionaryParts.length === 1) return snapshotTypeIssue(inner);
|
||||
if (dictionaryParts.length === 2) {
|
||||
const key = dictionaryParts[0]!.replace(/^Swift\./, '');
|
||||
if (key !== 'String') return 'must use String keys for snapshot dictionaries';
|
||||
return snapshotTypeIssue(dictionaryParts[1]!);
|
||||
}
|
||||
return 'requires a JSON-compatible array or dictionary type';
|
||||
}
|
||||
|
||||
const array = genericArgument(type, 'Array') ?? genericArgument(type, 'Swift.Array');
|
||||
if (array !== undefined) return snapshotTypeIssue(array);
|
||||
const dictionary = genericArgument(type, 'Dictionary') ?? genericArgument(type, 'Swift.Dictionary');
|
||||
if (dictionary !== undefined) {
|
||||
const parts = splitTopLevel(dictionary, ',');
|
||||
if (parts.length !== 2) return 'requires a JSON-compatible dictionary type';
|
||||
const key = parts[0]!.replace(/^Swift\./, '');
|
||||
if (key !== 'String') return 'must use String keys for snapshot dictionaries';
|
||||
return snapshotTypeIssue(parts[1]!);
|
||||
}
|
||||
|
||||
return `uses unsupported snapshot type '${typeText}'; use JSON scalar, array, or String-keyed dictionary types`;
|
||||
}
|
||||
|
||||
export function validateAccessorSpecs(specs: AccessorSpec[]): void {
|
||||
const owners = new Map<string, string>();
|
||||
const diagnostics: string[] = [];
|
||||
for (const spec of specs) {
|
||||
for (const field of spec.fields) {
|
||||
const previous = owners.get(field.name);
|
||||
if (previous !== undefined) {
|
||||
diagnostics.push(`snapshot key '${field.name}' is declared by both ${previous} and ${spec.className}; keys must be unique across @Observable types`);
|
||||
} else {
|
||||
owners.set(field.name, spec.className);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (diagnostics.length > 0) throw new AccessorGenerationError(diagnostics);
|
||||
}
|
||||
|
||||
function swiftStringLiteral(value: string): string {
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
export function render(specs: AccessorSpec[], buildId: string, accessorHash: string): string {
|
||||
validateAccessorSpecs(specs);
|
||||
let out = '// AUTO-GENERATED — DO NOT EDIT. Regenerate with /ios-sync.\n';
|
||||
out += '#if DEBUG\nimport Foundation\nimport DebugBridgeCore\n\n';
|
||||
for (const spec of specs) {
|
||||
// Accessors compile in the app target beside its usually-internal state
|
||||
// types. Making this API public would make valid internal models fail
|
||||
// Swift type checking (a public signature cannot expose an internal type).
|
||||
out += `@MainActor\nenum ${spec.className}Accessor {\n`;
|
||||
out += ` private static func decodeSnapshotValue<T: Decodable>(_ value: Any, as type: T.Type) -> T? {\n`;
|
||||
out += ` guard JSONSerialization.isValidJSONObject(["value": value]),\n`;
|
||||
out += ` let data = try? JSONSerialization.data(withJSONObject: ["value": value]),\n`;
|
||||
out += ` let decoded = try? JSONDecoder().decode([String: T].self, from: data) else { return nil }\n`;
|
||||
out += ` return decoded["value"]\n`;
|
||||
out += ` }\n\n`;
|
||||
out += ` static func register(_ state: ${spec.className}) {\n`;
|
||||
out += ` StateServer.shared.register(\n`;
|
||||
out += ` buildId: "${buildId}",\n`;
|
||||
out += ` accessorHash: "${accessorHash}",\n`;
|
||||
out += ` atomicRestore: { _ in .ok }\n`;
|
||||
out += ` buildId: {\n`;
|
||||
out += ` let shortVersion = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String\n`;
|
||||
out += ` let bundleVersion = Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String\n`;
|
||||
out += ` if let shortVersion, let bundleVersion { return "\\(shortVersion) (\\(bundleVersion))" }\n`;
|
||||
out += ` return shortVersion ?? bundleVersion ?? ${swiftStringLiteral(buildId)}\n`;
|
||||
out += ` }(),\n`;
|
||||
out += ` accessorHash: ${swiftStringLiteral(accessorHash)},\n`;
|
||||
out += ` atomicRestore: { keys, apply in\n`;
|
||||
out += ` // Validate every key and value before assignment.\n`;
|
||||
out += ` // Successful assignments are sequential on MainActor.\n`;
|
||||
spec.fields.forEach((field, index) => {
|
||||
out += ` guard let raw${index} = keys["${field.name}"] else {\n`;
|
||||
out += ` return .missingKey("${field.name}")\n`;
|
||||
out += ` }\n`;
|
||||
const wrapped = optionalWrappedType(field.typeText);
|
||||
if (wrapped !== undefined) {
|
||||
out += ` let restored${index}: ${field.typeText}\n`;
|
||||
out += ` if raw${index} is NSNull {\n`;
|
||||
out += ` restored${index} = nil\n`;
|
||||
out += ` } else if let typed = Self.decodeSnapshotValue(raw${index}, as: ${wrapped}.self) {\n`;
|
||||
out += ` restored${index} = typed\n`;
|
||||
out += ` } else {\n`;
|
||||
out += ` return .typeMismatch("${field.name}")\n`;
|
||||
out += ` }\n`;
|
||||
} else {
|
||||
out += ` guard let restored${index} = Self.decodeSnapshotValue(raw${index}, as: ${field.typeText}.self) else {\n`;
|
||||
out += ` return .typeMismatch("${field.name}")\n`;
|
||||
out += ` }\n`;
|
||||
}
|
||||
});
|
||||
out += ` if apply {\n`;
|
||||
spec.fields.forEach((field, index) => {
|
||||
out += ` state.${field.name} = restored${index}\n`;
|
||||
});
|
||||
out += ` }\n`;
|
||||
out += ` return .ok\n`;
|
||||
out += ` }\n`;
|
||||
out += ` )\n`;
|
||||
for (const field of spec.fields) {
|
||||
const wrapped = optionalWrappedType(field.typeText);
|
||||
out += ` StateServer.shared.registerAccessor(\n`;
|
||||
out += ` key: "${field.name}",\n`;
|
||||
out += ` type: "${field.typeText}",\n`;
|
||||
out += ` read: { state.${field.name} as Any? },\n`;
|
||||
out += ` write: { _ in false }\n`;
|
||||
if (wrapped !== undefined) {
|
||||
out += ` read: {\n`;
|
||||
out += ` guard let value = state.${field.name} else { return NSNull() }\n`;
|
||||
out += ` return value as Any\n`;
|
||||
out += ` },\n`;
|
||||
} else {
|
||||
out += ` read: { state.${field.name} as Any? },\n`;
|
||||
}
|
||||
out += ` write: { value in\n`;
|
||||
if (wrapped !== undefined) {
|
||||
out += ` if value is NSNull {\n`;
|
||||
out += ` state.${field.name} = nil\n`;
|
||||
out += ` return true\n`;
|
||||
out += ` }\n`;
|
||||
out += ` guard let typed = Self.decodeSnapshotValue(value, as: ${wrapped}.self) else { return false }\n`;
|
||||
} else {
|
||||
out += ` guard let typed = Self.decodeSnapshotValue(value, as: ${field.typeText}.self) else { return false }\n`;
|
||||
}
|
||||
out += ` state.${field.name} = typed\n`;
|
||||
out += ` return true\n`;
|
||||
out += ` }\n`;
|
||||
out += ` )\n`;
|
||||
}
|
||||
out += ` }\n}\n\n`;
|
||||
@@ -215,6 +760,15 @@ function detectToolGitRev(): string {
|
||||
}
|
||||
}
|
||||
|
||||
function detectBuildId(): string {
|
||||
if (process.env.APP_BUILD_ID) return process.env.APP_BUILD_ID;
|
||||
const marketing = process.env.MARKETING_VERSION;
|
||||
const build = process.env.CURRENT_PROJECT_VERSION;
|
||||
if (marketing && build) return `${marketing} (${build})`;
|
||||
if (build) return build;
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
export function defaultCacheRoot(): string {
|
||||
return process.env.GSTACK_IOS_CACHE_ROOT ?? join(homedir(), '.gstack', 'cache', 'gen-accessors');
|
||||
}
|
||||
@@ -223,13 +777,25 @@ export function generate(inputs: GenInputs): GenResult {
|
||||
const inputDir = resolve(inputs.inputDir);
|
||||
const outputDir = resolve(inputs.outputDir ?? inputDir);
|
||||
const cacheRoot = inputs.cacheRoot ?? defaultCacheRoot();
|
||||
const swiftFiles = collectSwiftFiles(inputDir);
|
||||
const swiftFiles = collectSwiftFiles(inputDir, { outputDir });
|
||||
const buildId = inputs.buildId ?? detectBuildId();
|
||||
|
||||
// Parse before cache lookup. This keeps diagnostics deterministic even when
|
||||
// an older cache entry exists, and gives us the schema-only accessor hash.
|
||||
const allSpecs: AccessorSpec[] = [];
|
||||
for (const f of swiftFiles) {
|
||||
const src = readFileSync(f, 'utf-8');
|
||||
allSpecs.push(...parseSwift(src));
|
||||
}
|
||||
validateAccessorSpecs(allSpecs);
|
||||
const accessorHash = computeAccessorHash(allSpecs);
|
||||
|
||||
const cacheKey = computeCacheKey({
|
||||
swiftFiles,
|
||||
swiftVersion: inputs.swiftVersion ?? detectSwiftVersion(),
|
||||
toolGitRev: inputs.toolGitRev ?? detectToolGitRev(),
|
||||
platformTriple: inputs.platformTriple ?? FALLBACK_PLATFORM,
|
||||
buildId,
|
||||
});
|
||||
|
||||
const cachedOutput = join(cacheRoot, cacheKey, 'StateAccessor.swift');
|
||||
@@ -242,18 +808,13 @@ export function generate(inputs: GenInputs): GenResult {
|
||||
return {
|
||||
outputPath: finalOutput,
|
||||
cacheKey,
|
||||
specs: [], // intentionally empty on cache hit (no need to re-parse)
|
||||
accessorHash,
|
||||
specs: allSpecs,
|
||||
cacheHit: true,
|
||||
};
|
||||
}
|
||||
|
||||
// Parse + render fresh
|
||||
const allSpecs: AccessorSpec[] = [];
|
||||
for (const f of swiftFiles) {
|
||||
const src = readFileSync(f, 'utf-8');
|
||||
allSpecs.push(...parseSwift(src));
|
||||
}
|
||||
const rendered = render(allSpecs, inputs.buildId ?? 'unknown', cacheKey);
|
||||
const rendered = render(allSpecs, buildId, accessorHash);
|
||||
writeFileSync(finalOutput, rendered);
|
||||
|
||||
// Populate cache (best-effort — cache failures don't break codegen).
|
||||
@@ -267,6 +828,7 @@ export function generate(inputs: GenInputs): GenResult {
|
||||
return {
|
||||
outputPath: finalOutput,
|
||||
cacheKey,
|
||||
accessorHash,
|
||||
specs: allSpecs,
|
||||
cacheHit: false,
|
||||
};
|
||||
@@ -300,10 +862,18 @@ if (import.meta.main) {
|
||||
const inputDir = args[inputIdx + 1]!;
|
||||
const outputIdx = args.indexOf('--output');
|
||||
const outputDir = outputIdx !== -1 ? args[outputIdx + 1] : undefined;
|
||||
const result = generate({ inputDir, outputDir });
|
||||
process.stdout.write(
|
||||
result.cacheHit
|
||||
? `gen-accessors: cache hit (${result.cacheKey.slice(0, 12)})\n`
|
||||
: `gen-accessors: wrote ${result.specs.length} accessor(s) to ${result.outputPath}\n`,
|
||||
);
|
||||
try {
|
||||
const result = generate({ inputDir, outputDir });
|
||||
process.stdout.write(
|
||||
result.cacheHit
|
||||
? `gen-accessors: cache hit (${result.cacheKey.slice(0, 12)})\n`
|
||||
: `gen-accessors: wrote ${result.specs.length} accessor(s) to ${result.outputPath}\n`,
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof AccessorGenerationError) {
|
||||
process.stderr.write(`${error.message}\n`);
|
||||
process.exit(4);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,12 +27,35 @@ public enum DebugBridgeUIWiring {
|
||||
/// times reinstalls the same closures. Must be called on @MainActor
|
||||
/// because every UIKit access requires the main actor.
|
||||
public static func installAll() {
|
||||
// KIF turns on accessibility automation before walking SwiftUI's AX
|
||||
// tree. Without it SwiftUI exposes only the hosting shell and taps
|
||||
// can report success without invoking Button.action.
|
||||
DebugBridgeTouch.prepareForAutomation()
|
||||
ScreenshotBridge.resolver = { ScreenshotBridgeImpl.capturePNG() }
|
||||
ElementsBridge.resolver = { ElementsBridgeImpl.snapshot() }
|
||||
MutationBridge.resolver = { op, payload in MutationBridgeImpl.dispatch(op: op, payload: payload) }
|
||||
}
|
||||
}
|
||||
|
||||
/// Return the children UIKit exposes specifically to accessibility automation.
|
||||
/// iOS 17 added `automationElements`; unlike the older container APIs it
|
||||
/// preserves identified SwiftUI descendants inside accessibility groups.
|
||||
@MainActor
|
||||
private func debugBridgeAccessibilityChildren(of element: NSObject) -> [NSObject] {
|
||||
if #available(iOS 17.0, *),
|
||||
let automation = element.automationElements,
|
||||
!automation.isEmpty {
|
||||
return automation.compactMap { $0 as? NSObject }
|
||||
}
|
||||
if let accessibility = element.accessibilityElements,
|
||||
!accessibility.isEmpty {
|
||||
return accessibility.compactMap { $0 as? NSObject }
|
||||
}
|
||||
let count = element.accessibilityElementCount()
|
||||
guard count > 0, count < 512 else { return [] }
|
||||
return (0..<count).compactMap { element.accessibilityElement(at: $0) as? NSObject }
|
||||
}
|
||||
|
||||
// MARK: - ScreenshotBridge implementation
|
||||
|
||||
@MainActor
|
||||
@@ -43,7 +66,11 @@ enum ScreenshotBridgeImpl {
|
||||
static func capturePNG() -> Data? {
|
||||
guard let scene = activeScene(), let window = activeKeyWindow(in: scene) else { return nil }
|
||||
let bounds = window.bounds
|
||||
let renderer = UIGraphicsImageRenderer(bounds: bounds)
|
||||
let format = UIGraphicsImageRendererFormat.default()
|
||||
// /tap consumes UIKit window points. Render at 1x so screenshot pixels
|
||||
// use that same coordinate space on 2x/3x devices.
|
||||
format.scale = 1
|
||||
let renderer = UIGraphicsImageRenderer(bounds: bounds, format: format)
|
||||
let image = renderer.image { _ in
|
||||
// drawHierarchy is the documented way to snapshot real UIKit
|
||||
// layers including layer-backed views. afterScreenUpdates: false
|
||||
@@ -61,7 +88,10 @@ enum ScreenshotBridgeImpl {
|
||||
}
|
||||
|
||||
private static func activeKeyWindow(in scene: UIWindowScene) -> UIWindow? {
|
||||
scene.windows.first(where: { $0.isKeyWindow }) ?? scene.windows.first
|
||||
let windows = scene.windows.filter { window in
|
||||
!window.isHidden && !String(describing: type(of: window)).contains("PassThroughWindow")
|
||||
}
|
||||
return windows.first(where: { $0.isKeyWindow }) ?? windows.max(by: { $0.windowLevel < $1.windowLevel })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,21 +106,40 @@ enum ElementsBridgeImpl {
|
||||
static func snapshot() -> [JSONDict] {
|
||||
guard let scene = activeScene(), let window = activeKeyWindow(in: scene) else { return [] }
|
||||
var elements: [JSONDict] = []
|
||||
collect(view: window, parentPath: "", windowBounds: window.bounds, into: &elements)
|
||||
var visited = Set<ObjectIdentifier>()
|
||||
var remaining = 2_048
|
||||
collect(
|
||||
view: window,
|
||||
parentPath: "",
|
||||
window: window,
|
||||
visited: &visited,
|
||||
remaining: &remaining,
|
||||
into: &elements
|
||||
)
|
||||
return elements
|
||||
}
|
||||
|
||||
private static func collect(view: UIView, parentPath: String, windowBounds: CGRect, into elements: inout [JSONDict]) {
|
||||
private static func collect(
|
||||
view: UIView,
|
||||
parentPath: String,
|
||||
window: UIWindow,
|
||||
visited: inout Set<ObjectIdentifier>,
|
||||
remaining: inout Int,
|
||||
into elements: inout [JSONDict]
|
||||
) {
|
||||
guard remaining > 0, visited.insert(ObjectIdentifier(view)).inserted else { return }
|
||||
remaining -= 1
|
||||
|
||||
// Skip hidden / zero-size / off-screen subtrees early.
|
||||
if view.isHidden || view.alpha < 0.01 { return }
|
||||
|
||||
let frameInWindow = view.convert(view.bounds, to: nil)
|
||||
if !windowBounds.intersects(frameInWindow) { return }
|
||||
let frameInWindow = view.convert(view.bounds, to: window)
|
||||
if !window.bounds.intersects(frameInWindow) { return }
|
||||
|
||||
let isAccessible = view.isAccessibilityElement
|
||||
let label = view.accessibilityLabel ?? ""
|
||||
let identifier = view.accessibilityIdentifier ?? ""
|
||||
let traits = Int(view.accessibilityTraits.rawValue)
|
||||
let traits = NSNumber(value: view.accessibilityTraits.rawValue)
|
||||
let value = (view.accessibilityValue ?? "") as String
|
||||
let className = String(describing: type(of: view))
|
||||
let path = parentPath.isEmpty ? className : "\(parentPath) > \(className)"
|
||||
@@ -120,66 +169,98 @@ enum ElementsBridgeImpl {
|
||||
])
|
||||
}
|
||||
|
||||
// Recurse into accessibility-elements first (some custom views vend
|
||||
// synthetic children), then UIView subviews. SwiftUI's host views
|
||||
// populate accessibilityElements lazily — many return nil before
|
||||
// VoiceOver triggers them. Force population by reading accessibilityElementCount.
|
||||
_ = view.accessibilityElementCount()
|
||||
if let axElements = view.accessibilityElements {
|
||||
for case let element as NSObject in axElements {
|
||||
if let v = element as? UIView {
|
||||
collect(view: v, parentPath: path, windowBounds: windowBounds, into: &elements)
|
||||
} else {
|
||||
// Synthetic accessibility element (no UIView). Capture frame in screen coords.
|
||||
let af = (element.value(forKey: "accessibilityFrame") as? CGRect) ?? .zero
|
||||
elements.append([
|
||||
"path": "\(path) > <synthetic>",
|
||||
"class": "AccessibilityElement",
|
||||
"label": (element.value(forKey: "accessibilityLabel") as? String) ?? "",
|
||||
"identifier": (element.value(forKey: "accessibilityIdentifier") as? String) ?? "",
|
||||
"value": (element.value(forKey: "accessibilityValue") as? String) ?? "",
|
||||
"traits": (element.value(forKey: "accessibilityTraits") as? NSNumber)?.intValue ?? 0,
|
||||
"frame": [
|
||||
"x": Int(af.origin.x),
|
||||
"y": Int(af.origin.y),
|
||||
"w": Int(af.size.width),
|
||||
"h": Int(af.size.height),
|
||||
],
|
||||
"is_user_interaction_enabled": true,
|
||||
])
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// accessibilityElements is nil — iterate by index. SwiftUI uses
|
||||
// this dynamic protocol pattern; many AX elements only respond
|
||||
// to accessibilityElementCount + accessibilityElement(at:).
|
||||
let count = view.accessibilityElementCount()
|
||||
for i in 0..<count {
|
||||
guard let element = view.accessibilityElement(at: i) as? NSObject else { continue }
|
||||
if let v = element as? UIView {
|
||||
collect(view: v, parentPath: path, windowBounds: windowBounds, into: &elements)
|
||||
} else {
|
||||
let af = (element.value(forKey: "accessibilityFrame") as? CGRect) ?? .zero
|
||||
elements.append([
|
||||
"path": "\(path) > <ax\(i)>",
|
||||
"class": String(describing: type(of: element)),
|
||||
"label": (element.value(forKey: "accessibilityLabel") as? String) ?? "",
|
||||
"identifier": (element.value(forKey: "accessibilityIdentifier") as? String) ?? "",
|
||||
"value": (element.value(forKey: "accessibilityValue") as? String) ?? "",
|
||||
"traits": (element.value(forKey: "accessibilityTraits") as? NSNumber)?.intValue ?? 0,
|
||||
"frame": [
|
||||
"x": Int(af.origin.x),
|
||||
"y": Int(af.origin.y),
|
||||
"w": Int(af.size.width),
|
||||
"h": Int(af.size.height),
|
||||
],
|
||||
"is_user_interaction_enabled": true,
|
||||
])
|
||||
}
|
||||
// Walk automation children before raw subviews. On iOS 17+ this
|
||||
// exposes identified SwiftUI controls nested inside GroupBox/List.
|
||||
for (index, element) in debugBridgeAccessibilityChildren(of: view).enumerated() {
|
||||
if let child = element as? UIView {
|
||||
collect(
|
||||
view: child,
|
||||
parentPath: path,
|
||||
window: window,
|
||||
visited: &visited,
|
||||
remaining: &remaining,
|
||||
into: &elements
|
||||
)
|
||||
} else {
|
||||
appendSynthetic(
|
||||
element,
|
||||
path: "\(path) > <ax\(index)>",
|
||||
window: window,
|
||||
visited: &visited,
|
||||
remaining: &remaining,
|
||||
into: &elements
|
||||
)
|
||||
}
|
||||
}
|
||||
for sub in view.subviews {
|
||||
collect(view: sub, parentPath: path, windowBounds: windowBounds, into: &elements)
|
||||
collect(
|
||||
view: sub,
|
||||
parentPath: path,
|
||||
window: window,
|
||||
visited: &visited,
|
||||
remaining: &remaining,
|
||||
into: &elements
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private static func appendSynthetic(
|
||||
_ element: NSObject,
|
||||
path: String,
|
||||
window: UIWindow,
|
||||
visited: inout Set<ObjectIdentifier>,
|
||||
remaining: inout Int,
|
||||
into elements: inout [JSONDict]
|
||||
) {
|
||||
guard remaining > 0, visited.insert(ObjectIdentifier(element)).inserted else { return }
|
||||
remaining -= 1
|
||||
|
||||
let screenFrame = (element.value(forKey: "accessibilityFrame") as? CGRect) ?? .zero
|
||||
let frame = window.coordinateSpace.convert(screenFrame, from: window.screen.coordinateSpace)
|
||||
let label = (element.value(forKey: "accessibilityLabel") as? String) ?? ""
|
||||
let identifier = (element.value(forKey: "accessibilityIdentifier") as? String) ?? ""
|
||||
let value = (element.value(forKey: "accessibilityValue") as? String) ?? ""
|
||||
let traits = (element.value(forKey: "accessibilityTraits") as? NSNumber)?.uint64Value ?? 0
|
||||
if !label.isEmpty || !identifier.isEmpty || !value.isEmpty || traits != 0 {
|
||||
elements.append([
|
||||
"path": path,
|
||||
"class": String(describing: type(of: element)),
|
||||
"label": label,
|
||||
"identifier": identifier,
|
||||
"value": value,
|
||||
"traits": NSNumber(value: traits),
|
||||
"frame": [
|
||||
"x": Int(frame.origin.x),
|
||||
"y": Int(frame.origin.y),
|
||||
"w": Int(frame.size.width),
|
||||
"h": Int(frame.size.height),
|
||||
],
|
||||
"is_user_interaction_enabled": true,
|
||||
])
|
||||
}
|
||||
|
||||
// Synthetic SwiftUI nodes are themselves accessibility containers.
|
||||
// Recurse even when this grouping node has no metadata of its own.
|
||||
for (index, child) in debugBridgeAccessibilityChildren(of: element).enumerated() {
|
||||
if let childView = child as? UIView {
|
||||
collect(
|
||||
view: childView,
|
||||
parentPath: path,
|
||||
window: window,
|
||||
visited: &visited,
|
||||
remaining: &remaining,
|
||||
into: &elements
|
||||
)
|
||||
} else {
|
||||
appendSynthetic(
|
||||
child,
|
||||
path: "\(path) > <ax\(index)>",
|
||||
window: window,
|
||||
visited: &visited,
|
||||
remaining: &remaining,
|
||||
into: &elements
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -191,7 +272,10 @@ enum ElementsBridgeImpl {
|
||||
}
|
||||
|
||||
private static func activeKeyWindow(in scene: UIWindowScene) -> UIWindow? {
|
||||
scene.windows.first(where: { $0.isKeyWindow }) ?? scene.windows.first
|
||||
let windows = scene.windows.filter { window in
|
||||
!window.isHidden && !String(describing: type(of: window)).contains("PassThroughWindow")
|
||||
}
|
||||
return windows.first(where: { $0.isKeyWindow }) ?? windows.max(by: { $0.windowLevel < $1.windowLevel })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -210,21 +294,62 @@ enum MutationBridgeImpl {
|
||||
}
|
||||
}
|
||||
|
||||
/// Tap at (x, y) in window coordinates. Delegates to DebugBridgeTouch
|
||||
/// (KIF-derived in-process touch synthesis). The Obj-C target builds a
|
||||
/// real UITouch + IOHIDEvent + UIEvent and dispatches via
|
||||
/// `UIApplication.sendEvent`, which is what UIKit uses for real touches.
|
||||
/// This works for UIControl, SwiftUI Button (via iOS 18+
|
||||
/// `_UIHitTestContext`), gesture recognizers, and anything else that
|
||||
/// listens to the real event-dispatch path.
|
||||
/// Tap at (x, y) in window coordinates. Prefer accessibility activation,
|
||||
/// which is stable for SwiftUI buttons across OS releases, then fall back
|
||||
/// to KIF-derived UITouch synthesis for gesture-only/custom controls.
|
||||
private static func handleTap(_ payload: JSONDict) -> Bool {
|
||||
guard let x = payload["x"] as? NSNumber,
|
||||
let y = payload["y"] as? NSNumber else { return false }
|
||||
let point = CGPoint(x: x.doubleValue, y: y.doubleValue)
|
||||
guard let scene = activeScene(), let window = activeKeyWindow(in: scene) else { return false }
|
||||
if let element = findActivatableAXElement(at: point, in: window),
|
||||
element.accessibilityActivate() {
|
||||
return true
|
||||
}
|
||||
return DebugBridgeTouch.sendTap(at: point, in: window)
|
||||
}
|
||||
|
||||
private static func findActivatableAXElement(at point: CGPoint, in window: UIWindow) -> NSObject? {
|
||||
let screenPoint = window.screen.coordinateSpace.convert(point, from: window.coordinateSpace)
|
||||
var best: NSObject?
|
||||
var bestArea: CGFloat = .infinity
|
||||
var visited = Set<ObjectIdentifier>()
|
||||
var remaining = 2_048
|
||||
|
||||
func consider(frame: CGRect, traits: UInt64, element: NSObject) {
|
||||
guard frame.contains(screenPoint),
|
||||
(traits & UIAccessibilityTraits.button.rawValue) != 0 else { return }
|
||||
let area = frame.width * frame.height
|
||||
if area > 0 && area < bestArea {
|
||||
best = element
|
||||
bestArea = area
|
||||
}
|
||||
}
|
||||
|
||||
func visit(_ element: NSObject) {
|
||||
guard remaining > 0, visited.insert(ObjectIdentifier(element)).inserted else { return }
|
||||
remaining -= 1
|
||||
|
||||
if let view = element as? UIView {
|
||||
guard !view.isHidden, view.alpha >= 0.01,
|
||||
view.convert(view.bounds, to: window).contains(point) else { return }
|
||||
if view.isAccessibilityElement {
|
||||
consider(frame: view.accessibilityFrame, traits: view.accessibilityTraits.rawValue, element: view)
|
||||
}
|
||||
for child in debugBridgeAccessibilityChildren(of: view) { visit(child) }
|
||||
for child in view.subviews { visit(child) }
|
||||
} else {
|
||||
let frame = (element.value(forKey: "accessibilityFrame") as? CGRect) ?? .zero
|
||||
let traits = (element.value(forKey: "accessibilityTraits") as? NSNumber)?.uint64Value ?? 0
|
||||
consider(frame: frame, traits: traits, element: element)
|
||||
for child in debugBridgeAccessibilityChildren(of: element) { visit(child) }
|
||||
}
|
||||
}
|
||||
|
||||
visit(window)
|
||||
return best
|
||||
}
|
||||
|
||||
/// Set text on the first responder if it's a UITextField or UITextView.
|
||||
private static func handleType(_ payload: JSONDict) -> Bool {
|
||||
guard let text = payload["text"] as? String else { return false }
|
||||
@@ -266,7 +391,10 @@ enum MutationBridgeImpl {
|
||||
var off = scroll.contentOffset
|
||||
off.x = max(0, min(scroll.contentSize.width - scroll.bounds.width, off.x + dx))
|
||||
off.y = max(0, min(scroll.contentSize.height - scroll.bounds.height, off.y + dy))
|
||||
scroll.setContentOffset(off, animated: true)
|
||||
// Automation commands return synchronously; do not report
|
||||
// success while the target is still moving underneath the
|
||||
// next tap coordinate.
|
||||
scroll.setContentOffset(off, animated: false)
|
||||
return true
|
||||
}
|
||||
node = cur.superview
|
||||
@@ -301,7 +429,10 @@ enum MutationBridgeImpl {
|
||||
}
|
||||
|
||||
private static func activeKeyWindow(in scene: UIWindowScene) -> UIWindow? {
|
||||
scene.windows.first(where: { $0.isKeyWindow }) ?? scene.windows.first
|
||||
let windows = scene.windows.filter { window in
|
||||
!window.isHidden && !String(describing: type(of: window)).contains("PassThroughWindow")
|
||||
}
|
||||
return windows.first(where: { $0.isKeyWindow }) ?? windows.max(by: { $0.windowLevel < $1.windowLevel })
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -14,12 +14,16 @@ import Foundation
|
||||
public final class DebugBridgeManager {
|
||||
public static let shared = DebugBridgeManager()
|
||||
|
||||
public func start(appState: AppState) {
|
||||
// 1. Register the canonical AppState struct + accessor wiring.
|
||||
// AppStateAccessor.register(_:) is generated by gen-accessors-tool.
|
||||
AppStateAccessor.register(appState)
|
||||
/// Register app-owned generated accessors, then start the server. The
|
||||
/// registration closure is passed in from the consuming app because the
|
||||
/// DebugBridgeCore package cannot import app-target types. On UIKit apps,
|
||||
/// call DebugBridgeUIWiring.installAll() before this method so a warm
|
||||
/// daemon cannot reach uninitialized resolvers during listener startup.
|
||||
public func start<State>(appState: State, register: (State) -> Void) {
|
||||
register(appState)
|
||||
|
||||
// 2. Boot the StateServer.
|
||||
// Boot only after registration so the first snapshot has a real build
|
||||
// id, schema hash, and key set.
|
||||
StateServer.shared.start()
|
||||
|
||||
// 3. The consuming app installs DebugOverlayWindow separately. See
|
||||
@@ -31,19 +35,4 @@ public final class DebugBridgeManager {
|
||||
}
|
||||
}
|
||||
|
||||
// Placeholder. gen-accessors-tool emits the real `AppStateAccessor` enum next
|
||||
// to the app's canonical state struct. Apps that haven't run codegen get a
|
||||
// stub that registers no accessors (snapshot is empty, restore returns
|
||||
// missing-key for every key).
|
||||
@MainActor
|
||||
public enum AppStateAccessor {
|
||||
public static var register: (Any) -> Void = { _ in }
|
||||
}
|
||||
|
||||
// Apps declare their canonical state struct; codegen reads it and emits
|
||||
// AppStateAccessor.register. The app's struct must be `@Observable` and
|
||||
// must hold all snapshot-eligible state in `@Snapshotable`-marked fields.
|
||||
@MainActor
|
||||
public protocol AppState: AnyObject {}
|
||||
|
||||
#endif // DEBUG
|
||||
|
||||
@@ -23,6 +23,10 @@ NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
@interface DebugBridgeTouch : NSObject
|
||||
|
||||
/// Enable the in-process accessibility automation tree used by SwiftUI and
|
||||
/// UIKit. Call once while installing the debug bridge, before /elements.
|
||||
+ (void)prepareForAutomation;
|
||||
|
||||
/// Synthesize a single tap (TouchPhaseBegan + TouchPhaseEnded) at the given
|
||||
/// window-coordinate point. Returns YES if the touch was delivered (a hit
|
||||
/// view was found and the event passed through UIApplication.sendEvent).
|
||||
|
||||
@@ -126,6 +126,36 @@ static BOOL DBT_LoadIOKit(void) {
|
||||
return _IOKitLoaded;
|
||||
}
|
||||
|
||||
// KIF enables accessibility automation before it asks UIKit/SwiftUI for the
|
||||
// accessibility tree. Without this switch SwiftUI exposes only its hosting
|
||||
// shell: /elements has no controls and a synthesized tap can return YES while
|
||||
// Button.action never fires. Keep this DEBUG-only with the rest of this file.
|
||||
typedef int (*DBTAXSAutomationEnabledFn)(void);
|
||||
typedef void (*DBTAXSSetAutomationEnabledFn)(int);
|
||||
static DBTAXSAutomationEnabledFn _DBTAXSAutomationEnabled;
|
||||
static DBTAXSSetAutomationEnabledFn _DBTAXSSetAutomationEnabled;
|
||||
static int _DBTInitialAutomationState = -1;
|
||||
|
||||
static void DBT_RestoreAccessibilityAutomation(void) {
|
||||
if (_DBTAXSSetAutomationEnabled && _DBTInitialAutomationState >= 0) {
|
||||
_DBTAXSSetAutomationEnabled(_DBTInitialAutomationState);
|
||||
}
|
||||
}
|
||||
|
||||
static void DBT_EnableAccessibilityAutomation(void) {
|
||||
static dispatch_once_t onceToken;
|
||||
dispatch_once(&onceToken, ^{
|
||||
void *handle = dlopen("/usr/lib/libAccessibility.dylib", RTLD_NOW | RTLD_LOCAL);
|
||||
if (!handle) return;
|
||||
_DBTAXSAutomationEnabled = (DBTAXSAutomationEnabledFn)dlsym(handle, "_AXSAutomationEnabled");
|
||||
_DBTAXSSetAutomationEnabled = (DBTAXSSetAutomationEnabledFn)dlsym(handle, "_AXSSetAutomationEnabled");
|
||||
if (!_DBTAXSAutomationEnabled || !_DBTAXSSetAutomationEnabled) return;
|
||||
_DBTInitialAutomationState = _DBTAXSAutomationEnabled();
|
||||
if (!_DBTInitialAutomationState) _DBTAXSSetAutomationEnabled(1);
|
||||
atexit(DBT_RestoreAccessibilityAutomation);
|
||||
});
|
||||
}
|
||||
|
||||
static IOHIDEventRef DBT_IOHIDEventWithTouch(UITouch *touch) CF_RETURNS_RETAINED;
|
||||
static IOHIDEventRef DBT_IOHIDEventWithTouch(UITouch *touch) {
|
||||
if (!DBT_LoadIOKit()) return NULL;
|
||||
@@ -172,6 +202,7 @@ static IOHIDEventRef DBT_IOHIDEventWithTouch(UITouch *touch) {
|
||||
- (void)setGestureView:(UIView *)view;
|
||||
- (void)_setLocationInWindow:(CGPoint)location resetPrevious:(BOOL)resetPrevious;
|
||||
- (void)_setIsFirstTouchForView:(BOOL)firstTouchForView;
|
||||
- (void)_setIsTapToClick:(BOOL)tapToClick;
|
||||
- (void)_setHidEvent:(IOHIDEventRef)event;
|
||||
@end
|
||||
|
||||
@@ -229,6 +260,10 @@ static id DBT_HitTestView(UIWindow *window, CGPoint point) {
|
||||
|
||||
@implementation DebugBridgeTouch
|
||||
|
||||
+ (void)prepareForAutomation {
|
||||
DBT_EnableAccessibilityAutomation();
|
||||
}
|
||||
|
||||
+ (BOOL)sendTapAtPoint:(CGPoint)point inWindow:(UIWindow *)window {
|
||||
if (!window) return NO;
|
||||
|
||||
@@ -247,10 +282,17 @@ static id DBT_HitTestView(UIWindow *window, CGPoint point) {
|
||||
[touch setPhase:UITouchPhaseBegan];
|
||||
if ([touch respondsToSelector:@selector(_setIsFirstTouchForView:)]) {
|
||||
[touch _setIsFirstTouchForView:YES];
|
||||
} else if ([touch respondsToSelector:@selector(_setIsTapToClick:)]) {
|
||||
[touch _setIsTapToClick:YES];
|
||||
Ivar flagsIvar = class_getInstanceVariable([UITouch class], "_touchFlags");
|
||||
if (flagsIvar) {
|
||||
ptrdiff_t offset = ivar_getOffset(flagsIvar);
|
||||
char *flags = (__bridge void *)touch + offset;
|
||||
*flags |= (char)0x01;
|
||||
}
|
||||
}
|
||||
[touch setTimestamp:[[NSProcessInfo processInfo] systemUptime]];
|
||||
if ([touch respondsToSelector:@selector(setGestureView:)] &&
|
||||
[hit isKindOfClass:[UIView class]]) {
|
||||
if ([touch respondsToSelector:@selector(setGestureView:)]) {
|
||||
[touch setGestureView:(UIView *)hit];
|
||||
}
|
||||
|
||||
|
||||
@@ -6,22 +6,35 @@
|
||||
// the DebugBridge target.
|
||||
|
||||
#if DEBUG
|
||||
import DebugBridge
|
||||
import Foundation
|
||||
import DebugBridgeCore
|
||||
#if canImport(UIKit)
|
||||
import DebugBridgeUI
|
||||
#endif
|
||||
|
||||
@MainActor
|
||||
func startGstackDebugBridge(appState: AppState) {
|
||||
func startGstackDebugBridge<State>(
|
||||
appState: State,
|
||||
register: (State) -> Void
|
||||
) {
|
||||
// Read --recording flag from launch arguments
|
||||
let recording = ProcessInfo.processInfo.arguments.contains("--gstack-recording")
|
||||
|
||||
// Install accessibility + screenshot + mutation bridges before starting
|
||||
// the server so the first authenticated request can use them.
|
||||
ElementsBridge.resolver = { AccessibilityScanner.snapshot() }
|
||||
ScreenshotBridge.resolver = { SnapshotCapture.capturePNG() }
|
||||
MutationBridge.resolver = { op, payload in
|
||||
MutationDispatcher.shared.run(op: op, payload: payload)
|
||||
}
|
||||
// Install the UI resolvers before opening the listener. A warm daemon can
|
||||
// issue its first request as soon as StateServer starts; it must never see
|
||||
// the default empty screenshot/elements/mutation handlers.
|
||||
#if canImport(UIKit)
|
||||
DebugBridgeUIWiring.installAll()
|
||||
#endif
|
||||
|
||||
DebugBridgeManager.shared.start(appState: appState, recording: recording)
|
||||
// Generated typed accessors live in the app target, so pass their register
|
||||
// function into the package instead of asking DebugBridgeCore to import
|
||||
// app-owned types.
|
||||
DebugBridgeManager.shared.start(appState: appState, register: register)
|
||||
|
||||
#if canImport(UIKit)
|
||||
DebugOverlayWindow.shared.install(recording: recording)
|
||||
#endif
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -33,7 +46,10 @@ func startGstackDebugBridge(appState: AppState) {
|
||||
//
|
||||
// init() {
|
||||
// #if DEBUG
|
||||
// startGstackDebugBridge(appState: appState)
|
||||
// startGstackDebugBridge(
|
||||
// appState: appState,
|
||||
// register: MyAppStateAccessor.register
|
||||
// )
|
||||
// #endif
|
||||
// }
|
||||
//
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// swift-tools-version:5.9
|
||||
// AUTO-GENERATED from gstack/ios-qa/templates/Package.swift.template
|
||||
//
|
||||
// Drop-in SPM package definition for the DebugBridge stack. Three targets:
|
||||
@@ -18,7 +19,6 @@
|
||||
// CI invariant: `swift build -c release` + `nm -j build/Release/<binary>
|
||||
// | grep -q DebugBridge && exit 1`.
|
||||
|
||||
// swift-tools-version:5.9
|
||||
import PackageDescription
|
||||
|
||||
let package = Package(
|
||||
@@ -58,10 +58,5 @@ let package = Package(
|
||||
.define("DEBUG", .when(configuration: .debug)),
|
||||
]
|
||||
),
|
||||
.testTarget(
|
||||
name: "DebugBridgeCoreTests",
|
||||
dependencies: ["DebugBridgeCore"],
|
||||
path: "Tests/DebugBridgeCoreTests"
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
@@ -3,12 +3,12 @@
|
||||
//
|
||||
// This file is a TEMPLATE that gen-accessors-tool fills in. The placeholders
|
||||
// are filled per-class from swift-syntax AST inspection of the app's
|
||||
// @Observable types. Only properties marked with @Snapshotable are emitted.
|
||||
// @Observable types. Only properties preceded by `// @Snapshotable` are emitted.
|
||||
//
|
||||
// {{CLASS_NAME}} — the canonical AppState struct name
|
||||
// {{APP_BUILD_ID}} — bundle short version + git SHA at codegen time
|
||||
// {{ACCESSOR_HASH}} — sha256 of accessor signatures (snapshot schema fingerprint)
|
||||
// {{ACCESSORS}} — generated register/read/write blocks per @Snapshotable field
|
||||
// {{ACCESSORS}} — generated register/read/write blocks per marked field
|
||||
|
||||
#if DEBUG
|
||||
|
||||
@@ -21,13 +21,16 @@ public enum {{CLASS_NAME}}Accessor {
|
||||
StateServer.shared.register(
|
||||
buildId: "{{APP_BUILD_ID}}",
|
||||
accessorHash: "{{ACCESSOR_HASH}}",
|
||||
atomicRestore: { keys in
|
||||
// Validate every key + type FIRST, then apply in one struct
|
||||
// assignment so SwiftUI observers see exactly one change.
|
||||
atomicRestore: { keys, apply in
|
||||
// Validate every key + type before mutating any state. Invalid
|
||||
// input therefore cannot leave a partially restored model.
|
||||
var snapshot = state.snapshotable
|
||||
{{VALIDATION_BLOCK}}
|
||||
// Apply atomically.
|
||||
state.snapshotable = snapshot
|
||||
// StateServer validates every model first, then invokes the
|
||||
// same handlers with apply=true for a cross-model commit.
|
||||
if apply {
|
||||
state.snapshotable = snapshot
|
||||
}
|
||||
return .ok
|
||||
}
|
||||
)
|
||||
|
||||
@@ -59,18 +59,20 @@ public final class StateServer {
|
||||
private var writeHandlers: [String: WriteHandler] = [:]
|
||||
private var typeNames: [String: TypeName] = [:]
|
||||
|
||||
// Atomic-restore hook. Codegen wires this to the canonical AppState struct.
|
||||
// Restore replaces the entire struct in one assignment so SwiftUI's Combine
|
||||
// pipeline observes exactly one change notification — true observable
|
||||
// atomicity. @MainActor alone doesn't guarantee that.
|
||||
public typealias AtomicRestoreFn = (JSONDict) -> RestoreResult
|
||||
// Validated-restore hooks. Every generated model registers one two-phase
|
||||
// handler. The server validates all models before it lets any model mutate,
|
||||
// so invalid input can never cause a cross-model partial restore.
|
||||
// Valid restores apply properties on MainActor; observers may receive one
|
||||
// change notification per property because arbitrary @Observable models do
|
||||
// not expose a general single-assignment transaction API.
|
||||
public typealias AtomicRestoreFn = (JSONDict, Bool) -> RestoreResult
|
||||
public enum RestoreResult {
|
||||
case ok
|
||||
case missingKey(String)
|
||||
case typeMismatch(String)
|
||||
case schemaMismatch(expected: String, got: String)
|
||||
}
|
||||
private var atomicRestore: AtomicRestoreFn?
|
||||
private var atomicRestores: [AtomicRestoreFn] = []
|
||||
|
||||
// Snapshot schema hash — written by codegen, stable across builds with
|
||||
// identical accessor signatures.
|
||||
@@ -109,7 +111,7 @@ public final class StateServer {
|
||||
public func register(buildId: String, accessorHash: String, atomicRestore: @escaping AtomicRestoreFn) {
|
||||
self.appBuildId = buildId
|
||||
self.accessorHash = accessorHash
|
||||
self.atomicRestore = atomicRestore
|
||||
self.atomicRestores.append(atomicRestore)
|
||||
}
|
||||
|
||||
public func registerAccessor(key: String, type: String, read: @escaping ReadHandler, write: @escaping WriteHandler) {
|
||||
@@ -284,6 +286,7 @@ public final class StateServer {
|
||||
"version": "1.0.0",
|
||||
"build": appBuildId,
|
||||
"accessor_hash": accessorHash,
|
||||
"bundle_id": Bundle.main.bundleIdentifier ?? "unknown",
|
||||
])
|
||||
return
|
||||
}
|
||||
@@ -476,22 +479,36 @@ public final class StateServer {
|
||||
send(connection: connection, status: 400, body: ["error": "missing_keys"])
|
||||
return
|
||||
}
|
||||
guard let restore = atomicRestore else {
|
||||
guard !atomicRestores.isEmpty else {
|
||||
send(connection: connection, status: 503, body: ["error": "atomic_restore_not_registered"])
|
||||
return
|
||||
}
|
||||
// Validate-then-apply via the codegen-supplied closure. The closure does
|
||||
// a single struct-assignment so SwiftUI sees one change notification.
|
||||
switch restore(keys) {
|
||||
case .ok:
|
||||
send(connection: connection, status: 200, body: ["ok": true])
|
||||
case .missingKey(let k):
|
||||
send(connection: connection, status: 400, body: ["error": "validation_failed", "key": k, "reason": "missing"])
|
||||
case .typeMismatch(let k):
|
||||
send(connection: connection, status: 400, body: ["error": "validation_failed", "key": k, "reason": "type-mismatch"])
|
||||
case .schemaMismatch(let expected, let got):
|
||||
send(connection: connection, status: 409, body: ["error": "schema_mismatch", "expected_hash": expected, "got_hash": got])
|
||||
// Phase one validates every registered model without assignment.
|
||||
for restore in atomicRestores {
|
||||
switch restore(keys, false) {
|
||||
case .ok:
|
||||
continue
|
||||
case .missingKey(let k):
|
||||
send(connection: connection, status: 400, body: ["error": "validation_failed", "key": k, "reason": "missing"])
|
||||
return
|
||||
case .typeMismatch(let k):
|
||||
send(connection: connection, status: 400, body: ["error": "validation_failed", "key": k, "reason": "type-mismatch"])
|
||||
return
|
||||
case .schemaMismatch(let expected, let got):
|
||||
send(connection: connection, status: 409, body: ["error": "schema_mismatch", "expected_hash": expected, "got_hash": got])
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Phase two applies only after every model accepted the immutable input.
|
||||
// A valid multi-field restore may notify once per property.
|
||||
for restore in atomicRestores {
|
||||
guard case .ok = restore(keys, true) else {
|
||||
send(connection: connection, status: 500, body: ["error": "restore_apply_failed"])
|
||||
return
|
||||
}
|
||||
}
|
||||
send(connection: connection, status: 200, body: ["ok": true])
|
||||
}
|
||||
|
||||
// MARK: Stubs (real impls live in DebugBridgeManager + UIKit)
|
||||
@@ -522,9 +539,19 @@ public final class StateServer {
|
||||
// MARK: Response
|
||||
|
||||
private func send(connection: NWConnection, status: Int, body: JSONDict) {
|
||||
let json = (try? JSONSerialization.data(withJSONObject: body)) ?? Data("{}".utf8)
|
||||
let responseStatus: Int
|
||||
let json: Data
|
||||
if JSONSerialization.isValidJSONObject(body),
|
||||
let encoded = try? JSONSerialization.data(withJSONObject: body) {
|
||||
responseStatus = status
|
||||
json = encoded
|
||||
} else {
|
||||
logger.error("Refusing to send a non-JSON response body")
|
||||
responseStatus = 500
|
||||
json = Data("{\"error\":\"response_not_json_serializable\"}".utf8)
|
||||
}
|
||||
let statusText: String
|
||||
switch status {
|
||||
switch responseStatus {
|
||||
case 200: statusText = "OK"
|
||||
case 400: statusText = "Bad Request"
|
||||
case 401: statusText = "Unauthorized"
|
||||
@@ -537,7 +564,7 @@ public final class StateServer {
|
||||
case 503: statusText = "Service Unavailable"
|
||||
default: statusText = "Status"
|
||||
}
|
||||
let header = "HTTP/1.1 \(status) \(statusText)\r\nContent-Type: application/json\r\nContent-Length: \(json.count)\r\nConnection: close\r\n\r\n"
|
||||
let header = "HTTP/1.1 \(responseStatus) \(statusText)\r\nContent-Type: application/json\r\nContent-Length: \(json.count)\r\nConnection: close\r\n\r\n"
|
||||
var packet = Data(header.utf8)
|
||||
packet.append(json)
|
||||
connection.send(content: packet, completion: .contentProcessed { _ in
|
||||
|
||||
+28
-23
@@ -806,49 +806,53 @@ After `/ios-qa` is installed in an app, the user may:
|
||||
|
||||
1. Add new `@Observable` classes or properties that need accessor coverage.
|
||||
2. Upgrade gstack to a newer version with hardening fixes.
|
||||
3. Move the `@Snapshotable` marker to a different field.
|
||||
3. Move the `// @Snapshotable` generator marker comment to a different field.
|
||||
|
||||
This skill regenerates the relevant artifacts in place.
|
||||
|
||||
**Templates live in upstream gstack.** This skill resolves them from
|
||||
`~/.claude/skills/gstack/ios-qa/templates/` (or the worktree's
|
||||
`ios-qa/templates/` when developing gstack itself). The fork's HTTP-fetch
|
||||
pattern is gone.
|
||||
**Templates live in upstream gstack.** The installed
|
||||
`gstack-ios-qa-regen` launcher resolves its own gstack root and copies only
|
||||
the supported bridge files from `ios-qa/templates/`. The fork's HTTP-fetch
|
||||
and wildcard-copy patterns are gone.
|
||||
|
||||
## Phase 1: Detect installed version
|
||||
|
||||
1. Read `<app>/DebugBridgeGenerated/.gstack-version` (written by /ios-qa
|
||||
during install). If missing, treat the install as "unknown old version".
|
||||
2. Read upstream version from `$GSTACK_HOME/ios-qa/.gstack-version` (or the
|
||||
value baked into the installed gstack binary).
|
||||
2. Read upstream version from `$GSTACK_ROOT/VERSION`.
|
||||
3. If versions match AND no new `@Observable` classes were added, exit
|
||||
early with "already up to date".
|
||||
|
||||
## Phase 2: Regenerate codegen output
|
||||
|
||||
Run `gstack-ios-qa-regen` (or the underlying SwiftPM tool directly):
|
||||
Run the deterministic regenerator once. `--app-source` is the directory the
|
||||
accessor scanner should inspect; `--bridge-dir` is the local Swift package
|
||||
that the app links in Debug builds:
|
||||
|
||||
```bash
|
||||
swift run --package-path "$GSTACK_HOME/ios-qa/scripts/gen-accessors-tool" \
|
||||
gen-accessors --input "$APP_SOURCE_DIR" --output "$APP_SOURCE_DIR/DebugBridgeGenerated"
|
||||
~/.claude/skills/gstack/bin/gstack-ios-qa-regen \
|
||||
--app-source "$APP_SOURCE_DIR" \
|
||||
--bridge-dir "$APP_SOURCE_DIR/DebugBridge"
|
||||
```
|
||||
|
||||
The command removes only the known obsolete generated files from the former
|
||||
flat `DebugBridgeGenerated/` layout before emitting the current accessor.
|
||||
Generation accepts file-scope observable classes and JSON-native scalar,
|
||||
array, String-keyed dictionary, and Optional field types. It rejects custom
|
||||
types, implicitly unwrapped Optionals, nested observable classes, and duplicate
|
||||
snapshot keys before writing a completion marker.
|
||||
|
||||
The composite-hash cache key handles whether anything actually needs
|
||||
regenerating; if Swift version, generator git rev, lockfile, source content,
|
||||
and platform triple all match the cache, this is a ~50ms no-op.
|
||||
|
||||
## Phase 3: Update templated Swift files in place
|
||||
## Phase 3: Review the generated diff
|
||||
|
||||
For each file that comes from `ios-qa/templates/*.swift.template`:
|
||||
|
||||
1. Read the current installed file at
|
||||
`<app>/DebugBridgeGenerated/<Name>.swift`.
|
||||
2. Read the upstream template at
|
||||
`$GSTACK_HOME/ios-qa/templates/<Name>.swift.template`.
|
||||
3. If the installed file has a `// GSTACK-EDIT-LINE` marker, fold the user's
|
||||
edits forward.
|
||||
4. Otherwise, replace the file outright with the new template (after
|
||||
AskUserQuestion if the diff is non-trivial).
|
||||
1. Review changes under `<app>/DebugBridge/` and
|
||||
`<app>/DebugBridgeGenerated/StateAccessor.swift`.
|
||||
2. Confirm the command did not modify the app's handwritten Swift files.
|
||||
3. Keep app-specific wiring in the app target; canonical bridge package files
|
||||
are regenerated from upstream and should not be hand-edited.
|
||||
|
||||
## Phase 4: Verify
|
||||
|
||||
@@ -862,5 +866,6 @@ For each file that comes from `ios-qa/templates/*.swift.template`:
|
||||
| Symptom | Action |
|
||||
|---|---|
|
||||
| Swift compile fails after regen | Revert via `git restore` + AskUserQuestion: surface the compile error |
|
||||
| Schema hash unchanged after adding new @Observable | The new class isn't marked `@Snapshotable` — the codegen excludes it correctly. If the user wanted it snapshotted, add the wrapper. |
|
||||
| `--input` source dir contains test fixtures | gen-accessors scans the input dir recursively; exclude test/ via `--exclude` |
|
||||
| Codegen reports an invalid marked declaration | Use a file-scope observable class and a writable instance `var` with an explicit JSON-native type, internal/public setter, and a key unique across models; otherwise remove the `// @Snapshotable` marker. |
|
||||
| Schema hash unchanged after adding new @Observable | No field has the standalone `// @Snapshotable` marker comment — codegen excludes unmarked state correctly. Add the comment immediately above each field that should be snapshotted. |
|
||||
| Scanner sees generated bridge sources | Pass the narrow app source directory; the regenerator automatically excludes `DebugBridgeGenerated` and `StateAccessor.swift`. |
|
||||
|
||||
+28
-23
@@ -35,49 +35,53 @@ After `/ios-qa` is installed in an app, the user may:
|
||||
|
||||
1. Add new `@Observable` classes or properties that need accessor coverage.
|
||||
2. Upgrade gstack to a newer version with hardening fixes.
|
||||
3. Move the `@Snapshotable` marker to a different field.
|
||||
3. Move the `// @Snapshotable` generator marker comment to a different field.
|
||||
|
||||
This skill regenerates the relevant artifacts in place.
|
||||
|
||||
**Templates live in upstream gstack.** This skill resolves them from
|
||||
`~/.claude/skills/gstack/ios-qa/templates/` (or the worktree's
|
||||
`ios-qa/templates/` when developing gstack itself). The fork's HTTP-fetch
|
||||
pattern is gone.
|
||||
**Templates live in upstream gstack.** The installed
|
||||
`gstack-ios-qa-regen` launcher resolves its own gstack root and copies only
|
||||
the supported bridge files from `ios-qa/templates/`. The fork's HTTP-fetch
|
||||
and wildcard-copy patterns are gone.
|
||||
|
||||
## Phase 1: Detect installed version
|
||||
|
||||
1. Read `<app>/DebugBridgeGenerated/.gstack-version` (written by /ios-qa
|
||||
during install). If missing, treat the install as "unknown old version".
|
||||
2. Read upstream version from `$GSTACK_HOME/ios-qa/.gstack-version` (or the
|
||||
value baked into the installed gstack binary).
|
||||
2. Read upstream version from `$GSTACK_ROOT/VERSION`.
|
||||
3. If versions match AND no new `@Observable` classes were added, exit
|
||||
early with "already up to date".
|
||||
|
||||
## Phase 2: Regenerate codegen output
|
||||
|
||||
Run `gstack-ios-qa-regen` (or the underlying SwiftPM tool directly):
|
||||
Run the deterministic regenerator once. `--app-source` is the directory the
|
||||
accessor scanner should inspect; `--bridge-dir` is the local Swift package
|
||||
that the app links in Debug builds:
|
||||
|
||||
```bash
|
||||
swift run --package-path "$GSTACK_HOME/ios-qa/scripts/gen-accessors-tool" \
|
||||
gen-accessors --input "$APP_SOURCE_DIR" --output "$APP_SOURCE_DIR/DebugBridgeGenerated"
|
||||
~/.claude/skills/gstack/bin/gstack-ios-qa-regen \
|
||||
--app-source "$APP_SOURCE_DIR" \
|
||||
--bridge-dir "$APP_SOURCE_DIR/DebugBridge"
|
||||
```
|
||||
|
||||
The command removes only the known obsolete generated files from the former
|
||||
flat `DebugBridgeGenerated/` layout before emitting the current accessor.
|
||||
Generation accepts file-scope observable classes and JSON-native scalar,
|
||||
array, String-keyed dictionary, and Optional field types. It rejects custom
|
||||
types, implicitly unwrapped Optionals, nested observable classes, and duplicate
|
||||
snapshot keys before writing a completion marker.
|
||||
|
||||
The composite-hash cache key handles whether anything actually needs
|
||||
regenerating; if Swift version, generator git rev, lockfile, source content,
|
||||
and platform triple all match the cache, this is a ~50ms no-op.
|
||||
|
||||
## Phase 3: Update templated Swift files in place
|
||||
## Phase 3: Review the generated diff
|
||||
|
||||
For each file that comes from `ios-qa/templates/*.swift.template`:
|
||||
|
||||
1. Read the current installed file at
|
||||
`<app>/DebugBridgeGenerated/<Name>.swift`.
|
||||
2. Read the upstream template at
|
||||
`$GSTACK_HOME/ios-qa/templates/<Name>.swift.template`.
|
||||
3. If the installed file has a `// GSTACK-EDIT-LINE` marker, fold the user's
|
||||
edits forward.
|
||||
4. Otherwise, replace the file outright with the new template (after
|
||||
AskUserQuestion if the diff is non-trivial).
|
||||
1. Review changes under `<app>/DebugBridge/` and
|
||||
`<app>/DebugBridgeGenerated/StateAccessor.swift`.
|
||||
2. Confirm the command did not modify the app's handwritten Swift files.
|
||||
3. Keep app-specific wiring in the app target; canonical bridge package files
|
||||
are regenerated from upstream and should not be hand-edited.
|
||||
|
||||
## Phase 4: Verify
|
||||
|
||||
@@ -91,5 +95,6 @@ For each file that comes from `ios-qa/templates/*.swift.template`:
|
||||
| Symptom | Action |
|
||||
|---|---|
|
||||
| Swift compile fails after regen | Revert via `git restore` + AskUserQuestion: surface the compile error |
|
||||
| Schema hash unchanged after adding new @Observable | The new class isn't marked `@Snapshotable` — the codegen excludes it correctly. If the user wanted it snapshotted, add the wrapper. |
|
||||
| `--input` source dir contains test fixtures | gen-accessors scans the input dir recursively; exclude test/ via `--exclude` |
|
||||
| Codegen reports an invalid marked declaration | Use a file-scope observable class and a writable instance `var` with an explicit JSON-native type, internal/public setter, and a key unique across models; otherwise remove the `// @Snapshotable` marker. |
|
||||
| Schema hash unchanged after adding new @Observable | No field has the standalone `// @Snapshotable` marker comment — codegen excludes unmarked state correctly. Add the comment immediately above each field that should be snapshotted. |
|
||||
| Scanner sees generated bridge sources | Pass the narrow app source directory; the regenerator automatically excludes `DebugBridgeGenerated` and `StateAccessor.swift`. |
|
||||
|
||||
@@ -106,9 +106,15 @@ export function canonicalizeRemote(url: string | null | undefined): string {
|
||||
// strip user@ prefix on URL-style remotes
|
||||
s = s.replace(/^[^@\/]+@/, "");
|
||||
}
|
||||
// strip trailing slash(es) first, so a URL written with a trailing slash
|
||||
// still matches the `.git$` suffix below (e.g. ".../repo.git/" must
|
||||
// canonicalize to ".../repo", not ".../repo.git").
|
||||
s = s.replace(/\/+$/, "");
|
||||
// strip trailing .git
|
||||
s = s.replace(/\.git$/i, "");
|
||||
// strip trailing slash
|
||||
// re-strip trailing slash(es): a path remote ending in a `.git` directory
|
||||
// component ("/repo/.git") exposes a new trailing slash once `.git` is
|
||||
// stripped, which would split the repo into a second identity.
|
||||
s = s.replace(/\/+$/, "");
|
||||
// collapse multiple slashes (after path normalization)
|
||||
s = s.replace(/\/{2,}/g, "/");
|
||||
@@ -396,6 +402,7 @@ function extractGbrainBlock(frontmatter: string): GbrainManifest | null {
|
||||
const globM = body.match(/(?:^|\n)\s*glob\s*:\s*"?([^"\n]+?)"?\s*$/m);
|
||||
const sortM = body.match(/(?:^|\n)\s*sort\s*:\s*([^\n]+)/);
|
||||
const tailM = body.match(/(?:^|\n)\s*tail\s*:\s*(\d+)/);
|
||||
const filterMap = parseFilterMap(body);
|
||||
|
||||
if (idM) q.id = idM[1].trim();
|
||||
if (kindM) {
|
||||
@@ -408,6 +415,7 @@ function extractGbrainBlock(frontmatter: string): GbrainManifest | null {
|
||||
if (globM) q.glob = globM[1].trim();
|
||||
if (sortM) q.sort = sortM[1].trim();
|
||||
if (tailM) q.tail = parseInt(tailM[1], 10);
|
||||
if (filterMap) q.filter = filterMap;
|
||||
|
||||
if (q.id && q.kind && q.render_as) {
|
||||
queries.push(q as GbrainManifestQuery);
|
||||
@@ -418,6 +426,39 @@ function extractGbrainBlock(frontmatter: string): GbrainManifest | null {
|
||||
return { schema, context_queries: queries };
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a nested `filter:` block map out of a single context_queries item body.
|
||||
*
|
||||
* The block is a YAML map nested under the `filter:` key:
|
||||
*
|
||||
* filter:
|
||||
* type: timeline
|
||||
* tags_contains: "repo:{repo_slug}"
|
||||
*
|
||||
* Each sub-key sits one indent level deeper than `filter:`. Surrounding quotes
|
||||
* are stripped and template vars ({repo_slug}, now-7d, ...) are left intact for
|
||||
* downstream substitution, matching how dispatchList stringifies each value
|
||||
* into a `--filter k=v` argument. Returns undefined when there is no `filter:`
|
||||
* block or it is empty.
|
||||
*/
|
||||
function parseFilterMap(body: string): Record<string, string> | undefined {
|
||||
const lines = body.split("\n");
|
||||
const filterIdx = lines.findIndex((l) => /^\s*filter\s*:\s*$/.test(l));
|
||||
if (filterIdx === -1) return undefined;
|
||||
const filterIndent = lines[filterIdx].match(/^\s*/)![0].length;
|
||||
|
||||
const filter: Record<string, string> = {};
|
||||
for (let i = filterIdx + 1; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
if (line.trim() === "") continue; // tolerate blank lines within the block
|
||||
const indent = line.match(/^\s*/)![0].length;
|
||||
if (indent <= filterIndent) break; // dedent to a sibling key ends the block
|
||||
const kv = line.match(/^\s*([A-Za-z0-9_]+)\s*:\s*"?(.*?)"?\s*$/);
|
||||
if (kv) filter[kv[1]] = kv[2].trim();
|
||||
}
|
||||
return Object.keys(filter).length > 0 ? filter : undefined;
|
||||
}
|
||||
|
||||
// ── Public: withErrorContext ──────────────────────────────────────────────
|
||||
|
||||
const ERROR_LOG_PATH = join(gstackHome(), ".gbrain-errors.jsonl");
|
||||
|
||||
+14
-1
@@ -18,10 +18,23 @@ let filterBranch: string | null = null;
|
||||
let filterTier: string | null = null;
|
||||
let limit = 20;
|
||||
|
||||
function parseLimit(raw: string | undefined): number {
|
||||
if (!raw || !/^[1-9]\d*$/.test(raw)) {
|
||||
console.error('eval:list: --limit requires a positive integer');
|
||||
process.exit(1);
|
||||
}
|
||||
const parsed = Number(raw);
|
||||
if (!Number.isSafeInteger(parsed)) {
|
||||
console.error('eval:list: --limit requires a positive integer');
|
||||
process.exit(1);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
if (args[i] === '--branch' && args[i + 1]) { filterBranch = args[++i]; }
|
||||
else if (args[i] === '--tier' && args[i + 1]) { filterTier = args[++i]; }
|
||||
else if (args[i] === '--limit' && args[i + 1]) { limit = parseInt(args[++i], 10); }
|
||||
else if (args[i] === '--limit') { limit = parseLimit(args[++i]); }
|
||||
}
|
||||
|
||||
// Read eval files
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
|
||||
import { ALL_HOST_CONFIGS, getHostConfig, ALL_HOST_NAMES } from '../hosts/index';
|
||||
import { validateAllConfigs } from './host-config';
|
||||
import { RESOLVERS } from './resolvers';
|
||||
import { execSync } from 'child_process';
|
||||
|
||||
const CLI_REGEX = /^[a-z][a-z0-9_-]*$/;
|
||||
@@ -82,7 +83,7 @@ switch (command) {
|
||||
}
|
||||
|
||||
case 'validate': {
|
||||
const errors = validateAllConfigs(ALL_HOST_CONFIGS);
|
||||
const errors = validateAllConfigs(ALL_HOST_CONFIGS, new Set(Object.keys(RESOLVERS)));
|
||||
if (errors.length > 0) {
|
||||
for (const error of errors) {
|
||||
console.error(`ERROR: ${error}`);
|
||||
|
||||
+15
-3
@@ -117,7 +117,7 @@ const NAME_REGEX = /^[a-z][a-z0-9-]*$/;
|
||||
const PATH_REGEX = /^[a-zA-Z0-9_.\/${}~-]+$/;
|
||||
const CLI_REGEX = /^[a-z][a-z0-9_-]*$/;
|
||||
|
||||
export function validateHostConfig(config: HostConfig): string[] {
|
||||
export function validateHostConfig(config: HostConfig, validResolverNames?: ReadonlySet<string>): string[] {
|
||||
const errors: string[] = [];
|
||||
|
||||
if (!NAME_REGEX.test(config.name)) {
|
||||
@@ -152,15 +152,27 @@ export function validateHostConfig(config: HostConfig): string[] {
|
||||
errors.push(`install.linkingStrategy must be 'real-dir-symlink' or 'symlink-generated'`);
|
||||
}
|
||||
|
||||
// Cross-check suppressedResolvers against the known resolver names (injected to avoid a
|
||||
// circular import on the resolver registry). A typo would otherwise silently no-op: the
|
||||
// generator short-circuits suppressed names before the "unknown placeholder" throw, so an
|
||||
// unknown entry never surfaces at generation time either.
|
||||
if (validResolverNames && config.suppressedResolvers) {
|
||||
for (const name of config.suppressedResolvers) {
|
||||
if (!validResolverNames.has(name)) {
|
||||
errors.push(`suppressedResolvers entry '${name}' is not a known resolver`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
export function validateAllConfigs(configs: HostConfig[]): string[] {
|
||||
export function validateAllConfigs(configs: HostConfig[], validResolverNames?: ReadonlySet<string>): string[] {
|
||||
const errors: string[] = [];
|
||||
|
||||
// Per-config validation
|
||||
for (const config of configs) {
|
||||
const configErrors = validateHostConfig(config);
|
||||
const configErrors = validateHostConfig(config, validResolverNames);
|
||||
errors.push(...configErrors.map(e => `[${config.name}] ${e}`));
|
||||
}
|
||||
|
||||
|
||||
@@ -62,9 +62,12 @@ const DESTRUCTIVE_PATTERNS: RegExp[] = [
|
||||
/\bterraform\s+destroy\b/i,
|
||||
/\brollback\b/i,
|
||||
|
||||
// Credentials / auth — allow filler words ("the", "my") between verb and noun
|
||||
/\brevoke\s+[\w\s]*\b(api key|token|credential|access key|password)\b/i,
|
||||
/\breset\s+[\w\s]*\b(api key|token|password|credential)\b/i,
|
||||
// Credentials / auth — allow filler words ("the", "my") between verb and noun.
|
||||
// Keep the noun alternation identical across revoke/reset/rotate so the three
|
||||
// verbs stay parallel; a noun in one but not the others is a false-negative
|
||||
// safety hole (e.g. "reset my secret" must be one-way just like "rotate my secret").
|
||||
/\brevoke\s+[\w\s]*\b(api key|token|secret|credential|access key|password)\b/i,
|
||||
/\breset\s+[\w\s]*\b(api key|token|secret|credential|access key|password)\b/i,
|
||||
/\brotate\s+[\w\s]*\b(api key|token|secret|credential|access key|password)\b/i,
|
||||
|
||||
// Scope / architecture forks (reversible with effort — still deserve confirmation)
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
import { spawnSync } from 'child_process';
|
||||
|
||||
const ROOT = path.resolve(import.meta.dir, '..');
|
||||
|
||||
let tmpHome: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-eval-list-'));
|
||||
const evalDir = path.join(tmpHome, '.gstack-dev', 'evals');
|
||||
fs.mkdirSync(evalDir, { recursive: true });
|
||||
writeEvalRun(evalDir, '2026-a.json', '2026-05-24T01:00:00Z', 2);
|
||||
writeEvalRun(evalDir, '2026-b.json', '2026-05-24T02:00:00Z', 3);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tmpHome, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function writeEvalRun(evalDir: string, filename: string, timestamp: string, turns: number) {
|
||||
fs.writeFileSync(
|
||||
path.join(evalDir, filename),
|
||||
JSON.stringify({
|
||||
schema_version: 1,
|
||||
version: '1.44.0.0',
|
||||
branch: 'main',
|
||||
git_sha: filename,
|
||||
timestamp,
|
||||
tier: 'e2e',
|
||||
total_tests: 1,
|
||||
passed: 1,
|
||||
failed: 0,
|
||||
total_cost_usd: 0,
|
||||
total_duration_ms: 1000,
|
||||
tests: [
|
||||
{
|
||||
name: filename,
|
||||
suite: 'sample',
|
||||
tier: 'e2e',
|
||||
passed: true,
|
||||
duration_ms: 1000,
|
||||
cost_usd: 0,
|
||||
turns_used: turns,
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function runEvalList(...args: string[]): { stdout: string; stderr: string; status: number } {
|
||||
const result = spawnSync('bun', ['run', 'scripts/eval-list.ts', ...args], {
|
||||
cwd: ROOT,
|
||||
env: {
|
||||
...process.env,
|
||||
HOME: tmpHome,
|
||||
GSTACK_HOME: path.join(tmpHome, '.gstack'),
|
||||
},
|
||||
encoding: 'utf-8',
|
||||
});
|
||||
return {
|
||||
stdout: result.stdout ?? '',
|
||||
stderr: result.stderr ?? '',
|
||||
status: result.status ?? -1,
|
||||
};
|
||||
}
|
||||
|
||||
describe('eval:list CLI', () => {
|
||||
test('limits displayed eval runs with a valid positive integer', () => {
|
||||
const result = runEvalList('--limit', '1');
|
||||
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.stdout).toContain('Eval History (2 total runs)');
|
||||
expect(result.stdout).toContain('Showing: 1');
|
||||
expect(result.stdout).toContain('2026-05-24 02:00');
|
||||
expect(result.stdout).not.toContain('2026-05-24 01:00');
|
||||
});
|
||||
|
||||
test('rejects malformed limit values instead of silently slicing output', () => {
|
||||
for (const value of ['1abc', 'nope', '0', '-1', '1.5']) {
|
||||
const result = runEvalList('--limit', value);
|
||||
expect(result.status).not.toBe(0);
|
||||
expect(result.stderr).toContain('--limit requires a positive integer');
|
||||
expect(result.stdout).toBe('');
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"_schema_version": 1,
|
||||
"_app_build_id": "uninitialized",
|
||||
"_accessor_hash": "uninitialized",
|
||||
"keys": {}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 96 KiB |
+9
-20
@@ -14,12 +14,16 @@ import Foundation
|
||||
public final class DebugBridgeManager {
|
||||
public static let shared = DebugBridgeManager()
|
||||
|
||||
public func start(appState: AppState) {
|
||||
// 1. Register the canonical AppState struct + accessor wiring.
|
||||
// AppStateAccessor.register(_:) is generated by gen-accessors-tool.
|
||||
AppStateAccessor.register(appState)
|
||||
/// Register app-owned generated accessors, then start the server. The
|
||||
/// registration closure is passed in from the consuming app because the
|
||||
/// DebugBridgeCore package cannot import app-target types. On UIKit apps,
|
||||
/// call DebugBridgeUIWiring.installAll() before this method so a warm
|
||||
/// daemon cannot reach uninitialized resolvers during listener startup.
|
||||
public func start<State>(appState: State, register: (State) -> Void) {
|
||||
register(appState)
|
||||
|
||||
// 2. Boot the StateServer.
|
||||
// Boot only after registration so the first snapshot has a real build
|
||||
// id, schema hash, and key set.
|
||||
StateServer.shared.start()
|
||||
|
||||
// 3. The consuming app installs DebugOverlayWindow separately. See
|
||||
@@ -31,19 +35,4 @@ public final class DebugBridgeManager {
|
||||
}
|
||||
}
|
||||
|
||||
// Placeholder. gen-accessors-tool emits the real `AppStateAccessor` enum next
|
||||
// to the app's canonical state struct. Apps that haven't run codegen get a
|
||||
// stub that registers no accessors (snapshot is empty, restore returns
|
||||
// missing-key for every key).
|
||||
@MainActor
|
||||
public enum AppStateAccessor {
|
||||
public static var register: (Any) -> Void = { _ in }
|
||||
}
|
||||
|
||||
// Apps declare their canonical state struct; codegen reads it and emits
|
||||
// AppStateAccessor.register. The app's struct must be `@Observable` and
|
||||
// must hold all snapshot-eligible state in `@Snapshotable`-marked fields.
|
||||
@MainActor
|
||||
public protocol AppState: AnyObject {}
|
||||
|
||||
#endif // DEBUG
|
||||
|
||||
+49
-22
@@ -59,18 +59,20 @@ public final class StateServer {
|
||||
private var writeHandlers: [String: WriteHandler] = [:]
|
||||
private var typeNames: [String: TypeName] = [:]
|
||||
|
||||
// Atomic-restore hook. Codegen wires this to the canonical AppState struct.
|
||||
// Restore replaces the entire struct in one assignment so SwiftUI's Combine
|
||||
// pipeline observes exactly one change notification — true observable
|
||||
// atomicity. @MainActor alone doesn't guarantee that.
|
||||
public typealias AtomicRestoreFn = (JSONDict) -> RestoreResult
|
||||
// Validated-restore hooks. Every generated model registers one two-phase
|
||||
// handler. The server validates all models before it lets any model mutate,
|
||||
// so invalid input can never cause a cross-model partial restore.
|
||||
// Valid restores apply properties on MainActor; observers may receive one
|
||||
// change notification per property because arbitrary @Observable models do
|
||||
// not expose a general single-assignment transaction API.
|
||||
public typealias AtomicRestoreFn = (JSONDict, Bool) -> RestoreResult
|
||||
public enum RestoreResult {
|
||||
case ok
|
||||
case missingKey(String)
|
||||
case typeMismatch(String)
|
||||
case schemaMismatch(expected: String, got: String)
|
||||
}
|
||||
private var atomicRestore: AtomicRestoreFn?
|
||||
private var atomicRestores: [AtomicRestoreFn] = []
|
||||
|
||||
// Snapshot schema hash — written by codegen, stable across builds with
|
||||
// identical accessor signatures.
|
||||
@@ -109,7 +111,7 @@ public final class StateServer {
|
||||
public func register(buildId: String, accessorHash: String, atomicRestore: @escaping AtomicRestoreFn) {
|
||||
self.appBuildId = buildId
|
||||
self.accessorHash = accessorHash
|
||||
self.atomicRestore = atomicRestore
|
||||
self.atomicRestores.append(atomicRestore)
|
||||
}
|
||||
|
||||
public func registerAccessor(key: String, type: String, read: @escaping ReadHandler, write: @escaping WriteHandler) {
|
||||
@@ -284,6 +286,7 @@ public final class StateServer {
|
||||
"version": "1.0.0",
|
||||
"build": appBuildId,
|
||||
"accessor_hash": accessorHash,
|
||||
"bundle_id": Bundle.main.bundleIdentifier ?? "unknown",
|
||||
])
|
||||
return
|
||||
}
|
||||
@@ -476,22 +479,36 @@ public final class StateServer {
|
||||
send(connection: connection, status: 400, body: ["error": "missing_keys"])
|
||||
return
|
||||
}
|
||||
guard let restore = atomicRestore else {
|
||||
guard !atomicRestores.isEmpty else {
|
||||
send(connection: connection, status: 503, body: ["error": "atomic_restore_not_registered"])
|
||||
return
|
||||
}
|
||||
// Validate-then-apply via the codegen-supplied closure. The closure does
|
||||
// a single struct-assignment so SwiftUI sees one change notification.
|
||||
switch restore(keys) {
|
||||
case .ok:
|
||||
send(connection: connection, status: 200, body: ["ok": true])
|
||||
case .missingKey(let k):
|
||||
send(connection: connection, status: 400, body: ["error": "validation_failed", "key": k, "reason": "missing"])
|
||||
case .typeMismatch(let k):
|
||||
send(connection: connection, status: 400, body: ["error": "validation_failed", "key": k, "reason": "type-mismatch"])
|
||||
case .schemaMismatch(let expected, let got):
|
||||
send(connection: connection, status: 409, body: ["error": "schema_mismatch", "expected_hash": expected, "got_hash": got])
|
||||
// Phase one validates every registered model without assignment.
|
||||
for restore in atomicRestores {
|
||||
switch restore(keys, false) {
|
||||
case .ok:
|
||||
continue
|
||||
case .missingKey(let k):
|
||||
send(connection: connection, status: 400, body: ["error": "validation_failed", "key": k, "reason": "missing"])
|
||||
return
|
||||
case .typeMismatch(let k):
|
||||
send(connection: connection, status: 400, body: ["error": "validation_failed", "key": k, "reason": "type-mismatch"])
|
||||
return
|
||||
case .schemaMismatch(let expected, let got):
|
||||
send(connection: connection, status: 409, body: ["error": "schema_mismatch", "expected_hash": expected, "got_hash": got])
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Phase two applies only after every model accepted the immutable input.
|
||||
// A valid multi-field restore may notify once per property.
|
||||
for restore in atomicRestores {
|
||||
guard case .ok = restore(keys, true) else {
|
||||
send(connection: connection, status: 500, body: ["error": "restore_apply_failed"])
|
||||
return
|
||||
}
|
||||
}
|
||||
send(connection: connection, status: 200, body: ["ok": true])
|
||||
}
|
||||
|
||||
// MARK: Stubs (real impls live in DebugBridgeManager + UIKit)
|
||||
@@ -522,9 +539,19 @@ public final class StateServer {
|
||||
// MARK: Response
|
||||
|
||||
private func send(connection: NWConnection, status: Int, body: JSONDict) {
|
||||
let json = (try? JSONSerialization.data(withJSONObject: body)) ?? Data("{}".utf8)
|
||||
let responseStatus: Int
|
||||
let json: Data
|
||||
if JSONSerialization.isValidJSONObject(body),
|
||||
let encoded = try? JSONSerialization.data(withJSONObject: body) {
|
||||
responseStatus = status
|
||||
json = encoded
|
||||
} else {
|
||||
logger.error("Refusing to send a non-JSON response body")
|
||||
responseStatus = 500
|
||||
json = Data("{\"error\":\"response_not_json_serializable\"}".utf8)
|
||||
}
|
||||
let statusText: String
|
||||
switch status {
|
||||
switch responseStatus {
|
||||
case 200: statusText = "OK"
|
||||
case 400: statusText = "Bad Request"
|
||||
case 401: statusText = "Unauthorized"
|
||||
@@ -537,7 +564,7 @@ public final class StateServer {
|
||||
case 503: statusText = "Service Unavailable"
|
||||
default: statusText = "Status"
|
||||
}
|
||||
let header = "HTTP/1.1 \(status) \(statusText)\r\nContent-Type: application/json\r\nContent-Length: \(json.count)\r\nConnection: close\r\n\r\n"
|
||||
let header = "HTTP/1.1 \(responseStatus) \(statusText)\r\nContent-Type: application/json\r\nContent-Length: \(json.count)\r\nConnection: close\r\n\r\n"
|
||||
var packet = Data(header.utf8)
|
||||
packet.append(json)
|
||||
connection.send(content: packet, completion: .contentProcessed { _ in
|
||||
|
||||
+44
-2
@@ -126,6 +126,36 @@ static BOOL DBT_LoadIOKit(void) {
|
||||
return _IOKitLoaded;
|
||||
}
|
||||
|
||||
// KIF enables accessibility automation before it asks UIKit/SwiftUI for the
|
||||
// accessibility tree. Without this switch SwiftUI exposes only its hosting
|
||||
// shell: /elements has no controls and a synthesized tap can return YES while
|
||||
// Button.action never fires. Keep this DEBUG-only with the rest of this file.
|
||||
typedef int (*DBTAXSAutomationEnabledFn)(void);
|
||||
typedef void (*DBTAXSSetAutomationEnabledFn)(int);
|
||||
static DBTAXSAutomationEnabledFn _DBTAXSAutomationEnabled;
|
||||
static DBTAXSSetAutomationEnabledFn _DBTAXSSetAutomationEnabled;
|
||||
static int _DBTInitialAutomationState = -1;
|
||||
|
||||
static void DBT_RestoreAccessibilityAutomation(void) {
|
||||
if (_DBTAXSSetAutomationEnabled && _DBTInitialAutomationState >= 0) {
|
||||
_DBTAXSSetAutomationEnabled(_DBTInitialAutomationState);
|
||||
}
|
||||
}
|
||||
|
||||
static void DBT_EnableAccessibilityAutomation(void) {
|
||||
static dispatch_once_t onceToken;
|
||||
dispatch_once(&onceToken, ^{
|
||||
void *handle = dlopen("/usr/lib/libAccessibility.dylib", RTLD_NOW | RTLD_LOCAL);
|
||||
if (!handle) return;
|
||||
_DBTAXSAutomationEnabled = (DBTAXSAutomationEnabledFn)dlsym(handle, "_AXSAutomationEnabled");
|
||||
_DBTAXSSetAutomationEnabled = (DBTAXSSetAutomationEnabledFn)dlsym(handle, "_AXSSetAutomationEnabled");
|
||||
if (!_DBTAXSAutomationEnabled || !_DBTAXSSetAutomationEnabled) return;
|
||||
_DBTInitialAutomationState = _DBTAXSAutomationEnabled();
|
||||
if (!_DBTInitialAutomationState) _DBTAXSSetAutomationEnabled(1);
|
||||
atexit(DBT_RestoreAccessibilityAutomation);
|
||||
});
|
||||
}
|
||||
|
||||
static IOHIDEventRef DBT_IOHIDEventWithTouch(UITouch *touch) CF_RETURNS_RETAINED;
|
||||
static IOHIDEventRef DBT_IOHIDEventWithTouch(UITouch *touch) {
|
||||
if (!DBT_LoadIOKit()) return NULL;
|
||||
@@ -172,6 +202,7 @@ static IOHIDEventRef DBT_IOHIDEventWithTouch(UITouch *touch) {
|
||||
- (void)setGestureView:(UIView *)view;
|
||||
- (void)_setLocationInWindow:(CGPoint)location resetPrevious:(BOOL)resetPrevious;
|
||||
- (void)_setIsFirstTouchForView:(BOOL)firstTouchForView;
|
||||
- (void)_setIsTapToClick:(BOOL)tapToClick;
|
||||
- (void)_setHidEvent:(IOHIDEventRef)event;
|
||||
@end
|
||||
|
||||
@@ -229,6 +260,10 @@ static id DBT_HitTestView(UIWindow *window, CGPoint point) {
|
||||
|
||||
@implementation DebugBridgeTouch
|
||||
|
||||
+ (void)prepareForAutomation {
|
||||
DBT_EnableAccessibilityAutomation();
|
||||
}
|
||||
|
||||
+ (BOOL)sendTapAtPoint:(CGPoint)point inWindow:(UIWindow *)window {
|
||||
if (!window) return NO;
|
||||
|
||||
@@ -247,10 +282,17 @@ static id DBT_HitTestView(UIWindow *window, CGPoint point) {
|
||||
[touch setPhase:UITouchPhaseBegan];
|
||||
if ([touch respondsToSelector:@selector(_setIsFirstTouchForView:)]) {
|
||||
[touch _setIsFirstTouchForView:YES];
|
||||
} else if ([touch respondsToSelector:@selector(_setIsTapToClick:)]) {
|
||||
[touch _setIsTapToClick:YES];
|
||||
Ivar flagsIvar = class_getInstanceVariable([UITouch class], "_touchFlags");
|
||||
if (flagsIvar) {
|
||||
ptrdiff_t offset = ivar_getOffset(flagsIvar);
|
||||
char *flags = (__bridge void *)touch + offset;
|
||||
*flags |= (char)0x01;
|
||||
}
|
||||
}
|
||||
[touch setTimestamp:[[NSProcessInfo processInfo] systemUptime]];
|
||||
if ([touch respondsToSelector:@selector(setGestureView:)] &&
|
||||
[hit isKindOfClass:[UIView class]]) {
|
||||
if ([touch respondsToSelector:@selector(setGestureView:)]) {
|
||||
[touch setGestureView:(UIView *)hit];
|
||||
}
|
||||
|
||||
|
||||
+4
@@ -23,6 +23,10 @@ NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
@interface DebugBridgeTouch : NSObject
|
||||
|
||||
/// Enable the in-process accessibility automation tree used by SwiftUI and
|
||||
/// UIKit. Call once while installing the debug bridge, before /elements.
|
||||
+ (void)prepareForAutomation;
|
||||
|
||||
/// Synthesize a single tap (TouchPhaseBegan + TouchPhaseEnded) at the given
|
||||
/// window-coordinate point. Returns YES if the touch was delivered (a hit
|
||||
/// view was found and the event passed through UIApplication.sendEvent).
|
||||
|
||||
+205
-74
@@ -27,12 +27,35 @@ public enum DebugBridgeUIWiring {
|
||||
/// times reinstalls the same closures. Must be called on @MainActor
|
||||
/// because every UIKit access requires the main actor.
|
||||
public static func installAll() {
|
||||
// KIF turns on accessibility automation before walking SwiftUI's AX
|
||||
// tree. Without it SwiftUI exposes only the hosting shell and taps
|
||||
// can report success without invoking Button.action.
|
||||
DebugBridgeTouch.prepareForAutomation()
|
||||
ScreenshotBridge.resolver = { ScreenshotBridgeImpl.capturePNG() }
|
||||
ElementsBridge.resolver = { ElementsBridgeImpl.snapshot() }
|
||||
MutationBridge.resolver = { op, payload in MutationBridgeImpl.dispatch(op: op, payload: payload) }
|
||||
}
|
||||
}
|
||||
|
||||
/// Return the children UIKit exposes specifically to accessibility automation.
|
||||
/// iOS 17 added `automationElements`; unlike the older container APIs it
|
||||
/// preserves identified SwiftUI descendants inside accessibility groups.
|
||||
@MainActor
|
||||
private func debugBridgeAccessibilityChildren(of element: NSObject) -> [NSObject] {
|
||||
if #available(iOS 17.0, *),
|
||||
let automation = element.automationElements,
|
||||
!automation.isEmpty {
|
||||
return automation.compactMap { $0 as? NSObject }
|
||||
}
|
||||
if let accessibility = element.accessibilityElements,
|
||||
!accessibility.isEmpty {
|
||||
return accessibility.compactMap { $0 as? NSObject }
|
||||
}
|
||||
let count = element.accessibilityElementCount()
|
||||
guard count > 0, count < 512 else { return [] }
|
||||
return (0..<count).compactMap { element.accessibilityElement(at: $0) as? NSObject }
|
||||
}
|
||||
|
||||
// MARK: - ScreenshotBridge implementation
|
||||
|
||||
@MainActor
|
||||
@@ -43,7 +66,11 @@ enum ScreenshotBridgeImpl {
|
||||
static func capturePNG() -> Data? {
|
||||
guard let scene = activeScene(), let window = activeKeyWindow(in: scene) else { return nil }
|
||||
let bounds = window.bounds
|
||||
let renderer = UIGraphicsImageRenderer(bounds: bounds)
|
||||
let format = UIGraphicsImageRendererFormat.default()
|
||||
// /tap consumes UIKit window points. Render at 1x so screenshot pixels
|
||||
// use that same coordinate space on 2x/3x devices.
|
||||
format.scale = 1
|
||||
let renderer = UIGraphicsImageRenderer(bounds: bounds, format: format)
|
||||
let image = renderer.image { _ in
|
||||
// drawHierarchy is the documented way to snapshot real UIKit
|
||||
// layers including layer-backed views. afterScreenUpdates: false
|
||||
@@ -61,7 +88,10 @@ enum ScreenshotBridgeImpl {
|
||||
}
|
||||
|
||||
private static func activeKeyWindow(in scene: UIWindowScene) -> UIWindow? {
|
||||
scene.windows.first(where: { $0.isKeyWindow }) ?? scene.windows.first
|
||||
let windows = scene.windows.filter { window in
|
||||
!window.isHidden && !String(describing: type(of: window)).contains("PassThroughWindow")
|
||||
}
|
||||
return windows.first(where: { $0.isKeyWindow }) ?? windows.max(by: { $0.windowLevel < $1.windowLevel })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,21 +106,40 @@ enum ElementsBridgeImpl {
|
||||
static func snapshot() -> [JSONDict] {
|
||||
guard let scene = activeScene(), let window = activeKeyWindow(in: scene) else { return [] }
|
||||
var elements: [JSONDict] = []
|
||||
collect(view: window, parentPath: "", windowBounds: window.bounds, into: &elements)
|
||||
var visited = Set<ObjectIdentifier>()
|
||||
var remaining = 2_048
|
||||
collect(
|
||||
view: window,
|
||||
parentPath: "",
|
||||
window: window,
|
||||
visited: &visited,
|
||||
remaining: &remaining,
|
||||
into: &elements
|
||||
)
|
||||
return elements
|
||||
}
|
||||
|
||||
private static func collect(view: UIView, parentPath: String, windowBounds: CGRect, into elements: inout [JSONDict]) {
|
||||
private static func collect(
|
||||
view: UIView,
|
||||
parentPath: String,
|
||||
window: UIWindow,
|
||||
visited: inout Set<ObjectIdentifier>,
|
||||
remaining: inout Int,
|
||||
into elements: inout [JSONDict]
|
||||
) {
|
||||
guard remaining > 0, visited.insert(ObjectIdentifier(view)).inserted else { return }
|
||||
remaining -= 1
|
||||
|
||||
// Skip hidden / zero-size / off-screen subtrees early.
|
||||
if view.isHidden || view.alpha < 0.01 { return }
|
||||
|
||||
let frameInWindow = view.convert(view.bounds, to: nil)
|
||||
if !windowBounds.intersects(frameInWindow) { return }
|
||||
let frameInWindow = view.convert(view.bounds, to: window)
|
||||
if !window.bounds.intersects(frameInWindow) { return }
|
||||
|
||||
let isAccessible = view.isAccessibilityElement
|
||||
let label = view.accessibilityLabel ?? ""
|
||||
let identifier = view.accessibilityIdentifier ?? ""
|
||||
let traits = Int(view.accessibilityTraits.rawValue)
|
||||
let traits = NSNumber(value: view.accessibilityTraits.rawValue)
|
||||
let value = (view.accessibilityValue ?? "") as String
|
||||
let className = String(describing: type(of: view))
|
||||
let path = parentPath.isEmpty ? className : "\(parentPath) > \(className)"
|
||||
@@ -120,66 +169,98 @@ enum ElementsBridgeImpl {
|
||||
])
|
||||
}
|
||||
|
||||
// Recurse into accessibility-elements first (some custom views vend
|
||||
// synthetic children), then UIView subviews. SwiftUI's host views
|
||||
// populate accessibilityElements lazily — many return nil before
|
||||
// VoiceOver triggers them. Force population by reading accessibilityElementCount.
|
||||
_ = view.accessibilityElementCount()
|
||||
if let axElements = view.accessibilityElements {
|
||||
for case let element as NSObject in axElements {
|
||||
if let v = element as? UIView {
|
||||
collect(view: v, parentPath: path, windowBounds: windowBounds, into: &elements)
|
||||
} else {
|
||||
// Synthetic accessibility element (no UIView). Capture frame in screen coords.
|
||||
let af = (element.value(forKey: "accessibilityFrame") as? CGRect) ?? .zero
|
||||
elements.append([
|
||||
"path": "\(path) > <synthetic>",
|
||||
"class": "AccessibilityElement",
|
||||
"label": (element.value(forKey: "accessibilityLabel") as? String) ?? "",
|
||||
"identifier": (element.value(forKey: "accessibilityIdentifier") as? String) ?? "",
|
||||
"value": (element.value(forKey: "accessibilityValue") as? String) ?? "",
|
||||
"traits": (element.value(forKey: "accessibilityTraits") as? NSNumber)?.intValue ?? 0,
|
||||
"frame": [
|
||||
"x": Int(af.origin.x),
|
||||
"y": Int(af.origin.y),
|
||||
"w": Int(af.size.width),
|
||||
"h": Int(af.size.height),
|
||||
],
|
||||
"is_user_interaction_enabled": true,
|
||||
])
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// accessibilityElements is nil — iterate by index. SwiftUI uses
|
||||
// this dynamic protocol pattern; many AX elements only respond
|
||||
// to accessibilityElementCount + accessibilityElement(at:).
|
||||
let count = view.accessibilityElementCount()
|
||||
for i in 0..<count {
|
||||
guard let element = view.accessibilityElement(at: i) as? NSObject else { continue }
|
||||
if let v = element as? UIView {
|
||||
collect(view: v, parentPath: path, windowBounds: windowBounds, into: &elements)
|
||||
} else {
|
||||
let af = (element.value(forKey: "accessibilityFrame") as? CGRect) ?? .zero
|
||||
elements.append([
|
||||
"path": "\(path) > <ax\(i)>",
|
||||
"class": String(describing: type(of: element)),
|
||||
"label": (element.value(forKey: "accessibilityLabel") as? String) ?? "",
|
||||
"identifier": (element.value(forKey: "accessibilityIdentifier") as? String) ?? "",
|
||||
"value": (element.value(forKey: "accessibilityValue") as? String) ?? "",
|
||||
"traits": (element.value(forKey: "accessibilityTraits") as? NSNumber)?.intValue ?? 0,
|
||||
"frame": [
|
||||
"x": Int(af.origin.x),
|
||||
"y": Int(af.origin.y),
|
||||
"w": Int(af.size.width),
|
||||
"h": Int(af.size.height),
|
||||
],
|
||||
"is_user_interaction_enabled": true,
|
||||
])
|
||||
}
|
||||
// Walk automation children before raw subviews. On iOS 17+ this
|
||||
// exposes identified SwiftUI controls nested inside GroupBox/List.
|
||||
for (index, element) in debugBridgeAccessibilityChildren(of: view).enumerated() {
|
||||
if let child = element as? UIView {
|
||||
collect(
|
||||
view: child,
|
||||
parentPath: path,
|
||||
window: window,
|
||||
visited: &visited,
|
||||
remaining: &remaining,
|
||||
into: &elements
|
||||
)
|
||||
} else {
|
||||
appendSynthetic(
|
||||
element,
|
||||
path: "\(path) > <ax\(index)>",
|
||||
window: window,
|
||||
visited: &visited,
|
||||
remaining: &remaining,
|
||||
into: &elements
|
||||
)
|
||||
}
|
||||
}
|
||||
for sub in view.subviews {
|
||||
collect(view: sub, parentPath: path, windowBounds: windowBounds, into: &elements)
|
||||
collect(
|
||||
view: sub,
|
||||
parentPath: path,
|
||||
window: window,
|
||||
visited: &visited,
|
||||
remaining: &remaining,
|
||||
into: &elements
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private static func appendSynthetic(
|
||||
_ element: NSObject,
|
||||
path: String,
|
||||
window: UIWindow,
|
||||
visited: inout Set<ObjectIdentifier>,
|
||||
remaining: inout Int,
|
||||
into elements: inout [JSONDict]
|
||||
) {
|
||||
guard remaining > 0, visited.insert(ObjectIdentifier(element)).inserted else { return }
|
||||
remaining -= 1
|
||||
|
||||
let screenFrame = (element.value(forKey: "accessibilityFrame") as? CGRect) ?? .zero
|
||||
let frame = window.coordinateSpace.convert(screenFrame, from: window.screen.coordinateSpace)
|
||||
let label = (element.value(forKey: "accessibilityLabel") as? String) ?? ""
|
||||
let identifier = (element.value(forKey: "accessibilityIdentifier") as? String) ?? ""
|
||||
let value = (element.value(forKey: "accessibilityValue") as? String) ?? ""
|
||||
let traits = (element.value(forKey: "accessibilityTraits") as? NSNumber)?.uint64Value ?? 0
|
||||
if !label.isEmpty || !identifier.isEmpty || !value.isEmpty || traits != 0 {
|
||||
elements.append([
|
||||
"path": path,
|
||||
"class": String(describing: type(of: element)),
|
||||
"label": label,
|
||||
"identifier": identifier,
|
||||
"value": value,
|
||||
"traits": NSNumber(value: traits),
|
||||
"frame": [
|
||||
"x": Int(frame.origin.x),
|
||||
"y": Int(frame.origin.y),
|
||||
"w": Int(frame.size.width),
|
||||
"h": Int(frame.size.height),
|
||||
],
|
||||
"is_user_interaction_enabled": true,
|
||||
])
|
||||
}
|
||||
|
||||
// Synthetic SwiftUI nodes are themselves accessibility containers.
|
||||
// Recurse even when this grouping node has no metadata of its own.
|
||||
for (index, child) in debugBridgeAccessibilityChildren(of: element).enumerated() {
|
||||
if let childView = child as? UIView {
|
||||
collect(
|
||||
view: childView,
|
||||
parentPath: path,
|
||||
window: window,
|
||||
visited: &visited,
|
||||
remaining: &remaining,
|
||||
into: &elements
|
||||
)
|
||||
} else {
|
||||
appendSynthetic(
|
||||
child,
|
||||
path: "\(path) > <ax\(index)>",
|
||||
window: window,
|
||||
visited: &visited,
|
||||
remaining: &remaining,
|
||||
into: &elements
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -191,7 +272,10 @@ enum ElementsBridgeImpl {
|
||||
}
|
||||
|
||||
private static func activeKeyWindow(in scene: UIWindowScene) -> UIWindow? {
|
||||
scene.windows.first(where: { $0.isKeyWindow }) ?? scene.windows.first
|
||||
let windows = scene.windows.filter { window in
|
||||
!window.isHidden && !String(describing: type(of: window)).contains("PassThroughWindow")
|
||||
}
|
||||
return windows.first(where: { $0.isKeyWindow }) ?? windows.max(by: { $0.windowLevel < $1.windowLevel })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -210,21 +294,62 @@ enum MutationBridgeImpl {
|
||||
}
|
||||
}
|
||||
|
||||
/// Tap at (x, y) in window coordinates. Delegates to DebugBridgeTouch
|
||||
/// (KIF-derived in-process touch synthesis). The Obj-C target builds a
|
||||
/// real UITouch + IOHIDEvent + UIEvent and dispatches via
|
||||
/// `UIApplication.sendEvent`, which is what UIKit uses for real touches.
|
||||
/// This works for UIControl, SwiftUI Button (via iOS 18+
|
||||
/// `_UIHitTestContext`), gesture recognizers, and anything else that
|
||||
/// listens to the real event-dispatch path.
|
||||
/// Tap at (x, y) in window coordinates. Prefer accessibility activation,
|
||||
/// which is stable for SwiftUI buttons across OS releases, then fall back
|
||||
/// to KIF-derived UITouch synthesis for gesture-only/custom controls.
|
||||
private static func handleTap(_ payload: JSONDict) -> Bool {
|
||||
guard let x = payload["x"] as? NSNumber,
|
||||
let y = payload["y"] as? NSNumber else { return false }
|
||||
let point = CGPoint(x: x.doubleValue, y: y.doubleValue)
|
||||
guard let scene = activeScene(), let window = activeKeyWindow(in: scene) else { return false }
|
||||
if let element = findActivatableAXElement(at: point, in: window),
|
||||
element.accessibilityActivate() {
|
||||
return true
|
||||
}
|
||||
return DebugBridgeTouch.sendTap(at: point, in: window)
|
||||
}
|
||||
|
||||
private static func findActivatableAXElement(at point: CGPoint, in window: UIWindow) -> NSObject? {
|
||||
let screenPoint = window.screen.coordinateSpace.convert(point, from: window.coordinateSpace)
|
||||
var best: NSObject?
|
||||
var bestArea: CGFloat = .infinity
|
||||
var visited = Set<ObjectIdentifier>()
|
||||
var remaining = 2_048
|
||||
|
||||
func consider(frame: CGRect, traits: UInt64, element: NSObject) {
|
||||
guard frame.contains(screenPoint),
|
||||
(traits & UIAccessibilityTraits.button.rawValue) != 0 else { return }
|
||||
let area = frame.width * frame.height
|
||||
if area > 0 && area < bestArea {
|
||||
best = element
|
||||
bestArea = area
|
||||
}
|
||||
}
|
||||
|
||||
func visit(_ element: NSObject) {
|
||||
guard remaining > 0, visited.insert(ObjectIdentifier(element)).inserted else { return }
|
||||
remaining -= 1
|
||||
|
||||
if let view = element as? UIView {
|
||||
guard !view.isHidden, view.alpha >= 0.01,
|
||||
view.convert(view.bounds, to: window).contains(point) else { return }
|
||||
if view.isAccessibilityElement {
|
||||
consider(frame: view.accessibilityFrame, traits: view.accessibilityTraits.rawValue, element: view)
|
||||
}
|
||||
for child in debugBridgeAccessibilityChildren(of: view) { visit(child) }
|
||||
for child in view.subviews { visit(child) }
|
||||
} else {
|
||||
let frame = (element.value(forKey: "accessibilityFrame") as? CGRect) ?? .zero
|
||||
let traits = (element.value(forKey: "accessibilityTraits") as? NSNumber)?.uint64Value ?? 0
|
||||
consider(frame: frame, traits: traits, element: element)
|
||||
for child in debugBridgeAccessibilityChildren(of: element) { visit(child) }
|
||||
}
|
||||
}
|
||||
|
||||
visit(window)
|
||||
return best
|
||||
}
|
||||
|
||||
/// Set text on the first responder if it's a UITextField or UITextView.
|
||||
private static func handleType(_ payload: JSONDict) -> Bool {
|
||||
guard let text = payload["text"] as? String else { return false }
|
||||
@@ -266,7 +391,10 @@ enum MutationBridgeImpl {
|
||||
var off = scroll.contentOffset
|
||||
off.x = max(0, min(scroll.contentSize.width - scroll.bounds.width, off.x + dx))
|
||||
off.y = max(0, min(scroll.contentSize.height - scroll.bounds.height, off.y + dy))
|
||||
scroll.setContentOffset(off, animated: true)
|
||||
// Automation commands return synchronously; do not report
|
||||
// success while the target is still moving underneath the
|
||||
// next tap coordinate.
|
||||
scroll.setContentOffset(off, animated: false)
|
||||
return true
|
||||
}
|
||||
node = cur.superview
|
||||
@@ -301,7 +429,10 @@ enum MutationBridgeImpl {
|
||||
}
|
||||
|
||||
private static func activeKeyWindow(in scene: UIWindowScene) -> UIWindow? {
|
||||
scene.windows.first(where: { $0.isKeyWindow }) ?? scene.windows.first
|
||||
let windows = scene.windows.filter { window in
|
||||
!window.isHidden && !String(describing: type(of: window)).contains("PassThroughWindow")
|
||||
}
|
||||
return windows.first(where: { $0.isKeyWindow }) ?? windows.max(by: { $0.windowLevel < $1.windowLevel })
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,15 +1,21 @@
|
||||
// FixtureApp — minimal SwiftUI app used by the ios-qa device-path E2E test.
|
||||
// FixtureApp — interaction-rich SwiftUI app used by the ios-qa device-path
|
||||
// E2E test. Every control exposes a stable accessibility identifier and writes
|
||||
// to visible verification state so device-driven taps have an explicit oracle.
|
||||
//
|
||||
// On launch:
|
||||
// 1. Boot StateServer (loopback :::1/127.0.0.1 + 9999)
|
||||
// 2. Log boot token to os_log so devicectl + the Mac daemon can scrape it
|
||||
// 3. Render a single ContentView so the app stays foreground
|
||||
// 1. Install UI resolvers before any request can arrive
|
||||
// 2. Register typed state and boot StateServer (::1/127.0.0.1 + 9999)
|
||||
// 3. Log the one-use boot token, then render the interaction harness
|
||||
//
|
||||
// Everything ios-qa-related is gated #if DEBUG. Release builds compile this
|
||||
// to a no-op app (no StateServer, no DebugBridge import, no overlay).
|
||||
|
||||
import SwiftUI
|
||||
|
||||
#if canImport(UIKit)
|
||||
import UIKit
|
||||
#endif
|
||||
|
||||
#if DEBUG
|
||||
import DebugBridgeCore
|
||||
#endif
|
||||
@@ -20,14 +26,21 @@ import DebugBridgeUI
|
||||
|
||||
@main
|
||||
struct FixtureAppApp: App {
|
||||
#if DEBUG
|
||||
private let appState = FixtureAppState()
|
||||
#endif
|
||||
|
||||
init() {
|
||||
#if DEBUG
|
||||
StateServer.shared.start()
|
||||
// Wire the three UIKit-backed bridges so /screenshot, /elements,
|
||||
// /tap, /type, /swipe actually do something on the device.
|
||||
// /tap, /type, /swipe are ready before the listener accepts requests.
|
||||
#if canImport(UIKit)
|
||||
DebugBridgeUIWiring.installAll()
|
||||
#endif
|
||||
DebugBridgeManager.shared.start(
|
||||
appState: appState,
|
||||
register: FixtureAppStateAccessor.register
|
||||
)
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -39,22 +52,707 @@ struct FixtureAppApp: App {
|
||||
}
|
||||
|
||||
struct ContentView: View {
|
||||
@State private var counter: Int = 0
|
||||
private enum HarnessTab: String, Hashable {
|
||||
case controls
|
||||
case inputs
|
||||
case rows
|
||||
|
||||
var title: String { rawValue.capitalized }
|
||||
}
|
||||
|
||||
private struct HarnessRow: Identifiable {
|
||||
let id: String
|
||||
let title: String
|
||||
let symbol: String
|
||||
}
|
||||
|
||||
private static let harnessRows = [
|
||||
HarnessRow(id: "alpha", title: "Alpha row", symbol: "a.circle.fill"),
|
||||
HarnessRow(id: "bravo", title: "Bravo row", symbol: "b.circle.fill"),
|
||||
HarnessRow(id: "charlie", title: "Charlie row", symbol: "c.circle.fill"),
|
||||
HarnessRow(id: "delta", title: "Delta row", symbol: "d.circle.fill"),
|
||||
]
|
||||
|
||||
@State private var selectedTab: HarnessTab = .controls
|
||||
@State private var lastAction = "Harness ready"
|
||||
@State private var totalActions = 0
|
||||
@State private var tabChangeCount = 0
|
||||
|
||||
@State private var primaryButtonCount = 0
|
||||
@State private var borderedButtonCount = 0
|
||||
@State private var plainButtonCount = 0
|
||||
@State private var destructiveButtonCount = 0
|
||||
@State private var toolbarRefreshCount = 0
|
||||
@State private var menuAddCount = 0
|
||||
@State private var menuArchiveCount = 0
|
||||
@State private var detailOpenCount = 0
|
||||
@State private var detailCloseCount = 0
|
||||
@State private var detailButtonCount = 0
|
||||
@State private var isShowingDetail = false
|
||||
|
||||
@State private var toggleValue = false
|
||||
@State private var toggleChangeCount = 0
|
||||
@State private var stepperValue = 0
|
||||
@State private var stepperChangeCount = 0
|
||||
@State private var selectedMode = "One"
|
||||
@State private var pickerChangeCount = 0
|
||||
@State private var draftText = ""
|
||||
@FocusState private var isTextFieldFocused: Bool
|
||||
@State private var textChangeCount = 0
|
||||
@State private var textCommitCount = 0
|
||||
@State private var uikitButtonCount = 0
|
||||
|
||||
@State private var rowTapCounts: [String: Int] = [:]
|
||||
@State private var rowFlagCount = 0
|
||||
@State private var rowArchiveCount = 0
|
||||
@State private var rowToolbarCount = 0
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 24) {
|
||||
Text("ios-qa fixture")
|
||||
.font(.largeTitle.bold())
|
||||
Text("StateServer should be on :9999")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
Button("Tap (\(counter))") {
|
||||
counter += 1
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.accessibilityIdentifier("tap-button")
|
||||
TabView(selection: $selectedTab) {
|
||||
controlsTab
|
||||
.tabItem {
|
||||
Label("Controls", systemImage: "hand.tap.fill")
|
||||
.accessibilityIdentifier("tab-controls")
|
||||
}
|
||||
.tag(HarnessTab.controls)
|
||||
|
||||
inputsTab
|
||||
.tabItem {
|
||||
Label("Inputs", systemImage: "slider.horizontal.3")
|
||||
.accessibilityIdentifier("tab-inputs")
|
||||
}
|
||||
.tag(HarnessTab.inputs)
|
||||
|
||||
rowsTab
|
||||
.tabItem {
|
||||
Label("Rows", systemImage: "list.bullet.rectangle")
|
||||
.accessibilityIdentifier("tab-rows")
|
||||
}
|
||||
.tag(HarnessTab.rows)
|
||||
}
|
||||
.padding()
|
||||
.accessibilityIdentifier("fixture-content")
|
||||
.accessibilityIdentifier("fixture-tab-view")
|
||||
.onChange(of: selectedTab) { newTab in
|
||||
tabChangeCount += 1
|
||||
record("Selected \(newTab.title) tab")
|
||||
}
|
||||
}
|
||||
|
||||
private var controlsTab: some View {
|
||||
NavigationStack {
|
||||
ScrollView {
|
||||
VStack(spacing: 14) {
|
||||
verificationPanel
|
||||
|
||||
GroupBox {
|
||||
LazyVGrid(
|
||||
columns: [GridItem(.flexible()), GridItem(.flexible())],
|
||||
spacing: 10
|
||||
) {
|
||||
Button {
|
||||
primaryButtonCount += 1
|
||||
record("Primary button tapped")
|
||||
} label: {
|
||||
buttonLabel("Primary", count: primaryButtonCount, symbol: "bolt.fill")
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.accessibilityIdentifier("primary-button")
|
||||
.accessibilityValue("\(primaryButtonCount) taps")
|
||||
|
||||
Button {
|
||||
borderedButtonCount += 1
|
||||
record("Bordered button tapped")
|
||||
} label: {
|
||||
buttonLabel("Bordered", count: borderedButtonCount, symbol: "square")
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.accessibilityIdentifier("bordered-button")
|
||||
.accessibilityValue("\(borderedButtonCount) taps")
|
||||
|
||||
Button {
|
||||
plainButtonCount += 1
|
||||
record("Plain button tapped")
|
||||
} label: {
|
||||
buttonLabel("Plain", count: plainButtonCount, symbol: "circle")
|
||||
.padding(.vertical, 8)
|
||||
.background(Color.accentColor.opacity(0.12))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityIdentifier("plain-button")
|
||||
.accessibilityValue("\(plainButtonCount) taps")
|
||||
|
||||
Button(role: .destructive) {
|
||||
destructiveButtonCount += 1
|
||||
record("Destructive-style button tapped")
|
||||
} label: {
|
||||
buttonLabel(
|
||||
"Destructive",
|
||||
count: destructiveButtonCount,
|
||||
symbol: "exclamationmark.triangle.fill"
|
||||
)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.accessibilityIdentifier("destructive-button")
|
||||
.accessibilityValue("\(destructiveButtonCount) taps")
|
||||
}
|
||||
} label: {
|
||||
Label("SwiftUI button styles", systemImage: "rectangle.3.group")
|
||||
.font(.headline)
|
||||
}
|
||||
.accessibilityIdentifier("button-styles-group")
|
||||
|
||||
GroupBox {
|
||||
VStack(spacing: 10) {
|
||||
verificationRow(
|
||||
"Refresh",
|
||||
value: toolbarRefreshCount,
|
||||
identifier: "nav-refresh-count"
|
||||
)
|
||||
verificationRow(
|
||||
"Menu add",
|
||||
value: menuAddCount,
|
||||
identifier: "menu-add-count"
|
||||
)
|
||||
verificationRow(
|
||||
"Menu archive",
|
||||
value: menuArchiveCount,
|
||||
identifier: "menu-archive-count"
|
||||
)
|
||||
verificationRow(
|
||||
"Detail opened / closed",
|
||||
textValue: "\(detailOpenCount) / \(detailCloseCount)",
|
||||
identifier: "detail-navigation-count"
|
||||
)
|
||||
}
|
||||
} label: {
|
||||
Label("Navigation and menu results", systemImage: "menubar.rectangle")
|
||||
.font(.headline)
|
||||
}
|
||||
.accessibilityIdentifier("navigation-results-group")
|
||||
|
||||
Button {
|
||||
detailOpenCount += 1
|
||||
isShowingDetail = true
|
||||
} label: {
|
||||
Label("Open detail screen (\(detailOpenCount))", systemImage: "chevron.forward.circle.fill")
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(.indigo)
|
||||
.accessibilityIdentifier("open-detail-button")
|
||||
.accessibilityValue("Opened \(detailOpenCount) times")
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
.accessibilityIdentifier("controls-scroll-view")
|
||||
.navigationTitle("Tap Lab")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarLeading) {
|
||||
Button {
|
||||
toolbarRefreshCount += 1
|
||||
record("Navigation refresh tapped")
|
||||
} label: {
|
||||
Label("Refresh", systemImage: "arrow.clockwise")
|
||||
}
|
||||
.accessibilityIdentifier("nav-refresh-button")
|
||||
.accessibilityValue("\(toolbarRefreshCount) refreshes")
|
||||
}
|
||||
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Menu {
|
||||
Button {
|
||||
menuAddCount += 1
|
||||
record("Add menu item selected")
|
||||
} label: {
|
||||
Label("Add item (\(menuAddCount))", systemImage: "plus")
|
||||
}
|
||||
.accessibilityIdentifier("menu-add-item")
|
||||
|
||||
Button {
|
||||
menuArchiveCount += 1
|
||||
record("Archive menu item selected")
|
||||
} label: {
|
||||
Label("Archive item (\(menuArchiveCount))", systemImage: "archivebox")
|
||||
}
|
||||
.accessibilityIdentifier("menu-archive-item")
|
||||
} label: {
|
||||
Image(systemName: "ellipsis.circle")
|
||||
.accessibilityLabel("Harness actions menu")
|
||||
}
|
||||
.accessibilityIdentifier("toolbar-actions-menu")
|
||||
.accessibilityValue("Add \(menuAddCount), archive \(menuArchiveCount)")
|
||||
}
|
||||
}
|
||||
.navigationDestination(isPresented: $isShowingDetail) {
|
||||
VStack(spacing: 18) {
|
||||
verificationPanel
|
||||
|
||||
Image(systemName: "rectangle.stack.badge.play.fill")
|
||||
.font(.system(size: 46))
|
||||
.foregroundColor(.indigo)
|
||||
.accessibilityIdentifier("detail-screen-artwork")
|
||||
|
||||
Text("Navigation destination")
|
||||
.font(.title2.bold())
|
||||
.accessibilityIdentifier("detail-screen-title")
|
||||
|
||||
Button {
|
||||
detailButtonCount += 1
|
||||
record("Detail button tapped")
|
||||
} label: {
|
||||
Label("Detail tap (\(detailButtonCount))", systemImage: "hand.tap")
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.accessibilityIdentifier("detail-action-button")
|
||||
.accessibilityValue("\(detailButtonCount) taps")
|
||||
|
||||
Text("Detail count: \(detailButtonCount)")
|
||||
.font(.headline.monospacedDigit())
|
||||
.accessibilityIdentifier("detail-action-count")
|
||||
}
|
||||
.padding()
|
||||
.navigationTitle("Detail")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.navigationBarBackButtonHidden(true)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarLeading) {
|
||||
Button {
|
||||
isShowingDetail = false
|
||||
} label: {
|
||||
Label("Back to Tap Lab", systemImage: "chevron.backward")
|
||||
}
|
||||
.accessibilityIdentifier("detail-back-button")
|
||||
}
|
||||
}
|
||||
}
|
||||
.onChange(of: isShowingDetail) { isShowing in
|
||||
if isShowing {
|
||||
record("Opened detail screen")
|
||||
} else {
|
||||
detailCloseCount += 1
|
||||
record("Closed detail screen")
|
||||
}
|
||||
}
|
||||
}
|
||||
.accessibilityIdentifier("controls-navigation-stack")
|
||||
}
|
||||
|
||||
private var inputsTab: some View {
|
||||
NavigationStack {
|
||||
ScrollView {
|
||||
VStack(spacing: 14) {
|
||||
verificationPanel
|
||||
|
||||
GroupBox {
|
||||
VStack(spacing: 14) {
|
||||
Toggle(isOn: Binding(
|
||||
get: { toggleValue },
|
||||
set: { newValue in
|
||||
toggleValue = newValue
|
||||
toggleChangeCount += 1
|
||||
record("Toggle changed to \(newValue ? "on" : "off")")
|
||||
}
|
||||
)) {
|
||||
Label("Harness toggle", systemImage: toggleValue ? "checkmark.circle.fill" : "circle")
|
||||
}
|
||||
.accessibilityIdentifier("harness-toggle")
|
||||
.accessibilityValue(toggleValue ? "On" : "Off")
|
||||
|
||||
verificationRow(
|
||||
"Toggle changes",
|
||||
value: toggleChangeCount,
|
||||
identifier: "toggle-change-count"
|
||||
)
|
||||
|
||||
Divider()
|
||||
|
||||
Stepper(
|
||||
value: Binding(
|
||||
get: { stepperValue },
|
||||
set: { newValue in
|
||||
let direction = newValue > stepperValue ? "up" : "down"
|
||||
stepperValue = newValue
|
||||
stepperChangeCount += 1
|
||||
record("Stepper moved \(direction) to \(newValue)")
|
||||
}
|
||||
),
|
||||
in: 0...9
|
||||
) {
|
||||
Label("Stepper value: \(stepperValue)", systemImage: "plusminus.circle")
|
||||
.monospacedDigit()
|
||||
}
|
||||
.accessibilityIdentifier("harness-stepper")
|
||||
.accessibilityValue("Value \(stepperValue), changed \(stepperChangeCount) times")
|
||||
|
||||
verificationRow(
|
||||
"Stepper changes",
|
||||
value: stepperChangeCount,
|
||||
identifier: "stepper-change-count"
|
||||
)
|
||||
verificationRow(
|
||||
"Stepper value",
|
||||
value: stepperValue,
|
||||
identifier: "stepper-value"
|
||||
)
|
||||
}
|
||||
} label: {
|
||||
Label("Toggle and stepper", systemImage: "switch.2")
|
||||
.font(.headline)
|
||||
}
|
||||
.accessibilityIdentifier("toggle-stepper-group")
|
||||
|
||||
GroupBox {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Picker("Harness mode", selection: Binding(
|
||||
get: { selectedMode },
|
||||
set: { newValue in
|
||||
selectedMode = newValue
|
||||
pickerChangeCount += 1
|
||||
record("Segment selected: \(newValue)")
|
||||
}
|
||||
)) {
|
||||
Text("One").tag("One")
|
||||
Text("Two").tag("Two")
|
||||
Text("Three").tag("Three")
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.accessibilityIdentifier("harness-segmented-picker")
|
||||
.accessibilityValue("Selected \(selectedMode)")
|
||||
|
||||
verificationRow(
|
||||
"Selected segment",
|
||||
textValue: selectedMode,
|
||||
identifier: "picker-selection-value"
|
||||
)
|
||||
verificationRow(
|
||||
"Segment changes",
|
||||
value: pickerChangeCount,
|
||||
identifier: "picker-change-count"
|
||||
)
|
||||
}
|
||||
} label: {
|
||||
Label("Segmented picker", systemImage: "rectangle.split.3x1")
|
||||
.font(.headline)
|
||||
}
|
||||
.accessibilityIdentifier("picker-group")
|
||||
|
||||
GroupBox {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
TextField("Type a device message", text: $draftText)
|
||||
.focused($isTextFieldFocused)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
.submitLabel(.done)
|
||||
.accessibilityIdentifier("harness-text-field")
|
||||
.accessibilityValue(draftText)
|
||||
.onSubmit {
|
||||
commitText(source: "keyboard submit")
|
||||
}
|
||||
.onChange(of: draftText) { newValue in
|
||||
textChangeCount += 1
|
||||
record("Text changed to \(newValue.isEmpty ? "empty" : newValue)")
|
||||
}
|
||||
|
||||
HStack {
|
||||
Text("Echo: \(draftText.isEmpty ? "<empty>" : draftText)")
|
||||
.font(.subheadline.monospaced())
|
||||
.lineLimit(1)
|
||||
.accessibilityIdentifier("text-echo-value")
|
||||
.accessibilityValue(draftText)
|
||||
Spacer()
|
||||
Button("Commit") {
|
||||
commitText(source: "commit button")
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.accessibilityIdentifier("commit-text-button")
|
||||
.accessibilityValue("Committed \(textCommitCount) times")
|
||||
}
|
||||
|
||||
verificationRow(
|
||||
"Text changes",
|
||||
value: textChangeCount,
|
||||
identifier: "text-change-count"
|
||||
)
|
||||
verificationRow(
|
||||
"Text commits",
|
||||
value: textCommitCount,
|
||||
identifier: "text-commit-count"
|
||||
)
|
||||
}
|
||||
} label: {
|
||||
Label("Text entry", systemImage: "keyboard")
|
||||
.font(.headline)
|
||||
}
|
||||
.accessibilityIdentifier("text-entry-group")
|
||||
|
||||
#if canImport(UIKit)
|
||||
GroupBox {
|
||||
VStack(spacing: 10) {
|
||||
UIKitHarnessButton(count: uikitButtonCount) {
|
||||
uikitButtonCount += 1
|
||||
record("UIKit UIButton tapped")
|
||||
}
|
||||
.frame(maxWidth: .infinity, minHeight: 48)
|
||||
|
||||
verificationRow(
|
||||
"UIKit taps",
|
||||
value: uikitButtonCount,
|
||||
identifier: "uikit-button-count"
|
||||
)
|
||||
}
|
||||
} label: {
|
||||
Label("Native UIKit control", systemImage: "iphone")
|
||||
.font(.headline)
|
||||
}
|
||||
.accessibilityIdentifier("uikit-control-group")
|
||||
#endif
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
.accessibilityIdentifier("inputs-scroll-view")
|
||||
.navigationTitle("Input Lab")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
.accessibilityIdentifier("inputs-navigation-stack")
|
||||
}
|
||||
|
||||
private var rowsTab: some View {
|
||||
NavigationStack {
|
||||
List {
|
||||
Section {
|
||||
verificationPanel
|
||||
.listRowInsets(EdgeInsets())
|
||||
.listRowBackground(Color.clear)
|
||||
}
|
||||
|
||||
Section("Tap a row") {
|
||||
ForEach(Self.harnessRows) { row in
|
||||
Button {
|
||||
let count = rowTapCounts[row.id, default: 0] + 1
|
||||
rowTapCounts[row.id] = count
|
||||
record("\(row.title) tapped")
|
||||
} label: {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: row.symbol)
|
||||
.foregroundColor(.accentColor)
|
||||
Text(row.title)
|
||||
Spacer()
|
||||
Text("\(rowTapCounts[row.id, default: 0])")
|
||||
.font(.headline.monospacedDigit())
|
||||
.foregroundColor(.secondary)
|
||||
.accessibilityIdentifier("row-\(row.id)-count")
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityIdentifier("row-\(row.id)-button")
|
||||
.accessibilityValue("\(rowTapCounts[row.id, default: 0]) taps")
|
||||
.swipeActions(edge: .leading, allowsFullSwipe: false) {
|
||||
Button {
|
||||
rowFlagCount += 1
|
||||
record("Flagged \(row.title)")
|
||||
} label: {
|
||||
Label("Flag", systemImage: "flag.fill")
|
||||
}
|
||||
.tint(.orange)
|
||||
.accessibilityIdentifier("row-\(row.id)-flag-action")
|
||||
}
|
||||
.swipeActions(edge: .trailing, allowsFullSwipe: false) {
|
||||
Button {
|
||||
rowArchiveCount += 1
|
||||
record("Archived \(row.title)")
|
||||
} label: {
|
||||
Label("Archive", systemImage: "archivebox.fill")
|
||||
}
|
||||
.tint(.indigo)
|
||||
.accessibilityIdentifier("row-\(row.id)-archive-action")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Section("Row action results") {
|
||||
verificationRow(
|
||||
"Flags",
|
||||
value: rowFlagCount,
|
||||
identifier: "row-flag-count"
|
||||
)
|
||||
verificationRow(
|
||||
"Archives",
|
||||
value: rowArchiveCount,
|
||||
identifier: "row-archive-count"
|
||||
)
|
||||
verificationRow(
|
||||
"Toolbar checks",
|
||||
value: rowToolbarCount,
|
||||
identifier: "row-toolbar-count"
|
||||
)
|
||||
}
|
||||
}
|
||||
.listStyle(.insetGrouped)
|
||||
.accessibilityIdentifier("rows-list")
|
||||
.navigationTitle("Row Lab")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button {
|
||||
rowToolbarCount += 1
|
||||
record("Rows toolbar check tapped")
|
||||
} label: {
|
||||
Label("Check rows", systemImage: "checkmark.circle")
|
||||
}
|
||||
.accessibilityIdentifier("rows-toolbar-button")
|
||||
.accessibilityValue("\(rowToolbarCount) checks")
|
||||
}
|
||||
}
|
||||
}
|
||||
.accessibilityIdentifier("rows-navigation-stack")
|
||||
}
|
||||
|
||||
private var verificationPanel: some View {
|
||||
VerificationPanel(
|
||||
lastAction: lastAction,
|
||||
totalActions: totalActions,
|
||||
selectedTab: selectedTab.title,
|
||||
tabChangeCount: tabChangeCount
|
||||
)
|
||||
}
|
||||
|
||||
private func buttonLabel(_ title: String, count: Int, symbol: String) -> some View {
|
||||
VStack(spacing: 4) {
|
||||
Label(title, systemImage: symbol)
|
||||
.lineLimit(1)
|
||||
Text("Count \(count)")
|
||||
.font(.caption.monospacedDigit())
|
||||
.accessibilityIdentifier("\(title.lowercased())-button-count")
|
||||
}
|
||||
.frame(maxWidth: .infinity, minHeight: 40)
|
||||
}
|
||||
|
||||
private func verificationRow(_ label: String, value: Int, identifier: String) -> some View {
|
||||
verificationRow(label, textValue: String(value), identifier: identifier)
|
||||
}
|
||||
|
||||
private func verificationRow(_ label: String, textValue: String, identifier: String) -> some View {
|
||||
HStack {
|
||||
Text(label)
|
||||
.foregroundColor(.secondary)
|
||||
Spacer()
|
||||
Text(textValue)
|
||||
.font(.subheadline.bold().monospacedDigit())
|
||||
.accessibilityIdentifier(identifier)
|
||||
.accessibilityLabel(label)
|
||||
.accessibilityValue(textValue)
|
||||
}
|
||||
}
|
||||
|
||||
private func commitText(source: String) {
|
||||
textCommitCount += 1
|
||||
isTextFieldFocused = false
|
||||
record("Text committed from \(source): \(draftText.isEmpty ? "empty" : draftText)")
|
||||
}
|
||||
|
||||
private func record(_ action: String) {
|
||||
totalActions += 1
|
||||
lastAction = action
|
||||
}
|
||||
}
|
||||
|
||||
private struct VerificationPanel: View {
|
||||
let lastAction: String
|
||||
let totalActions: Int
|
||||
let selectedTab: String
|
||||
let tabChangeCount: Int
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack {
|
||||
Label("LIVE", systemImage: "waveform.path.ecg")
|
||||
.font(.caption.bold())
|
||||
.foregroundColor(.green)
|
||||
.accessibilityIdentifier("harness-live-indicator")
|
||||
Spacer()
|
||||
Text("Total \(totalActions)")
|
||||
.font(.caption.bold().monospacedDigit())
|
||||
.accessibilityIdentifier("total-action-count")
|
||||
.accessibilityLabel("Total actions")
|
||||
.accessibilityValue("\(totalActions)")
|
||||
}
|
||||
|
||||
Text(lastAction)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.lineLimit(2)
|
||||
.accessibilityIdentifier("last-action-status")
|
||||
.accessibilityLabel("Last action")
|
||||
.accessibilityValue(lastAction)
|
||||
|
||||
HStack {
|
||||
Text("Tab: \(selectedTab)")
|
||||
.accessibilityIdentifier("selected-tab-status")
|
||||
.accessibilityValue(selectedTab)
|
||||
Spacer()
|
||||
Text("Tab changes: \(tabChangeCount)")
|
||||
.monospacedDigit()
|
||||
.accessibilityIdentifier("tab-change-count")
|
||||
.accessibilityValue("\(tabChangeCount)")
|
||||
}
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
.padding(12)
|
||||
.background(Color.green.opacity(0.10))
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.stroke(Color.green.opacity(0.35), lineWidth: 1)
|
||||
}
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
.accessibilityElement(children: .contain)
|
||||
.accessibilityIdentifier("verification-panel")
|
||||
}
|
||||
}
|
||||
|
||||
#if canImport(UIKit)
|
||||
private struct UIKitHarnessButton: UIViewRepresentable {
|
||||
let count: Int
|
||||
let action: () -> Void
|
||||
|
||||
final class Coordinator: NSObject {
|
||||
var action: () -> Void
|
||||
|
||||
init(action: @escaping () -> Void) {
|
||||
self.action = action
|
||||
}
|
||||
|
||||
@objc func tapped() {
|
||||
action()
|
||||
}
|
||||
}
|
||||
|
||||
func makeCoordinator() -> Coordinator {
|
||||
Coordinator(action: action)
|
||||
}
|
||||
|
||||
func makeUIView(context: Context) -> UIButton {
|
||||
let button = UIButton(type: .system)
|
||||
var configuration = UIButton.Configuration.filled()
|
||||
configuration.cornerStyle = .medium
|
||||
configuration.image = UIImage(systemName: "hand.tap.fill")
|
||||
configuration.imagePadding = 8
|
||||
button.configuration = configuration
|
||||
button.accessibilityIdentifier = "uikit-button"
|
||||
button.accessibilityLabel = "UIKit button"
|
||||
button.addTarget(context.coordinator, action: #selector(Coordinator.tapped), for: .touchUpInside)
|
||||
return button
|
||||
}
|
||||
|
||||
func updateUIView(_ button: UIButton, context: Context) {
|
||||
context.coordinator.action = action
|
||||
var configuration = button.configuration ?? UIButton.Configuration.filled()
|
||||
configuration.title = "UIKit Tap (\(count))"
|
||||
button.configuration = configuration
|
||||
button.accessibilityValue = "\(count) taps"
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -1,32 +1,22 @@
|
||||
// Canonical app state for the fixture. Every snapshot-eligible field is
|
||||
// marked with the @Snapshotable property wrapper that the codegen tool
|
||||
// detects via attribute scan.
|
||||
//
|
||||
// Note: we DON'T use @Observable here because the macro expansion converts
|
||||
// stored properties into computed ones, which the @Snapshotable wrapper
|
||||
// can't apply to. In production apps that need both observability AND
|
||||
// snapshotting, the right pattern is:
|
||||
// - Use ObservableObject + @Published (older API), or
|
||||
// - Hold all @Snapshotable state in a nested struct + replace it
|
||||
// wholesale on restore so SwiftUI sees a single change notification
|
||||
// (the canonical-state-struct atomicity strategy from the plan).
|
||||
// Canonical observable app state for the fixture. Snapshot eligibility is a
|
||||
// generator-only source marker, not a property wrapper, so it composes with
|
||||
// Observation's @Observable macro.
|
||||
|
||||
import Foundation
|
||||
import Observation
|
||||
|
||||
public final class FixtureAppState {
|
||||
@Snapshotable public var isLoggedIn: Bool = false
|
||||
@Snapshotable public var username: String = ""
|
||||
@Snapshotable public var tapCounter: Int = 0
|
||||
@Observable
|
||||
final class FixtureAppState {
|
||||
// @Snapshotable
|
||||
var isLoggedIn: Bool = false
|
||||
// @Snapshotable
|
||||
var username: String = ""
|
||||
// @Snapshotable
|
||||
var tapCounter: Int = 0
|
||||
// @Snapshotable
|
||||
var nickname: String? = nil
|
||||
/// Not snapshotted — ephemeral cache that should never leak via /state/snapshot.
|
||||
public var ephemeralCache: [String: String] = [:]
|
||||
var ephemeralCache: [String: String] = [:]
|
||||
|
||||
public init() {}
|
||||
}
|
||||
|
||||
/// Property wrapper marker for snapshot-eligible state. The actual wrapper
|
||||
/// is a no-op at runtime; codegen-tool detection happens via attribute scan.
|
||||
@propertyWrapper
|
||||
public struct Snapshotable<Value> {
|
||||
public var wrappedValue: Value
|
||||
public init(wrappedValue: Value) { self.wrappedValue = wrappedValue }
|
||||
init() {}
|
||||
}
|
||||
|
||||
+3
-3
@@ -1,7 +1,7 @@
|
||||
name: FixtureApp
|
||||
options:
|
||||
deploymentTarget:
|
||||
iOS: "16.0"
|
||||
iOS: "17.0"
|
||||
bundleIdPrefix: com.gstack.iosqa
|
||||
developmentLanguage: en
|
||||
createIntermediateGroups: true
|
||||
@@ -23,7 +23,7 @@ targets:
|
||||
FixtureApp:
|
||||
type: application
|
||||
platform: iOS
|
||||
deploymentTarget: "16.0"
|
||||
deploymentTarget: "17.0"
|
||||
sources:
|
||||
- path: Sources/FixtureApp
|
||||
dependencies:
|
||||
@@ -45,5 +45,5 @@ targets:
|
||||
CODE_SIGN_STYLE: Automatic
|
||||
TARGETED_DEVICE_FAMILY: "1"
|
||||
SWIFT_VERSION: "5.9"
|
||||
IPHONEOS_DEPLOYMENT_TARGET: "16.0"
|
||||
IPHONEOS_DEPLOYMENT_TARGET: "17.0"
|
||||
ENABLE_PREVIEWS: YES
|
||||
|
||||
@@ -67,6 +67,30 @@ describe("canonicalizeRemote", () => {
|
||||
it("collapses redundant slashes", () => {
|
||||
expect(canonicalizeRemote("https://github.com//foo//bar")).toBe("github.com/foo/bar");
|
||||
});
|
||||
|
||||
it("strips .git even when the URL has a trailing slash", () => {
|
||||
// A remote configured with both a .git suffix and a trailing slash must
|
||||
// canonicalize to the same key as one without — otherwise the same repo
|
||||
// gets two dedup/source-id keys across machines.
|
||||
expect(canonicalizeRemote("https://github.com/garrytan/gstack.git/")).toBe("github.com/garrytan/gstack");
|
||||
expect(canonicalizeRemote("git@github.com:garrytan/gstack.git/")).toBe("github.com/garrytan/gstack");
|
||||
expect(canonicalizeRemote("https://github.com/foo/bar.git///")).toBe("github.com/foo/bar");
|
||||
});
|
||||
|
||||
it("produces the same key with or without a trailing slash", () => {
|
||||
expect(canonicalizeRemote("https://github.com/garrytan/gstack.git/")).toBe(
|
||||
canonicalizeRemote("https://github.com/garrytan/gstack.git")
|
||||
);
|
||||
});
|
||||
|
||||
it("canonicalizes a path remote ending in a .git directory component", () => {
|
||||
// Stripping the `.git` suffix exposes a new trailing slash
|
||||
// ("/repo/.git" → "/repo/") which must also be stripped, or the same
|
||||
// repo splits into two identities.
|
||||
expect(canonicalizeRemote("file:///Users/x/repo/.git")).toBe(
|
||||
canonicalizeRemote("file:///Users/x/repo")
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ── secretScanFile ─────────────────────────────────────────────────────────
|
||||
@@ -244,6 +268,62 @@ body
|
||||
expect(m!.context_queries[0].id).toBe("complete");
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("parses a nested filter: block on a list query", () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "gstack-test-"));
|
||||
const file = join(dir, "filtered.md");
|
||||
writeFileSync(
|
||||
file,
|
||||
`---
|
||||
name: investigate
|
||||
gbrain:
|
||||
schema: 1
|
||||
context_queries:
|
||||
- id: prior-investigations
|
||||
kind: list
|
||||
filter:
|
||||
type: timeline
|
||||
tags_contains: "repo:{repo_slug}"
|
||||
content_contains: "investigate"
|
||||
sort: updated_at_desc
|
||||
limit: 5
|
||||
render_as: "## Prior investigations in this repo"
|
||||
- id: recent-no-filter
|
||||
kind: list
|
||||
sort: created_at_desc
|
||||
limit: 3
|
||||
render_as: "## Recent (no filter)"
|
||||
---
|
||||
|
||||
body
|
||||
`
|
||||
);
|
||||
|
||||
const m = parseSkillManifest(file);
|
||||
expect(m).not.toBeNull();
|
||||
expect(m!.context_queries).toHaveLength(2);
|
||||
|
||||
// The filter: sub-block is parsed into a key/value map, with quotes
|
||||
// stripped and template vars left intact for downstream substitution.
|
||||
const filtered = m!.context_queries[0];
|
||||
expect(filtered.id).toBe("prior-investigations");
|
||||
expect(filtered.filter).toEqual({
|
||||
type: "timeline",
|
||||
tags_contains: "repo:{repo_slug}",
|
||||
content_contains: "investigate",
|
||||
});
|
||||
// Sibling fields on the same item still parse alongside the filter.
|
||||
expect(filtered.sort).toBe("updated_at_desc");
|
||||
expect(filtered.limit).toBe(5);
|
||||
expect(filtered.render_as).toBe("## Prior investigations in this repo");
|
||||
|
||||
// A list query with no filter: leaves filter undefined (no regression).
|
||||
expect(m!.context_queries[1].id).toBe("recent-no-filter");
|
||||
expect(m!.context_queries[1].filter).toBeUndefined();
|
||||
expect(m!.context_queries[1].sort).toBe("created_at_desc");
|
||||
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
});
|
||||
|
||||
// ── withErrorContext ───────────────────────────────────────────────────────
|
||||
|
||||
@@ -173,13 +173,9 @@ export async function runSkillTest(options: {
|
||||
// restores operator MCP along with the operator env.
|
||||
if (isHermeticEnabled()) args.push('--strict-mcp-config');
|
||||
|
||||
// Write prompt to a temp file OUTSIDE workingDirectory to avoid race conditions
|
||||
// where afterAll cleanup deletes the dir before cat reads the file (especially
|
||||
// with --concurrent --retry). Using os.tmpdir() + unique suffix keeps it stable.
|
||||
const promptFile = path.join(os.tmpdir(), `.prompt-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
||||
fs.writeFileSync(promptFile, prompt);
|
||||
|
||||
const proc = Bun.spawn(['sh', '-c', `cat "${promptFile}" | claude ${args.map(a => `"${a}"`).join(' ')}`], {
|
||||
// Spawn claude directly with array-form args (no shell interpolation).
|
||||
// Prompt is piped via stdin using a Blob to avoid temp files and shell escaping.
|
||||
const proc = Bun.spawn(['claude', ...args], {
|
||||
cwd: workingDirectory,
|
||||
// Hermetic by default (see test/helpers/hermetic-env.ts): operator
|
||||
// session context (CONDUCTOR_*, CLAUDECODE, ~/.claude config, ~/.gstack)
|
||||
@@ -189,6 +185,7 @@ export async function runSkillTest(options: {
|
||||
// suite exercising the INTERACTIVE prose-fallback path opts out by passing
|
||||
// `env: { GSTACK_HEADLESS: '' }` — extraEnv wins because it spreads last.
|
||||
env: hermeticChildEnv({ GSTACK_HEADLESS: '1', ...extraEnv }),
|
||||
stdin: new Blob([prompt]),
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe',
|
||||
});
|
||||
@@ -201,11 +198,12 @@ export async function runSkillTest(options: {
|
||||
const timeoutId = setTimeout(() => {
|
||||
timedOut = true;
|
||||
proc.kill();
|
||||
// proc.kill() only signals the `sh -c` wrapper. The claude child it
|
||||
// spawned can survive as an orphan that inherited our stdout/stderr
|
||||
// pipes, so without cancel() the read loop below blocks until the
|
||||
// orphan finally exits (observed: a 600s timeout stretching past 1400s
|
||||
// and tripping bun's per-test timeout instead of returning a result).
|
||||
// proc.kill() signals claude itself (direct spawn, no shell wrapper),
|
||||
// but tool subprocesses claude spawned can survive as orphans that
|
||||
// inherited our stdout/stderr pipes, so without cancel() the read loop
|
||||
// below blocks until the orphan finally exits (observed: a 600s timeout
|
||||
// stretching past 1400s and tripping bun's per-test timeout instead of
|
||||
// returning a result).
|
||||
reader.cancel().catch(() => { /* stream already closed */ });
|
||||
}, timeout);
|
||||
|
||||
@@ -233,6 +231,11 @@ export async function runSkillTest(options: {
|
||||
if (!line.trim()) continue;
|
||||
collectedLines.push(line);
|
||||
|
||||
// Track time to first NDJSON line (measures latency from spawn to first Claude response)
|
||||
if (firstResponseMs === 0) {
|
||||
firstResponseMs = Date.now() - startTime;
|
||||
}
|
||||
|
||||
// Real-time progress to stderr + persistent logs
|
||||
try {
|
||||
const event = JSON.parse(line);
|
||||
@@ -244,8 +247,7 @@ export async function runSkillTest(options: {
|
||||
liveToolCount++;
|
||||
const now = Date.now();
|
||||
const elapsed = Math.round((now - startTime) / 1000);
|
||||
// Track timing telemetry
|
||||
if (firstResponseMs === 0) firstResponseMs = now - startTime;
|
||||
// Track inter-turn latency (tool call to tool call)
|
||||
if (lastToolTime > 0) {
|
||||
const interTurn = now - lastToolTime;
|
||||
if (interTurn > maxInterTurnMs) maxInterTurnMs = interTurn;
|
||||
@@ -310,8 +312,6 @@ export async function runSkillTest(options: {
|
||||
const exitCode = await proc.exited;
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
try { fs.unlinkSync(promptFile); } catch { /* non-fatal */ }
|
||||
|
||||
if (timedOut) {
|
||||
exitReason = 'timeout';
|
||||
} else if (exitCode === 0) {
|
||||
|
||||
@@ -96,6 +96,21 @@ describe('check-careful.sh', () => {
|
||||
expect(output.permissionDecision).toBe('ask');
|
||||
expect(output.message).toContain('recursive delete');
|
||||
});
|
||||
|
||||
test.each([
|
||||
'rm -rf /; rm -rf node_modules',
|
||||
'rm -rf / && rm -rf node_modules',
|
||||
'rm -rf / # rm -rf node_modules',
|
||||
'rm -rf node_modules; rm -rf /',
|
||||
'rm -rf node_modules || rm -rf /',
|
||||
'echo ok && rm -rf /',
|
||||
'rm -rf node_modules\nrm -rf /',
|
||||
])('never lets a safe-looking target hide a destructive command: %s', (command) => {
|
||||
const { exitCode, output } = runHook(CAREFUL_SCRIPT, carefulInput(command));
|
||||
expect(exitCode).toBe(0);
|
||||
expect(output.permissionDecision).toBe('ask');
|
||||
expect(output.message).toContain('recursive delete');
|
||||
});
|
||||
});
|
||||
|
||||
// --- SQL destructive commands ---
|
||||
|
||||
@@ -24,8 +24,10 @@ import {
|
||||
openclaw,
|
||||
} from '../hosts/index';
|
||||
import { HOST_PATHS } from '../scripts/resolvers/types';
|
||||
import { RESOLVERS } from '../scripts/resolvers';
|
||||
|
||||
const ROOT = path.resolve(import.meta.dir, '..');
|
||||
const RESOLVER_NAMES = new Set(Object.keys(RESOLVERS));
|
||||
|
||||
// ─── hosts/index.ts ─────────────────────────────────────────
|
||||
|
||||
@@ -205,13 +207,32 @@ describe('validateHostConfig', () => {
|
||||
c.cliCommand = 'opencode;rm -rf /';
|
||||
expect(validateHostConfig(c).some(e => e.includes('cliCommand'))).toBe(true);
|
||||
});
|
||||
|
||||
test('valid suppressedResolvers pass when resolver names provided', () => {
|
||||
const c = makeValid();
|
||||
c.suppressedResolvers = ['DESIGN_OUTSIDE_VOICES', 'REVIEW_ARMY'];
|
||||
expect(validateHostConfig(c, RESOLVER_NAMES)).toEqual([]);
|
||||
});
|
||||
|
||||
test('unknown suppressedResolvers entry is caught', () => {
|
||||
const c = makeValid();
|
||||
c.suppressedResolvers = ['DESIGN_OUTSIDE_VOICES', 'NONEXISTENT_RESOLVER'];
|
||||
const errors = validateHostConfig(c, RESOLVER_NAMES);
|
||||
expect(errors.some(e => e.includes('NONEXISTENT_RESOLVER'))).toBe(true);
|
||||
});
|
||||
|
||||
test('suppressedResolvers unchecked when resolver names omitted', () => {
|
||||
const c = makeValid();
|
||||
c.suppressedResolvers = ['TYPO_RESOLVER'];
|
||||
expect(validateHostConfig(c)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── validateAllConfigs ─────────────────────────────────────
|
||||
|
||||
describe('validateAllConfigs', () => {
|
||||
test('real configs all pass validation', () => {
|
||||
const errors = validateAllConfigs(ALL_HOST_CONFIGS);
|
||||
const errors = validateAllConfigs(ALL_HOST_CONFIGS, RESOLVER_NAMES);
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
|
||||
@@ -233,6 +254,12 @@ describe('validateAllConfigs', () => {
|
||||
expect(errors.some(e => e.includes('Duplicate globalRoot'))).toBe(true);
|
||||
});
|
||||
|
||||
test('unknown suppressedResolvers entry surfaces with host-name prefix', () => {
|
||||
const bad = { ...codex, name: 'bad-host', hostSubdir: '.bad', globalRoot: '.bad/skills/gstack', suppressedResolvers: ['BOGUS_RESOLVER'] } as HostConfig;
|
||||
const errors = validateAllConfigs([bad], RESOLVER_NAMES);
|
||||
expect(errors.some(e => e.startsWith('[bad-host]') && e.includes('BOGUS_RESOLVER'))).toBe(true);
|
||||
});
|
||||
|
||||
test('per-config validation errors are prefixed with host name', () => {
|
||||
const bad = { ...codex, name: 'BAD', cliCommand: 'also bad' } as HostConfig;
|
||||
const errors = validateAllConfigs([bad]);
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
import { afterEach, describe, expect, test } from 'bun:test';
|
||||
import { createHash } from 'crypto';
|
||||
import { spawnSync } from 'child_process';
|
||||
import {
|
||||
chmodSync,
|
||||
copyFileSync,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
readdirSync,
|
||||
rmSync,
|
||||
statSync,
|
||||
writeFileSync,
|
||||
} from 'fs';
|
||||
import { tmpdir } from 'os';
|
||||
import { join, relative } from 'path';
|
||||
|
||||
const ROOT = join(import.meta.dir, '..');
|
||||
const SAFE_TEMPLATE_MAP = [
|
||||
['Package.swift.template', 'Package.swift'],
|
||||
['StateServer.swift.template', 'Sources/DebugBridgeCore/StateServer.swift'],
|
||||
['DebugBridgeManager.swift.template', 'Sources/DebugBridgeCore/DebugBridgeManager.swift'],
|
||||
['Bridges.swift.template', 'Sources/DebugBridgeUI/Bridges.swift'],
|
||||
['DebugOverlay.swift.template', 'Sources/DebugBridgeUI/DebugOverlay.swift'],
|
||||
['DebugBridgeTouch.m.template', 'Sources/DebugBridgeTouch/DebugBridgeTouch.m'],
|
||||
['DebugBridgeTouch.h.template', 'Sources/DebugBridgeTouch/include/DebugBridgeTouch.h'],
|
||||
] as const;
|
||||
|
||||
const workDirs: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const dir of workDirs.splice(0)) {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
function copyIntoFakeInstall(workDir: string): { root: string; launcher: string } {
|
||||
const root = join(workDir, 'fake gstack install');
|
||||
const binDir = join(root, 'bin');
|
||||
const scriptsDir = join(root, 'ios-qa', 'scripts');
|
||||
const templatesDir = join(root, 'ios-qa', 'templates');
|
||||
mkdirSync(binDir, { recursive: true });
|
||||
mkdirSync(scriptsDir, { recursive: true });
|
||||
mkdirSync(templatesDir, { recursive: true });
|
||||
|
||||
const launcher = join(binDir, 'gstack-ios-qa-regen');
|
||||
copyFileSync(join(ROOT, 'bin', 'gstack-ios-qa-regen'), launcher);
|
||||
chmodSync(launcher, 0o755);
|
||||
copyFileSync(join(ROOT, 'ios-qa', 'scripts', 'gen-accessors.ts'), join(scriptsDir, 'gen-accessors.ts'));
|
||||
for (const [template] of SAFE_TEMPLATE_MAP) {
|
||||
copyFileSync(join(ROOT, 'ios-qa', 'templates', template), join(templatesDir, template));
|
||||
}
|
||||
|
||||
// These files deliberately exist in the discovered template directory. If
|
||||
// the launcher ever regresses to wildcard copying, their sentinel content
|
||||
// will escape into the app and fail the absence assertions below.
|
||||
writeFileSync(join(templatesDir, 'DebugBridgeWiring.swift.template'), '// FORBIDDEN-WIRING-SENTINEL\n');
|
||||
writeFileSync(join(templatesDir, 'StateAccessor.swift.template'), '// FORBIDDEN-STATE-SENTINEL\n');
|
||||
writeFileSync(join(root, 'VERSION'), '9.8.7.6\n');
|
||||
return { root, launcher };
|
||||
}
|
||||
|
||||
function treeHash(...roots: string[]): string {
|
||||
const hash = createHash('sha256');
|
||||
for (const root of roots) {
|
||||
const visit = (dir: string): void => {
|
||||
for (const name of readdirSync(dir).sort()) {
|
||||
const path = join(dir, name);
|
||||
const stat = statSync(path);
|
||||
if (stat.isDirectory()) {
|
||||
visit(path);
|
||||
} else {
|
||||
hash.update(relative(root, path));
|
||||
hash.update('\0');
|
||||
hash.update(readFileSync(path));
|
||||
hash.update('\0');
|
||||
}
|
||||
}
|
||||
};
|
||||
visit(root);
|
||||
}
|
||||
return hash.digest('hex');
|
||||
}
|
||||
|
||||
function allFileContents(root: string): string {
|
||||
let contents = '';
|
||||
const visit = (dir: string): void => {
|
||||
for (const name of readdirSync(dir)) {
|
||||
const path = join(dir, name);
|
||||
if (statSync(path).isDirectory()) visit(path);
|
||||
else contents += readFileSync(path, 'utf8');
|
||||
}
|
||||
};
|
||||
visit(root);
|
||||
return contents;
|
||||
}
|
||||
|
||||
describe('gstack-ios-qa-regen', () => {
|
||||
test('repository launcher is executable', () => {
|
||||
expect(statSync(join(ROOT, 'bin', 'gstack-ios-qa-regen')).mode & 0o111).not.toBe(0);
|
||||
});
|
||||
|
||||
test('requires the documented app-source and bridge-dir contract', () => {
|
||||
const workDir = mkdtempSync(join(tmpdir(), 'ios-qa-regen-'));
|
||||
workDirs.push(workDir);
|
||||
const { launcher } = copyIntoFakeInstall(workDir);
|
||||
const result = spawnSync('bash', [launcher, '--app-source', workDir], { encoding: 'utf8' });
|
||||
|
||||
expect(result.status).toBe(2);
|
||||
expect(result.stderr).toContain('both --app-source and --bridge-dir are required');
|
||||
});
|
||||
|
||||
test('leaves no completion marker when accessor generation fails', () => {
|
||||
const workDir = mkdtempSync(join(tmpdir(), 'ios-qa-regen-'));
|
||||
workDirs.push(workDir);
|
||||
const { launcher } = copyIntoFakeInstall(workDir);
|
||||
const appSource = join(workDir, 'app-source');
|
||||
const bridgeDir = join(workDir, 'bridge');
|
||||
const generatedDir = join(appSource, 'DebugBridgeGenerated');
|
||||
const fakeBin = join(workDir, 'fake-bin');
|
||||
mkdirSync(generatedDir, { recursive: true });
|
||||
mkdirSync(fakeBin, { recursive: true });
|
||||
writeFileSync(join(appSource, 'AppState.swift'), '@Observable final class AppState {}\n');
|
||||
writeFileSync(join(generatedDir, '.gstack-version'), 'stale-complete-marker\n');
|
||||
const fakeBun = join(fakeBin, 'bun');
|
||||
writeFileSync(fakeBun, '#!/bin/sh\nexit 17\n');
|
||||
chmodSync(fakeBun, 0o755);
|
||||
|
||||
const result = spawnSync('bash', [
|
||||
launcher,
|
||||
'--app-source', appSource,
|
||||
'--bridge-dir', bridgeDir,
|
||||
], {
|
||||
encoding: 'utf8',
|
||||
env: { ...process.env, PATH: `${fakeBin}:${process.env.PATH ?? ''}` },
|
||||
});
|
||||
|
||||
expect(result.status).toBe(17);
|
||||
expect(existsSync(join(generatedDir, '.gstack-version'))).toBe(false);
|
||||
});
|
||||
|
||||
test('regenerates the allowlisted package and accessors idempotently', () => {
|
||||
const workDir = mkdtempSync(join(tmpdir(), 'ios-qa-regen-'));
|
||||
workDirs.push(workDir);
|
||||
const { root, launcher } = copyIntoFakeInstall(workDir);
|
||||
const appSource = join(workDir, 'app source');
|
||||
const bridgeDir = join(appSource, 'DebugBridge');
|
||||
const generatedDir = join(appSource, 'DebugBridgeGenerated');
|
||||
const cacheRoot = join(workDir, 'isolated cache');
|
||||
mkdirSync(appSource, { recursive: true });
|
||||
writeFileSync(join(appSource, 'AppState.swift'), `
|
||||
@Observable
|
||||
final class AppState {
|
||||
// @Snapshotable
|
||||
var counter: Int = 0
|
||||
}
|
||||
`);
|
||||
|
||||
// Seed the flat legacy layout that old ios-sync versions produced. A
|
||||
// correct regeneration must remove these known generated artifacts rather
|
||||
// than letting Xcode compile a second, stale harness implementation.
|
||||
mkdirSync(generatedDir, { recursive: true });
|
||||
for (const obsolete of [
|
||||
join(bridgeDir, 'DebugBridgeWiring.swift'),
|
||||
join(bridgeDir, 'StateAccessor.swift'),
|
||||
join(generatedDir, 'Package.swift'),
|
||||
join(generatedDir, 'StateServer.swift'),
|
||||
join(generatedDir, 'DebugBridgeManager.swift'),
|
||||
join(generatedDir, 'Bridges.swift'),
|
||||
join(generatedDir, 'DebugOverlay.swift'),
|
||||
join(generatedDir, 'DebugBridgeTouch.m'),
|
||||
join(generatedDir, 'DebugBridgeTouch.h'),
|
||||
join(generatedDir, 'DebugBridgeWiring.swift'),
|
||||
]) {
|
||||
mkdirSync(join(obsolete, '..'), { recursive: true });
|
||||
writeFileSync(obsolete, '// OBSOLETE-HARNESS-SENTINEL\n');
|
||||
}
|
||||
|
||||
const env = {
|
||||
...process.env,
|
||||
GSTACK_IOS_CACHE_ROOT: cacheRoot,
|
||||
SWIFT_VERSION: '6.3.3',
|
||||
GEN_ACCESSORS_REV: 'regen-test',
|
||||
};
|
||||
const args = [launcher, '--app-source', appSource, '--bridge-dir', bridgeDir];
|
||||
const first = spawnSync('bash', args, { encoding: 'utf8', env });
|
||||
expect(first.status).toBe(0);
|
||||
expect(first.stderr).toBe('');
|
||||
|
||||
// Every installed package file must be byte-identical to its explicit
|
||||
// source template: this is the durable template/output parity contract.
|
||||
for (const [template, destination] of SAFE_TEMPLATE_MAP) {
|
||||
expect(readFileSync(join(bridgeDir, destination))).toEqual(
|
||||
readFileSync(join(root, 'ios-qa', 'templates', template)),
|
||||
);
|
||||
}
|
||||
|
||||
const accessorPath = join(generatedDir, 'StateAccessor.swift');
|
||||
const accessor = readFileSync(accessorPath, 'utf8');
|
||||
expect(accessor).toContain('import DebugBridgeCore');
|
||||
expect(accessor).toContain('enum AppStateAccessor');
|
||||
expect(accessor).not.toContain('public enum AppStateAccessor');
|
||||
expect(accessor).not.toContain('import DebugBridge\n');
|
||||
expect(accessor).toContain('guard let restored0 = Self.decodeSnapshotValue(raw0, as: Int.self)');
|
||||
expect(accessor).toContain('atomicRestore: { keys, apply in');
|
||||
expect(accessor).toContain('state.counter = restored0');
|
||||
expect(accessor).toContain('state.counter = typed');
|
||||
expect(accessor).toContain('return true');
|
||||
expect(accessor).not.toContain('atomicRestore: { _ in .ok }');
|
||||
expect(accessor).not.toContain('write: { _ in false }');
|
||||
expect(readFileSync(join(generatedDir, '.gstack-version'), 'utf8')).toBe(
|
||||
readFileSync(join(root, 'VERSION'), 'utf8'),
|
||||
);
|
||||
|
||||
expect(existsSync(join(bridgeDir, 'DebugBridgeWiring.swift'))).toBe(false);
|
||||
expect(existsSync(join(bridgeDir, 'StateAccessor.swift'))).toBe(false);
|
||||
for (const obsoleteName of [
|
||||
'Package.swift',
|
||||
'StateServer.swift',
|
||||
'DebugBridgeManager.swift',
|
||||
'Bridges.swift',
|
||||
'DebugOverlay.swift',
|
||||
'DebugBridgeTouch.m',
|
||||
'DebugBridgeTouch.h',
|
||||
'DebugBridgeWiring.swift',
|
||||
]) {
|
||||
expect(existsSync(join(generatedDir, obsoleteName))).toBe(false);
|
||||
}
|
||||
const installedContents = allFileContents(bridgeDir) + allFileContents(generatedDir);
|
||||
expect(installedContents).not.toContain('FORBIDDEN-WIRING-SENTINEL');
|
||||
expect(installedContents).not.toContain('FORBIDDEN-STATE-SENTINEL');
|
||||
expect(installedContents).not.toContain('OBSOLETE-HARNESS-SENTINEL');
|
||||
|
||||
const swiftAvailable = spawnSync('swift', ['--version'], { encoding: 'utf8' }).status === 0;
|
||||
if (swiftAvailable) {
|
||||
const dump = spawnSync('swift', ['package', 'dump-package', '--package-path', bridgeDir], {
|
||||
encoding: 'utf8',
|
||||
});
|
||||
expect(dump.status).toBe(0);
|
||||
const manifest = JSON.parse(dump.stdout) as { targets: Array<{ name: string }> };
|
||||
expect(manifest.targets.map(target => target.name).sort()).toEqual([
|
||||
'DebugBridgeCore',
|
||||
'DebugBridgeTouch',
|
||||
'DebugBridgeUI',
|
||||
]);
|
||||
}
|
||||
|
||||
const firstHash = treeHash(bridgeDir, generatedDir);
|
||||
const firstAccessorHash = accessor.match(/accessorHash: "([a-f0-9]+)"/)?.[1];
|
||||
const second = spawnSync('bash', args, { encoding: 'utf8', env });
|
||||
expect(second.status).toBe(0);
|
||||
expect(second.stderr).toBe('');
|
||||
expect(second.stdout).toContain('gen-accessors: cache hit');
|
||||
expect(treeHash(bridgeDir, generatedDir)).toBe(firstHash);
|
||||
expect(readFileSync(accessorPath, 'utf8').match(/accessorHash: "([a-f0-9]+)"/)?.[1]).toBe(firstAccessorHash);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { readFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
|
||||
const ROOT = join(import.meta.dir, '..');
|
||||
const PRE_FIXTURE = join(ROOT, 'test/fixtures/ios-fix/ios-qa-swiftui-tap-pre.json');
|
||||
const PRE_SCREENSHOT = join(ROOT, 'test/fixtures/ios-fix/ios-qa-swiftui-tap-pre.png');
|
||||
|
||||
describe('ios-fix regression fixture — SwiftUI taps reported success without acting', () => {
|
||||
test('preserves the pre-fix state and physical-device screenshot', () => {
|
||||
const state = JSON.parse(readFileSync(PRE_FIXTURE, 'utf8'));
|
||||
expect(state).toEqual({
|
||||
_schema_version: 1,
|
||||
_app_build_id: 'uninitialized',
|
||||
_accessor_hash: 'uninitialized',
|
||||
keys: {},
|
||||
});
|
||||
|
||||
const png = readFileSync(PRE_SCREENSHOT);
|
||||
expect([...png.subarray(0, 8)]).toEqual([137, 80, 78, 71, 13, 10, 26, 10]);
|
||||
expect(png.readUInt32BE(16)).toBe(1206);
|
||||
expect(png.readUInt32BE(20)).toBe(2622);
|
||||
});
|
||||
|
||||
test('keeps the physical-device deploy/tap test opt-in and executable', () => {
|
||||
const deviceTest = readFileSync(join(ROOT, 'test/skill-e2e-ios-device.test.ts'), 'utf8');
|
||||
expect(deviceTest).toContain("process.env.GSTACK_IOS_DEVICE_DEPLOY === '1'");
|
||||
expect(deviceTest).toContain("'primary-button'");
|
||||
expect(deviceTest).toContain("'/tap'");
|
||||
expect(deviceTest).not.toContain("test.skip('TODO(deploy)");
|
||||
});
|
||||
});
|
||||
@@ -29,4 +29,17 @@ describe("one-way-door credential keyword net (#1839)", () => {
|
||||
expect(classifyQuestion({ summary: `rotate my ${noun}` }).oneWay).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
// revoke/reset/rotate must all share the same credential noun list. Previously
|
||||
// "secret" was only in rotate (missing from revoke and reset) and "access key"
|
||||
// was missing from reset, so "revoke my secret" / "reset my secret" /
|
||||
// "reset my access key" leaked through as two-way (auto-decidable).
|
||||
test("revoke/reset/rotate are all parallel for every credential noun", () => {
|
||||
for (const verb of ["revoke", "reset", "rotate"]) {
|
||||
for (const noun of ["api key", "token", "secret", "credential", "access key", "password"]) {
|
||||
const r = classifyQuestion({ summary: `${verb} my ${noun}` });
|
||||
expect(r.oneWay, `${verb} my ${noun} should be one-way`).toBe(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
// GSTACK_HAS_IOS_DEVICE=1 device-path test. Runs only when:
|
||||
// Real-device tests. The lightweight CoreDevice checks run with
|
||||
// GSTACK_HAS_IOS_DEVICE=1; the signing/install/interaction smoke test has the
|
||||
// separate, explicit GSTACK_IOS_DEVICE_DEPLOY=1 opt-in.
|
||||
//
|
||||
// Runs only when:
|
||||
// - An iPhone is connected via USB and reachable through CoreDevice
|
||||
// - The iPhone is paired (user has tapped "Trust" on the trust dialog)
|
||||
// - Developer Mode is enabled on the iPhone (Settings → Privacy → Developer Mode)
|
||||
@@ -10,57 +14,91 @@
|
||||
// 4. The fixture iOS SPM package builds with `swift build` for iOS target
|
||||
// (verifies the templates compile against the iOS SDK, not just macOS)
|
||||
//
|
||||
// What it does NOT exercise (out of scope for this test):
|
||||
// - Building + signing a full iOS app via xcodebuild (requires provisioning
|
||||
// profile + dev team — environment-specific, not portable across CI)
|
||||
// - Actually deploying + launching the StateServer on the device (same)
|
||||
//
|
||||
// The first three steps prove the CoreDevice path is wired end-to-end on the
|
||||
// agent's side. The fourth proves the Swift templates compile against the
|
||||
// iOS SDK, not just macOS — which catches UIKit/SwiftUI gating bugs before
|
||||
// they reach a real app deployment.
|
||||
// GSTACK_IOS_DEVICE_DEPLOY=1 additionally generates the fixture Xcode project,
|
||||
// signs it with GSTACK_IOS_DEVELOPMENT_TEAM + GSTACK_IOS_BUNDLE_ID, installs
|
||||
// and launches it, then proves screenshot/elements/tap through the real daemon.
|
||||
// It remains skipped in normal CI because signing and a paired iPhone are
|
||||
// intentionally machine-specific.
|
||||
|
||||
import { describe, test, expect } from 'bun:test';
|
||||
import { spawnSync } from 'child_process';
|
||||
import { cpSync, existsSync, mkdtempSync, readFileSync, rmSync, unlinkSync } from 'fs';
|
||||
import { tmpdir } from 'os';
|
||||
import { join } from 'path';
|
||||
import { startDaemon, type RunningDaemon } from '../ios-qa/daemon/src/index';
|
||||
import { startTunnelKeepalive } from '../ios-qa/daemon/src/devicectl';
|
||||
import { bootstrapTunnel } from '../ios-qa/daemon/src/tunnel-bootstrap';
|
||||
import type { DeviceTunnel } from '../ios-qa/daemon/src/proxy';
|
||||
|
||||
const ROOT = join(import.meta.dir, '..');
|
||||
const FIXTURE_PATH = join(ROOT, 'test/fixtures/ios-qa/FixtureApp');
|
||||
|
||||
const HAS_DEVICE = process.env.GSTACK_HAS_IOS_DEVICE === '1';
|
||||
const DEPLOY_TO_DEVICE = process.env.GSTACK_IOS_DEVICE_DEPLOY === '1';
|
||||
const describeIfDevice = HAS_DEVICE ? describe : describe.skip;
|
||||
const testIfDeploy = DEPLOY_TO_DEVICE ? test : test.skip;
|
||||
|
||||
interface DeviceListEntry {
|
||||
identifier: string;
|
||||
state: string; // "available" | "available (pairing)" | "unavailable" | ...
|
||||
name: string;
|
||||
model: string;
|
||||
platform: string;
|
||||
transport: string;
|
||||
paired: boolean;
|
||||
}
|
||||
|
||||
interface DeviceElement {
|
||||
identifier?: string;
|
||||
label?: string;
|
||||
value?: string;
|
||||
frame?: { x: number; y: number; w: number; h: number };
|
||||
}
|
||||
|
||||
interface StateSnapshot {
|
||||
_app_build_id?: string;
|
||||
_accessor_hash?: string;
|
||||
keys?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
function listDevices(): DeviceListEntry[] {
|
||||
// devicectl JSON output requires --json-output to a path. Use a tempfile.
|
||||
const tmp = `/tmp/devicectl-list-${process.pid}-${Date.now()}.json`;
|
||||
const r = spawnSync('xcrun', ['devicectl', 'list', 'devices', '--json-output', tmp], {
|
||||
stdio: 'pipe',
|
||||
timeout: 30_000,
|
||||
});
|
||||
if (r.status !== 0) return [];
|
||||
try {
|
||||
const fs = require('fs');
|
||||
const raw = fs.readFileSync(tmp, 'utf-8');
|
||||
const r = spawnSync('xcrun', ['devicectl', 'list', 'devices', '--json-output', tmp], {
|
||||
stdio: 'pipe',
|
||||
timeout: 30_000,
|
||||
});
|
||||
if (r.status !== 0) return [];
|
||||
const raw = readFileSync(tmp, 'utf-8');
|
||||
const obj = JSON.parse(raw);
|
||||
fs.unlinkSync(tmp);
|
||||
return (obj.result?.devices ?? []).map((d: { identifier: string; connectionProperties: { tunnelState: string }; deviceProperties: { name: string }; hardwareProperties: { productType: string } }) => ({
|
||||
return (obj.result?.devices ?? []).map((d: { identifier: string; connectionProperties: { tunnelState: string; pairingState?: string; transportType?: string }; deviceProperties: { name: string }; hardwareProperties: { productType: string; platform?: string } }) => ({
|
||||
identifier: d.identifier,
|
||||
state: d.connectionProperties?.tunnelState ?? 'unknown',
|
||||
name: d.deviceProperties?.name ?? 'unknown',
|
||||
model: d.hardwareProperties?.productType ?? 'unknown',
|
||||
platform: d.hardwareProperties?.platform ?? 'unknown',
|
||||
transport: d.connectionProperties?.transportType ?? '',
|
||||
paired: d.connectionProperties?.pairingState === 'paired',
|
||||
}));
|
||||
} catch {
|
||||
return [];
|
||||
} finally {
|
||||
try { unlinkSync(tmp); } catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
|
||||
function isAvailableIPhone(device: DeviceListEntry): boolean {
|
||||
const state = device.state.trim().toLowerCase();
|
||||
const available = state === 'connected'
|
||||
|| state.startsWith('available')
|
||||
|| (state === 'disconnected' && device.transport.trim().toLowerCase() === 'wired');
|
||||
return available
|
||||
&& device.paired
|
||||
&& device.platform.toLowerCase() === 'ios'
|
||||
&& device.model.toLowerCase().startsWith('iphone');
|
||||
}
|
||||
|
||||
function isPaired(udid: string): boolean {
|
||||
// devicectl device info processes returns a clean exit when paired.
|
||||
const tmp = `/tmp/devicectl-info-${process.pid}-${Date.now()}.json`;
|
||||
@@ -69,12 +107,146 @@ function isPaired(udid: string): boolean {
|
||||
'-d', udid,
|
||||
'--json-output', tmp,
|
||||
], { stdio: 'pipe', timeout: 30_000 });
|
||||
try { require('fs').unlinkSync(tmp); } catch { /* ignore */ }
|
||||
try { unlinkSync(tmp); } catch { /* ignore */ }
|
||||
// Pair-required errors surface on stderr with "must be paired" or
|
||||
// CoreDeviceError 2. Treat any non-zero exit as not-paired.
|
||||
return r.status === 0;
|
||||
}
|
||||
|
||||
function requireDeployEnv(name: 'GSTACK_IOS_DEVELOPMENT_TEAM' | 'GSTACK_IOS_BUNDLE_ID'): string {
|
||||
const value = process.env[name]?.trim();
|
||||
if (!value) {
|
||||
throw new Error(`${name} is required when GSTACK_IOS_DEVICE_DEPLOY=1`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function runChecked(
|
||||
command: string,
|
||||
args: string[],
|
||||
opts: { cwd?: string; timeout?: number } = {},
|
||||
): string {
|
||||
const result = spawnSync(command, args, {
|
||||
cwd: opts.cwd,
|
||||
env: process.env,
|
||||
stdio: 'pipe',
|
||||
timeout: opts.timeout ?? 60_000,
|
||||
maxBuffer: 32 * 1024 * 1024,
|
||||
});
|
||||
const output = `${result.stdout?.toString() ?? ''}${result.stderr?.toString() ?? ''}`;
|
||||
if (result.error || result.status !== 0) {
|
||||
const tail = output.split('\n').slice(-120).join('\n');
|
||||
throw new Error([
|
||||
`${command} ${args.join(' ')} failed (${result.error?.message ?? `exit ${result.status}`})`,
|
||||
tail,
|
||||
].filter(Boolean).join('\n'));
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
async function daemonJson<T>(
|
||||
baseURL: string,
|
||||
path: string,
|
||||
init: RequestInit = {},
|
||||
): Promise<{ status: number; body: T; raw: string }> {
|
||||
const response = await fetch(`${baseURL}${path}`, {
|
||||
...init,
|
||||
signal: AbortSignal.timeout(60_000),
|
||||
});
|
||||
const raw = await response.text();
|
||||
let body: T;
|
||||
try {
|
||||
body = JSON.parse(raw) as T;
|
||||
} catch {
|
||||
throw new Error(`${init.method ?? 'GET'} ${path} returned non-JSON HTTP ${response.status}: ${raw.slice(0, 500)}`);
|
||||
}
|
||||
return { status: response.status, body, raw };
|
||||
}
|
||||
|
||||
function findElement(elements: DeviceElement[], identifier: string): DeviceElement | undefined {
|
||||
return elements.find((element) =>
|
||||
element.identifier === identifier
|
||||
&& (element.frame?.w ?? 0) > 0
|
||||
&& (element.frame?.h ?? 0) > 0,
|
||||
);
|
||||
}
|
||||
|
||||
type DeviceElementPredicate = (element: DeviceElement) => boolean;
|
||||
|
||||
interface DeviceViewport {
|
||||
w: number;
|
||||
h: number;
|
||||
}
|
||||
|
||||
function isInsideViewport(element: DeviceElement, viewport: DeviceViewport): boolean {
|
||||
const frame = element.frame;
|
||||
if (!frame || frame.w <= 0 || frame.h <= 0) return false;
|
||||
const centerX = frame.x + frame.w / 2;
|
||||
const centerY = frame.y + frame.h / 2;
|
||||
return centerX >= 0 && centerX <= viewport.w && centerY >= 0 && centerY <= viewport.h;
|
||||
}
|
||||
|
||||
async function readDeviceElements(baseURL: string): Promise<DeviceElement[]> {
|
||||
const result = await daemonJson<{ elements?: DeviceElement[] }>(baseURL, '/elements');
|
||||
if (result.status !== 200 || !Array.isArray(result.body.elements)) {
|
||||
throw new Error(`GET /elements failed with HTTP ${result.status}: ${result.raw.slice(0, 500)}`);
|
||||
}
|
||||
return result.body.elements;
|
||||
}
|
||||
|
||||
async function waitForDeviceElement(
|
||||
baseURL: string,
|
||||
predicate: DeviceElementPredicate,
|
||||
description: string,
|
||||
options: {
|
||||
condition?: DeviceElementPredicate;
|
||||
tappableIn?: DeviceViewport;
|
||||
timeoutMs?: number;
|
||||
} = {},
|
||||
): Promise<DeviceElement> {
|
||||
const deadline = Date.now() + (options.timeoutMs ?? 10_000);
|
||||
let lastElements: DeviceElement[] = [];
|
||||
while (Date.now() < deadline) {
|
||||
lastElements = await readDeviceElements(baseURL);
|
||||
const match = lastElements.find((element) =>
|
||||
predicate(element)
|
||||
&& (options.condition?.(element) ?? true)
|
||||
&& (!options.tappableIn || isInsideViewport(element, options.tappableIn)),
|
||||
);
|
||||
if (match) return match;
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
}
|
||||
const visible = lastElements
|
||||
.filter((element) => element.identifier || element.label)
|
||||
.slice(0, 80)
|
||||
.map((element) => element.identifier ?? element.label)
|
||||
.join(', ');
|
||||
throw new Error(`timed out waiting for ${description}; last elements: ${visible}`);
|
||||
}
|
||||
|
||||
async function tapDeviceElement(
|
||||
baseURL: string,
|
||||
sessionId: string,
|
||||
element: DeviceElement,
|
||||
): Promise<void> {
|
||||
const frame = element.frame;
|
||||
if (!frame) throw new Error('cannot tap an element without a frame');
|
||||
const tapped = await daemonJson<{ ok?: boolean; op?: string }>(baseURL, '/tap', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
'x-session-id': sessionId,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
x: frame.x + frame.w / 2,
|
||||
y: frame.y + frame.h / 2,
|
||||
}),
|
||||
});
|
||||
if (tapped.status !== 200 || tapped.body.ok !== true) {
|
||||
throw new Error(`tap failed with HTTP ${tapped.status}: ${tapped.raw.slice(0, 500)}`);
|
||||
}
|
||||
}
|
||||
|
||||
describeIfDevice('ios device path', () => {
|
||||
test('devicectl lists at least one connected device', () => {
|
||||
const devices = listDevices();
|
||||
@@ -104,11 +276,9 @@ describeIfDevice('ios device path', () => {
|
||||
expect(paired.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('fixture Swift package compiles for iOS target', () => {
|
||||
// Use xcrun --sdk iphoneos to get the iOS SDK path, then pass it through
|
||||
// to swift build via SDKROOT. This validates that the Swift templates
|
||||
// (StateServer, DebugBridgeManager, DebugOverlay) compile against the
|
||||
// iOS SDK — catches UIKit/SwiftUI gating bugs that macOS-only builds miss.
|
||||
test('fixture iOS SDK and UIKit compile guards are available', () => {
|
||||
// This is an environment + source-guard preflight. The explicit deployment
|
||||
// test below performs the real signed iOS xcodebuild before installation.
|
||||
const sdkPath = spawnSync('xcrun', ['--sdk', 'iphoneos', '--show-sdk-path'], { stdio: 'pipe' });
|
||||
if (sdkPath.status !== 0) {
|
||||
console.error('iOS SDK not found. Install via Xcode.');
|
||||
@@ -117,16 +287,8 @@ describeIfDevice('ios device path', () => {
|
||||
const sdk = sdkPath.stdout.toString().trim();
|
||||
expect(sdk).toContain('iPhoneOS');
|
||||
|
||||
// Build the DebugBridgeUI target specifically for iOS. We can't use
|
||||
// `swift build --triple arm64-apple-ios` directly because SwiftPM
|
||||
// doesn't ship an iOS toolchain out of the box. The xcodebuild path
|
||||
// requires a project — skip if no .xcodeproj exists.
|
||||
// Instead, verify the iOS-only code compiles by parsing the canImport
|
||||
// guards: if the template's `#if canImport(UIKit)` is wrong, the macOS
|
||||
// build would have failed in the swift-build invariant test. The iOS
|
||||
// SDK path being present is sufficient signal that the toolchain is
|
||||
// installed; the deeper iOS-target build belongs to xcodebuild + a real
|
||||
// app target, which is the "deploy to device" path documented below.
|
||||
// SwiftPM cannot directly cross-build this UIKit package with the standalone
|
||||
// host command, so keep the static guard assertion honest and narrowly named.
|
||||
const fs = require('fs') as typeof import('fs');
|
||||
const overlay = fs.readFileSync(
|
||||
join(FIXTURE_PATH, 'Sources/DebugBridgeUI/DebugOverlay.swift'),
|
||||
@@ -137,26 +299,575 @@ describeIfDevice('ios device path', () => {
|
||||
expect(overlay).toContain('#endif');
|
||||
});
|
||||
|
||||
// Documented next step. Becomes a real test once we have:
|
||||
// - test/fixtures/ios-qa/FixtureApp/FixtureApp.xcodeproj (or generated)
|
||||
// - A signing certificate + provisioning profile on the test machine
|
||||
// - GSTACK_IOS_DEVICE_DEPLOY=1 environment opt-in
|
||||
//
|
||||
// The flow would be:
|
||||
// xcodebuild -scheme FixtureApp -destination 'platform=iOS,id=<UDID>' \
|
||||
// -allowProvisioningUpdates build install
|
||||
// xcrun devicectl device process launch -d <UDID> --console <bundle-id>
|
||||
// # Scrape boot token from os_log
|
||||
// curl http://[<corodevice-ipv6>]:9999/healthz
|
||||
// # ... full smoke loop ...
|
||||
test.skip('TODO(deploy): build + deploy fixture to device + smoke test full StateServer loop', () => {});
|
||||
});
|
||||
|
||||
describe('ios device deployment (explicit opt-in)', () => {
|
||||
testIfDeploy('generates, signs, installs, launches, and drives the fixture through the daemon', async () => {
|
||||
const developmentTeam = requireDeployEnv('GSTACK_IOS_DEVELOPMENT_TEAM');
|
||||
const bundleId = requireDeployEnv('GSTACK_IOS_BUNDLE_ID');
|
||||
const devices = listDevices();
|
||||
const device = devices.find((candidate) => isAvailableIPhone(candidate) && isPaired(candidate.identifier));
|
||||
if (!device) {
|
||||
const summary = devices.length > 0
|
||||
? devices.map((d) => ` ${d.name} (${d.model}, ${d.platform}, ${d.identifier}): state=${d.state}, paired=${d.paired}`).join('\n')
|
||||
: ' devicectl returned no devices';
|
||||
throw new Error([
|
||||
'GSTACK_IOS_DEVICE_DEPLOY=1 requires an available, paired iPhone; stale unavailable devices are never selected.',
|
||||
summary,
|
||||
].join('\n'));
|
||||
}
|
||||
|
||||
const workDir = mkdtempSync(join(tmpdir(), 'gstack-ios-device-deploy-'));
|
||||
const fixtureDir = join(workDir, 'FixtureApp');
|
||||
const derivedData = join(workDir, 'DerivedData');
|
||||
let daemon: RunningDaemon | undefined;
|
||||
let keepalive: { stop: () => void } | undefined;
|
||||
let sessionId: string | undefined;
|
||||
|
||||
try {
|
||||
cpSync(FIXTURE_PATH, fixtureDir, { recursive: true });
|
||||
|
||||
// Exercise the same deterministic bootstrap that /ios-qa and /ios-sync
|
||||
// install for users. This creates the app-owned typed accessor before
|
||||
// XcodeGen discovers the fixture sources.
|
||||
runChecked(join(ROOT, 'bin/gstack-ios-qa-regen'), [
|
||||
'--app-source', join(fixtureDir, 'Sources/FixtureApp'),
|
||||
'--bridge-dir', fixtureDir,
|
||||
], { cwd: fixtureDir });
|
||||
const generatedAccessor = join(
|
||||
fixtureDir,
|
||||
'Sources/FixtureApp/DebugBridgeGenerated/StateAccessor.swift',
|
||||
);
|
||||
expect(existsSync(generatedAccessor)).toBe(true);
|
||||
expect(readFileSync(generatedAccessor, 'utf8')).toContain('enum FixtureAppStateAccessor');
|
||||
|
||||
runChecked('xcodegen', [
|
||||
'generate',
|
||||
'--spec', join(fixtureDir, 'project.yml'),
|
||||
'--project', fixtureDir,
|
||||
'--project-root', fixtureDir,
|
||||
], { cwd: fixtureDir });
|
||||
|
||||
const projectPath = join(fixtureDir, 'FixtureApp.xcodeproj');
|
||||
expect(existsSync(projectPath)).toBe(true);
|
||||
|
||||
runChecked('xcodebuild', [
|
||||
'-project', projectPath,
|
||||
'-scheme', 'FixtureApp',
|
||||
'-configuration', 'Debug',
|
||||
'-destination', `platform=iOS,id=${device.identifier}`,
|
||||
'-derivedDataPath', derivedData,
|
||||
'-allowProvisioningUpdates',
|
||||
`DEVELOPMENT_TEAM=${developmentTeam}`,
|
||||
`PRODUCT_BUNDLE_IDENTIFIER=${bundleId}`,
|
||||
'CODE_SIGN_STYLE=Automatic',
|
||||
'build',
|
||||
], { cwd: fixtureDir, timeout: 300_000 });
|
||||
|
||||
const appBundle = join(derivedData, 'Build/Products/Debug-iphoneos/FixtureApp.app');
|
||||
expect(existsSync(appBundle)).toBe(true);
|
||||
const builtBundleId = runChecked('/usr/libexec/PlistBuddy', [
|
||||
'-c', 'Print :CFBundleIdentifier',
|
||||
join(appBundle, 'Info.plist'),
|
||||
]).trim();
|
||||
expect(builtBundleId).toBe(bundleId);
|
||||
|
||||
runChecked('xcrun', [
|
||||
'devicectl', 'device', 'install', 'app',
|
||||
'--device', device.identifier,
|
||||
appBundle,
|
||||
], { timeout: 120_000 });
|
||||
|
||||
runChecked('xcrun', [
|
||||
'devicectl', 'device', 'process', 'launch',
|
||||
'--device', device.identifier,
|
||||
'--terminate-existing',
|
||||
bundleId,
|
||||
], { timeout: 60_000 });
|
||||
|
||||
keepalive = startTunnelKeepalive(device.identifier);
|
||||
const bootstrap = await bootstrapTunnel({
|
||||
udid: device.identifier,
|
||||
bundleId,
|
||||
startupTimeoutMs: 30_000,
|
||||
});
|
||||
if (!bootstrap.ok) {
|
||||
throw new Error(`daemon tunnel bootstrap failed: ${bootstrap.error}${bootstrap.detail ? ` (${bootstrap.detail})` : ''}`);
|
||||
}
|
||||
|
||||
// The first provider call consumes the already-rotated bootstrap. Later
|
||||
// calls perform a fresh bootstrap so the same daemon can recover after
|
||||
// this app is relaunched and its in-memory bearer changes.
|
||||
let pendingTunnel: DeviceTunnel | undefined = bootstrap.tunnel;
|
||||
let tunnelProviderCalls = 0;
|
||||
const provideTunnel = async (): Promise<DeviceTunnel | null> => {
|
||||
tunnelProviderCalls += 1;
|
||||
if (pendingTunnel) {
|
||||
const first = pendingTunnel;
|
||||
pendingTunnel = undefined;
|
||||
return first;
|
||||
}
|
||||
const refreshed = await bootstrapTunnel({
|
||||
udid: device.identifier,
|
||||
bundleId,
|
||||
startupTimeoutMs: 30_000,
|
||||
});
|
||||
if (!refreshed.ok) {
|
||||
throw new Error(`daemon rebootstrap failed: ${refreshed.error}${refreshed.detail ? ` (${refreshed.detail})` : ''}`);
|
||||
}
|
||||
return refreshed.tunnel;
|
||||
};
|
||||
|
||||
const started = await startDaemon({
|
||||
loopbackPort: 0,
|
||||
tailnetEnabled: false,
|
||||
pidfilePath: join(workDir, 'daemon.pid'),
|
||||
tunnelProvider: provideTunnel,
|
||||
});
|
||||
if ('error' in started) {
|
||||
throw new Error(`daemon failed to start: ${started.error}${started.reason ? ` (${started.reason})` : ''}`);
|
||||
}
|
||||
daemon = started;
|
||||
const baseURL = `http://127.0.0.1:${daemon.loopbackPort}`;
|
||||
|
||||
const initialState = await daemonJson<StateSnapshot>(baseURL, '/state/snapshot');
|
||||
expect(initialState.status).toBe(200);
|
||||
expect(typeof initialState.body._app_build_id).toBe('string');
|
||||
expect(initialState.body._app_build_id).not.toBe('unknown');
|
||||
expect(initialState.body._app_build_id).not.toBe('uninitialized');
|
||||
expect(initialState.body._accessor_hash).toMatch(/^[a-f0-9]{64}$/);
|
||||
expect(initialState.body._accessor_hash).not.toBe('uninitialized');
|
||||
expect(Object.keys(initialState.body.keys ?? {}).sort()).toEqual([
|
||||
'isLoggedIn',
|
||||
'nickname',
|
||||
'tapCounter',
|
||||
'username',
|
||||
]);
|
||||
expect(initialState.body.keys?.nickname).toBeNull();
|
||||
|
||||
const screenshot = await daemonJson<{ png_base64?: string; error?: string }>(baseURL, '/screenshot');
|
||||
expect(screenshot.status).toBe(200);
|
||||
expect(typeof screenshot.body.png_base64).toBe('string');
|
||||
const png = Buffer.from(screenshot.body.png_base64!, 'base64');
|
||||
expect(png.length).toBeGreaterThan(1_000);
|
||||
expect([...png.subarray(0, 8)]).toEqual([137, 80, 78, 71, 13, 10, 26, 10]);
|
||||
|
||||
const requiredIdentifiers = [
|
||||
'primary-button',
|
||||
'toolbar-actions-menu',
|
||||
'open-detail-button',
|
||||
'tab-controls',
|
||||
'tab-inputs',
|
||||
'tab-rows',
|
||||
];
|
||||
let elementsBefore: DeviceElement[] = [];
|
||||
let identifiers = new Set<string>();
|
||||
const elementsDeadline = Date.now() + 10_000;
|
||||
while (Date.now() < elementsDeadline) {
|
||||
const before = await daemonJson<{ elements?: DeviceElement[] }>(baseURL, '/elements');
|
||||
expect(before.status).toBe(200);
|
||||
expect(Array.isArray(before.body.elements)).toBe(true);
|
||||
elementsBefore = before.body.elements ?? [];
|
||||
identifiers = new Set(
|
||||
elementsBefore.map((element) => element.identifier).filter((value): value is string => Boolean(value)),
|
||||
);
|
||||
if (requiredIdentifiers.every((identifier) => identifiers.has(identifier))) break;
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
}
|
||||
expect(elementsBefore.length).toBeGreaterThan(30);
|
||||
expect(requiredIdentifiers.filter((identifier) => !identifiers.has(identifier))).toEqual([]);
|
||||
const appFrame = findElement(elementsBefore, 'fixture-tab-view')?.frame;
|
||||
expect(appFrame).toBeDefined();
|
||||
// Screenshot pixels and /tap coordinates must share UIKit's point
|
||||
// space. A 3x PNG here recreates the original missed-tap bug.
|
||||
expect(png.readUInt32BE(16)).toBe(appFrame!.w);
|
||||
expect(png.readUInt32BE(20)).toBe(appFrame!.h);
|
||||
|
||||
const buttonBefore = findElement(elementsBefore, 'primary-button');
|
||||
expect(buttonBefore).toBeDefined();
|
||||
expect(typeof buttonBefore!.value).toBe('string');
|
||||
|
||||
const acquired = await daemonJson<{ session_id?: string }>(baseURL, '/session/acquire', { method: 'POST' });
|
||||
expect(acquired.status).toBe(200);
|
||||
expect(typeof acquired.body.session_id).toBe('string');
|
||||
sessionId = acquired.body.session_id!;
|
||||
|
||||
const rejectedBooleanAsInteger = await daemonJson<{ error?: string }>(baseURL, '/state/tapCounter', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
'x-session-id': sessionId,
|
||||
},
|
||||
body: JSON.stringify({ value: true }),
|
||||
});
|
||||
expect(rejectedBooleanAsInteger.status).toBe(400);
|
||||
expect(rejectedBooleanAsInteger.body.error).toBe('type_mismatch');
|
||||
|
||||
const rejectedIntegerAsBoolean = await daemonJson<{ error?: string }>(baseURL, '/state/isLoggedIn', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
'x-session-id': sessionId,
|
||||
},
|
||||
body: JSON.stringify({ value: 1 }),
|
||||
});
|
||||
expect(rejectedIntegerAsBoolean.status).toBe(400);
|
||||
expect(rejectedIntegerAsBoolean.body.error).toBe('type_mismatch');
|
||||
const afterRejectedCoercions = await daemonJson<StateSnapshot>(baseURL, '/state/snapshot');
|
||||
expect(afterRejectedCoercions.body.keys?.tapCounter).toBe(0);
|
||||
expect(afterRejectedCoercions.body.keys?.isLoggedIn).toBe(false);
|
||||
|
||||
const wroteState = await daemonJson<{ ok?: boolean }>(baseURL, '/state/tapCounter', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
'x-session-id': sessionId,
|
||||
},
|
||||
body: JSON.stringify({ value: 7 }),
|
||||
});
|
||||
expect(wroteState.status).toBe(200);
|
||||
expect(wroteState.body).toEqual({ ok: true });
|
||||
const updatedState = await daemonJson<StateSnapshot>(baseURL, '/state/snapshot');
|
||||
expect(updatedState.status).toBe(200);
|
||||
expect(updatedState.body.keys?.tapCounter).toBe(7);
|
||||
|
||||
const wroteOptional = await daemonJson<{ ok?: boolean }>(baseURL, '/state/nickname', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
'x-session-id': sessionId,
|
||||
},
|
||||
body: JSON.stringify({ value: 'Device' }),
|
||||
});
|
||||
expect(wroteOptional.status).toBe(200);
|
||||
const optionalValue = await daemonJson<StateSnapshot>(baseURL, '/state/snapshot');
|
||||
expect(optionalValue.body.keys?.nickname).toBe('Device');
|
||||
|
||||
const clearedOptional = await daemonJson<{ ok?: boolean }>(baseURL, '/state/nickname', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
'x-session-id': sessionId,
|
||||
},
|
||||
body: JSON.stringify({ value: null }),
|
||||
});
|
||||
expect(clearedOptional.status).toBe(200);
|
||||
const clearedValue = await daemonJson<StateSnapshot>(baseURL, '/state/snapshot');
|
||||
expect(clearedValue.body.keys?.nickname).toBeNull();
|
||||
|
||||
const restoredState = await daemonJson<{ ok?: boolean }>(baseURL, '/state/restore', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
'x-session-id': sessionId,
|
||||
},
|
||||
body: JSON.stringify(initialState.body),
|
||||
});
|
||||
expect(restoredState.status).toBe(200);
|
||||
expect(restoredState.body).toEqual({ ok: true });
|
||||
const afterRestore = await daemonJson<StateSnapshot>(baseURL, '/state/snapshot');
|
||||
expect(afterRestore.body.keys?.tapCounter).toBe(0);
|
||||
expect(afterRestore.body.keys?.nickname).toBeNull();
|
||||
|
||||
const viewport = { w: appFrame!.w, h: appFrame!.h };
|
||||
const byIdentifier = (identifier: string): DeviceElementPredicate =>
|
||||
(element) => element.identifier === identifier;
|
||||
const byLabel = (label: string): DeviceElementPredicate =>
|
||||
(element) => element.label?.trim() === label;
|
||||
|
||||
const tapAndWaitForValueChange = async (
|
||||
target: DeviceElementPredicate,
|
||||
oracle: DeviceElementPredicate,
|
||||
description: string,
|
||||
): Promise<DeviceElement> => {
|
||||
const before = await waitForDeviceElement(
|
||||
baseURL,
|
||||
oracle,
|
||||
`${description} oracle before tap`,
|
||||
{ condition: (element) => typeof element.value === 'string' },
|
||||
);
|
||||
const targetElement = await waitForDeviceElement(
|
||||
baseURL,
|
||||
target,
|
||||
`${description} target`,
|
||||
{ tappableIn: viewport },
|
||||
);
|
||||
await tapDeviceElement(baseURL, sessionId!, targetElement);
|
||||
const after = await waitForDeviceElement(
|
||||
baseURL,
|
||||
oracle,
|
||||
`${description} value change`,
|
||||
{ condition: (element) => typeof element.value === 'string' && element.value !== before.value },
|
||||
);
|
||||
expect(after.value).not.toBe(before.value);
|
||||
return after;
|
||||
};
|
||||
|
||||
const firstInteger = (value: string | undefined): number | undefined => {
|
||||
const match = value?.match(/-?\d+/);
|
||||
return match ? Number(match[0]) : undefined;
|
||||
};
|
||||
|
||||
const tapAndWaitForCountIncrement = async (
|
||||
target: DeviceElementPredicate,
|
||||
oracle: DeviceElementPredicate,
|
||||
description: string,
|
||||
): Promise<DeviceElement> => {
|
||||
const before = await waitForDeviceElement(
|
||||
baseURL,
|
||||
oracle,
|
||||
`${description} counter before tap`,
|
||||
{ condition: (element) => firstInteger(element.value) !== undefined },
|
||||
);
|
||||
const beforeCount = firstInteger(before.value)!;
|
||||
const targetElement = await waitForDeviceElement(
|
||||
baseURL,
|
||||
target,
|
||||
`${description} target`,
|
||||
{ tappableIn: viewport },
|
||||
);
|
||||
await tapDeviceElement(baseURL, sessionId!, targetElement);
|
||||
const after = await waitForDeviceElement(
|
||||
baseURL,
|
||||
oracle,
|
||||
`${description} exactly-once counter increment`,
|
||||
{ condition: (element) => firstInteger(element.value) === beforeCount + 1 },
|
||||
);
|
||||
expect(firstInteger(after.value)).toBe(beforeCount + 1);
|
||||
return after;
|
||||
};
|
||||
|
||||
// SwiftUI button styles and both navigation-bar controls.
|
||||
for (const identifier of [
|
||||
'primary-button',
|
||||
'bordered-button',
|
||||
'plain-button',
|
||||
'destructive-button',
|
||||
'nav-refresh-button',
|
||||
]) {
|
||||
await tapAndWaitForCountIncrement(
|
||||
byIdentifier(identifier),
|
||||
byIdentifier(identifier),
|
||||
identifier,
|
||||
);
|
||||
}
|
||||
|
||||
// Menu presentation and both menu commands. The menu's own value is the
|
||||
// stable oracle after each transient command element disappears.
|
||||
for (const commandIdentifier of ['menu-add-item', 'menu-archive-item']) {
|
||||
const menuBefore = await waitForDeviceElement(
|
||||
baseURL,
|
||||
byIdentifier('toolbar-actions-menu'),
|
||||
'toolbar menu value',
|
||||
{ condition: (element) => typeof element.value === 'string' },
|
||||
);
|
||||
const menu = await waitForDeviceElement(
|
||||
baseURL,
|
||||
byIdentifier('toolbar-actions-menu'),
|
||||
'toolbar actions menu',
|
||||
{ tappableIn: viewport },
|
||||
);
|
||||
await tapDeviceElement(baseURL, sessionId, menu);
|
||||
const command = await waitForDeviceElement(
|
||||
baseURL,
|
||||
byIdentifier(commandIdentifier),
|
||||
commandIdentifier,
|
||||
{ tappableIn: viewport },
|
||||
);
|
||||
await tapDeviceElement(baseURL, sessionId, command);
|
||||
const beforeCounts = [...(menuBefore.value?.matchAll(/\d+/g) ?? [])].map((match) => Number(match[0]));
|
||||
expect(beforeCounts).toHaveLength(2);
|
||||
const changedIndex = commandIdentifier === 'menu-add-item' ? 0 : 1;
|
||||
const expectedCounts = beforeCounts.map((count, index) => count + (index === changedIndex ? 1 : 0));
|
||||
const menuAfter = await waitForDeviceElement(
|
||||
baseURL,
|
||||
byIdentifier('toolbar-actions-menu'),
|
||||
`${commandIdentifier} exactly-once result`,
|
||||
{
|
||||
condition: (element) => {
|
||||
const counts = [...(element.value?.matchAll(/\d+/g) ?? [])].map((match) => Number(match[0]));
|
||||
return counts.length === 2 && counts.every((count, index) => count === expectedCounts[index]);
|
||||
},
|
||||
},
|
||||
);
|
||||
const afterCounts = [...(menuAfter.value?.matchAll(/\d+/g) ?? [])].map((match) => Number(match[0]));
|
||||
expect(afterCounts).toEqual(expectedCounts);
|
||||
}
|
||||
|
||||
// Push, interact with, and pop the explicit navigation destination.
|
||||
const openDetail = await waitForDeviceElement(
|
||||
baseURL,
|
||||
byIdentifier('open-detail-button'),
|
||||
'open detail button',
|
||||
{ tappableIn: viewport },
|
||||
);
|
||||
await tapDeviceElement(baseURL, sessionId, openDetail);
|
||||
await waitForDeviceElement(baseURL, byIdentifier('detail-screen-title'), 'detail destination');
|
||||
await tapAndWaitForCountIncrement(
|
||||
byIdentifier('detail-action-button'),
|
||||
byIdentifier('detail-action-button'),
|
||||
'detail action button',
|
||||
);
|
||||
const back = await waitForDeviceElement(
|
||||
baseURL,
|
||||
byIdentifier('detail-back-button'),
|
||||
'detail back button',
|
||||
{ tappableIn: viewport },
|
||||
);
|
||||
await tapDeviceElement(baseURL, sessionId, back);
|
||||
await waitForDeviceElement(baseURL, byIdentifier('primary-button'), 'controls after detail pop');
|
||||
|
||||
// Tab navigation plus native toggle, stepper, segmented picker, text
|
||||
// input/commit, and a UIKit UIButton.
|
||||
const inputsTab = await waitForDeviceElement(
|
||||
baseURL,
|
||||
byIdentifier('tab-inputs'),
|
||||
'Inputs tab',
|
||||
{ tappableIn: viewport },
|
||||
);
|
||||
await tapDeviceElement(baseURL, sessionId, inputsTab);
|
||||
await waitForDeviceElement(baseURL, byIdentifier('harness-toggle'), 'Inputs controls');
|
||||
await tapAndWaitForValueChange(
|
||||
byIdentifier('harness-toggle'),
|
||||
byIdentifier('harness-toggle'),
|
||||
'toggle',
|
||||
);
|
||||
await tapAndWaitForCountIncrement(
|
||||
byIdentifier('harness-stepper-Increment'),
|
||||
byIdentifier('harness-stepper'),
|
||||
'stepper increment',
|
||||
);
|
||||
const segmentTwo = await waitForDeviceElement(
|
||||
baseURL,
|
||||
byLabel('Two'),
|
||||
'segmented picker option Two',
|
||||
{ tappableIn: viewport },
|
||||
);
|
||||
await tapDeviceElement(baseURL, sessionId, segmentTwo);
|
||||
const selectedTwo = await waitForDeviceElement(
|
||||
baseURL,
|
||||
byIdentifier('harness-segmented-picker'),
|
||||
'segmented picker selection',
|
||||
{ condition: (element) => element.value?.includes('Two') === true },
|
||||
);
|
||||
expect(selectedTwo.value).toContain('Two');
|
||||
|
||||
const textField = await waitForDeviceElement(
|
||||
baseURL,
|
||||
byIdentifier('harness-text-field'),
|
||||
'text field',
|
||||
{ tappableIn: viewport },
|
||||
);
|
||||
await tapDeviceElement(baseURL, sessionId, textField);
|
||||
const typed = await daemonJson<{ ok?: boolean; op?: string }>(baseURL, '/type', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
'x-session-id': sessionId,
|
||||
},
|
||||
body: JSON.stringify({ text: 'device matrix' }),
|
||||
});
|
||||
expect(typed.status).toBe(200);
|
||||
expect(typed.body).toMatchObject({ op: 'type', ok: true });
|
||||
await waitForDeviceElement(
|
||||
baseURL,
|
||||
byIdentifier('harness-text-field'),
|
||||
'typed text value',
|
||||
{ condition: (element) => element.value === 'device matrix' },
|
||||
);
|
||||
await tapAndWaitForCountIncrement(
|
||||
byIdentifier('commit-text-button'),
|
||||
byIdentifier('commit-text-button'),
|
||||
'text commit button',
|
||||
);
|
||||
// Commit clears FocusState; let the keyboard dismissal animation finish
|
||||
// before choosing a scroll-view hit point for the UIKit control below.
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
|
||||
const scrollInputs = await daemonJson<{ ok?: boolean; op?: string }>(baseURL, '/swipe', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
'x-session-id': sessionId,
|
||||
},
|
||||
body: JSON.stringify({ from_x: 200, from_y: 650, to_x: 200, to_y: 220 }),
|
||||
});
|
||||
expect(scrollInputs.status).toBe(200);
|
||||
expect(scrollInputs.body).toMatchObject({ op: 'swipe', ok: true });
|
||||
await tapAndWaitForCountIncrement(
|
||||
byIdentifier('uikit-button'),
|
||||
byIdentifier('uikit-button'),
|
||||
'UIKit button',
|
||||
);
|
||||
|
||||
// All four list rows and the row navigation-bar action.
|
||||
const rowsTab = await waitForDeviceElement(
|
||||
baseURL,
|
||||
byIdentifier('tab-rows'),
|
||||
'Rows tab',
|
||||
{ tappableIn: viewport },
|
||||
);
|
||||
await tapDeviceElement(baseURL, sessionId, rowsTab);
|
||||
await waitForDeviceElement(baseURL, byIdentifier('row-alpha-button'), 'Rows list');
|
||||
for (const row of ['alpha', 'bravo', 'charlie', 'delta']) {
|
||||
await tapAndWaitForCountIncrement(
|
||||
byIdentifier(`row-${row}-button`),
|
||||
byIdentifier(`row-${row}-button`),
|
||||
`${row} row`,
|
||||
);
|
||||
}
|
||||
await tapAndWaitForCountIncrement(
|
||||
byIdentifier('rows-toolbar-button'),
|
||||
byIdentifier('rows-toolbar-button'),
|
||||
'rows toolbar button',
|
||||
);
|
||||
|
||||
const controlsTab = await waitForDeviceElement(
|
||||
baseURL,
|
||||
byIdentifier('tab-controls'),
|
||||
'Controls tab',
|
||||
{ tappableIn: viewport },
|
||||
);
|
||||
await tapDeviceElement(baseURL, sessionId, controlsTab);
|
||||
await waitForDeviceElement(baseURL, byIdentifier('primary-button'), 'Controls tab restored');
|
||||
|
||||
// Keep the daemon alive while the app process gets a new boot token.
|
||||
// The first proxied request must observe the stale bearer, invalidate
|
||||
// only that tunnel, single-flight a fresh bootstrap, and retry once.
|
||||
runChecked('xcrun', [
|
||||
'devicectl', 'device', 'process', 'launch',
|
||||
'--device', device.identifier,
|
||||
'--terminate-existing',
|
||||
bundleId,
|
||||
], { timeout: 60_000 });
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
const afterRelaunch = await daemonJson<{ png_base64?: string; error?: string }>(baseURL, '/screenshot');
|
||||
expect(afterRelaunch.status).toBe(200);
|
||||
expect(Buffer.from(afterRelaunch.body.png_base64 ?? '', 'base64').length).toBeGreaterThan(1_000);
|
||||
expect(tunnelProviderCalls).toBe(2);
|
||||
const stateAfterRelaunch = await daemonJson<StateSnapshot>(baseURL, '/state/snapshot');
|
||||
expect(stateAfterRelaunch.status).toBe(200);
|
||||
expect(stateAfterRelaunch.body._accessor_hash).toBe(initialState.body._accessor_hash);
|
||||
} finally {
|
||||
if (daemon && sessionId) {
|
||||
await daemonJson(`http://127.0.0.1:${daemon.loopbackPort}`, '/session/release', { method: 'POST' }).catch(() => undefined);
|
||||
}
|
||||
if (daemon) await daemon.close();
|
||||
keepalive?.stop();
|
||||
rmSync(workDir, { recursive: true, force: true });
|
||||
}
|
||||
}, 600_000);
|
||||
});
|
||||
|
||||
// Always-on instructions if not paired. Surfaces actionable steps even when
|
||||
// the test is opted in via env var but the device isn't ready.
|
||||
if (HAS_DEVICE) {
|
||||
const devices = listDevices();
|
||||
const unpaired = devices.filter(d => !isPaired(d.identifier));
|
||||
const unpaired = devices.filter(d =>
|
||||
d.platform.toLowerCase() === 'ios'
|
||||
&& d.model.toLowerCase().startsWith('iphone')
|
||||
&& !d.paired,
|
||||
);
|
||||
if (unpaired.length > 0) {
|
||||
console.error('');
|
||||
console.error('=== iOS DEVICE PAIRING REQUIRED ===');
|
||||
|
||||
@@ -19,38 +19,90 @@
|
||||
|
||||
import { describe, test, expect } from 'bun:test';
|
||||
import { spawnSync } from 'child_process';
|
||||
import { existsSync, readFileSync } from 'fs';
|
||||
import { readFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
|
||||
const ROOT = join(import.meta.dir, '..');
|
||||
const FIXTURE_PATH = join(ROOT, 'test/fixtures/ios-qa/FixtureApp');
|
||||
const TEMPLATES_PATH = join(ROOT, 'ios-qa/templates');
|
||||
const GEN_ACCESSORS_PACKAGE = join(ROOT, 'ios-qa/scripts/gen-accessors-tool/Package.swift');
|
||||
|
||||
// Parity: canonical Obj-C touch templates must match the fixture's working
|
||||
// copy. The fixture is the only place the .m / .h are exercised end-to-end
|
||||
// on a real device, so any divergence means consuming apps would ship a
|
||||
// stale, untested version of the SwiftUI hit-test fix.
|
||||
const COPIED_BRIDGE_TEMPLATES = [
|
||||
['StateServer.swift.template', 'Sources/DebugBridgeCore/StateServer.swift'],
|
||||
['DebugBridgeManager.swift.template', 'Sources/DebugBridgeCore/DebugBridgeManager.swift'],
|
||||
['DebugOverlay.swift.template', 'Sources/DebugBridgeUI/DebugOverlay.swift'],
|
||||
['Bridges.swift.template', 'Sources/DebugBridgeUI/Bridges.swift'],
|
||||
['DebugBridgeTouch.h.template', 'Sources/DebugBridgeTouch/include/DebugBridgeTouch.h'],
|
||||
['DebugBridgeTouch.m.template', 'Sources/DebugBridgeTouch/DebugBridgeTouch.m'],
|
||||
] as const;
|
||||
|
||||
function readTemplate(name: string): string {
|
||||
return readFileSync(join(TEMPLATES_PATH, name), 'utf-8');
|
||||
}
|
||||
|
||||
function normalizeBridgePackage(source: string): string {
|
||||
// Package.swift.template has a generated-file prologue while the fixture has
|
||||
// a fixture-specific one. The tools-version declaration must remain first in
|
||||
// both real files, but neither header is part of the copied bridge surface.
|
||||
const importOffset = source.indexOf('import PackageDescription');
|
||||
expect(importOffset).toBeGreaterThanOrEqual(0);
|
||||
let packageBody = source.slice(importOffset);
|
||||
|
||||
// The fixture deliberately has its own package identity and XCTest target.
|
||||
// Normalize only those fixture concerns; all three bridge products, targets,
|
||||
// dependencies, settings, and paths must otherwise stay in lockstep.
|
||||
packageBody = packageBody.replace(
|
||||
/(let package = Package\(\s*name:)\s*"[^"]+"/,
|
||||
'$1 "<bridge-package>"',
|
||||
);
|
||||
packageBody = packageBody.replace(
|
||||
/\n\s*\.testTarget\(\s*\n\s*name:\s*"DebugBridgeCoreTests",[\s\S]*?\n\s{8}\),?/,
|
||||
'',
|
||||
);
|
||||
|
||||
// Ignore prose and formatting so a template-only explanatory comment does
|
||||
// not conceal a meaningful manifest mismatch.
|
||||
return packageBody
|
||||
.replace(/\/\/.*$/gm, '')
|
||||
.replace(/\s+/g, '')
|
||||
.replace(/,([\])])/g, '$1');
|
||||
}
|
||||
|
||||
function escapeRegExp(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
function bracedBlock(source: string, openBraceOffset: number): string {
|
||||
let depth = 0;
|
||||
for (let offset = openBraceOffset; offset < source.length; offset++) {
|
||||
if (source[offset] === '{') depth++;
|
||||
if (source[offset] !== '}') continue;
|
||||
depth--;
|
||||
if (depth === 0) return source.slice(openBraceOffset, offset + 1);
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
// The fixture is where the bridge is compiled and exercised end-to-end. Every
|
||||
// source copied into consuming apps must therefore be the canonical template,
|
||||
// or device QA can pass against code that /ios-qa never installs.
|
||||
describe('template ↔ fixture parity', () => {
|
||||
test('DebugBridgeTouch.h.template matches fixture include', () => {
|
||||
const tmpl = readFileSync(join(TEMPLATES_PATH, 'DebugBridgeTouch.h.template'), 'utf-8');
|
||||
const fixture = readFileSync(
|
||||
join(FIXTURE_PATH, 'Sources/DebugBridgeTouch/include/DebugBridgeTouch.h'),
|
||||
'utf-8',
|
||||
);
|
||||
expect(tmpl).toBe(fixture);
|
||||
});
|
||||
for (const [templateName, fixtureDestination] of COPIED_BRIDGE_TEMPLATES) {
|
||||
test(`${templateName} matches ${fixtureDestination}`, () => {
|
||||
expect(readTemplate(templateName)).toBe(
|
||||
readFileSync(join(FIXTURE_PATH, fixtureDestination), 'utf-8'),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
test('DebugBridgeTouch.m.template matches fixture .m', () => {
|
||||
const tmpl = readFileSync(join(TEMPLATES_PATH, 'DebugBridgeTouch.m.template'), 'utf-8');
|
||||
const fixture = readFileSync(
|
||||
join(FIXTURE_PATH, 'Sources/DebugBridgeTouch/DebugBridgeTouch.m'),
|
||||
'utf-8',
|
||||
);
|
||||
expect(tmpl).toBe(fixture);
|
||||
test('Package.swift bridge declarations match after fixture-only normalization', () => {
|
||||
const template = readTemplate('Package.swift.template');
|
||||
const fixture = readFileSync(join(FIXTURE_PATH, 'Package.swift'), 'utf-8');
|
||||
expect(normalizeBridgePackage(template)).toBe(normalizeBridgePackage(fixture));
|
||||
});
|
||||
|
||||
test('Package.swift.template declares all 3 DebugBridge targets', () => {
|
||||
const tmpl = readFileSync(join(TEMPLATES_PATH, 'Package.swift.template'), 'utf-8');
|
||||
const tmpl = readTemplate('Package.swift.template');
|
||||
// Each target must be present as a library product AND a target definition.
|
||||
for (const name of ['DebugBridgeCore', 'DebugBridgeUI', 'DebugBridgeTouch']) {
|
||||
expect(tmpl).toContain(`name: "${name}"`);
|
||||
@@ -59,6 +111,190 @@ describe('template ↔ fixture parity', () => {
|
||||
// app gets the transitive set with one dependency entry.
|
||||
expect(tmpl).toMatch(/name:\s*"DebugBridgeUI"[\s\S]*?dependencies:\s*\["DebugBridgeCore",\s*"DebugBridgeTouch"\]/);
|
||||
});
|
||||
|
||||
test('generated Swift packages only reference shipped test directories', () => {
|
||||
const genAccessorsPackage = readFileSync(GEN_ACCESSORS_PACKAGE, 'utf-8');
|
||||
const debugBridgePackage = readFileSync(join(TEMPLATES_PATH, 'Package.swift.template'), 'utf-8');
|
||||
|
||||
expect(genAccessorsPackage).not.toContain('Tests/GenAccessorsTests');
|
||||
expect(debugBridgePackage).not.toContain('Tests/DebugBridgeCoreTests');
|
||||
});
|
||||
|
||||
test('Package.swift.template keeps swift-tools-version on the first line', () => {
|
||||
const tmpl = readTemplate('Package.swift.template');
|
||||
expect(tmpl.split(/\r?\n/, 1)[0]).toBe('// swift-tools-version:5.9');
|
||||
});
|
||||
});
|
||||
|
||||
describe('iOS tap harness regressions', () => {
|
||||
test('manager receives app-owned generated accessors instead of a no-op package stub', () => {
|
||||
const manager = readTemplate('DebugBridgeManager.swift.template');
|
||||
const wiring = readTemplate('DebugBridgeWiring.swift.template');
|
||||
const fixtureApp = readFileSync(
|
||||
join(FIXTURE_PATH, 'Sources/FixtureApp/FixtureAppApp.swift'),
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
expect(manager).toContain('func start<State>');
|
||||
expect(manager).toContain('register: (State) -> Void');
|
||||
expect(manager).toContain('register(appState)');
|
||||
expect(manager).not.toContain('public enum AppStateAccessor');
|
||||
expect(manager).not.toContain('protocol AppState');
|
||||
|
||||
expect(wiring).toContain('import DebugBridgeCore');
|
||||
expect(wiring).toContain('import DebugBridgeUI');
|
||||
expect(wiring).toContain('DebugBridgeUIWiring.installAll()');
|
||||
expect(wiring.indexOf('DebugBridgeUIWiring.installAll()')).toBeLessThan(
|
||||
wiring.indexOf('DebugBridgeManager.shared.start'),
|
||||
);
|
||||
expect(fixtureApp.indexOf('DebugBridgeUIWiring.installAll()')).toBeLessThan(
|
||||
fixtureApp.indexOf('DebugBridgeManager.shared.start'),
|
||||
);
|
||||
expect(wiring).not.toContain('import DebugBridge\n');
|
||||
expect(wiring).not.toContain('AccessibilityScanner');
|
||||
expect(wiring).not.toContain('MutationDispatcher');
|
||||
});
|
||||
|
||||
test('fixture uses an @Observable-compatible source marker, not a property wrapper', () => {
|
||||
const state = readFileSync(
|
||||
join(FIXTURE_PATH, 'Sources/FixtureApp/FixtureAppState.swift'),
|
||||
'utf-8',
|
||||
);
|
||||
expect(state).toContain('@Observable');
|
||||
expect(state.match(/\/\/ @Snapshotable/g)?.length).toBe(4);
|
||||
expect(state).not.toContain('@propertyWrapper');
|
||||
expect(state).not.toMatch(/^[\t ]*@Snapshotable[\t ]+(?:public[\t ]+)?var/m);
|
||||
});
|
||||
|
||||
test('recurses through iOS automation elements to expose nested SwiftUI controls', () => {
|
||||
const bridges = readTemplate('Bridges.swift.template');
|
||||
expect(bridges).toContain('element.automationElements');
|
||||
expect(bridges).toContain('debugBridgeAccessibilityChildren(of: element)');
|
||||
expect(bridges).toContain('visited.insert(ObjectIdentifier(element)).inserted');
|
||||
expect(bridges).toContain('var remaining = 2_048');
|
||||
});
|
||||
|
||||
test('enables accessibility automation before SwiftUI AX is installed', () => {
|
||||
const implementation = readTemplate('DebugBridgeTouch.m.template');
|
||||
const bridges = readTemplate('Bridges.swift.template');
|
||||
const helper = [...implementation.matchAll(
|
||||
/static\s+void\s+([A-Za-z_]\w*)\s*\(\s*void\s*\)\s*\{/g,
|
||||
)].find((candidate) => {
|
||||
const body = bracedBlock(
|
||||
implementation,
|
||||
candidate.index! + candidate[0].lastIndexOf('{'),
|
||||
);
|
||||
return body.includes('_AXSAutomationEnabled') && body.includes('_AXSSetAutomationEnabled');
|
||||
});
|
||||
expect(helper).toBeDefined();
|
||||
|
||||
const helperName = helper![1];
|
||||
const helperBody = bracedBlock(
|
||||
implementation,
|
||||
helper!.index! + helper![0].lastIndexOf('{'),
|
||||
);
|
||||
expect(helperBody).toContain('_AXSAutomationEnabled');
|
||||
expect(helperBody).toContain('_AXSSetAutomationEnabled');
|
||||
|
||||
// Accept either Objective-C's eager +load hook or an explicit public
|
||||
// bootstrap selector, but require the enabling helper to be called before
|
||||
// Swift installs the resolver that walks SwiftUI's accessibility tree.
|
||||
const bootstrap = [...implementation.matchAll(/\+\s*\(void\)\s*([A-Za-z_]\w*)\s*\{/g)]
|
||||
.find((candidate) => bracedBlock(
|
||||
implementation,
|
||||
candidate.index! + candidate[0].lastIndexOf('{'),
|
||||
).match(new RegExp(`\\b${escapeRegExp(helperName)}\\s*\\(`)));
|
||||
expect(bootstrap).toBeDefined();
|
||||
|
||||
if (bootstrap![1] === 'load') {
|
||||
expect(bootstrap!.index!).toBeLessThan(implementation.indexOf('+ (BOOL)sendTapAtPoint:'));
|
||||
} else {
|
||||
const bootstrapCall = bridges.search(
|
||||
new RegExp(`DebugBridgeTouch\\.${escapeRegExp(bootstrap![1])}\\s*\\(`),
|
||||
);
|
||||
expect(bootstrapCall).toBeGreaterThanOrEqual(0);
|
||||
expect(bootstrapCall).toBeLessThan(bridges.indexOf('ElementsBridge.resolver'));
|
||||
}
|
||||
});
|
||||
|
||||
test('renders screenshots at one pixel per window point', () => {
|
||||
const bridges = readTemplate('Bridges.swift.template');
|
||||
const declaration = bridges.match(
|
||||
/(?:let|var)\s+([A-Za-z_]\w*)\s*=\s*UIGraphicsImageRendererFormat(?:\.default)?\(\)/,
|
||||
);
|
||||
expect(declaration).not.toBeNull();
|
||||
|
||||
const formatName = declaration![1];
|
||||
const scalePattern = new RegExp(`\\b${escapeRegExp(formatName)}\\.scale\\s*=\\s*1(?:\\.0)?\\b`);
|
||||
const rendererPattern = new RegExp(
|
||||
`UIGraphicsImageRenderer\\(\\s*bounds:\\s*bounds,\\s*format:\\s*${escapeRegExp(formatName)}\\s*\\)`,
|
||||
);
|
||||
const declarationOffset = declaration!.index!;
|
||||
const scaleOffset = bridges.search(scalePattern);
|
||||
const rendererOffset = bridges.search(rendererPattern);
|
||||
|
||||
expect(scaleOffset).toBeGreaterThan(declarationOffset);
|
||||
expect(rendererOffset).toBeGreaterThan(scaleOffset);
|
||||
});
|
||||
|
||||
test('uses accessibilityActivate for SwiftUI while retaining synthesized-touch delivery', () => {
|
||||
const bridges = readTemplate('Bridges.swift.template');
|
||||
const tapStart = bridges.indexOf('private static func handleTap');
|
||||
const tapEnd = bridges.indexOf('private static func handleType', tapStart);
|
||||
expect(tapStart).toBeGreaterThanOrEqual(0);
|
||||
expect(tapEnd).toBeGreaterThan(tapStart);
|
||||
const handleTap = bridges.slice(tapStart, tapEnd);
|
||||
|
||||
const synthesizedTouchOffset = handleTap.indexOf('DebugBridgeTouch.sendTap');
|
||||
const fallbackCall = handleTap.match(
|
||||
/\b([A-Za-z_]\w*)\s*\(\s*at:\s*point\s*,\s*in:\s*window\s*\)/,
|
||||
);
|
||||
const activationOffset = handleTap.indexOf('.accessibilityActivate()');
|
||||
expect(synthesizedTouchOffset).toBeGreaterThanOrEqual(0);
|
||||
expect(fallbackCall).not.toBeNull();
|
||||
expect(activationOffset).toBeGreaterThan(fallbackCall!.index!);
|
||||
|
||||
const fallbackName = fallbackCall![1];
|
||||
expect(bridges).toMatch(
|
||||
new RegExp(`(?:private\\s+)?static\\s+func\\s+${escapeRegExp(fallbackName)}\\s*\\(`),
|
||||
);
|
||||
});
|
||||
|
||||
test('finishes programmatic scrolls before returning success to the next tap', () => {
|
||||
const bridges = readTemplate('Bridges.swift.template');
|
||||
expect(bridges).toContain('setContentOffset(off, animated: false)');
|
||||
expect(bridges).not.toContain('setContentOffset(off, animated: true)');
|
||||
});
|
||||
|
||||
test('serializes accessibility traits without signed Int truncation', () => {
|
||||
const bridges = readTemplate('Bridges.swift.template');
|
||||
expect(bridges).toMatch(/\btraits\s*:\s*UInt64\b/);
|
||||
expect(bridges).toContain('.uint64Value');
|
||||
expect(bridges).not.toMatch(/\bInt(?:64)?\s*\(\s*view\.accessibilityTraits\.rawValue\s*\)/);
|
||||
expect(bridges).not.toMatch(/accessibilityTraits[\s\S]{0,120}?\.intValue\b/);
|
||||
});
|
||||
|
||||
test('validates every generated model before applying any snapshot state', () => {
|
||||
const server = readTemplate('StateServer.swift.template');
|
||||
const validationLoop = server.indexOf('for restore in atomicRestores');
|
||||
const applyComment = server.indexOf('Phase two applies only after every model accepted');
|
||||
const applyLoop = server.indexOf('for restore in atomicRestores', validationLoop + 1);
|
||||
|
||||
expect(server).toContain('typealias AtomicRestoreFn = (JSONDict, Bool) -> RestoreResult');
|
||||
expect(server).toContain('restore(keys, false)');
|
||||
expect(server).toContain('restore(keys, true)');
|
||||
expect(validationLoop).toBeGreaterThanOrEqual(0);
|
||||
expect(applyComment).toBeGreaterThan(validationLoop);
|
||||
expect(applyLoop).toBeGreaterThan(applyComment);
|
||||
});
|
||||
|
||||
test('turns non-JSON response bodies into an explicit HTTP 500', () => {
|
||||
const server = readTemplate('StateServer.swift.template');
|
||||
expect(server).toContain('JSONSerialization.isValidJSONObject(body)');
|
||||
expect(server).toContain('responseStatus = 500');
|
||||
expect(server).toContain('response_not_json_serializable');
|
||||
expect(server).not.toContain('?? Data("{}".utf8)');
|
||||
});
|
||||
});
|
||||
|
||||
function hasSwift(): boolean {
|
||||
|
||||
@@ -171,9 +171,12 @@ describe('ios-qa E2E (no-device path)', () => {
|
||||
writeFileSync(join(srcDir, 'AppState.swift'), `
|
||||
@Observable
|
||||
class AppState {
|
||||
@Snapshotable var isLoggedIn: Bool = false
|
||||
@Snapshotable var username: String = ""
|
||||
@Snapshotable var counter: Int = 0
|
||||
// @Snapshotable
|
||||
var isLoggedIn: Bool = false
|
||||
// @Snapshotable
|
||||
var username: String = ""
|
||||
// @Snapshotable
|
||||
var counter: Int = 0
|
||||
var ephemeralCache: [String: Any] = [:]
|
||||
}
|
||||
`);
|
||||
@@ -189,7 +192,8 @@ class AppState {
|
||||
expect(result.specs).toHaveLength(1);
|
||||
expect(result.specs[0]!.fields.map(f => f.name).sort()).toEqual(['counter', 'isLoggedIn', 'username']);
|
||||
const generatedSwift = readFileSync(result.outputPath, 'utf-8');
|
||||
expect(generatedSwift).toContain('public enum AppStateAccessor');
|
||||
expect(generatedSwift).toContain('enum AppStateAccessor');
|
||||
expect(generatedSwift).not.toContain('public enum AppStateAccessor');
|
||||
expect(generatedSwift).toContain('key: "isLoggedIn"');
|
||||
expect(generatedSwift).toContain('key: "counter"');
|
||||
expect(generatedSwift).not.toContain('key: "ephemeralCache"'); // not marked @Snapshotable
|
||||
|
||||
Reference in New Issue
Block a user