Skip to content

Commit a325940

Browse files
authored
Merge pull request #2264 from time-attack/time-attack/wave-test-infra-isolated-review
fix: harden test infrastructure and iOS device QA
2 parents 2beb636 + 4ad55e2 commit a325940

54 files changed

Lines changed: 6325 additions & 733 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

AGENTS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,7 @@ Companion CLIs (run on the Mac that's plugged into the device):
9292
|---------|-------------|
9393
| `gstack-ios-qa-daemon` | Mac-side broker. Loopback by default; `--tailnet` adds a Tailscale-facing listener with capability tiers and audit logging. |
9494
| `gstack-ios-qa-mint` | Owner-grant CLI for the tailnet allowlist (`grant`/`revoke`/`list`). |
95+
| `gstack-ios-qa-regen` | Regenerate the canonical local DebugBridge package and typed accessors (`--app-source` / `--bridge-dir`). |
9596

9697
End-to-end walkthrough: [docs/howto-ios-testing-with-gstack.md](docs/howto-ios-testing-with-gstack.md).
9798

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -245,6 +245,7 @@ Beyond the slash-command skills, gstack ships standalone CLIs for workflows that
245245
| `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. |
246246
| `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). |
247247
| `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. |
248+
| `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. |
248249

249250
### Continuous checkpoint mode (opt-in, local by default)
250251

