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 @@
+
+
+