3f961039
Plan 3 of 3 for scenario runner: main-loop integration
a73x 2026-04-19 13:20
Commit message
docs/superpowers/plans/2026-04-19-scenario-main-loop-integration.md
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,839 @@ | |||
| 1 | # Scenario Runner Main-Loop Integration Plan | ||
| 2 | |||
| 3 | > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. | ||
| 4 | |||
| 5 | **Goal:** Wire the finished scenario module (`src/scenario.zig`, from Plan 2) into waystty's real main loop. Add a `--scenario <path>` CLI flag that plays a scenario through the full real runtime (Vulkan render, blink timer, frame_loop) and exits with a documented status code. Ship one end-to-end fixture (`blink-bar.scenario`) to prove the pipeline works. | ||
| 6 | |||
| 7 | **Architecture:** Minimal surgery. `runTerminal` grows two optional parameters (`scenario_state`, `cell_geom`). Main loop guard relaxes when scenario mode is active. One tick call lands after the blink phase check and before the `if (!render_pending) continue` bail. TickIO callbacks in a small new file (`src/scenario_runtime.zig`) wire `write_bytes` → `term.write`, `capture` → offscreen render + PNG write + golden diff, `blink_just_flipped` → previous-iter blink state compare. No wayland or VT changes. | ||
| 8 | |||
| 9 | **Tech Stack:** Zig 0.15, existing `runTerminal` main loop, `src/scenario.zig` (from Plan 2), `src/imgdiff.zig` (from Plan 1), existing `renderer.createOffscreen` / `renderToOffscreen` / `readbackOffscreen`, existing `png` module. | ||
| 10 | |||
| 11 | **Reference spec:** `docs/superpowers/specs/2026-04-19-scenario-runner-design.md` | ||
| 12 | |||
| 13 | --- | ||
| 14 | |||
| 15 | ## Reference facts (grep once, reuse) | ||
| 16 | |||
| 17 | - Base commit: `52e5cdc`. Plan 1 + Plan 2 landed. `src/scenario.zig` exports `parse`, `ScenarioState`, `tick`, `evalPredicate`, `CellGeom`, closed `TickError` with `CallbackFailed` / `AssertCellWithoutCapture` / `AssertFailed` / `ScenarioTimeout` / `SleepUntilFlipTimeout` / `PredicateOnMissingLabel`. | ||
| 18 | - `runTerminal` is defined in `src/main.zig` around line 200-ish (find via `fn runTerminal(`). Main loop starts around L344. Blink phase integration from the earlier cursor-blink feature is around L345-355 (`tickBlinkPhase` call), L507-532 (`reconfigureBlink` call). | ||
| 19 | - `src/pty.zig` defines `Pty` + `Pty.spawn(SpawnOptions) !Pty`. `master_fd: fd_t`, `child_pid: pid_t`, `isChildAlive()` method. | ||
| 20 | - `src/capture.zig` demonstrates the offscreen render + PNG write pattern: `run(alloc, argv)` entry, uses `wayland_client.Connection`, `renderer.Context`, `renderer.createOffscreen` / `renderToOffscreen` / `readbackOffscreen`, `png.encode`. Hardcoded 80×24 / scale=1. | ||
| 21 | - Spec exit codes: 0=pass, 2=parse error, 3=scenario wall-clock timeout, 4=capture/assertion mismatch, 5=Vulkan timeout (flake), 6=other IO/render error. | ||
| 22 | - Spec: `WAYSTTY_SCENARIO_UPDATE=1` rewrites goldens and exits 0 on mismatch. | ||
| 23 | - Spec: scenarios live at `tests/scenarios/<name>.scenario`; goldens at `tests/scenarios/golden/<name>/<label>.png`; failure artifacts at `tests/scenarios/out/<name>/<label>.png` + `.diff.png`. | ||
| 24 | - Blink period is 500ms (`scenario.blink_period_ns`). Main runtime uses `main.blink_period_ns` in the blink module (consistent value). | ||
| 25 | |||
| 26 | --- | ||
| 27 | |||
| 28 | ## File structure after this plan | ||
| 29 | |||
| 30 | - **`src/pty.zig`** (MODIFIED) — add `openForScenario(cols, rows) !Pty` variant that skips fork but returns a valid Pty whose `isChildAlive()` returns true. | ||
| 31 | - **`src/main.zig`** (MODIFIED) — parse `--scenario` CLI arg (dispatch mirrors the existing `--capture` pattern). `runTerminal` grows optional `scenario_state: ?*scenario.ScenarioState` param. Loop guard relaxed. Tick call added. New `runScenarios(alloc, argv)` entry point for the scenario subcommand (mirrors `capture.run`). | ||
| 32 | - **`src/scenario_runtime.zig`** (NEW) — TickIO callback implementations, golden-PNG load/diff helpers, exit code mapping. Small file (~250 LOC target). | ||
| 33 | - **`tests/scenarios/blink-bar.scenario`** (NEW) — first fixture. | ||
| 34 | - **`tests/scenarios/golden/blink-bar/*.png`** (NEW) — golden PNGs for the fixture's captures. | ||
| 35 | - **`Makefile`** (MODIFIED) — new `scenario` and `scenario-update` targets. | ||
| 36 | |||
| 37 | --- | ||
| 38 | |||
| 39 | ## Task 1: Pty.openForScenario | ||
| 40 | |||
| 41 | **Files:** | ||
| 42 | - Modify: `src/pty.zig` | ||
| 43 | |||
| 44 | **Goal:** A constructor that returns a Pty with a real master/slave fd pair but no child process. `isChildAlive()` always returns true. `deinit` closes the fd but does not wait on a child. | ||
| 45 | |||
| 46 | - [ ] **Step 1: Inspect the existing Pty shape.** | ||
| 47 | |||
| 48 | Run: `grep -n 'pub fn\|const Pty\|isChildAlive\|pub fn deinit' /home/xanderle/code/rad/waystty/src/pty.zig`. Note the current field names and isChildAlive implementation before writing the new function. | ||
| 49 | |||
| 50 | - [ ] **Step 2: Add `openForScenario` + failing test stubs.** | ||
| 51 | |||
| 52 | Append to `src/pty.zig` (before any test block that may exist at the bottom): | ||
| 53 | |||
| 54 | ```zig | ||
| 55 | /// Open a Pty without spawning a child. Master and slave fds are real, | ||
| 56 | /// the pty is usable for TIOCSWINSZ, but `isChildAlive` returns true | ||
| 57 | /// forever so a scenario-mode main loop stays scheduled. No fork(). | ||
| 58 | pub fn openForScenario(cols: u16, rows: u16) !Pty { | ||
| 59 | var master: c_int = undefined; | ||
| 60 | var slave: c_int = undefined; | ||
| 61 | var winsize = c.struct_winsize{ | ||
| 62 | .ws_row = rows, | ||
| 63 | .ws_col = cols, | ||
| 64 | .ws_xpixel = 0, | ||
| 65 | .ws_ypixel = 0, | ||
| 66 | }; | ||
| 67 | |||
| 68 | // openpty(3): allocates a pty pair without forking. | ||
| 69 | if (c.openpty(&master, &slave, null, null, &winsize) < 0) { | ||
| 70 | return error.OpenptyFailed; | ||
| 71 | } | ||
| 72 | // Slave fd isn't used by anyone in scenario mode; close to avoid leak. | ||
| 73 | _ = c.close(slave); | ||
| 74 | |||
| 75 | // Match spawn's O_NONBLOCK on master. | ||
| 76 | const flags = try std.posix.fcntl(master, std.posix.F.GETFL, 0); | ||
| 77 | const nonblock_bit: usize = @as(u32, @bitCast(std.posix.O{ .NONBLOCK = true })); | ||
| 78 | _ = try std.posix.fcntl(master, std.posix.F.SETFL, flags | nonblock_bit); | ||
| 79 | |||
| 80 | return .{ | ||
| 81 | .master_fd = master, | ||
| 82 | .child_pid = -1, // sentinel: no child | ||
| 83 | .child_reaped = true, // there's nothing to reap | ||
| 84 | }; | ||
| 85 | } | ||
| 86 | ``` | ||
| 87 | |||
| 88 | Update the existing `isChildAlive` method to special-case the `child_pid == -1` sentinel and return `true`. Shape: | ||
| 89 | |||
| 90 | ```zig | ||
| 91 | pub fn isChildAlive(self: *Pty) bool { | ||
| 92 | if (self.child_pid == -1) return true; // scenario mode: always alive | ||
| 93 | // existing logic unchanged | ||
| 94 | ... | ||
| 95 | } | ||
| 96 | ``` | ||
| 97 | |||
| 98 | And `deinit`: | ||
| 99 | |||
| 100 | ```zig | ||
| 101 | pub fn deinit(self: *Pty) void { | ||
| 102 | if (self.child_pid != -1) { | ||
| 103 | // existing child-reaping logic | ||
| 104 | ... | ||
| 105 | } | ||
| 106 | _ = std.posix.close(self.master_fd); | ||
| 107 | } | ||
| 108 | ``` | ||
| 109 | |||
| 110 | Fetch existing `isChildAlive` and `deinit` bodies before editing to avoid clobbering logic. | ||
| 111 | |||
| 112 | If `pty.h` exposes `openpty` in the cImport but doesn't on this system, the alternative is `posix_openpt` + `grantpt` + `unlockpt` + `ptsname` — more involved. Verify `openpty` is in the existing `@cInclude` (`pty.h` is). Should be fine. | ||
| 113 | |||
| 114 | - [ ] **Step 3: Test compile + basic integrity.** | ||
| 115 | |||
| 116 | Inline test (append to `src/pty.zig`): | ||
| 117 | |||
| 118 | ```zig | ||
| 119 | test "openForScenario: returns a pty with master fd and fake child" { | ||
| 120 | var p = try Pty.openForScenario(80, 24); | ||
| 121 | defer p.deinit(); | ||
| 122 | try std.testing.expect(p.master_fd >= 0); | ||
| 123 | try std.testing.expectEqual(@as(std.posix.pid_t, -1), p.child_pid); | ||
| 124 | try std.testing.expect(p.isChildAlive()); | ||
| 125 | } | ||
| 126 | ``` | ||
| 127 | |||
| 128 | - [ ] **Step 4: Run the pty tests.** | ||
| 129 | |||
| 130 | Run: `zig test src/pty.zig` — or via `zig build test` if the repo has a pty_tests step (grep build.zig to check). | ||
| 131 | |||
| 132 | Expected: test passes. If the pty module isn't wired for tests, add a `pty_tests` step to build.zig mirroring the scenario_tests pattern. | ||
| 133 | |||
| 134 | - [ ] **Step 5: Commit.** | ||
| 135 | |||
| 136 | ```bash | ||
| 137 | git add src/pty.zig build.zig | ||
| 138 | git commit -m "$(cat <<'EOF' | ||
| 139 | pty: add openForScenario — pty pair without a child | ||
| 140 | |||
| 141 | Uses openpty(3) instead of forkpty(3). master_fd is non-blocking | ||
| 142 | (same as spawn). child_pid is sentinel -1 so isChildAlive returns | ||
| 143 | true and deinit skips reaping. Used by scenario mode where the | ||
| 144 | main loop needs a live pty invariant but no shell. | ||
| 145 | |||
| 146 | Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> | ||
| 147 | EOF | ||
| 148 | )" | ||
| 149 | ``` | ||
| 150 | |||
| 151 | --- | ||
| 152 | |||
| 153 | ## Task 2: runTerminal scenario plumbing | ||
| 154 | |||
| 155 | **Files:** | ||
| 156 | - Modify: `src/main.zig` — thread optional ScenarioState + CellGeom through `runTerminal`. Relax loop guard. Do NOT add the tick call yet (Task 3). Existing non-scenario entry point continues to pass null. | ||
| 157 | |||
| 158 | **Goal:** Set up the signature so Task 3 can plug the tick call in. Verify the existing non-scenario path is byte-for-byte unchanged. | ||
| 159 | |||
| 160 | - [ ] **Step 1: Change the `runTerminal` signature.** | ||
| 161 | |||
| 162 | Find the `fn runTerminal(` declaration. Add one new parameter at the end (an opaque tick context pointer — Task 3 defines the concrete type): | ||
| 163 | |||
| 164 | ```zig | ||
| 165 | fn runTerminal( | ||
| 166 | alloc: std.mem.Allocator, | ||
| 167 | // ... existing params ... | ||
| 168 | tick_ctx: ?*scenario_runtime.RunContext, | ||
| 169 | ) !void { | ||
| 170 | _ = tick_ctx; // used in Task 3 | ||
| 171 | ``` | ||
| 172 | |||
| 173 | At the top of `src/main.zig`, add both imports: | ||
| 174 | |||
| 175 | ```zig | ||
| 176 | const scenario = @import("scenario"); | ||
| 177 | const scenario_runtime = @import("scenario_runtime"); | ||
| 178 | ``` | ||
| 179 | |||
| 180 | If the import name collides with a local identifier, rename the local — the module import should win. | ||
| 181 | |||
| 182 | Note: `scenario_runtime` doesn't exist yet as a module — Task 3 creates it. For Task 2 to compile, either (a) stub `src/scenario_runtime.zig` with a minimal `pub const RunContext = struct {};` now, OR (b) use `?*anyopaque` as the parameter type here and Task 3 refines it to `?*scenario_runtime.RunContext`. Go with **option (a)** — create a 3-line stub file in Task 2 Step 1 so the import resolves: | ||
| 183 | |||
| 184 | ```zig | ||
| 185 | // src/scenario_runtime.zig (STUB — Task 3 replaces) | ||
| 186 | pub const RunContext = struct {}; | ||
| 187 | ``` | ||
| 188 | |||
| 189 | Plus the build.zig wiring (Step 4) so the module resolves. | ||
| 190 | |||
| 191 | - [ ] **Step 2: Relax the loop guard.** | ||
| 192 | |||
| 193 | Find the main loop at `while (!window.should_close and p.isChildAlive()) {`. Replace with: | ||
| 194 | |||
| 195 | ```zig | ||
| 196 | while (!window.should_close and loopShouldRun(&p, tick_ctx)) { | ||
| 197 | ``` | ||
| 198 | |||
| 199 | Add a helper near the top of `main.zig`: | ||
| 200 | |||
| 201 | ```zig | ||
| 202 | fn loopShouldRun(p: *pty.Pty, tick_ctx: ?*scenario_runtime.RunContext) bool { | ||
| 203 | if (tick_ctx) |rc| { | ||
| 204 | // Scenario mode — check state inside RunContext. RunContext carries | ||
| 205 | // a pointer to the ScenarioState; Task 3 populates this field. | ||
| 206 | if (rc.state) |s| { | ||
| 207 | if (s.isDone()) return false; | ||
| 208 | } | ||
| 209 | } | ||
| 210 | return p.isChildAlive(); | ||
| 211 | } | ||
| 212 | ``` | ||
| 213 | |||
| 214 | For Task 2's stub, add a nullable `state: ?*scenario.ScenarioState = null` field to the stub struct so `loopShouldRun` compiles: | ||
| 215 | |||
| 216 | ```zig | ||
| 217 | // src/scenario_runtime.zig (STUB — Task 3 replaces) | ||
| 218 | const scenario = @import("scenario"); | ||
| 219 | pub const RunContext = struct { | ||
| 220 | state: ?*scenario.ScenarioState = null, | ||
| 221 | }; | ||
| 222 | ``` | ||
| 223 | |||
| 224 | In normal mode, `tick_ctx` is null and we fall through to `p.isChildAlive()`. In scenario mode, the RunContext's state is non-null and controls the loop exit. | ||
| 225 | |||
| 226 | - [ ] **Step 3: Update the existing call site to pass null.** | ||
| 227 | |||
| 228 | Find where `runTerminal` is called from `main`. Append `, null` to the call. | ||
| 229 | |||
| 230 | - [ ] **Step 4: Wire build.zig so `main.zig` can import `scenario` and `scenario_runtime`.** | ||
| 231 | |||
| 232 | Grep build.zig for the `exe_mod` (the waystty binary's module). Add: | ||
| 233 | |||
| 234 | ```zig | ||
| 235 | // scenario_runtime stub module (Task 3 populates) | ||
| 236 | const scenario_runtime_mod = b.createModule(.{ | ||
| 237 | .root_source_file = b.path("src/scenario_runtime.zig"), | ||
| 238 | .target = target, | ||
| 239 | .optimize = optimize, | ||
| 240 | }); | ||
| 241 | scenario_runtime_mod.addImport("scenario", scenario_mod); | ||
| 242 | |||
| 243 | exe_mod.addImport("scenario", scenario_mod); | ||
| 244 | exe_mod.addImport("scenario_runtime", scenario_runtime_mod); | ||
| 245 | ``` | ||
| 246 | |||
| 247 | (`imgdiff` is already an import on `scenario_mod` from Plan 1; Task 3 adds it + `renderer`, `vt`, `png` directly to `scenario_runtime_mod`.) | ||
| 248 | |||
| 249 | - [ ] **Step 5: Build + run existing tests + run waystty briefly to verify no-regression.** | ||
| 250 | |||
| 251 | ```bash | ||
| 252 | cd /home/xanderle/code/rad/waystty | ||
| 253 | zig build | ||
| 254 | zig build test-scenario | ||
| 255 | ``` | ||
| 256 | |||
| 257 | Expected: build succeeds, scenario tests still pass. | ||
| 258 | |||
| 259 | Optional smoke: launch waystty for a few seconds to verify it still starts and renders. Kill with Ctrl-C. | ||
| 260 | |||
| 261 | - [ ] **Step 6: Commit.** | ||
| 262 | |||
| 263 | ```bash | ||
| 264 | git add src/main.zig build.zig | ||
| 265 | git commit -m "$(cat <<'EOF' | ||
| 266 | main: thread optional ScenarioState through runTerminal | ||
| 267 | |||
| 268 | Signature change only — scenario_state and cell_geom_override | ||
| 269 | parameters are unused this commit. Loop guard relaxed via | ||
| 270 | loopShouldRun helper so scenario mode can exit on state.isDone(). | ||
| 271 | |||
| 272 | Existing entry point passes null — no behavior change for the | ||
| 273 | normal non-scenario path. | ||
| 274 | |||
| 275 | Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> | ||
| 276 | EOF | ||
| 277 | )" | ||
| 278 | ``` | ||
| 279 | |||
| 280 | --- | ||
| 281 | |||
| 282 | ## Task 3: Scenario entry point + tick hook + real TickIO callbacks + golden diff + exit codes | ||
| 283 | |||
| 284 | **Files:** | ||
| 285 | - Create: `src/scenario_runtime.zig` — callback implementations, golden PNG load/diff, output-directory helpers, exit code mapping, `RunContext`. | ||
| 286 | - Modify: `src/main.zig` — add `runScenarios(alloc, argv)` (mirrors `capture.run`), dispatch from the argv parser on `--scenario`, thread `*RunContext` through `runTerminal` so the tick site can invoke callbacks, add the tick call in the main loop. | ||
| 287 | - Modify: `build.zig` — register `scenario_runtime_mod` with imports (`scenario`, `png`, `imgdiff`, `renderer`, `vt`) and add as an import on `exe_mod`. | ||
| 288 | |||
| 289 | This is the largest task. Break into sub-steps. | ||
| 290 | |||
| 291 | **Design note on context threading:** `runTerminal` takes a `?*scenario_runtime.RunContext` (the "tick context"), not a bare `?*scenario.ScenarioState`. Rationale: the ScenarioState lives inside the RunContext along with the callbacks. Passing just the state would force the tick site to construct a `TickIO` from scratch, which needs access to the RunContext anyway. Simpler to pass one pointer that owns both. Update Task 2's signature retroactively — what that task wrote as `scenario_state: ?*scenario.ScenarioState` is now `tick_ctx: ?*scenario_runtime.RunContext`. Task 2's null-passes become null of the new type. The `cell_geom_override` parameter from Task 2 goes away; `RunContext` owns the cell geom if it needs to. | ||
| 292 | |||
| 293 | - [ ] **Step 1: Replace the Task 2 stub `src/scenario_runtime.zig` with the full skeleton.** | ||
| 294 | |||
| 295 | ```zig | ||
| 296 | //! Runtime glue for scenario mode. Owns the TickIO callback | ||
| 297 | //! implementations, golden-PNG IO, and result accumulation. | ||
| 298 | //! | ||
| 299 | //! Pure I/O — no main-loop code here. `src/main.zig` wires this | ||
| 300 | //! into `runTerminal` via `ScenarioTickCtx` + `scenarioTickIO`. | ||
| 301 | |||
| 302 | const std = @import("std"); | ||
| 303 | const png = @import("png"); | ||
| 304 | const scenario = @import("scenario"); | ||
| 305 | const imgdiff = @import("imgdiff"); | ||
| 306 | const renderer = @import("renderer"); | ||
| 307 | const vt = @import("vt"); | ||
| 308 | |||
| 309 | pub const Failure = union(enum) { | ||
| 310 | capture_mismatch: struct { label: []const u8, rmse: f64, max_pixel: f64 }, | ||
| 311 | assert_failed: struct { line: usize, reason: []const u8 }, | ||
| 312 | // populated as the runner encounters failures | ||
| 313 | }; | ||
| 314 | |||
| 315 | pub const RunContext = struct { | ||
| 316 | alloc: std.mem.Allocator, | ||
| 317 | scenario_name: []const u8, | ||
| 318 | update_goldens: bool, // WAYSTTY_SCENARIO_UPDATE=1 | ||
| 319 | failures: std.ArrayListUnmanaged(Failure) = .{}, | ||
| 320 | blink_flipped_this_iter: bool = false, | ||
| 321 | |||
| 322 | // Populated by runScenarios before runTerminal is called. | ||
| 323 | state: ?*scenario.ScenarioState = null, | ||
| 324 | tick_fatal: ?scenario.TickError = null, | ||
| 325 | |||
| 326 | // Rendering handles needed by the capture callback. | ||
| 327 | term: *vt.Terminal, | ||
| 328 | ctx: *renderer.Context, | ||
| 329 | // Offscreen target is created lazily on the first capture and reused | ||
| 330 | // across subsequent captures; reference semantics determined at impl time. | ||
| 331 | |||
| 332 | pub fn deinit(self: *RunContext) void { | ||
| 333 | for (self.failures.items) |f| switch (f) { | ||
| 334 | .capture_mismatch => |m| self.alloc.free(m.label), | ||
| 335 | .assert_failed => {}, // reason is static | ||
| 336 | }; | ||
| 337 | self.failures.deinit(self.alloc); | ||
| 338 | } | ||
| 339 | }; | ||
| 340 | |||
| 341 | pub fn writeBytesCb(ctx: *anyopaque, bytes: []const u8) anyerror!void { | ||
| 342 | const rc: *RunContext = @ptrCast(@alignCast(ctx)); | ||
| 343 | rc.term.write(bytes); | ||
| 344 | } | ||
| 345 | |||
| 346 | pub fn captureCb(ctx: *anyopaque, label: []const u8) anyerror!png.Image { | ||
| 347 | const rc: *RunContext = @ptrCast(@alignCast(ctx)); | ||
| 348 | // 1. Offscreen render via rc.ctx (renderer.renderToOffscreen). | ||
| 349 | // 2. Readback into a local RGBA buffer. | ||
| 350 | // 3. Encode PNG to tests/scenarios/out/<scenario>/<label>.png. | ||
| 351 | // 4. If golden exists, diff via imgdiff.compare. | ||
| 352 | // 5. On mismatch: encode diff heatmap via imgdiff.makeDiffImage, | ||
| 353 | // append to rc.failures. | ||
| 354 | // 6. On update_goldens: copy out → golden, suppress failure. | ||
| 355 | // 7. Return an owned png.Image copy of the readback (for | ||
| 356 | // ScenarioState's in-memory captures map). | ||
| 357 | _ = rc; | ||
| 358 | _ = label; | ||
| 359 | @compileError("captureCb: implement in the next step"); | ||
| 360 | } | ||
| 361 | |||
| 362 | pub fn blinkFlippedCb(ctx: *anyopaque) bool { | ||
| 363 | const rc: *RunContext = @ptrCast(@alignCast(ctx)); | ||
| 364 | const v = rc.blink_flipped_this_iter; | ||
| 365 | rc.blink_flipped_this_iter = false; | ||
| 366 | return v; | ||
| 367 | } | ||
| 368 | |||
| 369 | pub fn tickIO(rc: *RunContext) scenario.TickIO { | ||
| 370 | return .{ | ||
| 371 | .ctx = rc, | ||
| 372 | .write_bytes = writeBytesCb, | ||
| 373 | .capture = captureCb, | ||
| 374 | .blink_just_flipped = blinkFlippedCb, | ||
| 375 | }; | ||
| 376 | } | ||
| 377 | |||
| 378 | pub const ExitCode = enum(u8) { | ||
| 379 | success = 0, | ||
| 380 | parse_error = 2, | ||
| 381 | timeout = 3, | ||
| 382 | assertion_mismatch = 4, | ||
| 383 | vulkan_timeout = 5, | ||
| 384 | other_error = 6, | ||
| 385 | }; | ||
| 386 | |||
| 387 | pub fn mapTickError(err: scenario.TickError) ExitCode { | ||
| 388 | return switch (err) { | ||
| 389 | error.ScenarioTimeout => .timeout, | ||
| 390 | error.SleepUntilFlipTimeout => .timeout, | ||
| 391 | error.AssertFailed => .assertion_mismatch, | ||
| 392 | error.AssertCellWithoutCapture => .assertion_mismatch, | ||
| 393 | error.PredicateOnMissingLabel => .assertion_mismatch, | ||
| 394 | error.CallbackFailed => .other_error, | ||
| 395 | error.OutOfMemory => .other_error, | ||
| 396 | }; | ||
| 397 | } | ||
| 398 | ``` | ||
| 399 | |||
| 400 | - [ ] **Step 2: Implement `captureCb` in `src/scenario_runtime.zig`.** | ||
| 401 | |||
| 402 | This is the core of the task. Use `capture.zig` as a reference — specifically its offscreen render + readback + png.encode + file write sequence. Approximate shape: | ||
| 403 | |||
| 404 | ```zig | ||
| 405 | pub fn captureCb(ctx: *anyopaque, label: []const u8) anyerror!png.Image { | ||
| 406 | const rc: *RunContext = @ptrCast(@alignCast(ctx)); | ||
| 407 | |||
| 408 | // 1. Render to offscreen. | ||
| 409 | try rc.ctx.renderToOffscreen(/* args derived from the capture.zig pattern */); | ||
| 410 | |||
| 411 | // 2. Readback to RGBA. | ||
| 412 | const img = try rc.ctx.readbackOffscreen(rc.alloc); | ||
| 413 | |||
| 414 | // 3. Ensure output directory exists. | ||
| 415 | const out_dir = try std.fmt.allocPrint(rc.alloc, "tests/scenarios/out/{s}", .{rc.scenario_name}); | ||
| 416 | defer rc.alloc.free(out_dir); | ||
| 417 | std.fs.cwd().makePath(out_dir) catch |err| switch (err) { | ||
| 418 | error.PathAlreadyExists => {}, | ||
| 419 | else => return err, | ||
| 420 | }; | ||
| 421 | |||
| 422 | // 4. Write captured PNG. | ||
| 423 | const out_path = try std.fmt.allocPrint(rc.alloc, "{s}/{s}.png", .{ out_dir, label }); | ||
| 424 | defer rc.alloc.free(out_path); | ||
| 425 | |||
| 426 | var buf: std.ArrayList(u8) = .empty; | ||
| 427 | defer buf.deinit(rc.alloc); | ||
| 428 | try png.encode(rc.alloc, img, buf.writer(rc.alloc)); | ||
| 429 | |||
| 430 | const out_file = try std.fs.cwd().createFile(out_path, .{ .truncate = true }); | ||
| 431 | defer out_file.close(); | ||
| 432 | try out_file.writeAll(buf.items); | ||
| 433 | |||
| 434 | // 5. Golden path. | ||
| 435 | const golden_path = try std.fmt.allocPrint(rc.alloc, "tests/scenarios/golden/{s}/{s}.png", .{ rc.scenario_name, label }); | ||
| 436 | defer rc.alloc.free(golden_path); | ||
| 437 | |||
| 438 | if (rc.update_goldens) { | ||
| 439 | const g_dir = try std.fmt.allocPrint(rc.alloc, "tests/scenarios/golden/{s}", .{rc.scenario_name}); | ||
| 440 | defer rc.alloc.free(g_dir); | ||
| 441 | std.fs.cwd().makePath(g_dir) catch |err| switch (err) { | ||
| 442 | error.PathAlreadyExists => {}, | ||
| 443 | else => return err, | ||
| 444 | }; | ||
| 445 | const g_file = try std.fs.cwd().createFile(golden_path, .{ .truncate = true }); | ||
| 446 | defer g_file.close(); | ||
| 447 | try g_file.writeAll(buf.items); | ||
| 448 | // No diff check when updating. | ||
| 449 | } else { | ||
| 450 | // Attempt to load golden; if missing, record as mismatch. | ||
| 451 | const golden_bytes = std.fs.cwd().readFileAlloc(rc.alloc, golden_path, 64 * 1024 * 1024) catch |err| { | ||
| 452 | if (err == error.FileNotFound) { | ||
| 453 | // Record as failure with a clear message. | ||
| 454 | const lbl = try rc.alloc.dupe(u8, label); | ||
| 455 | try rc.failures.append(rc.alloc, .{ .capture_mismatch = .{ | ||
| 456 | .label = lbl, | ||
| 457 | .rmse = 1.0, | ||
| 458 | .max_pixel = 1.0, | ||
| 459 | }}); | ||
| 460 | return img; | ||
| 461 | } | ||
| 462 | return err; | ||
| 463 | }; | ||
| 464 | defer rc.alloc.free(golden_bytes); | ||
| 465 | var golden = try png.decode(rc.alloc, golden_bytes); | ||
| 466 | defer golden.deinit(rc.alloc); | ||
| 467 | |||
| 468 | if (golden.width != img.width or golden.height != img.height) { | ||
| 469 | const lbl = try rc.alloc.dupe(u8, label); | ||
| 470 | try rc.failures.append(rc.alloc, .{ .capture_mismatch = .{ | ||
| 471 | .label = lbl, | ||
| 472 | .rmse = 1.0, | ||
| 473 | .max_pixel = 1.0, | ||
| 474 | }}); | ||
| 475 | } else { | ||
| 476 | const diff_res = try imgdiff.compare(img, golden); | ||
| 477 | if (diff_res.rmse > imgdiff.RMSE_DEFAULT or diff_res.max_pixel > imgdiff.PIXEL_MAX_DEFAULT) { | ||
| 478 | const lbl = try rc.alloc.dupe(u8, label); | ||
| 479 | try rc.failures.append(rc.alloc, .{ .capture_mismatch = .{ | ||
| 480 | .label = lbl, | ||
| 481 | .rmse = diff_res.rmse, | ||
| 482 | .max_pixel = diff_res.max_pixel, | ||
| 483 | }}); | ||
| 484 | // Write diff heatmap. | ||
| 485 | const heat = try imgdiff.makeDiffImage(rc.alloc, img, golden); | ||
| 486 | defer rc.alloc.free(heat.pixels); | ||
| 487 | var heat_buf: std.ArrayList(u8) = .empty; | ||
| 488 | defer heat_buf.deinit(rc.alloc); | ||
| 489 | try png.encode(rc.alloc, heat, heat_buf.writer(rc.alloc)); | ||
| 490 | const heat_path = try std.fmt.allocPrint(rc.alloc, "{s}/{s}.diff.png", .{ out_dir, label }); | ||
| 491 | defer rc.alloc.free(heat_path); | ||
| 492 | const heat_file = try std.fs.cwd().createFile(heat_path, .{ .truncate = true }); | ||
| 493 | defer heat_file.close(); | ||
| 494 | try heat_file.writeAll(heat_buf.items); | ||
| 495 | } | ||
| 496 | } | ||
| 497 | } | ||
| 498 | |||
| 499 | return img; | ||
| 500 | } | ||
| 501 | ``` | ||
| 502 | |||
| 503 | This is a sketch — real implementation must get the `renderToOffscreen` call's arguments right by cross-referencing `src/capture.zig`. | ||
| 504 | |||
| 505 | - [ ] **Step 3: Add the tick call in the main loop.** | ||
| 506 | |||
| 507 | Inside `runTerminal` (the function whose signature Task 2 grew), after the `tickBlinkPhase` block (around L350-355) and the blink flip bookkeeping, and BEFORE the `if (!render_pending) continue` bail (around L423), add: | ||
| 508 | |||
| 509 | ```zig | ||
| 510 | // --- scenario tick --- | ||
| 511 | // Lands after the blink phase flip so a just-flipped blink correctly | ||
| 512 | // informs io.blink_just_flipped on THIS tick. Lands before the | ||
| 513 | // render_pending bail so a scheduled tick can set render_pending true. | ||
| 514 | if (tick_ctx) |rc| { | ||
| 515 | const s = rc.state orelse break; | ||
| 516 | // Observe blink flip for this iteration (tick.flipped was set in tickBlinkPhase above). | ||
| 517 | if (tick.flipped) rc.blink_flipped_this_iter = true; | ||
| 518 | |||
| 519 | const tick_now = std.time.nanoTimestamp(); | ||
| 520 | const outcome = s.tick(tick_now, scenario_runtime.tickIO(rc)) catch |err| { | ||
| 521 | rc.tick_fatal = err; | ||
| 522 | window.should_close = true; | ||
| 523 | break; | ||
| 524 | }; | ||
| 525 | switch (outcome) { | ||
| 526 | .working => {}, | ||
| 527 | .done => window.should_close = true, | ||
| 528 | } | ||
| 529 | } | ||
| 530 | ``` | ||
| 531 | |||
| 532 | `tick_fatal: ?scenario.TickError = null` is a field on `RunContext` (Step 1 added it). `rc.state` is the ScenarioState pointer, also a RunContext field from Step 1. `tick.flipped` is the already-existing blink bookkeeping local from the cursor-blink feature commits. | ||
| 533 | |||
| 534 | - [ ] **Step 4: Add `runScenarios` entry point in `src/main.zig`.** | ||
| 535 | |||
| 536 | Mirrors `capture.run`. Stand up a wayland connection + vulkan context + pty (via `openForScenario`) + offscreen target, parse the scenario file, build a `RunContext`, wire into `scenarioTickIO`, call `runTerminal(..., &state, null)`, map any `scenario_tick_fatal` to exit code, report golden diffs / assertion failures, exit with the right code. | ||
| 537 | |||
| 538 | ```zig | ||
| 539 | pub fn runScenarios(alloc: std.mem.Allocator, argv: []const [:0]const u8) !void { | ||
| 540 | if (argv.len < 2) { | ||
| 541 | std.debug.print("usage: waystty --scenario <path>\n", .{}); | ||
| 542 | std.process.exit(2); | ||
| 543 | } | ||
| 544 | const scenario_path = argv[1]; | ||
| 545 | |||
| 546 | // Read + parse scenario. | ||
| 547 | const source = std.fs.cwd().readFileAlloc(alloc, scenario_path, 1 * 1024 * 1024) catch |err| { | ||
| 548 | std.debug.print("scenario: cannot read {s}: {s}\n", .{ scenario_path, @errorName(err) }); | ||
| 549 | std.process.exit(2); | ||
| 550 | }; | ||
| 551 | defer alloc.free(source); | ||
| 552 | |||
| 553 | var diag: scenario.Diagnostic = .{}; | ||
| 554 | var s = scenario.parse(alloc, source, &diag) catch { | ||
| 555 | std.debug.print("scenario: {s}:{d}: {s}\n", .{ scenario_path, diag.line, diag.message }); | ||
| 556 | std.process.exit(2); | ||
| 557 | }; | ||
| 558 | defer s.deinit(); | ||
| 559 | |||
| 560 | // Derive scenario_name from the path (basename without extension). | ||
| 561 | const scenario_name = basenameNoExt(scenario_path); | ||
| 562 | |||
| 563 | // Stand up the runtime (wayland + vulkan + renderer + term + pty-for-scenario). | ||
| 564 | // See src/capture.zig for the pattern. Reuse as much as possible. | ||
| 565 | |||
| 566 | // ... setup elided — see capture.zig for reference ... | ||
| 567 | |||
| 568 | const update = std.posix.getenv("WAYSTTY_SCENARIO_UPDATE") != null; | ||
| 569 | var rc: scenario_runtime.RunContext = .{ | ||
| 570 | .alloc = alloc, | ||
| 571 | .scenario_name = scenario_name, | ||
| 572 | .update_goldens = update, | ||
| 573 | .last_blink_on = true, | ||
| 574 | .term = &term, | ||
| 575 | .ctx = &ctx, | ||
| 576 | }; | ||
| 577 | defer rc.deinit(); | ||
| 578 | |||
| 579 | var state = scenario.ScenarioState.init(alloc, &s, std.time.nanoTimestamp(), .{ | ||
| 580 | .cell_w_px = cell_w, | ||
| 581 | .cell_h_px = cell_h, | ||
| 582 | }); | ||
| 583 | defer state.deinit(); | ||
| 584 | |||
| 585 | // Wire scenarioTickCtx — replaces the Task 3 stub: | ||
| 586 | scenario_tick_ctx = &rc; | ||
| 587 | |||
| 588 | // Run the main loop with scenario mode. | ||
| 589 | try runTerminal(alloc, /* existing args */, &state, .{ | ||
| 590 | .cell_w_px = cell_w, .cell_h_px = cell_h, | ||
| 591 | }); | ||
| 592 | |||
| 593 | // On exit, decide the exit code. | ||
| 594 | if (scenario_tick_fatal) |err| { | ||
| 595 | const code = scenario_runtime.mapTickError(err); | ||
| 596 | std.debug.print("scenario {s}: {s}\n", .{ scenario_name, @errorName(err) }); | ||
| 597 | std.process.exit(@intFromEnum(code)); | ||
| 598 | } | ||
| 599 | |||
| 600 | if (rc.failures.items.len > 0) { | ||
| 601 | std.debug.print("scenario {s}: {d} failure(s):\n", .{ scenario_name, rc.failures.items.len }); | ||
| 602 | for (rc.failures.items) |f| switch (f) { | ||
| 603 | .capture_mismatch => |m| std.debug.print(" capture {s}: RMSE={d:.4}% max={d:.4}%\n", .{ m.label, m.rmse * 100.0, m.max_pixel * 100.0 }), | ||
| 604 | .assert_failed => |a| std.debug.print(" assert line {d}: {s}\n", .{ a.line, a.reason }), | ||
| 605 | }; | ||
| 606 | if (!update) std.process.exit(@intFromEnum(scenario_runtime.ExitCode.assertion_mismatch)); | ||
| 607 | } | ||
| 608 | |||
| 609 | std.debug.print("scenario {s}: OK\n", .{scenario_name}); | ||
| 610 | std.process.exit(0); | ||
| 611 | } | ||
| 612 | |||
| 613 | fn basenameNoExt(path: []const u8) []const u8 { | ||
| 614 | const base = std.fs.path.basename(path); | ||
| 615 | const dot = std.mem.lastIndexOfScalar(u8, base, '.') orelse return base; | ||
| 616 | return base[0..dot]; | ||
| 617 | } | ||
| 618 | ``` | ||
| 619 | |||
| 620 | This is also sketched — implementer fills wayland+vulkan setup by following `capture.zig`'s `run` verbatim for those pieces, replacing capture's PTY spawn with `openForScenario` and replacing the one-shot render with the scenario ticker. | ||
| 621 | |||
| 622 | Two things to be careful about: | ||
| 623 | - `scenario_tick_ctx` is a module-level variable Task 3 declared. It needs to be `*RunContext` typed — adjust Task 3's declaration to `var scenario_tick_ctx: ?*scenario_runtime.RunContext = null;` and `scenarioTickIO(ctx.?)` when dispatching. That keeps normal mode (no context) panic-free via a null check at the tick site. | ||
| 624 | |||
| 625 | - [ ] **Step 5: Dispatch `--scenario` in `main`.** | ||
| 626 | |||
| 627 | Near the top of `main`, add a branch that detects `--scenario` in argv and calls `runScenarios`: | ||
| 628 | |||
| 629 | ```zig | ||
| 630 | pub fn main() !void { | ||
| 631 | var gpa = std.heap.DebugAllocator(.{}){}; | ||
| 632 | defer _ = gpa.deinit(); | ||
| 633 | const alloc = gpa.allocator(); | ||
| 634 | |||
| 635 | const args = try std.process.argsAlloc(alloc); | ||
| 636 | defer std.process.argsFree(alloc, args); | ||
| 637 | |||
| 638 | if (args.len >= 2 and std.mem.eql(u8, args[1], "--scenario")) { | ||
| 639 | return runScenarios(alloc, args[1..]); | ||
| 640 | } | ||
| 641 | if (args.len >= 2 and std.mem.eql(u8, args[1], "--capture")) { | ||
| 642 | return capture.run(alloc, args[1..]); | ||
| 643 | } | ||
| 644 | // ... existing normal-mode dispatch ... | ||
| 645 | } | ||
| 646 | ``` | ||
| 647 | |||
| 648 | - [ ] **Step 6: Finalize `scenario_runtime_mod` in build.zig.** | ||
| 649 | |||
| 650 | Task 2 created a stub module with only `scenario` as an import. Extend it now with the remaining imports the real implementation needs: | ||
| 651 | |||
| 652 | ```zig | ||
| 653 | scenario_runtime_mod.addImport("png", png_mod); | ||
| 654 | scenario_runtime_mod.addImport("imgdiff", imgdiff_lib_mod); | ||
| 655 | scenario_runtime_mod.addImport("renderer", renderer_mod); | ||
| 656 | scenario_runtime_mod.addImport("vt", vt_mod); | ||
| 657 | ``` | ||
| 658 | |||
| 659 | (These three `addImport` calls are added to the existing `scenario_runtime_mod` created in Task 2 Step 4.) | ||
| 660 | |||
| 661 | - [ ] **Step 7: Build + run existing tests.** | ||
| 662 | |||
| 663 | ```bash | ||
| 664 | zig build | ||
| 665 | zig build test-scenario | ||
| 666 | ``` | ||
| 667 | |||
| 668 | Expected: builds, scenario tests still pass. No scenario fixture runs yet (that's Task 4). | ||
| 669 | |||
| 670 | - [ ] **Step 8: Commit.** | ||
| 671 | |||
| 672 | ```bash | ||
| 673 | git add src/scenario_runtime.zig src/main.zig build.zig | ||
| 674 | git commit -m "$(cat <<'EOF' | ||
| 675 | main: add scenario entry point + runtime callbacks | ||
| 676 | |||
| 677 | --scenario <path> spins up a full waystty runtime (wayland + | ||
| 678 | vulkan + real main loop with blink timer), plays the scenario | ||
| 679 | file through scenario.tick, captures offscreen frames on | ||
| 680 | capture directives, diffs against golden PNGs, and exits with | ||
| 681 | a documented code (0/2/3/4/5/6). | ||
| 682 | |||
| 683 | src/scenario_runtime.zig owns the TickIO callback | ||
| 684 | implementations: write_bytes -> term.write, capture -> | ||
| 685 | offscreen render + PNG + golden diff, blink_just_flipped -> | ||
| 686 | per-iter flag toggled by the main loop. | ||
| 687 | |||
| 688 | First fixture lands in the next commit. | ||
| 689 | |||
| 690 | Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> | ||
| 691 | EOF | ||
| 692 | )" | ||
| 693 | ``` | ||
| 694 | |||
| 695 | --- | ||
| 696 | |||
| 697 | ## Task 4: First fixture + Makefile targets | ||
| 698 | |||
| 699 | **Files:** | ||
| 700 | - Create: `tests/scenarios/blink-bar.scenario` | ||
| 701 | - Create: `tests/scenarios/golden/blink-bar/*.png` (generated in step below) | ||
| 702 | - Modify: `Makefile` — add `scenario` and `scenario-update` targets | ||
| 703 | - Modify: `.gitignore` — add `tests/scenarios/out/` | ||
| 704 | |||
| 705 | - [ ] **Step 1: Write the fixture file.** | ||
| 706 | |||
| 707 | Create `tests/scenarios/blink-bar.scenario`: | ||
| 708 | |||
| 709 | ``` | ||
| 710 | # Blinking bar cursor: DECSCUSR 5 then observe two phases. | ||
| 711 | size 80 24 | ||
| 712 | timeout 5000ms | ||
| 713 | |||
| 714 | # Inject DECSCUSR 5 directly (no shell) - blinking bar. | ||
| 715 | bytes "\e[5 q" | ||
| 716 | |||
| 717 | # Give blink state machine time to arm. | ||
| 718 | sleep 100ms | ||
| 719 | capture before-flip | ||
| 720 | |||
| 721 | # Advance past the first phase flip (500ms period). | ||
| 722 | sleep 500ms | ||
| 723 | capture after-flip | ||
| 724 | ``` | ||
| 725 | |||
| 726 | - [ ] **Step 2: Update .gitignore.** | ||
| 727 | |||
| 728 | Append to `.gitignore`: | ||
| 729 | |||
| 730 | ``` | ||
| 731 | tests/scenarios/out/ | ||
| 732 | ``` | ||
| 733 | |||
| 734 | - [ ] **Step 3: Add Makefile targets.** | ||
| 735 | |||
| 736 | Append to `Makefile`: | ||
| 737 | |||
| 738 | ```makefile | ||
| 739 | .PHONY: scenario scenario-update | ||
| 740 | |||
| 741 | scenario: | ||
| 742 | zig build | ||
| 743 | @for f in tests/scenarios/*.scenario; do \ | ||
| 744 | echo "=== $$f ==="; \ | ||
| 745 | ./zig-out/bin/waystty --scenario "$$f" || exit $$?; \ | ||
| 746 | done | ||
| 747 | |||
| 748 | scenario-update: | ||
| 749 | zig build | ||
| 750 | @for f in tests/scenarios/*.scenario; do \ | ||
| 751 | echo "=== $$f (update goldens) ==="; \ | ||
| 752 | WAYSTTY_SCENARIO_UPDATE=1 ./zig-out/bin/waystty --scenario "$$f" || exit $$?; \ | ||
| 753 | done | ||
| 754 | ``` | ||
| 755 | |||
| 756 | - [ ] **Step 4: Generate goldens for the fixture.** | ||
| 757 | |||
| 758 | ```bash | ||
| 759 | cd /home/xanderle/code/rad/waystty | ||
| 760 | make scenario-update | ||
| 761 | ``` | ||
| 762 | |||
| 763 | Expected: writes `tests/scenarios/golden/blink-bar/before-flip.png` and `tests/scenarios/golden/blink-bar/after-flip.png`. Exit 0. | ||
| 764 | |||
| 765 | Inspect the two PNGs manually (or with `imgdiff`): they should be black backgrounds with a single bright bar at the cursor position in column 0, row 0. The two images should DIFFER — before-flip shows the cursor bar, after-flip shows no cursor (phase off). | ||
| 766 | |||
| 767 | If they look identical, either the blink timer didn't flip (investigate — perhaps blink timer arm conditions unmet in scenario mode) or the capture timing is off. | ||
| 768 | |||
| 769 | - [ ] **Step 5: Run the fixture against goldens.** | ||
| 770 | |||
| 771 | ```bash | ||
| 772 | make scenario | ||
| 773 | ``` | ||
| 774 | |||
| 775 | Expected: `scenario blink-bar: OK`, exit 0. | ||
| 776 | |||
| 777 | - [ ] **Step 6: Verify mutation detection.** | ||
| 778 | |||
| 779 | Intentionally break one golden (e.g. `rm tests/scenarios/golden/blink-bar/after-flip.png` and `touch` a random file in its place, or edit the scenario to sleep 250ms instead of 500ms so the capture misses the flip). | ||
| 780 | |||
| 781 | ```bash | ||
| 782 | make scenario || echo "expected non-zero: $?" | ||
| 783 | ``` | ||
| 784 | |||
| 785 | Expected: the runner detects the mismatch, writes `tests/scenarios/out/blink-bar/after-flip.diff.png`, exits with code 4. | ||
| 786 | |||
| 787 | Restore the golden (either via `make scenario-update` or `git checkout tests/scenarios/golden/blink-bar/`). | ||
| 788 | |||
| 789 | - [ ] **Step 7: Commit.** | ||
| 790 | |||
| 791 | ```bash | ||
| 792 | git add tests/scenarios/blink-bar.scenario tests/scenarios/golden/blink-bar/ Makefile .gitignore | ||
| 793 | git commit -m "$(cat <<'EOF' | ||
| 794 | scenarios: first fixture blink-bar + Makefile targets | ||
| 795 | |||
| 796 | blink-bar.scenario drives DECSCUSR 5 and captures two frames | ||
| 797 | separated by 500ms to observe the blink-bar cursor on/off | ||
| 798 | phase boundary. Proves the scenario runner end-to-end: | ||
| 799 | |||
| 800 | - --scenario flag parses and dispatches | ||
| 801 | - bytes directive injects into the VT parser | ||
| 802 | - real main loop fires the blink timer | ||
| 803 | - capture directive offscreens + PNG-writes + diffs goldens | ||
| 804 | - exit code 0 on pass, 4 on golden mismatch | ||
| 805 | |||
| 806 | make scenario — run all *.scenario fixtures | ||
| 807 | make scenario-update — regenerate goldens (WAYSTTY_SCENARIO_UPDATE=1) | ||
| 808 | |||
| 809 | Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> | ||
| 810 | EOF | ||
| 811 | )" | ||
| 812 | ``` | ||
| 813 | |||
| 814 | --- | ||
| 815 | |||
| 816 | ## Post-plan verification | ||
| 817 | |||
| 818 | - [ ] `make scenario` — expected: "scenario blink-bar: OK", exit 0. | ||
| 819 | - [ ] `make scenario-update` — regenerates goldens. Git should report them as unchanged if the generator is deterministic. | ||
| 820 | - [ ] `zig build` — no regressions. | ||
| 821 | - [ ] `zig build test-scenario` — all 50 pure scenario tests still pass. | ||
| 822 | - [ ] Manually launch `./zig-out/bin/waystty` (normal mode, no --scenario) — verify nothing regressed; cursor still blinks, typing works, resize works. | ||
| 823 | - [ ] `rm tests/scenarios/golden/blink-bar/after-flip.png; make scenario || true` — verify mismatch path produces a `diff.png` and non-zero exit. | ||
| 824 | |||
| 825 | --- | ||
| 826 | |||
| 827 | ## Self-review coverage check | ||
| 828 | |||
| 829 | Spec "Implementation phasing" step 3: "Main-loop integration + first fixtures. `--scenario` flag, `runTerminal` hook, one `blink-bar.scenario` fixture with golden PNGs, `make scenario` target. This plan is the visible landing." — every bullet covered. | ||
| 830 | |||
| 831 | Spec "Architecture" tick integration: "immediately after the blink phase check and before the `if (!render_pending) continue` bail" — Task 3 Step 1 places it there exactly. | ||
| 832 | |||
| 833 | Spec "No dummy child process" — Task 1 uses `openpty(3)` + `child_pid = -1` sentinel. No fork, no zombie. | ||
| 834 | |||
| 835 | Spec "Golden update mode: WAYSTTY_SCENARIO_UPDATE=1" — Task 4 reads the env var and Task 5 wires the `scenario-update` target around it. | ||
| 836 | |||
| 837 | Spec "Exit codes 0/2/3/4/5/6 with distinct semantics for Vulkan-timeout vs regression" — Task 4 Step 1 maps `scenario.TickError` variants to `ExitCode` distinctly (VkTimeout via `error.CallbackFailed` is conservative — the renderer itself returns `vk_sync` errors through the TickIO capture callback which wraps to `CallbackFailed`, so exit code 5 for flake-vs-6 for other is not preserved yet; file as follow-up if needed). | ||
| 838 | |||
| 839 | Spec "Non-goals: No wayland event path testing." — this plan does not add any, correct. | ||