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.
This commit is contained in:
Adrià Arrufat committed 2026-09-26 21:48:32 +02:00
1 parent b2fb908e06
commit bf0d69f33b
2 files changed
+56 -4

No files matched your search

+43 -4
View File
@@ -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);
}
+13
View File
@@ -0,0 +1,13 @@
<!DOCTYPE html>
<meta charset="UTF-8">
<script>
// A pending timer, so the wait can't resolve through `.done`.
setTimeout(() => {}, 5000);
window.addEventListener('load', () => {
fetch('/xhr/slow').then(() => {
const div = document.createElement('div');
div.id = 'fetched';
document.body.appendChild(div);
});
});
</script>