Skip to content

Commit 8394a52

Browse files
committed
test(ios): add tart ephemeral e2e reproduction pipeline
Host-orchestrated Tart VMs with detached iteration, session-scoped artifacts, virtiofs completion polling, and optional SCP harvest (--no-sync-artifacts).
1 parent bbaf1ea commit 8394a52

23 files changed

Lines changed: 2219 additions & 0 deletions

scripts/tart/.gitignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
# Local iOS e2e reproduction run outputs (see okf-bundle/testing/local-ios-e2e-reproduction.md)
2+
artifacts/
3+
manifests/golden-baked.json
4+
manifests/warmed-main.json

scripts/tart/README.md

Lines changed: 216 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,216 @@
1+
# Local iOS E2E reproduction (Tart)
2+
3+
Self-contained Tart VM pipeline for reproducing **Testing E2E iOS** locally. Full design: [okf-bundle/testing/local-ios-e2e-reproduction.md](../../okf-bundle/testing/local-ios-e2e-reproduction.md).
4+
5+
**Isolation:** everything lives under `scripts/tart/` plus OKF documentation. This system does not modify `.github/`, CI workflows, or package scripts.
6+
7+
## Prerequisites (host)
8+
9+
- Apple Silicon Mac with [Tart](https://tart.run/) installed
10+
- `sshpass` (`brew install sshpass`)
11+
- **≥ 80 GiB free** on the volume holding `~/.tart` (symlink to external disk if needed)
12+
- Git worktree for the branch under test (not the primary clone)
13+
14+
## Quick start
15+
16+
```bash
17+
# 1. Bake golden toolchain (once, after disk cleanup)
18+
./scripts/tart/bake-golden.sh
19+
20+
# 2. Prewarm caches on origin/main (once per main bump)
21+
./scripts/tart/bake-warmed.sh /path/to/any/rnfb-clone-or-worktree
22+
23+
# 3. Run one ephemeral iteration on your worktree
24+
./scripts/tart/run-ephemeral.sh --worktree /path/to/worktree --mode debug
25+
26+
# 4. Stress loop
27+
./scripts/tart/run-stress.sh --worktree /path/to/worktree --count 10 --mode debug
28+
```
29+
30+
## Yarn cache
31+
32+
All tart scripts export:
33+
34+
```bash
35+
YARN_CACHE_FOLDER=$HOME/.yarn-rnfb-cache
36+
```
37+
38+
The repo uses `enableGlobalCache: false` in `.yarnrc.yml`; the env var keeps Berry cache outside the virtiofs worktree without changing repo config.
39+
40+
## Scripts
41+
42+
| Script | Where |
43+
|--------|-------|
44+
| `bake-golden.sh` | Host — seed → golden VM |
45+
| `bake-warmed.sh` | Host — golden → warmed VM |
46+
| `run-ephemeral.sh` | Host — one iteration, delete VM |
47+
| `run-stress.sh` | Host — N iterations |
48+
| `stop-ephemeral.sh` | Host — stop/delete a kept ephemeral VM |
49+
| `provision-toolchain.sh` | In-VM (via SSH during bake) |
50+
| `prewarm-caches.sh` | In-VM (via SSH during warm bake) |
51+
| `run-iteration.sh` | In-VM (via SSH during ephemeral run) |
52+
| `verify-manifest.sh` | Host |
53+
| `report-disk-usage.sh` | Host |
54+
55+
CI helper equivalents are vendored under `lib/` and `vendor/homebrew-rnfb/` — not imported from `.github/`.
56+
57+
## Completion, artifacts, and cleanup
58+
59+
`run-ephemeral.sh` launches the iteration **detached inside the VM** (`nohup`), then polls virtiofs for `iteration-complete.json`. No long-lived SSH log stream.
60+
61+
### How completion is detected
62+
63+
1. Host SSHs once to start `run-iteration.sh` under `nohup` on VM disk (`~/rnfb-prewarm/...`).
64+
2. In-VM `run-iteration.sh` logs to `run-iteration.log` on VM disk (not over SSH).
65+
3. When finished (or on failure via `EXIT` trap), it writes **`iteration-complete.json`** and syncs small files to virtiofs.
66+
4. Host polls the worktree for `iteration-complete.json`, reads `rc`, then **SCPs bulk logs** from the VM (while it is still running, unless `--no-sync-artifacts`).
67+
5. Orchestrator tears down the VM on exit (or leaves it running with `--keep-vm`).
68+
69+
**Artifact flow (two paths):**
70+
71+
| Stage | What | Where |
72+
|-------|------|--------|
73+
| In-VM write | Full iteration output during the run | `~/rnfb-prewarm/scripts/tart/artifacts/<SESSION_ID>/<mode>-iter<N>/` on VM disk |
74+
| Virtiofs sync | Small completion files (`iteration-complete.json`, `detox-step.log`, `ccache-stats.txt`, …) | Same path under the mounted worktree (host-visible without SSH) |
75+
| Host SCP harvest (default) | Bulk files (`run-iteration.log`, `simulator.log`, `simulator.mp4`, …) | Host pulls from VM → worktree after completion marker |
76+
77+
Use **`--no-sync-artifacts`** to skip the SCP harvest step. Essentials still land on the worktree via virtiofs; bulk logs remain on the VM until teardown (or inspect with `--keep-vm` + SSH).
78+
79+
The VM **stays running** from `tart run` through iteration and SCP harvest. Nothing stops it until the host orchestrator calls teardown at exit (or you pass `--keep-vm` to leave it up).
80+
81+
Live status (optional):
82+
83+
```bash
84+
ssh admin@$(tart ip rnfb-e2e-<uuid>) tail -f ~/rnfb-prewarm/scripts/tart/artifacts/<SESSION_ID>/<mode>-iter<N>/run-iteration.log
85+
```
86+
87+
`bake-warmed.sh` uses a similar marker pattern: in-VM `prewarm-caches.sh` writes **`prewarm-complete.json`**; the host polls for it.
88+
89+
### Artifact layout
90+
91+
All run outputs live under **`scripts/tart/artifacts/<SESSION_ID>/`**, where `SESSION_ID` is the UTC run start timestamp (e.g. `20260623T062250Z`). The directory is gitignored.
92+
93+
| Path | Purpose |
94+
|------|---------|
95+
| `<SESSION_ID>/run-ephemeral.log` | Host orchestrator log (APFS, not virtiofs) |
96+
| `<SESSION_ID>/<mode>-iter<N>/` | Per-iteration artifacts (see below) |
97+
| `<SESSION_ID>/prewarm.log` | `bake-warmed.sh` prewarm log |
98+
99+
### Artifact files (per iteration)
100+
101+
Iteration dir: `scripts/tart/artifacts/<SESSION_ID>/<mode>-iter<N>/` (e.g. `debug-iter0`).
102+
103+
| File | Purpose |
104+
|------|---------|
105+
| `iteration-complete.json` | Authoritative completion marker + exit code |
106+
| `run-iteration.log` | Full in-VM iteration log (SCP'd on harvest) |
107+
| `iteration-env.txt` | Lockfile SHAs, warmed-manifest comparison, build duration |
108+
| `ccache-stats.txt` | `ccache -s` snapshots at each phase |
109+
| `detox-step.log` | Detox/Jet test output |
110+
| `flake-summary.txt` | Post-run flake digest |
111+
112+
Host wrapper log: `scripts/tart/artifacts/<SESSION_ID>/run-ephemeral.log` (host APFS, not virtiofs).
113+
114+
## Operational pitfalls (learned in practice)
115+
116+
### Do not stream iteration output over SSH or virtiofs
117+
118+
Build output (xcbeautify note spam) can block `tee` on virtiofs and wedge the whole pipeline. Iteration stdout belongs on **VM disk**; the host waits on **`iteration-complete.json`** only.
119+
120+
Bulk artifacts (`simulator.log`, `testing.log`, `simulator.mp4`, `run-iteration.log`) are pulled with **host-side SCP** after the completion marker appears.
121+
122+
### Do not rsync large logs over virtiofs
123+
124+
The iteration exit path **must not** block on copying hundreds of MB through virtiofs. Essential files only (`iteration-complete.json`, `detox-step.log`, etc.).
125+
126+
### SSH sessions can outlive the VM
127+
128+
`rnfb_tart_ssh` connects to `tart ip <vm>`. Problems observed on older flows:
129+
130+
- Long-lived SSH streams blocking cleanup (mitigated by detached iteration launch).
131+
- **Deleted VMs leave zombie `sshpass` processes** still connected to stale `192.168.64.*` addresses.
132+
133+
Mitigations in `lib/common.sh`:
134+
135+
- `ServerAliveInterval=15` / `ServerAliveCountMax=4` — dead connections drop in ~60s
136+
- `timeout` wrapper on SSH/SCP (default **`RNFB_TART_SSH_TIMEOUT=3600`** seconds for launch/harvest)
137+
- **`RNFB_TART_ITERATION_TIMEOUT=7200`** — max wait for `iteration-complete.json` on virtiofs
138+
139+
### Low CPU does not mean the pipeline finished
140+
141+
A completed test run can still leave:
142+
143+
- An ephemeral VM running (host script blocked on SSH)
144+
- Zombie `sshpass` / `run-ephemeral.sh` / `bake-warmed.sh` wrappers on the host
145+
- `tart run` background processes
146+
147+
Always check process state explicitly.
148+
149+
## Troubleshooting
150+
151+
### Check for zombie host processes
152+
153+
```bash
154+
pgrep -fl 'sshpass|run-ephemeral|bake-warmed|tart run'
155+
tart list
156+
```
157+
158+
- Any **`sshpass` to `192.168.64.*`** with no matching **`rnfb-e2e-*`** VM in `tart list` → safe to kill.
159+
- Multiple overlapping `run-ephemeral.sh` from retries → kill extras; only one should own the ephemeral VM.
160+
161+
```bash
162+
pkill -f 'sshpass.*192.168.64'
163+
pkill -f 'scripts/tart/run-ephemeral.sh'
164+
```
165+
166+
### Check iteration outcome
167+
168+
```bash
169+
ls scripts/tart/artifacts/*/
170+
cat scripts/tart/artifacts/<SESSION_ID>/<mode>-iter<N>/iteration-complete.json
171+
cat scripts/tart/artifacts/<SESSION_ID>/<mode>-iter<N>/ccache-stats.txt
172+
tail scripts/tart/artifacts/<SESSION_ID>/<mode>-iter<N>/detox-step.log
173+
```
174+
175+
### Manual VM cleanup
176+
177+
Ephemeral VMs are named `rnfb-e2e-<uuid>`. By default `run-ephemeral.sh` stops and deletes them on exit. With `--keep-vm`, the orchestrator leaves the VM running:
178+
179+
```bash
180+
./scripts/tart/stop-ephemeral.sh rnfb-e2e-<uuid> # stop only
181+
./scripts/tart/stop-ephemeral.sh rnfb-e2e-<uuid> --delete # stop + delete image
182+
```
183+
184+
Or manually:
185+
186+
```bash
187+
tart list
188+
tart stop rnfb-e2e-<uuid>
189+
tart delete rnfb-e2e-<uuid>
190+
```
191+
192+
Preserve **`rnfb-ios-e2e-golden`** and **`rnfb-ios-e2e-warmed`** (stopped is normal).
193+
194+
### `bake-warmed` appeared finished but host hung
195+
196+
If in-VM prewarm logged `[prewarm] complete` but the host never reached `[bake-warmed] tagging`:
197+
198+
1. Check for stuck `sshpass` / `bake-warmed.sh`.
199+
2. Confirm `~/rnfb-tart-manifests/prewarm-complete.json` exists in the build VM (or fetch `warmed-main.json` manually).
200+
3. Tag manually: `tart clone rnfb-ios-e2e-warmed-build rnfb-ios-e2e-warmed`, then delete the `-build` VM.
201+
202+
### When to rebake warmed
203+
204+
Re-run `bake-warmed.sh` when `origin/main` changes **`yarn.lock`** or **`tests/ios/Podfile.lock`** (compare SHAs in `manifests/warmed-main.json`). Golden rebake only when CI toolchain pins change (`manifests/golden-expected.json`).
205+
206+
## Environment variables (SSH / caches)
207+
208+
| Variable | Default | Notes |
209+
|----------|---------|-------|
210+
| `RNFB_TART_SSH_TIMEOUT` | `3600` | Max seconds per SSH/SCP call (`timeout` wrapper) |
211+
| `RNFB_TART_ITERATION_TIMEOUT` | `7200` | Max seconds to poll for `iteration-complete.json` |
212+
| `RNFB_TART_SSH_USER` / `RNFB_TART_SSH_PASSWORD` | `admin` / `admin` | Cirrus Tart images |
213+
| `YARN_CACHE_FOLDER` | `~/.yarn-rnfb-cache` | Outside virtiofs worktree |
214+
| `CCACHE_DIR` | `~/.ccache` | Shared between prewarm and iteration via `~/rnfb-prewarm` path |
215+
| `RNFB_TART_PREWARM_DIR` | `~/rnfb-prewarm` | Iteration materializes worktree here for ccache key alignment |
216+

scripts/tart/bake-golden.sh

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
#!/usr/bin/env bash
2+
# Host: clone Cirrus seed, provision toolchain, tag rnfb-ios-e2e-golden.
3+
set -euo pipefail
4+
5+
# shellcheck source=lib/common.sh
6+
source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib/common.sh"
7+
8+
rnfb_tart_require tart
9+
rnfb_tart_require sshpass
10+
11+
EXPECTED="${RNFB_TART_ROOT}/manifests/golden-expected.json"
12+
SEED_IMAGE="$(rnfb_tart_read_manifest_field "$EXPECTED" tart_seed_image)"
13+
BUILD_VM="${RNFB_TART_GOLDEN_VM}-build"
14+
15+
MIN_FREE_GIB="${RNFB_TART_MIN_FREE_GIB:-80}"
16+
avail_kib="$(df -k "${HOME}" | awk 'NR==2 {print $4}')"
17+
avail_gib=$((avail_kib / 1024 / 1024))
18+
if (( avail_gib < MIN_FREE_GIB )); then
19+
echo "warning: only ~${avail_gib} GiB free on ${HOME}; recommend >= ${MIN_FREE_GIB} GiB before baking" >&2
20+
fi
21+
22+
echo "[bake-golden] pulling ${SEED_IMAGE} if needed..."
23+
tart pull "$SEED_IMAGE" || true
24+
25+
tart stop "$BUILD_VM" 2>/dev/null || true
26+
tart delete "$BUILD_VM" 2>/dev/null || true
27+
tart stop "$RNFB_TART_GOLDEN_VM" 2>/dev/null || true
28+
tart delete "$RNFB_TART_GOLDEN_VM" 2>/dev/null || true
29+
30+
echo "[bake-golden] cloning seed → ${BUILD_VM}"
31+
tart clone "$SEED_IMAGE" "$BUILD_VM"
32+
33+
echo "[bake-golden] starting ${BUILD_VM} with tart-scripts mount..."
34+
tart run --no-graphics --dir=tart-scripts:"${RNFB_TART_ROOT}":ro "$BUILD_VM" &
35+
run_pid=$!
36+
sleep 5
37+
38+
cleanup() {
39+
kill "$run_pid" 2>/dev/null || true
40+
tart stop "$BUILD_VM" 2>/dev/null || true
41+
}
42+
trap cleanup EXIT
43+
44+
rnfb_tart_wait_ssh "$BUILD_VM" 240
45+
46+
echo "[bake-golden] provisioning toolchain..."
47+
rnfb_tart_ssh "$BUILD_VM" "bash '${RNFB_TART_SCRIPTS_MOUNT}/provision-toolchain.sh'"
48+
49+
rnfb_tart_ssh "$BUILD_VM" "cat ~/rnfb-tart-manifests/golden-manifest.json" \
50+
> "${RNFB_TART_ROOT}/manifests/golden-baked.json"
51+
52+
kill "$run_pid" 2>/dev/null || true
53+
wait "$run_pid" 2>/dev/null || true
54+
tart stop "$BUILD_VM" 2>/dev/null || true
55+
56+
echo "[bake-golden] tagging ${RNFB_TART_GOLDEN_VM}"
57+
tart clone "$BUILD_VM" "$RNFB_TART_GOLDEN_VM"
58+
tart delete "$BUILD_VM"
59+
trap - EXIT
60+
61+
echo "[bake-golden] done — local VM: ${RNFB_TART_GOLDEN_VM}"
62+
echo "[bake-golden] manifest: ${RNFB_TART_ROOT}/manifests/golden-baked.json"

scripts/tart/bake-warmed.sh

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
#!/usr/bin/env bash
2+
# Host: clone golden VM, prewarm caches on origin/main, tag rnfb-ios-e2e-warmed.
3+
set -euo pipefail
4+
5+
# shellcheck source=lib/common.sh
6+
source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib/common.sh"
7+
8+
rnfb_tart_require tart
9+
rnfb_tart_require sshpass
10+
11+
HOST_REPO="${1:-${RNFB_HOST_REPO:-}}"
12+
if [[ -z "$HOST_REPO" ]]; then
13+
echo "usage: $0 [path-to-host-git-repo]" >&2
14+
echo " Uses git remote.origin.url and resolves origin/main HEAD for prewarm." >&2
15+
exit 1
16+
fi
17+
HOST_REPO="$(cd "$HOST_REPO" && pwd)"
18+
19+
if ! rnfb_tart_vm_exists "${RNFB_TART_GOLDEN_VM}"; then
20+
echo "error: golden VM ${RNFB_TART_GOLDEN_VM} not found — run bake-golden.sh first" >&2
21+
exit 1
22+
fi
23+
24+
RNFB_REPO_URL="$(git -C "$HOST_REPO" remote get-url origin)"
25+
RNFB_REPO_URL="$(rnfb_tart_https_repo_url "$RNFB_REPO_URL")"
26+
RNFB_MAIN_REF="main"
27+
RNFB_MAIN_SHA="$(git -C "$HOST_REPO" ls-remote origin refs/heads/main | awk '{print $1}')"
28+
29+
BUILD_VM="${RNFB_TART_WARMED_VM}-build"
30+
tart stop "$BUILD_VM" 2>/dev/null || true
31+
tart delete "$BUILD_VM" 2>/dev/null || true
32+
tart stop "$RNFB_TART_WARMED_VM" 2>/dev/null || true
33+
tart delete "$RNFB_TART_WARMED_VM" 2>/dev/null || true
34+
35+
echo "[bake-warmed] cloning ${RNFB_TART_GOLDEN_VM}${BUILD_VM}"
36+
tart clone "$RNFB_TART_GOLDEN_VM" "$BUILD_VM"
37+
38+
BAKE_SESSION_ID="$(date -u +%Y%m%dT%H%M%SZ)"
39+
BAKE_ARTIFACT_DIR="$(rnfb_tart_artifact_session_dir "$RNFB_TART_ROOT" "$BAKE_SESSION_ID")"
40+
mkdir -p "$BAKE_ARTIFACT_DIR"
41+
42+
echo "[bake-warmed] prewarm origin/main @ ${RNFB_MAIN_SHA:0:12}"
43+
tart run --no-graphics --dir=tart-scripts:"${RNFB_TART_ROOT}":ro "$BUILD_VM" &
44+
run_pid=$!
45+
sleep 5
46+
47+
cleanup() {
48+
kill "$run_pid" 2>/dev/null || true
49+
tart stop "$BUILD_VM" 2>/dev/null || true
50+
}
51+
trap cleanup EXIT
52+
53+
rnfb_tart_wait_ssh "$BUILD_VM" 240
54+
55+
set +e
56+
rnfb_tart_ssh "$BUILD_VM" \
57+
"RNFB_REPO_URL='${RNFB_REPO_URL}' RNFB_MAIN_REF='${RNFB_MAIN_REF}' \
58+
bash '${RNFB_TART_SCRIPTS_MOUNT}/prewarm-caches.sh'" \
59+
> "${BAKE_ARTIFACT_DIR}/prewarm.log" 2>&1 &
60+
prewarm_pid=$!
61+
62+
while kill -0 "$prewarm_pid" 2>/dev/null; do
63+
if rnfb_tart_ssh "$BUILD_VM" "test -f ~/rnfb-tart-manifests/prewarm-complete.json" 2>/dev/null; then
64+
rnfb_tart_log "[bake-warmed] prewarm completion marker detected"
65+
sleep 3
66+
kill "$prewarm_pid" 2>/dev/null || true
67+
wait "$prewarm_pid" 2>/dev/null || true
68+
break
69+
fi
70+
sleep 15
71+
done
72+
73+
if kill -0 "$prewarm_pid" 2>/dev/null; then
74+
wait "$prewarm_pid"
75+
fi
76+
set -e
77+
78+
rnfb_tart_ssh "$BUILD_VM" "cat ~/rnfb-tart-manifests/warmed-main.json" \
79+
> "${RNFB_TART_ROOT}/manifests/warmed-main.json"
80+
81+
kill "$run_pid" 2>/dev/null || true
82+
wait "$run_pid" 2>/dev/null || true
83+
tart stop "$BUILD_VM" 2>/dev/null || true
84+
85+
echo "[bake-warmed] tagging ${RNFB_TART_WARMED_VM}"
86+
tart clone "$BUILD_VM" "$RNFB_TART_WARMED_VM"
87+
tart delete "$BUILD_VM"
88+
trap - EXIT
89+
90+
echo "[bake-warmed] done — local VM: ${RNFB_TART_WARMED_VM}"
91+
echo "[bake-warmed] warmed-main.json commit: $(python3 -c "import json; print(json.load(open('${RNFB_TART_ROOT}/manifests/warmed-main.json'))['main_commit'][:12])")"
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
#!/usr/bin/env bash
2+
# In-VM: one-shot SSH target — start run-iteration.sh detached, print pid, exit.
3+
set -euo pipefail
4+
5+
REMOTE_DIR="$1"
6+
RNFB_REPO_ROOT="$2"
7+
RNFB_BUILD_MODE="$3"
8+
RNFB_RUN_ID="$4"
9+
RNFB_ITERATION="$5"
10+
RNFB_HEADLESS_SIMULATOR="$6"
11+
12+
# shellcheck source=lib/common.sh
13+
source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib/common.sh"
14+
15+
mkdir -p "$REMOTE_DIR"
16+
export RNFB_REPO_ROOT RNFB_BUILD_MODE RNFB_RUN_ID RNFB_ITERATION RNFB_HEADLESS_SIMULATOR
17+
18+
nohup bash "${RNFB_TART_SCRIPTS_MOUNT}/run-iteration.sh" </dev/null >/dev/null 2>&1 &
19+
disown -h 2>/dev/null || disown
20+
echo $! > "${REMOTE_DIR}/iteration.pid"

0 commit comments

Comments
 (0)