3f961039
Plan 3 of 3 for scenario runner: main-loop integration
a73x 2026-04-19 13:20
Four tasks: 1. Pty.openForScenario — pty without fork, isChildAlive always true 2. runTerminal signature growth + loop guard relax (stub scenario_runtime) 3. Scenario entry point + real TickIO callbacks + golden diff + exit codes 4. First fixture (blink-bar.scenario) + Makefile targets Integration lands inside the existing runTerminal main loop so the real blink timer / frame_loop / Vulkan render participate — no parallel mode. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
diff --git a/docs/superpowers/plans/2026-04-19-scenario-main-loop-integration.md b/docs/superpowers/plans/2026-04-19-scenario-main-loop-integration.md new file mode 100644 index 0000000..6492d1a --- /dev/null +++ b/docs/superpowers/plans/2026-04-19-scenario-main-loop-integration.md @@ -0,0 +1,839 @@ # Scenario Runner Main-Loop Integration Plan > **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. **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. **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. **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. **Reference spec:** `docs/superpowers/specs/2026-04-19-scenario-runner-design.md` --- ## Reference facts (grep once, reuse) - 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`. - `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). - `src/pty.zig` defines `Pty` + `Pty.spawn(SpawnOptions) !Pty`. `master_fd: fd_t`, `child_pid: pid_t`, `isChildAlive()` method. - `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. - 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. - Spec: `WAYSTTY_SCENARIO_UPDATE=1` rewrites goldens and exits 0 on mismatch. - 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`. - Blink period is 500ms (`scenario.blink_period_ns`). Main runtime uses `main.blink_period_ns` in the blink module (consistent value). --- ## File structure after this plan - **`src/pty.zig`** (MODIFIED) — add `openForScenario(cols, rows) !Pty` variant that skips fork but returns a valid Pty whose `isChildAlive()` returns true. - **`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`). - **`src/scenario_runtime.zig`** (NEW) — TickIO callback implementations, golden-PNG load/diff helpers, exit code mapping. Small file (~250 LOC target). - **`tests/scenarios/blink-bar.scenario`** (NEW) — first fixture. - **`tests/scenarios/golden/blink-bar/*.png`** (NEW) — golden PNGs for the fixture's captures. - **`Makefile`** (MODIFIED) — new `scenario` and `scenario-update` targets. --- ## Task 1: Pty.openForScenario **Files:** - Modify: `src/pty.zig` **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. - [ ] **Step 1: Inspect the existing Pty shape.** 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. - [ ] **Step 2: Add `openForScenario` + failing test stubs.** Append to `src/pty.zig` (before any test block that may exist at the bottom): ```zig /// Open a Pty without spawning a child. Master and slave fds are real, /// the pty is usable for TIOCSWINSZ, but `isChildAlive` returns true /// forever so a scenario-mode main loop stays scheduled. No fork(). pub fn openForScenario(cols: u16, rows: u16) !Pty { var master: c_int = undefined; var slave: c_int = undefined; var winsize = c.struct_winsize{ .ws_row = rows, .ws_col = cols, .ws_xpixel = 0, .ws_ypixel = 0, }; // openpty(3): allocates a pty pair without forking. if (c.openpty(&master, &slave, null, null, &winsize) < 0) { return error.OpenptyFailed; } // Slave fd isn't used by anyone in scenario mode; close to avoid leak. _ = c.close(slave); // Match spawn's O_NONBLOCK on master. const flags = try std.posix.fcntl(master, std.posix.F.GETFL, 0); const nonblock_bit: usize = @as(u32, @bitCast(std.posix.O{ .NONBLOCK = true })); _ = try std.posix.fcntl(master, std.posix.F.SETFL, flags | nonblock_bit); return .{ .master_fd = master, .child_pid = -1, // sentinel: no child .child_reaped = true, // there's nothing to reap }; } ``` Update the existing `isChildAlive` method to special-case the `child_pid == -1` sentinel and return `true`. Shape: ```zig pub fn isChildAlive(self: *Pty) bool { if (self.child_pid == -1) return true; // scenario mode: always alive // existing logic unchanged ... } ``` And `deinit`: ```zig pub fn deinit(self: *Pty) void { if (self.child_pid != -1) { // existing child-reaping logic ... } _ = std.posix.close(self.master_fd); } ``` Fetch existing `isChildAlive` and `deinit` bodies before editing to avoid clobbering logic. 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. - [ ] **Step 3: Test compile + basic integrity.** Inline test (append to `src/pty.zig`): ```zig test "openForScenario: returns a pty with master fd and fake child" { var p = try Pty.openForScenario(80, 24); defer p.deinit(); try std.testing.expect(p.master_fd >= 0); try std.testing.expectEqual(@as(std.posix.pid_t, -1), p.child_pid); try std.testing.expect(p.isChildAlive()); } ``` - [ ] **Step 4: Run the pty tests.** Run: `zig test src/pty.zig` — or via `zig build test` if the repo has a pty_tests step (grep build.zig to check). 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. - [ ] **Step 5: Commit.** ```bash git add src/pty.zig build.zig git commit -m "$(cat <<'EOF' pty: add openForScenario — pty pair without a child Uses openpty(3) instead of forkpty(3). master_fd is non-blocking (same as spawn). child_pid is sentinel -1 so isChildAlive returns true and deinit skips reaping. Used by scenario mode where the main loop needs a live pty invariant but no shell. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> EOF )" ``` --- ## Task 2: runTerminal scenario plumbing **Files:** - 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. **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. - [ ] **Step 1: Change the `runTerminal` signature.** Find the `fn runTerminal(` declaration. Add one new parameter at the end (an opaque tick context pointer — Task 3 defines the concrete type): ```zig fn runTerminal( alloc: std.mem.Allocator, // ... existing params ... tick_ctx: ?*scenario_runtime.RunContext, ) !void { _ = tick_ctx; // used in Task 3 ``` At the top of `src/main.zig`, add both imports: ```zig const scenario = @import("scenario"); const scenario_runtime = @import("scenario_runtime"); ``` If the import name collides with a local identifier, rename the local — the module import should win. 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: ```zig // src/scenario_runtime.zig (STUB — Task 3 replaces) pub const RunContext = struct {}; ``` Plus the build.zig wiring (Step 4) so the module resolves. - [ ] **Step 2: Relax the loop guard.** Find the main loop at `while (!window.should_close and p.isChildAlive()) {`. Replace with: ```zig while (!window.should_close and loopShouldRun(&p, tick_ctx)) { ``` Add a helper near the top of `main.zig`: ```zig fn loopShouldRun(p: *pty.Pty, tick_ctx: ?*scenario_runtime.RunContext) bool { if (tick_ctx) |rc| { // Scenario mode — check state inside RunContext. RunContext carries // a pointer to the ScenarioState; Task 3 populates this field. if (rc.state) |s| { if (s.isDone()) return false; } } return p.isChildAlive(); } ``` For Task 2's stub, add a nullable `state: ?*scenario.ScenarioState = null` field to the stub struct so `loopShouldRun` compiles: ```zig // src/scenario_runtime.zig (STUB — Task 3 replaces) const scenario = @import("scenario"); pub const RunContext = struct { state: ?*scenario.ScenarioState = null, }; ``` 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. - [ ] **Step 3: Update the existing call site to pass null.** Find where `runTerminal` is called from `main`. Append `, null` to the call. - [ ] **Step 4: Wire build.zig so `main.zig` can import `scenario` and `scenario_runtime`.** Grep build.zig for the `exe_mod` (the waystty binary's module). Add: ```zig // scenario_runtime stub module (Task 3 populates) const scenario_runtime_mod = b.createModule(.{ .root_source_file = b.path("src/scenario_runtime.zig"), .target = target, .optimize = optimize, }); scenario_runtime_mod.addImport("scenario", scenario_mod); exe_mod.addImport("scenario", scenario_mod); exe_mod.addImport("scenario_runtime", scenario_runtime_mod); ``` (`imgdiff` is already an import on `scenario_mod` from Plan 1; Task 3 adds it + `renderer`, `vt`, `png` directly to `scenario_runtime_mod`.) - [ ] **Step 5: Build + run existing tests + run waystty briefly to verify no-regression.** ```bash cd /home/xanderle/code/rad/waystty zig build zig build test-scenario ``` Expected: build succeeds, scenario tests still pass. Optional smoke: launch waystty for a few seconds to verify it still starts and renders. Kill with Ctrl-C. - [ ] **Step 6: Commit.** ```bash git add src/main.zig build.zig git commit -m "$(cat <<'EOF' main: thread optional ScenarioState through runTerminal Signature change only — scenario_state and cell_geom_override parameters are unused this commit. Loop guard relaxed via loopShouldRun helper so scenario mode can exit on state.isDone(). Existing entry point passes null — no behavior change for the normal non-scenario path. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> EOF )" ``` --- ## Task 3: Scenario entry point + tick hook + real TickIO callbacks + golden diff + exit codes **Files:** - Create: `src/scenario_runtime.zig` — callback implementations, golden PNG load/diff, output-directory helpers, exit code mapping, `RunContext`. - 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. - Modify: `build.zig` — register `scenario_runtime_mod` with imports (`scenario`, `png`, `imgdiff`, `renderer`, `vt`) and add as an import on `exe_mod`. This is the largest task. Break into sub-steps. **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. - [ ] **Step 1: Replace the Task 2 stub `src/scenario_runtime.zig` with the full skeleton.** ```zig //! Runtime glue for scenario mode. Owns the TickIO callback //! implementations, golden-PNG IO, and result accumulation. //! //! Pure I/O — no main-loop code here. `src/main.zig` wires this //! into `runTerminal` via `ScenarioTickCtx` + `scenarioTickIO`. const std = @import("std"); const png = @import("png"); const scenario = @import("scenario"); const imgdiff = @import("imgdiff"); const renderer = @import("renderer"); const vt = @import("vt"); pub const Failure = union(enum) { capture_mismatch: struct { label: []const u8, rmse: f64, max_pixel: f64 }, assert_failed: struct { line: usize, reason: []const u8 }, // populated as the runner encounters failures }; pub const RunContext = struct { alloc: std.mem.Allocator, scenario_name: []const u8, update_goldens: bool, // WAYSTTY_SCENARIO_UPDATE=1 failures: std.ArrayListUnmanaged(Failure) = .{}, blink_flipped_this_iter: bool = false, // Populated by runScenarios before runTerminal is called. state: ?*scenario.ScenarioState = null, tick_fatal: ?scenario.TickError = null, // Rendering handles needed by the capture callback. term: *vt.Terminal, ctx: *renderer.Context, // Offscreen target is created lazily on the first capture and reused // across subsequent captures; reference semantics determined at impl time. pub fn deinit(self: *RunContext) void { for (self.failures.items) |f| switch (f) { .capture_mismatch => |m| self.alloc.free(m.label), .assert_failed => {}, // reason is static }; self.failures.deinit(self.alloc); } }; pub fn writeBytesCb(ctx: *anyopaque, bytes: []const u8) anyerror!void { const rc: *RunContext = @ptrCast(@alignCast(ctx)); rc.term.write(bytes); } pub fn captureCb(ctx: *anyopaque, label: []const u8) anyerror!png.Image { const rc: *RunContext = @ptrCast(@alignCast(ctx)); // 1. Offscreen render via rc.ctx (renderer.renderToOffscreen). // 2. Readback into a local RGBA buffer. // 3. Encode PNG to tests/scenarios/out/<scenario>/<label>.png. // 4. If golden exists, diff via imgdiff.compare. // 5. On mismatch: encode diff heatmap via imgdiff.makeDiffImage, // append to rc.failures. // 6. On update_goldens: copy out → golden, suppress failure. // 7. Return an owned png.Image copy of the readback (for // ScenarioState's in-memory captures map). _ = rc; _ = label; @compileError("captureCb: implement in the next step"); } pub fn blinkFlippedCb(ctx: *anyopaque) bool { const rc: *RunContext = @ptrCast(@alignCast(ctx)); const v = rc.blink_flipped_this_iter; rc.blink_flipped_this_iter = false; return v; } pub fn tickIO(rc: *RunContext) scenario.TickIO { return .{ .ctx = rc, .write_bytes = writeBytesCb, .capture = captureCb, .blink_just_flipped = blinkFlippedCb, }; } pub const ExitCode = enum(u8) { success = 0, parse_error = 2, timeout = 3, assertion_mismatch = 4, vulkan_timeout = 5, other_error = 6, }; pub fn mapTickError(err: scenario.TickError) ExitCode { return switch (err) { error.ScenarioTimeout => .timeout, error.SleepUntilFlipTimeout => .timeout, error.AssertFailed => .assertion_mismatch, error.AssertCellWithoutCapture => .assertion_mismatch, error.PredicateOnMissingLabel => .assertion_mismatch, error.CallbackFailed => .other_error, error.OutOfMemory => .other_error, }; } ``` - [ ] **Step 2: Implement `captureCb` in `src/scenario_runtime.zig`.** 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: ```zig pub fn captureCb(ctx: *anyopaque, label: []const u8) anyerror!png.Image { const rc: *RunContext = @ptrCast(@alignCast(ctx)); // 1. Render to offscreen. try rc.ctx.renderToOffscreen(/* args derived from the capture.zig pattern */); // 2. Readback to RGBA. const img = try rc.ctx.readbackOffscreen(rc.alloc); // 3. Ensure output directory exists. const out_dir = try std.fmt.allocPrint(rc.alloc, "tests/scenarios/out/{s}", .{rc.scenario_name}); defer rc.alloc.free(out_dir); std.fs.cwd().makePath(out_dir) catch |err| switch (err) { error.PathAlreadyExists => {}, else => return err, }; // 4. Write captured PNG. const out_path = try std.fmt.allocPrint(rc.alloc, "{s}/{s}.png", .{ out_dir, label }); defer rc.alloc.free(out_path); var buf: std.ArrayList(u8) = .empty; defer buf.deinit(rc.alloc); try png.encode(rc.alloc, img, buf.writer(rc.alloc)); const out_file = try std.fs.cwd().createFile(out_path, .{ .truncate = true }); defer out_file.close(); try out_file.writeAll(buf.items); // 5. Golden path. const golden_path = try std.fmt.allocPrint(rc.alloc, "tests/scenarios/golden/{s}/{s}.png", .{ rc.scenario_name, label }); defer rc.alloc.free(golden_path); if (rc.update_goldens) { const g_dir = try std.fmt.allocPrint(rc.alloc, "tests/scenarios/golden/{s}", .{rc.scenario_name}); defer rc.alloc.free(g_dir); std.fs.cwd().makePath(g_dir) catch |err| switch (err) { error.PathAlreadyExists => {}, else => return err, }; const g_file = try std.fs.cwd().createFile(golden_path, .{ .truncate = true }); defer g_file.close(); try g_file.writeAll(buf.items); // No diff check when updating. } else { // Attempt to load golden; if missing, record as mismatch. const golden_bytes = std.fs.cwd().readFileAlloc(rc.alloc, golden_path, 64 * 1024 * 1024) catch |err| { if (err == error.FileNotFound) { // Record as failure with a clear message. const lbl = try rc.alloc.dupe(u8, label); try rc.failures.append(rc.alloc, .{ .capture_mismatch = .{ .label = lbl, .rmse = 1.0, .max_pixel = 1.0, }}); return img; } return err; }; defer rc.alloc.free(golden_bytes); var golden = try png.decode(rc.alloc, golden_bytes); defer golden.deinit(rc.alloc); if (golden.width != img.width or golden.height != img.height) { const lbl = try rc.alloc.dupe(u8, label); try rc.failures.append(rc.alloc, .{ .capture_mismatch = .{ .label = lbl, .rmse = 1.0, .max_pixel = 1.0, }}); } else { const diff_res = try imgdiff.compare(img, golden); if (diff_res.rmse > imgdiff.RMSE_DEFAULT or diff_res.max_pixel > imgdiff.PIXEL_MAX_DEFAULT) { const lbl = try rc.alloc.dupe(u8, label); try rc.failures.append(rc.alloc, .{ .capture_mismatch = .{ .label = lbl, .rmse = diff_res.rmse, .max_pixel = diff_res.max_pixel, }}); // Write diff heatmap. const heat = try imgdiff.makeDiffImage(rc.alloc, img, golden); defer rc.alloc.free(heat.pixels); var heat_buf: std.ArrayList(u8) = .empty; defer heat_buf.deinit(rc.alloc); try png.encode(rc.alloc, heat, heat_buf.writer(rc.alloc)); const heat_path = try std.fmt.allocPrint(rc.alloc, "{s}/{s}.diff.png", .{ out_dir, label }); defer rc.alloc.free(heat_path); const heat_file = try std.fs.cwd().createFile(heat_path, .{ .truncate = true }); defer heat_file.close(); try heat_file.writeAll(heat_buf.items); } } } return img; } ``` This is a sketch — real implementation must get the `renderToOffscreen` call's arguments right by cross-referencing `src/capture.zig`. - [ ] **Step 3: Add the tick call in the main loop.** 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: ```zig // --- scenario tick --- // Lands after the blink phase flip so a just-flipped blink correctly // informs io.blink_just_flipped on THIS tick. Lands before the // render_pending bail so a scheduled tick can set render_pending true. if (tick_ctx) |rc| { const s = rc.state orelse break; // Observe blink flip for this iteration (tick.flipped was set in tickBlinkPhase above). if (tick.flipped) rc.blink_flipped_this_iter = true; const tick_now = std.time.nanoTimestamp(); const outcome = s.tick(tick_now, scenario_runtime.tickIO(rc)) catch |err| { rc.tick_fatal = err; window.should_close = true; break; }; switch (outcome) { .working => {}, .done => window.should_close = true, } } ``` `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. - [ ] **Step 4: Add `runScenarios` entry point in `src/main.zig`.** 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. ```zig pub fn runScenarios(alloc: std.mem.Allocator, argv: []const [:0]const u8) !void { if (argv.len < 2) { std.debug.print("usage: waystty --scenario <path>\n", .{}); std.process.exit(2); } const scenario_path = argv[1]; // Read + parse scenario. const source = std.fs.cwd().readFileAlloc(alloc, scenario_path, 1 * 1024 * 1024) catch |err| { std.debug.print("scenario: cannot read {s}: {s}\n", .{ scenario_path, @errorName(err) }); std.process.exit(2); }; defer alloc.free(source); var diag: scenario.Diagnostic = .{}; var s = scenario.parse(alloc, source, &diag) catch { std.debug.print("scenario: {s}:{d}: {s}\n", .{ scenario_path, diag.line, diag.message }); std.process.exit(2); }; defer s.deinit(); // Derive scenario_name from the path (basename without extension). const scenario_name = basenameNoExt(scenario_path); // Stand up the runtime (wayland + vulkan + renderer + term + pty-for-scenario). // See src/capture.zig for the pattern. Reuse as much as possible. // ... setup elided — see capture.zig for reference ... const update = std.posix.getenv("WAYSTTY_SCENARIO_UPDATE") != null; var rc: scenario_runtime.RunContext = .{ .alloc = alloc, .scenario_name = scenario_name, .update_goldens = update, .last_blink_on = true, .term = &term, .ctx = &ctx, }; defer rc.deinit(); var state = scenario.ScenarioState.init(alloc, &s, std.time.nanoTimestamp(), .{ .cell_w_px = cell_w, .cell_h_px = cell_h, }); defer state.deinit(); // Wire scenarioTickCtx — replaces the Task 3 stub: scenario_tick_ctx = &rc; // Run the main loop with scenario mode. try runTerminal(alloc, /* existing args */, &state, .{ .cell_w_px = cell_w, .cell_h_px = cell_h, }); // On exit, decide the exit code. if (scenario_tick_fatal) |err| { const code = scenario_runtime.mapTickError(err); std.debug.print("scenario {s}: {s}\n", .{ scenario_name, @errorName(err) }); std.process.exit(@intFromEnum(code)); } if (rc.failures.items.len > 0) { std.debug.print("scenario {s}: {d} failure(s):\n", .{ scenario_name, rc.failures.items.len }); for (rc.failures.items) |f| switch (f) { .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 }), .assert_failed => |a| std.debug.print(" assert line {d}: {s}\n", .{ a.line, a.reason }), }; if (!update) std.process.exit(@intFromEnum(scenario_runtime.ExitCode.assertion_mismatch)); } std.debug.print("scenario {s}: OK\n", .{scenario_name}); std.process.exit(0); } fn basenameNoExt(path: []const u8) []const u8 { const base = std.fs.path.basename(path); const dot = std.mem.lastIndexOfScalar(u8, base, '.') orelse return base; return base[0..dot]; } ``` 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. Two things to be careful about: - `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. - [ ] **Step 5: Dispatch `--scenario` in `main`.** Near the top of `main`, add a branch that detects `--scenario` in argv and calls `runScenarios`: ```zig pub fn main() !void { var gpa = std.heap.DebugAllocator(.{}){}; defer _ = gpa.deinit(); const alloc = gpa.allocator(); const args = try std.process.argsAlloc(alloc); defer std.process.argsFree(alloc, args); if (args.len >= 2 and std.mem.eql(u8, args[1], "--scenario")) { return runScenarios(alloc, args[1..]); } if (args.len >= 2 and std.mem.eql(u8, args[1], "--capture")) { return capture.run(alloc, args[1..]); } // ... existing normal-mode dispatch ... } ``` - [ ] **Step 6: Finalize `scenario_runtime_mod` in build.zig.** Task 2 created a stub module with only `scenario` as an import. Extend it now with the remaining imports the real implementation needs: ```zig scenario_runtime_mod.addImport("png", png_mod); scenario_runtime_mod.addImport("imgdiff", imgdiff_lib_mod); scenario_runtime_mod.addImport("renderer", renderer_mod); scenario_runtime_mod.addImport("vt", vt_mod); ``` (These three `addImport` calls are added to the existing `scenario_runtime_mod` created in Task 2 Step 4.) - [ ] **Step 7: Build + run existing tests.** ```bash zig build zig build test-scenario ``` Expected: builds, scenario tests still pass. No scenario fixture runs yet (that's Task 4). - [ ] **Step 8: Commit.** ```bash git add src/scenario_runtime.zig src/main.zig build.zig git commit -m "$(cat <<'EOF' main: add scenario entry point + runtime callbacks --scenario <path> spins up a full waystty runtime (wayland + vulkan + real main loop with blink timer), plays the scenario file through scenario.tick, captures offscreen frames on capture directives, diffs against golden PNGs, and exits with a documented code (0/2/3/4/5/6). src/scenario_runtime.zig owns the TickIO callback implementations: write_bytes -> term.write, capture -> offscreen render + PNG + golden diff, blink_just_flipped -> per-iter flag toggled by the main loop. First fixture lands in the next commit. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> EOF )" ``` --- ## Task 4: First fixture + Makefile targets **Files:** - Create: `tests/scenarios/blink-bar.scenario` - Create: `tests/scenarios/golden/blink-bar/*.png` (generated in step below) - Modify: `Makefile` — add `scenario` and `scenario-update` targets - Modify: `.gitignore` — add `tests/scenarios/out/` - [ ] **Step 1: Write the fixture file.** Create `tests/scenarios/blink-bar.scenario`: ``` # Blinking bar cursor: DECSCUSR 5 then observe two phases. size 80 24 timeout 5000ms # Inject DECSCUSR 5 directly (no shell) - blinking bar. bytes "\e[5 q" # Give blink state machine time to arm. sleep 100ms capture before-flip # Advance past the first phase flip (500ms period). sleep 500ms capture after-flip ``` - [ ] **Step 2: Update .gitignore.** Append to `.gitignore`: ``` tests/scenarios/out/ ``` - [ ] **Step 3: Add Makefile targets.** Append to `Makefile`: ```makefile .PHONY: scenario scenario-update scenario: zig build @for f in tests/scenarios/*.scenario; do \ echo "=== $$f ==="; \ ./zig-out/bin/waystty --scenario "$$f" || exit $$?; \ done scenario-update: zig build @for f in tests/scenarios/*.scenario; do \ echo "=== $$f (update goldens) ==="; \ WAYSTTY_SCENARIO_UPDATE=1 ./zig-out/bin/waystty --scenario "$$f" || exit $$?; \ done ``` - [ ] **Step 4: Generate goldens for the fixture.** ```bash cd /home/xanderle/code/rad/waystty make scenario-update ``` Expected: writes `tests/scenarios/golden/blink-bar/before-flip.png` and `tests/scenarios/golden/blink-bar/after-flip.png`. Exit 0. 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). 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. - [ ] **Step 5: Run the fixture against goldens.** ```bash make scenario ``` Expected: `scenario blink-bar: OK`, exit 0. - [ ] **Step 6: Verify mutation detection.** 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). ```bash make scenario || echo "expected non-zero: $?" ``` Expected: the runner detects the mismatch, writes `tests/scenarios/out/blink-bar/after-flip.diff.png`, exits with code 4. Restore the golden (either via `make scenario-update` or `git checkout tests/scenarios/golden/blink-bar/`). - [ ] **Step 7: Commit.** ```bash git add tests/scenarios/blink-bar.scenario tests/scenarios/golden/blink-bar/ Makefile .gitignore git commit -m "$(cat <<'EOF' scenarios: first fixture blink-bar + Makefile targets blink-bar.scenario drives DECSCUSR 5 and captures two frames separated by 500ms to observe the blink-bar cursor on/off phase boundary. Proves the scenario runner end-to-end: - --scenario flag parses and dispatches - bytes directive injects into the VT parser - real main loop fires the blink timer - capture directive offscreens + PNG-writes + diffs goldens - exit code 0 on pass, 4 on golden mismatch make scenario — run all *.scenario fixtures make scenario-update — regenerate goldens (WAYSTTY_SCENARIO_UPDATE=1) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> EOF )" ``` --- ## Post-plan verification - [ ] `make scenario` — expected: "scenario blink-bar: OK", exit 0. - [ ] `make scenario-update` — regenerates goldens. Git should report them as unchanged if the generator is deterministic. - [ ] `zig build` — no regressions. - [ ] `zig build test-scenario` — all 50 pure scenario tests still pass. - [ ] Manually launch `./zig-out/bin/waystty` (normal mode, no --scenario) — verify nothing regressed; cursor still blinks, typing works, resize works. - [ ] `rm tests/scenarios/golden/blink-bar/after-flip.png; make scenario || true` — verify mismatch path produces a `diff.png` and non-zero exit. --- ## Self-review coverage check 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. 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. Spec "No dummy child process" — Task 1 uses `openpty(3)` + `child_pid = -1` sentinel. No fork, no zombie. Spec "Golden update mode: WAYSTTY_SCENARIO_UPDATE=1" — Task 4 reads the env var and Task 5 wires the `scenario-update` target around it. 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). Spec "Non-goals: No wayland event path testing." — this plan does not add any, correct.