Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 45 additions & 4 deletions src/cdp/client.zig
Original file line number Diff line number Diff line change
Expand Up @@ -22,14 +22,24 @@ pub const EventBuffer = struct {
return self.items.items.len;
}

pub fn push(self: *EventBuffer, owner: std.mem.Allocator, event: []const u8) void {
if (self.items.items.len >= 256) {
pub fn push(self: *EventBuffer, caller_alloc: std.mem.Allocator, event: []const u8) void {
// Dupe into the long-lived event_buf allocator. Request-scoped arenas
// passed by callers are freed when the HTTP request completes, which
// happens long before a later har/stop request drains and frees these
// events. Storing the arena pointer in `owner` produced dangling refs.
const owned = self.allocator.dupe(u8, event) catch {
caller_alloc.free(event);
return;
};
// Release the caller's copy — we now hold the canonical allocation.
caller_alloc.free(event);
if (self.items.items.len >= 4096) {
const oldest = self.items.items[0];
oldest.owner.free(oldest.data);
_ = self.items.orderedRemove(0);
}
self.items.append(self.allocator, .{ .data = event, .owner = owner }) catch {
owner.free(event);
self.items.append(self.allocator, .{ .data = owned, .owner = self.allocator }) catch {
self.allocator.free(owned);
};
}

Expand Down Expand Up @@ -292,3 +302,34 @@ test "EventBuffer drain frees all" {
buf.drain();
try std.testing.expectEqual(@as(usize, 0), buf.len());
}

test "EventBuffer push dupes into internal allocator - cross-arena safety" {
// Regression test for #124: events buffered during a navigate request
// (arena A) must remain valid when consumed by a later har/stop request
// (arena B). push() must dupe into self.allocator, not keep the caller's ptr.
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer _ = gpa.deinit();

var buf = EventBuffer.init(gpa.allocator());
defer buf.deinit();

// Simulate arena A (navigate request) — allocate and push an event
var arena_a = std.heap.ArenaAllocator.init(gpa.allocator());
{
const event = try arena_a.allocator().dupe(u8, "{\"method\":\"Network.requestWillBeSent\",\"params\":{}}");
buf.push(arena_a.allocator(), event);
// event pointer is now freed inside push; arena_a can be safely torn down
}
arena_a.deinit(); // arena A is gone — dangling ptr if push stored arena_a ptr

// Simulate arena B (har/stop request) — consume events; must not crash/corrupt
var arena_b = std.heap.ArenaAllocator.init(gpa.allocator());
defer arena_b.deinit();
const drained = try buf.drainTo(arena_b.allocator());
defer arena_b.allocator().free(drained);

try std.testing.expectEqual(@as(usize, 1), drained.len);
try std.testing.expect(std.mem.indexOf(u8, drained[0].data, "Network.requestWillBeSent") != null);
// Free event data — must use the internal allocator (gpa), not arena_a
for (drained) |item| item.owner.free(item.data);
}
66 changes: 62 additions & 4 deletions src/server/router.zig
Original file line number Diff line number Diff line change
Expand Up @@ -628,14 +628,47 @@ fn handleAction(request: *std.http.Server.Request, arena: std.mem.Allocator, bri
resp.sendError(request, 400, "Missing value parameter for press");
return;
};
const params = std.fmt.allocPrint(arena, "{{\"expression\":\"document.dispatchEvent(new KeyboardEvent('keydown', {{key: '{s}'}})) || 'pressed'\",\"returnByValue\":true}}", .{v}) catch {
// Focus the target element first so key events reach the right element
if (node_id) |bid| {
const resolve_params = std.fmt.allocPrint(arena, "{{\"backendNodeId\":{d}}}", .{bid}) catch {
resp.sendError(request, 500, "Internal Server Error");
return;
};
const resolve_response = client.send(arena, protocol.Methods.dom_resolve_node, resolve_params) catch {
resp.sendError(request, 502, "DOM.resolveNode failed");
return;
};
if (extractSimpleJsonString(resolve_response, 0, "\"objectId\"")) |obj_id| {
const focus_params = std.fmt.allocPrint(arena, "{{\"objectId\":\"{s}\",\"functionDeclaration\":\"function() {{ this.focus(); }}\",\"returnByValue\":true}}", .{obj_id}) catch {
resp.sendError(request, 500, "Internal Server Error");
return;
};
_ = client.send(arena, protocol.Methods.runtime_call_function_on, focus_params) catch {};
}
}
// Use CDP Input.dispatchKeyEvent for native key processing
// (Enter submits forms, Escape closes dialogs, Tab moves focus, etc.)
// Only include "text" for single printable characters; special keys use empty text.
const key_text = if (v.len == 1 and v[0] >= 0x20 and v[0] < 0x7f) v else "";
const down_params = std.fmt.allocPrint(arena, "{{\"type\":\"keyDown\",\"key\":\"{s}\",\"text\":\"{s}\",\"unmodifiedText\":\"{s}\"}}", .{ v, key_text, key_text }) catch {
resp.sendError(request, 500, "Internal Server Error");
return;
};
const response = client.send(arena, protocol.Methods.runtime_evaluate, params) catch {
_ = client.send(arena, protocol.Methods.input_dispatch_key_event, down_params) catch {
resp.sendError(request, 502, "CDP command failed");
return;
};
const up_params = std.fmt.allocPrint(arena, "{{\"type\":\"keyUp\",\"key\":\"{s}\"}}", .{v}) catch {
resp.sendError(request, 500, "Internal Server Error");
return;
};
const response = client.send(arena, protocol.Methods.input_dispatch_key_event, up_params) catch {
resp.sendError(request, 502, "CDP command failed");
return;
};
// Flush microtasks so DOM mutations from key events are applied before responding
_ = client.send(arena, protocol.Methods.runtime_evaluate,
"{\"expression\":\"new Promise(r => setTimeout(r, 0))\",\"awaitPromise\":true,\"returnByValue\":true}") catch {};
resp.sendJson(request, response);
return;
}
Expand Down Expand Up @@ -709,10 +742,21 @@ fn handleAction(request: *std.http.Server.Request, arena: std.mem.Allocator, bri
resp.sendError(request, 502, "Runtime.callFunctionOn failed");
return;
};
// Flush microtasks so DOM mutations from the fill are applied before responding
_ = client.send(arena, protocol.Methods.runtime_evaluate,
"{\"expression\":\"new Promise(r => setTimeout(r, 0))\",\"awaitPromise\":true,\"returnByValue\":true}") catch {};
// Flush network events from the fill action
if (bridge.getHarRecorder(tab_id)) |rec| {
if (rec.isRecording()) {
flushEventsToHar(arena, client, rec);
}
}
resp.sendJson(request, change_response);
return;
}
const fn_str = std.fmt.allocPrint(arena, "function() {{ this.focus(); this.value = '{s}'; this.dispatchEvent(new Event('input', {{bubbles:true}})); return 'filled'; }}", .{v}) catch {
// Use nativeInputValueSetter to bypass React/Vue controlled input overrides,
// then dispatch input + change events so frameworks detect the mutation.
const fn_str = std.fmt.allocPrint(arena, "function() {{ this.focus(); var nativeSetter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value') || Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value'); if (nativeSetter && nativeSetter.set) {{ nativeSetter.set.call(this, '{s}'); }} else {{ this.value = '{s}'; }} this.dispatchEvent(new Event('input', {{bubbles:true}})); this.dispatchEvent(new Event('change', {{bubbles:true}})); return 'filled'; }}", .{ v, v }) catch {
resp.sendError(request, 500, "Internal Server Error");
return;
};
Expand All @@ -723,7 +767,7 @@ fn handleAction(request: *std.http.Server.Request, arena: std.mem.Allocator, bri
resp.sendError(request, 400, "Missing value parameter for select");
return;
};
const fn_str = std.fmt.allocPrint(arena, "function() {{ this.value = '{s}'; this.dispatchEvent(new Event('change', {{bubbles:true}})); return 'selected'; }}", .{v}) catch {
const fn_str = std.fmt.allocPrint(arena, "function() {{ this.value = '{s}'; this.dispatchEvent(new Event('input', {{bubbles:true}})); this.dispatchEvent(new Event('change', {{bubbles:true}})); return 'selected'; }}", .{v}) catch {
resp.sendError(request, 500, "Internal Server Error");
return;
};
Expand All @@ -741,6 +785,20 @@ fn handleAction(request: *std.http.Server.Request, arena: std.mem.Allocator, bri
resp.sendError(request, 502, "Runtime.callFunctionOn failed");
return;
};

// Flush microtasks so DOM mutations triggered by the action (React/Vue state
// updates, MutationObserver callbacks) are fully applied before we respond.
// Without this, a subsequent /snapshot call reads stale DOM (#125).
_ = client.send(arena, protocol.Methods.runtime_evaluate,
"{\"expression\":\"new Promise(r => setTimeout(r, 0))\",\"awaitPromise\":true,\"returnByValue\":true}") catch {};

// Flush network events triggered by the action (clicks often trigger XHR/fetch)
if (bridge.getHarRecorder(tab_id)) |rec| {
if (rec.isRecording()) {
flushEventsToHar(arena, client, rec);
}
}

resp.sendJson(request, call_response);
}

Expand Down
26 changes: 26 additions & 0 deletions src/test/integration.zig
Original file line number Diff line number Diff line change
Expand Up @@ -721,3 +721,29 @@ test "snapshot ref cache clear and repopulate cycle" {
try std.testing.expectEqual(@as(?u32, 20), cache.get("e0"));
try std.testing.expectEqual(@as(?u32, 22), cache.get("e2"));
}

test "action microtask flush params are well-formed JSON (#125)" {
// Regression test for #125: DOM mutations not applied after action.
// After every DOM-mutating action (click, fill, press, etc.) handleAction
// sends a Runtime.evaluate with awaitPromise:true to drain the browser's
// microtask queue so React/Vue state updates and MutationObserver callbacks
// are committed before we return. Verify the flush expression string is
// valid JSON containing the required fields.
const flush_json =
"{\"expression\":\"new Promise(r => setTimeout(r, 0))\",\"awaitPromise\":true,\"returnByValue\":true}";

// Must contain awaitPromise:true so CDP waits for the promise to resolve
try std.testing.expect(std.mem.indexOf(u8, flush_json, "\"awaitPromise\":true") != null);

// Must contain the setTimeout-based promise so the macrotask queue drains
try std.testing.expect(std.mem.indexOf(u8, flush_json, "setTimeout") != null);
try std.testing.expect(std.mem.indexOf(u8, flush_json, "new Promise") != null);

// Must be parseable as a JSON object (basic brace balance check)
var depth: i32 = 0;
for (flush_json) |ch| {
if (ch == '{') depth += 1;
if (ch == '}') depth -= 1;
}
try std.testing.expectEqual(@as(i32, 0), depth);
}