bin/gstack-ios-qa-regen

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
#!/usr/bin/env bash
2+
# gstack-ios-qa-regen — deterministically regenerate the iOS DebugBridge
3+
# package and the app-owned typed state accessors.
4+
5+
set -euo pipefail
6+
7+
usage() {
8+
cat <<'EOF'
9+
Usage: gstack-ios-qa-regen --app-source <dir> --bridge-dir <dir>
10+
11+
--app-source Swift source tree to scan for @Observable state
12+
--bridge-dir Destination for the generated local DebugBridge package
13+
EOF
14+
}
15+
16+
APP_SOURCE=""
17+
BRIDGE_DIR=""
18+
19+
while [[ $# -gt 0 ]]; do
20+
case "$1" in
21+
--app-source)
22+
[[ $# -ge 2 ]] || { echo "gstack-ios-qa-regen: --app-source requires a value" >&2; exit 2; }
23+
APP_SOURCE="$2"
24+
shift 2
25+
;;
26+
--bridge-dir)
27+
[[ $# -ge 2 ]] || { echo "gstack-ios-qa-regen: --bridge-dir requires a value" >&2; exit 2; }
28+
BRIDGE_DIR="$2"
29+
shift 2
30+
;;
31+
-h|--help)
32+
usage
33+
exit 0
34+
;;
35+
*)
36+
echo "gstack-ios-qa-regen: unknown argument: $1" >&2
37+
usage >&2
38+
exit 2
39+
;;
40+
esac
41+
done
42+
43+
if [[ -z "$APP_SOURCE" || -z "$BRIDGE_DIR" ]]; then
44+
echo "gstack-ios-qa-regen: both --app-source and --bridge-dir are required" >&2
45+
usage >&2
46+
exit 2
47+
fi
48+
49+
if [[ ! -d "$APP_SOURCE" ]]; then
50+
echo "gstack-ios-qa-regen: app source directory not found: $APP_SOURCE" >&2
51+
exit 1
52+
fi
53+
54+
if ! command -v bun >/dev/null 2>&1; then
55+
echo "gstack-ios-qa-regen: bun runtime not on PATH — install from https://bun.sh" >&2
56+
exit 1
57+
fi
58+
59+
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
60+
GSTACK_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
61+
TEMPLATE_DIR="$GSTACK_ROOT/ios-qa/templates"
62+
GENERATOR="$GSTACK_ROOT/ios-qa/scripts/gen-accessors.ts"
63+
VERSION_FILE="$GSTACK_ROOT/VERSION"
64+
GENERATED_DIR="$APP_SOURCE/DebugBridgeGenerated"
65+
66+
for required in "$GENERATOR" "$VERSION_FILE"; do
67+
if [[ ! -f "$required" ]]; then
68+
echo "gstack-ios-qa-regen: missing required gstack file: $required" >&2
69+
exit 1
70+
fi
71+
done
72+
73+
TMP_FILE=""
74+
cleanup() {
75+
if [[ -n "$TMP_FILE" ]]; then
76+
rm -f "$TMP_FILE"
77+
fi
78+
}
79+
trap cleanup EXIT
80+
81+
# Copy through a sibling temporary file so interruption never leaves a
82+
# truncated generated source. Preserve an unchanged destination byte-for-byte
83+
# and metadata-for-metadata on repeated runs.
84+
install_file() {
85+
local source="$1"
86+
local destination="$2"
87+
88+
if [[ ! -f "$source" ]]; then
89+
echo "gstack-ios-qa-regen: missing template: $source" >&2
90+
exit 1
91+
fi
92+
if [[ -f "$destination" ]] && cmp -s "$source" "$destination"; then
93+
return
94+
fi
95+
96+
mkdir -p "$(dirname "$destination")"
97+
TMP_FILE="${destination}.tmp.$$"
98+
cp "$source" "$TMP_FILE"
99+
mv "$TMP_FILE" "$destination"
100+
TMP_FILE=""
101+
}
102+
103+
# Invalidate the completion marker before changing any package source. A
104+
# failed or interrupted regeneration must never look current to ios-sync.
105+
mkdir -p "$GENERATED_DIR"
106+
rm -f -- "$GENERATED_DIR/.gstack-version"
107+
108+
# This is intentionally an allowlist, not a template glob. Wiring belongs to
109+
# the consuming app and StateAccessor.swift is emitted by the parser below.
110+
install_file "$TEMPLATE_DIR/Package.swift.template" \
111+
"$BRIDGE_DIR/Package.swift"
112+
install_file "$TEMPLATE_DIR/StateServer.swift.template" \
113+
"$BRIDGE_DIR/Sources/DebugBridgeCore/StateServer.swift"
114+
install_file "$TEMPLATE_DIR/DebugBridgeManager.swift.template" \
115+
"$BRIDGE_DIR/Sources/DebugBridgeCore/DebugBridgeManager.swift"
116+
install_file "$TEMPLATE_DIR/Bridges.swift.template" \
117+
"$BRIDGE_DIR/Sources/DebugBridgeUI/Bridges.swift"
118+
install_file "$TEMPLATE_DIR/DebugOverlay.swift.template" \
119+
"$BRIDGE_DIR/Sources/DebugBridgeUI/DebugOverlay.swift"
120+
install_file "$TEMPLATE_DIR/DebugBridgeTouch.m.template" \
121+
"$BRIDGE_DIR/Sources/DebugBridgeTouch/DebugBridgeTouch.m"
122+
install_file "$TEMPLATE_DIR/DebugBridgeTouch.h.template" \
123+
"$BRIDGE_DIR/Sources/DebugBridgeTouch/include/DebugBridgeTouch.h"
124+
125+
# Older ios-sync versions copied the entire template set flat into the app's
126+
# generated-source directory. Those files can shadow the package modules or
127+
# make Xcode compile two harness implementations. Remove only the explicit
128+
# obsolete generated paths; handwritten app sources are never touched.
129+
for obsolete in \
130+
"$BRIDGE_DIR/DebugBridgeWiring.swift" \
131+
"$BRIDGE_DIR/StateAccessor.swift" \
132+
"$GENERATED_DIR/Package.swift" \
133+
"$GENERATED_DIR/StateServer.swift" \
134+
"$GENERATED_DIR/DebugBridgeManager.swift" \
135+
"$GENERATED_DIR/Bridges.swift" \
136+
"$GENERATED_DIR/DebugOverlay.swift" \
137+
"$GENERATED_DIR/DebugBridgeTouch.m" \
138+
"$GENERATED_DIR/DebugBridgeTouch.h" \
139+
"$GENERATED_DIR/DebugBridgeWiring.swift"
140+
do
141+
if [[ -f "$obsolete" || -L "$obsolete" ]]; then
142+
rm -f -- "$obsolete"
143+
echo "gstack-ios-qa-regen: removed obsolete generated file $obsolete"
144+
fi
145+
done
146+
147+
bun run "$GENERATOR" --input "$APP_SOURCE" --output "$GENERATED_DIR"
148+
149+
# Stamp only after successful accessor generation. ios-sync uses this marker
150+
# to distinguish a complete current install from an interrupted regeneration.
151+
install_file "$VERSION_FILE" "$GENERATED_DIR/.gstack-version"
152+
153+
echo "gstack-ios-qa-regen: bridge package ready at $BRIDGE_DIR"
154+
echo "gstack-ios-qa-regen: accessors ready at $GENERATED_DIR/StateAccessor.swift"

careful/bin/check-careful.sh

Lines changed: 8 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -25,26 +25,14 @@ fi
2525
# Normalize: lowercase for case-insensitive SQL matching
2626
CMD_LOWER=$(printf '%s' "$CMD" | tr '[:upper:]' '[:lower:]')
2727

28-
# --- Check for safe exceptions (rm -rf of build artifacts) ---
29-
if printf '%s' "$CMD" | grep -qE 'rm\s+(-[a-zA-Z]*r[a-zA-Z]*\s+|--recursive\s+)' 2>/dev/null; then
30-
SAFE_ONLY=true
31-
RM_ARGS=$(printf '%s' "$CMD" | sed -E 's/.*rm[[:space:]]+(-[a-zA-Z]+[[:space:]]+)*//;s/--recursive[[:space:]]*//')
32-
for target in $RM_ARGS; do
33-
case "$target" in
34-
*/node_modules|node_modules|*/\.next|\.next|*/dist|dist|*/__pycache__|__pycache__|*/\.cache|\.cache|*/build|build|*/\.turbo|\.turbo|*/coverage|coverage)
35-
;; # safe target
36-
-*)
37-
;; # flag, skip
38-
*)
39-
SAFE_ONLY=false
40-
break
41-
;;
42-
esac
43-
done
44-
if [ "$SAFE_ONLY" = true ]; then
45-
echo '{}'
46-
exit 0
47-
fi
28+
# --- Check for safe exceptions (one standalone rm of build artifacts) ---
29+
# Match the complete command. Parsing only the last rm is unsafe because shell
30+
# syntax or comments can hide an earlier destructive command, for example:
31+
# rm -rf / # rm -rf node_modules
32+
# Unknown syntax fails closed and falls through to the destructive checks.
33+
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
34+
echo '{}'
35+
exit 0
4836
fi
4937

