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
+76 -1
View File
@@ -320,7 +320,27 @@ fn parse_command_inner(args: &[String], flags: &Flags) -> Result<Value, ParseErr
// scripts before the first real navigation (see `batch`).
// `goto` and `navigate` still require a URL since those verbs
// imply the navigation itself.
let first_url = rest.iter().find(|a| !a.starts_with("--"));
// The URL is the first positional arg, skipping flags AND any value
// consumed by `--wait-until` (so it isn't mistaken for the URL).
let first_url = {
let mut url = None;
let mut skip_next = false;
for a in &rest {
if skip_next {
skip_next = false;
continue;
}
if *a == "--wait-until" {
skip_next = true;
continue;
}
if !a.starts_with("--") {
url = Some(a);
break;
}
}
url
};
let url = match first_url {
Some(u) => *u,
None if cmd == "open" => {
@@ -350,6 +370,23 @@ fn parse_command_inner(args: &[String], flags: &Flags) -> Result<Value, ParseErr
if flags.provider.is_some() {
nav_cmd["waitUntil"] = json!("none");
}
// Explicit readiness override (issue #10): SPAs whose `load` event
// never fires (a long-lived XHR/websocket holds it open) hang out the
// load-event wait. `--wait-until domcontentloaded` returns as soon as
// the DOM is parsed.
if let Some(i) = rest.iter().position(|a| *a == "--wait-until") {
let val = rest.get(i + 1).ok_or(ParseError::MissingArguments {
context: "open --wait-until".to_string(),
usage: "open <url> --wait-until <load|domcontentloaded|networkidle|none>",
})?;
if !["load", "domcontentloaded", "networkidle", "none"].contains(val) {
return Err(ParseError::InvalidValue {
message: format!("Unknown --wait-until value: {}", val),
usage: "open <url> --wait-until <load|domcontentloaded|networkidle|none>",
});
}
nav_cmd["waitUntil"] = json!(val);
}
if let Some(ref headers_json) = flags.headers {
let headers =
serde_json::from_str::<serde_json::Value>(headers_json).map_err(|_| {
@@ -3643,6 +3680,44 @@ mod tests {
}
}
#[test]
fn test_open_wait_until_after_url() {
let cmd = parse_command(
&args("open https://x.com --wait-until domcontentloaded"),
&default_flags(),
)
.unwrap();
assert_eq!(cmd["action"], "navigate");
assert_eq!(cmd["url"], "https://x.com");
assert_eq!(cmd["waitUntil"], "domcontentloaded");
}
#[test]
fn test_open_wait_until_before_url_not_mistaken_for_url() {
// The --wait-until value must not be picked up as the URL.
let cmd = parse_command(
&args("open --wait-until domcontentloaded https://x.com"),
&default_flags(),
)
.unwrap();
assert_eq!(cmd["url"], "https://x.com");
assert_eq!(cmd["waitUntil"], "domcontentloaded");
}
#[test]
fn test_open_wait_until_rejects_bogus_value() {
let err = parse_command(
&args("open https://x.com --wait-until wat"),
&default_flags(),
)
.unwrap_err();
assert!(
err.format().contains("--wait-until"),
"got: {}",
err.format()
);
}
#[test]
fn test_find_bare_value_suggests_text_locator() {
// `find "I'm not a robot" click` — value where a locator keyword was