fix(open): graceful load-timeout + --wait-until override for SPAs (issue #10)

`open` waits for the `load` event by default. SPAs whose `load` never fires
(a long-pending XHR or a stuck sub-resource holds it open) made `open`
hard-fail after the lifecycle timeout — even though the DOM was ready and
eval/screenshot worked immediately right after.

- Graceful degradation: if the lifecycle event times out but document.readyState
  is interactive/complete, navigate returns success carrying a `warning` in the
  response (the CLI prints it to stderr; --json keeps the field) instead of
  erroring. Only a still-loading document is a real failure.
- `open/goto/navigate` now accept `--wait-until <load|domcontentloaded|
  networkidle|none>` so SPAs can return as soon as the DOM is parsed. The URL
  parser skips the --wait-until value so it isn't mistaken for the URL.
- WaitUntil::as_str() for the warning label; output.rs surfaces response warnings.

Verified live: --wait-until domcontentloaded returns immediately on a page whose
load never fires; default load on the same page now succeeds at the timeout with
a clear stderr warning instead of failing. Adds parse tests for both arg orders
+ bogus value.
This commit is contained in:
leeguooooo
2026-06-12 12:19:36 +09:00
parent 266b610358
commit b4c1707a01
3 changed files with 127 additions and 4 deletions
+45 -3
View File
@@ -265,6 +265,15 @@ impl WaitUntil {
_ => Self::Load,
}
}
pub fn as_str(&self) -> &'static str {
match self {
Self::Load => "load",
Self::DomContentLoaded => "domcontentloaded",
Self::NetworkIdle => "networkidle",
Self::None => "none",
}
}
}
pub enum BrowserProcess {
@@ -800,9 +809,38 @@ impl BrowserManager {
// Only wait for lifecycle events if Chrome created a new loader (full navigation).
// If loader_id is None, it was a same-document navigation (e.g., hash routing)
// which does not fire Page.loadEventFired or Page.domContentEventFired.
let mut nav_warning: Option<String> = None;
if nav_result.loader_id.is_some() && wait_until != WaitUntil::None {
self.wait_for_lifecycle(wait_until, &session_id, &mut lifecycle_rx)
.await?;
if let Err(e) = self
.wait_for_lifecycle(wait_until, &session_id, &mut lifecycle_rx)
.await
{
// The lifecycle event (e.g. `load`) didn't fire within the
// timeout. On SPAs this is common — a long-pending XHR or a stuck
// sub-resource holds `load` open long after the DOM is interactive
// and the page is usable, so `open` would hard-fail even though
// eval/screenshot work immediately (issue #10). If the DOM is
// already ready, treat navigation as done (with a warning, carried
// in the response so the CLI can surface it) instead of failing.
// Only a still-loading document is a real failure.
let ready = self
.evaluate_simple("document.readyState")
.await
.ok()
.and_then(|v| v.as_str().map(str::to_string))
.unwrap_or_default();
if ready == "interactive" || ready == "complete" {
nav_warning = Some(format!(
"`{}` didn't complete within the timeout, but the DOM is ready ({}) — \
continuing. Pass `--wait-until domcontentloaded` to skip this wait on \
SPAs with long-lived requests.",
wait_until.as_str(),
ready
));
} else {
return Err(e);
}
}
}
let page_url = self.get_url().await.unwrap_or_else(|_| url.to_string());
@@ -821,7 +859,11 @@ impl BrowserManager {
page.title = title.clone();
}
Ok(json!({ "url": page_url, "title": title }))
let mut out = json!({ "url": page_url, "title": title });
if let Some(w) = nav_warning {
out["warning"] = json!(w);
}
Ok(out)
}
async fn wait_for_lifecycle(