5038
# --- Destructive pattern checks ---

docs/howto-ios-testing-with-gstack.md

Lines changed: 71 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ Everything below has been verified end-to-end on a real iPhone 17 Pro Max runnin
99
- 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.
1010
- A real iPhone running iOS 16 or later. Unlocked, paired with your Mac, with **Developer Mode** enabled in Settings → Privacy & Security.
1111
- 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`.
12-
- gstack installed (`./setup` complete; `bin/gstack-ios-qa-daemon` must be on disk and executable).
12+
- gstack installed (`./setup` complete; `gstack-ios-qa-regen` and `gstack-ios-qa-daemon` must be on PATH).
1313
- Bun runtime on PATH (`bun --version`). The Mac-side daemon is a bun process.
1414

1515
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
3030

3131
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.
3232

33-
## Step 1: Add the DebugBridge templates to your iOS app
33+
## Step 1: Generate the DebugBridge package
3434

35-
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:
35+
Run `/ios-qa` from the app root, or invoke the same deterministic regenerator directly:
3636

37-
1. Copy these into a `DebugBridge/` SPM package inside your app workspace:
38-
- `Sources/DebugBridgeCore/StateServer.swift` (from `StateServer.swift.template`)
39-
- `Sources/DebugBridgeCore/DebugBridgeManager.swift` (from `DebugBridgeManager.swift.template`)
40-
- `Sources/DebugBridgeTouch/DebugBridgeTouch.m` + `Sources/DebugBridgeTouch/include/DebugBridgeTouch.h` (from the two `.template` files)
41-
- `Sources/DebugBridgeUI/Bridges.swift` (from `Bridges.swift.template`)
42-
- `Sources/DebugBridgeUI/DebugOverlay.swift` (from `DebugOverlay.swift.template`)
43-
- `Package.swift` (from `Package.swift.template`)
44-
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.
45-
3. In your `@main` App init, gate the wiring on `#if DEBUG`:
37+
```bash
38+
gstack-ios-qa-regen \
39+
--app-source "$PWD/Sources/YourApp" \
40+
--bridge-dir "$PWD/DebugBridge"
41+
```
42+
43+
The command copies an explicit allowlist of canonical templates into the local
44+
`DebugBridge/` Swift package, generates
45+
`DebugBridgeGenerated/StateAccessor.swift`, and writes the installed version to
46+
`DebugBridgeGenerated/.gstack-version`. It excludes generated output from its
47+
own schema hash, so rerunning it with unchanged source is a fast, byte-stable
48+
cache hit. It also removes the explicit legacy generated-file set from older
49+
flat harness layouts so stale bridge sources cannot shadow the package.
50+
51+
1. Add `DebugBridge/` as a local package dependency. Depend on the
52+
`DebugBridgeUI` product only in Debug configuration; `DebugBridgeCore` and
53+
`DebugBridgeTouch` come in transitively.
54+
2. Add `DebugBridgeGenerated/StateAccessor.swift` to the app target.
55+
3. In your `@main` App init, install the UIKit resolvers before starting the
56+
server, then register the generated accessor. Replace the example
57+
state/accessor names with the type the generator found:
4658

