From bf0d69f33b79509368f0b6341ca5e77a5475b907 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A0=20Arrufat?= Date: Sat, 26 Sep 2026 21:47:15 +0200 Subject: [PATCH] runner: hold networkidle on live activity, not the CDP latch The .networkidle/.networkalmostidle wait conditions read Frame._notified_network_idle, the one-shot latch behind CDP's lifecycle event. Once a frame has been idle it stays .done, so waiting on it after an action that loads a popup or navigates an iframe resolved at once. Track instead, per Runner, how long the network has been idle and meet the condition once that has held for 500ms. The frame latch stays for the CDP events. --- src/browser/Runner.zig | 47 ++++++++++++++++++++++-- src/browser/tests/runner/late_fetch.html | 13 +++++++ 2 files changed, 56 insertions(+), 4 deletions(-) create mode 100644 src/browser/tests/runner/late_fetch.html diff --git a/src/browser/Runner.zig b/src/browser/Runner.zig index c2fb72857..18f743bdc 100644 --- a/src/browser/Runner.zig +++ b/src/browser/Runner.zig @@ -37,6 +37,13 @@ browser: *Browser, http_client: *HttpClient, background_poll_ms: u32 = 0, +// Not Frame._notified_network_idle: that latches for CDP's one-shot lifecycle +// event, so a frame idle once reads idle forever. +network_idle_since: ?u64 = null, +network_almost_idle_since: ?u64 = null, + +const network_idle_hold_ms = 500; + pub const Opts = struct {}; pub fn init(session: *Session, _: Opts) Runner { @@ -219,6 +226,8 @@ fn _tick(self: *Runner, comptime is_cdp: bool, timeout_ms: u32, conditions: []Wa // A navigation can swap a frame pointer or the page set, // so restart the tick to re-resolve cleanly. if (try session.processQueuedNavigation()) { + self.network_idle_since = null; + self.network_almost_idle_since = null; return .{ .ok = 0 }; } @@ -236,10 +245,13 @@ fn _tick(self: *Runner, comptime is_cdp: bool, timeout_ms: u32, conditions: []Wa const network_idle = activity.idle(); const is_done = browser.hasMacrotasks() == false and network_idle; + const now = lp.datetime.milliTimestamp(.boot); + const network_idle_held = holdsFor(&self.network_idle_since, network_idle, now); + const network_almost_idle_held = holdsFor(&self.network_almost_idle_since, total_network_activity <= 2, now); + // Outside the condition loop: it skips resolved conditions, but an idle // notification needs a check 500ms+ after the hold starts, and on a quiet - // page one tick both starts the hold and resolves the condition. Before - // it, so `.networkidle` conditions read fresh state. + // page one tick both starts the hold and resolves the condition. var page_index: usize = 0; while (page_index < session.pages.items.len) : (page_index += 1) { // Indexed: notifyNetworkIdle dispatches to listeners. @@ -294,8 +306,8 @@ fn _tick(self: *Runner, comptime is_cdp: bool, timeout_ms: u32, conditions: []Wa .done => is_done, .domcontentloaded => frame._load_state == .load or frame._load_state == .complete, .load => frame._load_state == .complete, - .networkidle => frame._notified_network_idle == .done, - .networkalmostidle => frame._notified_network_almost_idle == .done, + .networkidle => network_idle_held, + .networkalmostidle => network_almost_idle_held, }; // `met` resolves the condition. Otherwise, as long as there's @@ -355,6 +367,18 @@ fn _tick(self: *Runner, comptime is_cdp: bool, timeout_ms: u32, conditions: []Wa return .done; } +fn holdsFor(since: *?u64, active: bool, now: u64) bool { + if (active == false) { + since.* = null; + return false; + } + const start = since.* orelse { + since.* = now; + return false; + }; + return now - start >= network_idle_hold_ms; +} + pub fn waitForSelector(self: *Runner, frame_id: u32, input: [:0]const u8, timeout_ms: u32) !*Node.Element { const session = self.session; const arena = try session.getArena(.small, "Runner.waitForSelector"); @@ -655,3 +679,18 @@ test "Runner: waits out a throttled navigation" { const el = try runner.waitForSelector(page.frame_id, "#sel1", 10); try testing.expectEqual("selector-1-content", try el.asNode().getTextContentAlloc(testing.arena_allocator)); } + +test "Runner: networkidle waits out activity after the frame latched idle" { + const page = try testing.pageTest("runner/late_fetch.html", .{ .wait_until_done = false }); + defer page.close(); + + var runner = page.session.runner(.{}); + try runner.waitForFrame(page.frame_id, 2000, .{ .until = .load }); + + page.frame().?._notified_network_idle = .done; + + const start = lp.datetime.milliTimestamp(.boot); + try runner.waitForFrame(page.frame_id, 3000, .{ .until = .networkidle }); + try testing.expectEqual(true, lp.datetime.milliTimestamp(.boot) - start >= 500); + _ = try runner.waitForSelector(page.frame_id, "#fetched", 0); +} diff --git a/src/browser/tests/runner/late_fetch.html b/src/browser/tests/runner/late_fetch.html new file mode 100644 index 000000000..3fe416d7a --- /dev/null +++ b/src/browser/tests/runner/late_fetch.html @@ -0,0 +1,13 @@ + + +