const std = @import("std"); const builtin = @import("builtin"); const protocol = @import("automation_protocol"); const ui_markup = @import("ui_markup"); const automation_dir = protocol.default_dir; pub fn run(allocator: std.mem.Allocator, io: std.Io, environ_map: *std.process.Environ.Map, args: []const []const u8) !void { if (args.len == 0) return usage(); const command = args[0]; if (std.mem.eql(u8, command, "--help") or std.mem.eql(u8, command, "-h") or std.mem.eql(u8, command, "help")) { // Asked-for help is a success: print the command table and exit 0. printUsage(); std.process.exit(0); } if (std.mem.eql(u8, command, "record")) { return runSessionLaunch(allocator, io, environ_map, args[1..], .record); } else if (std.mem.eql(u8, command, "replay")) { return runSessionLaunch(allocator, io, environ_map, args[1..], .replay); } else if (std.mem.eql(u8, command, "list")) { // windows.txt has no pid of its own; the snapshot in the same // dropbox vouches for (or condemns) the whole directory. try requireLiveSnapshotFile(allocator, io); try printFile(io, "windows.txt"); } else if (std.mem.eql(u8, command, "snapshot")) { try requireLiveSnapshotFile(allocator, io); try printFile(io, "snapshot.txt"); } else if (std.mem.eql(u8, command, "screenshot")) { if (args.len < 2 or args.len > 3) return usage(); var name_buffer: [128]u8 = undefined; const name = protocol.screenshotFileName(args[1], &name_buffer) catch return usage(); deleteAutomationFile(io, name); const value = try std.mem.join(allocator, " ", args[1..]); defer allocator.free(value); try sendCommand(allocator, io, "screenshot", value); try waitForScreenshot(io, name); } else if (std.mem.eql(u8, command, "reload")) { try sendCommand(allocator, io, "reload", ""); } else if (std.mem.eql(u8, command, "resize")) { if (args.len < 3 or args.len > 4) return usage(); const value = if (args.len == 4) try std.fmt.allocPrint(allocator, "{s} {s} {s}", .{ args[1], args[2], args[3] }) else try std.fmt.allocPrint(allocator, "{s} {s}", .{ args[1], args[2] }); defer allocator.free(value); try sendCommand(allocator, io, "resize", value); } else if (std.mem.eql(u8, command, "menu-command")) { if (args.len != 2) return usage(); try sendCommand(allocator, io, "menu-command", args[1]); } else if (std.mem.eql(u8, command, "native-command")) { if (args.len < 2 or args.len > 3) return usage(); if (args.len == 3) { const value = try std.fmt.allocPrint(allocator, "{s} {s}", .{ args[1], args[2] }); defer allocator.free(value); try sendCommand(allocator, io, "native-command", value); } else { try sendCommand(allocator, io, "native-command", args[1]); } } else if (std.mem.eql(u8, command, "widget-action")) { if (args.len < 4) return usage(); const value = try std.mem.join(allocator, " ", args[1..]); defer allocator.free(value); try sendCommand(allocator, io, "widget-action", value); } else if (std.mem.eql(u8, command, "widget-click")) { if (args.len != 3) return usage(); const value = try std.fmt.allocPrint(allocator, "{s} {s}", .{ args[1], args[2] }); defer allocator.free(value); try sendCommand(allocator, io, "widget-click", value); } else if (std.mem.eql(u8, command, "widget-hold")) { if (args.len != 3) return usage(); const value = try std.fmt.allocPrint(allocator, "{s} {s}", .{ args[1], args[2] }); defer allocator.free(value); try sendCommand(allocator, io, "widget-hold", value); } else if (std.mem.eql(u8, command, "widget-context-press")) { if (args.len != 3) return usage(); const value = try std.fmt.allocPrint(allocator, "{s} {s}", .{ args[1], args[2] }); defer allocator.free(value); try sendCommand(allocator, io, "widget-context-press", value); } else if (std.mem.eql(u8, command, "widget-context-menu")) { if (args.len != 4) return usage(); const value = try std.fmt.allocPrint(allocator, "{s} {s} {s}", .{ args[1], args[2], args[3] }); defer allocator.free(value); try sendCommand(allocator, io, "widget-context-menu", value); } else if (std.mem.eql(u8, command, "widget-drag")) { if (args.len != 5 and args.len != 7) return usage(); const value = if (args.len == 7) try std.fmt.allocPrint(allocator, "{s} {s} {s} {s} {s} {s}", .{ args[1], args[2], args[3], args[4], args[5], args[6] }) else try std.fmt.allocPrint(allocator, "{s} {s} {s} {s}", .{ args[1], args[2], args[3], args[4] }); defer allocator.free(value); try sendCommand(allocator, io, "widget-drag", value); } else if (std.mem.eql(u8, command, "widget-wheel")) { if (args.len != 4) return usage(); const value = try std.fmt.allocPrint(allocator, "{s} {s} {s}", .{ args[1], args[2], args[3] }); defer allocator.free(value); try sendCommand(allocator, io, "widget-wheel", value); } else if (std.mem.eql(u8, command, "widget-key")) { if (args.len < 3) return usage(); const value = try std.mem.join(allocator, " ", args[1..]); defer allocator.free(value); try sendCommand(allocator, io, "widget-key", value); } else if (std.mem.eql(u8, command, "shortcut")) { if (args.len != 2) return usage(); try sendCommand(allocator, io, "shortcut", args[1]); } else if (std.mem.eql(u8, command, "tray-action")) { if (args.len != 2) return usage(); try sendCommand(allocator, io, "tray-action", args[1]); } else if (std.mem.eql(u8, command, "focus")) { if (args.len != 2) return usage(); try sendCommand(allocator, io, "focus", args[1]); } else if (std.mem.eql(u8, command, "focus-next")) { if (args.len != 1) return usage(); try sendCommand(allocator, io, "focus-next", ""); } else if (std.mem.eql(u8, command, "focus-previous")) { if (args.len != 1) return usage(); try sendCommand(allocator, io, "focus-previous", ""); } else if (std.mem.eql(u8, command, "profile")) { if (args.len != 2 or (!std.mem.eql(u8, args[1], "on") and !std.mem.eql(u8, args[1], "off"))) return usage(); try sendCommand(allocator, io, "profile", args[1]); } else if (std.mem.eql(u8, command, "wait")) { try waitForFile(allocator, io, "snapshot.txt", "ready=true", .require_live_publisher); } else if (std.mem.eql(u8, command, "assert")) { try runAssert(allocator, io, args[1..]); } else if (std.mem.eql(u8, command, "bridge")) { if (args.len < 2) return usage(); deleteAutomationFile(io, "bridge-response.txt"); try sendCommand(allocator, io, "bridge", args[1]); try waitForFile(allocator, io, "bridge-response.txt", "", .any_publisher); } else if (std.mem.eql(u8, command, "provenance")) { // `provenance ` or // `provenance at `. if (args.len != 3 and !(args.len == 5 and std.mem.eql(u8, args[2], "at"))) return usage(); const value = try std.mem.join(allocator, " ", args[1..]); defer allocator.free(value); deleteAutomationFile(io, "provenance.txt"); try sendCommand(allocator, io, "provenance", value); try waitForFile(allocator, io, "provenance.txt", "", .any_publisher); } else if (std.mem.eql(u8, command, "edit")) { try runEdit(allocator, io, args[1..]); } else { return usage(); } } // ------------------------------------------------------------- write-back /// `edit ...`: /// apply one minimal-diff edit operation to the markup file backing a /// RUNNING widget. The flow is provenance -> refusal ladder -> checked /// span edit -> whole-closure validation -> file write; the app's own /// hot-reload watch then picks the change up (no reload command needed). /// Refusals never touch the file: a Zig-authored widget, a file that /// changed since the app loaded it, or an edit that fails validation all /// stop with a teaching error. fn runEdit(allocator: std.mem.Allocator, io: std.Io, args: []const []const u8) !void { if (args.len < 4) return usage(); const view_label = args[0]; const widget_id = args[1]; const op_name = args[2]; var op: ui_markup.edit.EditOp = undefined; if (std.mem.eql(u8, op_name, "set-attr")) { if (args.len != 5) return usage(); op = .{ .set_attr = .{ .name = args[3], .value = args[4] } }; } else if (std.mem.eql(u8, op_name, "remove-attr")) { if (args.len != 4) return usage(); op = .{ .remove_attr = .{ .name = args[3] } }; } else if (std.mem.eql(u8, op_name, "set-text")) { const text = try std.mem.join(allocator, " ", args[3..]); // Leaked into the arena's lifetime below via the op; freed with // the arena at function end. op = .{ .set_text = .{ .text = text } }; } else { return usage(); } var arena_state = std.heap.ArenaAllocator.init(allocator); defer arena_state.deinit(); const arena = arena_state.allocator(); // 1. Provenance: where the running app says this widget was authored. const query = try std.fmt.allocPrint(arena, "{s} {s}", .{ view_label, widget_id }); deleteAutomationFile(io, "provenance.txt"); try sendCommand(allocator, io, "provenance", query); try waitForFileQuietly(allocator, io, "provenance.txt"); var response_path: [256]u8 = undefined; const response = try readFile(arena, io, path(&response_path, "provenance.txt")); if (std.mem.startsWith(u8, response, "provenance error")) { return failEdit(response); } if (responseField(response, "authored=")) |authored| { if (!std.mem.eql(u8, authored, "markup")) return failEdit(response); } else return failEdit(response); const watching = responseField(response, "watching=") orelse "false"; if (!std.mem.eql(u8, watching, "true")) { return failEdit("the app is not watching markup sources (no MarkupOptions.watch_path) - write-back needs the dev hot-reload watch so the edit can land in the running app\n"); } const node_line = responseLine(response, "node ") orelse return failEdit(response); const file_path = responseField(node_line, "file=") orelse return failEdit(response); const hash_text = responseField(node_line, "hash=") orelse return failEdit(response); const span_text = responseField(node_line, "span=") orelse return failEdit(response); const root_path = responseField(response, "root=") orelse file_path; if (file_path.len == 0) return failEdit("provenance names no on-disk file for this widget's source - write-back has nothing to edit\n"); const app_hash = std.fmt.parseUnsigned(u64, hash_text, 16) catch return failEdit(response); const span_start = std.fmt.parseUnsigned(usize, span_text[0 .. std.mem.indexOf(u8, span_text, "..") orelse return failEdit(response)], 10) catch return failEdit(response); // 2. Concurrent-modification guard: the app answered with the hash of // the bytes it LOADED; the file on disk must still be those bytes, or // the spans do not describe it and writing would clobber someone's // concurrent edit (or race a not-yet-reloaded save). const disk_source = readFile(arena, io, file_path) catch { std.debug.print("error: cannot read {s} - run this command from the app project's working directory (the same place the app runs from)\n", .{file_path}); return error.AutomationCommandFailed; }; if (std.hash.Wyhash.hash(0, disk_source) != app_hash) { std.debug.print( "error: {s} changed on disk since the app loaded it - the provenance spans no longer describe these bytes.\n" ++ " save your editor, let the app's hot-reload watch catch up (it polls every 500ms), and retry;\n" ++ " this command never overwrites concurrent edits.\n", .{file_path}, ); return error.AutomationCommandFailed; } // 3. The checked minimal-diff edit: parse, apply, reparse, validate, // and diff the trees - the file is written only when every other node // is provably untouched. var diagnostic: ui_markup.MarkupErrorInfo = .{}; const edited = ui_markup.edit.applyChecked(arena, disk_source, span_start, op, &diagnostic) catch { std.debug.print("error: edit refused at {s}:{d}:{d}: {s}\n", .{ file_path, diagnostic.line, diagnostic.column, diagnostic.message }); return error.AutomationCommandFailed; }; // 4. Whole-closure validation: the single-file check cannot see // cross-file template wiring, so resolve the import closure from disk // with the edited bytes substituted and run the strict pass before // anything lands on disk. try validateEditedClosure(arena, io, root_path, file_path, edited); // 5. Write. The app's watch reloads within its poll interval; // `native automate assert` on the snapshot is the way to await the // repaint. std.Io.Dir.cwd().writeFile(io, .{ .sub_path = file_path, .data = edited }) catch { std.debug.print("error: could not write {s}\n", .{file_path}); return error.AutomationCommandFailed; }; var summary_buffer: [512]u8 = undefined; const summary = std.fmt.bufPrint(&summary_buffer, "edited {s} at byte {d} ({s}); the app's markup watch reloads it within 500ms\n", .{ file_path, span_start, op_name }) catch "edited\n"; try emitPayload(io, &.{summary}); } /// Poll for a response artifact without echoing it to stdout (the edit /// flow consumes the provenance response internally). fn waitForFileQuietly(allocator: std.mem.Allocator, io: std.Io, name: []const u8) !void { var attempts: usize = 0; while (attempts < 300) : (attempts += 1) { var file_path: [256]u8 = undefined; const bytes = readFile(allocator, io, path(&file_path, name)) catch { try std.Io.sleep(io, std.Io.Duration.fromNanoseconds(100 * std.time.ns_per_ms), .awake); continue; }; allocator.free(bytes); return; } return fail("timed out waiting for the provenance response"); } fn failEdit(response: []const u8) error{AutomationCommandFailed} { std.debug.print("error: {s}", .{response}); if (response.len == 0 or response[response.len - 1] != '\n') std.debug.print("\n", .{}); return error.AutomationCommandFailed; } /// `` field from a key=value response (value ends at /// whitespace; `message="..."` fields are not parsed here). fn responseField(text: []const u8, marker: []const u8) ?[]const u8 { var search: usize = 0; while (std.mem.indexOfPos(u8, text, search, marker)) |index| { search = index + marker.len; if (index > 0 and text[index - 1] != ' ' and text[index - 1] != '\n') continue; var end = search; while (end < text.len and text[end] != ' ' and text[end] != '\n') end += 1; return text[search..end]; } return null; } /// The first line starting with `prefix`. fn responseLine(text: []const u8, prefix: []const u8) ?[]const u8 { var lines = std.mem.splitScalar(u8, text, '\n'); while (lines.next()) |line| { if (std.mem.startsWith(u8, line, prefix)) return line; } return null; } /// Resolve the root file's import closure from disk with `edited_path`'s /// bytes substituted, and run the strict validation pass - the cross-file /// half of "ops validate before write". fn validateEditedClosure(arena: std.mem.Allocator, io: std.Io, root_path: []const u8, edited_path: []const u8, edited: []const u8) !void { var loader = EditOverlayLoader{ .io = io, .edited_path = edited_path, .edited = edited }; const root_source = if (std.mem.eql(u8, root_path, edited_path)) edited else readFile(arena, io, root_path) catch { std.debug.print("error: cannot read the markup root {s} for closure validation\n", .{root_path}); return error.AutomationCommandFailed; }; var diagnostic: ui_markup.MarkupErrorInfo = .{}; const document = ui_markup.resolveImports(arena, root_path, root_source, loader.loader(), &diagnostic) catch { std.debug.print("error: the edit would break the markup closure ({s}:{d}:{d}): {s}\n", .{ diagnostic.path, diagnostic.line, diagnostic.column, diagnostic.message }); return error.AutomationCommandFailed; }; if (ui_markup.validate(document)) |info| { std.debug.print("error: the edit would fail validation ({s}:{d}:{d}): {s}\n", .{ info.path, info.line, info.column, info.message }); return error.AutomationCommandFailed; } } /// Disk loader with one path overridden by the edited bytes (validation /// must see the closure AS IT WOULD BE after the write). const EditOverlayLoader = struct { io: std.Io, edited_path: []const u8, edited: []const u8, fn loader(self: *EditOverlayLoader) ui_markup.ImportLoader { return .{ .context = @ptrCast(self), .load = load }; } fn load(context: *const anyopaque, arena: std.mem.Allocator, load_path: []const u8) ?[]const u8 { const self: *const EditOverlayLoader = @ptrCast(@alignCast(context)); if (std.mem.eql(u8, load_path, self.edited_path)) return self.edited; var file = std.Io.Dir.cwd().openFile(self.io, load_path, .{}) catch return null; defer file.close(self.io); const buffer = arena.alloc(u8, 256 * 1024) catch return null; const len = file.readPositionalAll(self.io, buffer, 0) catch return null; return buffer[0..len]; } }; const SessionLaunchMode = enum { record, replay }; /// `record --out -- ` and /// `replay [--verify|--no-verify] -- `: /// launch the app with the session environment armed /// (NATIVE_SDK_SESSION_RECORD / NATIVE_SDK_SESSION_REPLAY — the app /// runner does the recording and replaying; this verb only launches). /// Recording must arm at launch because replay re-runs the app's init: /// a mid-session recording could never replay its init-time effects. /// The child's exit code propagates, so `replay --verify` fails this /// command when verification fails. fn runSessionLaunch(allocator: std.mem.Allocator, io: std.Io, environ_map: *std.process.Environ.Map, args: []const []const u8, mode: SessionLaunchMode) !void { var journal_path: ?[]const u8 = null; var verify = true; var child_argv: []const []const u8 = &.{}; var index: usize = 0; while (index < args.len) : (index += 1) { const arg = args[index]; if (std.mem.eql(u8, arg, "--")) { child_argv = args[index + 1 ..]; break; } else if (mode == .record and std.mem.eql(u8, arg, "--out")) { index += 1; if (index >= args.len) return fail("record: --out requires a journal path"); journal_path = args[index]; } else if (mode == .replay and std.mem.eql(u8, arg, "--verify")) { verify = true; } else if (mode == .replay and std.mem.eql(u8, arg, "--no-verify")) { verify = false; } else if (journal_path == null and arg.len > 0 and arg[0] != '-') { journal_path = arg; } else { return usage(); } } const journal = journal_path orelse return fail(if (mode == .record) "record: name the journal with --out (before the -- app command)" else "replay: name the journal file (before the -- app command)"); if (child_argv.len == 0) { return fail("name the app to launch after `--`, e.g.: native automate record --out session.journal -- ./zig-out/bin/my-app"); } var env = std.process.Environ.Map.init(allocator); defer env.deinit(); const keys = environ_map.keys(); const values = environ_map.values(); for (keys, values) |key, value| try env.put(key, value); switch (mode) { .record => try env.put("NATIVE_SDK_SESSION_RECORD", journal), .replay => { try env.put("NATIVE_SDK_SESSION_REPLAY", journal); try env.put("NATIVE_SDK_SESSION_VERIFY", if (verify) "1" else "0"); }, } var child = try std.process.spawn(io, .{ .argv = child_argv, .stdin = .inherit, .stdout = .inherit, .stderr = .inherit, .environ_map = &env, }); const term = try child.wait(io); switch (term) { .exited => |code| { if (code != 0) { std.debug.print("{s}: the app exited with code {d}\n", .{ @tagName(mode), code }); return error.AutomationCommandFailed; } }, else => { std.debug.print("{s}: the app did not exit cleanly (the journal, if any, is unsealed and replay will refuse it)\n", .{@tagName(mode)}); return error.AutomationCommandFailed; }, } } /// Missing or malformed arguments: print the command table and exit 1 (a /// misuse must never exit 0). Asked-for help goes through printUsage + exit /// 0 in run() instead. fn usage() noreturn { printUsage(); std.process.exit(1); } fn printUsage() void { std.debug.print( \\usage: native automate \\ \\commands: \\ record --out -- (launch the app with session recording armed) \\ replay [--verify|--no-verify] -- (replay the journal headlessly and verify checkpoints) \\ list \\ snapshot \\ screenshot [scale] (renders the gpu_surface view's canvas to screenshot-.png) \\ reload \\ resize [scale] \\ menu-command \\ native-command [view-label] \\ widget-action [value] \\ widget-click (ids are the bare number; snapshots print #id) \\ widget-hold (press-and-hold: arms and fires the on_hold timer, release suppressed) \\ widget-context-press (right-click: context menu, or on_hold when the route has none) \\ widget-context-menu (invoke a declared context-menu item; snapshots list them as context_menu=[...]) \\ widget-drag [start-y-ratio end-y-ratio] \\ widget-wheel \\ widget-key [text] \\ shortcut \\ tray-action (status-item dropdown row; snapshots print tray-item #id) \\ focus \\ focus-next \\ focus-previous \\ profile (per-stage frame timing; snapshots then carry a frame_profile line of rolling p50/p90s in us) \\ wait \\ assert [--absent] [--timeout-ms 30000] [more patterns...] \\ (each pattern is a regex that must match snapshot.txt; --absent \\ inverts: every pattern must be gone. Polls until the timeout.) \\ bridge \\ provenance ( | at ) (where a live widget was authored: file, byte span, line:column, template chain, iteration keys) \\ edit set-attr (write the change back into the widget's markup file; hot reload picks it up) \\ edit remove-attr \\ edit set-text \\ , .{}); } fn sendCommand(allocator: std.mem.Allocator, io: std.Io, action: []const u8, value: []const u8) !void { const buffer = try allocator.alloc(u8, protocol.max_command_bytes); defer allocator.free(buffer); const line = try protocol.commandLine(action, value, buffer); // The automation dir is created by the RUNNING APP (built with // -Dautomation=true), never by this CLI: a queue written into a // freshly created dir would go to an app that does not exist — // classically, the wrong cwd — and silently do nothing. Refuse // loudly instead, naming the dir we looked at. try requireAutomationDir(io); // A live publisher already on another protocol makes the queue write // provably useless (or worse, misread) — refuse before writing. try requireCompatibleSnapshotIfPresent(allocator, io); // Claim a slot in the dropbox command queue: an exclusive create of // `command-.txt`, so back-to-back (or fully concurrent) `native // automate` invocations can NEVER overwrite each other — the old // single-entry slot lost exactly that race... var name_buffer: [64]u8 = undefined; var dir_buffer: [1024]u8 = undefined; const name = enqueueCommand(io, automation_dir, line, &name_buffer, queue_attempt_budget) catch |err| { switch (err) { error.QueueStayedFull => std.debug.print( "error: refusing to send {s} - the command queue in {s} stayed full ({d} pending) for 10s\n" ++ " (the app drains one command per presented frame; a queue that never drains\n" ++ " means the app exited, froze, or stopped presenting - check `native automate\n" ++ " wait` and that the snapshot's publisher_pid is your app)\n", .{ action, automationDirDescription(io, &dir_buffer), protocol.max_queued_commands }, ), error.QueueUnwritable => std.debug.print( "error: could not write a command entry into {s}\n", .{automationDirDescription(io, &dir_buffer)}, ), } return error.AutomationCommandFailed; }; // ...and only report success once OUR entry was consumed (the app // deletes it after reading), so a dead/frozen app fails loudly here // instead of a "queued" line that went nowhere. awaitCommandConsumed(io, automation_dir, name, queue_attempt_budget) catch { std.debug.print( "error: the app never consumed {s} (still queued as {s}/{s} after 10s)\n" ++ " (the app drains one command per presented frame and deletes the entry as its\n" ++ " ack; a silent timeout here means the app exited, froze, or stopped presenting -\n" ++ " check `native automate wait` and the snapshot's publisher_pid)\n", .{ action, automationDirDescription(io, &dir_buffer), name }, ); return error.AutomationCommandFailed; }; std.debug.print("delivered {s} -> {s}\n", .{ action, automationDirDescription(io, &dir_buffer) }); } /// The shared patience of both queue waits: ~10s at 25ms per attempt. /// Consumption normally takes one presented frame, so exhausting this /// budget means the app is gone, frozen, or not presenting. Tests pass a /// budget of 1 to pin the loud refusals without real waiting. const queue_attempt_budget: usize = 400; /// One pass over the dropbox queue from a writer's seat: how many /// entries are pending, and which claim sequence a new command should /// take (max present + 1). Sequences only order entries that COEXIST — /// the app always consumes the lowest number present and deletes it — /// so numbering restarting at 1 after the queue drains is fine. const QueueState = struct { pending: usize = 0, next_sequence: u64 = 1, }; fn scanQueue(io: std.Io, directory: []const u8) QueueState { var state: QueueState = .{}; var dir = std.Io.Dir.cwd().openDir(io, directory, .{ .iterate = true }) catch return state; defer dir.close(io); var iterator = dir.iterate(); while (iterator.next(io) catch null) |entry| { if (entry.kind != .file) continue; const sequence = protocol.queueFileSequence(entry.name) orelse continue; state.pending += 1; if (sequence >= state.next_sequence) state.next_sequence = sequence + 1; } return state; } /// Land `line` in the dropbox command queue and return its entry name. /// Diagnostics stay with the caller (sendCommand), which owns the /// teaching messages for both errors. /// /// Two honesty rules shape this writer: /// - The claim is an EXCLUSIVE create: losing a name race to another /// writer is a rescan-and-retry, never an overwrite, so no command can /// be silently replaced before the app drains it. /// - A FULL queue (`max_queued_commands` entries pending) is retried at /// the consumption cadence up to the same ~10s budget as the /// consumption wait, then refused loudly naming the depth. Retrying in /// the writer keeps the protocol one-writer-one-file simple (no /// overflow side-channel for the app to publish and both sides to /// version), and the outcome stays deterministic: a live app drains /// one command per presented frame, so a burst clears in milliseconds, /// while a queue still full after 10s means the app stopped consuming /// — which must be a loud error, never a dropped command. fn enqueueCommand(io: std.Io, directory: []const u8, line: []const u8, name_buffer: []u8, attempt_budget: usize) error{ QueueStayedFull, QueueUnwritable }![]const u8 { var attempts: usize = 0; while (attempts < attempt_budget) : (attempts += 1) { const state = scanQueue(io, directory); if (state.pending >= protocol.max_queued_commands) { std.Io.sleep(io, std.Io.Duration.fromNanoseconds(25 * std.time.ns_per_ms), .awake) catch {}; continue; } // The buffer always fits `command-.txt`; only a caller bug // could trip this. const name = protocol.queueFileName(state.next_sequence, name_buffer) catch unreachable; var entry_path: [256]u8 = undefined; std.Io.Dir.cwd().writeFile(io, .{ .sub_path = std.fmt.bufPrint(&entry_path, "{s}/{s}", .{ directory, name }) catch unreachable, .data = line, .flags = .{ .exclusive = true }, }) catch |err| switch (err) { // Another writer claimed this sequence between our scan and // our create; rescan and take the next number. error.PathAlreadyExists => continue, else => return error.QueueUnwritable, }; return name; } return error.QueueStayedFull; } /// Poll until the app deletes our queue entry — deletion IS the /// consumption ack, so "the file is gone" means the runtime took the /// line into its frame-boundary dispatch. ~10s budget at 25ms: /// consumption normally takes one presented frame, so hitting the /// timeout means the app is gone, frozen, or not presenting. A vanished /// DIRECTORY counts as consumed for the same reason the old protocol /// accepted a vanished slot: the dropbox owner swept it. fn awaitCommandConsumed(io: std.Io, directory: []const u8, name: []const u8, attempt_budget: usize) error{NeverConsumed}!void { var attempts: usize = 0; while (attempts < attempt_budget) : (attempts += 1) { var entry_path: [256]u8 = undefined; const full_path = std.fmt.bufPrint(&entry_path, "{s}/{s}", .{ directory, name }) catch unreachable; var file = std.Io.Dir.cwd().openFile(io, full_path, .{}) catch return; file.close(io); std.Io.sleep(io, std.Io.Duration.fromNanoseconds(25 * std.time.ns_per_ms), .awake) catch {}; } return error.NeverConsumed; } /// Error out (loudly, with the absolute path) when the automation dir /// does not exist under the current cwd — the app creates it at start, /// so its absence means no automation-enabled app runs HERE and the /// command would be queued into the void. fn requireAutomationDir(io: std.Io) error{AutomationCommandFailed}!void { var dir = std.Io.Dir.cwd().openDir(io, automation_dir, .{}) catch { var dir_buffer: [1024]u8 = undefined; std.debug.print( "error: no automation dir at {s}\n" ++ " (the app creates it on launch when built with -Dautomation=true;\n" ++ " run this command from the app project's working directory)\n", .{automationDirDescription(io, &dir_buffer)}, ); return error.AutomationCommandFailed; }; dir.close(io); } /// The automation dir as an absolute path when the cwd resolves, the /// relative default otherwise — for messages only. fn automationDirDescription(io: std.Io, buffer: []u8) []const u8 { var cwd_buffer: [1024]u8 = undefined; const cwd_len = std.Io.Dir.cwd().realPathFile(io, ".", &cwd_buffer) catch return automation_dir; return std.fmt.bufPrint(buffer, "{s}/{s}", .{ cwd_buffer[0..cwd_len], automation_dir }) catch automation_dir; } /// Payloads a script exists to parse — snapshot/window-list text, wait /// output, bridge responses, screenshot artifact paths — go to STDOUT so /// `automate snapshot > file` and `| grep` work; diagnostics and progress /// stay on stderr (std.debug.print) throughout this file. fn emitPayload(io: std.Io, chunks: []const []const u8) error{AutomationCommandFailed}!void { var buffer: [4096]u8 = undefined; // Streaming, not positional: the default `writer` pwrite()s from offset // 0, so consecutive invocations sharing one redirected stdout (e.g. a // smoke script's `{ ...; } > log`) would clobber each other's payloads. var writer = std.Io.File.stdout().writerStreaming(io, &buffer); for (chunks) |chunk| writer.interface.writeAll(chunk) catch return error.AutomationCommandFailed; writer.interface.flush() catch return error.AutomationCommandFailed; } fn printFile(io: std.Io, name: []const u8) !void { var file_path: [256]u8 = undefined; const bytes = readFile(std.heap.page_allocator, io, path(&file_path, name)) catch { var dir_buffer: [1024]u8 = undefined; std.debug.print("error: no app connected — nothing readable at {s}\n", .{automationDirDescription(io, &dir_buffer)}); return error.AutomationCommandFailed; }; defer std.heap.page_allocator.free(bytes); try emitPayload(io, &.{bytes}); } // -------------------------------------------------- publisher liveness // // Dropbox files persist across builds and runs, so "a snapshot exists" // never means "an app is publishing": an app rebuilt WITHOUT // -Dautomation happily coexists with days-old files from an earlier // run, and serving those as live state once cost a full misdiagnosis // round. Every snapshot the framework writes carries the publisher's // pid in its header; reads refuse the file loudly when that pid is // gone (or the header predates the pid stamp). const Liveness = enum { live, no_pid, dead_publisher }; const WaitPolicy = enum { require_live_publisher, any_publisher }; /// A numeric `` field from the snapshot header. Only accepts /// the header field, not e.g. widget text echoing it: it must start a /// line or follow a space. fn headerField(bytes: []const u8, marker: []const u8) ?u32 { var search: usize = 0; while (std.mem.indexOfPos(u8, bytes, search, marker)) |index| { search = index + marker.len; if (index > 0 and bytes[index - 1] != ' ' and bytes[index - 1] != '\n') continue; var end = search; while (end < bytes.len and std.ascii.isDigit(bytes[end])) end += 1; if (end == search) continue; return std.fmt.parseUnsigned(u32, bytes[search..end], 10) catch continue; } return null; } /// `publisher_pid=` from the snapshot header, if present. fn publisherPid(bytes: []const u8) ?u32 { return headerField(bytes, "publisher_pid="); } /// Like `headerField`, for values that overflow u32 (nanosecond fields). fn headerField64(bytes: []const u8, marker: []const u8) ?u64 { var search: usize = 0; while (std.mem.indexOfPos(u8, bytes, search, marker)) |index| { search = index + marker.len; if (index > 0 and bytes[index - 1] != ' ' and bytes[index - 1] != '\n') continue; var end = search; while (end < bytes.len and std.ascii.isDigit(bytes[end])) end += 1; if (end == search) continue; return std.fmt.parseUnsigned(u64, bytes[search..end], 10) catch continue; } return null; } // -------------------------------------------------- stale-instance guard // // A LIVE publisher can still be the WRONG app: an instance launched // before the last rebuild keeps publishing plausible snapshots into the // same dropbox, and every read then describes yesterday's binary (two // full misdiagnosis rounds came from exactly this). The snapshot header // carries `runtime_uptime_ns`, so the publisher's start time is // `now - uptime`; when the newest binary in zig-out/bin was built AFTER // the publisher started, the publisher cannot be that binary — warn // loudly on every snapshot-interpreting verb (never fatal: running an // older build on purpose is legitimate). fn warnStaleInstanceIfDetectable(bytes: []const u8, io: std.Io) void { const uptime_ns = headerField64(bytes, "runtime_uptime_ns=") orelse return; const pid = publisherPid(bytes) orelse return; const now_ns: i128 = std.Io.Timestamp.now(io, .real).nanoseconds; if (now_ns <= 0) return; const started_ns: i128 = now_ns - @as(i128, uptime_ns); const newest = newestBinaryInZigOut(io) orelse return; // Two seconds of slack absorbs clock steps and the write/stat skew. if (newest.mtime_ns > started_ns + 2 * std.time.ns_per_s) { std.debug.print( "warning: the publishing app (pid {d}) started BEFORE zig-out/bin/{s} was last built -\n" ++ " this snapshot comes from an OLDER instance, not the binary you just built;\n" ++ " quit that instance (kill {d}) and relaunch, or its state will keep\n" ++ " impersonating the new build\n", .{ pid, newest.name, pid }, ); } } const NewestBinary = struct { name_storage: [128]u8 = undefined, name_len: usize = 0, mtime_ns: i128 = 0, fn name(self: *const NewestBinary) []const u8 { return self.name_storage[0..self.name_len]; } }; fn newestBinaryInZigOut(io: std.Io) ?struct { name: []const u8, mtime_ns: i128 } { const S = struct { var newest: NewestBinary = .{}; }; var dir = std.Io.Dir.cwd().openDir(io, "zig-out/bin", .{ .iterate = true }) catch return null; defer dir.close(io); var found = false; var iterator = dir.iterate(); while (iterator.next(io) catch null) |entry| { if (entry.kind != .file) continue; const stat = dir.statFile(io, entry.name, .{}) catch continue; const mtime_ns: i128 = stat.mtime.nanoseconds; if (!found or mtime_ns > S.newest.mtime_ns) { found = true; S.newest.mtime_ns = mtime_ns; const len = @min(entry.name.len, S.newest.name_storage.len); @memcpy(S.newest.name_storage[0..len], entry.name[0..len]); S.newest.name_len = len; } } if (!found) return null; return .{ .name = S.newest.name(), .mtime_ns = S.newest.mtime_ns }; } // ----------------------------------------------------- protocol handshake // // A stale `native` binary beside a fresh app (or the reverse) can drive // the wrong dropbox name, misread the snapshot format, or speak an old // command vocabulary — and every failure mode is SILENT (a days-old // snapshot reads as plausible state; a stale binary once cost a whole // misdiagnosis round). // Both binaries bake `protocol.version` at their own build time and the // app stamps its copy into every snapshot header; any read that would // interpret snapshot state first proves both sides speak the same // version, and refuses loudly — naming both versions — otherwise. const ProtocolSkew = union(enum) { ok, /// The publisher stamps no `protocol=` field: it predates the /// handshake (or this CLI is from the future relative to the app). missing, /// The publisher's version, when it differs from this binary's. mismatch: u32, }; /// `protocol=` from the snapshot header, checked against this /// binary's own baked-in `protocol.version`. fn protocolSkew(bytes: []const u8) ProtocolSkew { const published = headerField(bytes, "protocol=") orelse return .missing; if (published == protocol.version) return .ok; return .{ .mismatch = published }; } fn describeProtocolSkew(skew: ProtocolSkew, io: std.Io) void { var dir_buffer: [1024]u8 = undefined; const dir = automationDirDescription(io, &dir_buffer); switch (skew) { .ok => {}, .mismatch => |published| std.debug.print( "error: automation protocol mismatch at {s}/snapshot.txt\n" ++ " the running app publishes protocol v{d} but this native binary speaks v{d} -\n" ++ " one of them is stale; rebuild the native CLI (zig build) and/or relaunch\n" ++ " the freshly built app so both share one protocol (compare `native version`\n" ++ " against your framework checkout, and delete stale zig-out binaries)\n", .{ dir, published, protocol.version }, ), .missing => std.debug.print( "error: the automation snapshot at {s}/snapshot.txt carries no protocol version\n" ++ " its publisher predates the protocol handshake, so this native binary\n" ++ " (protocol v{d}) may misread it - rebuild and relaunch the app against the\n" ++ " current framework; if the app IS current, this native binary is the stale\n" ++ " side (an old zig-out copy?) - rebuild it and check `native version`\n", .{ dir, protocol.version }, ), } } /// Refuse snapshot-interpreting reads when the publisher speaks a /// different protocol than this binary. Only meaningful on a LIVE /// publisher — stale files already fail the pid guard first. fn requireProtocolMatch(bytes: []const u8, io: std.Io) error{AutomationCommandFailed}!void { const skew = protocolSkew(bytes); if (skew == .ok) return; describeProtocolSkew(skew, io); return error.AutomationCommandFailed; } /// Command-queue guard: sends stay permissive when no snapshot exists /// yet (the app may still be starting) and when the publisher is dead /// (the next read screams), but a LIVE publisher on another protocol is /// a proven binary/dropbox skew. fn requireCompatibleSnapshotIfPresent(allocator: std.mem.Allocator, io: std.Io) error{AutomationCommandFailed}!void { var file_path: [256]u8 = undefined; const bytes = readFile(allocator, io, path(&file_path, "snapshot.txt")) catch return; defer allocator.free(bytes); if (snapshotLiveness(bytes, pidIsAlive) != .live) return; try requireProtocolMatch(bytes, io); warnStaleInstanceIfDetectable(bytes, io); } /// Classify a snapshot's publisher against the live process table via /// an injectable probe (tests pass their own). fn snapshotLiveness(bytes: []const u8, alive: *const fn (u32) bool) Liveness { const pid = publisherPid(bytes) orelse return .no_pid; if (pid == 0) return .no_pid; return if (alive(pid)) .live else .dead_publisher; } /// True when `pid` names a running process. Signal 0 probes without /// delivering; EPERM still proves existence. Pid reuse can in principle /// vouch for the wrong process — accepted, the window is tiny and the /// failure mode is the pre-#93 status quo. Windows has no cheap probe /// from here; never false-alarm there. fn pidIsAlive(pid: u32) bool { if (builtin.os.tag == .windows) return true; if (pid == 0) return false; std.posix.kill(@intCast(pid), @enumFromInt(0)) catch |err| return err == error.PermissionDenied; return true; } fn describeStaleness(liveness: Liveness, io: std.Io) void { var dir_buffer: [1024]u8 = undefined; const dir = automationDirDescription(io, &dir_buffer); switch (liveness) { .live => {}, .dead_publisher => std.debug.print( "error: stale automation snapshot at {s}/snapshot.txt\n" ++ " its publisher is no longer running, so the file is left over from an\n" ++ " earlier run - relaunch the app built with -Dautomation=true (a live\n" ++ " publisher rewrites the snapshot with its own pid on every presented frame)\n", .{dir}, ), .no_pid => std.debug.print( "error: stale automation snapshot at {s}/snapshot.txt\n" ++ " it carries no publisher_pid, so it predates the current publisher -\n" ++ " rebuild and relaunch the app with -Dautomation=true\n", .{dir}, ), } } /// Refuse a read command outright when the dropbox's snapshot has no /// live publisher. A missing snapshot falls through: the caller's own /// no-app error (which names the directory) stays the message for that. fn requireLiveSnapshotFile(allocator: std.mem.Allocator, io: std.Io) !void { var file_path: [256]u8 = undefined; const bytes = readFile(allocator, io, path(&file_path, "snapshot.txt")) catch return; defer allocator.free(bytes); const liveness = snapshotLiveness(bytes, pidIsAlive); if (liveness == .live) { try requireProtocolMatch(bytes, io); warnStaleInstanceIfDetectable(bytes, io); return; } describeStaleness(liveness, io); return error.AutomationCommandFailed; } fn waitForFile(allocator: std.mem.Allocator, io: std.Io, name: []const u8, marker: []const u8, policy: WaitPolicy) !void { // 30s to match the assert default: `wait` gates on a cold app start, // and a GTK/software-EGL launch on a loaded shared CI runner routinely // needs more than the 5s this used to allow. var last_liveness: Liveness = .live; var saw_file = false; var attempts: usize = 0; while (attempts < 300) : (attempts += 1) { var file_path: [256]u8 = undefined; const bytes = readFile(allocator, io, path(&file_path, name)) catch { try std.Io.sleep(io, std.Io.Duration.fromNanoseconds(100 * std.time.ns_per_ms), .awake); continue; }; saw_file = true; if (marker.len == 0 or std.mem.indexOf(u8, bytes, marker) != null) { // A marker match in a stale file is yesterday's app looking // ready; keep polling for a live publisher instead. last_liveness = if (policy == .require_live_publisher) snapshotLiveness(bytes, pidIsAlive) else .live; if (last_liveness == .live) { defer allocator.free(bytes); // A LIVE publisher speaking the wrong protocol never gets // better by polling: fail fast, naming both versions. if (policy == .require_live_publisher) try requireProtocolMatch(bytes, io); warnStaleInstanceIfDetectable(bytes, io); return emitPayload(io, &.{bytes}); } } allocator.free(bytes); try std.Io.sleep(io, std.Io.Duration.fromNanoseconds(100 * std.time.ns_per_ms), .awake); } if (!saw_file) { // The common cold-start miss: nothing was ever published here. // Name the two usual causes instead of a bare timeout — the app // was not built with -Dautomation=true, or this is the wrong cwd. var dir_buffer: [1024]u8 = undefined; std.debug.print( "error: timed out waiting for automation - no {s} ever appeared at {s}\n" ++ " (the app publishes it on every presented frame when built with\n" ++ " -Dautomation=true; check the app is running, that it was built with\n" ++ " -Dautomation=true, and that you run this from the app's directory)\n", .{ name, automationDirDescription(io, &dir_buffer) }, ); return error.AutomationCommandFailed; } describeStaleness(last_liveness, io); return fail("timed out waiting for automation"); } fn waitForScreenshot(io: std.Io, name: []const u8) !void { // Screenshots are published atomically (write + rename), so existence // means the PNG is complete. Reference rendering large surfaces takes a // moment, so poll longer than text artifacts. var attempts: usize = 0; while (attempts < 100) : (attempts += 1) { var file_path: [256]u8 = undefined; const screenshot_path = path(&file_path, name); if (std.Io.Dir.cwd().openFile(io, screenshot_path, .{})) |opened| { var file = opened; file.close(io); return emitPayload(io, &.{ screenshot_path, "\n" }); } else |_| {} try std.Io.sleep(io, std.Io.Duration.fromNanoseconds(100 * std.time.ns_per_ms), .awake); } return fail("timed out waiting for screenshot"); } fn deleteAutomationFile(io: std.Io, name: []const u8) void { var file_path: [256]u8 = undefined; std.Io.Dir.cwd().deleteFile(io, path(&file_path, name)) catch {}; } fn readFile(allocator: std.mem.Allocator, io: std.Io, file_path: []const u8) ![]u8 { var file = try std.Io.Dir.cwd().openFile(io, file_path, .{}); defer file.close(io); var read_buffer: [4096]u8 = undefined; var reader = file.reader(io, &read_buffer); return reader.interface.allocRemaining(allocator, .limited(1024 * 1024)); } fn path(buffer: []u8, name: []const u8) []const u8 { return std.fmt.bufPrint(buffer, "{s}/{s}", .{ automation_dir, name }) catch unreachable; } fn fail(message: []const u8) error{AutomationCommandFailed} { std.debug.print("error: {s}\n", .{message}); return error.AutomationCommandFailed; } // ---------------------------------------------------------------- assert const assert_poll_interval_ms = 100; const assert_default_timeout_ms: u32 = 30_000; const assert_tail_lines = 20; const assert_max_patterns = 32; const AssertSpec = struct { patterns: []const []const u8, absent: bool = false, timeout_ms: u32 = assert_default_timeout_ms, }; const AssertParseError = error{ MissingFlagValue, InvalidTimeout, NoPatterns, TooManyPatterns, }; /// `assert [--absent] [--timeout-ms N] ...` — flags may appear /// anywhere; everything else is a pattern. `patterns_buffer` holds the /// positional slices, so no allocation happens here. fn parseAssertArgs(args: []const []const u8, patterns_buffer: [][]const u8) AssertParseError!AssertSpec { var spec: AssertSpec = .{ .patterns = &.{} }; var count: usize = 0; var index: usize = 0; while (index < args.len) : (index += 1) { const arg = args[index]; if (std.mem.eql(u8, arg, "--absent")) { spec.absent = true; } else if (std.mem.eql(u8, arg, "--timeout-ms")) { index += 1; if (index >= args.len) return error.MissingFlagValue; spec.timeout_ms = std.fmt.parseUnsigned(u32, args[index], 10) catch return error.InvalidTimeout; } else { if (count >= patterns_buffer.len) return error.TooManyPatterns; patterns_buffer[count] = arg; count += 1; } } if (count == 0) return error.NoPatterns; spec.patterns = patterns_buffer[0..count]; return spec; } fn runAssert(allocator: std.mem.Allocator, io: std.Io, args: []const []const u8) !void { var patterns_buffer: [assert_max_patterns][]const u8 = undefined; const spec = parseAssertArgs(args, &patterns_buffer) catch |err| switch (err) { error.NoPatterns => return fail("assert needs at least one pattern (see: native automate)"), error.MissingFlagValue => return fail("--timeout-ms requires a value in milliseconds"), error.InvalidTimeout => return fail("--timeout-ms value must be a positive integer"), error.TooManyPatterns => return fail("too many assert patterns (max 32)"), }; for (spec.patterns) |pattern| { validatePattern(pattern) catch { std.debug.print("error: invalid pattern: {s}\n", .{pattern}); std.debug.print(" (supported: literals, . * + ? ^ $ [...] and \\d \\w \\s escapes)\n", .{}); return error.AutomationCommandFailed; }; } var elapsed_ms: u64 = 0; var snapshot: ?[]u8 = null; defer if (snapshot) |bytes| allocator.free(bytes); while (true) { if (snapshot) |bytes| { allocator.free(bytes); snapshot = null; } var file_path: [256]u8 = undefined; snapshot = readFile(allocator, io, path(&file_path, "snapshot.txt")) catch null; if (snapshot) |bytes| { // A live publisher on the wrong protocol can neither satisfy // nor refute assertions, and polling never fixes it. if (snapshotLiveness(bytes, pidIsAlive) == .live) try requireProtocolMatch(bytes, io); // A stale dropbox (dead or pid-less publisher) can neither // satisfy nor refute assertions — keep polling for live state. if (snapshotLiveness(bytes, pidIsAlive) == .live and assertSatisfied(bytes, spec.patterns, spec.absent)) { warnStaleInstanceIfDetectable(bytes, io); std.debug.print("assert ok: {d} pattern(s) {s} after {d}ms\n", .{ spec.patterns.len, if (spec.absent) "absent" else "matched", elapsed_ms, }); return; } } if (elapsed_ms >= spec.timeout_ms) break; try std.Io.sleep(io, std.Io.Duration.fromNanoseconds(assert_poll_interval_ms * std.time.ns_per_ms), .awake); elapsed_ms += assert_poll_interval_ms; } // Timed out: name every unsatisfied pattern, then show where we looked // and the snapshot tail so CI logs carry the evidence. std.debug.print("error: automate assert failed after {d}ms\n", .{spec.timeout_ms}); if (snapshot) |bytes| { const liveness = snapshotLiveness(bytes, pidIsAlive); if (liveness != .live) { describeStaleness(liveness, io); return error.AutomationCommandFailed; } for (spec.patterns) |pattern| { const matched = matchesPattern(pattern, bytes) catch false; if (spec.absent and matched) { std.debug.print(" still present: {s}\n", .{pattern}); } else if (!spec.absent and !matched) { std.debug.print(" missing: {s}\n", .{pattern}); } } const tail = textTail(bytes, assert_tail_lines); std.debug.print("--- snapshot.txt tail (last {d} lines) ---\n{s}", .{ assert_tail_lines, tail }); if (tail.len == 0 or tail[tail.len - 1] != '\n') std.debug.print("\n", .{}); } else { var dir_buffer: [1024]u8 = undefined; std.debug.print( " no snapshot at {s}\n" ++ " (the app creates it on launch when built with -Dautomation=true;\n" ++ " run this command from the app project's working directory)\n", .{automationDirDescription(io, &dir_buffer)}, ); } return error.AutomationCommandFailed; } /// All patterns must match (or, with `absent`, none may match). fn assertSatisfied(text: []const u8, patterns: []const []const u8, absent: bool) bool { for (patterns) |pattern| { const matched = matchesPattern(pattern, text) catch return false; if (matched == absent) return false; } return true; } /// The last `max_lines` lines of `text` (for failure output). fn textTail(text: []const u8, max_lines: usize) []const u8 { const trimmed = std.mem.trimEnd(u8, text, "\n"); if (trimmed.len == 0) return text[0..0]; var lines: usize = 0; var index = trimmed.len; while (index > 0) { index -= 1; if (trimmed[index] == '\n') { lines += 1; if (lines == max_lines) return text[index + 1 ..]; } } return text; } // ------------------------------------------------------- pattern matching // // A small grep-style regex subset, so CI assertions do not need a shell // pipeline: literals, `.` (any char but newline), postfix `*` `+` `?`, // line anchors `^` and `$`, character classes `[abc]` / `[^a-z0-9]`, and // the escapes `\d \D \w \W \s \S` plus escaped metacharacters (`\.`). // No groups or alternation — assert takes multiple patterns instead. const PatternError = error{InvalidPattern}; /// True when `pattern` matches anywhere in `text`. fn matchesPattern(pattern: []const u8, text: []const u8) PatternError!bool { try validatePattern(pattern); var start: usize = 0; while (true) { if (matchHere(pattern, 0, text, start)) return true; if (start >= text.len) return false; start += 1; } } fn validatePattern(pattern: []const u8) PatternError!void { var index: usize = 0; var last_atom = false; while (index < pattern.len) { const ch = pattern[index]; switch (ch) { '*', '+', '?' => { if (!last_atom) return error.InvalidPattern; last_atom = false; index += 1; }, '^', '$' => { last_atom = false; index += 1; }, else => { index = try atomEnd(pattern, index); last_atom = true; }, } } } /// End index (exclusive) of the atom starting at `index`. fn atomEnd(pattern: []const u8, index: usize) PatternError!usize { switch (pattern[index]) { '\\' => { if (index + 1 >= pattern.len) return error.InvalidPattern; return index + 2; }, '[' => { var end = index + 1; if (end < pattern.len and pattern[end] == '^') end += 1; // A `]` directly after `[` or `[^` is a literal member. if (end < pattern.len and pattern[end] == ']') end += 1; while (end < pattern.len and pattern[end] != ']') { end += if (pattern[end] == '\\') @as(usize, 2) else 1; } if (end >= pattern.len) return error.InvalidPattern; return end + 1; }, else => return index + 1, } } fn matchHere(pattern: []const u8, p: usize, text: []const u8, t: usize) bool { if (p >= pattern.len) return true; switch (pattern[p]) { '^' => { if (t == 0 or text[t - 1] == '\n') return matchHere(pattern, p + 1, text, t); return false; }, '$' => { if (t == text.len or text[t] == '\n') return matchHere(pattern, p + 1, text, t); return false; }, else => {}, } const end = atomEnd(pattern, p) catch return false; const quantifier: u8 = if (end < pattern.len) pattern[end] else 0; switch (quantifier) { '*' => return matchRepeat(pattern, p, end, end + 1, text, t, 0), '+' => return matchRepeat(pattern, p, end, end + 1, text, t, 1), '?' => { if (t < text.len and atomMatches(pattern[p..end], text[t])) { if (matchHere(pattern, end + 1, text, t + 1)) return true; } return matchHere(pattern, end + 1, text, t); }, else => { if (t < text.len and atomMatches(pattern[p..end], text[t])) { return matchHere(pattern, end, text, t + 1); } return false; }, } } /// Greedy repetition with backtracking: consume as many atom matches as /// possible, then retreat until the rest of the pattern matches. fn matchRepeat(pattern: []const u8, atom_start: usize, atom_stop: usize, rest: usize, text: []const u8, t: usize, min: usize) bool { const atom = pattern[atom_start..atom_stop]; var count: usize = 0; while (t + count < text.len and atomMatches(atom, text[t + count])) count += 1; while (true) { if (count >= min and matchHere(pattern, rest, text, t + count)) return true; if (count == 0) return false; count -= 1; if (count < min) return false; } } fn atomMatches(atom: []const u8, ch: u8) bool { if (atom.len == 1) { return switch (atom[0]) { '.' => ch != '\n', else => atom[0] == ch, }; } if (atom[0] == '\\') return escapeMatches(atom[1], ch); if (atom[0] == '[') return classMatches(atom[1 .. atom.len - 1], ch); return false; } fn escapeMatches(escape: u8, ch: u8) bool { return switch (escape) { 'd' => std.ascii.isDigit(ch), 'D' => !std.ascii.isDigit(ch), 'w' => std.ascii.isAlphanumeric(ch) or ch == '_', 'W' => !(std.ascii.isAlphanumeric(ch) or ch == '_'), 's' => std.ascii.isWhitespace(ch), 'S' => !std.ascii.isWhitespace(ch), 'n' => ch == '\n', 't' => ch == '\t', else => escape == ch, }; } /// `body` is the class content without brackets; supports leading `^`, /// ranges (`a-z`), `\`-escapes, and a literal `]` as the first member. fn classMatches(body: []const u8, ch: u8) bool { var negate = false; var index: usize = 0; if (index < body.len and body[index] == '^') { negate = true; index += 1; } var found = false; while (index < body.len) { const low: u8 = body[index]; if (low == '\\' and index + 1 < body.len) { index += 1; if (escapeMatches(body[index], ch)) found = true; index += 1; continue; } index += 1; if (index + 1 < body.len and body[index] == '-') { const high = body[index + 1]; if (ch >= low and ch <= high) found = true; index += 2; } else if (low == ch) { found = true; } } return found != negate; } // ------------------------------------------------------------------ tests const testing = std.testing; test "matchesPattern: literals and metacharacters" { try testing.expect(try matchesPattern("ready=true", "app foo\nready=true\n")); try testing.expect(!try matchesPattern("ready=true", "ready=false\n")); try testing.expect(try matchesPattern("count: \\d+", "status count: 42 open")); try testing.expect(!try matchesPattern("count: \\d+", "status count: none")); try testing.expect(try matchesPattern("role=button name=\"Reset\"", "widget #3 role=button name=\"Reset\"\n")); try testing.expect(try matchesPattern("gpu_.*=true", "gpu_nonblank=true")); try testing.expect(try matchesPattern("a.c", "abc")); try testing.expect(!try matchesPattern("a.c", "a\nc")); } test "matchesPattern: quantifiers backtrack" { try testing.expect(try matchesPattern("wo*rld", "wrld")); try testing.expect(try matchesPattern("wo+rld", "wooorld")); try testing.expect(!try matchesPattern("wo+rld", "wrld")); try testing.expect(try matchesPattern("colou?r", "color")); try testing.expect(try matchesPattern("colou?r", "colour")); try testing.expect(try matchesPattern("a.*b", "a x b y b")); try testing.expect(try matchesPattern(".*=true$", "gpu_nonblank=true")); } test "matchesPattern: line anchors work mid-file" { const snapshot = "app demo\nready=true dispatch_errors=0\nwindow main\n"; try testing.expect(try matchesPattern("^ready=true", snapshot)); try testing.expect(try matchesPattern("^window main$", snapshot)); try testing.expect(!try matchesPattern("^main$", snapshot)); try testing.expect(try matchesPattern("dispatch_errors=0$", snapshot)); } test "matchesPattern: character classes" { try testing.expect(try matchesPattern("[0-9]+ open", "inbox 4 open")); try testing.expect(try matchesPattern("[a-z_]+=true", "gpu_nonblank=true")); try testing.expect(try matchesPattern("[^a]bc", "xbc")); try testing.expect(!try matchesPattern("[^a]bc", "abc")); try testing.expect(try matchesPattern("[]x]", "]")); } test "matchesPattern: invalid patterns are rejected" { try testing.expectError(error.InvalidPattern, matchesPattern("*x", "anything")); try testing.expectError(error.InvalidPattern, matchesPattern("a\\", "anything")); try testing.expectError(error.InvalidPattern, matchesPattern("[abc", "anything")); try testing.expectError(error.InvalidPattern, matchesPattern("^*", "anything")); } test "assertSatisfied: present and absent modes" { const snapshot = "ready=true\ngpu_nonblank=true\nwidget role=button name=\"Reset\"\n"; const present = [_][]const u8{ "ready=true", "gpu_nonblank=true", "name=\"Reset\"" }; try testing.expect(assertSatisfied(snapshot, &present, false)); const with_missing = [_][]const u8{ "ready=true", "name=\"Delete\"" }; try testing.expect(!assertSatisfied(snapshot, &with_missing, false)); const gone = [_][]const u8{ "error event=", "panicked" }; try testing.expect(assertSatisfied(snapshot, &gone, true)); const still_there = [_][]const u8{"gpu_nonblank=true"}; try testing.expect(!assertSatisfied(snapshot, &still_there, true)); } test "parseAssertArgs: flags and patterns in any order" { var buffer: [assert_max_patterns][]const u8 = undefined; const plain = try parseAssertArgs(&.{ "ready=true", "count: 0" }, &buffer); try testing.expectEqual(@as(usize, 2), plain.patterns.len); try testing.expect(!plain.absent); try testing.expectEqual(assert_default_timeout_ms, plain.timeout_ms); const flagged = try parseAssertArgs(&.{ "--absent", "error event=", "--timeout-ms", "5000" }, &buffer); try testing.expect(flagged.absent); try testing.expectEqual(@as(u32, 5000), flagged.timeout_ms); try testing.expectEqualStrings("error event=", flagged.patterns[0]); try testing.expectError(error.NoPatterns, parseAssertArgs(&.{"--absent"}, &buffer)); try testing.expectError(error.MissingFlagValue, parseAssertArgs(&.{ "x", "--timeout-ms" }, &buffer)); try testing.expectError(error.InvalidTimeout, parseAssertArgs(&.{ "x", "--timeout-ms", "soon" }, &buffer)); } fn alwaysAlive(pid: u32) bool { _ = pid; return true; } fn neverAlive(pid: u32) bool { _ = pid; return false; } test "publisherPid parses the header field only" { try testing.expectEqual(@as(?u32, 4242), publisherPid("ready=true frame=3 publisher_pid=4242\nwindow @w1\n")); try testing.expectEqual(@as(?u32, 7), publisherPid("publisher_pid=7\n")); // Pre-#93 snapshots carry no pid at all. try testing.expectEqual(@as(?u32, null), publisherPid("ready=true frame=3 runtime_uptime_ns=42\n")); // Widget text echoing the token mid-word is not the header field. try testing.expectEqual(@as(?u32, null), publisherPid("name=\"xpublisher_pid=9\"")); try testing.expectEqual(@as(?u32, 9), publisherPid("text a\nname x publisher_pid=9\n")); try testing.expectEqual(@as(?u32, null), publisherPid("publisher_pid=abc\n")); } test "protocolSkew matches this binary's version and names the skew" { var ok_buffer: [64]u8 = undefined; const ok_snapshot = try std.fmt.bufPrint(&ok_buffer, "ready=true protocol={d} publisher_pid=4242\n", .{protocol.version}); try testing.expectEqual(ProtocolSkew.ok, protocolSkew(ok_snapshot)); var skewed_buffer: [64]u8 = undefined; const skewed_snapshot = try std.fmt.bufPrint(&skewed_buffer, "ready=true protocol={d} publisher_pid=4242\n", .{protocol.version + 1}); try testing.expectEqual(ProtocolSkew{ .mismatch = protocol.version + 1 }, protocolSkew(skewed_snapshot)); // Pre-handshake publishers stamp no protocol field at all. try testing.expectEqual(ProtocolSkew.missing, protocolSkew("ready=true frame=3 publisher_pid=4242\n")); // Widget text echoing the token mid-word is not the header field. try testing.expectEqual(ProtocolSkew.missing, protocolSkew("name=\"xprotocol=9\"\n")); } test "snapshotLiveness classifies both directions" { const live_snapshot = "ready=true publisher_pid=4242\n"; try testing.expectEqual(Liveness.live, snapshotLiveness(live_snapshot, alwaysAlive)); try testing.expectEqual(Liveness.dead_publisher, snapshotLiveness(live_snapshot, neverAlive)); try testing.expectEqual(Liveness.no_pid, snapshotLiveness("ready=true frame=3\n", alwaysAlive)); // pid 0 means the platform could not stamp one — unverifiable is stale. try testing.expectEqual(Liveness.no_pid, snapshotLiveness("ready=true publisher_pid=0\n", alwaysAlive)); } test "pidIsAlive: own process is alive, pid 0 is not" { if (builtin.os.tag == .windows or builtin.os.tag == .wasi) return; const own: u32 = @intCast(@max(0, std.posix.system.getpid())); try testing.expect(pidIsAlive(own)); try testing.expect(!pidIsAlive(0)); } test "textTail keeps the last lines only" { try testing.expectEqualStrings("", textTail("", 3)); try testing.expectEqualStrings("a\nb\n", textTail("a\nb\n", 3)); try testing.expectEqualStrings("c\nd\ne\n", textTail("a\nb\nc\nd\ne\n", 3)); try testing.expectEqualStrings("d\ne", textTail("c\nd\ne", 2)); } /// Pid-suffixed scratch dropbox: these tests are compiled into more than /// one test binary and the aggregate build step runs those binaries in /// parallel, so a fixed path would let two copies race each other. fn queueTestDirectory(buffer: []u8, comptime prefix: []const u8) ![]const u8 { const pid: u32 = switch (builtin.os.tag) { .windows => std.os.windows.GetCurrentProcessId(), .wasi, .freestanding, .emscripten => 0, else => @intCast(@max(0, std.posix.system.getpid())), }; return std.fmt.bufPrint(buffer, prefix ++ "-{d}", .{pid}); } test "scanQueue counts only queue entries and picks max-present + 1" { var dir_buffer: [64]u8 = undefined; const directory = try queueTestDirectory(&dir_buffer, ".zig-cache/test-native-cli-queue-scan"); var cwd = std.Io.Dir.cwd(); cwd.deleteTree(testing.io, directory) catch {}; try cwd.createDirPath(testing.io, directory); defer cwd.deleteTree(testing.io, directory) catch {}; // Empty queue: nothing pending, sequences start at 1. try testing.expectEqual(@as(usize, 0), scanQueue(testing.io, directory).pending); try testing.expectEqual(@as(u64, 1), scanQueue(testing.io, directory).next_sequence); // Gapped entries count; snapshots, response artifacts, and a // retired-v5 writer's `command.txt` slot are invisible to the queue. var path_buffer: [128]u8 = undefined; for ([_][]const u8{ "command-2.txt", "command-5.txt", "command.txt", "snapshot.txt" }) |name| { try cwd.writeFile(testing.io, .{ .sub_path = try std.fmt.bufPrint(&path_buffer, "{s}/{s}", .{ directory, name }), .data = "menu-command app.probe\n", }); } const state = scanQueue(testing.io, directory); try testing.expectEqual(@as(usize, 2), state.pending); try testing.expectEqual(@as(u64, 6), state.next_sequence); } test "enqueueCommand claims the next sequence exclusively and refuses a full queue loudly" { var dir_buffer: [64]u8 = undefined; const directory = try queueTestDirectory(&dir_buffer, ".zig-cache/test-native-cli-queue-claim"); var cwd = std.Io.Dir.cwd(); cwd.deleteTree(testing.io, directory) catch {}; try cwd.createDirPath(testing.io, directory); defer cwd.deleteTree(testing.io, directory) catch {}; // Claim into a queue that already holds an entry: the new command // lands BEHIND it (higher sequence), preserving arrival order. var path_buffer: [128]u8 = undefined; try cwd.writeFile(testing.io, .{ .sub_path = try std.fmt.bufPrint(&path_buffer, "{s}/command-3.txt", .{directory}), .data = "menu-command app.earlier\n", }); var name_buffer: [64]u8 = undefined; const name = try enqueueCommand(testing.io, directory, "menu-command app.later\n", &name_buffer, queue_attempt_budget); try testing.expectEqualStrings("command-4.txt", name); var content_buffer: [64]u8 = undefined; var file = try cwd.openFile(testing.io, try std.fmt.bufPrint(&path_buffer, "{s}/{s}", .{ directory, name }), .{}); const content_len = try file.readPositionalAll(testing.io, &content_buffer, 0); file.close(testing.io); try testing.expectEqualStrings("menu-command app.later\n", content_buffer[0..content_len]); // The overflow pin: fill the queue to its bound and the writer // refuses (after its retry budget — 1 here so the test never really // waits) instead of overwriting or silently dropping anything. var sequence: u64 = 5; while (scanQueue(testing.io, directory).pending < protocol.max_queued_commands) : (sequence += 1) { try cwd.writeFile(testing.io, .{ .sub_path = try std.fmt.bufPrint(&path_buffer, "{s}/command-{d}.txt", .{ directory, sequence }), .data = "menu-command app.filler\n", }); } try testing.expectError( error.QueueStayedFull, enqueueCommand(testing.io, directory, "menu-command app.overflow\n", &name_buffer, 1), ); // Nothing pending was disturbed by the refusal. try testing.expectEqual(protocol.max_queued_commands, scanQueue(testing.io, directory).pending); // Draining one entry frees a slot and the very next claim succeeds. try cwd.deleteFile(testing.io, try std.fmt.bufPrint(&path_buffer, "{s}/command-3.txt", .{directory})); const freed = try enqueueCommand(testing.io, directory, "menu-command app.freed\n", &name_buffer, queue_attempt_budget); try testing.expect(protocol.queueFileSequence(freed) != null); } test "awaitCommandConsumed returns on deletion and fails loudly on a stuck entry" { var dir_buffer: [64]u8 = undefined; const directory = try queueTestDirectory(&dir_buffer, ".zig-cache/test-native-cli-queue-consume"); var cwd = std.Io.Dir.cwd(); cwd.deleteTree(testing.io, directory) catch {}; try cwd.createDirPath(testing.io, directory); defer cwd.deleteTree(testing.io, directory) catch {}; // An entry the app already deleted (or that never existed — the // dropbox owner swept it) reads as consumed. try awaitCommandConsumed(testing.io, directory, "command-1.txt", 1); // An entry the app never touches fails once the budget is spent — // the CLI's honest signal that the command did NOT dispatch. var path_buffer: [128]u8 = undefined; try cwd.writeFile(testing.io, .{ .sub_path = try std.fmt.bufPrint(&path_buffer, "{s}/command-2.txt", .{directory}), .data = "menu-command app.stuck\n", }); try testing.expectError( error.NeverConsumed, awaitCommandConsumed(testing.io, directory, "command-2.txt", 1), ); }