4759
```swift
4860
#if DEBUG
4961
import DebugBridgeCore
50-
StateServer.shared.start()
5162
#if canImport(UIKit)
5263
import DebugBridgeUI
64+
#endif
65+
#endif
66+
67+
// Inside App.init(), after appState is initialized:
68+
#if DEBUG
69+
#if canImport(UIKit)
5370
DebugBridgeUIWiring.installAll()
5471
#endif
72+
DebugBridgeManager.shared.start(
73+
appState: appState,
74+
register: AppStateAccessor.register
75+
)
5576
#endif
5677
```
5778

@@ -109,7 +130,16 @@ GSTACK_IOS_TARGET_BUNDLE_ID=com.yourorg.yourapp
109130
GSTACK_IOS_DAEMON_PORT=9099 # loopback listener port; default 9099
110131
```
111132

112-
If `GSTACK_IOS_TARGET_UDID` is unset, the daemon picks the first paired connected device.
133+
If `GSTACK_IOS_TARGET_UDID` is unset, the daemon picks the best paired,
134+
available iPhone.
135+
Automatic selection is restricted to available iPhones and prefers a wired
136+
phone. The daemon keeps a healthy rotated tunnel, then invalidates and
137+
rebootstraps once on an app-relaunch 401 or recoverable CoreDevice connection
138+
failure.
139+
If a newly started daemon reaches an already-running target whose one-use boot
140+
token was consumed by an earlier daemon, it verifies the bundle owner, force
141+
relaunches that target once, waits for a fresh token, verifies ownership again,
142+
and rotates normally.
113143

114144
## Step 4: Drive the device
115145

@@ -123,15 +153,39 @@ Once the daemon is running, you have an HTTP surface at `http://127.0.0.1:9099`
123153
| `POST /session/release` | Release the lock. | bearer + session |
124154
| `GET /screenshot` | Capture a PNG of the active window. Returns `{png_base64: "..."}`. | bearer |
125155
| `GET /elements` | Accessibility-tree snapshot. | bearer |
126-
| `GET /state/snapshot` | Dump every `@Snapshotable` field as JSON. | bearer |
127-
| `POST /state/restore` | Atomically restore a full snapshot. | bearer + session, mutate tier |
156+
| `GET /state/snapshot` | Dump every `// @Snapshotable` field as JSON. | bearer |
157+
| `POST /state/restore` | Validate the full snapshot, then restore it on MainActor. | bearer + session, mutate tier |
128158
| `POST /tap` `{x,y}` | Synthesize a real UITouch at window coordinates. SwiftUI Buttons fire. | bearer + session, interact tier |
129159
| `POST /swipe` `{from_x,from_y,to_x,to_y}` | Scroll the nearest enclosing UIScrollView. | bearer + session, interact tier |
130160
| `POST /type` `{text}` | Set text on the current first responder. | bearer + session, interact tier |
131161

132162
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.
133163

134-
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.
164+
The state snapshot is opt-in per field via a standalone generator marker
165+
comment immediately above a property. It is intentionally not a property
166+
wrapper, so it compiles cleanly with Observation:
167+
168+
```swift
169+
@Observable
170+
final class AppState {
171+
// @Snapshotable
172+
var username: String = ""
173+
174+
var authToken: String = "" // never exported
175+
}
176+
```
177+
178+
Unmarked fields never appear in the snapshot, which keeps tokens, PII, and
179+
auth state out of recorded fixtures by default. A marked field must be a
180+
writable instance `var` on a file-scope observable class, with an explicit type
181+
and an internal or public setter. Supported snapshot types are JSON-native
182+
scalars (`String`, `Bool`, signed/unsigned integer widths, `Float`, `Double`,
183+
`CGFloat`), arrays, String-keyed dictionaries, and Optional compositions of
184+
those types. Snapshot keys must be unique across observable classes. The
185+
generator reports and stops on invalid declarations, custom values, implicitly
186+
unwrapped Optionals, nested observable classes, or duplicate keys instead of
187+
emitting broken or lossy Swift. Restore uses two phases: every model validates
188+
the complete input first, and only then are assignments applied on MainActor.
135189

136190
## Step 5: Make remote agents work (optional)
137191

ios-clean/SKILL.md

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

ios-clean/SKILL.md.tmpl

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

0 commit comments

Comments
 (0)