diff --git a/BENCHMARK.md b/BENCHMARK.md index 654700f..ed5e096 100644 --- a/BENCHMARK.md +++ b/BENCHMARK.md @@ -211,9 +211,7 @@ Before using a benchmark in launch-facing latency copy or progress claims, run: ```bash python tools/check_benchmark_artifacts.py --source testbox --runner blacksmith-testbox --artifact --run-url --enforce-phase2 --enforce-launch --json -python tools/check_launch_latency_claim.py python tools/check_launch_latency_claim.py --benchmark-json .benchmark-data/results/.json --source testbox --artifact --run-url -python tools/render_project_tables.py --table launch ``` The checker is intentionally stricter than a benchmark smoke. It rejects @@ -307,14 +305,10 @@ case means. The `click_button` case measured 89.61 ms for Rustwright versus 662.44 ms for Python Playwright after the fix. The attempted local `strict` 3-iteration comparison failed before producing -timing because the current Python Playwright reference returned a timeout call -log for `check_and_uncheck` where the existing benchmark assertion expected an -`Element is not visible` message. The attempted -`RUSTWRIGHT_UNSAFE_DOM_FASTPATH=1` old-behavior proxy also failed setup on -three local runs at `Browser.new_page: timed out after 5000 ms`, so no honest -local before-fastpath timing sample is recorded here. Treat previous DOM -fast-path benchmark claims as tainted until reproduced with trusted default -input in a capped Testbox/Docker run. +timing. The Python Playwright reference returned a timeout call log for +`check_and_uncheck`, while the benchmark expected `Element is not visible`. +Treat earlier synthetic DOM fast-path claims as tainted. The removed path cannot +serve as a valid baseline. Reproduce trusted input in a capped Testbox run. ## Client Memory: Form-Fill Diagnostic diff --git a/CHANGELOG.md b/CHANGELOG.md index 969a7e1..898c987 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,37 @@ All notable user-facing changes to Rustwright are documented in this file. ## [Unreleased] +### Breaking + +- Removed `disable_playwright_compat()`. Compatibility aliases are now a one-way + process opt-in. +- Removed `RUSTWRIGHT_UNSAFE_DOM_FASTPATH`. Click, fill, check, and select + actions always use the trusted native action path. +- Promoted one MCP behavior profile for every client. Snapshot distillation, + page headers, console deduplication, network notes, compact descriptions, and + 9 KiB/200-line response limits are now the defaults. + +### Changed + +- `Browser.new_page()` now creates a native ephemeral browser context for each + page. Closing the page disposes its context, matching Playwright isolation. +- Navigation internals now retain response bodies and drain console and + page-error history before each navigation. +- Chromium launches now include a broader resource-safe default switch set. +- User-provided feature-list launch arguments now merge with defaults into one + switch per feature flag. + +- Network request filters now accept the standard Rust `regex` syntax. + +- Console and page-error event waiters now block in Rust and apply exact or + substring text filters without Python polling. Console and page-error + listeners use the persistent event pump. Once/remove cleanup detaches worker + console forwarding and page close reaps worker listener threads. + +### Fixed + +- Fixed native drag-and-drop to stop dispatching trailing pointer and mouse release events after a completed drop. + ## [0.3.0] - 2026-08-16 ### Added diff --git a/CODE_ARCHITECTURE.md b/CODE_ARCHITECTURE.md index b2428d5..607b411 100644 --- a/CODE_ARCHITECTURE.md +++ b/CODE_ARCHITECTURE.md @@ -86,11 +86,10 @@ C-ABI bindings. | `go/`, `java/`, `csharp/`, `ruby/`, `php/` | C-ABI language bindings (alpha surface) + per-language conformance runners. | | `bindings/` | Cross-binding contract (`CONTRACT.md`) and shared conformance case data. | | `benchmarks/automation_cases.py` | Shared Playwright-style automation/parity cases and benchmark workflows, including WebVoyager/Mind2Web-style workflow cases. | -| `benchmarks/run_benchmarks.py` | Rustwright and Playwright benchmark runner for a comparable workload. | +| `benchmarks/run_benchmarks.py` | Canonical Rustwright and Playwright runner for benchmark and shared parity suites. | | `tests/test_rustwright_sync_api.py` | Main behavior/regression suite. | | `tests/test_playwright_parity_cases.py` | Shared parity harness test entry point. | | `tools/api_surface_audit.py` | Public API surface comparison against reference Playwright. | -| `tools/run_parity_cases.py` | Runs shared parity cases against Rustwright or reference Playwright. | | `tools/run_antibot_benchmarks.py` | Anti-bot benchmark runner covering local smoke signals, public fingerprint adapters, and fresh/warm profile matrix checks across Rustwright and Playwright. | ## Known Architecture Debt diff --git a/Cargo.lock b/Cargo.lock index 1073c99..3824265 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,15 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + [[package]] name = "atomic-waker" version = "1.1.2" @@ -860,6 +869,35 @@ dependencies = [ "bitflags 1.3.2", ] +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + [[package]] name = "reqwest" version = "0.12.28" @@ -986,6 +1024,7 @@ name = "rustwright-agent" version = "0.1.0" dependencies = [ "base64", + "regex", "rustwright", "serde", "serde_json", diff --git a/Dockerfile b/Dockerfile index 606c4f4..2a8a98a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -54,9 +54,9 @@ RUN --mount=type=cache,target=/root/.cache/pip \ COPY src ./src COPY python ./python -# The root Cargo.toml declares `node`, `capi`, and `rust-native` as workspace -# members, so cargo must be able to load each member manifest (and its lib -# sources) to resolve the workspace before maturin builds the Python +# The root Cargo.toml declares `node`, `capi`, `rust-native`, and `agent` as +# workspace members, so cargo must be able to load each member manifest (and its +# lib sources) to resolve the workspace before maturin builds the Python # extension — even though this image never builds those crates. Copy just the # crate manifests and sources, not node_modules or build output. COPY node/Cargo.toml node/build.rs ./node/ @@ -65,6 +65,8 @@ COPY capi/Cargo.toml ./capi/ COPY capi/src ./capi/src COPY rust-native/Cargo.toml ./rust-native/ COPY rust-native/src ./rust-native/src +COPY agent/Cargo.toml ./agent/ +COPY agent/src ./agent/src RUN --mount=type=cache,target=/root/.cache/pip \ --mount=type=cache,target=/usr/local/cargo/registry \ diff --git a/MEMORY_BENCH.md b/MEMORY_BENCH.md index f62c809..ac0f285 100644 --- a/MEMORY_BENCH.md +++ b/MEMORY_BENCH.md @@ -13,14 +13,16 @@ Each passed item in `results` contains a `memory` block with: including the driver and Chromium process tree. - sampling provenance, interval, availability, and sample count. -The sampler uses the same method as `benchmarks/async_concurrency_load.py`: it -walks parent/child PIDs with `ps`, reads RSS in KiB, samples every 50 ms, and -retains the independent peak for the root and whole tree. It takes one baseline -before entering the implementation run, then samples on a daemon background -thread; case timers continue to wrap only the operation under test, so `ps` -calls are not executed on the latency hot path. If `ps`, the tree walk, or an -individual RSS read is unavailable, the corresponding value is `null` and the -benchmark continues. +On Linux, the sampler reads parent PIDs and `VmRSS` directly from +`/proc//status`. Other platforms fall back to `ps`. It samples every 50 ms +and retains independent peaks for the root process and the full process tree. It +takes one baseline before the implementation run, then samples on a daemon +background thread. Case timers still wrap only the operation under test. + +If neither backend yields complete positive `rss_self_kb` and `rss_tree_kb` +values in KiB, both fields are unavailable for canonical evidence. `bench-full` +treats that run as failed, so the matrix cannot publish a latency-only or +partial-tree comparison. Whole-tree RSS is normally Chromium-dominated. Report `rss_tree_kb` for the actual end-to-end process cost and `rss_self_kb` alongside it as the available diff --git a/README.md b/README.md index 84d6e2f..b2776c8 100644 --- a/README.md +++ b/README.md @@ -113,7 +113,7 @@ server, use the standalone [`rustwright-mcp`](mcp/) package. - **No Node driver subprocess.** `playwright-python` launches and pipes to a bundled Node driver. Rustwright's engine is native — the browser-control code runs in-process. - **Raw CDP, in Rust.** A from-scratch async CDP client — not a wrapper around another automation library. - **No Playwright automation fingerprint.** The driver never loads, so its signatures never appear. See [Automation detection](#automation-detection). -- **Trusted input by default.** Clicks and typing go through real CDP input events (`Input.dispatchMouseEvent`), not synthetic `element.click()` DOM calls. Untrusted DOM shortcuts are opt-in only. +- **Trusted input.** Clicks and typing always use real CDP input events (`Input.dispatchMouseEvent`), not synthetic `element.click()` DOM calls. - **Cross-origin iframes (OOPIF).** Auto-attaches out-of-process iframe targets with flattened CDP sessions and routes `frame_locator()` across origins. - **One engine, two languages.** The same Rust core backs the Python and Node bindings. diff --git a/agent/Cargo.toml b/agent/Cargo.toml index 4495047..101693b 100644 --- a/agent/Cargo.toml +++ b/agent/Cargo.toml @@ -9,6 +9,7 @@ test-support = [] [dependencies] base64 = "=0.22.1" +regex = "=1.12.3" rustwright = { path = "../rust-native" } serde = { version = "1.0.194", features = ["derive"] } serde_json = "1.0.127" diff --git a/agent/src/lib.rs b/agent/src/lib.rs index dafe36d..0b64ea1 100644 --- a/agent/src/lib.rs +++ b/agent/src/lib.rs @@ -6111,298 +6111,17 @@ fn bounded_network_detail_text(text: &str, max_bytes: usize, label: &str, inline rendered } -#[derive(Clone)] -enum NetworkRegexAtom { - Literal(char), - Any, - Digit, - Word, - Space, - Class { - negated: bool, - ranges: Vec<(char, char)>, - }, -} - -#[derive(Clone, Copy)] -enum NetworkRegexQuantifier { - One, - ZeroOrOne, - ZeroOrMore, - OneOrMore, -} - -#[derive(Clone)] -struct NetworkRegexToken { - atom: NetworkRegexAtom, - quantifier: NetworkRegexQuantifier, -} - -struct NetworkRegexBranch { - tokens: Vec, - start_anchor: bool, - end_anchor: bool, -} - -struct NetworkRegex { - branches: Vec, - case_insensitive: bool, -} +struct NetworkRegex(regex::Regex); impl NetworkRegex { fn compile(pattern: &str) -> Result { - let (pattern, case_insensitive) = pattern - .strip_prefix("(?i)") - .map_or((pattern, false), |pattern| (pattern, true)); - let pattern = if case_insensitive { - pattern.to_ascii_lowercase() - } else { - pattern.to_owned() - }; - let branches = split_network_regex_alternatives(&pattern)? - .into_iter() - .map(|branch| parse_network_regex_branch(&branch)) - .collect::, _>>()?; - Ok(Self { - branches, - case_insensitive, - }) + regex::Regex::new(pattern) + .map(Self) + .map_err(|error| error.to_string()) } fn is_match(&self, text: &str) -> bool { - let text = if self.case_insensitive { - text.to_ascii_lowercase() - } else { - text.to_owned() - }; - self.branches - .iter() - .any(|branch| network_regex_branch_matches(branch, &text)) - } -} - -fn split_network_regex_alternatives(pattern: &str) -> Result, String> { - let mut alternatives = Vec::new(); - let mut current = String::new(); - let mut escaped = false; - let mut in_class = false; - for character in pattern.chars() { - if escaped { - current.push(character); - escaped = false; - continue; - } - match character { - '\\' => { - current.push(character); - escaped = true; - } - '[' => { - in_class = true; - current.push(character); - } - ']' if in_class => { - in_class = false; - current.push(character); - } - '|' if !in_class => alternatives.push(std::mem::take(&mut current)), - '(' | ')' if !in_class => { - return Err("groups and lookarounds are not supported".to_owned()); - } - _ => current.push(character), - } - } - if escaped { - return Err("trailing escape".to_owned()); - } - if in_class { - return Err("unterminated character class".to_owned()); - } - alternatives.push(current); - Ok(alternatives) -} - -fn parse_network_regex_branch(pattern: &str) -> Result { - let mut chars = pattern.chars().peekable(); - let start_anchor = chars.next_if_eq(&'^').is_some(); - let mut tokens = Vec::new(); - let mut end_anchor = false; - while let Some(character) = chars.next() { - if character == '$' && chars.peek().is_none() { - end_anchor = true; - break; - } - let atom = match character { - '.' => NetworkRegexAtom::Any, - '\\' => match chars.next().ok_or_else(|| "trailing escape".to_owned())? { - 'd' => NetworkRegexAtom::Digit, - 'w' => NetworkRegexAtom::Word, - 's' => NetworkRegexAtom::Space, - unsupported if unsupported.is_ascii_alphanumeric() => { - return Err(format!("escape \\{unsupported} is not supported")); - } - escaped => NetworkRegexAtom::Literal(escaped), - }, - '[' => parse_network_regex_class(&mut chars)?, - '*' | '+' | '?' => { - return Err(format!("quantifier {character:?} has no preceding atom")); - } - '{' | '}' => { - return Err("counted quantifiers are not supported".to_owned()); - } - '^' | '$' => { - return Err("anchors are only supported at branch boundaries".to_owned()); - } - literal => NetworkRegexAtom::Literal(literal), - }; - let quantifier = match chars.peek().copied() { - Some('*') => { - chars.next(); - NetworkRegexQuantifier::ZeroOrMore - } - Some('+') => { - chars.next(); - NetworkRegexQuantifier::OneOrMore - } - Some('?') => { - chars.next(); - NetworkRegexQuantifier::ZeroOrOne - } - _ => NetworkRegexQuantifier::One, - }; - tokens.push(NetworkRegexToken { atom, quantifier }); - } - Ok(NetworkRegexBranch { - tokens, - start_anchor, - end_anchor, - }) -} - -fn parse_network_regex_class( - chars: &mut std::iter::Peekable>, -) -> Result { - let negated = chars.next_if_eq(&'^').is_some(); - let mut values = Vec::new(); - let mut closed = false; - while let Some(character) = chars.next() { - if character == ']' && !values.is_empty() { - closed = true; - break; - } - let character = if character == '\\' { - let escaped = chars - .next() - .ok_or_else(|| "trailing escape in character class".to_owned())?; - if escaped.is_ascii_alphanumeric() { - return Err(format!( - "escape \\{escaped} in a character class is not supported" - )); - } - escaped - } else { - character - }; - values.push(character); - } - if !closed { - return Err("unterminated or empty character class".to_owned()); - } - let mut ranges = Vec::new(); - let mut index = 0; - while index < values.len() { - if index + 2 < values.len() && values[index + 1] == '-' { - if values[index] > values[index + 2] { - return Err("character class range is reversed".to_owned()); - } - ranges.push((values[index], values[index + 2])); - index += 3; - } else { - ranges.push((values[index], values[index])); - index += 1; - } - } - Ok(NetworkRegexAtom::Class { negated, ranges }) -} - -fn network_regex_branch_matches(branch: &NetworkRegexBranch, text: &str) -> bool { - let chars = text.chars().collect::>(); - let starts: Box> = if branch.start_anchor { - Box::new(std::iter::once(0)) - } else { - Box::new(0..=chars.len()) - }; - starts.into_iter().any(|start| { - match_network_regex_tokens(&branch.tokens, 0, &chars, start, branch.end_anchor) - }) -} - -fn match_network_regex_tokens( - tokens: &[NetworkRegexToken], - token_index: usize, - text: &[char], - text_index: usize, - end_anchor: bool, -) -> bool { - if token_index == tokens.len() { - return !end_anchor || text_index == text.len(); - } - let token = &tokens[token_index]; - let matches_one = |index: usize| { - text.get(index) - .is_some_and(|character| network_regex_atom_matches(&token.atom, *character)) - }; - match token.quantifier { - NetworkRegexQuantifier::One => { - matches_one(text_index) - && match_network_regex_tokens( - tokens, - token_index + 1, - text, - text_index + 1, - end_anchor, - ) - } - NetworkRegexQuantifier::ZeroOrOne => { - match_network_regex_tokens(tokens, token_index + 1, text, text_index, end_anchor) - || (matches_one(text_index) - && match_network_regex_tokens( - tokens, - token_index + 1, - text, - text_index + 1, - end_anchor, - )) - } - NetworkRegexQuantifier::ZeroOrMore | NetworkRegexQuantifier::OneOrMore => { - let minimum = matches!(token.quantifier, NetworkRegexQuantifier::OneOrMore) as usize; - let mut end = text_index; - while matches_one(end) { - end += 1; - } - if end.saturating_sub(text_index) < minimum { - return false; - } - (text_index + minimum..=end).rev().any(|next| { - match_network_regex_tokens(tokens, token_index + 1, text, next, end_anchor) - }) - } - } -} - -fn network_regex_atom_matches(atom: &NetworkRegexAtom, character: char) -> bool { - match atom { - NetworkRegexAtom::Literal(literal) => character == *literal, - NetworkRegexAtom::Any => character != '\n', - NetworkRegexAtom::Digit => character.is_ascii_digit(), - NetworkRegexAtom::Word => character.is_ascii_alphanumeric() || character == '_', - NetworkRegexAtom::Space => character.is_whitespace(), - NetworkRegexAtom::Class { negated, ranges } => { - let contained = ranges - .iter() - .any(|(start, end)| (*start..=*end).contains(&character)); - contained != *negated - } + self.0.is_match(text) } } @@ -9022,15 +8741,18 @@ mod tests { } #[test] - fn network_filter_compiles_once_and_matches_supported_regex_surface() { - let regex = NetworkRegex::compile("(?i)api/data$|large-text$").unwrap(); + fn network_filter_uses_standard_regex_syntax() { + let regex = NetworkRegex::compile(r"(?i)(?:api/data|large-text)$").unwrap(); assert!(regex.is_match("https://example.test/API/DATA")); assert!(regex.is_match("https://example.test/large-text")); assert!(!regex.is_match("https://example.test/image.png")); + assert!( + NetworkRegex::compile(r"\bapi\b") + .unwrap() + .is_match("/api/data") + ); + assert!(NetworkRegex::compile("api{2}").unwrap().is_match("/apii")); assert!(NetworkRegex::compile("[").is_err()); - assert!(NetworkRegex::compile("(group)").is_err()); - assert!(NetworkRegex::compile(r"\bapi\b").is_err()); - assert!(NetworkRegex::compile("api{2}").is_err()); } #[test] diff --git a/benchmarks/automation_cases.py b/benchmarks/automation_cases.py index 2d0b5f1..8056c44 100644 --- a/benchmarks/automation_cases.py +++ b/benchmarks/automation_cases.py @@ -11062,7 +11062,11 @@ def browser_type_launch_ignore_default_args_filters_selected_defaults(page): browser.close() assert "--mute-audio" not in launch_args - assert "--disable-features=RustwrightIgnoreDefaultArgsProbe" in launch_args + assert any( + arg.startswith("--disable-features=") + and "RustwrightIgnoreDefaultArgsProbe" in arg + for arg in launch_args + ) @case diff --git a/benchmarks/process_tree_memory.py b/benchmarks/process_tree_memory.py index 1823cdc..2866d04 100644 --- a/benchmarks/process_tree_memory.py +++ b/benchmarks/process_tree_memory.py @@ -5,11 +5,13 @@ import subprocess import threading from dataclasses import dataclass +from pathlib import Path from typing import Any, Callable, TypeVar, cast SAMPLE_INTERVAL_SECONDS = 0.05 PS_TIMEOUT_SECONDS = 2.0 +PROC_ROOT = Path("/proc") @dataclass(frozen=True) @@ -80,8 +82,8 @@ def summary(self) -> dict[str, Any]: "sampling_interval_ms": self.sample_interval * 1000, "statistic": "peak", "scope": "benchmark_process_and_descendants", - "sampling_mode": "background_thread_ps", - "available": rss_self_kb is not None or rss_tree_kb is not None, + "sampling_mode": "background_thread_procfs_or_ps", + "available": rss_self_kb is not None and rss_tree_kb is not None, } @@ -113,20 +115,95 @@ def max_optional(values: Any) -> int | None: def sample_process_tree_rss(root_pid: int) -> ProcessTreeRssSample: + procfs_sample = procfs_process_tree_rss(root_pid) + if procfs_sample is not None: + return procfs_sample tree = ps_process_tree(root_pid) + rss_self_kb = ps_rss_kb(root_pid) return ProcessTreeRssSample( - rss_self_kb=ps_rss_kb(root_pid), - rss_tree_kb=sum_optional(ps_rss_kb(pid) for pid in tree), + rss_self_kb=rss_self_kb, + rss_tree_kb=( + None + if tree is None + else sum_required(ps_rss_kb(pid) for pid in tree) + ), ) -def sum_optional(values: Any) -> int | None: +def procfs_process_tree_rss( + root_pid: int, + proc_root: Path | None = None, +) -> ProcessTreeRssSample | None: + process_table, complete = procfs_process_table( + PROC_ROOT if proc_root is None else proc_root + ) + if not complete or root_pid not in process_table: + return None + children: dict[int, list[int]] = {} + for pid, (parent_pid, _) in process_table.items(): + children.setdefault(parent_pid, []).append(pid) + tree: list[int] = [] + stack = [root_pid] + while stack: + pid = stack.pop() + if pid in tree: + continue + tree.append(pid) + stack.extend(children.get(pid, [])) + return ProcessTreeRssSample( + rss_self_kb=process_table[root_pid][1], + rss_tree_kb=sum_required(process_table[pid][1] for pid in tree), + ) + + +def procfs_process_table( + proc_root: Path, +) -> tuple[dict[int, tuple[int, int | None]], bool]: + try: + entries = list(proc_root.iterdir()) + except OSError: + return {}, False + numeric_entries = [entry for entry in entries if entry.name.isdigit()] + unreadable: set[str] = set() + result: dict[int, tuple[int, int | None]] = {} + for entry in numeric_entries: + try: + status = (entry / "status").read_text(encoding="utf-8") + except (OSError, UnicodeError): + unreadable.add(entry.name) + continue + parent_pid: int | None = None + rss_kb: int | None = None + for line in status.splitlines(): + if line.startswith("PPid:"): + try: + parent_pid = int(line.split()[1]) + except (IndexError, ValueError): + parent_pid = None + elif line.startswith("VmRSS:"): + try: + rss_kb = int(line.split()[1]) + except (IndexError, ValueError): + rss_kb = None + if parent_pid is None: + unreadable.add(entry.name) + continue + result[int(entry.name)] = (parent_pid, rss_kb) + try: + remaining = {entry.name for entry in proc_root.iterdir() if entry.name.isdigit()} + except OSError: + return result, False + return result, not bool(unreadable & remaining) + + +def sum_required(values: Any) -> int | None: total = 0 seen = False for value in values: - if value is not None: - total += value - seen = True + if value is None: + return None + total += value + seen = True return total if seen else None @@ -155,10 +232,10 @@ def ps_rss_kb(pid: int) -> int | None: return None -def ps_process_tree(root_pid: int) -> list[int]: +def ps_process_tree(root_pid: int) -> list[int] | None: output = run_ps(["ps", "-axo", "pid=,ppid="]) if not output: - return [root_pid] + return None children: dict[int, list[int]] = {} for line in output.splitlines(): parts = line.split() diff --git a/benchmarks/run_benchmarks.py b/benchmarks/run_benchmarks.py index ebd9e1e..6a3da85 100644 --- a/benchmarks/run_benchmarks.py +++ b/benchmarks/run_benchmarks.py @@ -2,6 +2,7 @@ import argparse import importlib +import inspect import json import os import platform @@ -17,6 +18,7 @@ from automation_cases import BENCHMARK_CASES as EQUIVALENT_CASES from automation_cases import BENCHMARK_STRICT_CASES +from automation_cases import CASES as PARITY_CASES from process_tree_memory import attach_peak_process_tree_rss ROOT = Path(__file__).resolve().parents[1] @@ -189,6 +191,8 @@ def select_cases(suite: str) -> list[Callable]: return list(EQUIVALENT_CASES) if suite == "strict": return list(BENCHMARK_STRICT_CASES) + if suite == "parity": + return list(PARITY_CASES) raise ValueError(f"unknown benchmark suite: {suite}") @@ -212,6 +216,8 @@ def filter_cases(cases: list[Callable], requested: list[str] | None) -> list[Cal def comparison_mode(suite: str, implementation: str) -> str: if suite == "strict": return "strict_playwright_api" + if suite == "parity": + return "playwright_api_parity_cases" if implementation in {"rustwright", "playwright", "typescript-playwright"}: return "playwright_api_equivalent_cases" return "lower_level_equivalent_workflows" @@ -235,20 +241,26 @@ def benchmark_metadata(implementation: str, suite: str, lifecycle: str, cases: l } -def run_once(sync_playwright: Callable, cases: list[Callable]) -> dict[str, float]: - timings = {} - with sync_playwright() as p: - browser = launch_chromium(p) - try: - for case in cases: - page = browser.new_page() - started = time.perf_counter() - case(page) - timings[case.__name__] = time.perf_counter() - started - page.close() - finally: - browser.close() - return timings +def is_connected(browser) -> bool: + try: + return bool(browser.is_connected()) + except Exception: + return False + + +def close_quietly(target) -> None: + try: + target.close() + except Exception: + pass + + +def invoke_case(case: Callable, page, playwright) -> None: + if "playwright" in inspect.signature(case).parameters: + case(page, playwright=playwright) + else: + case(page) + def find_chromium_executable() -> str: @@ -370,6 +382,8 @@ def run_playwright_like( samples: list[dict[str, float]] = [] browser_version = None effective_lifecycle = "warm-browser" if lifecycle == "cold-container" else lifecycle + if suite == "parity" and effective_lifecycle != "warm-browser": + raise ValueError("the parity suite supports only the warm-browser lifecycle") with sync_playwright() as p: if effective_lifecycle == "warm-browser": browser = launch_chromium(p) @@ -378,16 +392,32 @@ def run_playwright_like( for _ in range(iterations): timings = {} for case in cases: - page = browser.new_page() + if suite == "parity" and not is_connected(browser): + close_quietly(browser) + browser = launch_chromium(p) + try: + page = browser.new_page() + except Exception: + if suite != "parity" or is_connected(browser): + raise + close_quietly(browser) + browser = launch_chromium(p) + page = browser.new_page() started = time.perf_counter() try: - case(page) + invoke_case(case, page, p) timings[case.__name__] = time.perf_counter() - started finally: - page.close() + if suite == "parity": + close_quietly(page) + else: + page.close() samples.append(timings) finally: - browser.close() + if suite == "parity": + close_quietly(browser) + else: + browser.close() elif effective_lifecycle == "warm-page": browser = launch_chromium(p) browser_version = getattr(browser, "version", None) @@ -398,7 +428,7 @@ def run_playwright_like( try: for case in cases: started = time.perf_counter() - case(page) + invoke_case(case, page, p) timings[case.__name__] = time.perf_counter() - started finally: page.close() @@ -415,7 +445,7 @@ def run_playwright_like( try: page = browser.new_page() try: - case(page) + invoke_case(case, page, p) timings[case.__name__] = time.perf_counter() - started finally: page.close() @@ -1533,7 +1563,7 @@ def typescript_puppeteer_code( def main() -> int: - parser = argparse.ArgumentParser() + parser = argparse.ArgumentParser(allow_abbrev=False) parser.add_argument( "--impl", choices=[ @@ -1551,7 +1581,7 @@ def main() -> int: default=str(ROOT / ".audit-playwright"), help="Path containing a real Playwright installation for --impl playwright or --impl all.", ) - parser.add_argument("--suite", choices=["equivalent", "strict"], default="equivalent") + parser.add_argument("--suite", choices=["equivalent", "strict", "parity"], default="equivalent") parser.add_argument( "--lifecycle", choices=["warm-browser", "warm-page", "cold-browser", "cold-container"], @@ -1568,6 +1598,10 @@ def main() -> int: if args.suite == "strict" and args.impl not in STRICT_IMPLS | {"all"}: raise SystemExit("--suite strict currently supports only --impl rustwright, --impl playwright, or --impl all") + if args.suite == "parity" and args.impl not in STRICT_IMPLS | {"all"}: + raise SystemExit("--suite parity supports only --impl rustwright, --impl playwright, or --impl all") + if args.suite == "parity" and args.lifecycle != "warm-browser": + raise SystemExit("--suite parity supports only --lifecycle warm-browser") if args.impl == "all": results = [ run_playwright_like( diff --git a/cli/Cargo.lock b/cli/Cargo.lock index b951ed0..a02a935 100644 --- a/cli/Cargo.lock +++ b/cli/Cargo.lock @@ -2,6 +2,15 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + [[package]] name = "anstream" version = "1.0.0" @@ -839,6 +848,35 @@ dependencies = [ "rand_core 0.10.1", ] +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + [[package]] name = "reqwest" version = "0.12.28" @@ -965,6 +1003,7 @@ name = "rustwright-agent" version = "0.1.0" dependencies = [ "base64", + "regex", "rustwright", "serde", "serde_json", diff --git a/docs/PARITY.md b/docs/PARITY.md index 081491b..816c9cb 100644 --- a/docs/PARITY.md +++ b/docs/PARITY.md @@ -764,7 +764,6 @@ Missing async exports: `FormData`, `ScreencastFrame`, `ScreencastSize`, `Virtual | `Video` | yes | yes | | `async_playwright` | no | yes | | `backend_marker` | yes | yes | -| `disable_playwright_compat` | no | no | | `enable_playwright_compat` | no | no | | `expect` | yes | yes | | `sync_playwright` | yes | no | @@ -813,9 +812,9 @@ This table is parsed from `node/README.md`; it does not infer capabilities from - The reference surface was introspected from real Playwright Python 1.61.0. When Playwright is unavailable to the invoking interpreter, the generator uses the ignored `.venv-parity` environment and installs the pinned fallback `playwright==1.61.0`. It never installs into `.venv`. - API classes are discovered from `sync_api`, `async_api`, and their generated modules. Public methods and properties declared on those classes are compared with matching Rustwright classes. Inherited event-emitter implementation helpers are excluded. Names are converted to snake_case before exact matching; normalization collisions fail generation. - Method totals count methods, not properties. Properties remain in the detailed tables because they are part of the usable API and because their return annotations make chained receiver types resolvable. -- The exercised state starts only from the 520 functions actually present in the `CASES` registry. The generator verifies that `tests/test_playwright_parity_cases.py` imports that registry, runs `_run_parity` for a `playwright` parameter, and checks `len(CASES)`. +- The exercised state starts only from the 522 functions in the `CASES` registry. The generator verifies that `tests/test_playwright_parity_cases.py` imports the registry and runs the canonical `benchmarks/run_benchmarks.py --suite parity` entry point for both backends. - Exercise detection is conservative static analysis. It propagates `Page` and `Playwright` case parameters through assignments, reference return annotations, property chains, collection indexing, local helper calls, callback annotations, and literal event names. A member becomes green only when its receiver resolves to one reference class. Dynamic `getattr`, aliases returned through untyped helpers, and callbacks with ambiguous receiver types remain yellow even if a case reaches them at runtime. - 4 call site(s) had more than one plausible reference receiver class and were left uncredited rather than guessed. - A green mark means the shared suite invokes or reads the member while running the same registered case against real Playwright and Rustwright. It does not prove all options, errors, events, browser engines, or edge cases match. - The limitations and Node.js sections are parsed from `LIMITATIONS.md` and `node/README.md` on each run. -- Source digest (case registry, parity test, limitations, Node README): `73532a56375f4be0`. +- Source digest (case registry, canonical runner, parity test, limitations, Node README): `4f0f11f4960a4451`. diff --git a/docs/async-design.md b/docs/async-design.md index ad434bf..5b3d806 100644 --- a/docs/async-design.md +++ b/docs/async-design.md @@ -73,12 +73,11 @@ sleeps and do not occupy a Python executor worker or hold the GIL. As on the sync path, per-poll renderer evaluation timeouts propagate; only successful pending actionability results are polled again. -The native action gate currently applies to page-level click/fill with their -default option set. Option combinations such as positioned, forced, delayed, -multi-click, or trial clicks and forced fills still use the off-loop sync path; -locator, frame, and element-handle actions are follow-up scope. Setting -`RUSTWRIGHT_UNSAFE_DOM_FASTPATH` retains the explicitly opted-in synthetic DOM -click/fill behavior rather than selecting trusted actionability. +The native action gate applies to page-level click and fill with their default +option set. Positioned, forced, delayed, multi-click, trial, and forced-fill +calls use the off-loop sync path. Every path uses trusted native action +semantics. Locator, frame, and element-handle native async actions remain +follow-up scope. Re-measurement on the same benchmark (macOS arm64, 10 cores, Python 3.13.5), `benchmarks/async_concurrency_load.py --concurrency 100`, all scenarios diff --git a/mcp/Cargo.lock b/mcp/Cargo.lock index 13affdc..ad7ff07 100644 --- a/mcp/Cargo.lock +++ b/mcp/Cargo.lock @@ -2,6 +2,15 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + [[package]] name = "android_system_properties" version = "0.1.5" @@ -875,6 +884,35 @@ dependencies = [ "syn", ] +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + [[package]] name = "reqwest" version = "0.12.28" @@ -1021,6 +1059,7 @@ name = "rustwright-agent" version = "0.1.0" dependencies = [ "base64", + "regex", "rustwright", "serde", "serde_json", diff --git a/mcp/README.md b/mcp/README.md index cb61007..0947ac4 100644 --- a/mcp/README.md +++ b/mcp/README.md @@ -115,42 +115,18 @@ profile. The default `mirror` profile exposes all 27 native tools. | `RUSTWRIGHT_MCP_ALLOW_EVAL` | Enable or disable `browser_evaluate`; defaults to enabled. | | `RUSTWRIGHT_MCP_WORKSPACE` | Absolute directory that confines file paths supplied to `browser_drop`. | | `RUSTWRIGHT_MCP_SCREENSHOT_MAX_BYTES` | Largest screenshot returned inline. Oversized captures are written to a private (0600) temp file and the path is returned instead. | -| `RUSTWRIGHT_MCP_BUDGET` | `on` enables client-aware text response shaping; defaults to `off`. Codex product ids (`codex`, `codex-mcp-client`, and `codex-*`) use exact 9 KiB/200-line JSON-RPC limits. Unknown clients remain unbounded unless an explicit nonzero limit is set. | -| `RUSTWRIGHT_MCP_MAX_RESPONSE_BYTES` | Overrides the profile byte limit. `0` disables this dimension; nonzero values below 4096 or invalid values warn and use the profile default. | -| `RUSTWRIGHT_MCP_MAX_RESPONSE_LINES` | Overrides the decoded text line limit. `0` disables this dimension; nonzero values below 16 or invalid values warn and use the profile default. | -| `RUSTWRIGHT_MCP_CONSOLE_DEDUP` | `on` collapses adjacent inline console duplicates by severity and normalized text. File exports remain verbatim. Defaults to `off`. | -| `RUSTWRIGHT_MCP_NET_NOTE` | `on` reports successful static requests hidden after regex filtering. `static:true` still returns them. Defaults to `off`. | -| `RUSTWRIGHT_MCP_DISTILL` | `on` enables bounded full-tree construction, full-subset refs/find, and render-only snapshot distillation. `off` selects the retained legacy traversal before page-side ref mutation. Defaults to `off`. | -| `RUSTWRIGHT_MCP_HEADER` | `on` prepends a change-triggered `### Page` digest with the active URL, title when available, navigation status, and console error/warning counts. Unchanged page state is not repeated. Defaults to `off`. | -| `RUSTWRIGHT_MCP_LEAN_DESCRIPTIONS` | `on` serves shorter descriptions with narrowing guidance; `off` serves the byte-compatible legacy catalog. When unset or invalid, recognized Codex clients default to `on` and other clients to `off`; invalid values warn. | - -Budgeting is applied to text success results, browser errors, and validation or -unknown-tool JSON-RPC errors. Image blocks and explicit file-output contracts are -unchanged. Explicit response dimensions take precedence over the matched client -profile; `RUSTWRIGHT_MCP_BUDGET=off` bypasses all response shaping. - -The Codex treatment deployment is an explicit all-on configuration; peer -identification alone defaults only lean descriptions to `on`: - -```text -RUSTWRIGHT_MCP_BUDGET=on -RUSTWRIGHT_MCP_DISTILL=on -RUSTWRIGHT_MCP_HEADER=on -RUSTWRIGHT_MCP_CONSOLE_DEDUP=on -RUSTWRIGHT_MCP_NET_NOTE=on -RUSTWRIGHT_MCP_LEAN_DESCRIPTIONS=on -RUSTWRIGHT_MCP_TOOLSET=mirror -``` - -Treated console presentation attributes messages to the first page frame; -legacy presentation preserves Chromium's raw top-frame attribution. - -The hermetic configuration regression fixture records the initialize frame and -capture metadata observed from Codex CLI 0.146.0. CI parses that static fixture; -it does not execute Codex or require network access, authentication, or a -browser. Live recapture is a manual release compatibility check when updating -supported client metadata. The fixture verifies profile selection only and does -not establish or quantify token savings. +| `RUSTWRIGHT_MCP_MAX_RESPONSE_BYTES` | Overrides the default 9 KiB text-response limit. `0` disables this dimension. Values below 4096 or invalid values warn and use the default. | +| `RUSTWRIGHT_MCP_MAX_RESPONSE_LINES` | Overrides the default 200-line text-response limit. `0` disables this dimension. Values below 16 or invalid values warn and use the default. | + +Rustwright uses one MCP behavior profile for every client. It distills snapshots, +adds change-triggered page headers, deduplicates adjacent inline console records, +reports filtered static network requests, and serves the compact tool catalog. +Text success results, browser errors, and JSON-RPC errors use the default +9 KiB and 200-line limits. Image blocks and explicit file-output contracts are +not shaped. The two response-limit variables above can change or disable each +dimension. + +Console presentation attributes messages to the first page frame. Snapshot traversal executes in the page's main JavaScript world. This preserves the existing trust boundary: page scripts can observe or patch the traversal. diff --git a/mcp/npm/main/README.md b/mcp/npm/main/README.md index 2197e9f..1d5967e 100644 --- a/mcp/npm/main/README.md +++ b/mcp/npm/main/README.md @@ -54,17 +54,12 @@ The launcher forwards its environment unchanged to the native server. Supported - `RUSTWRIGHT_MCP_ALLOW_EVAL`: enable or disable `browser_evaluate`; defaults to enabled. - `RUSTWRIGHT_MCP_WORKSPACE`: absolute directory that confines paths supplied to `browser_drop`. - `RUSTWRIGHT_MCP_SCREENSHOT_MAX_BYTES`: largest screenshot returned inline; oversized captures use a private temporary file. -- `RUSTWRIGHT_MCP_BUDGET`: set to `on` for client-aware text response budgeting. Recognized Codex clients use exact 9 KiB and 200-line limits; unknown clients are unchanged unless an explicit limit is supplied. -- `RUSTWRIGHT_MCP_MAX_RESPONSE_BYTES` / `RUSTWRIGHT_MCP_MAX_RESPONSE_LINES`: override individual response dimensions; `0` disables that dimension. Minimum nonzero values are 4096 bytes and 16 lines. -- `RUSTWRIGHT_MCP_CONSOLE_DEDUP`: set to `on` to collapse adjacent duplicates in inline console output; file exports remain verbatim. -- `RUSTWRIGHT_MCP_NET_NOTE`: set to `on` to report successful static requests hidden after regex filtering. -- `RUSTWRIGHT_MCP_DISTILL`: set to `on` for bounded full-tree snapshot construction, full-subset refs/find, and render-only distillation. `off` retains legacy traversal and ref assignment. -- `RUSTWRIGHT_MCP_HEADER`: set to `on` to prepend a change-triggered `### Page` digest with active URL, title when available, navigation status, and console error/warning counts. Unchanged page state is not repeated. -- `RUSTWRIGHT_MCP_LEAN_DESCRIPTIONS`: set to `on` for shorter descriptions with narrowing guidance or `off` for the byte-compatible legacy catalog. When unset or invalid, recognized Codex clients default to `on` and other clients to `off`; invalid values warn. - -Other optimization switches default to `off`. `RUSTWRIGHT_MCP_BUDGET=off` -bypasses shaping; otherwise explicit response dimensions take precedence over -the client profile. Images are never shaped. +- `RUSTWRIGHT_MCP_MAX_RESPONSE_BYTES` / `RUSTWRIGHT_MCP_MAX_RESPONSE_LINES`: override the default 9 KiB and 200-line response limits. `0` disables that dimension. Minimum nonzero values are 4096 bytes and 16 lines. + +The server uses one behavior profile for every client. It distills snapshots, +adds page headers, deduplicates adjacent inline console records, reports filtered +static network requests, and serves compact tool descriptions. Images are never +shaped. For example, a Desktop entry can include an `env` object: diff --git a/mcp/src/actor.rs b/mcp/src/actor.rs index e6f86c6..8677794 100644 --- a/mcp/src/actor.rs +++ b/mcp/src/actor.rs @@ -6,8 +6,6 @@ use rustwright_agent::{ ActorConfig, BrowserActor as NativeBrowserActor, BrowserStartup, RequestId as ActorRequestId, }; -use crate::config::FeatureConfig; - pub(crate) use rustwright_agent::{ BrowserError, BrowserOp, BrowserOutput, BrowserResult, FillField, FillFieldKind, RegexSpec, ScreenshotType, TabAction, @@ -28,13 +26,13 @@ pub(crate) struct BrowserActor { } impl BrowserActor { - pub(crate) fn spawn_with_features(features: FeatureConfig) -> Self { + pub(crate) fn spawn() -> Self { let startup = browser_startup_from_env(); let config = ActorConfig { - distill: features.distill, - header: features.header, - console_dedup: features.console_dedup, - net_note: features.net_note, + distill: true, + header: true, + console_dedup: true, + net_note: true, default_timeout: tool_timeout_from_env(), workspace: env::var_os("RUSTWRIGHT_MCP_WORKSPACE").map(Into::into), }; diff --git a/mcp/src/config.rs b/mcp/src/config.rs index 9328b1f..5da6b49 100644 --- a/mcp/src/config.rs +++ b/mcp/src/config.rs @@ -1,13 +1,10 @@ use std::env; +const DEFAULT_MAX_RESPONSE_BYTES: usize = 9 * 1024; +const DEFAULT_MAX_RESPONSE_LINES: usize = 200; + #[derive(Clone, Debug, Default, PartialEq, Eq)] pub(crate) struct FeatureConfig { - pub(crate) budget: bool, - pub(crate) distill: bool, - pub(crate) header: bool, - pub(crate) console_dedup: bool, - pub(crate) net_note: bool, - lean_descriptions: ProfileFlag, byte_override: Override, line_override: Override, } @@ -20,14 +17,6 @@ enum Override { Invalid, } -#[derive(Clone, Debug, Default, PartialEq, Eq)] -enum ProfileFlag { - #[default] - ProfileDefault, - On, - Off, -} - #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub(crate) struct ResponseBudget { pub(crate) max_bytes: Option, @@ -37,65 +26,15 @@ pub(crate) struct ResponseBudget { impl FeatureConfig { pub(crate) fn from_env() -> Self { Self { - budget: flag("RUSTWRIGHT_MCP_BUDGET"), - distill: flag("RUSTWRIGHT_MCP_DISTILL"), - header: flag("RUSTWRIGHT_MCP_HEADER"), - console_dedup: flag("RUSTWRIGHT_MCP_CONSOLE_DEDUP"), - net_note: flag("RUSTWRIGHT_MCP_NET_NOTE"), - lean_descriptions: profile_flag("RUSTWRIGHT_MCP_LEAN_DESCRIPTIONS"), byte_override: dimension("RUSTWRIGHT_MCP_MAX_RESPONSE_BYTES", 4096), line_override: dimension("RUSTWRIGHT_MCP_MAX_RESPONSE_LINES", 16), } } - pub(crate) fn response_budget(&self, client_name: Option<&str>) -> ResponseBudget { - if !self.budget { - return ResponseBudget::default(); - } - let codex = client_name.is_some_and(is_codex_client); - let profile = codex.then_some(ResponseBudget { - max_bytes: Some(9 * 1024), - max_lines: Some(200), - }); + pub(crate) fn response_budget(&self) -> ResponseBudget { ResponseBudget { - max_bytes: resolve(&self.byte_override, profile.and_then(|p| p.max_bytes)), - max_lines: resolve(&self.line_override, profile.and_then(|p| p.max_lines)), - } - } - - pub(crate) fn lean_descriptions(&self, client_name: Option<&str>) -> bool { - match self.lean_descriptions { - ProfileFlag::On => true, - ProfileFlag::Off => false, - ProfileFlag::ProfileDefault => client_name.is_some_and(is_codex_client), - } - } -} - -fn flag(name: &str) -> bool { - match env::var(name) { - Err(env::VarError::NotPresent) => false, - Ok(value) if value.eq_ignore_ascii_case("on") => true, - Ok(value) if value.eq_ignore_ascii_case("off") => false, - Ok(_) | Err(env::VarError::NotUnicode(_)) => { - eprintln!( - "rustwright_mcp_config_warning variable={name} expected=on_or_off fallback=off" - ); - false - } - } -} - -fn profile_flag(name: &str) -> ProfileFlag { - match env::var(name) { - Err(env::VarError::NotPresent) => ProfileFlag::ProfileDefault, - Ok(value) if value.eq_ignore_ascii_case("on") => ProfileFlag::On, - Ok(value) if value.eq_ignore_ascii_case("off") => ProfileFlag::Off, - Ok(_) | Err(env::VarError::NotUnicode(_)) => { - eprintln!( - "rustwright_mcp_config_warning variable={name} expected=on_or_off fallback=profile_default" - ); - ProfileFlag::ProfileDefault + max_bytes: resolve(&self.byte_override, DEFAULT_MAX_RESPONSE_BYTES), + max_lines: resolve(&self.line_override, DEFAULT_MAX_RESPONSE_LINES), } } } @@ -108,67 +47,39 @@ fn dimension(name: &str, minimum: usize) -> Override { Ok(parsed) if parsed >= minimum => Override::Valid(parsed), _ => { eprintln!( - "rustwright_mcp_config_warning variable={name} invalid_or_too_small=true fallback=profile_default" + "rustwright_mcp_config_warning variable={name} invalid_or_too_small=true fallback=default" ); Override::Invalid } }, Err(env::VarError::NotUnicode(_)) => { eprintln!( - "rustwright_mcp_config_warning variable={name} invalid_unicode=true fallback=profile_default" + "rustwright_mcp_config_warning variable={name} invalid_unicode=true fallback=default" ); Override::Invalid } } } -fn resolve(value: &Override, profile: Option) -> Option { +fn resolve(value: &Override, default: usize) -> Option { match value { Override::Valid(0) => None, Override::Valid(value) => Some(*value), - Override::Absent | Override::Invalid => profile, + Override::Absent | Override::Invalid => Some(default), } } -pub(crate) fn is_codex_client(name: &str) -> bool { - let name = name.to_ascii_lowercase(); - name == "codex" || name == "codex-mcp-client" || name.starts_with("codex-") -} - #[cfg(test)] mod tests { use super::*; - use serde_json::{Value, json}; - - const CODEX_INITIALIZE_FIXTURE: &str = - include_str!("../tests/fixtures/codex-mcp-initialize-0.146.0.json"); - - #[test] - fn client_matching_is_exact_except_documented_prefix() { - for name in ["codex", "CODEX-MCP-CLIENT", "CoDeX-cli"] { - assert!(is_codex_client(name), "{name}"); - } - for name in ["codexish", "my-codex", "codex_mcp_client", ""] { - assert!(!is_codex_client(name), "{name}"); - } - } #[test] - fn missing_unknown_and_supported_profiles_resolve_as_documented() { - let config = FeatureConfig { - budget: true, - ..FeatureConfig::default() - }; - assert_eq!(config.response_budget(None), ResponseBudget::default()); - assert_eq!( - config.response_budget(Some("other")), - ResponseBudget::default() - ); + fn default_profile_applies_to_every_client() { assert_eq!( - config.response_budget(Some("codex")), + FeatureConfig::default().response_budget(), ResponseBudget { - max_bytes: Some(9216), - max_lines: Some(200) + max_bytes: Some(9 * 1024), + max_lines: Some(200), } ); } @@ -176,123 +87,30 @@ mod tests { #[test] fn overrides_are_independent_and_zero_disables_a_dimension() { let config = FeatureConfig { - budget: true, byte_override: Override::Valid(4096), line_override: Override::Valid(0), - ..FeatureConfig::default() }; assert_eq!( - config.response_budget(Some("unknown")), + config.response_budget(), ResponseBudget { max_bytes: Some(4096), - max_lines: None + max_lines: None, } ); } #[test] - fn invalid_dimensions_fall_back_to_profile_defaults() { + fn invalid_dimensions_fall_back_to_defaults() { let config = FeatureConfig { - budget: true, byte_override: Override::Invalid, line_override: Override::Invalid, - ..FeatureConfig::default() - }; - assert_eq!( - config.response_budget(Some("codex-mcp-client")), - ResponseBudget { - max_bytes: Some(9216), - max_lines: Some(200) - } - ); - assert_eq!( - config.response_budget(Some("unknown")), - ResponseBudget::default() - ); - } - - #[test] - fn captured_codex_initialize_client_selects_budget_profile() { - let fixture: Value = - serde_json::from_str(CODEX_INITIALIZE_FIXTURE).expect("fixture must be valid JSON"); - assert_eq!(fixture["schema_version"], 1); - assert_eq!(fixture["captured_at_utc"], "2026-08-06"); - assert_eq!(fixture["codex_cli_version"], "0.146.0"); - assert_eq!( - fixture["codex_js_launcher_sha256"], - "134063e133f0b4244fa3b251acf973d4fe4b4aeeacbdc135211bf480f59f1477" - ); - assert_eq!( - fixture["codex_native_binary_sha256"], - "ae1d3ffe6d48aec6a4dc3f50e7eb8e0d11962485a6a9406c5a7012139383da02" - ); - assert_eq!( - fixture["initialize_frame"], - json!({ - "id": 0, - "jsonrpc": "2.0", - "method": "initialize", - "params": { - "capabilities": { - "elicitation": { - "form": {}, - "url": {} - } - }, - "clientInfo": { - "name": "codex-mcp-client", - "title": "Codex", - "version": "0.146.0" - }, - "protocolVersion": "2025-06-18" - } - }) - ); - - let config = FeatureConfig { - budget: true, - ..FeatureConfig::default() }; - let client_name = fixture["initialize_frame"]["params"]["clientInfo"]["name"] - .as_str() - .expect("fixture client name must be a string"); assert_eq!( - config.response_budget(Some(client_name)), + config.response_budget(), ResponseBudget { max_bytes: Some(9 * 1024), - max_lines: Some(200) + max_lines: Some(200), } ); - - let mut near_match = fixture.clone(); - near_match["initialize_frame"]["params"]["clientInfo"]["name"] = - Value::String("codex_mcp_client".to_owned()); - let near_match_name = near_match["initialize_frame"]["params"]["clientInfo"]["name"] - .as_str() - .expect("mutated client name must be a string"); - assert_eq!( - config.response_budget(Some(near_match_name)), - ResponseBudget::default() - ); - } - - #[test] - fn lean_description_override_precedes_client_profile() { - let profile_default = FeatureConfig::default(); - assert!(profile_default.lean_descriptions(Some("codex-mcp-client"))); - assert!(!profile_default.lean_descriptions(Some("other"))); - assert!(!profile_default.lean_descriptions(None)); - - let on = FeatureConfig { - lean_descriptions: ProfileFlag::On, - ..FeatureConfig::default() - }; - assert!(on.lean_descriptions(Some("other"))); - - let off = FeatureConfig { - lean_descriptions: ProfileFlag::Off, - ..FeatureConfig::default() - }; - assert!(!off.lean_descriptions(Some("codex"))); } } diff --git a/mcp/src/server.rs b/mcp/src/server.rs index 5fec3e9..e095b43 100644 --- a/mcp/src/server.rs +++ b/mcp/src/server.rs @@ -23,10 +23,7 @@ use crate::{ actor::{BrowserActor, BrowserError, BrowserOutput}, config::{FeatureConfig, ResponseBudget}, shaping::{ResponseShape, shape_error, shape_tool_text, shape_tool_text_with_shape}, - tools::{ - descriptor_with_profile, enabled_tool_specs, find_tool, parse_op, - validate_tool_configuration, - }, + tools::{descriptor, enabled_tool_specs, find_tool, parse_op, validate_tool_configuration}, }; const DEFAULT_SCREENSHOT_MAX_BYTES: usize = 5 * 1024 * 1024; @@ -48,7 +45,7 @@ impl BrowserServer { let screenshot_temp_dir = ScreenshotTempDir::new()?; let features = FeatureConfig::from_env(); Ok(Self { - actor: Arc::new(BrowserActor::spawn_with_features(features.clone())), + actor: Arc::new(BrowserActor::spawn()), screenshot_max_bytes: screenshot_max_bytes_from_env(), screenshot_temp_dir, features, @@ -237,24 +234,15 @@ impl ServerHandler for BrowserServer { fn list_tools( &self, _request: Option, - context: RequestContext, + _context: RequestContext, ) -> impl Future> + Send + '_ { - let client_name = context - .peer - .peer_info() - .map(|peer| peer.client_info.name.clone()); - let lean_descriptions = self.features.lean_descriptions(client_name.as_deref()); std::future::ready(Ok(ListToolsResult::with_all_items( - enabled_tool_specs() - .into_iter() - .map(|spec| descriptor_with_profile(spec, lean_descriptions)) - .collect(), + enabled_tool_specs().into_iter().map(descriptor).collect(), ))) } fn get_tool(&self, name: &str) -> Option { - let lean_descriptions = self.features.lean_descriptions(None); - find_tool(name).map(|spec| descriptor_with_profile(spec, lean_descriptions)) + find_tool(name).map(descriptor) } async fn call_tool( @@ -263,11 +251,7 @@ impl ServerHandler for BrowserServer { context: RequestContext, ) -> Result { let request_id = context.id.clone(); - let client_name = context - .peer - .peer_info() - .map(|peer| peer.client_info.name.clone()); - let budget = self.features.response_budget(client_name.as_deref()); + let budget = self.features.response_budget(); let tool_name = request.name.to_string(); let spec = match find_tool(&request.name) { Some(spec) => spec, @@ -326,7 +310,7 @@ mod tests { use rustwright::{ CommandWritten, FailureKind, FailureMetadata, FailurePhase, FailureTargetKind, }; - use serde_json::{Value, json}; + use serde_json::json; fn classified_error(metadata: FailureMetadata) -> BrowserError { BrowserError::Classified { diff --git a/mcp/src/tools.rs b/mcp/src/tools.rs index 94c3db6..9fe55bd 100644 --- a/mcp/src/tools.rs +++ b/mcp/src/tools.rs @@ -43,171 +43,143 @@ pub(crate) struct ToolSpec { pub(crate) kind: ToolKind, pub(crate) name: &'static str, pub(crate) description: &'static str, - pub(crate) lean_description: &'static str, } pub(crate) const TOOL_SPECS: &[ToolSpec] = &[ ToolSpec { kind: ToolKind::Navigate, name: "browser_navigate", - description: "Navigate the browser and return a compact page snapshot.", - lean_description: "Navigate; snapshot.", + description: "Navigate; snapshot.", }, ToolSpec { kind: ToolKind::NavigateBack, name: "browser_navigate_back", - description: "Navigate back in browser history and return a compact page snapshot.", - lean_description: "Back; snapshot.", + description: "Back; snapshot.", }, ToolSpec { kind: ToolKind::NavigateForward, name: "browser_navigate_forward", - description: "Navigate forward in browser history and return a compact page snapshot.", - lean_description: "Forward; snapshot.", + description: "Forward; snapshot.", }, ToolSpec { kind: ToolKind::Reload, name: "browser_reload", - description: "Reload the active page and return a compact page snapshot.", - lean_description: "Reload; snapshot.", + description: "Reload; snapshot.", }, ToolSpec { kind: ToolKind::Resize, name: "browser_resize", - description: "Resize the active page viewport in CSS pixels and return a fresh snapshot.", - lean_description: "Resize viewport; snapshot.", + description: "Resize viewport; snapshot.", }, ToolSpec { kind: ToolKind::Snapshot, name: "browser_snapshot", - description: "Return a fresh full or targeted compact snapshot of the current page.", - lean_description: "Snapshot page. Narrow with target/depth. Refs are session-only and never reused.", + description: "Snapshot page. Narrow with target/depth. Refs are session-only and never reused.", }, ToolSpec { kind: ToolKind::Find, name: "browser_find", - description: "Search one fresh snapshot and return compact actionable context.", - lean_description: "Find text/regex; snapshot.", + description: "Find text/regex; snapshot.", }, ToolSpec { kind: ToolKind::Click, name: "browser_click", - description: "Click or double-click a ref from the latest snapshot and return a fresh snapshot.", - lean_description: "Click/double-click ref; snapshot.", + description: "Click/double-click ref; snapshot.", }, ToolSpec { kind: ToolKind::Scroll, name: "browser_scroll", - description: "Scroll a snapshot ref into view or move the viewport, then return a fresh snapshot.", - lean_description: "Scroll ref/viewport; snapshot.", + description: "Scroll ref/viewport; snapshot.", }, ToolSpec { kind: ToolKind::Type, name: "browser_type", - description: "Enter text into a snapshot ref, optionally slowly or followed by Enter.", - lean_description: "Type ref; optional submit.", + description: "Type ref; optional submit.", }, ToolSpec { kind: ToolKind::SelectOption, name: "browser_select_option", - description: "Select one or more option values or labels in a snapshot ref.", - lean_description: "Select values/labels on ref.", + description: "Select values/labels on ref.", }, ToolSpec { kind: ToolKind::FillForm, name: "browser_fill_form", - description: "Fill up to 50 fields sequentially as a non-transactional batch.", - lean_description: "Fill 1-50 fields; non-transactional.", + description: "Fill 1-50 fields; non-transactional.", }, ToolSpec { kind: ToolKind::Hover, name: "browser_hover", - description: "Move Chromium's native mouse to a snapshot ref and return a fresh snapshot.", - lean_description: "Native hover ref; snapshot.", + description: "Native hover ref; snapshot.", }, ToolSpec { kind: ToolKind::PressKey, name: "browser_press_key", - description: "Press a native browser key, optionally focusing a snapshot ref first, and return a fresh snapshot.", - lean_description: "Press key; optional ref focus; snapshot.", + description: "Press key; optional ref focus; snapshot.", }, ToolSpec { kind: ToolKind::Drag, name: "browser_drag", - description: "Physically drag one snapshot ref to another through Chromium's native mouse input.", - lean_description: "Native drag between refs.", + description: "Native drag between refs.", }, ToolSpec { kind: ToolKind::Drop, name: "browser_drop", - description: "Synthesize a DataTransfer drop of confined files and/or MIME strings on a snapshot ref.", - lean_description: "Drop confined files/MIME on ref.", + description: "Drop confined files/MIME on ref.", }, ToolSpec { kind: ToolKind::ConsoleMessages, name: "browser_console_messages", - description: "List bounded console records at a severity threshold.", - lean_description: "List bounded console records; filter by level.", + description: "List bounded console records; filter by level.", }, ToolSpec { kind: ToolKind::NetworkRequests, name: "browser_network_requests", - description: "List bounded current-navigation network lifecycle records.", - lean_description: "List/filter bounded requests; use browser_network_request for one.", + description: "List/filter bounded requests; use browser_network_request for one.", }, ToolSpec { kind: ToolKind::NetworkRequest, name: "browser_network_request", - description: "Return request/response details and a bounded lazy response body by stable index.", - lean_description: "Get one request/response or bounded body.", + description: "Get one request/response or bounded body.", }, ToolSpec { kind: ToolKind::Tabs, name: "browser_tabs", - description: "List, open, select, or close browser tabs.", - lean_description: "List/change tabs.", + description: "List/change tabs.", }, ToolSpec { kind: ToolKind::HandleDialog, name: "browser_handle_dialog", - description: "Accept or dismiss the JavaScript dialog that is pending now.", - lean_description: "Handle dialog. Dialogs block further page work until handled.", + description: "Handle dialog. Dialogs block further page work until handled.", }, ToolSpec { kind: ToolKind::FileUpload, name: "browser_file_upload", - description: "Resolve the pending file chooser with confined local files, or cancel it.", - lean_description: "Resolve chooser with confined paths or cancel.", + description: "Resolve chooser with confined paths or cancel.", }, ToolSpec { kind: ToolKind::WaitFor, name: "browser_wait_for", - description: "Wait for elapsed time and/or visible-text state, then return a fresh snapshot.", - lean_description: "Wait for time/text; snapshot.", + description: "Wait for time/text; snapshot.", }, ToolSpec { kind: ToolKind::GetText, name: "browser_get_text", - description: "Return rendered inner text for a unique CSS selector.", - lean_description: "Extract text. Use browser_wait_for to validate.", + description: "Extract text. Use browser_wait_for to validate.", }, ToolSpec { kind: ToolKind::Evaluate, name: "browser_evaluate", - description: "Evaluate a JavaScript function in the page or snapshot-ref context.", - lean_description: "Run page JS on page/ref.", + description: "Run page JS on page/ref.", }, ToolSpec { kind: ToolKind::TakeScreenshot, name: "browser_take_screenshot", - description: "Capture the current page as an inline PNG or JPEG image. Oversized fallback files remain available until this server process shuts down.", - lean_description: "Capture image; fallback lasts until shutdown.", + description: "Capture image; fallback lasts until shutdown.", }, ToolSpec { kind: ToolKind::Close, name: "browser_close", - description: "Close the browser; the next browser tool starts a fresh session.", - lean_description: "Close browser; next tool starts fresh.", + description: "Close browser; next tool starts fresh.", }, ]; @@ -614,14 +586,6 @@ pub(crate) fn descriptor(spec: ToolSpec) -> Tool { Tool::new(spec.name, spec.description, schema(spec.kind)) } -pub(crate) fn descriptor_with_profile(spec: ToolSpec, lean_descriptions: bool) -> Tool { - if lean_descriptions { - Tool::new(spec.name, spec.lean_description, schema(spec.kind)) - } else { - descriptor(spec) - } -} - pub(crate) fn parse_op( spec: ToolSpec, arguments: Option>, @@ -1298,29 +1262,25 @@ mod tests { use rmcp::model::{ListToolsResult, RequestId, ServerJsonRpcMessage, ServerResult}; - const LEGACY_CATALOG_FIXTURE: &str = include_str!("../tests/fixtures/tools-list-legacy.json"); - const LEAN_CATALOG_FIXTURE: &str = include_str!("../tests/fixtures/tools-list-lean.json"); + const CATALOG_FIXTURE: &str = include_str!("../tests/fixtures/tools-list.json"); - fn catalog(lean_descriptions: bool) -> ListToolsResult { - catalog_for_profile(ToolsetProfile::Mirror, lean_descriptions) + fn catalog() -> ListToolsResult { + catalog_for_toolset(ToolsetProfile::Mirror) } - fn catalog_for_profile( - toolset_profile: ToolsetProfile, - lean_descriptions: bool, - ) -> ListToolsResult { + fn catalog_for_toolset(toolset_profile: ToolsetProfile) -> ListToolsResult { ListToolsResult::with_all_items( TOOL_SPECS .iter() .copied() .filter(|spec| tool_in_profile(spec.name, toolset_profile)) - .map(|spec| descriptor_with_profile(spec, lean_descriptions)) + .map(descriptor) .collect(), ) } - fn serialized_catalog(lean_descriptions: bool) -> String { - serde_json::to_string(&catalog(lean_descriptions)).expect("serialize tools/list catalog") + fn serialized_catalog() -> String { + serde_json::to_string(&catalog()).expect("serialize tools/list catalog") } fn tool_description<'a>(catalog: &'a ListToolsResult, name: &str) -> &'a str { @@ -1339,85 +1299,51 @@ mod tests { } #[test] - fn lean_catalog_preserves_names_order_and_schemas() { - let legacy = catalog(false); - let lean = catalog(true); - assert_eq!(legacy.tools.len(), lean.tools.len()); - for (legacy, lean) in legacy.tools.iter().zip(&lean.tools) { - assert_eq!(legacy.name, lean.name); - assert_eq!(legacy.input_schema, lean.input_schema); - } - } - - #[test] - fn lean_descriptions_are_materially_shorter() { - let legacy_bytes: usize = catalog(false) - .tools - .iter() - .filter_map(|tool| tool.description.as_deref()) - .map(str::len) - .sum(); - let lean_bytes: usize = catalog(true) - .tools - .iter() - .filter_map(|tool| tool.description.as_deref()) - .map(str::len) - .sum(); - assert!( - lean_bytes * 100 <= legacy_bytes * 60, - "lean descriptions are {lean_bytes} bytes versus {legacy_bytes} legacy bytes" - ); - } - - #[test] - fn every_description_tool_reference_is_exposed_in_its_toolset_profile() { + fn every_description_tool_reference_is_exposed_in_its_toolset() { for toolset_profile in [ToolsetProfile::Mirror, ToolsetProfile::Lean] { - for lean_descriptions in [false, true] { - let profile = catalog_for_profile(toolset_profile, lean_descriptions); - let exposed: HashSet<&str> = profile - .tools - .iter() - .map(|tool| tool.name.as_ref()) - .collect(); - for tool in &profile.tools { - let description = tool.description.as_deref().expect("tool description"); - for referenced in references_in(description) { - assert!( - exposed.contains(referenced), - "{} references unavailable tool {referenced} in toolset={} descriptions={}", - tool.name, - if toolset_profile == ToolsetProfile::Lean { - "lean" - } else { - "mirror" - }, - if lean_descriptions { "lean" } else { "legacy" } - ); - } + let profile = catalog_for_toolset(toolset_profile); + let exposed: HashSet<&str> = profile + .tools + .iter() + .map(|tool| tool.name.as_ref()) + .collect(); + for tool in &profile.tools { + let description = tool.description.as_deref().expect("tool description"); + for referenced in references_in(description) { + assert!( + exposed.contains(referenced), + "{} references unavailable tool {referenced} in toolset={}", + tool.name, + if toolset_profile == ToolsetProfile::Lean { + "lean" + } else { + "mirror" + }, + ); } } } } #[test] - fn lean_catalog_pins_lifetimes_dialog_blocking_and_all_steering() { - let lean = catalog(true); + fn canonical_catalog_pins_lifetimes_dialog_blocking_and_steering() { + let catalog = catalog(); assert!( - tool_description(&lean, "browser_snapshot") + tool_description(&catalog, "browser_snapshot") .contains("Refs are session-only and never reused.") ); assert!( - tool_description(&lean, "browser_take_screenshot") + tool_description(&catalog, "browser_take_screenshot") .contains("fallback lasts until shutdown.") ); assert!( - tool_description(&lean, "browser_handle_dialog") + tool_description(&catalog, "browser_handle_dialog") .contains("Dialogs block further page work until handled.") ); let steering_descriptions: Vec<(&str, &str)> = TOOL_SPECS .iter() .filter(|spec| { - spec.lean_description + spec.description .split(|character: char| !character.is_ascii_alphabetic()) .any(|word| { ["prefer", "use", "narrow", "filter"] @@ -1425,7 +1351,7 @@ mod tests { .any(|marker| word.eq_ignore_ascii_case(marker)) }) }) - .map(|spec| (spec.name, spec.lean_description)) + .map(|spec| (spec.name, spec.description)) .collect(); assert_eq!( steering_descriptions, @@ -1451,56 +1377,32 @@ mod tests { } #[test] - fn lean_serialized_catalog_fits_codex_nine_kib_budget_at_w1_id_boundary() { - // W1 certifies ordinary request ids through 256 serialized bytes. Unlike - // tool-call text, tools/list has no response body that can fall back to an - // empty string, so the complete catalog must fit at that boundary. - const LEAN_CATALOG_MAX_BYTES: usize = 9 * 1024; + fn serialized_catalog_fits_default_nine_kib_budget_at_id_boundary() { + const CATALOG_MAX_BYTES: usize = 9 * 1024; let id = RequestId::String("i".repeat(254).into()); assert_eq!(serde_json::to_vec(&id).unwrap().len(), 256); - let response = - ServerJsonRpcMessage::response(ServerResult::ListToolsResult(catalog(true)), id); + let response = ServerJsonRpcMessage::response(ServerResult::ListToolsResult(catalog()), id); let mut serialized = serde_json::to_vec(&response).expect("serialize tools/list response"); serialized.push(b'\n'); assert!( - serialized.len() <= LEAN_CATALOG_MAX_BYTES - 64, - "lean tools/list response is {} bytes, leaving less than 64 bytes below {LEAN_CATALOG_MAX_BYTES}", + serialized.len() <= CATALOG_MAX_BYTES - 64, + "tools/list response is {} bytes, leaving less than 64 bytes below {CATALOG_MAX_BYTES}", serialized.len() ); } #[test] - fn legacy_catalog_matches_archived_pre_w5_bytes() { - let actual = serialized_catalog(false); - if std::env::var_os("RUSTWRIGHT_UPDATE_LEGACY_TOOL_CATALOG").is_some() { - // Regenerate only from the legacy descriptor path: - // RUSTWRIGHT_UPDATE_LEGACY_TOOL_CATALOG=1 cargo test \ - // --manifest-path mcp/Cargo.toml legacy_catalog_matches_archived_pre_w5_bytes - let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")) - .join("tests/fixtures/tools-list-legacy.json"); - fs::write(path, format!("{actual}\n")).expect("write legacy catalog fixture"); - return; - } - assert_eq!( - actual.as_bytes(), - LEGACY_CATALOG_FIXTURE.trim_end_matches('\n').as_bytes() - ); - } - - #[test] - fn lean_catalog_matches_archived_w5_bytes() { - let actual = serialized_catalog(true); - if std::env::var_os("RUSTWRIGHT_UPDATE_LEAN_TOOL_CATALOG").is_some() { - // Regenerate only by explicit opt-in from the complete mirror toolset - // with lean descriptions enabled. - let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")) - .join("tests/fixtures/tools-list-lean.json"); - fs::write(path, format!("{actual}\n")).expect("write lean catalog fixture"); + fn catalog_matches_archived_bytes() { + let actual = serialized_catalog(); + if std::env::var_os("RUSTWRIGHT_UPDATE_TOOL_CATALOG").is_some() { + let path = + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/tools-list.json"); + fs::write(path, format!("{actual}\n")).expect("write catalog fixture"); return; } assert_eq!( actual.as_bytes(), - LEAN_CATALOG_FIXTURE.trim_end_matches('\n').as_bytes() + CATALOG_FIXTURE.trim_end_matches('\n').as_bytes() ); } diff --git a/mcp/tests/fixtures/README.md b/mcp/tests/fixtures/README.md index 01ce4c8..81d0966 100644 --- a/mcp/tests/fixtures/README.md +++ b/mcp/tests/fixtures/README.md @@ -9,11 +9,9 @@ Baseline commit: `815a6616b227c3a6373180c0528d19a96296a62b`. | Artifact | Immutable provenance / derivation | SHA-256 | Exact regeneration command | |---|---|---|---| | `../../../agent/src/snapshot_legacy.js` | Baseline `mcp/src/snapshot.js`, plus the structured `units` and `rendererIncomplete` mirror fields required by response shaping. The shared actor migration renames the internal DOM marker from `data-mcp-ref` to `data-rustwright-ref`; the legacy outline and traversal stay unchanged. The response-shape derivation is pinned in `snapshot_legacy-derivation.patch`. | `3e9be04b7cbbc6c3567c7341d09c168ed720a9fcd162006e1d5ac9236bc32340` | `git show 815a6616b227c3a6373180c0528d19a96296a62b:mcp/src/snapshot.js > mcp/src/snapshot_legacy.js && git apply mcp/tests/fixtures/snapshot_legacy-derivation.patch && python -c 'from pathlib import Path; p = Path("mcp/src/snapshot_legacy.js"); p.write_text(p.read_text().replace("data-mcp-ref", "data-rustwright-ref"))' && mv mcp/src/snapshot_legacy.js agent/src/snapshot_legacy.js` | -| `tools-list-legacy.json` | Complete `mirror` catalog serialized through the retained legacy-description path. The descriptions and schemas originate at the baseline commit above. | `6083c18fb648153975998bbde2172523fa6fdd1aa74637d5990727ca19239f56` | `RUSTWRIGHT_UPDATE_LEGACY_TOOL_CATALOG=1 cargo test --manifest-path mcp/Cargo.toml --locked legacy_catalog_matches_archived_pre_w5_bytes` | -| `tools-list-lean.json` | Complete `mirror` catalog serialized through the W5 lean-description path. | `addbebf233cadc7d8aed1cc6765920430eeda4623e01bccfb208b1386ce7ec75` | `RUSTWRIGHT_UPDATE_LEAN_TOOL_CATALOG=1 cargo test --manifest-path mcp/Cargo.toml --locked lean_catalog_matches_archived_w5_bytes` | -| `union-legacy-surfaces.json` | Exact decoded all-off union outputs. Navigation and snapshot were derived offline by running the union DOM through `mcp/src/snapshot.js` extracted from the baseline archive. Console and network are browser-observable: their exact renderings are pinned from the hash-checked baseline `actor.rs` formatter and the certified legacy-path W4 tests. The console template preserves the injected Proxy's raw anonymous top-frame line; `{PAGE_URL}` is the sole network-template input. | `93022b7a449042e922b1dc3514edae7de0bf3e4ba844206dd50f984a2e1cd04d` | `LEGACY_FIXTURE_DIR="$(mktemp -d)"; git archive 815a6616b227c3a6373180c0528d19a96296a62b | tar -x -C "$LEGACY_FIXTURE_DIR"; node mcp/tests/fixtures/derive_union_legacy_surfaces.js "$LEGACY_FIXTURE_DIR" > mcp/tests/fixtures/union-legacy-surfaces.json` | -| `rustwright-union-console.js` | Authored, content-addressed external-script fixture for the union corpus. Each console emission occupies its own stable source line; Chromium/CDP reports the warning calls on zero-based lines 1 and 2. | `c16e1a852f660177de3071e65592840d0ac88a27355a5845e5caa1404f97af33` | Authored fixture; update deliberately and recalculate with `shasum -a 256 mcp/tests/fixtures/rustwright-union-console.js`. | -| `codex-mcp-initialize-0.146.0.json` | Captured first MCP initialize frame from `@openai/codex@0.146.0`; the fixture records the capture date plus SHA-256 digests of that package's JavaScript launcher and platform-native binary. | `1dbd670a45fbac8a923b522e90c673d0c95c5b920a14ac6f6163bb39331ebd81` | `CAPTURE_DIR="$(mktemp -d)"; npm install --prefix "$CAPTURE_DIR" @openai/codex@0.146.0; cargo build --manifest-path mcp/Cargo.toml --locked; CODEX_HOME="$CAPTURE_DIR/home" node "$CAPTURE_DIR/node_modules/@openai/codex/bin/codex.js" -c "mcp_servers.capture.command='sh'" -c "mcp_servers.capture.args=['-c','tee $CAPTURE_DIR/frames.jsonl | mcp/target/debug/rustwright-mcp']" exec 'Return the word ready.'; CODEX_LAUNCHER="$CAPTURE_DIR/node_modules/@openai/codex/bin/codex.js"; CODEX_NATIVE="$(find "$CAPTURE_DIR/node_modules" -path '*/bin/codex' -type f | head -n 1)"; jq -n --arg captured "$(date -u +%F)" --arg launcher "$(shasum -a 256 "$CODEX_LAUNCHER" | awk '{print $1}')" --arg native "$(shasum -a 256 "$CODEX_NATIVE" | awk '{print $1}')" --slurpfile initialize <(head -n 1 "$CAPTURE_DIR/frames.jsonl") '{captured_at_utc:$captured,codex_cli_version:"0.146.0",codex_js_launcher_sha256:$launcher,codex_native_binary_sha256:$native,initialize_frame:$initialize[0],schema_version:1}' > mcp/tests/fixtures/codex-mcp-initialize-0.146.0.json` | +| `tools-list.json` | Complete canonical `mirror` catalog. | `addbebf233cadc7d8aed1cc6765920430eeda4623e01bccfb208b1386ce7ec75` | `RUSTWRIGHT_UPDATE_TOOL_CATALOG=1 cargo test --manifest-path mcp/Cargo.toml --locked catalog_matches_archived_bytes` | +| `union-legacy-surfaces.json` | Baseline decoded outputs used to prove the canonical profile changes the intended snapshot, console, and network surfaces. | `93022b7a449042e922b1dc3514edae7de0bf3e4ba844206dd50f984a2e1cd04d` | `LEGACY_FIXTURE_DIR="$(mktemp -d)"; git archive 815a6616b227c3a6373180c0528d19a96296a62b | tar -x -C "$LEGACY_FIXTURE_DIR"; node mcp/tests/fixtures/derive_union_legacy_surfaces.js "$LEGACY_FIXTURE_DIR"` | +| `rustwright-union-console.js` | Authored, content-addressed external-script fixture for the union corpus. Each console emission occupies its own stable source line. | `c16e1a852f660177de3071e65592840d0ac88a27355a5845e5caa1404f97af33` | Authored fixture; update deliberately and recalculate with `shasum -a 256 mcp/tests/fixtures/rustwright-union-console.js`. | After a catalog refresh, run the same test command again without its `RUSTWRIGHT_UPDATE_*` variable. After any recapture, verify the recorded digest @@ -21,9 +19,7 @@ with: ```text shasum -a 256 agent/src/snapshot_legacy.js \ - mcp/tests/fixtures/codex-mcp-initialize-0.146.0.json \ - mcp/tests/fixtures/tools-list-legacy.json \ - mcp/tests/fixtures/tools-list-lean.json \ + mcp/tests/fixtures/tools-list.json \ mcp/tests/fixtures/union-legacy-surfaces.json \ mcp/tests/fixtures/rustwright-union-console.js ``` @@ -31,8 +27,8 @@ shasum -a 256 agent/src/snapshot_legacy.js \ The recapture commands above only write artifacts. Run assertions separately: ```text -cargo test --manifest-path mcp/Cargo.toml --locked captured_codex_initialize_client_selects_budget_profile -cargo test --manifest-path mcp/Cargo.toml --locked canonical_all_off_transcript_matches_pre_treatment_union_expectations +cargo test --manifest-path mcp/Cargo.toml --locked catalog_matches_archived_bytes +cargo test --manifest-path mcp/Cargo.toml --locked canonical_profile_activates_every_reviewed_behavior_for_every_client ``` `snapshot_w2_test.js` is an authored deterministic harness, not a captured diff --git a/mcp/tests/fixtures/tools-list.json b/mcp/tests/fixtures/tools-list.json new file mode 100644 index 0000000..c5d7457 --- /dev/null +++ b/mcp/tests/fixtures/tools-list.json @@ -0,0 +1 @@ +{"tools":[{"name":"browser_navigate","description":"Navigate; snapshot.","inputSchema":{"additionalProperties":false,"properties":{"url":{"description":"URL to navigate to","type":"string"}},"required":["url"],"type":"object"}},{"name":"browser_navigate_back","description":"Back; snapshot.","inputSchema":{"additionalProperties":false,"properties":{},"type":"object"}},{"name":"browser_navigate_forward","description":"Forward; snapshot.","inputSchema":{"additionalProperties":false,"properties":{},"type":"object"}},{"name":"browser_reload","description":"Reload; snapshot.","inputSchema":{"additionalProperties":false,"properties":{},"type":"object"}},{"name":"browser_resize","description":"Resize viewport; snapshot.","inputSchema":{"additionalProperties":false,"properties":{"height":{"exclusiveMinimum":0,"type":"number"},"width":{"exclusiveMinimum":0,"type":"number"}},"required":["width","height"],"type":"object"}},{"name":"browser_snapshot","description":"Snapshot page. Narrow with target/depth. Refs are session-only and never reused.","inputSchema":{"additionalProperties":false,"properties":{"boxes":{"default":false,"type":"boolean"},"depth":{"minimum":0,"type":"number"},"target":{"description":"Ref from the latest snapshot, such as e3","pattern":"^e[1-9][0-9]*$","type":"string"}},"type":"object"}},{"name":"browser_find","description":"Find text/regex; snapshot.","inputSchema":{"additionalProperties":false,"oneOf":[{"not":{"required":["regex"]},"required":["text"]},{"not":{"required":["text"]},"required":["regex"]}],"properties":{"regex":{"type":"string"},"text":{"type":"string"}},"type":"object"}},{"name":"browser_click","description":"Click/double-click ref; snapshot.","inputSchema":{"additionalProperties":false,"properties":{"doubleClick":{"default":false,"type":"boolean"},"element":{"type":"string"},"target":{"description":"Ref from the latest snapshot, such as e3","pattern":"^e[1-9][0-9]*$","type":"string"}},"required":["target"],"type":"object"}},{"name":"browser_scroll","description":"Scroll ref/viewport; snapshot.","inputSchema":{"additionalProperties":false,"oneOf":[{"not":{"anyOf":[{"required":["direction"]},{"required":["pixels"]}]},"required":["target"]},{"not":{"required":["target"]},"required":["direction"]}],"properties":{"direction":{"enum":["up","down"],"type":"string"},"pixels":{"exclusiveMinimum":0,"type":"number"},"target":{"description":"Ref from the latest snapshot, such as e3","pattern":"^e[1-9][0-9]*$","type":"string"}},"type":"object"}},{"name":"browser_type","description":"Type ref; optional submit.","inputSchema":{"additionalProperties":false,"properties":{"clear":{"default":true,"type":"boolean"},"element":{"type":"string"},"slowly":{"default":false,"type":"boolean"},"submit":{"default":false,"type":"boolean"},"target":{"description":"Ref from the latest snapshot, such as e3","pattern":"^e[1-9][0-9]*$","type":"string"},"text":{"type":"string"}},"required":["target","text"],"type":"object"}},{"name":"browser_select_option","description":"Select values/labels on ref.","inputSchema":{"additionalProperties":false,"oneOf":[{"not":{"required":["value"]},"required":["values"]},{"not":{"required":["values"]},"required":["value"]}],"properties":{"element":{"type":"string"},"target":{"description":"Ref from the latest snapshot, such as e3","pattern":"^e[1-9][0-9]*$","type":"string"},"value":{"oneOf":[{"type":"string"},{"items":{"type":"string"},"type":"array"}]},"values":{"oneOf":[{"type":"string"},{"items":{"type":"string"},"type":"array"}]}},"required":["target"],"type":"object"}},{"name":"browser_fill_form","description":"Fill 1-50 fields; non-transactional.","inputSchema":{"additionalProperties":false,"properties":{"fields":{"items":{"additionalProperties":false,"properties":{"element":{"type":"string"},"name":{"type":"string"},"target":{"description":"Ref from the latest snapshot, such as e3","pattern":"^e[1-9][0-9]*$","type":"string"},"type":{"enum":["textbox","checkbox","radio","combobox","slider"],"type":"string"},"value":{"type":"string"}},"required":["target","name","type","value"],"type":"object"},"maxItems":50,"minItems":1,"type":"array"}},"required":["fields"],"type":"object"}},{"name":"browser_hover","description":"Native hover ref; snapshot.","inputSchema":{"additionalProperties":false,"properties":{"element":{"type":"string"},"target":{"description":"Ref from the latest snapshot, such as e3","pattern":"^e[1-9][0-9]*$","type":"string"}},"required":["target"],"type":"object"}},{"name":"browser_press_key","description":"Press key; optional ref focus; snapshot.","inputSchema":{"additionalProperties":false,"properties":{"key":{"type":"string"},"target":{"description":"Ref from the latest snapshot, such as e3","pattern":"^e[1-9][0-9]*$","type":"string"}},"required":["key"],"type":"object"}},{"name":"browser_drag","description":"Native drag between refs.","inputSchema":{"additionalProperties":false,"properties":{"endElement":{"type":["string","null"]},"endTarget":{"type":"string"},"startElement":{"type":["string","null"]},"startTarget":{"type":"string"}},"required":["startTarget","endTarget"],"type":"object"}},{"name":"browser_drop","description":"Drop confined files/MIME on ref.","inputSchema":{"additionalProperties":false,"properties":{"data":{"additionalProperties":{"type":"string"},"type":"object"},"element":{"type":"string"},"paths":{"items":{"type":"string"},"maxItems":50,"type":"array"},"target":{"description":"Ref from the latest snapshot, such as e3","pattern":"^e[1-9][0-9]*$","type":"string"}},"required":["target"],"type":"object"}},{"name":"browser_console_messages","description":"List bounded console records; filter by level.","inputSchema":{"additionalProperties":false,"properties":{"all":{"default":false,"type":"boolean"},"filename":{"type":["string","null"]},"level":{"default":"info","enum":["error","warning","info","debug"],"type":"string"}},"type":"object"}},{"name":"browser_network_requests","description":"List/filter bounded requests; use browser_network_request for one.","inputSchema":{"additionalProperties":false,"properties":{"filename":{"type":["string","null"]},"filter":{"type":["string","null"]},"static":{"default":false,"type":"boolean"}},"type":"object"}},{"name":"browser_network_request","description":"Get one request/response or bounded body.","inputSchema":{"additionalProperties":false,"properties":{"filename":{"type":["string","null"]},"index":{"minimum":1,"type":"integer"},"part":{"enum":["request-headers","request-body","response-headers","response-body",null],"type":["string","null"]}},"required":["index"],"type":"object"}},{"name":"browser_tabs","description":"List/change tabs.","inputSchema":{"additionalProperties":false,"properties":{"action":{"enum":["list","new","close","select"],"type":"string"},"index":{"minimum":0,"type":"integer"},"url":{"type":"string"}},"required":["action"],"type":"object"}},{"name":"browser_handle_dialog","description":"Handle dialog. Dialogs block further page work until handled.","inputSchema":{"additionalProperties":false,"properties":{"accept":{"type":"boolean"},"promptText":{"type":["string","null"]}},"required":["accept"],"type":"object"}},{"name":"browser_file_upload","description":"Resolve chooser with confined paths or cancel.","inputSchema":{"additionalProperties":false,"properties":{"paths":{"items":{"type":"string"},"type":["array","null"]}},"type":"object"}},{"name":"browser_wait_for","description":"Wait for time/text; snapshot.","inputSchema":{"additionalProperties":false,"anyOf":[{"required":["time"]},{"required":["text"]},{"required":["textGone"]}],"properties":{"text":{"type":"string"},"textGone":{"type":"string"},"time":{"minimum":0,"type":"number"},"timeout_ms":{"default":10000,"minimum":0,"type":"number"}},"type":"object"}},{"name":"browser_get_text","description":"Extract text. Use browser_wait_for to validate.","inputSchema":{"additionalProperties":false,"properties":{"max_chars":{"default":20000,"minimum":0,"type":"integer"},"selector":{"default":"body","type":"string"}},"type":"object"}},{"name":"browser_evaluate","description":"Run page JS on page/ref.","inputSchema":{"additionalProperties":false,"properties":{"element":{"type":"string"},"function":{"type":"string"},"target":{"description":"Ref from the latest snapshot, such as e3","pattern":"^e[1-9][0-9]*$","type":"string"}},"required":["function"],"type":"object"}},{"name":"browser_take_screenshot","description":"Capture image; fallback lasts until shutdown.","inputSchema":{"additionalProperties":false,"properties":{"fullPage":{"default":false,"type":"boolean"},"type":{"default":"png","enum":["png","jpeg"],"type":"string"}},"type":"object"}},{"name":"browser_close","description":"Close browser; next tool starts fresh.","inputSchema":{"additionalProperties":false,"properties":{},"type":"object"}}]} diff --git a/mcp/tests/stdio_e2e.rs b/mcp/tests/stdio_e2e.rs index d88db50..aef96ff 100644 --- a/mcp/tests/stdio_e2e.rs +++ b/mcp/tests/stdio_e2e.rs @@ -706,12 +706,6 @@ impl ServerProcess { .env_remove("RUSTWRIGHT_MCP_TOOL_TIMEOUT_MS") .env_remove("RUSTWRIGHT_MCP_MAX_RESPONSE_BYTES") .env_remove("RUSTWRIGHT_MCP_MAX_RESPONSE_LINES") - .env_remove("RUSTWRIGHT_MCP_BUDGET") - .env_remove("RUSTWRIGHT_MCP_DISTILL") - .env_remove("RUSTWRIGHT_MCP_HEADER") - .env_remove("RUSTWRIGHT_MCP_CONSOLE_DEDUP") - .env_remove("RUSTWRIGHT_MCP_NET_NOTE") - .env_remove("RUSTWRIGHT_MCP_LEAN_DESCRIPTIONS") .env_remove("RUSTWRIGHT_MCP_TOOLSET") .env_remove("RUSTWRIGHT_MCP_ALLOW_EVAL") .stdin(Stdio::piped()) @@ -894,38 +888,83 @@ impl Drop for ServerProcess { } fn role_ref(snapshot: &str, role: &str, name: &str) -> String { - let line = snapshot - .lines() - .find(|line| line.contains(&format!("- {role}")) && line.contains(name)) - .unwrap_or_else(|| panic!("{role} {name:?} missing from snapshot:\n{snapshot}")); - let marker = "[ref="; - let start = line.find(marker).expect("role ref start") + marker.len(); - let end = line[start..].find(']').expect("role ref end") + start; - line[start..end].to_owned() + let lines = snapshot.lines().collect::>(); + for (index, line) in lines.iter().enumerate() { + let trimmed = line.trim_start(); + let Some(after_dash) = trimmed.strip_prefix("- ") else { + continue; + }; + let Some(after_role) = after_dash.strip_prefix(role) else { + continue; + }; + if !after_role.is_empty() && !after_role.starts_with(' ') && !after_role.starts_with('[') { + continue; + } + + let fields = after_role.trim_start(); + let (inline_name, metadata) = if fields.starts_with('"') { + let mut values = serde_json::Deserializer::from_str(fields).into_iter::(); + let inline_name = values + .next() + .expect("quoted accessible name") + .expect("valid quoted accessible name"); + let offset = values.byte_offset(); + (Some(inline_name), &fields[offset..]) + } else { + (None, fields) + }; + let mut matches_name = inline_name.as_deref() == Some(name); + if inline_name.is_none() { + let indent = line.len() - trimmed.len(); + for descendant in &lines[index + 1..] { + if descendant.trim().is_empty() { + continue; + } + let descendant_indent = descendant.len() - descendant.trim_start().len(); + if descendant_indent <= indent { + break; + } + matches_name |= descendant + .trim_start() + .strip_prefix("- text: ") + .is_some_and(|text| text == name); + } + } + if !matches_name { + continue; + } + let reference = metadata + .split_ascii_whitespace() + .find_map(|field| field.strip_prefix("[ref=")?.strip_suffix(']')) + .expect("role ref"); + return reference.to_owned(); + } + panic!("{role} {name:?} missing from snapshot:\n{snapshot}"); } -// Extract a rendered readout's full text so two snapshots can be compared for -// equality. `prefix` starts with the opening quote the renderer wraps names in; -// returning the text up to the closing quote keeps ref markers -- whose numbers -// are reassigned on every snapshot -- out of the comparison. -fn status_text(snapshot: &str, prefix: &str) -> String { - assert!( - prefix.starts_with('"'), - "prefix must start at the renderer's opening quote: {prefix:?}" - ); - let mut matches = snapshot.match_indices(prefix); - let (start, _) = matches - .next() - .unwrap_or_else(|| panic!("{prefix:?} missing from snapshot:\n{snapshot}")); +// Find a rendered readout by prefix. Ref markers are on ancestor nodes, so the +// complete text line is stable. +fn matching_status_text<'a>(snapshot: &'a str, prefix: &str) -> Option<&'a str> { + let mut matches = snapshot.lines().filter_map(|line| { + let text = line.trim_start().strip_prefix("- text: ")?; + text.starts_with(prefix).then_some(text) + }); + let text = matches.next(); assert!( matches.next().is_none(), "{prefix:?} matched more than once, so equality would be ambiguous:\n{snapshot}" ); - let rest = &snapshot[start + 1..]; - let end = rest - .find('"') - .unwrap_or_else(|| panic!("unterminated readout for {prefix:?}:\n{snapshot}")); - rest[..end].to_owned() + text +} + +fn status_text(snapshot: &str, prefix: &str) -> String { + matching_status_text(snapshot, prefix) + .unwrap_or_else(|| panic!("{prefix:?} missing from snapshot:\n{snapshot}")) + .to_owned() +} + +fn has_exact_status_text(snapshot: &str, expected: &str) -> bool { + matching_status_text(snapshot, expected).is_some_and(|text| text == expected) } fn result_text(message: &Value) -> &str { @@ -997,14 +1036,7 @@ fn converge_snapshot_text( } fn named_ref(snapshot: &str, role: &str, name: &str) -> String { - let line = snapshot - .lines() - .find(|line| line.contains(&format!("- {role}")) && line.contains(name)) - .unwrap_or_else(|| panic!("{role} {name:?} missing from snapshot:\n{snapshot}")); - let marker = "[ref="; - let start = line.find(marker).expect("named ref start") + marker.len(); - let end = line[start..].find(']').expect("named ref end") + start; - line[start..end].to_owned() + role_ref(snapshot, role, name) } fn png_path_from_fallback(text: &str) -> PathBuf { @@ -1026,14 +1058,7 @@ fn png_path_from_fallback(text: &str) -> PathBuf { } fn button_ref(snapshot: &str, name: &str) -> String { - let line = snapshot - .lines() - .find(|line| line.contains("- button") && line.contains(name)) - .unwrap_or_else(|| panic!("button {name:?} missing from snapshot:\n{snapshot}")); - let marker = "[ref="; - let start = line.find(marker).expect("button ref start") + marker.len(); - let end = line[start..].find(']').expect("button ref end") + start; - line[start..end].to_owned() + role_ref(snapshot, "button", name) } fn poll_snapshot_until( @@ -1053,38 +1078,54 @@ fn poll_snapshot_until( // Masking is a property of EVERY snapshot, not just the one a poll settles on. // Checking only the returned snapshot would let a plaintext leak in the tool's -// immediate response or in any intermediate poll pass unnoticed, because a later -// masked snapshot would overwrite it. `poll_snapshot_state` runs its predicate on -// the initial value and on each snapshot it fetches, so asserting inside the -// predicate covers every snapshot this poll observes. +// immediate response or in any intermediate poll pass unnoticed. +fn assert_snapshot_never_leaks(snapshot: &str, secret: &str) { + assert!( + !snapshot.contains(secret), + "snapshot leaked the secret {secret:?}:\n{snapshot}" + ); + // The positive complement of the leak check: absence of the plaintext is + // also satisfied by a snapshot that dropped the field entirely. + assert!( + !snapshot.contains("Secret input") || snapshot.contains("[value=••••••]"), + "snapshot rendered the password row unmasked:\n{snapshot}" + ); +} + fn poll_snapshot_until_never_leaking( server: &mut ServerProcess, latest: String, next_id: &mut i64, - needle: &str, + expected: &str, secret: &str, ) -> String { poll_snapshot_state( server, latest, next_id, - &format!("{needle:?}"), + &format!("{expected:?}"), |snapshot| { - assert!( - !snapshot.contains(secret), - "snapshot leaked the secret {secret:?}:\n{snapshot}" - ); - // The positive complement of the leak check: absence of the plaintext - // is also satisfied by a snapshot that dropped the field entirely, so - // any snapshot still rendering the password row has to render it - // masked. Responses that carry no snapshot mention neither string and - // pass, which is what lets the poll keep going instead of failing on - // an intermediate tool reply. - assert!( - !snapshot.contains("Secret input") || snapshot.contains("[value=••••••]"), - "snapshot rendered the password row unmasked:\n{snapshot}" - ); - snapshot.contains(needle) + assert_snapshot_never_leaks(snapshot, secret); + has_exact_status_text(snapshot, expected) + }, + ) +} + +fn poll_snapshot_until_status_prefix_never_leaking( + server: &mut ServerProcess, + latest: String, + next_id: &mut i64, + prefix: &str, + secret: &str, +) -> String { + poll_snapshot_state( + server, + latest, + next_id, + &format!("{prefix:?}"), + |snapshot| { + assert_snapshot_never_leaks(snapshot, secret); + matching_status_text(snapshot, prefix).is_some() }, ) } @@ -1118,6 +1159,28 @@ fn poll_snapshot_state( latest } +#[test] +fn snapshot_helpers_parse_names_refs_and_statuses_exactly() { + let snapshot = r#"- button "Save [ref=e999]" [ref=e1] + - text: Save [ref=e999] +- button "Save [draft]" [ref=e2] +- button "Save draft" [ref=e4] + - text: Save +- button [ref=e3] + - text: Save +- status + - text: Input value: stdio typed7"#; + + assert_eq!(role_ref(snapshot, "button", "Save [ref=e999]"), "e1"); + assert_eq!(role_ref(snapshot, "button", "Save [draft]"), "e2"); + assert_eq!(role_ref(snapshot, "button", "Save"), "e3"); + assert!(!has_exact_status_text(snapshot, "Input value: stdio typed")); + assert_eq!( + status_text(snapshot, "Input value: "), + "Input value: stdio typed7" + ); +} + fn scroll_y(snapshot: &str) -> u64 { let marker = "Scroll Y: "; let suffix = snapshot @@ -1580,9 +1643,9 @@ fn real_stdio_tool_profiles_and_evaluation_gate_match_contract() { } #[test] -fn real_stdio_lean_description_profile_and_override_match_contract() { - fn listed(client_name: &str, environment: &[(&str, &str)]) -> (Value, usize) { - let mut server = ServerProcess::spawn_with_env(environment); +fn real_stdio_uses_one_description_profile_for_every_client() { + fn listed(client_name: &str) -> (Value, usize) { + let mut server = ServerProcess::spawn(); server.initialize_as(client_name); let id = "i".repeat(254); assert_eq!( @@ -1597,36 +1660,18 @@ fn real_stdio_lean_description_profile_and_override_match_contract() { (response["result"].clone(), frame_bytes) } - let (codex_default, codex_default_frame_bytes) = listed("codex-mcp-client", &[]); - let (explicit_off, _) = listed( - "codex-mcp-client", - &[("RUSTWRIGHT_MCP_LEAN_DESCRIPTIONS", "off")], - ); - let (unknown_default, _) = listed("unknown-hermetic-client", &[]); - let (explicit_on, _) = listed( - "unknown-hermetic-client", - &[("RUSTWRIGHT_MCP_LEAN_DESCRIPTIONS", "on")], - ); - let (invalid_codex, _) = listed( - "codex-mcp-client", - &[("RUSTWRIGHT_MCP_LEAN_DESCRIPTIONS", "invalid")], - ); - - let archived_legacy: Value = - serde_json::from_str(include_str!("fixtures/tools-list-legacy.json")) - .expect("legacy tools/list fixture"); - let archived_lean: Value = serde_json::from_str(include_str!("fixtures/tools-list-lean.json")) - .expect("lean tools/list fixture"); - assert_eq!(explicit_off, archived_legacy); - assert_eq!(unknown_default, archived_legacy); - assert_eq!(codex_default, archived_lean); - assert_eq!(codex_default, explicit_on); - assert_eq!(invalid_codex, codex_default); - assert_ne!(codex_default, explicit_off); - assert!( - codex_default_frame_bytes <= 9 * 1024, - "lean tools/list response is {codex_default_frame_bytes} bytes, over 9216" - ); + let (codex, codex_frame_bytes) = listed("codex-mcp-client"); + let (unknown, unknown_frame_bytes) = listed("unknown-hermetic-client"); + let archived: Value = + serde_json::from_str(include_str!("fixtures/tools-list.json")).expect("tools/list fixture"); + assert_eq!(codex, archived); + assert_eq!(unknown, archived); + for frame_bytes in [codex_frame_bytes, unknown_frame_bytes] { + assert!( + frame_bytes <= 9 * 1024, + "tools/list response is {frame_bytes} bytes, over 9216" + ); + } } #[derive(Debug, PartialEq, Eq)] @@ -1876,126 +1921,34 @@ fn assert_page_digest<'a>( body } -fn assert_legacy_union_expectations(corpus: &UnionCorpus, page_url: &str) { - let frozen = frozen_union_legacy_surfaces(); - assert_eq!(frozen["schema_version"], 1); - assert_eq!( - frozen["baseline_commit"], - "815a6616b227c3a6373180c0528d19a96296a62b" - ); - let archived_legacy: Value = - serde_json::from_str(include_str!("fixtures/tools-list-legacy.json")) - .expect("legacy tools/list fixture"); - // Decoded union output is compared semantically here. Raw-byte archive identity - // remains pinned by the archive unit tests, with stdio frame bytes checked below. - assert_eq!( - corpus.catalog, archived_legacy, - "decoded tools/list result semantically diverged from the legacy archive" - ); - let archived_legacy_bytes = include_str!("fixtures/tools-list-legacy.json").trim_end(); - assert!( - corpus - .catalog_frame - .contains(&format!("\"result\":{archived_legacy_bytes}")), - "tools/list result bytes diverged from the legacy archive" - ); - assert_eq!( - corpus.navigation, - frozen_union_surface(&frozen, "navigation") - ); - assert_eq!(corpus.snapshot, frozen_union_surface(&frozen, "snapshot")); - // The injected console Proxy is the raw top CDP frame for both calls, so - // legacy/all-off intentionally reproduces its anonymous line-169 artifact. - assert_eq!( - corpus.console, - frozen_union_surface(&frozen, "console_template") - ); - assert_eq!( - corpus.network, - frozen_union_surface(&frozen, "network_template").replace("{PAGE_URL}", page_url) - ); - assert!(corpus.budget_probe.is_none()); -} - -#[test] -fn union_matrix_unknown_client_with_unset_environment_is_legacy_compatible() { - let (workspace, page_url) = union_fixture(); - assert_union_fixture_uses_external_console_script(&workspace); - if chromium().executable_path().is_none() { - eprintln!("skipping union legacy matrix: Chromium executable unavailable"); - remove_union_fixture(&workspace); - return; - } - let corpus = run_union_corpus(&page_url, "unknown-hermetic-client", &[], false); - assert_legacy_union_expectations(&corpus, &page_url); - remove_union_fixture(&workspace); -} - -#[test] -fn canonical_all_off_transcript_matches_pre_treatment_union_expectations() { - let (workspace, page_url) = union_fixture(); - assert_union_fixture_uses_external_console_script(&workspace); - if chromium().executable_path().is_none() { - eprintln!("skipping all-off union corpus: Chromium executable unavailable"); - remove_union_fixture(&workspace); - return; - } - let treatment_all_off = run_union_corpus( - &page_url, - "unknown-hermetic-client", - &[ - ("RUSTWRIGHT_MCP_BUDGET", "off"), - ("RUSTWRIGHT_MCP_DISTILL", "off"), - ("RUSTWRIGHT_MCP_HEADER", "off"), - ("RUSTWRIGHT_MCP_CONSOLE_DEDUP", "off"), - ("RUSTWRIGHT_MCP_NET_NOTE", "off"), - ("RUSTWRIGHT_MCP_LEAN_DESCRIPTIONS", "off"), - ("RUSTWRIGHT_MCP_TOOLSET", "mirror"), - ], - false, - ); - assert_legacy_union_expectations(&treatment_all_off, &page_url); - remove_union_fixture(&workspace); -} - #[test] -fn union_matrix_exact_codex_peer_with_all_on_deployment_activates_every_wave() { +fn canonical_profile_activates_every_reviewed_behavior_for_every_client() { let (workspace, page_url) = union_fixture(); assert_union_fixture_uses_external_console_script(&workspace); if chromium().executable_path().is_none() { - eprintln!("skipping union Codex matrix: Chromium executable unavailable"); + eprintln!("skipping canonical union corpus: Chromium executable unavailable"); remove_union_fixture(&workspace); return; } let treatment = run_union_corpus( &page_url, - "codex-mcp-client", - &[ - ("RUSTWRIGHT_MCP_BUDGET", "on"), - ("RUSTWRIGHT_MCP_DISTILL", "on"), - ("RUSTWRIGHT_MCP_HEADER", "on"), - ("RUSTWRIGHT_MCP_CONSOLE_DEDUP", "on"), - ("RUSTWRIGHT_MCP_NET_NOTE", "on"), - ("RUSTWRIGHT_MCP_LEAN_DESCRIPTIONS", "on"), - ("RUSTWRIGHT_MCP_TOOLSET", "mirror"), - ], + "unknown-hermetic-client", + &[("RUSTWRIGHT_MCP_TOOLSET", "mirror")], true, ); - let archived_lean: Value = serde_json::from_str(include_str!("fixtures/tools-list-lean.json")) - .expect("lean tools/list fixture"); - // Decoded union output is compared semantically here. Raw-byte archive identity - // remains pinned by the archive unit tests, with stdio frame bytes checked below. + let archived: Value = + serde_json::from_str(include_str!("fixtures/tools-list.json")).expect("tools/list fixture"); assert_eq!( - treatment.catalog, archived_lean, - "decoded tools/list result semantically diverged from the W5 lean archive" + treatment.catalog, archived, + "decoded tools/list result semantically diverged from the archived catalog" ); - let archived_lean_bytes = include_str!("fixtures/tools-list-lean.json").trim_end(); + let archived_bytes = include_str!("fixtures/tools-list.json").trim_end(); assert!( treatment .catalog_frame - .contains(&format!("\"result\":{archived_lean_bytes}")), - "tools/list result bytes diverged from the lean archive" + .contains(&format!("\"result\":{archived_bytes}")), + "tools/list result bytes diverged from the archived catalog" ); let distilled_snapshot = "- main\n - heading [level=1]\n - text: Network records"; assert_eq!( @@ -2093,7 +2046,10 @@ fn real_stdio_type_press_enter_and_select_option_round_trip() { "params":{"name":"browser_snapshot","arguments":{}} })); let snapshot = result_text(&server.receive()).to_owned(); - assert!(snapshot.contains(r#""Password length: 0""#), "{snapshot}"); + assert_eq!( + status_text(&snapshot, "Password length: "), + "Password length: 0" + ); // Nothing has been typed yet, and the renderer omits the value part entirely // for an empty field, so the mask is absent here. Pinning that keeps the mask // assertions below honest: they observe a transition rather than a constant @@ -2121,10 +2077,7 @@ fn real_stdio_type_press_enter_and_select_option_round_trip() { &mut server, password_typed, &mut next_id, - &format!( - r#""Password length: {}""#, - password_sentinel.chars().count() - ), + &format!("Password length: {}", password_sentinel.chars().count()), password_sentinel, ); assert!( @@ -2162,12 +2115,12 @@ fn real_stdio_type_press_enter_and_select_option_round_trip() { &mut server, pressed, &mut next_id, - r#""Key pressed: Enter; trusted: true; target: name""#, + "Key pressed: Enter; trusted: true; target: name", password_sentinel, ); - assert!( - observed.contains(r#""Input value: stdio typed""#), - "{observed}" + assert_eq!( + status_text(&observed, "Input value: "), + "Input value: stdio typed" ); let select_target = role_ref(&observed, "combobox", "Test choice"); @@ -2185,12 +2138,12 @@ fn real_stdio_type_press_enter_and_select_option_round_trip() { &mut server, selected, &mut next_id, - r#""Selected value: beta; changes: 1""#, + "Selected value: beta; changes: 1", password_sentinel, ); - assert!( - selected.contains(r#""Input value: stdio typed""#), - "{selected}" + assert_eq!( + status_text(&selected, "Input value: "), + "Input value: stdio typed" ); assert!(selected.contains("[value=••••••]"), "{selected}"); assert!(!selected.contains(password_sentinel), "{selected}"); @@ -2215,22 +2168,19 @@ fn real_stdio_type_press_enter_and_select_option_round_trip() { &mut server, targeted, &mut next_id, - &format!( - r#""Password length: {}""#, - password_sentinel.chars().count() + 1 - ), + &format!("Password length: {}", password_sentinel.chars().count() + 1), password_sentinel, ); - assert!( - targeted.contains(r#""Key pressed: 7; trusted: true; target: secret""#), - "{targeted}" + assert_eq!( + status_text(&targeted, "Key pressed: "), + "Key pressed: 7; trusted: true; target: secret" ); // The key went to the targeted ref and nowhere else: the text input's readout - // still reads exactly what the earlier typing left it, closing quote included, - // so a stray "7" delivered there would fail this. - assert!( - targeted.contains(r#""Input value: stdio typed""#), - "{targeted}" + // still reads exactly what the earlier typing left it, so a stray "7" + // delivered there would fail this. + assert_eq!( + status_text(&targeted, "Input value: "), + "Input value: stdio typed" ); assert!(targeted.contains("[value=••••••]"), "{targeted}"); @@ -2238,14 +2188,14 @@ fn real_stdio_type_press_enter_and_select_option_round_trip() { // targeted press above focused the password field to deliver its key, so // focus now names `secret` -- which also proves the readout is live rather // than a constant, since it started at `none`. - let targeted = poll_snapshot_until_never_leaking( + let targeted = poll_snapshot_until_status_prefix_never_leaking( &mut server, targeted, &mut next_id, - r#""Focused: secret; focus changes: "#, + "Focused: secret; focus changes: ", password_sentinel, ); - let focus_before = status_text(&targeted, r#""Focused: secret"#); + let focus_before = status_text(&targeted, "Focused: secret"); // Aim the bad key at the *text* input, not the password field that currently // holds focus: validating after resolving the ref would run the text input's @@ -2268,15 +2218,16 @@ fn real_stdio_type_press_enter_and_select_option_round_trip() { let after_reject = call_tool(&mut server, after_reject_id, "browser_snapshot", json!({})); let after_reject = result_text(&after_reject).to_owned(); assert_eq!( - status_text(&after_reject, r#""Focused: "#), + status_text(&after_reject, "Focused: "), focus_before, "a rejected key press moved focus:\n{after_reject}" ); - assert!( - after_reject.contains(r#""Key pressed: 7; trusted: true; target: secret""#), + assert_eq!( + status_text(&after_reject, "Key pressed: "), + "Key pressed: 7; trusted: true; target: secret", "a rejected key press reached the page:\n{after_reject}" ); - assert!(!after_reject.contains(password_sentinel), "{after_reject}"); + assert_snapshot_never_leaks(&after_reject, password_sentinel); // Filling a combobox resolves the visible label: the options are labelled // "Alpha" and "Beta" over lowercase values, so a value-only matcher finds @@ -2303,10 +2254,13 @@ fn real_stdio_type_press_enter_and_select_option_round_trip() { &mut server, filled, &mut next_id, - r#""Selected value: alpha; changes: 2""#, + "Selected value: alpha; changes: 2", password_sentinel, ); - assert!(filled.contains(r#""Input value: stdio typed""#), "{filled}"); + assert_eq!( + status_text(&filled, "Input value: "), + "Input value: stdio typed" + ); assert!(filled.contains("[value=••••••]"), "{filled}"); assert!(!filled.contains(password_sentinel), "{filled}"); @@ -2720,9 +2674,6 @@ fn real_stdio_w4_console_and_network_presentation_round_trips() { let workspace_text = workspace.to_string_lossy().to_string(); let mut server = ServerProcess::spawn_with_env(&[ ("RUSTWRIGHT_MCP_WORKSPACE", &workspace_text), - ("RUSTWRIGHT_MCP_CONSOLE_DEDUP", "on"), - ("RUSTWRIGHT_MCP_NET_NOTE", "on"), - ("RUSTWRIGHT_MCP_BUDGET", "on"), ("RUSTWRIGHT_MCP_MAX_RESPONSE_BYTES", "4096"), ("RUSTWRIGHT_MCP_MAX_RESPONSE_LINES", "16"), ]); @@ -2900,41 +2851,17 @@ fn real_stdio_w4_console_and_network_presentation_round_trips() { line.starts_with("S> ") && line.contains("successful static requests hidden") })); - let mut legacy = - ServerProcess::spawn_with_env(&[("RUSTWRIGHT_MCP_WORKSPACE", &workspace_text)]); - legacy.initialize_as("offline-w4-legacy-fixture"); - let _ = call_tool( - &mut legacy, - 950, - "browser_navigate", - json!({"url": console_url}), - ); - let _ = call_tool( - &mut legacy, - 951, - "browser_evaluate", - json!({ - "function": "() => { console.warn('W4 adjacent duplicate'); console.warn('W4 adjacent duplicate'); console.error('W4 adjacent duplicate'); console.warn('W4 separator'); console.warn('W4 adjacent duplicate'); return true; }" - }), - ); - let _ = call_tool( - &mut legacy, - 952, - "browser_navigate", - json!({"url": network_url}), - ); - let legacy_console = call_tool( - &mut legacy, - 953, - "browser_console_messages", - json!({"level": "debug", "all": true}), - ); assert_eq!( normalize_console_locations(&console_artifact), - normalize_console_locations(result_text(&legacy_console)), - "active-W4 export must exactly match an independently captured legacy rendering" + vec![ + "WARNING W4 adjacent duplicate".to_owned(), + "WARNING W4 adjacent duplicate".to_owned(), + "ERROR W4 adjacent duplicate".to_owned(), + "WARNING W4 separator".to_owned(), + "WARNING W4 adjacent duplicate".to_owned(), + ], + "file export must preserve each raw console record" ); - legacy.finish(); for filename in [ "console-w4.txt", @@ -3224,8 +3151,16 @@ fn real_stdio_dialog_returns_fast_surfaces_modal_and_converges_after_accept() { "dialog-triggering click did not return promptly: {click_elapsed:?}" ); let modal = result_text(&clicked); - assert!(modal.contains("### Modal"), "{modal}"); - assert!(modal.contains("Parity dialog"), "{modal}"); + assert!( + modal.contains( + "- Current tab: Dialog pending: type=alert; message=\"Parity dialog\". Call browser_handle_dialog." + ), + "{modal}" + ); + assert!( + modal.contains("Snapshot deferred until the pending modal is handled."), + "{modal}" + ); let deferred_started = Instant::now(); let deferred = call_tool(&mut server, 302, "browser_snapshot", json!({})); @@ -4243,7 +4178,6 @@ fn malformed_json_and_unknown_method_return_errors_and_server_recovers() { fn budgeted_stdio_preserves_validation_and_unknown_tool_error_envelopes() { let canary = "opaque-request-id-value".repeat(300); let mut server = ServerProcess::spawn_with_env(&[ - ("RUSTWRIGHT_MCP_BUDGET", "on"), ("RUSTWRIGHT_MCP_MAX_RESPONSE_BYTES", "4096"), ("RUSTWRIGHT_MCP_MAX_RESPONSE_LINES", "16"), ]); @@ -4281,10 +4215,8 @@ fn budgeted_stdio_preserves_validation_and_unknown_tool_error_envelopes() { #[test] fn no_browser_actor_handler_rmcp_frames_preserve_ids_and_result_classes() { - let mut server = ServerProcess::spawn_with_env(&[("RUSTWRIGHT_MCP_BUDGET", "on")]); - // This is the production initialize/peer_info path; the profile is selected by the - // name rmcp stores from this frame, not by calling the matcher in the test. - server.initialize_as("codex-mcp-client"); + let mut server = ServerProcess::spawn(); + server.initialize_as("unknown-hermetic-client"); server.send(json!({ "jsonrpc":"2.0","id":"actor-success","method":"tools/call", "params":{"name":"browser_close","arguments":{}} @@ -4320,249 +4252,8 @@ fn no_browser_actor_handler_rmcp_frames_preserve_ids_and_result_classes() { } #[test] -fn absent_and_explicit_off_are_raw_transcript_equivalent_for_generic_config_path() { - fn run(environment: &[(&str, &str)]) -> Vec { - let mut server = ServerProcess::spawn_with_env(environment); - server.initialize_as("unknown-hermetic-client"); - server.send(json!({ - "jsonrpc":"2.0","id":81,"method":"tools/call", - "params":{"name":"browser_close","arguments":{}} - })); - assert_eq!(server.receive()["result"]["isError"], false); - server.finish().0 - } - assert_eq!( - run(&[]), - run(&[ - ("RUSTWRIGHT_MCP_BUDGET", "off"), - ("RUSTWRIGHT_MCP_CONSOLE_DEDUP", "off"), - ("RUSTWRIGHT_MCP_NET_NOTE", "off"), - ]) - ); -} - -#[test] -fn absent_and_explicit_off_are_byte_exact_for_console_and_network_production_paths() { - if chromium().executable_path().is_none() { - eprintln!("skipping W4 off-switch test: Chromium executable unavailable"); - return; - } - - let workspace = std::env::temp_dir().join(format!( - "rustwright-mcp-w4-off-{}-{}", - std::process::id(), - STDIO_WORKSPACE_COUNTER.fetch_add(1, Ordering::SeqCst) - )); - fs::create_dir(&workspace).expect("create W4 off-switch workspace"); - let console_page = workspace.join("off-console.html"); - let network_page = workspace.join("off-network.html"); - let static_asset = workspace.join("off-static.svg"); - fs::write( - &console_page, - "off console
console
", - ) - .expect("write off console fixture"); - fs::write( - &network_page, - "off network", - ) - .expect("write off network fixture"); - fs::write( - &static_asset, - "", - ) - .expect("write off static fixture"); - let console_url = format!("file://{}", console_page.display()); - let network_url = format!("file://{}", network_page.display()); - - fn environment<'a>(workspace: &'a str, explicit_off: bool) -> Vec<(&'a str, &'a str)> { - if explicit_off { - vec![ - ("RUSTWRIGHT_MCP_WORKSPACE", workspace), - ("RUSTWRIGHT_MCP_CONSOLE_DEDUP", "off"), - ("RUSTWRIGHT_MCP_NET_NOTE", "off"), - ] - } else { - vec![("RUSTWRIGHT_MCP_WORKSPACE", workspace)] - } - } - - fn run_console( - workspace: &Path, - console_url: &str, - filename: &str, - explicit_off: bool, - ) -> (String, String) { - let workspace_text = workspace.to_string_lossy(); - let environment = environment(&workspace_text, explicit_off); - let artifact_path = workspace.join(filename); - assert!( - !artifact_path.exists(), - "console export path must be unused before the production write" - ); - let mut server = ServerProcess::spawn_with_env(&environment); - server.initialize_as("offline-w4-off-fixture"); - let _ = call_tool( - &mut server, - 970, - "browser_navigate", - json!({"url": console_url}), - ); - let _ = call_tool( - &mut server, - 971, - "browser_evaluate", - json!({ - "function": "() => { console.warn('W4 off duplicate'); console.warn('W4 off duplicate'); return true; }" - }), - ); - let console = call_tool( - &mut server, - 973, - "browser_console_messages", - json!({"level": "debug", "all": true}), - ); - let console_written = call_tool( - &mut server, - 974, - "browser_console_messages", - json!({"level": "debug", "all": true, "filename": filename}), - ); - assert!(result_text(&console_written).contains("Console messages written")); - let console_artifact = fs::read_to_string(&artifact_path).unwrap(); - server.finish(); - (result_text(&console).to_owned(), console_artifact) - } - - fn run_network( - workspace: &Path, - network_url: &str, - filename: &str, - explicit_off: bool, - ) -> (String, String) { - let workspace_text = workspace.to_string_lossy(); - let environment = environment(&workspace_text, explicit_off); - let artifact_path = workspace.join(filename); - assert!( - !artifact_path.exists(), - "network export path must be unused before the production write" - ); - let mut server = ServerProcess::spawn_with_env(&environment); - server.initialize_as("offline-w4-off-fixture"); - let _ = call_tool( - &mut server, - 972, - "browser_navigate", - json!({"url": network_url}), - ); - let deadline = Instant::now() + Duration::from_secs(10); - let network = loop { - let response = call_tool(&mut server, 975, "browser_network_requests", json!({})); - if result_text(&response).contains("off-network.html") { - break response; - } - assert!( - Instant::now() < deadline, - "network records did not converge" - ); - thread::sleep(Duration::from_millis(25)); - }; - let network_written = call_tool( - &mut server, - 976, - "browser_network_requests", - json!({"filename": filename}), - ); - assert!(result_text(&network_written).contains("Network requests written")); - let network_artifact = fs::read_to_string(&artifact_path).unwrap(); - server.finish(); - (result_text(&network).to_owned(), network_artifact) - } - - let mut export_workspaces = HashSet::new(); - let mut reserve_workspace = |configuration: &str, phase: &str| { - let path = workspace.join(format!("export-{configuration}-{phase}")); - assert!( - export_workspaces.insert(path.clone()), - "each configuration/phase pair must reserve a unique workspace" - ); - assert!( - !path.exists(), - "reserved export workspace must not pre-exist" - ); - fs::create_dir(&path).expect("create isolated W4 export workspace"); - path - }; - let absent_console_workspace = reserve_workspace("absent", "console"); - let absent_network_workspace = reserve_workspace("absent", "network"); - let off_console_workspace = reserve_workspace("explicit-off", "console"); - let off_network_workspace = reserve_workspace("explicit-off", "network"); - assert_eq!(export_workspaces.len(), 4); - - let absent_console = run_console( - &absent_console_workspace, - &console_url, - "absent-console-export.txt", - false, - ); - let absent_network = run_network( - &absent_network_workspace, - &network_url, - "absent-network-export.txt", - false, - ); - let explicit_off_console = run_console( - &off_console_workspace, - &console_url, - "explicit-off-console-export.txt", - true, - ); - let explicit_off_network = run_network( - &off_network_workspace, - &network_url, - "explicit-off-network-export.txt", - true, - ); - let absent = ( - absent_console.0, - absent_network.0, - absent_console.1, - absent_network.1, - ); - let explicit_off = ( - explicit_off_console.0, - explicit_off_network.0, - explicit_off_console.1, - explicit_off_network.1, - ); - assert_eq!(absent, explicit_off); - assert_eq!(absent.0.matches("W4 off duplicate").count(), 2); - assert!(!absent.0.contains("(repeated")); - assert!(!absent.1.contains("static requests hidden")); - - for export_workspace in export_workspaces { - let entries = fs::read_dir(&export_workspace) - .expect("read isolated W4 export workspace") - .collect::, _>>() - .expect("collect isolated W4 export artifacts"); - assert_eq!(entries.len(), 1, "each phase writes exactly one artifact"); - fs::remove_file(entries[0].path()).expect("remove isolated W4 export artifact"); - fs::remove_dir(export_workspace).expect("remove isolated W4 export workspace"); - } - for filename in ["off-console.html", "off-network.html", "off-static.svg"] { - fs::remove_file(workspace.join(filename)).expect("remove W4 off-switch artifact"); - } - fs::remove_dir(workspace).expect("remove W4 off-switch workspace"); -} - -#[test] -fn isolated_process_parses_config_environment_and_warns_once_per_invalid_value() { +fn isolated_process_validates_response_dimensions() { let mut server = ServerProcess::spawn_with_env(&[ - ("RUSTWRIGHT_MCP_BUDGET", "invalid"), - ("RUSTWRIGHT_MCP_DISTILL", "ON"), - ("RUSTWRIGHT_MCP_CONSOLE_DEDUP", "invalid"), - ("RUSTWRIGHT_MCP_NET_NOTE", "yes"), - ("RUSTWRIGHT_MCP_LEAN_DESCRIPTIONS", "invalid"), ("RUSTWRIGHT_MCP_MAX_RESPONSE_BYTES", "4095"), ("RUSTWRIGHT_MCP_MAX_RESPONSE_LINES", "15"), ]); @@ -4571,14 +4262,10 @@ fn isolated_process_parses_config_environment_and_warns_once_per_invalid_value() let listed = server.receive(); assert_eq!( listed["result"]["tools"][0]["description"], - "Navigate the browser and return a compact page snapshot." + "Navigate; snapshot." ); let (_, diagnostics) = server.finish(); for variable in [ - "RUSTWRIGHT_MCP_BUDGET", - "RUSTWRIGHT_MCP_CONSOLE_DEDUP", - "RUSTWRIGHT_MCP_NET_NOTE", - "RUSTWRIGHT_MCP_LEAN_DESCRIPTIONS", "RUSTWRIGHT_MCP_MAX_RESPONSE_BYTES", "RUSTWRIGHT_MCP_MAX_RESPONSE_LINES", ] { @@ -4589,7 +4276,6 @@ fn isolated_process_parses_config_environment_and_warns_once_per_invalid_value() 1 ); } - assert!(!diagnostics.contains("variable=RUSTWRIGHT_MCP_DISTILL")); fn config_diagnostics(environment: &[(&str, &str)]) -> String { let mut server = ServerProcess::spawn_with_env(environment); @@ -4598,8 +4284,6 @@ fn isolated_process_parses_config_environment_and_warns_once_per_invalid_value() } for (variable, value) in [ - ("RUSTWRIGHT_MCP_HEADER", "on"), - ("RUSTWRIGHT_MCP_DISTILL", "off"), ("RUSTWRIGHT_MCP_MAX_RESPONSE_BYTES", "0"), ("RUSTWRIGHT_MCP_MAX_RESPONSE_BYTES", "4096"), ("RUSTWRIGHT_MCP_MAX_RESPONSE_LINES", "0"), @@ -4611,25 +4295,10 @@ fn isolated_process_parses_config_environment_and_warns_once_per_invalid_value() "valid subprocess value {variable}={value} warned: {diagnostics}" ); } - for (variable, value) in [ - ("RUSTWRIGHT_MCP_HEADER", "invalid"), - ("RUSTWRIGHT_MCP_DISTILL", "invalid"), - ("RUSTWRIGHT_MCP_MAX_RESPONSE_BYTES", "invalid"), - ("RUSTWRIGHT_MCP_MAX_RESPONSE_LINES", "invalid"), - ] { - let diagnostics = config_diagnostics(&[(variable, value)]); - assert_eq!( - diagnostics - .matches(&format!("variable={variable} ")) - .count(), - 1, - "invalid subprocess value {variable}={value}: {diagnostics}" - ); - } - fn oversized_error_frame(environment: &[(&str, &str)], client_name: &str) -> (usize, String) { + fn oversized_error_frame(environment: &[(&str, &str)]) -> (usize, String) { let mut server = ServerProcess::spawn_with_env(environment); - server.initialize_as(client_name); + server.initialize_as("unknown-hermetic-client"); server.send(json!({ "jsonrpc":"2.0","id":77,"method":"tools/call", "params":{"name":"unknown_tool".repeat(3000),"arguments":{}} @@ -4639,36 +4308,24 @@ fn isolated_process_parses_config_environment_and_warns_once_per_invalid_value() (frame_bytes, diagnostics) } - let (disabled_bytes, disabled_diagnostics) = oversized_error_frame( - &[ - ("RUSTWRIGHT_MCP_BUDGET", "on"), - ("RUSTWRIGHT_MCP_MAX_RESPONSE_BYTES", "0"), - ("RUSTWRIGHT_MCP_MAX_RESPONSE_LINES", "0"), - ], - "unknown-hermetic-client", - ); + let (disabled_bytes, disabled_diagnostics) = oversized_error_frame(&[ + ("RUSTWRIGHT_MCP_MAX_RESPONSE_BYTES", "0"), + ("RUSTWRIGHT_MCP_MAX_RESPONSE_LINES", "0"), + ]); assert!(disabled_bytes > 9 * 1024); assert!(!disabled_diagnostics.contains("invalid_or_too_small")); - let (minimum_bytes, minimum_diagnostics) = oversized_error_frame( - &[ - ("RUSTWRIGHT_MCP_BUDGET", "on"), - ("RUSTWRIGHT_MCP_MAX_RESPONSE_BYTES", "4096"), - ("RUSTWRIGHT_MCP_MAX_RESPONSE_LINES", "16"), - ], - "unknown-hermetic-client", - ); + let (minimum_bytes, minimum_diagnostics) = oversized_error_frame(&[ + ("RUSTWRIGHT_MCP_MAX_RESPONSE_BYTES", "4096"), + ("RUSTWRIGHT_MCP_MAX_RESPONSE_LINES", "16"), + ]); assert!(minimum_bytes <= 4096); assert!(!minimum_diagnostics.contains("invalid_or_too_small")); - let (fallback_bytes, fallback_diagnostics) = oversized_error_frame( - &[ - ("RUSTWRIGHT_MCP_BUDGET", "on"), - ("RUSTWRIGHT_MCP_MAX_RESPONSE_BYTES", "4095"), - ("RUSTWRIGHT_MCP_MAX_RESPONSE_LINES", "15"), - ], - "codex-mcp-client", - ); + let (fallback_bytes, fallback_diagnostics) = oversized_error_frame(&[ + ("RUSTWRIGHT_MCP_MAX_RESPONSE_BYTES", "4095"), + ("RUSTWRIGHT_MCP_MAX_RESPONSE_LINES", "15"), + ]); assert!(fallback_bytes <= 9 * 1024); assert_eq!( fallback_diagnostics.matches("invalid_or_too_small").count(), diff --git a/mcp/tests/validation_p1b_remote.rs b/mcp/tests/validation_p1b_remote.rs index 5aaebcc..bd59047 100644 --- a/mcp/tests/validation_p1b_remote.rs +++ b/mcp/tests/validation_p1b_remote.rs @@ -181,12 +181,6 @@ impl ServerProcess { .env_remove("RUSTWRIGHT_MCP_CDP_TIMEOUT_MS") .env_remove("RUSTWRIGHT_MCP_MAX_RESPONSE_BYTES") .env_remove("RUSTWRIGHT_MCP_MAX_RESPONSE_LINES") - .env_remove("RUSTWRIGHT_MCP_BUDGET") - .env_remove("RUSTWRIGHT_MCP_DISTILL") - .env_remove("RUSTWRIGHT_MCP_HEADER") - .env_remove("RUSTWRIGHT_MCP_CONSOLE_DEDUP") - .env_remove("RUSTWRIGHT_MCP_NET_NOTE") - .env_remove("RUSTWRIGHT_MCP_LEAN_DESCRIPTIONS") .env("RUSTWRIGHT_MCP_CDP_ENDPOINT", endpoint) .env("RUSTWRIGHT_MCP_CDP_HEADERS", headers.to_string()) .env("RUSTWRIGHT_MCP_CDP_TIMEOUT_MS", "10000") @@ -324,13 +318,25 @@ fn result_text(message: &Value) -> &str { } fn button_ref(snapshot: &str) -> String { - let line = snapshot - .lines() - .find(|line| line.contains("- button") && line.contains("Validate action")) - .unwrap_or_else(|| panic!("validation button absent from snapshot:\n{snapshot}")); - let start = line.find("[ref=").expect("validation button ref start") + 5; - let end = line[start..].find(']').expect("validation button ref end") + start; - line[start..end].to_owned() + let lines = snapshot.lines().collect::>(); + for pair in lines.windows(2) { + let button = pair[0]; + let child = pair[1]; + let button_indent = button.len() - button.trim_start().len(); + let child_indent = child.len() - child.trim_start().len(); + if !button.trim_start().starts_with("- button ") + || child_indent <= button_indent + || child.trim_start() != "- text: Validate action" + { + continue; + } + let reference = button + .split_ascii_whitespace() + .find_map(|field| field.strip_prefix("[ref=")?.strip_suffix(']')) + .expect("validation button ref"); + return reference.to_owned(); + } + panic!("validation button absent from snapshot:\n{snapshot}"); } #[test] diff --git a/python/rustwright/__init__.py b/python/rustwright/__init__.py index ab21afd..15a36d4 100644 --- a/python/rustwright/__init__.py +++ b/python/rustwright/__init__.py @@ -1,6 +1,6 @@ """Rust-backed browser automation with a Playwright-shaped Python API.""" -from ._compat import disable_playwright_compat, enable_playwright_compat +from ._compat import enable_playwright_compat from .sync_api import ( Browser, BrowserContext, @@ -49,7 +49,6 @@ "UnknownOutcomeError", "Video", "backend_marker", - "disable_playwright_compat", "enable_playwright_compat", "expect", "async_playwright", diff --git a/python/rustwright/_async_generated.py b/python/rustwright/_async_generated.py index 2870aa0..0997493 100644 --- a/python/rustwright/_async_generated.py +++ b/python/rustwright/_async_generated.py @@ -1,5 +1,5 @@ # This file is generated by tools/generate_async_api.py. Do not edit. -# sync_api.py sha256: 815282b57f5b5c4b11fc2ce5671f1ab53b79f99a597e2814c1c9043b9e7acbfe +# sync_api.py sha256: 96400fc6ad22ef6c66648b17602e2b8c6edc1afefda0d437409b7e9641c50657 from __future__ import annotations from pathlib import Path diff --git a/python/rustwright/_compat/__init__.py b/python/rustwright/_compat/__init__.py index ae4c9ab..1d3a988 100644 --- a/python/rustwright/_compat/__init__.py +++ b/python/rustwright/_compat/__init__.py @@ -5,13 +5,13 @@ the function again after pytest becomes importable to add them. Pytest users normally enable compatibility inside a process where pytest is importable. -Target imports happen before alias publication. If enable fails, canonical +Alias publication is transactional. If enable fails, canonical ``rustwright._compat.*`` modules and pytest imported during that phase stay in -``sys.modules``. Complete rollback covers only legacy alias entries and their -parent attributes; removing canonical imports could disturb unrelated users. +``sys.modules``. Published legacy aliases and parent attributes return to their +prior state. -Do not enable or disable compatibility concurrently with in-flight imports of -aliased names. Direct ``sys.modules`` aliasing cannot make those imports atomic. +Do not enable compatibility concurrently with in-flight imports of aliased +names. Direct ``sys.modules`` aliasing cannot make those imports atomic. """ from __future__ import annotations @@ -53,8 +53,6 @@ ) _MISSING = object() -_PREVIOUS_MODULES: dict[str, object] = {} -_PREVIOUS_PARENT_ATTRIBUTES: dict[str, tuple[ModuleType, object]] = {} _STATE_LOCK = RLock() _ENABLED = False _PYTEST_ALIASES_ENABLED = False @@ -122,10 +120,9 @@ def enable_playwright_compat() -> PlaywrightCompatEnableResult: Calling again after pytest becomes importable upgrades an enabled core-only state with the pytest aliases. - Target imports are outside the rollback boundary. Successfully imported - canonical compatibility modules, including pytest dependencies, remain in - ``sys.modules`` if enable later fails. Legacy alias entries and their parent - attributes are the complete transactional publication boundary. + Canonical compatibility modules, including pytest dependencies, remain in + ``sys.modules`` if enable fails. Published legacy aliases and parent + attributes return to their prior state. """ global _ENABLED, _LAST_ENABLE_RESULT, _PYTEST_ALIASES_ENABLED @@ -162,10 +159,14 @@ def enable_playwright_compat() -> PlaywrightCompatEnableResult: _restore_aliases(previous_modules, previous_parent_attributes) raise - _PREVIOUS_MODULES.update(previous_modules) - _PREVIOUS_PARENT_ATTRIBUTES.update(previous_parent_attributes) _ENABLED = True _PYTEST_ALIASES_ENABLED = _PYTEST_ALIASES_ENABLED or pytest_available + registered_aliases = tuple( + alias_name + for alias_name, _target_name in ( + _CORE_ALIASES + (_PYTEST_ALIASES if _PYTEST_ALIASES_ENABLED else ()) + ) + ) skipped_aliases = ( () if _PYTEST_ALIASES_ENABLED @@ -173,30 +174,14 @@ def enable_playwright_compat() -> PlaywrightCompatEnableResult: ) _LAST_ENABLE_RESULT = PlaywrightCompatEnableResult( True, - tuple(_PREVIOUS_MODULES), + registered_aliases, skipped_aliases, ) return _LAST_ENABLE_RESULT -def disable_playwright_compat() -> None: - """Restore modules and parent attributes replaced by compatibility.""" - - global _ENABLED, _LAST_ENABLE_RESULT, _PYTEST_ALIASES_ENABLED - - with _STATE_LOCK: - if not _ENABLED: - return - _restore_aliases(_PREVIOUS_MODULES, _PREVIOUS_PARENT_ATTRIBUTES) - _PREVIOUS_MODULES.clear() - _PREVIOUS_PARENT_ATTRIBUTES.clear() - _ENABLED = False - _PYTEST_ALIASES_ENABLED = False - _LAST_ENABLE_RESULT = PlaywrightCompatEnableResult(False, (), ()) - __all__ = [ "PlaywrightCompatEnableResult", - "disable_playwright_compat", "enable_playwright_compat", ] diff --git a/python/rustwright/async_api.py b/python/rustwright/async_api.py index 0e8bb31..013b6f1 100644 --- a/python/rustwright/async_api.py +++ b/python/rustwright/async_api.py @@ -75,6 +75,9 @@ _default_timeout_for_method, _emit_event, _event_handler_positional_args, + _EVENT_DISPATCH_OWNER, + _EVENT_DISPATCH_REGISTRATION, + _EVENT_DISPATCH_SEQUENCE, _json, _is_ignorable_close_error, _navigation_timeout_for_method, @@ -89,8 +92,9 @@ _options_from_explicit_kwargs, _response_from_payload, _translate_error, - _unsafe_dom_fastpath_enabled, _validate_timeout_value, + _CONSOLE_HISTORY_BUFFER, + _PAGE_ERROR_HISTORY_BUFFER, ) from .sync_api import sync_playwright as _sync_playwright from ._async_generated import ( @@ -1237,8 +1241,8 @@ def _wrap_async_event_value(event: str, value: Any) -> Any: def _wrap_async_event_predicate(event: str, predicate: Any = None, owner: Any = None) -> Any: - if predicate is None: - return None + if predicate is None or not callable(predicate): + return predicate owner_loop = getattr(owner, "_loop", None) @@ -1255,8 +1259,6 @@ def _wrap_async_url_or_predicate(event: str, url_or_predicate: Any, owner: Any = if callable(url_or_predicate): return _wrap_async_event_predicate(event, url_or_predicate, owner) return url_or_predicate - - def _wrap_async_event_handler(owner: Any, event: str, handler: Any) -> Any: wrappers = getattr(owner, "_event_handler_wrappers", None) if wrappers is None: @@ -1267,10 +1269,90 @@ def _wrap_async_event_handler(owner: Any, event: str, handler: Any) -> Any: return wrapped def wrapper(*args: Any) -> None: + sync_owner = getattr(owner, "_sync", None) + def call_handler() -> Any: mapped_args = tuple(_wrap_async_event_value(event, arg) for arg in args) return handler(*_event_handler_positional_args(handler, mapped_args)) + def run_awaitable_with_state(result: Any, state: Any, loop: asyncio.AbstractEventLoop) -> None: + async def await_result() -> Any: + task = asyncio.current_task() + if state is not None: + activate = getattr(sync_owner, "_activate_deferred_event_handler", None) + if not callable(activate) or not activate(state, task): + if inspect.iscoroutine(result): + result.close() + return None + owner_token = _EVENT_DISPATCH_OWNER.set(task) + try: + return await result + finally: + _EVENT_DISPATCH_OWNER.reset(owner_token) + if state is not None: + finish = getattr(sync_owner, "_finish_event_handler", None) + if callable(finish): + finish(state, task) + + loop.create_task(await_result()) + + def invoke_on_loop(callback_owner: Any, state: Any) -> None: + owner_token = _EVENT_DISPATCH_OWNER.set(callback_owner) + active = False + deferred = False + try: + if state is not None: + activate = getattr(sync_owner, "_activate_deferred_event_handler", None) + if not callable(activate) or not activate(state, callback_owner): + return + active = True + result = call_handler() + if _should_await_callback_result(result): + if state is not None: + deferred_state = getattr(sync_owner, "_defer_current_event_handler", lambda: None)() + if deferred_state is None: + if inspect.iscoroutine(result): + result.close() + return + deferred = True + try: + run_awaitable_with_state(result, deferred_state, loop) + except BaseException: + release = getattr(sync_owner, "_release_deferred_event_handler", None) + if callable(release): + release(deferred_state) + raise + else: + _run_awaitable_on_loop(loop, result) + finally: + if active and not deferred: + finish = getattr(sync_owner, "_finish_event_handler", None) + if callable(finish): + finish(state, callback_owner) + _EVENT_DISPATCH_OWNER.reset(owner_token) + + def invoke_in_current_loop() -> None: + result = call_handler() + if not _should_await_callback_result(result): + return + state = _EVENT_DISPATCH_REGISTRATION.get() + defer = getattr(sync_owner, "_defer_current_event_handler", None) + deferred_state = defer() if callable(defer) else None + if state is not None and deferred_state is None: + if inspect.iscoroutine(result): + result.close() + return + if deferred_state is not None: + try: + run_awaitable_with_state(result, deferred_state, loop) + except BaseException: + release = getattr(sync_owner, "_release_deferred_event_handler", None) + if callable(release): + release(deferred_state) + raise + else: + _run_awaitable_on_loop(loop, result) + loop = getattr(owner, "_loop", None) if loop is not None and loop.is_running(): try: @@ -1278,19 +1360,24 @@ def call_handler() -> Any: except RuntimeError: current_loop = None if current_loop is loop: - result = call_handler() - _run_awaitable_on_loop(loop, result) + invoke_in_current_loop() return + defer = getattr(sync_owner, "_defer_current_event_handler", None) + state = defer() if callable(defer) else None + callback_context = contextvars.copy_context() + def run_on_loop() -> None: - try: - result = call_handler() - if _should_await_callback_result(result): - loop.create_task(result) - except BaseException: - pass + callback_context.run(invoke_on_loop, object(), state) - loop.call_soon_threadsafe(run_on_loop) + try: + loop.call_soon_threadsafe(run_on_loop) + except BaseException: + if state is not None: + release = getattr(sync_owner, "_release_deferred_event_handler", None) + if callable(release): + release(state) + raise return result = call_handler() @@ -2044,14 +2131,17 @@ async def new_page( and not options and not self._sync._launch_proxy and not self._sync._launch_downloads_path + and not self._sync._browser_download_behavior + and not bool(self._sync._core.single_process_fallback()) ): - context = SyncBrowserContext(None, browser=self._sync, options={}) + core = await _await_native(self._sync._core.new_context_async(None)) + context = SyncBrowserContext(core, browser=self._sync, options={}) self._sync._contexts.append(context) try: - core = await _await_native(self._sync._core.new_page_async()) - page = await _finish_native_page(context, core) + page_core = await _await_native(core.new_page_async()) + page = await _finish_native_page(context, page_core) except BaseException: - self._sync._contexts.remove(context) + await _run_sync_call(context.close) raise page._owns_context = True return _wrap_async_page(page) @@ -2389,10 +2479,14 @@ async def _consume_event_batch(self, batch: list[Any]) -> bool: envelope.get("payload"), ) continue + event_sequence = envelope.get("seq") + if isinstance(event_sequence, bool) or not isinstance(event_sequence, int): + event_sequence = None await _run_sync_call( self._sync._handle_observation_event, kind, envelope.get("payload"), + event_sequence=event_sequence, ) if not self._sync._event_listeners_active(): break @@ -2564,8 +2658,8 @@ async def goto( ) target = self._sync._resolve_url(target) self._sync._mark_request_cookie_sync_required() - await _run_sync_call(self._sync._retain_navigation_response_bodies) - await _run_sync_call(self._sync._mark_navigation_history_boundary) + before, _ = await _run_sync_call(self._sync._prepare_navigation) + await _run_sync_call(self._sync._mark_navigation_history_boundary, before) self._sync._set_content_html_document_known = None download_waiter = ( await _run_sync_call(self._sync._download_event_waiter) @@ -2748,17 +2842,6 @@ async def click( normalized_selector = _native_normalize_selector(selector, method="Page.click") timeout_ms = _default_timeout_for_method(self._sync, timeout, method="Page.click") locator = _native_selector_locator(self._sync, normalized_selector, strict, method="Page.click") - if _unsafe_dom_fastpath_enabled(): - await _await_native_method( - "Page.click", - self._sync._core.click_async( - _json(locator._spec), - locator._index, - timeout_ms, - locator._strict, - ), - ) - return point_info = await _await_native_action( "Page.click", self._sync._core.click_actionable_wait_async( @@ -2778,18 +2861,22 @@ async def click( mouse._x = target_x mouse._y = target_y mouse._buttons &= ~1 - await _await_native_action( - "Page.click", - self._sync._core.dispatch_mouse_click_async( - target_x, - target_y, - start_x, - start_y, - initial_buttons, - modifiers, - float(point_info[2]), - ), - ) + dialog_operation_release = self._sync._begin_dialog_operation() + try: + await _await_native_action( + "Page.click", + self._sync._core.dispatch_mouse_click_async( + target_x, + target_y, + start_x, + start_y, + initial_buttons, + modifiers, + float(point_info[2]), + ), + ) + finally: + dialog_operation_release() async def fill( self, @@ -2807,18 +2894,6 @@ async def fill( or no_wait_after is not None or force is not None ) - if sync_fallback and _unsafe_dom_fastpath_enabled(): - await _run_sync_wait_sliced( - self._sync, - self._sync.fill, - selector, - value, - timeout=timeout, - no_wait_after=no_wait_after, - strict=strict, - force=force, - ) - return normalized_selector = _native_normalize_selector(selector, method="Page.fill") normalized_value = _normalize_required_string_argument( value, @@ -2828,18 +2903,6 @@ async def fill( ) timeout_ms = _default_timeout_for_method(self._sync, timeout, method="Page.fill") locator = _native_selector_locator(self._sync, normalized_selector, strict, method="Page.fill") - if _unsafe_dom_fastpath_enabled(): - await _await_native_method( - "Page.fill", - self._sync._core.fill_async( - _json(locator._spec), - locator._index, - normalized_value, - timeout_ms, - locator._strict, - ), - ) - return if sync_fallback: # Cancelling an executor Future does not stop its running sync call. Carry # cancellation into Rust so the fill future—and its guard owner—is dropped. diff --git a/python/rustwright/sync_api.py b/python/rustwright/sync_api.py index b4f24c3..d34d35e 100644 --- a/python/rustwright/sync_api.py +++ b/python/rustwright/sync_api.py @@ -1,6 +1,7 @@ from __future__ import annotations import base64 +import contextvars import collections.abc import copy import functools @@ -50,7 +51,6 @@ _CONSOLE_HISTORY_SETTLE_TIMEOUT_SECONDS = 0.001 _CONSOLE_HISTORY_BUFFER = "__console_history__" _PAGE_ERROR_HISTORY_BUFFER = "__page_error_history__" -_UNSAFE_DOM_FASTPATH_ENV = "RUSTWRIGHT_UNSAFE_DOM_FASTPATH" _MULTIPART_BOUNDARY_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789AB" _CHECKED_STATE_JS = """(el) => { const tagName = String(el && el.tagName || '').toUpperCase(); @@ -2818,9 +2818,6 @@ def _json(value: Dict[str, Any]) -> str: return json.dumps(value, separators=(",", ":")) -def _unsafe_dom_fastpath_enabled() -> bool: - value = os.environ.get(_UNSAFE_DOM_FASTPATH_ENV, "") - return value.strip().lower() in {"1", "true", "yes", "on"} def _normalize_nth_index(index: Any) -> Any: @@ -3762,6 +3759,47 @@ def _invalid_role_level(role: Any, symbol: str, *, value: Any = None, selector: return {"kind": "invalid_role_level", "symbol": symbol, "selector": selector} +_EVENT_DISPATCH_SEQUENCE: contextvars.ContextVar[ + Optional[tuple[Any, Any]] +] = contextvars.ContextVar( + "rustwright_event_dispatch_sequence", + default=None, +) + +def _running_asyncio_loop_and_task() -> tuple[Any, Any]: + import asyncio + + try: + loop = asyncio.get_running_loop() + return loop, asyncio.current_task(loop=loop) + except RuntimeError: + return None, None + +_EVENT_DISPATCH_OWNER: contextvars.ContextVar[Any] = contextvars.ContextVar( + "rustwright_event_dispatch_owner", + default=None, +) +_EVENT_DISPATCH_REGISTRATION: contextvars.ContextVar[Any] = contextvars.ContextVar( + "rustwright_event_dispatch_registration", + default=None, +) +_EVENT_DISPATCH_DEFERRED: contextvars.ContextVar[bool] = contextvars.ContextVar( + "rustwright_event_dispatch_deferred", + default=False, +) + + +class _EventHandlerRegistration: + __slots__ = ("event", "handler", "pending", "executing", "cancelled") + + def __init__(self, event: str, handler: Callable[..., Any]) -> None: + self.event = event + self.handler = handler + self.pending = 0 + self.executing: dict[Any, int] = {} + self.cancelled = False + + class _EventEmitter: def _event_handlers_for_emitter(self) -> dict[str, list[Callable[..., Any]]]: handlers = getattr(self, "_event_handlers", None) @@ -3771,6 +3809,7 @@ def _event_handlers_for_emitter(self) -> dict[str, list[Callable[..., Any]]]: setattr(self, "_event_handlers", handlers) return handlers + def on(self, event: str, f: Callable[..., Any]) -> None: _add_listener_to_handlers(self, event, f, self._event_handlers_for_emitter()) @@ -3782,21 +3821,99 @@ def remove_listener(self, event: str, f: Callable[..., Any]) -> None: def _emit_local_event(self, event: str, *args: Any) -> None: _emit_event(self._event_handlers_for_emitter(), event, *args) +_EVENT_DIALOG_DISPATCH: contextvars.ContextVar[Any] = contextvars.ContextVar( + "rustwright_event_dialog_dispatch", + default=None, +) -def _register_once_listener(owner: Any, event: str, f: Callable[..., Any]) -> None: - owner.remove_listener(event, f) +class _DialogDispatch: + __slots__ = ( + "page", + "dialog", + "remaining", + "finished", + "captured", + "handled", + "fallback_started", + "lock", + ) - def wrapper(*args: Any, **kwargs: Any) -> Any: - owner.remove_listener(event, wrapper) - return f(*args, **kwargs) + def __init__(self, page: Any, dialog: Any, remaining: int) -> None: + self.page = page + self.dialog = dialog + self.remaining = remaining + self.finished = False + self.captured = False + self.handled = False + self.fallback_started = False + self.lock = threading.Lock() + + def capture(self) -> None: + with self.lock: + self.captured = True + + def mark_handled(self) -> None: + with self.lock: + self.handled = True + self.page._forget_dialog_dispatch(self) + + def settle(self) -> None: + finished = False + with self.lock: + if self.finished: + return + self.remaining = max(self.remaining - 1, 0) + if self.remaining == 0: + self.finished = True + finished = True + if finished: + self.page._dialog_dispatch_finished(self) + + def finish_if_idle(self) -> None: + finished = False + with self.lock: + if self.remaining == 0 and not self.finished: + self.finished = True + finished = True + if finished: + self.page._dialog_dispatch_finished(self) + + def maybe_fallback(self) -> None: + with self.lock: + if ( + not self.finished + or self.captured + or self.handled + or self.fallback_started + ): + return + if self.page._dialog_fallback_suppressed(self.dialog): + return + self.fallback_started = True + self.page._dismiss_dialog_if_unhandled(self.dialog) - owner.on(event, wrapper) - wrappers = getattr(owner, "_once_event_wrappers", None) - if not isinstance(wrappers, list): - wrappers = [] - setattr(owner, "_once_event_wrappers", wrappers) - wrappers.append((event, f, wrapper)) +def _register_once_listener(owner: Any, event: str, f: Callable[..., Any]) -> None: + def register() -> None: + owner.remove_listener(event, f) + + def wrapper(*args: Any, **kwargs: Any) -> Any: + owner.remove_listener(event, wrapper) + return f(*args, **kwargs) + + owner.on(event, wrapper) + wrappers = getattr(owner, "_once_event_wrappers", None) + if not isinstance(wrappers, list): + wrappers = [] + setattr(owner, "_once_event_wrappers", wrappers) + wrappers.append((event, f, wrapper)) + + dispatch_lock = getattr(owner, "_event_dispatch_lock", None) + if dispatch_lock is None: + register() + else: + with dispatch_lock: + register() def _pop_once_listener_wrapper(owner: Any, event: str, f: Callable[..., Any]) -> Optional[Callable[..., Any]]: @@ -4993,6 +5110,8 @@ def _wait_for_ready_event_or_owner_close( event: str, timeout_ms: Optional[float], timeout_display: str, + *, + deadline: Any = _UNSET, ) -> None: if owner is not None and threading.current_thread() in _owner_event_pump_threads(owner): # This wait's event is delivered by one of the owner's event pump @@ -5010,7 +5129,8 @@ def _wait_for_ready_event_or_owner_close( ) if owner is not None: _prepare_owner_event_rejection(owner) - deadline = None if timeout_ms is None else time.monotonic() + (timeout_ms / 1000) + if deadline is _UNSET: + deadline = None if timeout_ms is None else time.monotonic() + (timeout_ms / 1000) while True: if owner is not None: _raise_if_owner_unavailable(owner, use_close_reason=True) @@ -6435,6 +6555,20 @@ def header_values(self, name: str) -> list[str]: def headers_array(self) -> list[dict[str, str]]: return list(self._headers_array_cache) + def _set_body_from_payload(self, payload: Any) -> None: + if not isinstance(payload, dict): + return + body = payload.get("body", "") + if payload.get("base64Encoded"): + self._body_cache = base64.b64decode(body) + else: + self._body_cache = str(body).encode("utf-8") + if self.request is not None: + self.request._sizes = { + **(self.request._sizes or {}), + "responseBodySize": len(self._body_cache), + } + def _cache_body(self, timeout_ms: Optional[float] = None) -> bytes: if self._body_cache is not None: return self._body_cache @@ -6452,16 +6586,7 @@ def _cache_body(self, timeout_ms: Optional[float] = None) -> bytes: self._page._default_timeout if timeout_ms is None else timeout_ms, ) ) - body = payload.get("body", "") - if payload.get("base64Encoded"): - self._body_cache = base64.b64decode(body) - else: - self._body_cache = str(body).encode("utf-8") - if self.request is not None: - self.request._sizes = { - **(self.request._sizes or {}), - "responseBodySize": len(self._body_cache), - } + self._set_body_from_payload(payload) return self._body_cache def body(self) -> bytes: @@ -7991,6 +8116,16 @@ def _console_event_matches(matcher: Any, event: "ConsoleMessage") -> bool: return True if callable(matcher): return bool(matcher(event)) + if isinstance(matcher, dict): + native_matcher = _native_event_matcher("console", matcher) + if native_matcher is not None: + event_type = native_matcher["event_type"] + text = native_matcher["text"] + if event_type is not None and event.type != event_type: + return False + if text is None: + return True + return event.text == text if native_matcher["exact_text"] else text in event.text return str(matcher) in event.text @@ -8050,6 +8185,16 @@ def _page_error_event_matches(matcher: Any, event: Error) -> bool: return True if callable(matcher): return bool(matcher(event)) + if isinstance(matcher, dict): + native_matcher = _native_event_matcher("pageerror", matcher) + if native_matcher is not None: + event_type = native_matcher["event_type"] + text = native_matcher["text"] + if event_type is not None and getattr(event, "name", "Error") != event_type: + return False + if text is None: + return True + return str(event) == text if native_matcher["exact_text"] else text in str(event) return str(matcher) in str(event) @@ -8107,9 +8252,23 @@ def _event_handler_positional_args(handler: Callable[..., Any], args: tuple[Any, return args -def _emit_event(handlers: dict[str, list[Callable[..., Any]]], event: str, *args: Any) -> None: +def _emit_event( + handlers: dict[str, list[Callable[..., Any]]], + event: str, + *args: Any, + isolate_exceptions: bool = False, +) -> None: + first_error: Optional[BaseException] = None for handler in list(handlers.get(event, [])): - handler(*_event_handler_positional_args(handler, args)) + try: + handler(*_event_handler_positional_args(handler, args)) + except BaseException as exc: + if not isolate_exceptions: + raise + if first_error is None: + first_error = exc + if first_error is not None: + raise first_error _DEFAULT_EVENT_TIMEOUT_MESSAGE = 'Timeout {timeout}ms exceeded while waiting for event "{event}"' @@ -8129,6 +8288,60 @@ def _callable_event_matches(_target: Any, matcher: Any, value: Any) -> bool: return matcher is None or bool(matcher(value)) +def _event_matcher_kind(kind: str) -> str: + return kind.rsplit(".", 1)[-1] + + +def _validate_event_matcher(kind: str, matcher: Any, *, method: str) -> None: + if ( + _event_matcher_kind(kind) not in {"console", "pageerror"} + or matcher is None + or callable(matcher) + or isinstance(matcher, str) + ): + return + if not isinstance(matcher, dict): + return + allowed = {"text", "event_type", "type", "contains", "exact"} + for key in matcher: + if key not in allowed: + raise Error(f"{method}: predicate: unexpected key {key!r}") + for key in ("text", "event_type", "type"): + if key in matcher and not isinstance(matcher[key], str): + raise Error( + f"{method}: predicate.{key}: expected string, got {_playwright_type_name(matcher[key])}" + ) + for key in ("contains", "exact"): + if key in matcher and not isinstance(matcher[key], bool): + raise Error( + f"{method}: predicate.{key}: expected boolean, got {_playwright_type_name(matcher[key])}" + ) + if not any(key in matcher for key in ("text", "event_type", "type")): + raise Error(f"{method}: predicate: expected text or event_type") + + +def _native_event_matcher(kind: str, matcher: Any) -> Optional[dict[str, Any]]: + """Return the lossless matcher subset that the Rust waiter can evaluate.""" + if _event_matcher_kind(kind) not in {"console", "pageerror"} or matcher is None or callable(matcher): + return None + if isinstance(matcher, str): + return {"text": matcher, "exact_text": False} + if not isinstance(matcher, dict): + return None + _validate_event_matcher(kind, matcher, method="event matcher") + text = matcher.get("text") + event_type = matcher.get("event_type", matcher.get("type")) + contains = matcher.get("contains", False) + exact = matcher.get("exact", False) + if "contains" in matcher: + exact = not contains + return { + "event_type": event_type, + "text": text, + "exact_text": exact, + } + + def _network_event_from_payload(page: "Page", payload: dict[str, Any], kind: str) -> Request | Response: event: Request | Response if kind == "request": @@ -8170,18 +8383,28 @@ def _network_log_values(page: "Page", state: dict[str, Any], kind: str) -> Itera return () -def _page_console_waiter(page: "Page") -> Any: - waiter = page._core.console_event_waiter() +def _page_console_waiter(page: "Page", timeout_ms: Optional[float] = None) -> Any: + waiter = _call(page._core.console_event_waiter, timeout_ms) page._runtime_observation_enabled = True return waiter def _page_console_from_payload(page: "Page", payload: dict[str, Any]) -> ConsoleMessage: - event = ConsoleMessage(page, payload) + session_id = str(payload.get("session_id") or "") + worker = next( + ( + candidate + for candidate in page._workers.values() + if session_id and candidate._session_id == session_id + ), + None, + ) + event = ConsoleMessage(page, payload, worker=worker) page._record_console_message(event) return event + def _page_error_from_event_payload(page: "Page", payload: dict[str, Any]) -> Error: event = _page_error_from_payload(payload) page._record_page_error(event) @@ -8230,18 +8453,24 @@ def _emit_websocket_value(websocket: "WebSocket", event: str, value: Any) -> Non def _mark_worker_closed(worker: "Worker") -> None: - if worker._closed: - return - worker._closed = True - _emit_event(worker._event_handlers_for_emitter(), "close", worker) + _emit_worker_value(worker, "close", worker) + def _emit_worker_value(worker: "Worker", event: str, value: Any) -> None: if event == "close": - worker._closed = True + with worker._event_thread_lock: + if getattr(worker, "_close_event_fired", False) or worker._closed: + return + worker._close_event_fired = True + worker._closed = True + handlers = list(worker._event_handlers_for_emitter().get(event, [])) + _emit_event({event: handlers}, event, value, isolate_exceptions=True) + return worker._emit_local_event(event, value) + def _enter_page_cdp_event_context(page: "Page") -> None: page._active_page_cdp_event_contexts = getattr(page, "_active_page_cdp_event_contexts", 0) + 1 @@ -8306,7 +8535,7 @@ def _network_event_descriptor(kind: str) -> _EventWaiterDescriptor: waiter_factory=_page_console_waiter, payload_to_value=_page_console_from_payload, matches=lambda _page, matcher, event: _console_event_matches(matcher, event), - context_manager="listener", + context_manager="native", ), "dialog": _EventWaiterDescriptor( event="dialog", @@ -8508,6 +8737,32 @@ def _descriptor_remaining_ms( raise TimeoutError(_descriptor_timeout_message(descriptor, timeout_display)) return remaining +def _create_descriptor_waiter( + target: Any, + descriptor: _EventWaiterDescriptor, + deadline: Optional[float], + timeout_display: str, +) -> Any: + def setup_timeout() -> Optional[float]: + if deadline is None: + return None + return _descriptor_remaining_ms(descriptor, timeout_display, deadline) + + def setup_call(factory: Callable[[Optional[float]], Any]) -> Any: + try: + return factory(setup_timeout()) + except TimeoutError: + raise TimeoutError(_descriptor_timeout_message(descriptor, timeout_display)) from None + + if descriptor.event == "console" and target.__class__.__name__ == "Page": + arm_worker_auto_attach = getattr(target, "_worker_event_waiter", None) + if callable(arm_worker_auto_attach): + setup_call(lambda remaining: arm_worker_auto_attach(timeout_ms=remaining)) + return setup_call(lambda remaining: _page_console_waiter(target, remaining)) + if descriptor.event == "pageerror" and target.__class__.__name__ == "Page": + return setup_call(lambda remaining: target._page_error_event_waiter(remaining)) + return descriptor.waiter_factory(target) + def _wait_for_descriptor_event( target: Any, @@ -8520,16 +8775,83 @@ def _wait_for_descriptor_event( reject_on_close: bool = True, method: Optional[str] = None, state: Optional[dict[str, Any]] = None, + deadline: Any = _UNSET, + timeout_display: Optional[str] = None, +) -> Any: + release = None + dialog_release = None + if ( + waiter is None + and target.__class__.__name__ == "Page" + and kind == "dialog" + ): + acquire = getattr(target, "_acquire_dialog_waiter", None) + if callable(acquire): + dialog_release = acquire() + if ( + waiter is None + and target.__class__.__name__ == "Page" + and kind in {"console", "worker"} + ): + acquire = getattr(target, "_acquire_worker_waiter_interest", None) + if callable(acquire): + release = acquire() + try: + value = _wait_for_descriptor_event_impl( + target, + kind, + matcher, + timeout=timeout, + waiter=waiter, + competing_waiter=competing_waiter, + reject_on_close=reject_on_close, + method=method, + state=state, + deadline=deadline, + timeout_display=timeout_display, + ) + if dialog_release is not None and isinstance(value, Dialog): + target._capture_dialog(value, dialog_release) + dialog_release = None + return value + finally: + if release is not None: + release() + if dialog_release is not None: + dialog_release() + +def _wait_for_descriptor_event_impl( + target: Any, + kind: str, + matcher: Any = None, + *, + timeout: Optional[float] = None, + waiter: Any = None, + competing_waiter: Any = None, + reject_on_close: bool = True, + method: Optional[str] = None, + state: Optional[dict[str, Any]] = None, + deadline: Any = _UNSET, + timeout_display: Optional[str] = None, ) -> Any: """Drive a typed native waiter without duplicating deadline or predicate policy.""" descriptor = _event_waiter_descriptor(kind) - deadline, timeout_display = _event_deadline_for_target( - target, - timeout, - method=method or descriptor.timeout_method, - ) - waiter = waiter or descriptor.waiter_factory(target) + _validate_event_matcher(kind, matcher, method=method or descriptor.timeout_method) + if deadline is _UNSET: + deadline, timeout_display = _event_deadline_for_target( + target, + timeout, + method=method or descriptor.timeout_method, + ) + elif timeout_display is None: + _, timeout_display = _event_timeout_for_target( + target, + timeout, + method=method or descriptor.timeout_method, + ) + if waiter is None: + waiter = _create_descriptor_waiter(target, descriptor, deadline, timeout_display) if descriptor.competing_waiter_factory is not None: competing_waiter = competing_waiter or descriptor.competing_waiter_factory(target) owner = ( @@ -8540,6 +8862,9 @@ def _wait_for_descriptor_event( else None ) event_state = state or {} + native_wait_matching = getattr(waiter, "wait_matching", None) + native_wait = kind in {"console", "pageerror"} and callable(native_wait_matching) + native_matcher = _native_event_matcher(kind, matcher) if native_wait else None while True: if descriptor.buffered_values is not None: for buffered_value in descriptor.buffered_values(target, event_state): @@ -8548,19 +8873,27 @@ def _wait_for_descriptor_event( descriptor.on_match(target, buffered_value) return buffered_value remaining = _descriptor_remaining_ms(descriptor, timeout_display, deadline) - step = _event_wait_step(remaining, owner) - if descriptor.max_wait_ms is not None: - step = min(step, descriptor.max_wait_ms) + if native_wait: + # A disabled Python timeout is represented by a long native wait. The + # native waiter still wakes on its owner and broadcast notifications. + step = remaining if deadline is not None else 24 * 60 * 60 * 1000.0 + else: + step = _event_wait_step(remaining, owner) + if descriptor.max_wait_ms is not None: + step = min(step, descriptor.max_wait_ms) poll_started = time.monotonic() try: - raw_payload = _call(waiter.wait, step) + if native_matcher is not None: + raw_payload = _call(native_wait_matching, step, **native_matcher) + else: + raw_payload = _call(waiter.wait, step) except TimeoutError: if competing_waiter is not None: try: _call(competing_waiter.wait, 1.0) except TimeoutError: if descriptor.recreate_waiter_on_timeout: - waiter = descriptor.waiter_factory(target) + waiter = _create_descriptor_waiter(target, descriptor, deadline, timeout_display) competing_waiter = descriptor.competing_waiter_factory(target) else: if descriptor.on_competing_event is not None: @@ -8570,11 +8903,24 @@ def _wait_for_descriptor_event( _raise_if_owner_unavailable(owner, use_close_reason=True) if deadline is not None and (deadline - time.monotonic()) <= 0: raise TimeoutError(_descriptor_timeout_message(descriptor, timeout_display)) from None + if native_wait: + continue poll_deadline = poll_started + 0.02 if deadline is not None: poll_deadline = min(poll_deadline, deadline) _sleep_until_next_poll(poll_deadline) continue + except Error: + if owner is not None: + try: + _raise_if_owner_unavailable(owner, use_close_reason=True) + except Error: + raise + if getattr(owner, "_closing", False): + close_reason = getattr(owner, "_closed_reason", None) + if close_reason: + raise Error(str(close_reason)) + raise payload = descriptor.decode_payload(raw_payload) if descriptor.payload_filter is not None and not descriptor.payload_filter(target, payload): continue @@ -8583,8 +8929,10 @@ def _wait_for_descriptor_event( if descriptor.on_match is not None: descriptor.on_match(target, value) return value + # Python callables intentionally keep the Python predicate path. The + # same waiter is reused, so a rejected event only costs one next wait. if descriptor.recreate_waiter_on_rejection: - waiter = descriptor.waiter_factory(target) + waiter = _create_descriptor_waiter(target, descriptor, deadline, timeout_display) class _NativeEventContextManager: @@ -8599,52 +8947,112 @@ def __init__(self, target: Any, kind: str, matcher: Any, timeout: Optional[float self._defer_to_local_context = False self._value: Any = None self._complete = False + self._deadline: Optional[float] = None + self._release_dialog_interest: Optional[Callable[[], None]] = None + self._timeout_display = "" + self._release_worker_interest: Optional[Callable[[], None]] = None + + def _release_worker_interest_lease(self) -> None: + release = self._release_worker_interest + self._release_worker_interest = None + if release is not None: + release() + + def _release_dialog_interest_lease(self) -> None: + release = self._release_dialog_interest + self._release_dialog_interest = None + if release is not None: + release() def __enter__(self) -> "_NativeEventContextManager": descriptor = _event_waiter_descriptor(self._kind) + _validate_event_matcher(self._kind, self._matcher, method=descriptor.timeout_method) + self._deadline, self._timeout_display = _event_deadline_for_target( + self._target, + self._timeout, + method=descriptor.timeout_method, + ) if descriptor.local_context_if_owner_closed and _owner_is_closed(self._target): self._defer_to_local_context = True return self if descriptor.on_context_enter is not None: descriptor.on_context_enter(self._target) - if descriptor.context_state_factory is not None: - self._state = descriptor.context_state_factory(self._target) - self._waiter = descriptor.waiter_factory(self._target) - if descriptor.competing_waiter_factory is not None: - self._competing_waiter = descriptor.competing_waiter_factory(self._target) + if self._kind == "dialog" and self._target.__class__.__name__ == "Page": + self._release_dialog_interest = self._target._acquire_dialog_waiter() + if ( + self._target.__class__.__name__ == "Page" + and descriptor.event in {"console", "worker"} + ): + acquire = getattr(self._target, "_acquire_worker_waiter_interest", None) + if callable(acquire): + self._release_worker_interest = acquire() + try: + if descriptor.context_state_factory is not None: + self._state = descriptor.context_state_factory(self._target) + self._waiter = _create_descriptor_waiter( + self._target, + descriptor, + self._deadline, + self._timeout_display, + ) + if descriptor.competing_waiter_factory is not None: + self._competing_waiter = descriptor.competing_waiter_factory(self._target) + except BaseException: + self._release_worker_interest_lease() + self._release_dialog_interest_lease() + raise return self def __exit__(self, exc_type, exc, tb) -> None: descriptor = _event_waiter_descriptor(self._kind) if self._defer_to_local_context: + try: + if exc_type is None: + local_context = _LocalEventContextManager( + self._target, + descriptor.event, + self._matcher, + self._timeout, + ) + local_context.__enter__() + local_context.__exit__(exc_type, exc, tb) + self._value = local_context.value + self._complete = True + finally: + self._defer_to_local_context = False + self._release_worker_interest_lease() + self._release_dialog_interest_lease() + return + try: + if descriptor.on_context_exit is not None: + descriptor.on_context_exit(self._target) if exc_type is None: - local_context = _LocalEventContextManager( + self._value = _wait_for_descriptor_event( self._target, - descriptor.event, + self._kind, self._matcher, - self._timeout, + timeout=self._timeout, + waiter=self._waiter, + competing_waiter=self._competing_waiter, + state=self._state, + deadline=self._deadline, + timeout_display=self._timeout_display, ) - local_context.__enter__() - local_context.__exit__(exc_type, exc, tb) - self._value = local_context.value + if self._release_dialog_interest is not None: + if isinstance(self._value, Dialog): + self._target._capture_dialog( + self._value, + self._release_dialog_interest, + ) + self._release_dialog_interest = None + else: + self._release_dialog_interest_lease() self._complete = True - self._defer_to_local_context = False - return - if descriptor.on_context_exit is not None: - descriptor.on_context_exit(self._target) - if exc_type is None: - self._value = _wait_for_descriptor_event( - self._target, - self._kind, - self._matcher, - timeout=self._timeout, - waiter=self._waiter, - competing_waiter=self._competing_waiter, - state=self._state, - ) - self._complete = True - self._waiter = None - self._competing_waiter = None + finally: + self._waiter = None + self._competing_waiter = None + self._release_worker_interest_lease() + self._release_dialog_interest_lease() @property def value(self) -> Any: @@ -8760,44 +9168,71 @@ def __init__( self._value: Any = None self._ready = threading.Event() self._handler: Optional[Callable[..., Any]] = None + self._deadline: Optional[float] = None + self._timeout_display = "" + self._dialog_owner_release: Optional[Callable[[], None]] = None def __enter__(self) -> "_ListenerEventContextManager": descriptor = _event_waiter_descriptor(self._kind) + _validate_event_matcher(self._kind, self._matcher, method=descriptor.timeout_method) + self._deadline, self._timeout_display = _event_deadline_for_target( + self._page, + self._timeout, + method=descriptor.timeout_method, + ) + if self._kind == "dialog": + self._dialog_owner_release = self._page._acquire_dialog_waiter() def handler(value: Any) -> None: if self._value is None and descriptor.matches(self._page, self._matcher, value): + owner_release = self._dialog_owner_release + self._dialog_owner_release = None + if self._kind == "dialog" and isinstance(value, Dialog): + self._page._capture_dialog(value, owner_release) + elif owner_release is not None: + owner_release() self._value = value self._ready.set() self._handler = handler - self._page.on(descriptor.event, handler) - if self._include_existing and self._kind == "console": - for value in list(self._page._console_messages): - handler(value) - if self._ready.is_set(): - break + try: + self._page.on(descriptor.event, handler) + if self._include_existing and self._kind == "console": + for value in list(self._page._console_messages): + handler(value) + if self._ready.is_set(): + break + except BaseException: + if self._dialog_owner_release is not None: + self._dialog_owner_release() + self._dialog_owner_release = None + raise return self def __exit__(self, exc_type, exc, tb) -> None: descriptor = _event_waiter_descriptor(self._kind) try: if exc_type is None: - timeout_ms, timeout_display = _event_timeout_for_target( - self._page, - self._timeout, - method=descriptor.timeout_method, + timeout_ms = ( + None + if self._deadline is None + else max((self._deadline - time.monotonic()) * 1000, 0) ) _wait_for_ready_event_or_owner_close( self._ready, self._page, descriptor.event, timeout_ms, - timeout_display, + self._timeout_display, + deadline=self._deadline, ) finally: if self._handler is not None: self._page.remove_listener(descriptor.event, self._handler) self._handler = None + if self._dialog_owner_release is not None: + self._dialog_owner_release() + self._dialog_owner_release = None @property def value(self) -> Any: @@ -8861,9 +9296,26 @@ def _event_context_manager( kind: str, matcher: Any, timeout: Optional[float], + *, + prefer_native: bool = True, ) -> Any: - strategy = _event_waiter_descriptor(kind).context_manager + descriptor = _event_waiter_descriptor(kind) + _validate_event_matcher(kind, matcher, method=descriptor.timeout_method) + strategy = descriptor.context_manager if strategy == "native": + # A page-level console waiter observes the persistent Rust stream directly. + # Keep the listener bridge only when an existing page listener owns dispatch. + if ( + kind == "console" + and ( + not prefer_native + or ( + target.__class__.__name__ == "Page" + and target._event_handlers.get("console") + ) + ) + ): + return _ListenerEventContextManager(target, kind, matcher, timeout) return _NativeEventContextManager(target, kind, matcher, timeout) if strategy == "listener": return _ListenerEventContextManager(target, kind, matcher, timeout) @@ -9005,19 +9457,30 @@ def __init__(self, context: "BrowserContext", predicate: Any, timeout: Optional[ self._value: Optional[Dialog] = None self._context_handler: Optional[Callable[..., Any]] = None self._page_handlers: dict[Page, Callable[..., Any]] = {} + self._page_owner_releases: dict[Page, Callable[[], None]] = {} def __enter__(self) -> "_ContextDialogEventContextManager": def page_handler(page: "Page") -> None: if page in self._page_handlers or page.is_closed(): return + owner_release = page._acquire_dialog_waiter() + self._page_owner_releases[page] = owner_release def dialog_handler(dialog: Dialog) -> None: if self._value is None and _dialog_event_matches(self._predicate, dialog): + owner_release = self._page_owner_releases.pop(page, None) + page._capture_dialog(dialog, owner_release) self._value = dialog self._ready.set() self._page_handlers[page] = dialog_handler - page.on("dialog", dialog_handler) + try: + page.on("dialog", dialog_handler) + except BaseException: + self._page_handlers.pop(page, None) + self._page_owner_releases.pop(page, None) + owner_release() + raise for page in self._context.pages: page_handler(page) @@ -9047,6 +9510,10 @@ def __exit__(self, exc_type, exc, tb) -> None: for page, handler in list(self._page_handlers.items()): page.remove_listener("dialog", handler) self._page_handlers.clear() + releases = list(self._page_owner_releases.values()) + self._page_owner_releases.clear() + for release in releases: + release() @property def value(self) -> Dialog: @@ -9642,29 +10109,71 @@ def __init__(self, page: "Page", payload: dict[str, Any]): default_value = payload.get("default_value") self.default_value = "" if default_value is None else str(default_value) self._handled = False + self._fallback_lock = threading.Lock() + self._handle_lock = threading.Lock() + self._fallback_attempted = False + self._captured = False + self._dispatch: Optional[_DialogDispatch] = None + self._owner_releases: list[Callable[[], None]] = [] @property def page(self) -> "Page": return self._page + def _attach_dispatch(self, dispatch: _DialogDispatch) -> None: + self._dispatch = dispatch + if self._captured: + dispatch.capture() + + def _capture(self, owner_release: Optional[Callable[[], None]] = None) -> None: + release_now = False + with self._fallback_lock: + self._captured = True + if owner_release is not None: + if self._handled: + release_now = True + else: + self._owner_releases.append(owner_release) + dispatch = self._dispatch + if dispatch is not None: + dispatch.capture() + if release_now: + owner_release() + + def _mark_handled(self) -> None: + with self._fallback_lock: + if self._handled: + return + self._handled = True + releases = self._owner_releases + self._owner_releases = [] + dispatch = self._dispatch + if dispatch is not None: + dispatch.mark_handled() + self._page._dialog_was_handled(self) + for release in releases: + release() + def accept(self, prompt_text: Optional[str] = None) -> None: - if self._handled: - raise Error("Dialog.accept: Cannot accept dialog which is already handled!") - if prompt_text is not None and not isinstance(prompt_text, str): - raise Error(f"Dialog.accept: prompt_text: expected string, got {_playwright_type_name(prompt_text)}") - _call( - self._page._core.handle_dialog, - True, - self.default_value if prompt_text is None and self.type == "prompt" else prompt_text, - self._page._default_timeout, - ) - self._handled = True + with self._handle_lock: + if self._handled: + raise Error("Dialog.accept: Cannot accept dialog which is already handled!") + if prompt_text is not None and not isinstance(prompt_text, str): + raise Error(f"Dialog.accept: prompt_text: expected string, got {_playwright_type_name(prompt_text)}") + _call( + self._page._core.handle_dialog, + True, + self.default_value if prompt_text is None and self.type == "prompt" else prompt_text, + self._page._default_timeout, + ) + self._mark_handled() def dismiss(self) -> None: - if self._handled: - raise Error("Dialog.dismiss: Cannot dismiss dialog which is already handled!") - _call(self._page._core.handle_dialog, False, None, self._page._default_timeout) - self._handled = True + with self._handle_lock: + if self._handled: + raise Error("Dialog.dismiss: Cannot dismiss dialog which is already handled!") + _call(self._page._core.handle_dialog, False, None, self._page._default_timeout) + self._mark_handled() def _console_arg_handle_payload(value: Any) -> dict[str, Any]: @@ -9688,11 +10197,24 @@ class ConsoleMessage(_EventEmitter): def __init__(self, page: Optional["Page"], payload: dict[str, Any], *, worker: Optional["Worker"] = None): self.page = page self._worker = worker + event_sequence = payload.get("__rustwright_cdp_event_seq") + self._event_sequence = ( + event_sequence + if isinstance(event_sequence, int) and not isinstance(event_sequence, bool) + else None + ) self.type = str(payload.get("type") or "log") self.text = str(payload.get("text") or "") self.timestamp = float(payload.get("timestamp") or time.time() * 1000) + raw_args = list(payload.get("args") or []) owner_frame = None - if page is not None and worker is None: + has_remote_object_arg = any( + isinstance(value, dict) + and isinstance(remote := value.get("__rustwright_cdp_remote_object__"), dict) + and bool(remote.get("objectId")) + for value in raw_args + ) + if page is not None and worker is None and has_remote_object_arg: session_id = payload.get("session_id") execution_context_id = payload.get("execution_context_id") if session_id is not None and execution_context_id is not None: @@ -9707,7 +10229,7 @@ def __init__(self, page: Optional["Page"], payload: dict[str, Any], *, worker: O owner_frame = None self.args = [ JSHandle(page or worker, _console_arg_handle_payload(arg), owner_frame=owner_frame) - for arg in list(payload.get("args") or []) + for arg in raw_args ] location = payload.get("location") self.location = dict(location) if isinstance(location, dict) else { @@ -11221,17 +11743,7 @@ def _new_page_from_options(self, options: Dict[str, Any]) -> "Page": ) if "downloads_path" not in effective_options and "downloadsPath" not in effective_options and self._launch_downloads_path is not None: effective_options["downloads_path"] = self._launch_downloads_path - if effective_options.get("proxy") is not None or self._launch_proxy is not None: - context = self._new_context_from_options(effective_options, method="Browser.new_page") - try: - page = context._new_page(method="Browser.new_page") - except Exception: - context.close() - raise - page._owns_context = True - return page - context = BrowserContext(None, browser=self, options=effective_options) - self._contexts.append(context) + context = self._new_context_from_options(effective_options, method="Browser.new_page") try: page = context._new_page(method="Browser.new_page") except Exception: @@ -15209,6 +15721,7 @@ def __init__( self._video: Optional[Video] = None self._request = context.request if context is not None else APIRequestContext() self._event_handlers: dict[str, list[Callable[..., Any]]] = {} + self._event_handler_cursors: dict[tuple[str, int], Optional[int]] = {} self._main_frame = Frame(self, name="", url="", is_main=True) self._set_content_html_document_known: Optional[bool] = True self._frame_object_cache: dict[str, Frame] = {} @@ -15255,6 +15768,12 @@ def __init__( self._proxy_auth_credentials: Optional[tuple[str, str]] = None self._http_auth_credentials: Optional[tuple[str, str, Optional[str]]] = None self._dialog_dispatch_count = 0 + self._dialog_state_lock = threading.Lock() + self._dialog_waiter_count = 0 + self._dialog_operation_count = 0 + self._pending_dialog_dispatches: set[_DialogDispatch] = set() + self._page_cdp_event_condition = threading.Condition() + self._page_cdp_event_generations: dict[str, int] = {} self._crash_thread: Optional[threading.Thread] = None self._crash_session: Optional[CDPSession] = None self._crash_waiter: Any = None @@ -15290,9 +15809,18 @@ def __init__( self._pick_locator_state_name = _page_internal_global("pickLocator") self._pick_locator_cancelled = threading.Event() self._worker_thread: Optional[threading.Thread] = None + self._worker_thread_stop = threading.Event() + self._worker_thread_lock = threading.Lock() + self._worker_auto_attach_configured = False self._worker_waiter: Any = None self._workers: dict[str, Worker] = {} - self._worker_console_targets: set[str] = set() + self._worker_console_forwards: dict[str, tuple[Worker, Callable[[ConsoleMessage], None]]] = {} + self._worker_state_lock = threading.RLock() + self._worker_console_forward_lock = threading.RLock() + self._worker_interest_lock = threading.Lock() + self._worker_listener_interest = False + self._worker_waiter_interest_count = 0 + self._worker_core_interest = False self._bindings: dict[str, tuple[Callable[..., Any], bool, bool]] = {} self._context_binding_names: set[str] = set() self._binding_server: Optional[ThreadingHTTPServer] = None @@ -15316,6 +15844,9 @@ def __init__( self._closed_reason: Optional[str] = None self._event_pump_stop_lock = threading.Lock() self._event_pump_stopped = False + self._event_dispatch_lock = threading.RLock() + self._event_dispatch_condition = threading.Condition(self._event_dispatch_lock) + self._event_handler_registrations: dict[tuple[str, int], _EventHandlerRegistration] = {} self._event_stream = self._core.combined_event_stream() self._event_pump_thread: Optional[threading.Thread] = None if _start_event_pump: @@ -15747,8 +16278,11 @@ def set_default_navigation_timeout(self, timeout: float) -> None: def _navigation_timeout(self, timeout: Optional[float]) -> float: return _validate_timeout_value(_effective_navigation_timeout_value(self, timeout), method="Page") - def _mark_navigation_history_boundary(self) -> None: - self._drain_history_buffers() + def _mark_navigation_history_boundary(self, before: Optional[tuple[int, int]] = None) -> None: + if before is not None and self._runtime_observation_enabled: + self._console_messages_navigation_index = before[0] + self._page_errors_navigation_index = before[1] + return self._console_messages_navigation_index = len(self._console_messages) self._page_errors_navigation_index = len(self._page_errors) @@ -15831,6 +16365,71 @@ def _retain_navigation_response_bodies(self) -> None: retained.append(response) self._navigation_responses = retained[-20:] + def _navigation_response_request_ids(self) -> list[str]: + request_ids: list[str] = [] + for response in self._navigation_responses: + if response._body_cache is not None: + continue + if response._request_id: + request_ids.append(str(response._request_id)) + else: + response._body_cache = b"" + return request_ids + + def _apply_retained_navigation_response_bodies(self, payload: Any) -> None: + if not isinstance(payload, dict): + return + retained = payload.get("response_bodies") + if not isinstance(retained, dict): + return + responses = { + str(response._request_id): response + for response in self._navigation_responses + if response._request_id and response._body_cache is None + } + for request_id, body_payload in retained.items(): + response = responses.get(str(request_id)) + if response is None: + continue + try: + response._set_body_from_payload(body_payload) + except (TypeError, ValueError): + continue + + def _record_drained_history_payload(self, payload: Any) -> None: + if self._runtime_observation_enabled or not isinstance(payload, dict): + return + console_payloads = payload.get("console") + if isinstance(console_payloads, list): + for item in console_payloads: + if isinstance(item, dict): + self._record_console_message(ConsoleMessage(self, item)) + page_error_payloads = payload.get("page_errors") + if isinstance(page_error_payloads, list): + for item in page_error_payloads: + if isinstance(item, dict): + self._record_page_error(_page_error_from_history_payload(item)) + + def _prepare_navigation(self) -> tuple[tuple[int, int], dict[str, Any]]: + before = (len(self._console_messages), len(self._page_errors)) + request_ids = self._navigation_response_request_ids() + try: + payload = json.loads( + _call( + self._core.prepare_navigation, + _CONSOLE_HISTORY_BUFFER, + _PAGE_ERROR_HISTORY_BUFFER, + json.dumps(request_ids), + ) + ) + except (Error, TypeError, ValueError): + payload = {} + if not isinstance(payload, dict): + payload = {} + self._apply_retained_navigation_response_bodies(payload) + self._record_drained_history_payload(payload) + return before, payload + def _uses_single_process_fallback(self) -> bool: browser = self._context._browser if self._context is not None else None if browser is None: @@ -15849,6 +16448,7 @@ def _mark_request_cookie_sync_required(self) -> None: if self._context is not None: self._context._mark_request_cookie_sync_required() + def goto( self, url: str, @@ -15870,6 +16470,7 @@ def goto( normalized_referer = _normalize_string_option(referer, method="Page.goto", name="referer") target_url = self._resolve_url(url) self._mark_request_cookie_sync_required() + self._set_content_html_document_known = None call_id = self._trace_begin_action( "goto", { @@ -15883,9 +16484,6 @@ def goto( self._uses_single_process_fallback() and target_url.lower().startswith("chrome://crash") ) try: - self._retain_navigation_response_bodies() - self._mark_navigation_history_boundary() - self._set_content_html_document_known = None try: target_scheme = url_parse.urlparse(target_url).scheme.lower() except ValueError: @@ -15893,15 +16491,31 @@ def goto( download_waiter = ( self._download_event_waiter() if target_scheme in {"http", "https"} else None ) + lifecycle_event = ( + "domcontentloaded" + if normalized_state == "domcontentloaded" + else None + if normalized_state == "commit" + else "load" + ) + lifecycle_generation = ( + self._page_cdp_event_generation(lifecycle_event) + if lifecycle_event is not None + else None + ) + before, _ = self._prepare_navigation() + self._mark_navigation_history_boundary(before) try: - payload = json.loads(_call_wait_with_playwright_timeout( - "Page.goto", - self._core.goto, - target_url, - normalized_state, - navigation_timeout, - normalized_referer, - )) + payload = json.loads( + _call_wait_with_playwright_timeout( + "Page.goto", + self._core.goto, + target_url, + normalized_state, + navigation_timeout, + normalized_referer, + ) + ) except Error as exc: message = str(exc).splitlines()[0] if ( @@ -15937,6 +16551,12 @@ def goto( self._trace_end_action(call_id, result={"response": {"url": response.url, "status": response.status}}) return self._remember_navigation_response(response) raise + if lifecycle_event is not None and lifecycle_generation is not None: + self._wait_for_page_cdp_event_dispatch( + lifecycle_event, + lifecycle_generation, + navigation_timeout, + ) if payload is None or target_url.lower().startswith(("about:", "data:")): if self._context is not None: self._context._apply_storage_state_to_page(self) @@ -15978,8 +16598,8 @@ def reload( prior_time_origin = self.evaluate("() => performance.timeOrigin") except Error: prior_time_origin = None - self._retain_navigation_response_bodies() - self._mark_navigation_history_boundary() + before, _ = self._prepare_navigation() + self._mark_navigation_history_boundary(before) self._set_content_html_document_known = None waiter = self._core.network_event_waiter("response") payload = json.loads(_call_wait_with_playwright_timeout("Page.reload", self._core.reload, "commit", reload_timeout)) @@ -16109,10 +16729,10 @@ def go_back( ) -> Optional[Response]: navigation_timeout = _navigation_timeout_for_method(self, timeout, method="Page.go_back") normalized_state = _normalize_lifecycle_state(wait_until, label="wait_until", method="Page.go_back") - boundary = self._navigation_history_boundary() - self._retain_navigation_response_bodies() - self._mark_navigation_history_boundary() self._set_content_html_document_known = None + boundary = self._navigation_history_boundary() + before, _ = self._prepare_navigation() + self._mark_navigation_history_boundary(before) before_url = self.url try: payload = json.loads(_call_wait_with_playwright_timeout( @@ -16143,8 +16763,8 @@ def go_forward( navigation_timeout = _navigation_timeout_for_method(self, timeout, method="Page.go_forward") normalized_state = _normalize_lifecycle_state(wait_until, label="wait_until", method="Page.go_forward") boundary = self._navigation_history_boundary() - self._retain_navigation_response_bodies() - self._mark_navigation_history_boundary() + before, _ = self._prepare_navigation() + self._mark_navigation_history_boundary(before) self._set_content_html_document_known = None before_url = self.url try: @@ -16329,11 +16949,10 @@ def _wait_for_dialog_event( reject_on_close=reject_on_close, ) - def _page_error_event_waiter(self) -> Any: - session = _call(self._core.cdp_session) - _call(session.send, "Runtime.enable", json_module_dumps({}), self._default_timeout) + def _page_error_event_waiter(self, timeout_ms: Optional[float] = None) -> Any: + waiter = _call(self._core.page_error_event_waiter, timeout_ms) self._runtime_observation_enabled = True - return session.event_waiter("Runtime.exceptionThrown") + return waiter def _wait_for_page_error_event( self, @@ -16518,87 +17137,242 @@ def _wait_for_websocket_event( def expect_websocket(self, predicate: Any = None, *, timeout: Optional[float] = None) -> _WebSocketEventContextManager: return _event_context_manager(self, "websocket", predicate, timeout) - def _worker_event_waiter(self) -> Any: - return self._core.worker_event_waiter(self._default_timeout) + def _sync_worker_event_interest_locked(self) -> None: + interested = self._worker_listener_interest or self._worker_waiter_interest_count > 0 + if interested == self._worker_core_interest: + return + setter = getattr(self._core, "set_worker_event_interest", None) + if callable(setter): + _call(setter, interested, self._default_timeout) + self._worker_core_interest = interested + + def _sync_worker_event_interest(self) -> None: + with self._worker_interest_lock: + self._sync_worker_event_interest_locked() + + def _set_worker_event_interest(self, interested: bool) -> None: + with self._worker_interest_lock: + self._worker_listener_interest = bool(interested) + self._sync_worker_event_interest_locked() + + def _set_worker_forwarding_interest(self, interested: bool) -> None: + setter = getattr(self._core, "set_worker_forwarding_interest", None) + if not callable(setter): + return + _call(setter, bool(interested), self._default_timeout) - def _worker_from_core(self, core: Any) -> "Worker": - target_id = str(getattr(core, "target_id", "") or "") - worker = Worker(core=core, page=self) - if target_id: - self._workers[target_id] = worker - self._attach_worker_console_propagation(worker) - return worker + def _acquire_worker_waiter_interest(self) -> Callable[[], None]: + with self._worker_interest_lock: + self._worker_waiter_interest_count += 1 + try: + self._sync_worker_event_interest_locked() + except BaseException: + self._worker_waiter_interest_count -= 1 + raise + released = False + release_lock = threading.Lock() - def _attach_worker_console_propagation(self, worker: "Worker") -> None: - if not self._event_handlers.get("console"): - return - key = worker._target_id or worker.url or str(id(worker)) - if key in self._worker_console_targets: + def release() -> None: + nonlocal released + with release_lock: + if released: + return + released = True + with self._worker_interest_lock: + self._worker_waiter_interest_count = max( + self._worker_waiter_interest_count - 1, + 0, + ) + self._sync_worker_event_interest_locked() + + return release + + def _worker_event_waiter( + self, + *, + configure: Optional[bool] = None, + timeout_ms: Optional[float] = None, + ) -> Any: + if configure is None: + configure = not self._worker_auto_attach_configured + setup_timeout = self._default_timeout if timeout_ms is None else timeout_ms + waiter = _call(self._core.worker_event_waiter, setup_timeout, configure=configure) + if configure: + self._worker_auto_attach_configured = True + return waiter + + @staticmethod + def _worker_forward_key(worker: "Worker") -> str: + return worker._target_id or worker.url or str(id(worker)) + + def _remove_worker(self, worker: "Worker") -> None: + target_id = worker._target_id + if target_id: + with self._worker_state_lock: + if self._workers.get(target_id) is worker: + self._workers.pop(target_id, None) + key = self._worker_forward_key(worker) + with self._worker_console_forward_lock: + forward = self._worker_console_forwards.get(key) + if forward is None or forward[0] is not worker: + return + self._worker_console_forwards.pop(key, None) + try: + worker.remove_listener("console", forward[1]) + except Exception: + pass + + def _drop_worker_close_cleanup(self, worker: "Worker") -> None: + callback = getattr(worker, "_rustwright_page_close_handler", None) + if callback is None: return - self._worker_console_targets.add(key) + try: + worker.remove_listener("close", callback) + except Exception: + pass + try: + delattr(worker, "_rustwright_page_close_handler") + except AttributeError: + pass + + def _cache_worker(self, worker: "Worker") -> "Worker": + target_id = worker._target_id + previous: Optional[Worker] = None + if target_id: + with self._worker_state_lock: + previous = self._workers.get(target_id) + self._workers[target_id] = worker + if previous is not None and previous is not worker: + self._remove_worker(previous) + self._drop_worker_close_cleanup(previous) + if isinstance(worker, Worker) and getattr(worker, "_rustwright_page_close_handler", None) is None: + def on_close(_closed_worker: "Worker") -> None: + self._remove_worker(worker) + + setattr(worker, "_rustwright_page_close_handler", on_close) + try: + worker.on("close", on_close) + except Exception: + if getattr(worker, "_closed", False): + self._remove_worker(worker) + return worker + + def _worker_from_core(self, core: Any) -> "Worker": + target_id = str(getattr(core, "target_id", "") or "") + session_id = str(getattr(core, "session_id", "") or "") + worker: Optional[Worker] = None + if target_id: + with self._worker_state_lock: + existing = self._workers.get(target_id) + if ( + existing is not None + and not existing._closed + and existing._session_id == session_id + ): + worker = existing + if worker is None: + worker = Worker(core=core, page=self) + self._cache_worker(worker) + attach_succeeded = False + claimed = True + try: + claim = getattr(worker, "_claim_capture_handoff", None) + if callable(claim): + claimed = claim() + self._attach_worker_console_propagation(worker) + mark_ready = getattr(worker, "_mark_capture_ready", None) + if callable(mark_ready): + mark_ready() + attach_succeeded = True + if claimed and not worker._resume_if_waiting_for_debugger(): + raise RuntimeError("worker debugger resume failed") + except BaseException: + if claimed: + fail = getattr(worker, "_fail_capture_handoff", None) + if callable(fail): + try: + fail() + except BaseException: + pass + if not attach_succeeded: + self._remove_worker(worker) + return worker + + + def _attach_worker_console_propagation(self, worker: "Worker") -> None: + key = self._worker_forward_key(worker) def forward(message: ConsoleMessage) -> None: self._record_console_message(message) - for handler in list(self._event_handlers.get("console", [])): + self._dispatch_event_handlers( + "console", + message, + event_sequence=getattr(message, "_event_sequence", None), + ) + + with self._worker_console_forward_lock: + with self._event_dispatch_condition: + if not self._event_handlers.get("console"): + return + if key in self._worker_console_forwards: + return + self._worker_console_forwards[key] = (worker, forward) + try: + worker.on("console", forward) + except BaseException: + with self._event_dispatch_condition: + if self._worker_console_forwards.get(key) == (worker, forward): + self._worker_console_forwards.pop(key, None) try: - handler(message) - except Exception: - continue + worker.remove_listener("console", forward) + finally: + raise - worker.on("console", forward) + def _detach_worker_console_propagation(self, *, force: bool = False) -> None: + with self._worker_console_forward_lock: + with self._event_dispatch_condition: + if not force and self._event_handlers.get("console"): + return + forwards = list(self._worker_console_forwards.values()) + self._worker_console_forwards.clear() + drop_close_cleanup = force or not ( + self._event_handlers.get("console") or self._event_handlers.get("worker") + ) + for worker, forward in forwards: + try: + worker.remove_listener("console", forward) + except Exception: + pass + if drop_close_cleanup: + with self._worker_state_lock: + workers = list(self._workers.values()) + for worker in workers: + self._drop_worker_close_cleanup(worker) - def _evaluate_history_buffer(self, expression: str, arg: Any) -> Any: + def _drain_history_buffers( + self, + *, + record_console: bool = True, + record_page_errors: bool = True, + ) -> None: try: - payload = _call(self._core.evaluate, expression, json.dumps(arg), 250.0) - return _decode_json_result_json(payload) + payload = json.loads( + _call( + self._core.prepare_navigation, + _CONSOLE_HISTORY_BUFFER, + _PAGE_ERROR_HISTORY_BUFFER, + "[]", + ) + ) except (Error, TypeError, ValueError): - return None - - def _drain_history_buffers(self) -> None: - self._drain_console_history_buffer() - self._drain_page_error_history_buffer() - - def _drain_console_history_buffer(self) -> None: - payloads = self._evaluate_history_buffer( - """(name) => { - try { - const history = console && console[name]; - if (!Array.isArray(history) || history.length === 0) return []; - return history.splice(0, history.length); - } catch (_) { - return []; - } - }""", - _CONSOLE_HISTORY_BUFFER, - ) - if not isinstance(payloads, list): return - if self._runtime_observation_enabled: - return - for payload in payloads: - if isinstance(payload, dict): - self._record_console_message(ConsoleMessage(self, payload)) - - def _drain_page_error_history_buffer(self, *, record: bool = True) -> None: - payloads = self._evaluate_history_buffer( - """(name) => { - try { - const history = window && window[name]; - if (!Array.isArray(history) || history.length === 0) return []; - return history.splice(0, history.length); - } catch (_) { - return []; - } - }""", - _PAGE_ERROR_HISTORY_BUFFER, - ) - if not isinstance(payloads, list): - return - if not record or self._runtime_observation_enabled: + if not isinstance(payload, dict): return - for payload in payloads: - if isinstance(payload, dict): - self._record_page_error(_page_error_from_history_payload(payload)) + if not record_console: + payload["console"] = [] + if not record_page_errors: + payload["page_errors"] = [] + self._record_drained_history_payload(payload) def _record_console_message(self, message: ConsoleMessage) -> None: self._console_messages_skip_wait_until = 0.0 @@ -16623,13 +17397,19 @@ def _record_console_message(self, message: ConsoleMessage) -> None: self._console_messages_condition.notify_all() def _attach_existing_worker_console_propagation(self) -> None: - for worker in list(self._workers.values()): - self._attach_worker_console_propagation(worker) try: - for worker in self.workers: + workers = self.workers + except Exception: + with self._worker_state_lock: + workers = list(self._workers.values()) + for worker in workers: + if getattr(worker, "_closed", False): + self._remove_worker(worker) + continue + try: self._attach_worker_console_propagation(worker) - except Error: - pass + except Exception: + self._remove_worker(worker) def _wait_for_worker_event( self, @@ -16884,7 +17664,7 @@ def expect_event( if event in {"requestfinished", "requestfailed"}: return _event_context_manager(self, event, predicate, timeout) if event == "console": - return self.expect_console_message(predicate, timeout=timeout) + return _event_context_manager(self, "console", predicate, timeout) if event == "dialog": return _event_context_manager(self, "dialog", predicate, timeout) if event == "pageerror": @@ -17842,6 +18622,7 @@ def _evaluate_with_method(self, expression: str, arg: Any = None, *, method: str _ensure_evaluate_argument_context(self, self._main_frame, arg, method=method) self._mark_request_cookie_sync_required() self._mark_history_events_may_arrive() + dialog_operation_release = self._begin_dialog_operation() console_marker = self._console_dispatch_marker_if_listening() trace_params = None if self._context is not None and self._context.tracing._recording: @@ -17881,8 +18662,10 @@ def _evaluate_with_method(self, expression: str, arg: Any = None, *, method: str else: value = _decode_json_result_json(result) except Exception as exc: + dialog_operation_release() self._trace_end_action(call_id, error=exc) raise + dialog_operation_release() self._trace_end_action(call_id, result={"value": value}) self._settle_console_dispatch_after_action(console_marker) return value @@ -18235,6 +19018,7 @@ def click( "click", {"selector": selector, "timeout": self._default_timeout if timeout is None else timeout, **action_options}, ) + dialog_operation_release = self._begin_dialog_operation() try: self._selector_locator(selector, {"strict": strict})._click_impl( "Page.click", @@ -18249,8 +19033,10 @@ def click( trial=trial, ) except Exception as exc: + dialog_operation_release() self._trace_end_action(call_id, error=exc) raise + dialog_operation_release() self._trace_end_action(call_id) def dblclick( @@ -19099,6 +19885,7 @@ def pdf( tagged=tagged, outline=outline, ) + encoded = _call( self._core.pdf, normalized_path, @@ -19107,6 +19894,36 @@ def pdf( ) return base64.b64decode(encoded) + @property + def workers(self) -> list[Any]: + try: + core_workers = _call(self._core.list_workers, self._default_timeout) + except Exception: + with self._worker_state_lock: + return list(self._workers.values()) + live_target_ids: set[str] = set() + for core_worker in core_workers: + target_id = str(getattr(core_worker, "target_id", "") or "") + if not target_id: + continue + try: + worker = self._worker_from_core(core_worker) + except Exception: + continue + if getattr(worker, "_closed", False): + continue + live_target_ids.add(target_id) + with self._worker_state_lock: + stale_workers = [ + worker + for target_id, worker in self._workers.items() + if target_id not in live_target_ids + ] + for worker in stale_workers: + self._remove_worker(worker) + with self._worker_state_lock: + return list(self._workers.values()) + def wait_for_timeout(self, timeout: float) -> None: time.sleep(_normalize_wait_timeout(timeout, method="Page.wait_for_timeout") / 1000) @@ -19129,6 +19946,22 @@ def _stop_event_pump(self) -> None: if pump is not None and pump is not threading.current_thread(): pump.join(timeout=1.0) + def _stop_worker_thread(self) -> None: + lock = getattr(self, "_worker_thread_lock", None) + if lock is None: + lock = threading.Lock() + self._worker_thread_lock = lock + with lock: + self._worker_thread_stop.set() + worker = self._worker_thread + if worker is not None and worker is not threading.current_thread(): + worker.join(timeout=0.2) + + def _reap_worker_event_threads(self) -> None: + for worker in list(self._workers.values()): + worker._closed = True + worker._reap_event_threads() + def _mark_owned_cdp_sessions_closed(self) -> None: sessions = list(self._owned_cdp_sessions) self._owned_cdp_sessions.clear() @@ -19141,13 +19974,6 @@ def bring_to_front(self) -> None: def opener(self) -> Optional["Page"]: return self._opener - @property - def workers(self) -> list[Any]: - if self._workers: - return list(self._workers.values()) - for core_worker in _call(self._core.list_workers, self._default_timeout): - self._worker_from_core(core_worker) - return list(self._workers.values()) def requests(self) -> list[Request]: if not self._closed and time.monotonic() < self._request_history_wait_until: @@ -19169,7 +19995,7 @@ def _console_messages_snapshot(self, filter: Optional[str]) -> list[ConsoleMessa def console_messages(self, *, filter: Optional[str] = None) -> list[ConsoleMessage]: if filter not in {None, "all", "since-navigation"}: raise Error("Page.console_messages: filter: expected one of (all|since-navigation)") - self._drain_console_history_buffer() + self._drain_history_buffers() messages = self._console_messages_snapshot(filter) if not self._closed: if messages: @@ -19235,7 +20061,7 @@ def _page_errors_snapshot(self, filter: Optional[str]) -> list[Any]: def page_errors(self, *, filter: Optional[str] = None) -> list[Any]: if filter not in {None, "all", "since-navigation"}: raise Error("Page.page_errors: filter: expected one of (all|since-navigation)") - self._drain_page_error_history_buffer() + self._drain_history_buffers() errors = self._page_errors_snapshot(filter) if not errors and not self._closed: generation = self._page_errors_generation @@ -19243,12 +20069,12 @@ def page_errors(self, *, filter: Optional[str] = None) -> list[Any]: with self._page_errors_condition: if self._page_errors_generation == generation: self._page_errors_condition.wait(timeout=_HISTORY_EVENT_DRAIN_TIMEOUT_SECONDS) - self._drain_page_error_history_buffer() + self._drain_history_buffers() errors = self._page_errors_snapshot(filter) return errors def clear_page_errors(self) -> None: - self._drain_page_error_history_buffer(record=False) + self._drain_history_buffers(record_page_errors=False) self._page_errors.clear() self._page_errors_navigation_index = 0 self._page_errors_skip_wait_until = time.monotonic() + _HISTORY_EVENT_DRAIN_TIMEOUT_SECONDS @@ -19561,6 +20387,9 @@ def close(self, *, run_before_unload: Optional[bool] = None, reason: Optional[st time.sleep(0.01) finally: self._stop_event_pump() + self._detach_worker_console_propagation(force=True) + self._stop_worker_thread() + self._reap_worker_event_threads() self._closed = True self._closing = True self._mark_owned_cdp_sessions_closed() @@ -19618,32 +20447,429 @@ def get_by_alt_text(self, text: str, *, exact: bool = False) -> "Locator": def get_by_title(self, text: str, *, exact: bool = False) -> "Locator": return Locator(self, {"kind": "title", "value": _attribute_text_matcher(text), "exact": exact}) + @staticmethod + def _event_dispatch_owner() -> Any: + owner = _EVENT_DISPATCH_OWNER.get() + return owner if owner is not None else threading.get_ident() + + def _retire_event_handler_registration_locked(self, state: _EventHandlerRegistration) -> None: + if not state.cancelled or state.pending or state.executing: + return + key = (state.event, id(state.handler)) + if self._event_handler_registrations.get(key) is state: + self._event_handler_registrations.pop(key, None) + + def _cancel_event_handler_locked( + self, + event: str, + handler: Callable[..., Any], + ) -> Optional[_EventHandlerRegistration]: + key = (event, id(handler)) + state = self._event_handler_registrations.get(key) + if state is None or state.handler is not handler: + return None + state.cancelled = True + state.pending = 0 + self._retire_event_handler_registration_locked(state) + self._event_dispatch_condition.notify_all() + return state + + def _activate_event_handler( + self, + state: _EventHandlerRegistration, + owner: Any, + ) -> bool: + with self._event_dispatch_condition: + if state.cancelled or state.pending <= 0: + self._retire_event_handler_registration_locked(state) + return False + state.pending -= 1 + state.executing[owner] = state.executing.get(owner, 0) + 1 + return True + + def _defer_current_event_handler(self) -> Optional[_EventHandlerRegistration]: + state = _EVENT_DISPATCH_REGISTRATION.get() + owner = self._event_dispatch_owner() + if not isinstance(state, _EventHandlerRegistration): + return None + with self._event_dispatch_condition: + if state.cancelled or state.executing.get(owner, 0) <= 0: + return None + count = state.executing[owner] - 1 + if count: + state.executing[owner] = count + else: + state.executing.pop(owner, None) + state.pending += 1 + _EVENT_DISPATCH_DEFERRED.set(True) + self._event_dispatch_condition.notify_all() + return state + + def _activate_deferred_event_handler( + self, + state: _EventHandlerRegistration, + owner: Any, + ) -> bool: + activated = self._activate_event_handler(state, owner) + if not activated: + dispatch = _EVENT_DIALOG_DISPATCH.get() + if isinstance(dispatch, _DialogDispatch): + dispatch.settle() + return activated + + def _release_deferred_event_handler(self, state: _EventHandlerRegistration) -> None: + with self._event_dispatch_condition: + if state.pending > 0: + state.pending -= 1 + self._retire_event_handler_registration_locked(state) + self._event_dispatch_condition.notify_all() + dispatch = _EVENT_DIALOG_DISPATCH.get() + if isinstance(dispatch, _DialogDispatch): + dispatch.settle() + + def _finish_event_handler( + self, + state: _EventHandlerRegistration, + owner: Any = None, + ) -> None: + owner = self._event_dispatch_owner() if owner is None else owner + with self._event_dispatch_condition: + count = state.executing.get(owner, 0) + if count <= 1: + state.executing.pop(owner, None) + else: + state.executing[owner] = count - 1 + self._retire_event_handler_registration_locked(state) + self._event_dispatch_condition.notify_all() + dispatch = _EVENT_DIALOG_DISPATCH.get() + if isinstance(dispatch, _DialogDispatch): + dispatch.settle() + + def _wait_for_event_handler_removal( + self, + event: str, + handler: Callable[..., Any], + ) -> None: + owner = self._event_dispatch_owner() + key = (event, id(handler)) + current_loop, current_task = _running_asyncio_loop_and_task() + with self._event_dispatch_condition: + while True: + state = self._event_handler_registrations.get(key) + if state is None or state.handler is not handler: + return + if state.executing.get(owner, 0) and ( + current_task is None or owner is current_task + ): + return + if current_loop is not None: + loop_owned = False + for executing_owner in state.executing: + if executing_owner is current_task: + continue + get_loop = getattr(executing_owner, "get_loop", None) + if not callable(get_loop): + continue + try: + executing_loop = get_loop() + except Exception: + continue + if executing_loop is not current_loop: + continue + loop_owned = True + cancel = getattr(executing_owner, "cancel", None) + if callable(cancel): + cancel() + if loop_owned: + return + if not state.executing: + self._retire_event_handler_registration_locked(state) + return + self._event_dispatch_condition.wait() + + def _listener_registration_cursor(self, event: str) -> Optional[int]: + dispatch_context = _EVENT_DISPATCH_SEQUENCE.get() + if isinstance(dispatch_context, tuple) and len(dispatch_context) == 2: + dispatch_source, dispatch_sequence = dispatch_context + if ( + dispatch_source is self + and isinstance(dispatch_sequence, int) + and not isinstance(dispatch_sequence, bool) + ): + return dispatch_sequence + 1 + cursor_method = getattr(self._core, "event_cursor", None) + if callable(cursor_method): + try: + return int(cursor_method()) + except (Error, TypeError, ValueError): + pass + return None + + def _dialog_fallback_suppressed(self, dialog: Dialog) -> bool: + if getattr(dialog, "_captured", False): + return True + with self._dialog_state_lock: + return self._dialog_waiter_count > 0 or self._dialog_operation_count > 0 + + def _dialog_dispatch_finished(self, dispatch: _DialogDispatch) -> None: + if getattr(dispatch.dialog, "_handled", False): + return + with self._dialog_state_lock: + self._pending_dialog_dispatches.add(dispatch) + dispatch.maybe_fallback() + + def _forget_dialog_dispatch(self, dispatch: _DialogDispatch) -> None: + with self._dialog_state_lock: + self._pending_dialog_dispatches.discard(dispatch) + + def _dialog_was_handled(self, _dialog: Dialog) -> None: + with self._dialog_state_lock: + pending = list(self._pending_dialog_dispatches) + self._pending_dialog_dispatches.clear() + for dispatch in pending: + dispatch.mark_handled() + + def _maybe_fallback_pending_dialogs(self) -> None: + with self._dialog_state_lock: + pending = list(self._pending_dialog_dispatches) + for dispatch in pending: + dispatch.maybe_fallback() + + def _acquire_dialog_waiter(self) -> Callable[[], None]: + with self._dialog_state_lock: + self._dialog_waiter_count += 1 + released = False + release_lock = threading.Lock() + + def release() -> None: + nonlocal released + with release_lock: + if released: + return + released = True + with self._dialog_state_lock: + self._dialog_waiter_count = max(self._dialog_waiter_count - 1, 0) + self._maybe_fallback_pending_dialogs() + + return release + + def _begin_dialog_operation(self) -> Callable[[], None]: + with self._dialog_state_lock: + self._dialog_operation_count += 1 + released = False + release_lock = threading.Lock() + + def release() -> None: + nonlocal released + with release_lock: + if released: + return + released = True + with self._dialog_state_lock: + self._dialog_operation_count = max(self._dialog_operation_count - 1, 0) + self._maybe_fallback_pending_dialogs() + + return release + + def _capture_dialog( + self, + dialog: Dialog, + owner_release: Optional[Callable[[], None]] = None, + ) -> None: + dialog._capture(owner_release) + + def _dialog_operation_active(self) -> bool: + with self._dialog_state_lock: + return self._dialog_operation_count > 0 + + def _dispatch_event_handlers( + self, + event: str, + *args: Any, + event_sequence: Optional[int] = None, + _dialog_dispatch: Optional[_DialogDispatch] = None, + ) -> bool: + registrations = self._snapshot_event_handlers(event, event_sequence=event_sequence) + dialog_dispatch = _dialog_dispatch + if dialog_dispatch is None and event == "dialog" and args: + dialog_dispatch = _DialogDispatch(self, args[0], len(registrations)) + attach_dispatch = getattr(args[0], "_attach_dispatch", None) + if callable(attach_dispatch): + attach_dispatch(dialog_dispatch) + activated = False + for state in registrations: + owner = threading.get_ident() + if not self._activate_event_handler(state, owner): + if dialog_dispatch is not None: + dialog_dispatch.settle() + continue + activated = True + if event == "dialog" and args and self._dialog_operation_active(): + capture = getattr(args[0], "_capture", None) + if callable(capture): + capture() + sequence_token = _EVENT_DISPATCH_SEQUENCE.set((self, event_sequence)) + owner_token = _EVENT_DISPATCH_OWNER.set(owner) + registration_token = _EVENT_DISPATCH_REGISTRATION.set(state) + deferred_token = _EVENT_DISPATCH_DEFERRED.set(False) + dialog_token = _EVENT_DIALOG_DISPATCH.set(dialog_dispatch) + try: + try: + state.handler(*args) + except Exception: + continue + finally: + deferred = _EVENT_DISPATCH_DEFERRED.get() + _EVENT_DISPATCH_DEFERRED.reset(deferred_token) + _EVENT_DISPATCH_REGISTRATION.reset(registration_token) + _EVENT_DISPATCH_OWNER.reset(owner_token) + _EVENT_DISPATCH_SEQUENCE.reset(sequence_token) + if not deferred: + self._finish_event_handler(state, owner) + _EVENT_DIALOG_DISPATCH.reset(dialog_token) + if dialog_dispatch is not None: + dialog_dispatch.finish_if_idle() + return activated + + def _dismiss_dialog_if_unhandled(self, dialog: Dialog) -> None: + with dialog._fallback_lock: + if dialog._handled or dialog._fallback_attempted: + return + dialog._fallback_attempted = True + try: + dialog.dismiss() + except Exception: + pass + finally: + dispatch = getattr(dialog, "_dispatch", None) + if dispatch is not None: + self._forget_dialog_dispatch(dispatch) + + def _handle_dialog_event(self, payload: dict[str, Any]) -> None: + dialog = Dialog(self, payload) + self._dispatch_event_handlers("dialog", dialog) + self._dialog_dispatch_count += 1 + + def _snapshot_event_handlers( + self, + event: str, + *, + event_sequence: Optional[int] = None, + ) -> list[_EventHandlerRegistration]: + detach_worker_forwards = False + with self._event_dispatch_condition: + registrations: list[_EventHandlerRegistration] = [] + for handler in self._event_handlers.get(event, []): + if event in {"console", "pageerror"} and event_sequence is not None: + cursor = self._event_handler_cursors.get((event, id(handler))) + if cursor is None and (event, id(handler)) not in self._event_handler_cursors: + continue + if cursor is not None and event_sequence < cursor: + continue + state = self._event_handler_registrations.get((event, id(handler))) + if state is None or state.handler is not handler: + state = _EventHandlerRegistration(event, handler) + self._event_handler_registrations[(event, id(handler))] = state + if state.cancelled: + continue + state.pending += 1 + registrations.append(state) + once_wrappers = getattr(self, "_once_event_wrappers", None) + if isinstance(once_wrappers, list): + claimed_ids = { + id(wrapper) + for stored_event, _original, wrapper in once_wrappers + if stored_event == event + and any(state.handler is wrapper for state in registrations) + } + if claimed_ids: + self._event_handlers[event] = [ + handler + for handler in self._event_handlers.get(event, []) + if id(handler) not in claimed_ids + ] + detach_worker_forwards = ( + event == "console" and not self._event_handlers.get("console") + ) + if event == "console" and not self._event_handlers.get("console"): + try: + self._set_worker_forwarding_interest(False) + except Exception: + pass + if event in {"console", "worker"} and not ( + self._event_handlers.get("console") or self._event_handlers.get("worker") + ): + try: + self._set_worker_event_interest(False) + except Exception: + pass + if detach_worker_forwards: + self._detach_worker_console_propagation() + return registrations + + + def on(self, event: str, f: Callable[..., Any]) -> None: - _add_listener_to_handlers(self, event, f, self._event_handlers) + with self._event_dispatch_condition: + if event == "console": + self._set_worker_forwarding_interest(True) + if event in {"console", "worker"}: + self._set_worker_event_interest(True) + if event == "console": + self._ensure_console_thread() + elif event == "pageerror": + self._ensure_page_error_thread() + removed = _remove_listener_from_handlers(self, event, f, self._event_handlers) + self._cancel_event_handler_locked(event, removed) + self._event_handlers.setdefault(event, []).append(f) + if event in {"console", "pageerror"}: + self._event_handler_cursors[(event, id(f))] = self._listener_registration_cursor(event) + self._event_handler_registrations[(event, id(f))] = _EventHandlerRegistration(event, f) if event == "console": - self._ensure_console_thread() - self._attach_existing_worker_console_propagation() self._ensure_worker_thread() - if event == "pageerror": - self._ensure_page_error_thread() - if event == "download": + self._attach_existing_worker_console_propagation() + elif event == "download": self._ensure_download_thread() - if event == "filechooser": + elif event == "filechooser": self._ensure_file_chooser_thread() - if event == "popup": + elif event == "popup": self._ensure_popup_thread() - if event == "websocket": + elif event == "websocket": self._ensure_websocket_thread() - if event == "worker": + elif event == "worker": self._ensure_worker_thread() - if event == "crash": + elif event == "crash": self._ensure_crash_thread() def once(self, event: str, f: Callable[..., Any]) -> None: _register_once_listener(self, event, f) - def remove_listener(self, event: str, f: Callable[..., Any]) -> None: - _remove_listener_from_handlers(self, event, f, self._event_handlers) + detach_worker_forwards = False + with self._event_dispatch_condition: + removed = _remove_listener_from_handlers(self, event, f, self._event_handlers) + self._cancel_event_handler_locked(event, removed) + if event in {"console", "pageerror"}: + self._event_handler_cursors.pop((event, id(f)), None) + if removed is not f: + self._event_handler_cursors.pop((event, id(removed)), None) + if event == "console" and not self._event_handlers.get("console"): + detach_worker_forwards = True + try: + self._set_worker_forwarding_interest(False) + except Exception: + pass + if event in {"console", "worker"} and not ( + self._event_handlers.get("console") or self._event_handlers.get("worker") + ): + try: + self._set_worker_event_interest(False) + except Exception: + pass + with self._worker_thread_lock: + self._worker_thread_stop.set() + self._wait_for_event_handler_removal(event, removed) + if detach_worker_forwards: + self._detach_worker_console_propagation() def _event_pump(self) -> None: while self._event_listeners_active(): @@ -19664,7 +20890,14 @@ def _event_pump(self) -> None: continue if kind not in _PAGE_OBSERVATION_EVENTS: continue - self._handle_observation_event(kind, envelope.get("payload")) + event_sequence = envelope.get("seq") + if isinstance(event_sequence, bool) or not isinstance(event_sequence, int): + event_sequence = None + self._handle_observation_event( + kind, + envelope.get("payload"), + event_sequence=event_sequence, + ) if not self._event_listeners_active(): return @@ -19689,16 +20922,22 @@ def _reconcile_event_stream_overflow(self, payload: Any) -> None: except Error: pass - def _handle_observation_event(self, event: str, payload: Any) -> None: + def _handle_observation_event( + self, + event: str, + payload: Any, + *, + event_sequence: Optional[int] = None, + ) -> None: payload = payload if isinstance(payload, dict) else {} if event in {"request", "response", "requestfinished", "requestfailed"}: self._handle_network_event(event, payload) elif event == "dialog": self._handle_dialog_event(payload) elif event == "console": - self._handle_console_event(payload) + self._handle_console_event(payload, event_sequence=event_sequence) elif event == "pageerror": - self._handle_page_error_event(payload) + self._handle_page_error_event(payload, event_sequence=event_sequence) elif event in {"load", "domcontentloaded"}: self._handle_page_cdp_event(event) elif event == "framenavigated": @@ -19722,60 +20961,78 @@ def _handle_network_event(self, event: str, payload: dict[str, Any]) -> None: def _dispatch_network_value(self, event: str, value: Request | Response) -> None: if isinstance(value, Request): self._note_network_lifecycle_event(event, value) - for handler in list(self._event_handlers.get(event, [])): - try: - handler(value) - except Exception: - continue + self._dispatch_event_handlers(event, value) - def _handle_dialog_event(self, payload: dict[str, Any]) -> None: - dialog = Dialog(self, payload) - handlers = list(self._event_handlers.get("dialog", [])) - for handler in handlers: - try: - handler(dialog) - except Exception: - continue - if not handlers and not dialog._handled: - try: - dialog.dismiss() - except Error: - pass - self._dialog_dispatch_count += 1 - def _handle_console_event(self, payload: dict[str, Any]) -> None: + def _handle_console_event( + self, + payload: dict[str, Any], + *, + event_sequence: Optional[int] = None, + ) -> None: + if event_sequence is not None: + payload = dict(payload) + payload["__rustwright_cdp_event_seq"] = event_sequence event = ConsoleMessage(self, payload) self._record_console_message(event) - self._dispatch_console_event(event) + self._dispatch_console_event(event, event_sequence=event_sequence) - def _dispatch_console_event(self, event: ConsoleMessage) -> None: - for handler in list(self._event_handlers.get("console", [])): - try: - handler(event) - except Exception: - continue + def _dispatch_console_event( + self, + event: ConsoleMessage, + *, + event_sequence: Optional[int] = None, + ) -> None: + self._dispatch_event_handlers("console", event, event_sequence=event_sequence) with self._console_dispatch_condition: self._console_dispatch_generation += 1 self._console_dispatch_condition.notify_all() - def _handle_page_error_event(self, payload: dict[str, Any]) -> None: + def _handle_page_error_event( + self, + payload: dict[str, Any], + *, + event_sequence: Optional[int] = None, + ) -> None: event = _page_error_from_payload(payload) self._record_page_error(event) - self._dispatch_page_error_event(event) + self._dispatch_page_error_event(event, event_sequence=event_sequence) - def _dispatch_page_error_event(self, event: Error) -> None: - for handler in list(self._event_handlers.get("pageerror", [])): - try: - handler(event) - except Exception: - continue + def _dispatch_page_error_event( + self, + event: Error, + *, + event_sequence: Optional[int] = None, + ) -> None: + self._dispatch_event_handlers("pageerror", event, event_sequence=event_sequence) + + def _page_cdp_event_generation(self, event: str) -> int: + with self._page_cdp_event_condition: + return self._page_cdp_event_generations.get(event, 0) + + def _wait_for_page_cdp_event_dispatch( + self, + event: str, + generation: int, + timeout: float, + ) -> None: + if not self._event_handlers.get(event): + return + deadline = time.monotonic() + min(max(timeout, 0.0) / 1000, 0.5) + with self._page_cdp_event_condition: + while self._page_cdp_event_generations.get(event, 0) <= generation: + remaining = deadline - time.monotonic() + if remaining <= 0: + return + self._page_cdp_event_condition.wait(timeout=remaining) def _handle_page_cdp_event(self, event: str) -> None: - for handler in list(self._event_handlers.get(event, [])): - try: - handler(self) - except Exception: - continue + self._dispatch_event_handlers(event, self) + with self._page_cdp_event_condition: + self._page_cdp_event_generations[event] = ( + self._page_cdp_event_generations.get(event, 0) + 1 + ) + self._page_cdp_event_condition.notify_all() def _handle_frame_navigated_event(self, payload: dict[str, Any]) -> None: frame = self._frame_from_navigated_payload( @@ -19785,11 +21042,7 @@ def _handle_frame_navigated_event(self, payload: dict[str, Any]) -> None: self._dispatch_frame_navigated_event(frame) def _dispatch_frame_navigated_event(self, frame: Frame) -> None: - for handler in list(self._event_handlers.get("framenavigated", [])): - try: - handler(frame) - except Exception: - continue + self._dispatch_event_handlers("framenavigated", frame) def _handle_frame_lifecycle_event(self, event: str, payload: dict[str, Any]) -> None: envelope = {"params": payload} @@ -19797,11 +21050,7 @@ def _handle_frame_lifecycle_event(self, event: str, payload: dict[str, Any]) -> self._dispatch_frame_lifecycle_event(event, frame) def _dispatch_frame_lifecycle_event(self, event: str, frame: Frame) -> None: - for handler in list(self._event_handlers.get(event, [])): - try: - handler(frame) - except Exception: - continue + self._dispatch_event_handlers(event, frame) def _record_request(self, request: Request) -> Request: request = self._adopt_request(request) @@ -19905,10 +21154,14 @@ def _remember_fulfilled_route_body(self, request_id: str, body: bytes) -> None: self._fulfilled_route_bodies.pop(old_request_id, None) def _ensure_console_thread(self) -> None: + if self._runtime_observation_enabled: + return self._event_stream.enable_runtime() self._runtime_observation_enabled = True def _ensure_page_error_thread(self) -> None: + if self._runtime_observation_enabled: + return self._event_stream.enable_runtime() self._runtime_observation_enabled = True @@ -20014,11 +21267,7 @@ def _download_loop(self) -> None: continue except Error: break - for handler in list(self._event_handlers.get("download", [])): - try: - handler(event) - except Exception: - continue + self._dispatch_event_handlers("download", event) def _ensure_file_chooser_thread(self) -> None: existing = self._file_chooser_thread @@ -20046,11 +21295,7 @@ def _file_chooser_loop(self) -> None: break if not self._should_dispatch_file_chooser_listener_event(event): continue - for handler in list(self._event_handlers.get("filechooser", [])): - try: - handler(event) - except Exception: - continue + self._dispatch_event_handlers("filechooser", event) def _should_dispatch_file_chooser_listener_event(self, event: FileChooser) -> bool: key = ( @@ -20085,11 +21330,7 @@ def _popup_loop(self) -> None: continue except Error: break - for handler in list(self._event_handlers.get("popup", [])): - try: - handler(event) - except Exception: - continue + self._dispatch_event_handlers("popup", event) def _ensure_websocket_thread(self) -> None: existing = self._websocket_thread @@ -20111,38 +21352,83 @@ def _websocket_loop(self) -> None: continue except Error: break - for handler in list(self._event_handlers.get("websocket", [])): - try: - handler(event) - except Exception: - continue + self._dispatch_event_handlers("websocket", event) def _ensure_worker_thread(self) -> None: - existing = self._worker_thread - if existing is not None and existing.is_alive(): - return - self._worker_waiter = self._worker_event_waiter() - thread = threading.Thread(target=self._worker_loop, daemon=True, name="rustwright-worker-listener") - self._worker_thread = thread - thread.start() + lock = getattr(self, "_worker_thread_lock", None) + if lock is None: + lock = threading.Lock() + self._worker_thread_lock = lock + with lock: + existing = self._worker_thread + if existing is not None and existing.is_alive(): + self._worker_thread_stop.clear() + return + self._worker_thread_stop.clear() + self._worker_waiter = self._worker_event_waiter( + configure=not self._worker_auto_attach_configured, + ) + thread = threading.Thread(target=self._worker_loop, daemon=True, name="rustwright-worker-listener") + self._worker_thread = thread + thread.start() def _worker_loop(self) -> None: - while self._event_listeners_active(): - if not self._event_handlers.get("worker") and not self._event_handlers.get("console"): - time.sleep(0.05) - continue - try: - event = self._wait_for_worker_event(timeout=500.0, waiter=self._worker_waiter) - except TimeoutError: - continue - except Error: - break - self._attach_worker_console_propagation(event) - for handler in list(self._event_handlers.get("worker", [])): + current_thread = threading.current_thread() + try: + while self._event_listeners_active(): + lock = getattr(self, "_worker_thread_lock", None) + if lock is None: + lock = threading.Lock() + self._worker_thread_lock = lock + with lock: + if self._worker_thread is not current_thread: + return + has_handlers = bool(self._event_handlers.get("worker") or self._event_handlers.get("console")) + if self._worker_thread_stop.is_set() and not has_handlers: + self._worker_thread = None + return + if not has_handlers: + self._worker_thread_stop.wait(0.05) + continue try: - handler(event) - except Exception: + event = self._wait_for_worker_event(timeout=50.0, waiter=self._worker_waiter) + except TimeoutError: continue + except Error: + break + self._cache_worker(event) + attach_succeeded = False + claimed = True + try: + claim = getattr(event, "_claim_capture_handoff", None) + if callable(claim): + claimed = claim() + mark_ready = getattr(event, "_mark_capture_ready", None) + if callable(mark_ready): + mark_ready() + attach_succeeded = True + if claimed and not event._resume_if_waiting_for_debugger(): + raise RuntimeError("worker debugger resume failed") + except BaseException: + if claimed: + fail = getattr(event, "_fail_capture_handoff", None) + if callable(fail): + try: + fail() + except BaseException: + pass + if not attach_succeeded: + self._remove_worker(event) + self._dispatch_event_handlers("worker", event) + + finally: + lock = getattr(self, "_worker_thread_lock", None) + if lock is None: + lock = threading.Lock() + self._worker_thread_lock = lock + with lock: + if self._worker_thread is current_thread: + self._worker_thread = None class _ScreenshotMaskCleanupHandle: @@ -22241,7 +23527,6 @@ def _click_impl( trial = _normalize_action_boolean(trial, method=method, name="trial") no_wait_after = _normalize_action_boolean(no_wait_after, method=method, name="no_wait_after") forced = bool(force) - unsafe_dom_fastpath = _unsafe_dom_fastpath_enabled() def run_post_action_locator_handlers() -> None: if not any(entry.get("no_wait_after") for entry in getattr(self._page, "_locator_handlers", [])): @@ -22250,17 +23535,6 @@ def run_post_action_locator_handlers() -> None: deadline = time.monotonic() + max(timeout_ms, 1.0) / 1000 self._page._run_locator_handlers(deadline) - if ( - unsafe_dom_fastpath - and not forced - and not trial - and not any(value is not None for value in (modifiers, position, delay, button, click_count, steps)) - and getattr(self._page, "_active_page_cdp_event_contexts", 0) <= 0 - and self._try_fast_named_button_role_dom_click(timeout=timeout) - ): - run_post_action_locator_handlers() - self._page._slow_mo() - return target_info: Optional[dict[str, Any]] = None if forced: target_info = self._wait_for_forced_visible_pointer_action( @@ -22332,46 +23606,6 @@ def run_post_action_locator_handlers() -> None: run_post_action_locator_handlers() self._page._slow_mo() return - if unsafe_dom_fastpath and self._try_fast_simple_css_dom_click(timeout=timeout): - run_post_action_locator_handlers() - self._page._slow_mo() - return - if unsafe_dom_fastpath and self._should_mouse_click_editable_control(method=method, timeout=timeout): - point = self._mouse_point_from_target_state(target_info, position=None, timeout=timeout) if target_info else None - if point is None: - self._mouse_click( - timeout=timeout, - modifiers=None, - position=None, - delay=None, - button=None, - click_count=None, - steps=None, - ) - else: - self._mouse_click_at_point( - point[0], - point[1], - timeout=timeout, - modifiers=None, - delay=None, - button=None, - click_count=None, - steps=None, - ) - run_post_action_locator_handlers() - self._page._slow_mo() - return - if unsafe_dom_fastpath: - _call( - self._page._core.click, - _json(self._spec), - self._index, - self._page._default_timeout if timeout is None else timeout, - ) - run_post_action_locator_handlers() - self._page._slow_mo() - return point = self._mouse_point_from_target_state(target_info, position=None, timeout=timeout) if target_info else None if point is None: self._mouse_click( @@ -22677,433 +23911,81 @@ def _dispatch_locator_pointer_action( self, kind: str, target_x: float, - target_y: float, - *, - position: Optional[dict[str, Any]], - timeout: Optional[float], - steps: int = 1, - button: str = "none", - button_mask: int = 0, - click_count: int = 0, - delay_ms: Optional[float] = None, - initial_buttons: Optional[int] = None, - target: Optional["Locator"] = None, - target_position: Optional[dict[str, Any]] = None, - ) -> None: - mouse = self._page.mouse - action: dict[str, Any] = { - "kind": kind, - "startX": mouse._x, - "startY": mouse._y, - "targetX": target_x, - "targetY": target_y, - "steps": max(1, int(steps)), - "button": button, - "buttonMask": button_mask, - "clickCount": click_count, - "delayMs": delay_ms or 0.0, - "initialButtons": mouse._buttons if initial_buttons is None else initial_buttons, - "modifiers": self._page.keyboard._modifiers_mask(), - } - if position is not None: - action["position"] = {"x": float(position.get("x", 0)), "y": float(position.get("y", 0))} - if target is not None: - action.update( - targetLocator=_json(target._spec), - targetIndex=target._index, - ) - if target_position is not None: - action["targetPosition"] = { - "x": float(target_position.get("x", 0)), - "y": float(target_position.get("y", 0)), - } - _call( - self._page._core.dispatch_locator_pointer_action, - _json(self._spec), - self._index, - json.dumps(action), - self._page._default_timeout if timeout is None else timeout, - ) - - def _mouse_dblclick( - self, - *, - timeout: Optional[float], - modifiers: Optional[Iterable[str]], - position: Optional[dict[str, Any]], - delay: Optional[float], - button: Optional[str], - steps: Optional[int], - ) -> None: - x, y = self._mouse_point(position=position, timeout=timeout) - self._mouse_click_at_point( - x, - y, - timeout=timeout, - modifiers=modifiers, - delay=delay, - button=button, - click_count=2, - steps=steps, - position=position, - ) - - def _try_fast_fill( - self, - operation: str, - value: str, - *, - action: str, - timeout: Optional[float], - force: Optional[bool], - ) -> bool: - if not _unsafe_dom_fastpath_enabled(): - return False - result = self._native_locator_fast_path( - operation, - timeout=timeout, - method=f"Locator.{action}", - args={"value": str(value), "forced": bool(force)}, - ) - if not isinstance(result, dict): - return False - result_type = result.get("type") - if result.get("ok"): - self._page._slow_mo() - return True - if result_type in {"fallback", "not-applicable", "pending"}: - return False - if result_type == "strict": - count = int(result.get("count") or 0) - raise Error(f"strict mode violation: locator resolved to {count} elements while trying to {action}") - if result_type == "number-text": - raise Error(f"Locator.{action}: Error: Cannot type text into input[type=number]") - if result_type == "malformed": - raise Error(f"Locator.{action}: Error: Malformed value") - if result_type == "not-editable": - raise Error(f"Locator.{action}: Error: Element is not editable") - info = result.get("info") if isinstance(result.get("info"), dict) else {} - if result_type == "input-type": - info = {**info, "non_fillable_input": True, "input_type": result.get("inputType") or info.get("input_type")} - if result_type == "select": - info = {**info, "is_select": True} - raise self._fill_type_error(action, info, force=result_type == "force-non-fillable") - - def _try_fast_simple_css_fill( - self, - value: str, - *, - action: str, - timeout: Optional[float], - force: Optional[bool], - ) -> bool: - return self._try_fast_fill( - "css_fill", - value, - action=action, - timeout=timeout, - force=force, - ) - - def _try_fast_simple_css_dom_click(self, *, timeout: Optional[float]) -> bool: - if not _unsafe_dom_fastpath_enabled(): - return False - selector = self._simple_css_fast_path_selector() - if selector is None or self._explicit_index or self._strict: - return False - result = self._evaluate_simple_css_fast_path( - """(payload) => { -const el = document.querySelector(String(payload.selector || '')); -if (!el) return false; -if (el.tagName !== 'BUTTON') return false; -el.focus({ preventScroll: true }); -el.click(); -return true; -}""", - {"selector": selector, "index": int(self._index)}, - timeout=timeout, - method="Locator.click", - ) - return bool(result) - - def _try_fast_simple_css_select_option( - self, - *, - values: list[str], - labels: list[str], - indexes: list[int], - timeout: Optional[float], - force: Optional[bool], - method: str, - ) -> Optional[list[str]]: - if not _unsafe_dom_fastpath_enabled(): - return None - payload = self._simple_css_indexed_read_payload() - if payload is None: - return None - payload = {**payload, "values": values, "labels": labels, "indexes": indexes, "forced": bool(force)} - result = self._evaluate_simple_css_fast_path( - """(payload) => { -try { - const allElements = document.querySelectorAll('*'); - for (let i = 0; i < allElements.length; i++) { - if (allElements[i].shadowRoot) return { ok: false, type: 'fallback' }; - } - const visible = el => { - if (!el || !el.isConnected) return false; - const view = (el.ownerDocument && el.ownerDocument.defaultView) || window; - const style = view.getComputedStyle(el); - if (style.visibility === 'hidden' || style.display === 'none') return false; - const rect = el.getBoundingClientRect(); - return rect.width > 0 && rect.height > 0; - }; - const disabledState = el => { - if (typeof el.matches === 'function' && el.matches(':disabled')) return true; - let current = el; - while (current && current.nodeType === 1) { - if (String(current.getAttribute('aria-disabled') || '').toLowerCase() === 'true') return true; - current = current.parentElement; - } - return false; - }; - const matches = Array.from(document.querySelectorAll(String(payload.selector || ''))); - if (payload.strict && matches.length > 1) return { ok: false, type: 'strict', count: matches.length }; - let index = Number(payload.index || 0); - if (index < 0) index = matches.length + index; - const el = matches[index] || null; - if (!el) return { ok: false, type: 'fallback' }; - if (String(el.tagName || '').toUpperCase() !== 'SELECT') return { ok: false, type: 'fallback' }; - if (disabledState(el)) return { ok: false, type: 'fallback' }; - if (!payload.forced && !visible(el)) return { ok: false, type: 'fallback' }; - const values = Array.isArray(payload.values) ? payload.values.map(String) : []; - const labels = Array.isArray(payload.labels) ? payload.labels.map(String) : []; - const indexes = Array.isArray(payload.indexes) ? payload.indexes.map(Number) : []; - const options = Array.from(el.options); - const foundValues = new Set(); - const foundLabels = new Set(); - const foundIndexes = new Set(); - const selectedOptions = []; - for (const option of options) { - let matched = false; - for (const value of values) { - if (option.value === value || option.label === value) { - foundValues.add(value); - matched = true; - } - } - for (const label of labels) { - if (option.label === label) { - foundLabels.add(label); - matched = true; - } - } - for (const optionIndex of indexes) { - if (option.index === optionIndex) { - foundIndexes.add(optionIndex); - matched = true; - } - } - if (matched) selectedOptions.push(option); - } - const hasRequests = values.length > 0 || labels.length > 0 || indexes.length > 0; - const allRequestedFound = - values.every(value => foundValues.has(value)) && - labels.every(label => foundLabels.has(label)) && - indexes.every(optionIndex => foundIndexes.has(optionIndex)); - const ready = !hasRequests || (el.multiple ? allRequestedFound : selectedOptions.length > 0); - if (!ready) return { ok: false, type: 'fallback' }; - for (const option of options) option.selected = false; - if (el.multiple) { - for (const option of selectedOptions) option.selected = true; - } else if (selectedOptions.length) { - selectedOptions[0].selected = true; - } - el.dispatchEvent(new Event('input', { bubbles: true })); - el.dispatchEvent(new Event('change', { bubbles: true })); - return { ok: true, selected: Array.from(el.selectedOptions).map(option => option.value) }; -} catch (_) { - return { ok: false, type: 'fallback' }; -} -}""", - payload, - timeout=timeout, - method=method, - ) - if not isinstance(result, dict): - return None - if result.get("ok"): - self._page._slow_mo() - return [str(item) for item in result.get("selected") or []] - if result.get("type") == "strict": - count = int(result.get("count") or 0) - raise Error(f"strict mode violation: locator resolved to {count} elements while trying to select option") - return None - - def _should_mouse_click_editable_control(self, *, method: str, timeout: Optional[float]) -> bool: - return bool( - self._eval( - """ -if (!el) return false; -const tagName = String(el.tagName || '').toUpperCase(); -if (tagName === 'TEXTAREA' || el.isContentEditable) return true; -if (tagName !== 'INPUT') return false; -const type = String(el.type || 'text').toLowerCase(); -return ['text', 'search', 'url', 'tel', 'email', 'password', 'number'].includes(type); -""", - timeout, - method=method, - ) - ) - - def _try_fast_named_button_role_dom_click(self, *, timeout: Optional[float]) -> bool: - if not _unsafe_dom_fastpath_enabled(): - return False - spec = self._spec - if ( - self._explicit_index - or spec.get("kind") != "role" - or spec.get("role") != "button" - or spec.get("include_hidden") - or getattr(self._page, "_locator_handlers", None) - ): - return False - if any(spec.get(key) is not None for key in ("checked", "disabled", "selected", "expanded", "pressed", "level")): - return False - name = spec.get("name") - if not isinstance(name, str): - return False - payload = { - "name": name, - "exact": bool(spec.get("exact")), - "strict": bool(self._strict), - "index": int(self._index), - } - result = self._evaluate_simple_css_fast_path( - """(payload) => { -const normalize = value => String(value ?? '').replace(/[\\u200b\\u00ad]/g, '').replace(/\\s+/g, ' ').trim(); -const includesText = (value, needle, exact) => { - const left = normalize(value); - const right = normalize(needle); - return exact ? left === right : left.toLowerCase().includes(right.toLowerCase()); -}; -if (Array.from(document.querySelectorAll('*')).some(el => el.shadowRoot)) { - return { ok: false, type: 'fallback' }; -} -const referencedText = el => { - const ids = String(el.getAttribute('aria-labelledby') || '').trim().split(/\\s+/).filter(Boolean); - if (!ids.length) return ''; - const doc = el.ownerDocument || document; - return normalize(ids.map(id => { - const node = doc.getElementById(id); - return node ? (node.innerText || node.textContent || '') : ''; - }).join(' ')); -}; -const explicitRoleOf = el => { - for (const token of String(el.getAttribute('role') || '').trim().split(/\\s+/).filter(Boolean)) { - if (token === 'button') return 'button'; - } - return ''; -}; -const buttonRoleOf = el => { - if (!el || el.nodeType !== 1) return ''; - const explicit = explicitRoleOf(el); - if (explicit) return explicit; - const tag = String(el.tagName || '').toUpperCase(); - const type = String(el.getAttribute('type') || 'text').toLowerCase(); - if (tag === 'BUTTON') return 'button'; - if (tag === 'INPUT' && ['button', 'submit', 'reset', 'image'].includes(type)) return 'button'; - return ''; -}; -const accessibleName = el => { - const tag = String(el.tagName || '').toUpperCase(); - const type = String(el.getAttribute('type') || 'text').toLowerCase(); - const explicit = referencedText(el) || normalize(el.getAttribute('aria-label') || ''); - if (explicit) return explicit; - if (tag === 'INPUT' && type === 'image') return normalize(el.getAttribute('alt') || el.getAttribute('title') || 'Submit'); - if (el.labels && el.labels.length) { - const labelText = Array.from(el.labels).map(label => label.innerText || label.textContent || '').join(' '); - if (normalize(labelText)) return normalize(labelText); - } - if (tag === 'INPUT') { - const value = el.value || el.getAttribute('value') || ''; - if (value) return normalize(value); - if (type === 'submit') return 'Submit'; - if (type === 'reset') return 'Reset'; - } - return normalize(el.innerText || el.textContent || el.getAttribute('title') || ''); -}; -const visible = el => { - if (!el || !el.isConnected) return false; - const view = (el.ownerDocument && el.ownerDocument.defaultView) || window; - const style = view.getComputedStyle(el); - if (style.visibility === 'hidden' || style.display === 'none') return false; - const rect = el.getBoundingClientRect(); - return rect.width > 0 && rect.height > 0; -}; -const disabledState = el => { - if (typeof el.matches === 'function' && el.matches(':disabled')) return true; - let current = el; - while (current && current.nodeType === 1) { - if (String(current.getAttribute('aria-disabled') || '').toLowerCase() === 'true') return true; - current = current.parentElement; - } - return false; -}; -const targetContains = (target, node) => { - let current = node; - while (current) { - if (current === target) return true; - const root = current.getRootNode ? current.getRootNode() : null; - current = current.parentElement || (root && root.host) || null; - } - return false; -}; -const deepElementFromPoint = (doc, x, y) => { - let hit = doc.elementFromPoint(x, y); - while (hit && hit.shadowRoot) { - const nested = hit.shadowRoot.elementFromPoint(x, y); - if (!nested || nested === hit) break; - hit = nested; - } - return hit; -}; -const candidates = Array.from(document.querySelectorAll('button,input,[role]')); -const matches = candidates.filter(el => { - if (buttonRoleOf(el) !== 'button') return false; - if (!visible(el)) return false; - return includesText(accessibleName(el), payload.name, !!payload.exact); -}); -if (payload.strict && matches.length > 1) return { ok: false, type: 'strict', count: matches.length }; -const el = matches[Number(payload.index || 0)] || null; -if (!el) return { ok: false, type: 'fallback' }; -if (disabledState(el)) return { ok: false, type: 'fallback' }; -el.scrollIntoView({ block: 'center', inline: 'center' }); -const doc = el.ownerDocument || document; -const view = doc.defaultView || window; -const rect = el.getBoundingClientRect(); -if (!rect || rect.width <= 0 || rect.height <= 0) return { ok: false, type: 'fallback' }; -const point = { - x: Math.min(Math.max(rect.left + rect.width / 2, 0), Math.max(view.innerWidth - 1, 0)), - y: Math.min(Math.max(rect.top + rect.height / 2, 0), Math.max(view.innerHeight - 1, 0)), -}; -const hit = deepElementFromPoint(doc, point.x, point.y); -if (!targetContains(el, hit)) return { ok: false, type: 'fallback' }; -if (typeof el.focus === 'function') el.focus({ preventScroll: true }); -el.click(); -return { ok: true }; -}""", - payload, + target_y: float, + *, + position: Optional[dict[str, Any]], + timeout: Optional[float], + steps: int = 1, + button: str = "none", + button_mask: int = 0, + click_count: int = 0, + delay_ms: Optional[float] = None, + initial_buttons: Optional[int] = None, + target: Optional["Locator"] = None, + target_position: Optional[dict[str, Any]] = None, + ) -> None: + mouse = self._page.mouse + action: dict[str, Any] = { + "kind": kind, + "startX": mouse._x, + "startY": mouse._y, + "targetX": target_x, + "targetY": target_y, + "steps": max(1, int(steps)), + "button": button, + "buttonMask": button_mask, + "clickCount": click_count, + "delayMs": delay_ms or 0.0, + "initialButtons": mouse._buttons if initial_buttons is None else initial_buttons, + "modifiers": self._page.keyboard._modifiers_mask(), + } + if position is not None: + action["position"] = {"x": float(position.get("x", 0)), "y": float(position.get("y", 0))} + if target is not None: + action.update( + targetLocator=_json(target._spec), + targetIndex=target._index, + ) + if target_position is not None: + action["targetPosition"] = { + "x": float(target_position.get("x", 0)), + "y": float(target_position.get("y", 0)), + } + _call( + self._page._core.dispatch_locator_pointer_action, + _json(self._spec), + self._index, + json.dumps(action), + self._page._default_timeout if timeout is None else timeout, + ) + + def _mouse_dblclick( + self, + *, + timeout: Optional[float], + modifiers: Optional[Iterable[str]], + position: Optional[dict[str, Any]], + delay: Optional[float], + button: Optional[str], + steps: Optional[int], + ) -> None: + x, y = self._mouse_point(position=position, timeout=timeout) + self._mouse_click_at_point( + x, + y, timeout=timeout, - method="Locator.click", + modifiers=modifiers, + delay=delay, + button=button, + click_count=2, + steps=steps, + position=position, ) - if not isinstance(result, dict): - return False - if result.get("ok"): - return True - if result.get("type") == "strict": - count = int(result.get("count") or 0) - raise Error(f"strict mode violation: locator resolved to {count} elements while trying to click") - return False + + + + + def _label_fast_path_payload(self) -> Optional[dict[str, Any]]: spec = self._spec @@ -23284,205 +24166,14 @@ def _try_fast_simple_label_evaluate_all(self, expression: str, arg: Any, *, meth return result.get("value") return _MISSING - @staticmethod - def _fast_placeholder_control_script(action_body: str) -> str: - return f"""(payload) => {{ -const allElements = document.querySelectorAll('*'); -for (let i = 0; i < allElements.length; i++) {{ - if (allElements[i].shadowRoot) return {{ ok: false, type: 'fallback' }}; -}} -const includesText = (value, needle, exact) => {{ - const left = String(value ?? ''); - const right = String(needle ?? ''); - return exact ? left === right : left.toLowerCase().includes(right.toLowerCase()); -}}; -const visible = el => {{ - if (!el || !el.isConnected) return false; - const view = (el.ownerDocument && el.ownerDocument.defaultView) || window; - const style = view.getComputedStyle(el); - if (style.visibility === 'hidden' || style.display === 'none') return false; - const rect = el.getBoundingClientRect(); - return rect.width > 0 && rect.height > 0; -}}; -const disabledState = el => {{ - if (typeof el.matches === 'function' && el.matches(':disabled')) return true; - let current = el; - while (current && current.nodeType === 1) {{ - if (String(current.getAttribute('aria-disabled') || '').toLowerCase() === 'true') return true; - current = current.parentElement; - }} - return false; -}}; -const matches = Array.from(document.querySelectorAll('[placeholder]')).filter(el => - includesText(el.getAttribute('placeholder') || '', payload.placeholder, !!payload.exact) -); -if (payload.strict && matches.length > 1) return {{ ok: false, type: 'strict', count: matches.length }}; -const el = matches[Number(payload.index || 0)] || null; -if (!el) return {{ ok: false, type: 'fallback' }}; -{action_body} -}}""" - def _try_fast_simple_label_fill( - self, - value: str, - *, - action: str, - timeout: Optional[float], - force: Optional[bool], - ) -> bool: - return self._try_fast_fill( - "label_fill", - value, - action=action, - timeout=timeout, - force=force, - ) - def _try_fast_simple_label_check(self, *, timeout: Optional[float]) -> bool: - if not _unsafe_dom_fastpath_enabled(): - return False - payload = self._label_fast_path_payload() - if payload is None: - return False - result = self._evaluate_simple_css_fast_path( - self._fast_label_control_script( - """ -const tagName = String(el.tagName || '').toUpperCase(); -const inputType = tagName === 'INPUT' ? String(el.type || '').toLowerCase() : ''; -if (tagName !== 'INPUT' || !['checkbox', 'radio'].includes(inputType)) return { ok: false, type: 'fallback' }; -if (!visible(el) || disabledState(el)) return { ok: false, type: 'fallback' }; -el.scrollIntoView({ block: 'center', inline: 'center' }); -if (!el.checked) { - el.checked = true; - el.dispatchEvent(new Event('input', { bubbles: true })); - el.dispatchEvent(new Event('change', { bubbles: true })); -} -return { ok: true }; -""" - ), - payload, - timeout=timeout, - method="Locator.check", - ) - if not isinstance(result, dict): - return False - if result.get("ok"): - self._page._slow_mo() - return True - if result.get("type") == "strict": - count = int(result.get("count") or 0) - raise Error(f"strict mode violation: locator resolved to {count} elements while trying to check") - return False - def _try_fast_simple_label_select_option( - self, - *, - values: list[str], - labels: list[str], - indexes: list[int], - timeout: Optional[float], - force: Optional[bool], - ) -> Optional[list[str]]: - if not _unsafe_dom_fastpath_enabled(): - return None - payload = self._label_fast_path_payload() - if payload is None: - return None - payload = {**payload, "values": values, "labels": labels, "indexes": indexes, "forced": bool(force)} - result = self._evaluate_simple_css_fast_path( - self._fast_label_control_script( - """ -const tagName = String(el.tagName || '').toUpperCase(); -if (tagName !== 'SELECT') return { ok: false, type: 'fallback' }; -if (disabledState(el)) return { ok: false, type: 'fallback' }; -if (!payload.forced && !visible(el)) return { ok: false, type: 'fallback' }; -const values = Array.isArray(payload.values) ? payload.values.map(String) : []; -const labels = Array.isArray(payload.labels) ? payload.labels.map(String) : []; -const indexes = Array.isArray(payload.indexes) ? payload.indexes.map(Number) : []; -const options = Array.from(el.options); -const foundValues = new Set(); -const foundLabels = new Set(); -const foundIndexes = new Set(); -const selectedOptions = []; -for (const option of options) { - let matched = false; - for (const value of values) { - if (option.value === value || option.label === value) { - foundValues.add(value); - matched = true; - } - } - for (const label of labels) { - if (option.label === label) { - foundLabels.add(label); - matched = true; - } - } - for (const index of indexes) { - if (option.index === index) { - foundIndexes.add(index); - matched = true; - } - } - if (matched) selectedOptions.push(option); -} -const hasRequests = values.length > 0 || labels.length > 0 || indexes.length > 0; -const allRequestedFound = - values.every(value => foundValues.has(value)) && - labels.every(label => foundLabels.has(label)) && - indexes.every(index => foundIndexes.has(index)); -const ready = !hasRequests || (el.multiple ? allRequestedFound : selectedOptions.length > 0); -if (!ready) return { ok: false, type: 'fallback' }; -for (const option of options) option.selected = false; -if (el.multiple) { - for (const option of selectedOptions) option.selected = true; -} else if (selectedOptions.length) { - selectedOptions[0].selected = true; -} -el.dispatchEvent(new Event('input', { bubbles: true })); -el.dispatchEvent(new Event('change', { bubbles: true })); -return { ok: true, selected: Array.from(el.selectedOptions).map(option => option.value) }; -""" - ), - payload, - timeout=timeout, - method="Locator.select_option", - ) - if not isinstance(result, dict): - return None - if result.get("ok"): - self._page._slow_mo() - return [str(item) for item in result.get("selected") or []] - if result.get("type") == "strict": - count = int(result.get("count") or 0) - raise Error(f"strict mode violation: locator resolved to {count} elements while trying to select option") - return None - def _try_fast_simple_placeholder_fill( - self, - value: str, - *, - action: str, - timeout: Optional[float], - force: Optional[bool], - ) -> bool: - return self._try_fast_fill( - "placeholder_fill", - value, - action=action, - timeout=timeout, - force=force, - ) def _fill(self, value: str, *, action: str, timeout: Optional[float] = None, force: Optional[bool] = None) -> None: self._raise_if_frame_locator_in_composite(f"Locator.{action}") timeout_ms = _default_timeout_for_method(self._page, timeout, method=_locator_method_for_action(action)) - if self._try_fast_simple_css_fill(value, action=action, timeout=timeout_ms, force=force): - return - if self._try_fast_simple_label_fill(value, action=action, timeout=timeout_ms, force=force): - return - if self._try_fast_simple_placeholder_fill(value, action=action, timeout=timeout_ms, force=force): - return on_poll = ( self._page._run_locator_handlers_for_remaining if getattr(self._page, "_locator_handlers", None) @@ -24162,26 +24853,6 @@ def _select_option_impl( raw_values = _normalize_select_string_options(value, method=method, field="valueOrLabel") raw_labels = _normalize_select_string_options(label, method=method, field="label") raw_indexes = _normalize_select_index_options(index, method=method) - if element is None: - fast_selected = self._try_fast_simple_css_select_option( - values=raw_values, - labels=raw_labels, - indexes=raw_indexes, - timeout=timeout_ms, - force=force, - method=method, - ) - if fast_selected is not None: - return fast_selected - fast_selected = self._try_fast_simple_label_select_option( - values=raw_values, - labels=raw_labels, - indexes=raw_indexes, - timeout=timeout_ms, - force=force, - ) - if fast_selected is not None: - return fast_selected try: self._wait_for_single("select option", state="enabled" if forced else "selectable", timeout=timeout_ms) except TimeoutError: @@ -27020,10 +27691,16 @@ def __init__(self, url: str = "", page: Optional[Page] = None, core: Any = None) self._core = core self._url = str(url or (getattr(core, "url", "") if core is not None else "")) self._target_id = str(getattr(core, "target_id", "") or "") + self._session_id = str(getattr(core, "session_id", "") or "") self._page = page self._default_timeout = page._default_timeout if page is not None else 30_000.0 self._closed = False + self._close_event_fired = False self._event_threads: dict[str, threading.Thread] = {} + self._event_thread_stops: dict[str, threading.Event] = {} + self._event_thread_generations: dict[str, int] = {} + self._next_event_thread_generation = 0 + self._event_thread_lock = threading.Lock() if not self._url and self._core is not None: try: self._url = str(self.evaluate("() => self.location.href")) @@ -27093,6 +27770,38 @@ def _event_waiter(self, event: str) -> Any: raise Error("worker is not attached") return self._core.close_event_waiter() + def _claim_capture_handoff(self) -> bool: + if self._core is None: + return True + claim = getattr(self._core, "claim_capture_handoff", None) + if not callable(claim): + return True + return bool(_call(claim)) + def _mark_capture_ready(self) -> bool: + if self._core is None: + return True + mark_ready = getattr(self._core, "mark_capture_ready", None) + if not callable(mark_ready): + return True + return bool(_call(mark_ready)) + + def _fail_capture_handoff(self) -> None: + if self._core is None: + return + fail = getattr(self._core, "fail_capture_handoff", None) + if callable(fail): + _call(fail) + + def _resume_if_waiting_for_debugger(self) -> bool: + if self._core is None: + return True + try: + _call(self._core.run_if_waiting_for_debugger, self._default_timeout) + except Exception: + return False + return True + + def _wait_for_event( self, event: str, @@ -27146,10 +27855,27 @@ def on(self, event: str, f: Callable[..., Any]) -> None: def once(self, event: str, f: Callable[..., Any]) -> None: _register_once_listener(self, event, f) - def _ensure_event_thread(self, event: str) -> None: - existing = self._event_threads.get(event) - if existing is not None and existing.is_alive(): + def remove_listener(self, event: str, f: Callable[..., Any]) -> None: + super().remove_listener(event, f) + if event not in {"close", "console"}: + return + if self._event_handlers_for_emitter().get(event): return + with self._event_thread_lock: + stop = self._event_thread_stops.get(event) + if stop is not None: + stop.set() + + def _ensure_event_thread(self, event: str) -> None: + with self._event_thread_lock: + existing = self._event_threads.get(event) + if existing is not None and existing.is_alive(): + stop = self._event_thread_stops.get(event) + if stop is not None: + stop.clear() + return + self._next_event_thread_generation += 1 + generation = self._next_event_thread_generation descriptor = _event_waiter_descriptor(f"worker.{event}") waiter = descriptor.waiter_factory(self) close_waiter = ( @@ -27157,35 +27883,92 @@ def _ensure_event_thread(self, event: str) -> None: if descriptor.competing_waiter_factory is not None else None ) + stop = threading.Event() thread = threading.Thread( target=self._event_loop, - args=(event, waiter, close_waiter), + args=(event, waiter, close_waiter, stop, generation), daemon=True, name=f"rustwright-worker-{event}-listener", ) - self._event_threads[event] = thread + with self._event_thread_lock: + existing = self._event_threads.get(event) + if existing is not None and existing.is_alive(): + return + self._event_thread_stops[event] = stop + self._event_thread_generations[event] = generation + self._event_threads[event] = thread thread.start() - def _event_loop(self, event: str, waiter: Any, close_waiter: Any) -> None: - while not self._closed: - if not self._event_handlers_for_emitter().get(event): - time.sleep(0.05) - continue - try: - self._wait_for_event( - event, - timeout=500.0, - waiter=waiter, - close_waiter=close_waiter, + def _event_loop( + self, + event: str, + waiter: Any, + close_waiter: Any, + stop: threading.Event, + generation: int, + ) -> None: + current_thread = threading.current_thread() + stop_decided = False + try: + while not self._closed: + with self._event_thread_lock: + if ( + self._event_threads.get(event) is not current_thread + or self._event_thread_generations.get(event) != generation + ): + return + handlers = self._event_handlers_for_emitter().get(event) + if stop.is_set() and not handlers: + stop_decided = True + return + if not handlers: + stop.wait(0.05) + continue + try: + self._wait_for_event( + event, + timeout=50.0, + waiter=waiter, + close_waiter=close_waiter, + ) + except TimeoutError: + continue + except Error: + return + except Exception: + return + if event == "close": + return + finally: + restart = False + with self._event_thread_lock: + owns_thread = ( + self._event_threads.get(event) is current_thread + and self._event_thread_generations.get(event) == generation ) - except TimeoutError: - continue - except Error: - return - except Exception: - return - if event == "close": - return + if owns_thread: + handlers = self._event_handlers_for_emitter().get(event) + self._event_threads.pop(event, None) + self._event_thread_stops.pop(event, None) + self._event_thread_generations.pop(event, None) + restart = stop_decided and bool(handlers) and not self._closed + if restart: + self._ensure_event_thread(event) + + def _reap_event_threads(self) -> None: + with self._event_thread_lock: + for stop in self._event_thread_stops.values(): + stop.set() + threads = list(self._event_threads.values()) + for thread in threads: + if thread is not threading.current_thread(): + thread.join(timeout=1.0) + with self._event_thread_lock: + for event, thread in list(self._event_threads.items()): + if not thread.is_alive(): + self._event_threads.pop(event, None) + self._event_thread_stops.pop(event, None) + self._event_thread_generations.pop(event, None) def _required_expectation_arg(value: Any, method: str, name: str) -> Any: diff --git a/src/lib.rs b/src/lib.rs index 8f3b551..b50662d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1599,7 +1599,6 @@ struct LocatorDispatchScript { #[derive(Debug)] struct LocatorFastPathScript { body: String, - fill_guard_key: Option, } fn locator_fill_apply_body(value: &str, strict: bool, forced: bool) -> RwResult { @@ -1736,23 +1735,6 @@ fn simple_label_fast_spec(spec: &Value, explicit_index: bool) -> bool { ) } -fn simple_placeholder_fast_spec(spec: &Value, explicit_index: bool) -> bool { - !explicit_index - && spec.get("kind").and_then(Value::as_str) == Some("placeholder") - && spec.get("value").and_then(Value::as_str).is_some() -} - -fn fast_fill_body(args: &Value) -> RwResult { - let value = args.get("value").and_then(Value::as_str).ok_or_else(|| { - RwError::InvalidInput("locator fast fill value must be a string".to_string()) - })?; - locator_fill_apply_body( - value, - args.get("strict").and_then(Value::as_bool).unwrap_or(false), - args.get("forced").and_then(Value::as_bool).unwrap_or(false), - ) -} - fn locator_fast_path_body( locator_json: &str, operation: &str, @@ -1771,7 +1753,6 @@ fn locator_fast_path_body( .get("explicit_index") .and_then(Value::as_bool) .unwrap_or(false); - let mut fill_guard_key = None; let body = match operation { "css_count" if simple_css_fast_spec(&spec) => Some(LOCATOR_FAST_COUNT_BODY.to_string()), "css_text" if simple_css_fast_spec(&spec) => Some(LOCATOR_FAST_TEXT_BODY.to_string()), @@ -1794,11 +1775,6 @@ fn locator_fast_path_body( "css_input_value" if simple_css_fast_spec(&spec) => { Some(LOCATOR_FAST_INPUT_VALUE_BODY.to_string()) } - "css_fill" if !explicit_index && simple_css_fast_spec(&spec) => { - let fill = fast_fill_body(&args)?; - fill_guard_key = Some(fill.guard_key); - Some(fill.body) - } "css_immediate_state" if simple_css_fast_spec(&spec) => { let state = args.get("state").and_then(Value::as_str); matches!(state, Some("hidden" | "detached")) @@ -1828,16 +1804,6 @@ fn locator_fast_path_body( "label_attribute" if simple_label_fast_spec(&spec, explicit_index) => { Some(LOCATOR_FAST_ATTRIBUTE_BODY.to_string()) } - "label_fill" if simple_label_fast_spec(&spec, explicit_index) => { - let fill = fast_fill_body(&args)?; - fill_guard_key = Some(fill.guard_key); - Some(fill.body) - } - "placeholder_fill" if simple_placeholder_fast_spec(&spec, explicit_index) => { - let fill = fast_fill_body(&args)?; - fill_guard_key = Some(fill.guard_key); - Some(fill.body) - } _ => None, }; let Some(body) = body else { @@ -1850,21 +1816,10 @@ fn locator_fast_path_body( ) { effective_args["strict"] = Value::Bool(false); } - let body = if fill_guard_key.is_some() { - // Fill owns its strictness ordering: a retained dispatch must be observed before - // element-count strictness is considered. Wrapping it in the generic fast-path - // preflight would inspect a page-created duplicate before the fill guard can - // confirm the already-dispatched edit. - body - } else { - LOCATOR_FAST_PATH_TEMPLATE - .replace("__ARGS__", &effective_args.to_string()) - .replace("__BODY__", &body) - }; - Ok(Some(LocatorFastPathScript { - body, - fill_guard_key, - })) + let body = LOCATOR_FAST_PATH_TEMPLATE + .replace("__ARGS__", &effective_args.to_string()) + .replace("__BODY__", &body); + Ok(Some(LocatorFastPathScript { body })) } #[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] @@ -7422,10 +7377,146 @@ multiline-compatible = """4.5.6""" #[test] fn chromium_default_launch_args_disable_system_keychains() { - let args = chromium_default_launch_args(&LaunchOptions::default()); + let args = chromium_effective_launch_args(&LaunchOptions::default()); assert!(args.iter().any(|arg| arg == "--password-store=basic")); assert!(args.iter().any(|arg| arg == "--use-mock-keychain")); + assert!(args + .iter() + .any(|arg| arg == "--disable-blink-features=AutomationControlled")); + assert!(args + .iter() + .any(|arg| arg == "--enable-features=CDPScreenshotNewSurface")); + for switch in [ + "--disable-back-forward-cache", + "--disable-extensions", + "--disable-component-update", + "--disable-field-trial-config", + "--metrics-recording-only", + "--no-service-autorun", + "--disable-sync", + "--disable-default-apps", + "--disable-hang-monitor", + "--disable-ipc-flooding-protection", + "--disable-breakpad", + "--disable-client-side-phishing-detection", + "--disable-component-extensions-with-background-pages", + "--allow-pre-commit-input", + "--force-color-profile=srgb", + "--disable-search-engine-choice-screen", + ] { + assert!(args.iter().any(|arg| arg == switch), "missing {switch}"); + } + assert_eq!( + args.iter() + .filter(|arg| arg.starts_with("--disable-features=")) + .count(), + 1 + ); + assert!(args.iter().any(|arg| { + arg == "--disable-features=DialMediaRouteProvider,GlobalMediaControls,MediaRouter,OptimizationHints,Translate,HttpsUpgrades,PaintHolding,ThirdPartyStoragePartitioning,DestroyProfileOnBrowserClose,AvoidUnnecessaryBeforeUnloadCheckSync,LensOverlay" + })); + } + + #[test] + fn chromium_effective_launch_args_merge_feature_lists() { + let expected_default = vec![ + "--no-first-run", + "--no-default-browser-check", + "--password-store=basic", + "--use-mock-keychain", + "--disable-background-networking", + "--disable-background-timer-throttling", + "--disable-dev-shm-usage", + "--disable-blink-features=AutomationControlled", + "--disable-renderer-backgrounding", + "--disable-popup-blocking", + "--disable-prompt-on-repost", + "--disable-back-forward-cache", + "--disable-extensions", + "--disable-component-update", + "--disable-field-trial-config", + "--metrics-recording-only", + "--no-service-autorun", + "--disable-sync", + "--disable-default-apps", + "--disable-hang-monitor", + "--disable-ipc-flooding-protection", + "--disable-breakpad", + "--disable-client-side-phishing-detection", + "--disable-component-extensions-with-background-pages", + "--allow-pre-commit-input", + "--force-color-profile=srgb", + "--disable-search-engine-choice-screen", + "--disable-features=DialMediaRouteProvider,GlobalMediaControls,MediaRouter,OptimizationHints,Translate,HttpsUpgrades,PaintHolding,ThirdPartyStoragePartitioning,DestroyProfileOnBrowserClose,AvoidUnnecessaryBeforeUnloadCheckSync,LensOverlay", + "--enable-features=CDPScreenshotNewSurface", + "--mute-audio", + "--headless=new", + "--hide-scrollbars", + "--no-sandbox", + ] + .into_iter() + .map(str::to_string) + .collect::>(); + assert_eq!( + chromium_effective_launch_args(&LaunchOptions::default()), + expected_default + ); + + let mut user_features = LaunchOptions::default(); + user_features.args = vec![ + "--disable-features=UserFeature,DialMediaRouteProvider".to_string(), + "--enable-features=UserEnabled,CDPScreenshotNewSurface".to_string(), + "--disable-blink-features=UserBlink,AutomationControlled".to_string(), + ]; + let mut expected_user_features = expected_default.clone(); + expected_user_features[7] = + "--disable-blink-features=AutomationControlled,UserBlink".to_string(); + expected_user_features[27] = "--disable-features=DialMediaRouteProvider,GlobalMediaControls,MediaRouter,OptimizationHints,Translate,HttpsUpgrades,PaintHolding,ThirdPartyStoragePartitioning,DestroyProfileOnBrowserClose,AvoidUnnecessaryBeforeUnloadCheckSync,LensOverlay,UserFeature".to_string(); + expected_user_features[28] = + "--enable-features=CDPScreenshotNewSurface,UserEnabled".to_string(); + assert_eq!( + chromium_effective_launch_args(&user_features), + expected_user_features + ); + + let mut selective_ignore = LaunchOptions::default(); + selective_ignore.ignore_default_args = vec!["--disable-features".to_string()]; + selective_ignore.args = vec!["--disable-features=SelectiveProbe".to_string()]; + let mut expected_selective_ignore = expected_default.clone(); + expected_selective_ignore.remove(27); + expected_selective_ignore.push("--disable-features=SelectiveProbe".to_string()); + assert_eq!( + chromium_effective_launch_args(&selective_ignore), + expected_selective_ignore + ); + + let mut ignore_all = LaunchOptions::default(); + ignore_all.ignore_all_default_args = true; + ignore_all.args = vec![ + "--disable-features=First,First".to_string(), + "--disable-features=Second".to_string(), + "--enable-features=UserEnabled".to_string(), + ]; + assert_eq!(chromium_effective_launch_args(&ignore_all), ignore_all.args); + } + + #[test] + fn browser_context_defaults_create_disposable_native_contexts() { + let params = browser_context_create_params(None).unwrap(); + + assert_eq!(params["disposeOnDetach"], true); + } + + #[test] + fn navigation_history_expression_drains_both_named_buffers() { + let expression = navigation_history_expression("console-buffer", "page-errors"); + + assert!(expression.contains("console-buffer")); + assert!(expression.contains("page-errors")); + assert!(expression.contains("console: drain")); + assert!(expression.contains("page_errors: drain")); + assert_eq!(expression.matches("splice(0, history.length)").count(), 1); } #[test] @@ -7727,25 +7818,6 @@ multiline-compatible = """4.5.6""" ) .unwrap() .is_some()); - - let fill = locator_fast_path_body( - r##"{"kind":"css","selector":"#target"}"##, - "css_fill", - r#"{"strict":true,"explicit_index":false,"has_handlers":false,"value":"committed","forced":false}"#, - ) - .unwrap() - .unwrap(); - assert!(fill.fill_guard_key.is_some()); - assert!(!fill.body.contains("const fastArgs =")); - assert!( - fill.body - .find("const pendingFillGuard") - .expect("fill guard observation") - < fill - .body - .find("if (strict && (strictFrameViolation || matches.length > 1))") - .expect("fill strictness") - ); } #[test] @@ -8004,99 +8076,6 @@ multiline-compatible = """4.5.6""" ); } - #[cfg(feature = "python")] - #[tokio::test] - async fn fast_path_post_dispatch_fill_timeout_has_settle_metadata() { - Python::initialize(); - let (page, mut peer) = input_protocol_page(Some("Ada")); - let page = PyPage { inner: page.inner }; - peer.allow_close = true; - peer.pending_fill_confirmation = true; - let (result_tx, result_rx) = std::sync::mpsc::channel(); - std::thread::spawn(move || { - let result = Python::attach(|py| { - page.locator_fast_path( - py, - r##"{"kind":"css","selector":"#target"}"##, - 0, - "css_fill", - r#"{"strict":true,"explicit_index":false,"has_handlers":false,"value":"Ada","forced":false}"#, - Some(60.0), - ) - }); - let _ = result_tx.send(result.map_err(|error| error.to_string())); - }); - let deadline = tokio::time::Instant::now() + Duration::from_secs(3); - let error = loop { - if let Ok(result) = result_rx.try_recv() { - break result.expect_err("configured fast fill must time out"); - } - assert!( - tokio::time::Instant::now() < deadline, - "real fast fill timeout did not finish" - ); - let _ = tokio::time::timeout(Duration::from_millis(150), peer.next_command()).await; - }; - let marker = error - .find(ACTION_TIMEOUT_MARKER) - .expect("PyErr must contain the structured ActionTimeout marker"); - let Some(FfiWireError::ActionTimeout(payload)) = FfiWireError::parse(&error[marker..]) - else { - panic!("fast fill must preserve structured ActionTimeout: {error}"); - }; - assert_eq!(payload.phase, Some(FailurePhase::Settle)); - assert_eq!(payload.command_written, Some(CommandWritten::Yes)); - assert_eq!(payload.retryable, Some(false)); - } - - #[cfg(feature = "python")] - #[tokio::test] - async fn fast_path_retryable_tracked_timeout_cleans_retained_fill_guard() { - Python::initialize(); - let (page, mut peer) = input_protocol_page(Some("")); - let page = PyPage { inner: page.inner }; - peer.allow_close = true; - peer.fail_delete_before_begin = true; - let (result_tx, result_rx) = std::sync::mpsc::channel(); - std::thread::spawn(move || { - let result = Python::attach(|py| { - page.locator_fast_path( - py, - r##"{"kind":"css","selector":"#target"}"##, - 0, - "css_fill", - r#"{"strict":true,"explicit_index":false,"has_handlers":false,"value":"","forced":false}"#, - Some(60.0), - ) - }); - let _ = result_tx.send(result.map_err(|error| error.to_string())); - }); - let deadline = tokio::time::Instant::now() + Duration::from_secs(3); - let mut commands = Vec::new(); - let error = loop { - if let Ok(result) = result_rx.try_recv() { - break result.expect_err("configured fast fill dispatch must fail"); - } - assert!( - tokio::time::Instant::now() < deadline, - "fast fill cleanup regression did not finish" - ); - if let Ok(command) = - tokio::time::timeout(Duration::from_millis(150), peer.next_command()).await - { - commands.push(command); - } - }; - assert!( - error.contains(TIMEOUT_MARKER) - && error.contains(r#""command_written":"no","retryable":true"#), - "tracked timeout must retain its structured metadata: {error}" - ); - assert!(commands.iter().any(|command| { - input_command_expression(command) - .is_some_and(|expression| expression.contains("guard.cleanup()")) - })); - } #[test] fn ffi_wire_error_markers_round_trip_with_closed_payload_schemas() { let action_timeout = |evidence: FillAttemptEvidence| { @@ -9280,7 +9259,7 @@ multiline-compatible = """4.5.6""" .await .map(Some) }, - |_| async { Ok(()) }, + |_| async { Ok(DragOutcome::NoInterceptedDrop) }, ), async { let mut commands = Vec::new(); @@ -9314,6 +9293,147 @@ multiline-compatible = """4.5.6""" assert!(write_rx.try_recv().is_err()); } + fn new_drag_test_client() -> (CdpClient, mpsc::UnboundedReceiver) { + let (write_tx, write_rx) = mpsc::unbounded_channel(); + let (events, _) = broadcast::channel(4); + let (alive_tx, _) = watch::channel(true); + ( + CdpClient { + write_tx, + pending: Arc::new(Mutex::new(HashMap::new())), + outstanding: Arc::new(Mutex::new(HashMap::new())), + events, + event_log: Arc::new(Mutex::new(CdpEventLog::new())), + traffic_log: Arc::new(Mutex::new(CdpTrafficLog::new())), + runtime_state: Arc::new(Mutex::new(CdpRuntimeState::new(None))), + next_id: AtomicU64::new(1), + sent_runtime_enable_count: AtomicU64::new(0), + sent_target_close_count: AtomicU64::new(0), + sent_context_dispose_count: AtomicU64::new(0), + sent_get_frame_tree_count: AtomicU64::new(0), + alive: Arc::new(AtomicBool::new(true)), + alive_tx, + }, + write_rx, + ) + } + + async fn collect_drag_test_commands( + client: &CdpClient, + write_rx: &mut mpsc::UnboundedReceiver, + count: usize, + ) -> Vec { + let mut commands = Vec::new(); + for _ in 0..count { + let command = match write_rx.recv().await.unwrap() { + CdpOutgoing::Text { payload, .. } => { + serde_json::from_str::(&payload).unwrap() + } + CdpOutgoing::Close => panic!("unexpected transport close"), + }; + dispatch_cdp_payload( + json!({ "id": command["id"], "result": {} }), + Arc::clone(&client.pending), + client.events.clone(), + Arc::clone(&client.event_log), + ); + commands.push(command); + } + commands + } + + #[tokio::test] + async fn intercepted_drag_success_skips_mouse_release() { + let (client, mut write_rx) = new_drag_test_client(); + let client_for_completion = &client; + let (result, commands) = tokio::join!( + run_drag_with_cleanup( + &client, + "root-session", + "pointer-session", + 30.0, + 40.0, + 0, + async { Ok(Some(json!({ "items": [] }))) }, + |drag_data| async move { + assert!(drag_data.is_some()); + client_for_completion + .send( + "Input.dispatchDragEvent", + json!({ "type": "drop" }), + Some("pointer-session"), + Duration::from_secs(1), + ) + .await?; + Ok(DragOutcome::InterceptedDrop) + }, + ), + collect_drag_test_commands(&client, &mut write_rx, 2), + ); + + result.unwrap(); + assert_eq!(commands[0]["method"], "Input.setInterceptDrags"); + assert_eq!(commands[0]["params"]["enabled"], false); + assert_eq!(commands[1]["method"], "Input.dispatchDragEvent"); + assert_eq!(commands[1]["params"]["type"], "drop"); + assert!(write_rx.try_recv().is_err()); + } + + #[tokio::test] + async fn drag_without_intercepted_start_releases_mouse() { + let (client, mut write_rx) = new_drag_test_client(); + let (result, commands) = tokio::join!( + run_drag_with_cleanup( + &client, + "root-session", + "pointer-session", + 30.0, + 40.0, + 0, + async { Ok(None) }, + |_| async { Ok(DragOutcome::NoInterceptedDrop) }, + ), + collect_drag_test_commands(&client, &mut write_rx, 2), + ); + + result.unwrap(); + assert_eq!(commands[0]["method"], "Input.setInterceptDrags"); + assert_eq!(commands[0]["params"]["enabled"], false); + assert_eq!(commands[1]["method"], "Input.dispatchMouseEvent"); + assert_eq!(commands[1]["params"]["type"], "mouseReleased"); + assert_eq!(commands[1]["params"]["x"], 30.0); + assert_eq!(commands[1]["params"]["y"], 40.0); + assert!(write_rx.try_recv().is_err()); + } + + #[tokio::test] + async fn drag_completion_error_releases_mouse() { + let (client, mut write_rx) = new_drag_test_client(); + let (result, commands) = tokio::join!( + run_drag_with_cleanup( + &client, + "root-session", + "pointer-session", + 30.0, + 40.0, + 0, + async { Ok(Some(json!({ "items": [] }))) }, + |_| async { Err(RwError::Message("completion failed".to_string())) }, + ), + collect_drag_test_commands(&client, &mut write_rx, 2), + ); + + assert!(matches!( + result, + Err(RwError::Message(message)) if message == "completion failed" + )); + assert_eq!(commands[0]["method"], "Input.setInterceptDrags"); + assert_eq!(commands[0]["params"]["enabled"], false); + assert_eq!(commands[1]["method"], "Input.dispatchMouseEvent"); + assert_eq!(commands[1]["params"]["type"], "mouseReleased"); + assert!(write_rx.try_recv().is_err()); + } + #[tokio::test] async fn physical_drag_emits_move_press_stepped_moves_and_release_in_order() { let (write_tx, mut write_rx) = mpsc::unbounded_channel(); @@ -10334,6 +10454,485 @@ multiline-compatible = """4.5.6""" assert_eq!(batch[2]["kind"], "requestfinished"); } + #[tokio::test] + async fn console_capture_provisional_cutoff_blocks_ordered_pump_during_setup() { + let mut harness = navigation_test_harness(16); + let page = Arc::clone(&harness.page); + let event_log = Arc::clone(&harness.event_log); + let mut events = harness.events.subscribe(); + let (_close_tx, close_rx) = watch::channel(false); + let (_alive_tx, alive_rx) = watch::channel(true); + let mut state = PageEventStreamState::for_page(&page); + let mut cursor = 0; + + let capture_page = Arc::clone(&page); + let capture = tokio::spawn(async move { + enable_console_capture_for_session( + &capture_page, + "child-session", + Duration::from_secs(1), + ) + .await + }); + let enable = harness.next_command("Runtime.enable").await; + assert_eq!( + event_log + .lock() + .unwrap() + .console_replay_cutoff("child-session"), + Some(u64::MAX) + ); + + harness.emit(json!({ + "sessionId": "page-session", + "method": "Target.attachedToTarget", + "params": { + "sessionId": "child-session", + "targetInfo": { + "type": "iframe", + "targetId": "child-frame", + "parentFrameId": "main-frame" + } + } + })); + let attach = event_log + .lock() + .unwrap() + .entries_since(0) + .last() + .unwrap() + .1 + .clone(); + harness.emit(json!({ + "sessionId": "child-session", + "method": "Runtime.consoleAPICalled", + "params": { + "type": "log", + "args": [{"type": "string", "value": "before capture response"}] + } + })); + let console = event_log + .lock() + .unwrap() + .entries_since(0) + .last() + .unwrap() + .1 + .clone(); + record_page_observation_event(&page, &attach); + record_page_observation_event(&page, &console); + + let (batch, closed) = wait_for_page_event_batch( + &mut events, + Arc::clone(&event_log), + &mut cursor, + "page-session", + Arc::clone(&page.network_requests), + &mut state, + &page, + close_rx, + alive_rx, + Duration::ZERO, + 64, + ) + .await; + assert!(!closed); + assert!( + batch.is_empty(), + "the ordered pump must hide console history while Runtime.enable is pending" + ); + + page.observation_event_cursor + .store(cursor, Ordering::SeqCst); + harness.reply(&enable, json!({})); + assert_eq!(capture.await.unwrap().unwrap(), cursor); + assert_eq!( + event_log + .lock() + .unwrap() + .console_replay_cutoff("child-session"), + Some(cursor) + ); + } + + #[tokio::test] + async fn console_capture_failure_releases_ordered_replay_cutoff() { + let mut harness = navigation_test_harness(16); + let page = Arc::clone(&harness.page); + let event_log = Arc::clone(&harness.event_log); + let mut events = harness.events.subscribe(); + let (_close_tx, close_rx) = watch::channel(false); + let (_alive_tx, alive_rx) = watch::channel(true); + let mut state = PageEventStreamState::for_page(&page); + let mut cursor = 0; + + let capture_page = Arc::clone(&page); + let capture = tokio::spawn(async move { + enable_console_capture_for_session( + &capture_page, + "child-session", + Duration::from_secs(1), + ) + .await + }); + let enable = harness.next_command("Runtime.enable").await; + assert_eq!( + event_log + .lock() + .unwrap() + .console_replay_cutoff("child-session"), + Some(u64::MAX) + ); + harness.emit(json!({ + "sessionId": "page-session", + "method": "Target.attachedToTarget", + "params": { + "sessionId": "child-session", + "targetInfo": { + "type": "iframe", + "targetId": "child-frame", + "parentFrameId": "main-frame" + } + } + })); + harness.reply_error(&enable, "capture setup failed"); + assert!(capture.await.unwrap().is_err()); + let canceled_cutoff = event_log + .lock() + .unwrap() + .console_replay_cutoff("child-session") + .expect("failed capture must retain a finite cancellation cutoff"); + assert_ne!(canceled_cutoff, u64::MAX); + + harness.emit(json!({ + "sessionId": "child-session", + "method": "Runtime.consoleAPICalled", + "params": { + "type": "log", + "args": [{"type": "string", "value": "after failed capture"}] + } + })); + let (batch, closed) = wait_for_page_event_batch( + &mut events, + Arc::clone(&event_log), + &mut cursor, + "page-session", + Arc::clone(&page.network_requests), + &mut state, + &page, + close_rx, + alive_rx, + Duration::ZERO, + 64, + ) + .await; + assert!(!closed); + assert_eq!(batch.len(), 1); + assert_eq!(batch[0]["kind"], "console"); + assert_eq!(batch[0]["payload"]["text"], "after failed capture"); + } + + #[tokio::test] + async fn worker_resume_claim_and_failure_use_production_watchdog_path() { + let mut harness = navigation_test_harness(16); + let page = Arc::clone(&harness.page); + let event_log = Arc::clone(&harness.event_log); + page.frame_state + .lock() + .unwrap() + .set_worker_event_interest(true); + harness.emit(json!({ + "sessionId": "page-session", + "method": "Target.attachedToTarget", + "params": { + "sessionId": "worker-session", + "targetInfo": { + "type": "worker", + "targetId": "worker-target" + } + } + })); + let attached = event_log + .lock() + .unwrap() + .entries_since(0) + .last() + .unwrap() + .1 + .clone(); + handle_page_oopif_event(Arc::clone(&page), attached.clone()).await; + assert!(page + .frame_state + .lock() + .unwrap() + .worker_resume_handoff_pending("worker-session")); + assert_eq!( + page_worker_session_from_attachment(&page, &attached), + Some("worker-session".to_string()) + ); + let mut updates = page.frame_state.lock().unwrap().subscribe_session_updates(); + + let first_resume_page = Arc::clone(&page); + let first_resume = tokio::spawn(async move { + resume_worker_after_claim(&first_resume_page, "worker-session", Duration::from_secs(1)) + .await + }); + let first_command = harness + .next_command("Runtime.runIfWaitingForDebugger") + .await; + harness.reply_error(&first_command, "transient resume failure"); + assert!(first_resume.await.unwrap().is_err()); + assert!(page + .frame_state + .lock() + .unwrap() + .worker_resume_handoff_pending("worker-session")); + + let mut watchdog = Box::pin(worker_resume_watchdog_step( + &page, + "worker-session", + &mut updates, + Duration::ZERO, + )); + let retry_command = tokio::select! { + command = harness.next_command("Runtime.runIfWaitingForDebugger") => command, + result = &mut watchdog => panic!("watchdog completed without a resume command: {result}"), + }; + harness.reply(&retry_command, json!({})); + assert!(watchdog.await); + assert!(!page + .frame_state + .lock() + .unwrap() + .worker_resume_handoff_pending("worker-session")); + + harness.emit(json!({ + "sessionId": "page-session", + "method": "Target.attachedToTarget", + "params": { + "sessionId": "worker-session-claimed", + "targetInfo": { + "type": "worker", + "targetId": "worker-target-claimed" + } + } + })); + let second_attached = event_log + .lock() + .unwrap() + .entries_since(0) + .last() + .unwrap() + .1 + .clone(); + handle_page_oopif_event(Arc::clone(&page), second_attached.clone()).await; + assert_eq!( + page_worker_session_from_attachment(&page, &second_attached), + Some("worker-session-claimed".to_string()) + ); + let mut claimed_updates = page.frame_state.lock().unwrap().subscribe_session_updates(); + assert!( + worker_resume_watchdog_step( + &page, + "worker-session-claimed", + &mut claimed_updates, + Duration::ZERO, + ) + .await + ); + assert!( + harness.try_next_command_any().is_none(), + "the watchdog must not steal a claimed consumer handoff" + ); + let second_resume_page = Arc::clone(&page); + let second_resume = tokio::spawn(async move { + resume_worker_after_claim( + &second_resume_page, + "worker-session-claimed", + Duration::from_secs(1), + ) + .await + }); + let second_command = harness + .next_command("Runtime.runIfWaitingForDebugger") + .await; + harness.reply(&second_command, json!({})); + assert!(second_resume.await.unwrap().is_ok()); + } + + #[tokio::test] + async fn worker_resume_watchdog_stops_after_page_close_cleanup() { + let mut harness = navigation_test_harness(16); + let page = Arc::clone(&harness.page); + { + let mut state = page.frame_state.lock().unwrap(); + state.set_worker_event_interest(true); + } + harness.emit(json!({ + "sessionId": "page-session", + "method": "Target.attachedToTarget", + "params": { + "sessionId": "worker-session", + "targetInfo": { + "type": "worker", + "targetId": "worker-target" + } + } + })); + let attached = harness + .event_log + .lock() + .unwrap() + .entries_since(0) + .last() + .unwrap() + .1 + .clone(); + handle_page_oopif_event(Arc::clone(&page), attached).await; + assert!(page + .frame_state + .lock() + .unwrap() + .worker_resume_handoff_pending("worker-session")); + + page.target_closed.store(true, Ordering::SeqCst); + page.close_in_background(); + assert!(!page + .frame_state + .lock() + .unwrap() + .worker_resume_handoff_pending("worker-session")); + let mut updates = page.frame_state.lock().unwrap().subscribe_session_updates(); + assert!( + !worker_resume_watchdog_step(&page, "worker-session", &mut updates, Duration::ZERO,) + .await + ); + assert!(harness.try_next_command_any().is_none()); + } + + #[tokio::test] + async fn native_waiter_snapshot_survives_oopif_console_before_detach_during_setup() { + let harness = navigation_test_harness(16); + let page = Arc::clone(&harness.page); + let event_log = Arc::clone(&harness.event_log); + warm_main_frame_cache(&page); + let (lifecycle_events, lifecycle_cursor) = page.browser.client.subscribe_with_cursor(); + start_page_frame_cache_tracking_with_subscription( + &page, + lifecycle_events, + lifecycle_cursor, + "page-session", + ); + let (mut entered, mut completed, released) = + install_page_lifecycle_test_barrier(&page, "child-session"); + + harness.emit(json!({ + "sessionId": "page-session", + "method": "Target.attachedToTarget", + "params": { + "sessionId": "child-session", + "targetInfo": { + "type": "iframe", + "parentFrameId": "main-frame" + } + } + })); + tokio::time::timeout(Duration::from_secs(1), entered.changed()) + .await + .expect("lifecycle consumer must reach the setup barrier") + .expect("lifecycle barrier sender must remain connected"); + assert!(*entered.borrow()); + assert_eq!(event_log.lock().unwrap().cursor(), 1); + assert!( + !page + .frame_state + .lock() + .unwrap() + .owns_session("child-session"), + "the lifecycle consumer must still be paused at the barrier" + ); + + let child_cursor = event_log.lock().unwrap().cursor(); + let (mut events, mut cursor, mut state) = + PageEventStreamState::subscribe_page_event_stream(&page); + assert_eq!(cursor, child_cursor); + assert!(state.owns_session("child-session")); + + released.notify_one(); + tokio::time::timeout(Duration::from_secs(1), completed.changed()) + .await + .expect("lifecycle consumer must leave the setup barrier") + .expect("lifecycle completion sender must remain connected"); + let lifecycle_tail = harness.event_log.lock().unwrap().cursor(); + harness.emit(json!({ + "method": "Test.lifecycle-barrier-released", + "params": {} + })); + tokio::time::timeout(Duration::from_secs(1), async { + loop { + if page.observation_event_cursor.load(Ordering::SeqCst) >= lifecycle_tail + 1 { + break; + } + tokio::task::yield_now().await; + } + }) + .await + .expect("lifecycle consumer must finish the barrier event"); + + harness.emit(json!({ + "sessionId": "child-session", + "method": "Runtime.consoleAPICalled", + "params": { + "type": "log", + "args": [{"type": "string", "value": "last"}] + } + })); + harness.emit(json!({ + "method": "Target.detachedFromTarget", + "params": {"sessionId": "child-session"} + })); + + let (_close_tx, close_rx) = watch::channel(false); + let (_alive_tx, alive_rx) = watch::channel(true); + let (batch, closed) = wait_for_page_event_batch( + &mut events, + Arc::clone(&event_log), + &mut cursor, + "page-session", + Arc::clone(&page.network_requests), + &mut state, + &page, + close_rx, + alive_rx, + Duration::ZERO, + 1, + ) + .await; + assert!(!closed); + assert_eq!(batch.len(), 1); + assert_eq!(batch[0]["kind"], "console"); + assert_eq!(batch[0]["payload"]["text"], "last"); + let (_close_tx, close_rx) = watch::channel(false); + let (_alive_tx, alive_rx) = watch::channel(true); + let (tail, tail_closed) = wait_for_page_event_batch( + &mut events, + Arc::clone(&event_log), + &mut cursor, + "page-session", + Arc::clone(&page.network_requests), + &mut state, + &page, + close_rx, + alive_rx, + Duration::ZERO, + 64, + ) + .await; + assert!(!tail_closed); + assert!(tail.is_empty()); + assert!(!state.owns_session("child-session")); + } + #[test] fn combined_page_stream_filters_sessions_and_merges_frame_navigation_methods() { let event_log = Arc::new(Mutex::new(CdpEventLog::new())); @@ -12910,6 +13509,103 @@ return this.dataset.mainWorldOverride === "observed"; "stale attachment must not send recovery setup commands" ); } + #[tokio::test] + async fn worker_session_sequence_gate_rejects_stale_attachment_and_clears_capture_state() { + let harness = navigation_test_harness(8); + let worker_session = "worker-session"; + let attach = json!({ + "__rustwright_cdp_event_seq": 10, + "sessionId": "page-session", + "method": "Target.attachedToTarget", + "params": { + "sessionId": worker_session, + "targetInfo": {"type": "worker"} + } + }); + handle_page_oopif_event(Arc::clone(&harness.page), attach.clone()).await; + harness + .page + .console_capture + .enabled_sessions + .lock() + .await + .insert(worker_session.to_string()); + harness + .page + .console_replay_until_event_cursor + .lock() + .unwrap() + .insert(worker_session.to_string(), 11); + + handle_page_oopif_event( + Arc::clone(&harness.page), + json!({ + "__rustwright_cdp_event_seq": 20, + "sessionId": "page-session", + "method": "Target.detachedFromTarget", + "params": {"sessionId": worker_session} + }), + ) + .await; + handle_page_oopif_event(Arc::clone(&harness.page), attach).await; + + let state = harness.page.frame_state.lock().unwrap(); + assert!(!state.owns_worker_session(worker_session)); + drop(state); + assert!(!harness + .page + .console_capture + .enabled_sessions + .lock() + .await + .contains(worker_session)); + assert!(!harness + .page + .console_replay_until_event_cursor + .lock() + .unwrap() + .contains_key(worker_session)); + } + + #[tokio::test] + async fn console_capture_multi_session_setup_uses_remaining_deadline() { + let mut harness = navigation_test_harness(8); + { + let mut state = harness.page.frame_state.lock().unwrap(); + state.record_session_for_frame("iframe-frame", "iframe-session"); + state + .iframe_sessions_ready + .insert("iframe-session".to_string()); + } + let page = Arc::clone(&harness.page); + let operation = + async move { enable_console_capture(&page, Duration::from_millis(100)).await }; + let responder = async { + let first = harness.next_command("Runtime.enable").await; + tokio::time::sleep(Duration::from_millis(60)).await; + harness.reply(&first, json!({})); + let second = harness.next_command("Runtime.enable").await; + (first["sessionId"].clone(), second["sessionId"].clone()) + }; + let started = Instant::now(); + let (result, (first_session, second_session)) = + tokio::time::timeout(Duration::from_millis(135), async { + tokio::join!(operation, responder) + }) + .await + .expect("second session setup must consume the remaining budget"); + + assert!( + matches!(result, Err(RwError::Timeout(ms)) if ms > 0 && ms < 100), + "second setup must report only its remaining timeout: {result:?}" + ); + assert_ne!(first_session, second_session); + assert!( + started.elapsed() < Duration::from_millis(135), + "second setup exceeded the original deadline: {:?}", + started.elapsed() + ); + } #[tokio::test] async fn sequence_gate_live_iframe_attachments_without_parent_mapping_are_noops() { @@ -22199,6 +22895,200 @@ return this.dataset.mainWorldOverride === "observed"; ); } + #[test] + fn goto_timeout_reports_configured_budget_after_suboperation_timeout() { + let deadline = OperationDeadline::new(Duration::from_millis(50)); + assert!(matches!( + normalize_navigation_timeout(RwError::Timeout(49), deadline), + RwError::Timeout(50) + )); + } + + #[tokio::test] + async fn observed_goto_timeout_reports_configured_budget_after_suboperation_timeout() { + let mut harness = navigation_test_harness(4); + let timeout = Duration::from_millis(150); + let navigation = page_goto_observed_async( + Arc::clone(&harness.page), + "https://example.test/observed-timeout".to_owned(), + "load".to_owned(), + timeout, + None, + ); + let responder = async move { + let navigate = harness.next_command("Page.navigate").await; + tokio::time::sleep(Duration::from_millis(90)).await; + harness.reply( + &navigate, + json!({ "frameId": "frame-1", "loaderId": "loader-1" }), + ); + harness.next_command("Runtime.evaluate").await; + }; + + let (result, ()) = tokio::time::timeout(Duration::from_millis(300), async { + tokio::join!(navigation, responder) + }) + .await + .expect("observed goto timeout should remain bounded"); + assert!( + matches!(result, Err(RwError::Timeout(150))), + "observed goto timeout: {result:?}" + ); + } + + #[tokio::test] + async fn reload_timeout_reports_configured_budget_after_suboperation_timeout() { + let mut harness = navigation_test_harness(4); + let timeout = Duration::from_millis(150); + let navigation = page_reload_observed_async( + Arc::clone(&harness.page), + Arc::clone(&harness.page.browser.client), + "page-session".to_owned(), + "load".to_owned(), + timeout, + ); + let responder = async move { + harness + .reply_next( + "Page.getFrameTree", + json!({ + "frameTree": { + "frame": { + "id": "frame-main", + "url": "https://example.test/reload-timeout" + } + } + }), + ) + .await; + let reload = harness.next_command("Page.reload").await; + tokio::time::sleep(Duration::from_millis(90)).await; + harness.reply(&reload, json!({})); + harness.next_command("Runtime.evaluate").await; + }; + + let (result, ()) = tokio::time::timeout(Duration::from_millis(300), async { + tokio::join!(navigation, responder) + }) + .await + .expect("reload timeout should remain bounded"); + assert!( + matches!(result, Err(RwError::Timeout(150))), + "reload timeout: {result:?}" + ); + } + + #[tokio::test] + async fn history_navigation_timeouts_report_configured_budget_after_suboperation_timeout() { + for offset in [-1, 1] { + let mut harness = navigation_test_harness(4); + let timeout = Duration::from_millis(150); + let navigation = page_history_observed_async( + Arc::clone(&harness.page), + Arc::clone(&harness.page.browser.client), + "page-session".to_owned(), + offset, + "commit".to_owned(), + timeout, + ); + let responder = async move { + let history = harness.next_command("Page.getNavigationHistory").await; + tokio::time::sleep(Duration::from_millis(90)).await; + harness.reply( + &history, + json!({ + "currentIndex": if offset < 0 { 1 } else { 0 }, + "entries": [ + { "id": 1, "url": "https://example.test/history-a" }, + { "id": 2, "url": "https://example.test/history-b" } + ] + }), + ); + harness + .reply_next( + "Page.getFrameTree", + json!({ + "frameTree": { + "frame": { + "id": "frame-main", + "url": "https://example.test/history-current" + } + } + }), + ) + .await; + harness.next_command("Page.navigateToHistoryEntry").await; + }; + + let (result, ()) = tokio::time::timeout(Duration::from_millis(300), async { + tokio::join!(navigation, responder) + }) + .await + .expect("history timeout should remain bounded"); + assert!( + matches!(result, Err(RwError::Timeout(150))), + "history offset {offset} timeout: {result:?}" + ); + } + } + + #[test] + fn legacy_navigation_timeouts_report_configured_budget_after_suboperation_timeout() { + let timeout = Duration::from_millis(150); + + let mut reload_harness = navigation_test_harness(4); + let reload_page = RustwrightPage { + inner: Arc::clone(&reload_harness.page), + }; + let reload_handle = reload_harness.page.browser.runtime.handle().clone(); + reload_handle.spawn(async move { + let reload = reload_harness.next_command("Page.reload").await; + tokio::time::sleep(Duration::from_millis(90)).await; + reload_harness.reply(&reload, json!({})); + }); + let reload_result = reload_page.reload(Some("load"), timeout); + assert!( + matches!(reload_result, Err(RwError::Timeout(150))), + "reload timeout: {reload_result:?}" + ); + + for offset in [-1_i64, 1] { + let mut history_harness = navigation_test_harness(4); + let history_page = RustwrightPage { + inner: Arc::clone(&history_harness.page), + }; + let history_handle = history_harness.page.browser.runtime.handle().clone(); + history_handle.spawn(async move { + let history = history_harness + .next_command("Page.getNavigationHistory") + .await; + tokio::time::sleep(Duration::from_millis(90)).await; + history_harness.reply( + &history, + json!({ + "currentIndex": if offset < 0 { 1 } else { 0 }, + "entries": [ + { "id": 1, "url": "https://example.test/history-a" }, + { "id": 2, "url": "https://example.test/history-b" } + ] + }), + ); + history_harness + .next_command("Page.navigateToHistoryEntry") + .await; + }); + let result = if offset < 0 { + history_page.go_back(Some("commit"), timeout) + } else { + history_page.go_forward(Some("commit"), timeout) + }; + assert!( + matches!(result, Err(RwError::Timeout(150))), + "history offset {offset} timeout: {result:?}" + ); + } + } + #[tokio::test] async fn navigation_reconciles_missing_domcontentloaded_event_from_ready_state() { let mut harness = navigation_test_harness(4); @@ -22483,6 +23373,10 @@ return this.dataset.mainWorldOverride === "observed"; next_seq: 10, events: VecDeque::with_capacity(CDP_EVENT_LOG_LIMIT), action_dispatch_receipts: HashMap::new(), + console_replay_cutoffs: HashMap::new(), + console_replay_cutoff_generations: HashMap::new(), + console_replay_cutoff_cancellations: HashMap::new(), + next_console_replay_cutoff_generation: 0, }; let initial = json!({ "sessionId": "page-session", @@ -22731,6 +23625,7 @@ return this.dataset.mainWorldOverride === "observed"; #[cfg(feature = "python")] #[test] fn python_launch_option_parse_errors_remain_value_errors() { + Python::initialize(); Python::attach(|py| { let error = parse_python_launch_options("{").unwrap_err(); assert!(error.is_instance_of::(py)); @@ -24004,10 +24899,28 @@ fn wrap_cdp_command_error(method: &str, error: RwError) -> RwError { } } +fn merge_console_replay_cutoff(stored: &mut u64, cutoff: u64) { + if *stored == u64::MAX && cutoff != u64::MAX { + // A finite response cursor finalizes an in-flight provisional sentinel. Do not let + // overflow-recreated state retain the sentinel forever. + *stored = cutoff; + } else if cutoff == u64::MAX { + // A new capture generation must suppress replay immediately, even if an older finite + // cutoff is still retained for a slow ordered consumer. + *stored = u64::MAX; + } else { + *stored = (*stored).max(cutoff); + } +} + struct CdpEventLog { next_seq: u64, events: VecDeque<(u64, Value)>, action_dispatch_receipts: HashMap, + console_replay_cutoffs: HashMap, + console_replay_cutoff_generations: HashMap, + console_replay_cutoff_cancellations: HashMap, + next_console_replay_cutoff_generation: u64, } impl CdpEventLog { @@ -24016,8 +24929,86 @@ impl CdpEventLog { next_seq: 0, events: VecDeque::with_capacity(CDP_EVENT_LOG_LIMIT), action_dispatch_receipts: HashMap::new(), + console_replay_cutoff_cancellations: HashMap::new(), + console_replay_cutoffs: HashMap::new(), + console_replay_cutoff_generations: HashMap::new(), + next_console_replay_cutoff_generation: 0, + } + } + + fn begin_console_replay_cutoff(&mut self, session_id: &str) -> u64 { + advance_lifetime_counter(&mut self.next_console_replay_cutoff_generation); + let generation = self.next_console_replay_cutoff_generation; + self.console_replay_cutoff_generations + .insert(session_id.to_string(), generation); + self.console_replay_cutoff_cancellations.remove(session_id); + // The provisional sentinel is deliberately published in the event-log metadata. + // Every ordered consumer reads this map, so no replay event can pass while Runtime.enable + // is still in flight. + self.console_replay_cutoffs + .insert(session_id.to_string(), u64::MAX); + generation + } + + fn finalize_console_replay_cutoff(&mut self, session_id: &str, generation: u64, cutoff: u64) { + if self.console_replay_cutoff_generations.get(session_id) == Some(&generation) { + self.console_replay_cutoffs + .insert(session_id.to_string(), cutoff); + self.console_replay_cutoff_cancellations.remove(session_id); + self.prune_console_replay_cutoffs(); } } + fn cancel_console_replay_cutoff(&mut self, session_id: &str, generation: u64) { + if self.console_replay_cutoff_generations.get(session_id) == Some(&generation) { + let cutoff = self.cursor(); + self.console_replay_cutoffs + .insert(session_id.to_string(), cutoff); + self.console_replay_cutoff_generations.remove(session_id); + self.console_replay_cutoff_cancellations + .insert(session_id.to_string(), generation); + self.prune_console_replay_cutoffs(); + } + } + fn finalize_console_replay_cutoff_on_detach(&mut self, session_id: &str, cutoff: Option) { + let generation = self.console_replay_cutoff_generations.remove(session_id); + if self.console_replay_cutoffs.get(session_id) == Some(&u64::MAX) { + self.console_replay_cutoffs.insert( + session_id.to_string(), + cutoff.unwrap_or_else(|| self.cursor()), + ); + if let Some(generation) = generation { + self.console_replay_cutoff_cancellations + .insert(session_id.to_string(), generation); + } + } + self.prune_console_replay_cutoffs(); + } + + fn console_replay_cutoff_with_generation(&self, session_id: &str) -> Option<(u64, u64)> { + let generation = self + .console_replay_cutoff_generations + .get(session_id) + .copied() + .or_else(|| { + self.console_replay_cutoff_cancellations + .get(session_id) + .copied() + })?; + Some((generation, self.console_replay_cutoff(session_id)?)) + } + fn console_replay_cutoff(&self, session_id: &str) -> Option { + self.console_replay_cutoffs.get(session_id).copied() + } + + fn prune_console_replay_cutoffs(&mut self) { + let oldest_seq = self.oldest_seq(); + self.console_replay_cutoffs + .retain(|_, stored| *stored == u64::MAX || *stored >= oldest_seq); + self.console_replay_cutoff_cancellations + .retain(|session_id, _| self.console_replay_cutoffs.contains_key(session_id)); + self.console_replay_cutoff_generations + .retain(|session_id, _| self.console_replay_cutoffs.contains_key(session_id)); + } fn cursor(&self) -> u64 { self.next_seq @@ -24036,8 +25027,15 @@ impl CdpEventLog { while self.events.len() > CDP_EVENT_LOG_LIMIT { self.events.pop_front(); } + // Cutoff pruning is low-water driven, not part of the hot event path. A bounded batch + // keeps the map inexpensive under high-volume Runtime/Network traffic. + let oldest_seq = self.oldest_seq(); + if oldest_seq != 0 && oldest_seq % 256 == 0 { + self.prune_console_replay_cutoffs(); + } event } + fn register_action_dispatch( &mut self, session_id: &str, @@ -24068,8 +25066,8 @@ impl CdpEventLog { dispatch_id: &str, ) -> Option { self.action_dispatch_receipts - .get(session_id)? - .receipt(dispatch_id) + .get(session_id) + .and_then(|receipts| receipts.receipt(dispatch_id)) } fn settle_action_dispatch(&mut self, session_id: &str, dispatch_id: &str) -> Option { @@ -24082,16 +25080,27 @@ impl CdpEventLog { } fn entries_since(&self, cursor: u64) -> Vec<(u64, Value)> { + self.entries_since_limited(cursor, usize::MAX) + } + + fn entries_since_limited(&self, cursor: u64, limit: usize) -> Vec<(u64, Value)> { + let oldest = self.oldest_seq(); + let start = cursor.saturating_sub(oldest) as usize; self.events .iter() - .filter(|(seq, _)| *seq >= cursor) + .skip(start) + .take(limit) .map(|(seq, event)| (*seq, event.clone())) .collect() } + fn entries_between(&self, start: u64, end: u64) -> Vec<(u64, Value)> { + let oldest = self.oldest_seq(); + let start_index = start.saturating_sub(oldest) as usize; self.events .iter() - .filter(|(seq, _)| *seq >= start && *seq < end) + .skip(start_index) + .take(end.saturating_sub(start) as usize) .map(|(seq, event)| (*seq, event.clone())) .collect() } @@ -26325,6 +27334,7 @@ struct IframeSetupTaskRegistry { handles: Mutex>, next_token: AtomicU64, permits: Arc, + auto_attach_locks: Arc>>>>, } impl Default for IframeSetupTaskRegistry { @@ -26335,11 +27345,20 @@ impl Default for IframeSetupTaskRegistry { permits: Arc::new(tokio::sync::Semaphore::new( MAX_CONCURRENT_ATTACHED_IFRAME_SETUPS, )), + auto_attach_locks: Arc::new(Mutex::new(HashMap::new())), } } } impl IframeSetupTaskRegistry { + fn auto_attach_lock_for_session(&self, session_id: &str) -> Arc> { + let mut locks = self.auto_attach_locks.lock().unwrap(); + Arc::clone( + locks + .entry(session_id.to_string()) + .or_insert_with(|| Arc::new(tokio::sync::Mutex::new(()))), + ) + } fn abort_generations(&self, generations: impl IntoIterator) { let mut handles = self.handles.lock().unwrap(); for generation in generations { @@ -26564,8 +27583,15 @@ impl PageInner { fn abort_iframe_setup_tasks(&self) { self.iframe_setup_tasks.abort_all(); } + fn clear_worker_resume_handoffs(&self) { + self.frame_state + .lock() + .unwrap() + .clear_worker_resume_handoffs(); + } fn close_in_background(&self) { + self.clear_worker_resume_handoffs(); self.abort_iframe_setup_tasks(); if !self.close_target_on_drop.swap(false, Ordering::SeqCst) || self.lifecycle.is_closing_or_closed() @@ -27308,10 +28334,32 @@ struct AuthoritativeFrameTreeCommitEffects { removed_setup_generations: Vec, } +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum WorkerResumeHandoffPhase { + CaptureReady, + ResumeClaimed, + ResumeCompleted, + ResumeFailed, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +struct WorkerResumeHandoff { + sequence: u64, + phase: WorkerResumeHandoffPhase, + python_capture_ready: bool, +} + #[derive(Debug)] struct PageFrameState { main_session_id: String, frame_sessions: HashMap, + worker_sessions: HashSet, + worker_target_sessions: HashMap, + worker_session_event_sequences: HashMap, + worker_forwarding_interest: bool, + worker_resume_handoffs: HashMap, + worker_event_interest: bool, + worker_auto_attach_requested: bool, frame_attachment_generations: HashMap, frame_swaps: HashMap, frame_session_waiters: HashMap>, @@ -27347,6 +28395,13 @@ impl PageFrameState { Self { main_session_id, frame_sessions: HashMap::new(), + worker_sessions: HashSet::new(), + worker_target_sessions: HashMap::new(), + worker_session_event_sequences: HashMap::new(), + worker_resume_handoffs: HashMap::new(), + worker_event_interest: false, + worker_forwarding_interest: false, + worker_auto_attach_requested: false, frame_attachment_generations: HashMap::new(), frame_swaps: HashMap::new(), frame_session_waiters: HashMap::new(), @@ -27381,6 +28436,294 @@ impl PageFrameState { self.frame_event_cursor = cursor; } + fn worker_session_mutation_is_current(&self, session_id: &str, sequence: Option) -> bool { + match sequence { + Some(sequence) => self + .worker_session_event_sequences + .get(session_id) + .is_none_or(|last_sequence| sequence > *last_sequence), + // Unstamped worker mutations are retained for direct attach APIs, but they + // must never overwrite an event whose ordering is already known. + None => !self.worker_session_event_sequences.contains_key(session_id), + } + } + + fn worker_session_attachment_is_current( + &self, + session_id: &str, + sequence: Option, + ) -> bool { + match sequence { + Some(sequence) => self + .worker_session_event_sequences + .get(session_id) + .is_none_or(|last_sequence| { + sequence > *last_sequence + || (sequence == *last_sequence && self.worker_sessions.contains(session_id)) + }), + None => !self.worker_session_event_sequences.contains_key(session_id), + } + } + + fn record_worker_session(&mut self, session_id: &str) -> bool { + self.record_worker_session_at_sequence(session_id, None) + } + + fn record_worker_session_at_sequence( + &mut self, + session_id: &str, + sequence: Option, + ) -> bool { + if !self.worker_session_mutation_is_current(session_id, sequence) { + return false; + } + if let Some(sequence) = sequence { + self.worker_session_event_sequences + .insert(session_id.to_string(), sequence); + } + let inserted = self.worker_sessions.insert(session_id.to_string()); + if inserted { + self.notify_session_update(); + } + inserted + } + fn record_worker_target_session_at_sequence( + &mut self, + target_id: &str, + session_id: &str, + sequence: Option, + ) -> bool { + let accepted = match sequence { + Some(_) => { + self.worker_session_attachment_is_current(session_id, sequence) + && (self.worker_session_recorded_at_sequence(session_id, sequence) + || self.record_worker_session_at_sequence(session_id, sequence)) + } + None => self.record_worker_session_at_sequence(session_id, None), + }; + if accepted { + self.worker_target_sessions + .insert(target_id.to_string(), session_id.to_string()); + } + accepted + } + + fn associate_worker_target_session(&mut self, target_id: &str, session_id: &str) -> bool { + if !self.worker_sessions.contains(session_id) { + return false; + } + self.worker_target_sessions + .insert(target_id.to_string(), session_id.to_string()); + true + } + + fn worker_session_for_target(&self, target_id: &str) -> Option { + let session_id = self.worker_target_sessions.get(target_id)?; + self.worker_sessions + .contains(session_id) + .then(|| session_id.clone()) + } + + fn remove_worker_session_at_sequence( + &mut self, + session_id: &str, + sequence: Option, + ) -> bool { + if !self.worker_session_mutation_is_current(session_id, sequence) { + return false; + } + self.worker_resume_handoffs.remove(session_id); + if let Some(sequence) = sequence { + self.worker_session_event_sequences + .insert(session_id.to_string(), sequence); + } + self.worker_target_sessions + .retain(|_, owner_session_id| owner_session_id != session_id); + let removed = self.worker_sessions.remove(session_id); + if removed { + self.notify_session_update(); + } + // A sequence-confirmed detach is meaningful even when a different consumer + // already removed the set entry. Callers use this result to clear capture state. + true + } + + fn owns_worker_session(&self, session_id: &str) -> bool { + self.worker_sessions.contains(session_id) + } + + fn arm_worker_resume_handoff(&mut self, session_id: &str, sequence: Option) { + let sequence = sequence.unwrap_or(0); + if self + .worker_resume_handoffs + .get(session_id) + .is_some_and(|handoff| { + handoff.sequence >= sequence + || handoff.phase != WorkerResumeHandoffPhase::ResumeCompleted + }) + { + return; + } + self.worker_resume_handoffs.insert( + session_id.to_string(), + WorkerResumeHandoff { + sequence, + phase: WorkerResumeHandoffPhase::CaptureReady, + python_capture_ready: false, + }, + ); + self.notify_session_update(); + } + + fn claim_worker_resume_handoff(&mut self, session_id: &str) -> bool { + let Some(handoff) = self.worker_resume_handoffs.get_mut(session_id) else { + return false; + }; + if handoff.phase != WorkerResumeHandoffPhase::CaptureReady { + return false; + } + handoff.phase = WorkerResumeHandoffPhase::ResumeClaimed; + self.notify_session_update(); + true + } + + fn mark_worker_resume_handoff_failed(&mut self, session_id: &str) -> bool { + let Some(handoff) = self.worker_resume_handoffs.get_mut(session_id) else { + return false; + }; + if handoff.phase != WorkerResumeHandoffPhase::ResumeClaimed { + return false; + } + handoff.phase = WorkerResumeHandoffPhase::ResumeFailed; + self.notify_session_update(); + true + } + + fn complete_worker_resume_handoff(&mut self, session_id: &str) -> bool { + let Some(handoff) = self.worker_resume_handoffs.get_mut(session_id) else { + return false; + }; + if handoff.phase != WorkerResumeHandoffPhase::ResumeClaimed { + return false; + } + handoff.phase = WorkerResumeHandoffPhase::ResumeCompleted; + self.notify_session_update(); + true + } + fn mark_worker_capture_ready(&mut self, session_id: &str) -> bool { + let Some(handoff) = self.worker_resume_handoffs.get_mut(session_id) else { + return false; + }; + if handoff.phase != WorkerResumeHandoffPhase::ResumeClaimed { + return false; + } + handoff.python_capture_ready = true; + self.notify_session_update(); + true + } + + fn worker_capture_ready_or_unclaimed(&self, session_id: &str) -> bool { + if !self.worker_forwarding_interest { + return true; + } + self.worker_resume_handoffs + .get(session_id) + .is_none_or(|handoff| { + handoff.python_capture_ready + || handoff.phase == WorkerResumeHandoffPhase::ResumeFailed + }) + } + + fn set_worker_forwarding_interest(&mut self, interested: bool) { + if self.worker_forwarding_interest != interested { + self.worker_forwarding_interest = interested; + self.notify_session_update(); + } + } + + fn clear_worker_resume_handoffs(&mut self) { + if !self.worker_resume_handoffs.is_empty() { + self.worker_resume_handoffs.clear(); + self.notify_session_update(); + } + } + fn prepare_worker_resume(&mut self, session_id: &str) -> bool { + let Some(handoff) = self.worker_resume_handoffs.get_mut(session_id) else { + // Workers discovered after capture was disabled still need the normal idempotent + // Runtime.runIfWaitingForDebugger command. + return true; + }; + match handoff.phase { + WorkerResumeHandoffPhase::ResumeCompleted => false, + WorkerResumeHandoffPhase::CaptureReady | WorkerResumeHandoffPhase::ResumeFailed => { + handoff.phase = WorkerResumeHandoffPhase::ResumeClaimed; + self.notify_session_update(); + true + } + WorkerResumeHandoffPhase::ResumeClaimed => true, + } + } + + fn take_worker_resume_fallback(&mut self, session_id: &str) -> bool { + if !self.worker_sessions.contains(session_id) { + return false; + } + let Some(handoff) = self.worker_resume_handoffs.get_mut(session_id) else { + return false; + }; + if !matches!( + handoff.phase, + WorkerResumeHandoffPhase::CaptureReady | WorkerResumeHandoffPhase::ResumeFailed + ) { + return false; + } + handoff.phase = WorkerResumeHandoffPhase::ResumeClaimed; + self.notify_session_update(); + true + } + + fn worker_resume_handoff_pending(&self, session_id: &str) -> bool { + self.worker_resume_handoffs + .get(session_id) + .is_some_and(|handoff| handoff.phase != WorkerResumeHandoffPhase::ResumeCompleted) + } + + fn set_worker_event_interest(&mut self, interested: bool) { + if self.worker_event_interest != interested { + self.worker_event_interest = interested; + self.notify_session_update(); + } + } + + fn worker_event_interest(&self) -> bool { + self.worker_event_interest + } + + fn mark_worker_auto_attach_requested(&mut self) { + self.worker_auto_attach_requested = true; + } + + fn worker_auto_attach_requested(&self) -> bool { + self.worker_auto_attach_requested + } + + fn all_owned_session_ids(&self) -> Vec { + let mut sessions = vec![self.main_session_id.clone()]; + for session_id in self.session_frames.keys() { + if !sessions.iter().any(|existing| existing == session_id) { + sessions.push(session_id.clone()); + } + } + sessions + } + + fn worker_session_recorded_at_sequence(&self, session_id: &str, sequence: Option) -> bool { + sequence.is_some_and(|sequence| { + self.worker_sessions.contains(session_id) + && self.worker_session_event_sequences.get(session_id) == Some(&sequence) + }) + } + fn mark_frame_event_listener_registered(&mut self) { self.frame_event_listener_registered = true; self.mark_all_frame_caches_dirty(); @@ -27795,6 +29138,9 @@ impl PageFrameState { fn owns_session(&self, session_id: &str) -> bool { session_id == self.main_session_id || self.session_frames.contains_key(session_id) } + fn owns_frame_target(&self, target_id: &str) -> bool { + self.frame_sessions.contains_key(target_id) + } fn refresh_registration(&self, session_id: &str) -> Option<(Option, u64)> { self.owns_session(session_id).then(|| { @@ -28721,6 +30067,7 @@ struct PyCdpEventWaiter { #[pyclass(name = "Worker")] struct PyWorker { browser: Arc, + page: Option>, target_id: String, session_id: String, url: String, @@ -28758,6 +30105,11 @@ struct PyPageEventStream { struct PageEventStreamState { network: NetworkObservationState, ready: BTreeMap, + main_session_id: String, + owned_sessions: HashSet, + session_parents: HashMap, + replay_until: HashMap, + replay_cutoff_generations: HashMap, } #[cfg(feature = "python")] @@ -28821,12 +30173,225 @@ impl Drop for PageEventStreamLease { } impl PageEventStreamState { + #[cfg(test)] fn new() -> Self { + Self::for_session(String::new()) + } + + fn for_session(main_session_id: String) -> Self { + let mut owned_sessions = HashSet::new(); + if !main_session_id.is_empty() { + owned_sessions.insert(main_session_id.clone()); + } Self { network: NetworkObservationState::new(), ready: BTreeMap::new(), + main_session_id, + owned_sessions, + session_parents: HashMap::new(), + replay_until: HashMap::new(), + replay_cutoff_generations: HashMap::new(), + } + } + + fn for_page(page: &PageInner) -> Self { + let mut state = Self::for_session(page.session_id.clone()); + let frame_state = page.frame_state.lock().unwrap(); + state + .owned_sessions + .extend(frame_state.session_frames.keys().cloned()); + for frame in frame_state.frames.values() { + let Some(child_session_id) = frame.session_id.as_deref() else { + continue; + }; + let Some(parent_session_id) = frame + .parent_id + .as_deref() + .and_then(|parent_id| frame_state.frames.get(parent_id)) + .and_then(|parent| parent.session_id.as_deref()) + else { + continue; + }; + if child_session_id != parent_session_id { + state + .session_parents + .insert(child_session_id.to_string(), parent_session_id.to_string()); + } + } + drop(frame_state); + state.replay_until = page + .console_replay_until_event_cursor + .lock() + .unwrap() + .clone(); + state + } + + #[cfg(any(test, feature = "python"))] + fn subscribe_page_event_stream( + page: &PageInner, + ) -> (broadcast::Receiver, u64, PageEventStreamState) { + let client = &page.browser.client; + let event_log = client.event_log.lock().unwrap(); + let receiver = client.events.subscribe(); + let event_cursor = event_log.cursor(); + let entries = event_log.entries_since(0); + let replay_cutoffs = event_log.console_replay_cutoffs.clone(); + let replay_generations = event_log.console_replay_cutoff_generations.clone(); + let replay_cancellations = event_log.console_replay_cutoff_cancellations.clone(); + drop(event_log); + let mut page_state = PageEventStreamState::for_page(page); + for (sequence, event) in entries { + if sequence >= event_cursor { + break; + } + page_state.apply_target_transition(&event, &page.session_id); + } + page_state.seed_replay_cutoffs(&replay_cutoffs); + for session_id in replay_cutoffs.keys() { + if let Some(generation) = replay_generations + .get(session_id) + .copied() + .or_else(|| replay_cancellations.get(session_id).copied()) + { + page_state + .replay_cutoff_generations + .insert(session_id.clone(), generation); + } + } + (receiver, event_cursor, page_state) + } + + fn seed_replay_cutoffs(&mut self, replay_until: &HashMap) { + for (session_id, cutoff) in replay_until { + self.replay_until + .entry(session_id.clone()) + .and_modify(|stored| merge_console_replay_cutoff(stored, *cutoff)) + .or_insert(*cutoff); + } + } + + fn sync_replay_cutoffs_for_event( + &mut self, + event: &Value, + event_log: &Arc>, + ) { + if !matches!( + event.get("method").and_then(Value::as_str), + Some( + "Target.attachedToTarget" + | "Target.detachedFromTarget" + | "Runtime.consoleAPICalled" + | "Runtime.exceptionThrown" + ) + ) { + return; + } + let mut session_ids = Vec::with_capacity(2); + if let Some(session_id) = event.get("sessionId").and_then(Value::as_str) { + session_ids.push(session_id); + } + if let Some(session_id) = event.pointer("/params/sessionId").and_then(Value::as_str) { + session_ids.push(session_id); + } + if session_ids.is_empty() { + return; + } + let log = event_log.lock().unwrap(); + for session_id in session_ids { + let Some(cutoff) = log.console_replay_cutoff(session_id) else { + continue; + }; + if let Some((generation, _)) = log.console_replay_cutoff_with_generation(session_id) { + if self + .replay_cutoff_generations + .get(session_id) + .is_some_and(|stored| *stored > generation) + { + continue; + } + self.replay_cutoff_generations + .insert(session_id.to_string(), generation); + } + self.replay_until + .entry(session_id.to_string()) + .and_modify(|stored| merge_console_replay_cutoff(stored, cutoff)) + .or_insert(cutoff); + } + } + + fn ensure_main_session(&mut self, session_id: &str) { + if self.main_session_id.is_empty() { + self.main_session_id = session_id.to_string(); + } + self.owned_sessions.insert(self.main_session_id.clone()); + } + + fn owns_session(&self, session_id: &str) -> bool { + session_id == self.main_session_id || self.owned_sessions.contains(session_id) + } + + fn apply_target_transition(&mut self, event: &Value, session_id: &str) { + self.ensure_main_session(session_id); + match event.get("method").and_then(Value::as_str) { + Some("Target.attachedToTarget") => { + let target_info = event.pointer("/params/targetInfo").unwrap_or(&Value::Null); + if target_info.get("type").and_then(Value::as_str) != Some("iframe") { + return; + } + let Some(parent_session_id) = event.get("sessionId").and_then(Value::as_str) else { + return; + }; + let Some(child_session_id) = + event.pointer("/params/sessionId").and_then(Value::as_str) + else { + return; + }; + if self.owns_session(parent_session_id) { + self.owned_sessions.insert(child_session_id.to_string()); + self.session_parents + .insert(child_session_id.to_string(), parent_session_id.to_string()); + } + } + Some("Target.detachedFromTarget") => { + let Some(detached_session_id) = + event.pointer("/params/sessionId").and_then(Value::as_str) + else { + return; + }; + if detached_session_id == self.main_session_id { + return; + } + let mut pending = vec![detached_session_id.to_string()]; + while let Some(session_id) = pending.pop() { + self.replay_until.remove(&session_id); + if !self.owned_sessions.remove(&session_id) { + continue; + } + let children = self + .session_parents + .iter() + .filter_map(|(child, parent)| { + (parent == &session_id).then_some(child.clone()) + }) + .collect::>(); + pending.extend(children); + self.session_parents.remove(&session_id); + } + self.session_parents.retain(|child, parent| { + self.owned_sessions.contains(child) && self.owned_sessions.contains(parent) + }); + } + _ => {} } } + + fn reset_after_overflow(&mut self) { + self.network = NetworkObservationState::new(); + self.ready.clear(); + self.replay_until.clear(); + self.replay_cutoff_generations.clear(); + } } #[cfg(feature = "python")] @@ -28858,7 +30423,13 @@ struct PyDialogEventWaiter { struct PyConsoleEventWaiter { browser: Arc, receiver: Mutex>>, + event_log: Arc>, session_id: String, + event_cursor: Mutex, + replay_until: HashMap, + event_kind: String, + page: Option>, + page_state: Mutex>, } #[cfg(feature = "python")] @@ -28909,8 +30480,10 @@ struct PyPopupEventWaiter { #[pyclass(name = "_WorkerEventWaiter")] struct PyWorkerEventWaiter { browser: Arc, + page: Arc, receiver: Mutex>>, - opener_target_id: String, + event_log: Arc>, + event_cursor: Mutex, } #[cfg(feature = "python")] @@ -28918,6 +30491,8 @@ struct PyWorkerEventWaiter { struct PyWorkerCloseEventWaiter { browser: Arc, receiver: Mutex>>, + event_log: Arc>, + event_cursor: Mutex, target_id: String, session_id: String, } @@ -30569,6 +32144,12 @@ async fn wait_for_drag_intercepted( } } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum DragOutcome { + InterceptedDrop, + NoInterceptedDrop, +} + #[allow(clippy::too_many_arguments)] async fn run_drag_with_cleanup( client: &CdpClient, @@ -30583,7 +32164,7 @@ async fn run_drag_with_cleanup( where Start: Future>>, Complete: FnOnce(Option) -> Completion, - Completion: Future>, + Completion: Future>, { const CLEANUP_TIMEOUT: Duration = Duration::from_secs(1); @@ -30600,28 +32181,31 @@ where Ok(drag_data) => complete(drag_data).await, Err(error) => Err(error), }; - if let Err(error) = client - .send( - "Input.dispatchMouseEvent", - mouse_event_payload( - "mouseReleased", - release_x, - release_y, - "left", - 0, - 1, - modifiers, - ), - Some(pointer_session_id), - CLEANUP_TIMEOUT, - ) - .await - { - eprintln!( - "rustwright: mouse release cleanup received no confirmation after committed drag: {error}" - ); + let release_mouse = !matches!(result, Ok(DragOutcome::InterceptedDrop)); + if release_mouse { + if let Err(error) = client + .send( + "Input.dispatchMouseEvent", + mouse_event_payload( + "mouseReleased", + release_x, + release_y, + "left", + 0, + 1, + modifiers, + ), + Some(pointer_session_id), + CLEANUP_TIMEOUT, + ) + .await + { + eprintln!( + "rustwright: mouse release cleanup received no confirmation after committed drag: {error}" + ); + } } - result + result.map(|_| ()) } const PHYSICAL_DRAG_STEPS: u32 = 10; @@ -30683,7 +32267,8 @@ async fn dispatch_physical_drag_sequence_in_session( modifiers, deadline, ) - .await + .await?; + Ok(DragOutcome::NoInterceptedDrop) }, ) .await @@ -31161,6 +32746,7 @@ fn evaluate_expression_for_frame( let session_id = page.session_for_frame_id(&frame_id)?; let realm_identity = format!("frame:{frame_id}"); browser.block_on(async move { + enable_console_capture_for_session_if_requested(&page, &session_id, timeout).await?; let world = client .send( "Page.createIsolatedWorld", @@ -33820,6 +35406,129 @@ async fn resolve_screenshot_clip( })) } +const NAVIGATION_HISTORY_TIMEOUT: Duration = Duration::from_millis(250); + +async fn retain_navigation_response_bodies_async( + client: &CdpClient, + session_id: &str, + request_ids: &[String], +) -> Value { + let mut retained = serde_json::Map::new(); + for request_id in request_ids { + if request_id.is_empty() { + continue; + } + if let Ok(body) = client + .send( + "Network.getResponseBody", + json!({ "requestId": request_id }), + Some(session_id), + NAVIGATION_HISTORY_TIMEOUT, + ) + .await + { + retained.insert(request_id.clone(), body); + } + } + Value::Object(retained) +} + +fn navigation_history_expression( + console_buffer_name: &str, + page_error_buffer_name: &str, +) -> String { + let console_name = + serde_json::to_string(console_buffer_name).unwrap_or_else(|_| "\"\"".to_string()); + let page_error_name = + serde_json::to_string(page_error_buffer_name).unwrap_or_else(|_| "\"\"".to_string()); + format!( + r#"(() => {{ + const drain = (owner, name) => {{ + try {{ + const history = owner && owner[name]; + if (!Array.isArray(history) || history.length === 0) return []; + return history.splice(0, history.length); + }} catch (_) {{ + return []; + }} + }}; + return {{ + console: drain(typeof console === "undefined" ? null : console, {console_name}), + page_errors: drain(typeof window === "undefined" ? null : window, {page_error_name}) + }}; + }})()"# + ) +} + +async fn drain_navigation_history_async( + client: &CdpClient, + session_id: &str, + console_buffer_name: &str, + page_error_buffer_name: &str, +) -> Value { + let result = client + .send( + "Runtime.evaluate", + json!({ + "expression": navigation_history_expression( + console_buffer_name, + page_error_buffer_name, + ), + "awaitPromise": false, + "returnByValue": true, + "userGesture": true, + }), + Some(session_id), + NAVIGATION_HISTORY_TIMEOUT, + ) + .await; + match result + .as_ref() + .ok() + .and_then(|value| runtime_result_to_json(value).ok()) + .and_then(|value| serde_json::from_str::(&value).ok()) + { + Some(Value::Object(history)) => Value::Object(history), + _ => json!({ "console": [], "page_errors": [] }), + } +} + +async fn prepare_navigation_async( + page: &Arc, + console_buffer_name: &str, + page_error_buffer_name: &str, + request_ids: Vec, +) -> RwResult { + let client = Arc::clone(&page.browser.client); + let session_id = page.session_id.clone(); + let response_bodies = + retain_navigation_response_bodies_async(&client, &session_id, &request_ids).await; + let history = drain_navigation_history_async( + &client, + &session_id, + console_buffer_name, + page_error_buffer_name, + ) + .await; + let mut payload = match history { + Value::Object(history) => history, + _ => serde_json::Map::new(), + }; + payload.insert("response_bodies".to_string(), response_bodies); + Ok(Value::Object(payload)) +} + +fn parse_navigation_request_ids(request_ids_json: Option<&str>) -> RwResult> { + match request_ids_json { + None => Ok(Vec::new()), + Some(value) if value.trim().is_empty() => Ok(Vec::new()), + Some(value) => { + let request_ids = serde_json::from_str::>(value)?; + Ok(request_ids) + } + } +} + async fn page_goto_async( page: Arc, url: String, @@ -33856,7 +35565,7 @@ async fn page_goto_async( started_at, ) .await; - return Err(error); + return Err(normalize_navigation_timeout(error, deadline)); } Err(error) => return Err(error), }; @@ -33885,10 +35594,10 @@ async fn page_goto_async( } let navigation_frame_id = match navigation_frame_id { Some(frame_id) => frame_id, - None => { - page.main_frame_id(&client, &session_id, deadline.remaining()?) - .await? - } + None => page + .main_frame_id(&client, &session_id, deadline.remaining()?) + .await + .map_err(|error| normalize_navigation_timeout(error, deadline))?, }; let response = match wait_for_navigation( &client, @@ -33922,7 +35631,7 @@ async fn page_goto_async( started_at, ) .await; - return Err(error); + return Err(normalize_navigation_timeout(error, deadline)); } Err(error) => return Err(error), }; @@ -33967,7 +35676,9 @@ async fn page_goto_observed_async( timeout: Duration, referer: Option, ) -> RwResult { - page_goto_observed_impl(page, url, wait_until, timeout, referer).await + page_goto_observed_impl(page, url, wait_until, timeout, referer) + .await + .map_err(|error| normalize_navigation_timeout(error, OperationDeadline::new(timeout))) } async fn page_goto_observed_impl( @@ -34111,6 +35822,18 @@ async fn page_reload_observed_async( session_id: String, wait_until: String, timeout: Duration, +) -> RwResult { + page_reload_observed_impl(page, client, session_id, wait_until, timeout) + .await + .map_err(|error| normalize_navigation_timeout(error, OperationDeadline::new(timeout))) +} + +async fn page_reload_observed_impl( + page: Arc, + client: Arc, + session_id: String, + wait_until: String, + timeout: Duration, ) -> RwResult { let deadline = OperationDeadline::new(timeout); let expected_frame_id = page @@ -34176,6 +35899,19 @@ async fn page_history_observed_async( offset: i64, wait_until: String, timeout: Duration, +) -> RwResult { + page_history_observed_impl(page, client, session_id, offset, wait_until, timeout) + .await + .map_err(|error| normalize_navigation_timeout(error, OperationDeadline::new(timeout))) +} + +async fn page_history_observed_impl( + page: Arc, + client: Arc, + session_id: String, + offset: i64, + wait_until: String, + timeout: Duration, ) -> RwResult { let deadline = OperationDeadline::new(timeout); let history = client @@ -34775,78 +36511,6 @@ async fn element_handle_wait_for_selector_async( wait_for_selector_result(&json, timeout) } -fn locator_action_body(action: &str, strict: bool, timeout: Duration) -> String { - let strict_json = if strict { "true" } else { "false" }; - let timeout_millis = timeout.as_millis().max(1); - format!( - r#" -const strict = {strict_json}; -const timeoutMs = {timeout_millis}; -const attempt = () => {{ - const currentMatches = all(spec); - if (strict && currentMatches.length > 1) return {{ done: true, value: `__rustwright_strict_violation__:${{currentMatches.length}}` }}; - const current = currentMatches[index] || null; - if (!current || !visible(current) || current.disabled) return {{ done: false }}; - const el = current; - {action} - return {{ done: true, value: true }}; -}}; -const first = attempt(); -if (first.done) return first.value; -return new Promise(resolve => {{ - let settled = false; - let observer = null; - let interval = null; - let timer = null; - const finish = value => {{ - if (settled) return; - settled = true; - if (observer) observer.disconnect(); - if (interval) clearInterval(interval); - if (timer) clearTimeout(timer); - resolve(value); - }}; - const check = () => {{ - const next = attempt(); - if (next.done) finish(next.value); - }}; - observer = new MutationObserver(check); - observer.observe(document, {{ subtree: true, childList: true, attributes: true, characterData: true }}); - interval = setInterval(check, 5); - timer = setTimeout(() => finish('__rustwright_timeout__'), timeoutMs); -}}); -"# - ) -} - -async fn page_locator_action_async( - page: Arc, - locator_json: String, - index: usize, - action: String, - timeout: Duration, - strict: bool, - method: &'static str, -) -> RwResult<()> { - let body = locator_action_body(&action, strict, timeout); - let eval_timeout = Duration::from_millis(timeout.as_millis().saturating_add(1_000) as u64); - let json = evaluate_locator_for_page(page, locator_json, index, body, eval_timeout).await?; - let value = serde_json::from_str::(&json).unwrap_or(Value::Null); - if value.as_str() == Some("__rustwright_timeout__") { - return Err(RwError::Timeout(timeout.as_millis() as u64)); - } - if let Some(count) = value - .as_str() - .and_then(|text| text.strip_prefix("__rustwright_strict_violation__:")) - .and_then(|text| text.parse::().ok()) - { - return Err(RwError::Message(format!( - "strict mode violation: locator resolved to {count} elements while trying to {method}" - ))); - } - Ok(()) -} - fn native_action_body(template: &str) -> String { template .replace("__SCROLL__", "true") @@ -35724,6 +37388,7 @@ async fn page_close_async( ) -> RwResult<()> { let lifecycle = Arc::clone(&page.lifecycle); single_flight_close(lifecycle, false, move || async move { + page.clear_worker_resume_handoffs(); page.abort_iframe_setup_tasks(); page_close_cleanup(page, timeout, run_before_unload).await }) @@ -35904,6 +37569,10 @@ async fn dispatch_mouse_click_async( #[cfg(feature = "python")] #[pymethods] impl PyPage { + fn event_cursor(&self) -> u64 { + self.inner.browser.client.event_cursor() + } + fn background_override_active(&self) -> bool { self.inner.background_override_active.load(Ordering::SeqCst) } @@ -35929,7 +37598,6 @@ impl PyPage { .to_string() } - #[pyo3(signature = (url, wait_until=None, timeout_ms=None, referer=None))] fn goto_async( &self, py: Python<'_>, @@ -35972,40 +37640,6 @@ impl PyPage { ) } - #[pyo3(signature = (locator_json, index, timeout_ms=None, strict=false))] - fn click_async( - &self, - py: Python<'_>, - locator_json: &str, - index: usize, - timeout_ms: Option, - strict: bool, - ) -> PyResult> { - let page = Arc::clone(&self.inner); - let runtime = page.browser.runtime.handle().clone(); - let timeout = BrowserInner::command_timeout(timeout_ms); - let action = r#" -el.scrollIntoView({ block: 'center', inline: 'center' }); -if (typeof el.focus === 'function') el.focus({ preventScroll: true }); -el.click(); -"# - .to_string(); - python_future_on( - py, - runtime, - page_locator_action_async( - page, - locator_json.to_string(), - index, - action, - timeout, - strict, - "click", - ), - |py, ()| Ok(py.None()), - ) - } - #[pyo3(signature = (locator_json, index, timeout_ms=None, strict=false))] fn click_actionable_wait_async( &self, @@ -36061,36 +37695,6 @@ el.click(); ) } - #[pyo3(signature = (locator_json, index, value, timeout_ms=None, strict=false))] - fn fill_async( - &self, - py: Python<'_>, - locator_json: &str, - index: usize, - value: &str, - timeout_ms: Option, - strict: bool, - ) -> PyResult> { - let page = Arc::clone(&self.inner); - let runtime = page.browser.runtime.handle().clone(); - python_future_on( - py, - runtime, - page_fill_actionable_async( - page, - locator_json.to_string(), - index, - value.to_string(), - timeout_ms, - strict, - false, - "fill".to_string(), - None, - ), - |py, ()| Ok(py.None()), - ) - } - #[pyo3(signature = (locator_json, index, value, timeout_ms=None, strict=false))] fn fill_actionable_async( &self, @@ -36723,8 +38327,10 @@ return win.__rustwrightCleanupDrag ? win.__rustwrightCleanupDrag() : false; deadline.remaining()?, ) .await?; + Ok(DragOutcome::InterceptedDrop) + } else { + Ok(DragOutcome::NoInterceptedDrop) } - Ok(()) }, ) .await?; @@ -36804,6 +38410,33 @@ return win.__rustwrightCleanupDrag ? win.__rustwrightCleanupDrag() : false; .map_err(py_err) } + #[pyo3(signature = (console_buffer_name, page_error_buffer_name, response_request_ids_json=None))] + fn prepare_navigation( + &self, + py: Python<'_>, + console_buffer_name: &str, + page_error_buffer_name: &str, + response_request_ids_json: Option<&str>, + ) -> PyResult { + let request_ids = + parse_navigation_request_ids(response_request_ids_json).map_err(py_err)?; + let page = Arc::clone(&self.inner); + let browser = Arc::clone(&page.browser); + let console_buffer_name = console_buffer_name.to_string(); + let page_error_buffer_name = page_error_buffer_name.to_string(); + py.detach(move || { + browser + .block_on(prepare_navigation_async( + &page, + &console_buffer_name, + &page_error_buffer_name, + request_ids, + )) + .map(|payload| payload.to_string()) + }) + .map_err(py_err) + } + #[pyo3(signature = (url, wait_until=None, timeout_ms=None, referer=None))] fn goto( &self, @@ -36915,6 +38548,7 @@ return win.__rustwrightCleanupDrag ? win.__rustwrightCleanupDrag() : false; .to_string()) }) }) + .map_err(|error| normalize_navigation_timeout(error, OperationDeadline::new(timeout))) .map_err(py_err) } @@ -37937,6 +39571,7 @@ return win.__rustwrightCleanupDrag ? win.__rustwrightCleanupDrag() : false; } Ok(Value::Null.to_string()) }) + .map_err(|error| normalize_navigation_timeout(error, OperationDeadline::new(timeout))) .map_err(py_err) } @@ -37950,34 +39585,6 @@ return win.__rustwrightCleanupDrag ? win.__rustwrightCleanupDrag() : false; self.navigate_history(1, wait_until, timeout_ms) } - #[pyo3(signature = (locator_json, index, timeout_ms=None))] - fn click( - &self, - py: Python<'_>, - locator_json: &str, - index: usize, - timeout_ms: Option, - ) -> PyResult<()> { - let prepare_body = r#" -if (!el) throw new Error('No element matches locator'); -el.scrollIntoView({ block: 'center', inline: 'center' }); -if (typeof el.focus === 'function') el.focus({ preventScroll: true }); -return { ready: true, result: true, payload: null }; -"#; - let dispatch_body = "if (!el.isConnected) throw new Error('__rustwright_action_dispatch_not_started__: element detached'); rustwrightCommitDispatch(true); el.click(); return true;"; - py.detach(|| { - self.evaluate_locator_dispatch( - locator_json, - index, - prepare_body, - dispatch_body, - timeout_ms, - ) - }) - .map(|_| ()) - .map_err(py_err) - } - #[pyo3(signature = (locator_json, index, body, timeout_ms=None))] fn locator_eval( &self, @@ -37987,11 +39594,10 @@ return { ready: true, result: true, payload: null }; body: &str, timeout_ms: Option, ) -> PyResult { - // Release the GIL for the duration of the blocking CDP round-trip, matching - // `click` and `locator_eval_handle`. `evaluate_locator` already detaches inside - // `block_on`, but keeping the detach explicit at the binding layer keeps the - // three locator bindings consistent and guards against a future refactor that - // adds GIL-holding work before `block_on` or changes the transport. + // Release the GIL for the blocking CDP round-trip, as + // `locator_eval_handle` does. `evaluate_locator` already detaches inside + // `block_on`, but an explicit binding-layer detach prevents a future + // refactor from adding GIL-holding work before `block_on`. py.detach(|| self.evaluate_locator(locator_json, index, body, timeout_ms)) .map_err(py_err) } @@ -38134,211 +39740,12 @@ return { ready: true, result: true, payload: null }; else { return Ok(json!({ "ok": false, "type": "not-applicable" }).to_string()); }; - let fill_options = if matches!(operation, "css_fill" | "label_fill" | "placeholder_fill") { - let args = serde_json::from_str::(args_json) - .map_err(|error| PyValueError::new_err(error.to_string()))?; - Some(( - args.get("value") - .and_then(Value::as_str) - .ok_or_else(|| { - PyValueError::new_err("locator fast fill value must be a string") - })? - .to_string(), - args.get("strict").and_then(Value::as_bool).unwrap_or(false), - )) - } else { - None - }; let page = Arc::clone(&self.inner); let locator_json = locator_json.to_string(); let wait_probe = operation == "css_immediate_state"; let timeout = BrowserInner::command_timeout(timeout_ms); py.detach(move || { - if let Some((value, strict)) = fill_options { - let guard_key = script.fill_guard_key.ok_or_else(|| { - RwError::Message("fast fill did not return a fill guard key".to_string()) - })?; - let fill_script = LocatorFillScript { - body: script.body, - guard_key, - }; - let browser = Arc::clone(&page.browser); - browser.block_on(async move { - let deadline = action_deadline(timeout); - let mut pinned_resolution = None; - let mut current_attempt_evidence = FillAttemptEvidence::Resolve; - let evaluation = evaluate_locator_fill_for_page( - Arc::clone(&page), - locator_json.clone(), - index, - fill_script.body.clone(), - fill_script.guard_key.clone(), - value.clone(), - timeout, - &mut pinned_resolution, - &mut current_attempt_evidence, - None, - ) - .await; - let mut json = match evaluation { - Ok(json) => json, - Err(error) if error.is_retryable_timeout() => { - let _ = cleanup_retained_fill_guard( - Arc::clone(&page), - &locator_json, - &fill_script.guard_key, - pinned_resolution.as_ref(), - Duration::from_millis(100), - ) - .await; - return Err(error); - } - Err(error) => return Err(error), - }; - let first_result = - decode_runtime_serialized_value(serde_json::from_str::(&json)?); - let first_info = first_result - .get("info") - .cloned() - .unwrap_or_else(|| json!({})); - let first_result_type = first_result.get("type").and_then(Value::as_str); - let first_result_succeeded = - first_result.get("ok").and_then(Value::as_bool) == Some(true); - if !first_result_succeeded - && !matches!( - first_result_type, - Some("observe-dispatch" | "pending-dispatch") - ) - { - if let Some(error) = strict_violation_error(&first_info, strict, "fill") { - let _ = cleanup_retained_fill_guard( - Arc::clone(&page), - &locator_json, - &fill_script.guard_key, - pinned_resolution.as_ref(), - Duration::from_millis(100), - ) - .await; - return Err(error); - } - } - match classify_fill_attempt(&first_result, "fill")? { - FillAttempt::Success | FillAttempt::PendingActionability => { - return Ok(json); - } - FillAttempt::PendingDispatch => {} - } - let mut last_info = first_info; - let mut last_info_json = json; - let mut last_timeout_state = "confirmed as edited"; - let mut last_attempt_evidence = FillAttemptEvidence::SettleWritten; - loop { - if Instant::now() >= deadline { - let _ = cleanup_retained_fill_guard( - Arc::clone(&page), - &locator_json, - &fill_script.guard_key, - pinned_resolution.as_ref(), - Duration::from_millis(100), - ) - .await; - return Err(ActionTimeoutError::from_attempt_raw_json( - last_timeout_state, - "fill".to_string(), - last_info_json, - &last_info, - Some("info"), - last_attempt_evidence, - ) - .into()); - } - tokio::time::sleep( - deadline - .saturating_duration_since(Instant::now()) - .min(Duration::from_millis(20)), - ) - .await; - if let Err(error) = ensure_native_action_owner_available(&page, "fill") { - let _ = cleanup_retained_fill_guard( - Arc::clone(&page), - &locator_json, - &fill_script.guard_key, - pinned_resolution.as_ref(), - Duration::from_millis(100), - ) - .await; - return Err(error); - } - let remaining = deadline.saturating_duration_since(Instant::now()); - let mut current_attempt_evidence = FillAttemptEvidence::Resolve; - let evaluation = evaluate_locator_fill_for_page( - Arc::clone(&page), - locator_json.clone(), - index, - fill_script.body.clone(), - fill_script.guard_key.clone(), - value.clone(), - remaining.max(Duration::from_millis(1)), - &mut pinned_resolution, - &mut current_attempt_evidence, - None, - ) - .await; - json = match evaluation { - Ok(json) => json, - Err(error) if error.is_retryable_timeout() => { - last_attempt_evidence = current_attempt_evidence; - continue; - } - Err(error) => return Err(error), - }; - let result = - decode_runtime_serialized_value(serde_json::from_str::(&json)?); - let info = result.get("info").cloned().unwrap_or_else(|| json!({})); - let result_type = result.get("type").and_then(Value::as_str); - let result_succeeded = - result.get("ok").and_then(Value::as_bool) == Some(true); - if !result_succeeded - && !matches!(result_type, Some("observe-dispatch" | "pending-dispatch")) - { - if let Some(error) = strict_violation_error(&info, strict, "fill") { - let _ = cleanup_retained_fill_guard( - Arc::clone(&page), - &locator_json, - &fill_script.guard_key, - pinned_resolution.as_ref(), - Duration::from_millis(100), - ) - .await; - return Err(error); - } - } - match classify_fill_attempt(&result, "fill")? { - FillAttempt::Success => return Ok(json), - FillAttempt::PendingActionability | FillAttempt::PendingDispatch => { - let pending_dispatch = result_type == Some("pending-dispatch"); - last_attempt_evidence = if pending_dispatch { - debug_assert_eq!( - current_attempt_evidence, - FillAttemptEvidence::SettleWritten - ); - FillAttemptEvidence::SettleWritten - } else { - pinned_resolution.take(); - FillAttemptEvidence::Resolve - }; - last_timeout_state = if pending_dispatch { - "confirmed as edited" - } else { - "editable" - }; - last_info = info; - last_info_json = json; - } - } - } - }) - } else if wait_probe { + if wait_probe { let expression = locator_script(&locator_json, index, &script.body); evaluate_locator_wait_probe_for_page(page, expression, timeout_ms) } else { @@ -38423,31 +39830,6 @@ return { ready: true, result: true, payload: null }; .map_err(py_err) } - #[pyo3(signature = (locator_json, index, value, timeout_ms=None))] - fn fill( - &self, - locator_json: &str, - index: usize, - value: &str, - timeout_ms: Option, - ) -> PyResult<()> { - let page = Arc::clone(&self.inner); - let browser = Arc::clone(&page.browser); - browser - .block_on(page_fill_actionable_async( - page, - locator_json.to_string(), - index, - value.to_string(), - timeout_ms, - false, - false, - "fill".to_string(), - None, - )) - .map_err(py_err) - } - #[pyo3(signature = (locator_json, index, text, timeout_ms=None))] fn r#type( &self, @@ -38880,7 +40262,7 @@ return { ready: true, result: true, payload: null }; cursor: Arc::new(Mutex::new(cursor)), session_id: page.session_id.clone(), requests: Arc::clone(&page.network_requests), - state: Arc::new(Mutex::new(Some(PageEventStreamState::new()))), + state: Arc::new(Mutex::new(Some(PageEventStreamState::for_page(&page)))), pending_batch: Arc::new(Mutex::new(None)), close_tx, closed: Arc::new(AtomicBool::new(false)), @@ -38912,16 +40294,55 @@ return { ready: true, result: true, payload: null }; } } - fn console_event_waiter(&self) -> PyResult { + #[pyo3(signature = (timeout_ms=None))] + fn console_event_waiter(&self, timeout_ms: Option) -> PyResult { let page = Arc::clone(&self.inner); let browser = Arc::clone(&page.browser); - browser - .block_on(async move { enable_console_capture(&page, Duration::from_secs(5)).await }) + let client = Arc::clone(&browser.client); + let (receiver, event_cursor, mut page_wait_state) = + PageEventStreamState::subscribe_page_event_stream(&page); + let timeout = BrowserInner::command_timeout(timeout_ms); + let page_for_setup = Arc::clone(&page); + let replay_until = browser + .block_on(async move { enable_console_capture(&page_for_setup, timeout).await }) .map_err(py_err)?; + page_wait_state.seed_replay_cutoffs(&replay_until); Ok(PyConsoleEventWaiter { browser: Arc::clone(&self.inner.browser), - receiver: Mutex::new(Some(self.inner.browser.client.subscribe())), + receiver: Mutex::new(Some(receiver)), + event_log: Arc::clone(&client.event_log), session_id: self.inner.session_id.clone(), + event_cursor: Mutex::new(event_cursor), + replay_until, + event_kind: "console".to_string(), + page: Some(Arc::clone(&self.inner)), + page_state: Mutex::new(Some(page_wait_state)), + }) + } + + #[pyo3(signature = (timeout_ms=None))] + fn page_error_event_waiter(&self, timeout_ms: Option) -> PyResult { + let page = Arc::clone(&self.inner); + let browser = Arc::clone(&page.browser); + let client = Arc::clone(&browser.client); + let (receiver, event_cursor, mut page_wait_state) = + PageEventStreamState::subscribe_page_event_stream(&page); + let timeout = BrowserInner::command_timeout(timeout_ms); + let page_for_setup = Arc::clone(&page); + let replay_until = browser + .block_on(async move { enable_console_capture(&page_for_setup, timeout).await }) + .map_err(py_err)?; + page_wait_state.seed_replay_cutoffs(&replay_until); + Ok(PyConsoleEventWaiter { + browser: Arc::clone(&self.inner.browser), + receiver: Mutex::new(Some(receiver)), + event_log: Arc::clone(&client.event_log), + session_id: self.inner.session_id.clone(), + event_cursor: Mutex::new(event_cursor), + replay_until, + event_kind: "pageerror".to_string(), + page: Some(Arc::clone(&self.inner)), + page_state: Mutex::new(Some(page_wait_state)), }) } @@ -39072,34 +40493,114 @@ return { ready: true, result: true, payload: null }; }) } - #[pyo3(signature = (timeout_ms=None))] - fn worker_event_waiter(&self, timeout_ms: Option) -> PyResult { + #[pyo3(signature = (interested, timeout_ms=None))] + fn set_worker_event_interest(&self, interested: bool, timeout_ms: Option) -> PyResult<()> { + let page = Arc::clone(&self.inner); + let browser = Arc::clone(&page.browser); + let timeout = BrowserInner::command_timeout(timeout_ms); + let sessions = { + let mut state = page.frame_state.lock().unwrap(); + state.set_worker_event_interest(interested); + if interested { + Vec::new() + } else { + let worker_sessions = state.worker_sessions.iter().cloned().collect::>(); + worker_sessions + .into_iter() + .filter(|session_id| state.take_worker_resume_fallback(session_id)) + .collect::>() + } + }; + browser + .block_on(async move { + for session_id in sessions { + let _ = resume_worker_after_claim(&page, &session_id, timeout).await; + } + Ok::<_, RwError>(()) + }) + .map_err(py_err) + } + #[pyo3(signature = (interested, timeout_ms=None))] + fn set_worker_forwarding_interest( + &self, + interested: bool, + timeout_ms: Option, + ) -> PyResult<()> { + let _ = timeout_ms; + self.inner + .frame_state + .lock() + .unwrap() + .set_worker_forwarding_interest(interested); + Ok(()) + } + + #[pyo3(signature = (timeout_ms=None, configure=true))] + fn worker_event_waiter( + &self, + timeout_ms: Option, + configure: bool, + ) -> PyResult { let page = Arc::clone(&self.inner); + page.frame_state + .lock() + .unwrap() + .set_worker_event_interest(true); let timeout = BrowserInner::command_timeout(timeout_ms); let browser = Arc::clone(&page.browser); let client = Arc::clone(&browser.client); let session_id = page.session_id.clone(); - browser - .block_on(async move { - client - .send( - "Target.setAutoAttach", - json!({ - "autoAttach": true, - "waitForDebuggerOnStart": false, - "flatten": true, - }), - Some(&session_id), - timeout, + let (receiver, event_cursor) = client.subscribe_with_cursor(); + let setup_auto_attach_lock = page + .iframe_setup_tasks + .auto_attach_lock_for_session(&session_id); + if configure { + let owned_sessions = { + let state = page.frame_state.lock().unwrap(); + state.all_owned_session_ids() + }; + let child_auto_attach_locks = owned_sessions + .iter() + .filter(|child_session_id| *child_session_id != &session_id) + .map(|child_session_id| { + ( + child_session_id.clone(), + page.iframe_setup_tasks + .auto_attach_lock_for_session(child_session_id), ) - .await?; - Ok(()) - }) - .map_err(py_err)?; + }) + .collect::>(); + let setup_client = Arc::clone(&client); + let setup_page = Arc::clone(&page); + let setup_session_id = session_id.clone(); + browser + .block_on(async move { + { + let _auto_attach_guard = setup_auto_attach_lock.lock().await; + enable_worker_auto_attach(&setup_client, &setup_session_id, timeout) + .await?; + } + setup_page + .frame_state + .lock() + .unwrap() + .mark_worker_auto_attach_requested(); + for (child_session_id, auto_attach_lock) in child_auto_attach_locks { + let _auto_attach_guard = auto_attach_lock.lock().await; + let _ = + enable_worker_auto_attach(&setup_client, &child_session_id, timeout) + .await; + } + Ok::<_, RwError>(()) + }) + .map_err(py_err)?; + } Ok(PyWorkerEventWaiter { - browser: Arc::clone(&self.inner.browser), - receiver: Mutex::new(Some(self.inner.browser.client.subscribe())), - opener_target_id: self.inner.target_id.clone(), + browser: Arc::clone(&page.browser), + page, + receiver: Mutex::new(Some(receiver)), + event_log: Arc::clone(&client.event_log), + event_cursor: Mutex::new(event_cursor), }) } @@ -39108,11 +40609,13 @@ return { ready: true, result: true, payload: null }; let page = Arc::clone(&self.inner); let browser = Arc::clone(&page.browser); let browser_for_task = Arc::clone(&browser); + let page_for_task = Arc::clone(&page); let client = Arc::clone(&browser.client); let opener_target_id = page.target_id.clone(); let timeout = BrowserInner::command_timeout(timeout_ms); browser .block_on(async move { + register_page_owned_workers_from_event_log(&page_for_task).await; let result = client .send("Target.getTargets", json!({}), None, timeout) .await?; @@ -39123,12 +40626,31 @@ return { ready: true, result: true, payload: null }; .into_iter() .flatten() { - let target_type = info.get("type").and_then(Value::as_str).unwrap_or(""); - if target_type != "worker" { + if info.get("type").and_then(Value::as_str) != Some("worker") { continue; } let opener = info.get("openerId").and_then(Value::as_str); - if opener != Some(opener_target_id.as_str()) { + let opener_owned = opener == Some(opener_target_id.as_str()) + || opener.is_some_and(|opener| { + page_for_task + .frame_state + .lock() + .unwrap() + .owns_frame_target(opener) + }); + let parent_owned = info + .get("parentId") + .or_else(|| info.get("parentFrameId")) + .and_then(Value::as_str) + .is_some_and(|parent| { + parent == opener_target_id + || page_for_task + .frame_state + .lock() + .unwrap() + .owns_frame_target(parent) + }); + if !opener_owned && !parent_owned { continue; } let Some(target_id) = info.get("targetId").and_then(Value::as_str) else { @@ -39139,14 +40661,39 @@ return { ready: true, result: true, payload: null }; .and_then(Value::as_str) .unwrap_or("") .to_string(); + let attached = info + .get("attached") + .and_then(Value::as_bool) + .unwrap_or(false); + if attached { + let existing_session_id = page_for_task + .frame_state + .lock() + .unwrap() + .worker_session_for_target(target_id); + if let Some(session_id) = existing_session_id { + workers.push(PyWorker { + browser: Arc::clone(&browser_for_task), + page: Some(Arc::clone(&page_for_task)), + target_id: target_id.to_string(), + session_id, + url, + }); + continue; + } + } if let Ok(worker) = attach_existing_worker( Arc::clone(&browser_for_task), + Some(Arc::clone(&page_for_task)), target_id.to_string(), url, timeout, ) .await { + let mut state = page_for_task.frame_state.lock().unwrap(); + state.record_worker_session(&worker.session_id); + state.associate_worker_target_session(target_id, &worker.session_id); workers.push(worker); } } @@ -39487,6 +41034,70 @@ impl PyWorker { fn target_id(&self) -> String { self.target_id.clone() } + #[getter] + fn session_id(&self) -> String { + self.session_id.clone() + } + + #[pyo3(signature = (timeout_ms=None))] + fn run_if_waiting_for_debugger(&self, py: Python<'_>, timeout_ms: Option) -> PyResult<()> { + let browser = Arc::clone(&self.browser); + let client = Arc::clone(&browser.client); + let page = self.page.clone(); + let session_id = self.session_id.clone(); + let timeout = BrowserInner::command_timeout(timeout_ms); + py.detach(move || { + browser.block_on(async move { + if let Some(page) = page.as_ref() { + let should_resume = page + .frame_state + .lock() + .unwrap() + .prepare_worker_resume(&session_id); + if !should_resume { + return Ok(()); + } + return resume_worker_after_claim(page, &session_id, timeout).await; + } + client + .send( + "Runtime.runIfWaitingForDebugger", + json!({}), + Some(&session_id), + timeout, + ) + .await + .map(|_| ()) + }) + }) + .map_err(py_err) + } + + fn claim_capture_handoff(&self) -> bool { + self.page.as_ref().is_some_and(|page| { + page.frame_state + .lock() + .unwrap() + .claim_worker_resume_handoff(&self.session_id) + }) + } + fn mark_capture_ready(&self) -> bool { + self.page.as_ref().is_some_and(|page| { + page.frame_state + .lock() + .unwrap() + .mark_worker_capture_ready(&self.session_id) + }) + } + + fn fail_capture_handoff(&self) -> bool { + self.page.as_ref().is_some_and(|page| { + page.frame_state + .lock() + .unwrap() + .mark_worker_resume_handoff_failed(&self.session_id) + }) + } #[pyo3(signature = (expression, arg_json=None, timeout_ms=None))] fn evaluate( @@ -39739,9 +41350,13 @@ impl PyWorker { } fn close_event_waiter(&self) -> PyWorkerCloseEventWaiter { + let client = &self.browser.client; + let (receiver, event_cursor) = client.subscribe_with_cursor(); PyWorkerCloseEventWaiter { browser: Arc::clone(&self.browser), - receiver: Mutex::new(Some(self.browser.client.subscribe())), + receiver: Mutex::new(Some(receiver)), + event_log: Arc::clone(&client.event_log), + event_cursor: Mutex::new(event_cursor), target_id: self.target_id.clone(), session_id: self.session_id.clone(), } @@ -39753,18 +41368,26 @@ impl PyWorker { let client = Arc::clone(&browser.client); let session_id = self.session_id.clone(); let timeout = BrowserInner::command_timeout(timeout_ms); - browser + let (receiver, event_cursor) = client.subscribe_with_cursor(); + let setup_client = Arc::clone(&client); + let setup_session_id = session_id.clone(); + let replay_until = browser .block_on(async move { - client - .send("Runtime.enable", json!({}), Some(&session_id), timeout) - .await?; - Ok(()) + enable_runtime_with_local_console_cutoff(&setup_client, &setup_session_id, timeout) + .await }) .map_err(py_err)?; + let replay_until = std::iter::once((session_id.clone(), replay_until)).collect(); Ok(PyConsoleEventWaiter { browser: Arc::clone(&self.browser), - receiver: Mutex::new(Some(self.browser.client.subscribe())), + receiver: Mutex::new(Some(receiver)), + event_log: Arc::clone(&client.event_log), session_id: self.session_id.clone(), + event_cursor: Mutex::new(event_cursor), + replay_until, + event_kind: "console".to_string(), + page: None, + page_state: Mutex::new(None), }) } } @@ -39917,7 +41540,11 @@ impl PyPageEventStream { let page = Arc::clone(&self.page); let browser = Arc::clone(&self.browser); browser - .block_on(async move { enable_console_capture(&page, Duration::from_secs(5)).await }) + .block_on(async move { + enable_console_capture(&page, Duration::from_secs(5)) + .await + .map(|_| ()) + }) .map_err(py_err) } @@ -39928,7 +41555,11 @@ impl PyPageEventStream { python_future_on( py, runtime, - async move { enable_console_capture(&page, Duration::from_secs(5)).await }, + async move { + enable_console_capture(&page, Duration::from_secs(5)) + .await + .map(|_| ()) + }, |py, ()| Ok(py.None()), ) } @@ -39969,6 +41600,7 @@ impl PyPageEventStream { let mut cursor = *self.cursor.lock().unwrap(); let session_id = self.session_id.clone(); let requests = Arc::clone(&self.requests); + let page = Arc::clone(&self.page); let close_rx = self.close_tx.subscribe(); let alive_rx = browser.client.alive_tx.subscribe(); let timeout = BrowserInner::command_timeout(timeout_ms); @@ -39980,6 +41612,7 @@ impl PyPageEventStream { &session_id, requests, &mut state, + page.as_ref(), close_rx, alive_rx, timeout, @@ -40032,6 +41665,7 @@ impl PyPageEventStream { .take() .ok_or_else(|| PyRuntimeError::new_err("page event stream is already waiting"))?; let browser = Arc::clone(&self.browser); + let page = Arc::clone(&self.page); let runtime = browser.runtime.handle().clone(); let event_log = Arc::clone(&self.event_log); let cursor = *self.cursor.lock().unwrap(); @@ -40066,6 +41700,7 @@ impl PyPageEventStream { &session_id, working_requests, lease.state.as_mut().unwrap(), + page.as_ref(), close_rx, alive_rx, timeout, @@ -40170,26 +41805,79 @@ impl PyDialogEventWaiter { } #[cfg(feature = "python")] -#[pymethods] impl PyConsoleEventWaiter { - #[pyo3(signature = (timeout_ms=None))] - fn wait(&self, py: Python<'_>, timeout_ms: Option) -> PyResult { + fn wait_internal( + &self, + py: Python<'_>, + timeout_ms: Option, + event_type: Option, + text: Option, + exact_text: bool, + ) -> PyResult { let mut receiver = self .receiver .lock() .unwrap() .take() .ok_or_else(|| PyRuntimeError::new_err("console waiter is already waiting"))?; + let mut page_state = self.page_state.lock().unwrap().take(); let browser = Arc::clone(&self.browser); + let event_log = Arc::clone(&self.event_log); let session_id = self.session_id.clone(); + let event_cursor = *self.event_cursor.lock().unwrap(); + let replay_until = self.replay_until.clone(); + let event_kind = self.event_kind.clone(); + let page = self.page.clone(); let timeout = BrowserInner::command_timeout(timeout_ms); - let (result, receiver) = py.detach(move || { - let result = - browser.block_on_raw(wait_for_console_event(&mut receiver, &session_id, timeout)); - (result, receiver) + let (result, receiver, event_cursor, page_state) = py.detach(move || { + let result = browser.block_on_raw(wait_for_console_event( + &mut receiver, + event_log, + Arc::clone(&browser), + &session_id, + event_cursor, + &replay_until, + &event_kind, + page, + &mut page_state, + event_type.as_deref(), + text.as_deref(), + exact_text, + timeout, + )); + (result, receiver, event_cursor, page_state) }); *self.receiver.lock().unwrap() = Some(receiver); - result.map_err(py_err) + *self.event_cursor.lock().unwrap() = event_cursor; + *self.page_state.lock().unwrap() = page_state; + result.0.map_err(py_err) + } +} + +#[cfg(feature = "python")] +#[pymethods] +impl PyConsoleEventWaiter { + #[pyo3(signature = (timeout_ms=None))] + fn wait(&self, py: Python<'_>, timeout_ms: Option) -> PyResult { + self.wait_internal(py, timeout_ms, None, None, false) + } + + #[pyo3(signature = (timeout_ms=None, event_type=None, text=None, exact_text=false))] + fn wait_matching( + &self, + py: Python<'_>, + timeout_ms: Option, + event_type: Option<&str>, + text: Option<&str>, + exact_text: bool, + ) -> PyResult { + self.wait_internal( + py, + timeout_ms, + event_type.map(ToString::to_string), + text.map(ToString::to_string), + exact_text, + ) } } @@ -40350,19 +42038,23 @@ impl PyWorkerEventWaiter { .take() .ok_or_else(|| PyRuntimeError::new_err("worker waiter is already waiting"))?; let browser = Arc::clone(&self.browser); - let opener_target_id = self.opener_target_id.clone(); + let page = Arc::clone(&self.page); + let event_log = Arc::clone(&self.event_log); + let event_cursor = *self.event_cursor.lock().unwrap(); let timeout = BrowserInner::command_timeout(timeout_ms); - let (result, receiver) = py.detach(move || { - let browser_for_wait = Arc::clone(&browser); - let result = browser.block_on_raw(wait_for_worker( + let (result, receiver, event_cursor) = py.detach(move || { + let (result, event_cursor) = browser.block_on_raw(wait_for_worker( &mut receiver, - browser_for_wait, - &opener_target_id, + event_log, + event_cursor, + Arc::clone(&browser), + page, timeout, )); - (result, receiver) + (result, receiver, event_cursor) }); *self.receiver.lock().unwrap() = Some(receiver); + *self.event_cursor.lock().unwrap() = event_cursor; result.map_err(py_err) } } @@ -40379,19 +42071,24 @@ impl PyWorkerCloseEventWaiter { .take() .ok_or_else(|| PyRuntimeError::new_err("worker close waiter is already waiting"))?; let browser = Arc::clone(&self.browser); + let event_log = Arc::clone(&self.event_log); + let event_cursor = *self.event_cursor.lock().unwrap(); let target_id = self.target_id.clone(); let session_id = self.session_id.clone(); let timeout = BrowserInner::command_timeout(timeout_ms); - let (result, receiver) = py.detach(move || { - let result = browser.block_on_raw(wait_for_worker_close( + let (result, receiver, event_cursor) = py.detach(move || { + let (result, event_cursor) = browser.block_on_raw(wait_for_worker_close( &mut receiver, + event_log, + event_cursor, &target_id, &session_id, timeout, )); - (result, receiver) + (result, receiver, event_cursor) }); *self.receiver.lock().unwrap() = Some(receiver); + *self.event_cursor.lock().unwrap() = event_cursor; result.map_err(py_err) } } @@ -40545,6 +42242,7 @@ impl PyPage { .await?; Ok(response.response.unwrap_or(Value::Null).to_string()) }) + .map_err(|error| normalize_navigation_timeout(error, OperationDeadline::new(timeout))) .map_err(py_err) } @@ -42231,7 +43929,11 @@ impl RustwrightPage { pub fn arm_console_capture(&self) -> RwResult<()> { let page = Arc::clone(&self.inner); let browser = Arc::clone(&page.browser); - browser.block_on(async move { enable_console_capture(&page, Duration::from_secs(5)).await }) + browser.block_on(async move { + enable_console_capture(&page, Duration::from_secs(5)) + .await + .map(|_| ()) + }) } /// Read the page's bounded request/response lifecycle ring. @@ -42510,7 +44212,7 @@ impl RustwrightPage { let session_id = page.session_id.clone(); let operation_client = Arc::clone(&client); let operation_session_id = session_id.clone(); - browser.block_on_raw(cancelable_navigation( + let result = browser.block_on_raw(cancelable_navigation( client, session_id, cancel.cloned(), @@ -42551,7 +44253,8 @@ impl RustwrightPage { } Ok(Value::Null.to_string()) }, - )) + )); + result.map_err(|error| normalize_navigation_timeout(error, OperationDeadline::new(timeout))) } pub fn reload_with_cancel_observed( @@ -43396,7 +45099,7 @@ return waitForScrollSettle(); let session_id = page.session_id.clone(); let operation_client = Arc::clone(&client); let operation_session_id = session_id.clone(); - browser.block_on_raw(cancelable_navigation( + let result = browser.block_on_raw(cancelable_navigation( client, session_id, cancel.cloned(), @@ -43499,7 +45202,8 @@ return waitForScrollSettle(); } Ok((true, response.response.unwrap_or(Value::Null).to_string())) }, - )) + )); + result.map_err(|error| normalize_navigation_timeout(error, OperationDeadline::new(timeout))) } fn evaluate_locator_json( @@ -45338,6 +47042,7 @@ async fn attach_existing_page_unregistered( #[cfg(feature = "python")] async fn attach_existing_worker( browser: Arc, + page: Option>, target_id: String, url: String, timeout: Duration, @@ -45359,6 +47064,7 @@ async fn attach_existing_worker( install_worker_stealth_defaults(&browser.client, &session_id).await?; Ok(PyWorker { browser, + page, target_id, session_id, url, @@ -45389,6 +47095,31 @@ async fn enable_page_iframe_auto_attach( Ok(()) } +async fn enable_worker_auto_attach( + client: &CdpClient, + session_id: &str, + timeout: Duration, +) -> RwResult<()> { + client + .send( + "Target.setAutoAttach", + json!({ + "autoAttach": true, + "waitForDebuggerOnStart": true, + "flatten": true, + "filter": [ + { "type": "iframe", "exclude": false }, + { "type": "worker", "exclude": false }, + { "exclude": true }, + ], + }), + Some(session_id), + timeout, + ) + .await + .map(|_| ()) +} + async fn enable_action_dispatch_binding_for_session( client: &CdpClient, session_id: &str, @@ -45638,78 +47369,298 @@ fn enqueue_focus_emulation( }) } -async fn enable_console_capture(page: &PageInner, timeout: Duration) -> RwResult<()> { +async fn clear_console_capture_for_session(page: &PageInner, session_id: &str) { + let mut enabled_sessions = page.console_capture.enabled_sessions.lock().await; + enabled_sessions.remove(session_id); + page.console_replay_until_event_cursor + .lock() + .unwrap() + .remove(session_id); +} + +async fn register_page_owned_iframes_from_event_log(page: &PageInner) { + let events = page + .browser + .client + .event_log + .lock() + .unwrap() + .entries_since(0); + let detached_sessions = { + let mut state = page.frame_state.lock().unwrap(); + let mut detached_sessions = Vec::new(); + for (sequence, event) in events { + match event.get("method").and_then(Value::as_str) { + Some("Target.attachedToTarget") => { + let target_info = event.pointer("/params/targetInfo").unwrap_or(&Value::Null); + if target_info.get("type").and_then(Value::as_str) != Some("iframe") { + continue; + } + let Some(parent_session_id) = event.get("sessionId").and_then(Value::as_str) + else { + continue; + }; + let Some(child_session_id) = + event.pointer("/params/sessionId").and_then(Value::as_str) + else { + continue; + }; + let Some(frame_id) = target_info.get("targetId").and_then(Value::as_str) else { + continue; + }; + if !state.owns_session(parent_session_id) { + continue; + } + let parent_frame_id = target_info + .get("parentFrameId") + .and_then(Value::as_str) + .map(ToString::to_string); + state.record_session_for_frame_at_sequence( + frame_id, + child_session_id, + Some(sequence), + ); + state.record_frame( + frame_id.to_string(), + parent_frame_id, + target_info + .get("name") + .or_else(|| target_info.get("title")) + .and_then(Value::as_str) + .map(ToString::to_string), + target_info + .get("url") + .and_then(Value::as_str) + .map(ToString::to_string), + child_session_id.to_string(), + ); + } + Some("Target.detachedFromTarget") => { + let Some(session_id) = + event.pointer("/params/sessionId").and_then(Value::as_str) + else { + continue; + }; + if !state.session_frames.contains_key(session_id) { + continue; + } + state.detach_session_at_sequence(session_id, Some(sequence)); + detached_sessions.push((session_id.to_string(), Some(sequence))); + } + _ => {} + } + } + detached_sessions + }; + for (session_id, cutoff) in detached_sessions { + page.browser + .client + .event_log + .lock() + .unwrap() + .finalize_console_replay_cutoff_on_detach(&session_id, cutoff); + clear_console_capture_for_session(page, &session_id).await; + } +} + +async fn register_page_owned_workers_from_event_log(page: &PageInner) { + let events = page + .browser + .client + .event_log + .lock() + .unwrap() + .entries_since(0); + let detached_sessions = { + let mut state = page.frame_state.lock().unwrap(); + let mut detached_sessions = Vec::new(); + for (sequence, event) in events { + match event.get("method").and_then(Value::as_str) { + Some("Target.attachedToTarget") => { + let target_info = event.pointer("/params/targetInfo").unwrap_or(&Value::Null); + if target_info.get("type").and_then(Value::as_str) != Some("worker") { + continue; + } + let Some(parent_session_id) = event.get("sessionId").and_then(Value::as_str) + else { + continue; + }; + let Some(child_session_id) = + event.pointer("/params/sessionId").and_then(Value::as_str) + else { + continue; + }; + if state.owns_session(parent_session_id) { + let accepted = if let Some(target_id) = + target_info.get("targetId").and_then(Value::as_str) + { + state.record_worker_target_session_at_sequence( + target_id, + child_session_id, + Some(sequence), + ) + } else { + state + .record_worker_session_at_sequence(child_session_id, Some(sequence)) + }; + if accepted { + state.arm_worker_resume_handoff(child_session_id, Some(sequence)); + } + } + } + Some("Target.detachedFromTarget") => { + if let Some(session_id) = + event.pointer("/params/sessionId").and_then(Value::as_str) + { + if state.remove_worker_session_at_sequence(session_id, Some(sequence)) { + detached_sessions.push((session_id.to_string(), Some(sequence))); + } + } + } + _ => {} + } + } + detached_sessions + }; + for (session_id, cutoff) in detached_sessions { + page.browser + .client + .event_log + .lock() + .unwrap() + .finalize_console_replay_cutoff_on_detach(&session_id, cutoff); + clear_console_capture_for_session(page, &session_id).await; + } +} + +async fn enable_console_capture( + page: &PageInner, + timeout: Duration, +) -> RwResult> { + let deadline = Instant::now() + timeout; page.console_capture.requested.store(true, Ordering::SeqCst); + register_page_owned_iframes_from_event_log(page).await; + register_page_owned_workers_from_event_log(page).await; let sessions = { let frame_state = page.frame_state.lock().unwrap(); - std::iter::once(page.session_id.clone()) - .chain(frame_state.iframe_sessions_ready.iter().cloned()) - .collect::>() + let mut sessions = frame_state.all_owned_session_ids(); + sessions.extend(frame_state.worker_sessions.iter().cloned()); + sessions.into_iter().collect::>() }; + let mut replay_until = HashMap::with_capacity(sessions.len()); for session_id in sessions { - enable_console_capture_for_session(page, &session_id, timeout).await?; + let remaining = deadline.saturating_duration_since(Instant::now()); + if remaining.is_zero() { + return Err(RwError::Timeout(timeout.as_millis() as u64)); + } + let cutoff = enable_console_capture_for_session(page, &session_id, remaining).await?; + replay_until.insert(session_id, cutoff); } - Ok(()) + Ok(replay_until) } async fn enable_console_capture_for_session_if_requested( page: &PageInner, session_id: &str, timeout: Duration, -) -> RwResult<()> { +) -> RwResult> { if !page.console_capture.requested.load(Ordering::SeqCst) { - return Ok(()); + return Ok(None); } - enable_console_capture_for_session(page, session_id, timeout).await + enable_console_capture_for_session(page, session_id, timeout) + .await + .map(Some) +} + +async fn enable_runtime_with_local_console_cutoff( + client: &CdpClient, + session_id: &str, + timeout: Duration, +) -> RwResult { + client + .send("Runtime.enable", json!({}), Some(session_id), timeout) + .await?; + Ok(client.event_cursor()) } async fn enable_console_capture_for_session( page: &PageInner, session_id: &str, timeout: Duration, -) -> RwResult<()> { +) -> RwResult { // Hold the enabled-session set across Runtime.enable. This makes first use // single-flight and records the session before any later console_records() // call can send the detectable command a second time. let mut enabled_sessions = page.console_capture.enabled_sessions.lock().await; if enabled_sessions.contains(session_id) { - return Ok(()); + return Ok(page.browser.client.event_cursor()); } let started = Instant::now(); - // CDP emits replay events before the Runtime.enable response, but the page - // listener may consume them after the response task wakes. Keep the event - // cursor window explicit so scheduler order cannot restamp replayed records. + // Publish the provisional cutoff in CdpEventLog before Runtime.enable. Ordered consumers + // all read this sequenced metadata source, so replay cannot pass during setup. + let cutoff_generation = page + .browser + .client + .event_log + .lock() + .unwrap() + .begin_console_replay_cutoff(session_id); page.console_replay_until_event_cursor .lock() .unwrap() .insert(session_id.to_owned(), u64::MAX); - let enabled = page + let replay_until = match page .browser .client .send("Runtime.enable", json!({}), Some(session_id), timeout) - .await; - let replay_until = page.browser.client.event_cursor(); + .await + { + Ok(_) => { + let cutoff = page.browser.client.event_cursor(); + page.browser + .client + .event_log + .lock() + .unwrap() + .finalize_console_replay_cutoff(session_id, cutoff_generation, cutoff); + cutoff + } + Err(error) => { + page.console_replay_until_event_cursor + .lock() + .unwrap() + .remove(session_id); + page.browser + .client + .event_log + .lock() + .unwrap() + .cancel_console_replay_cutoff(session_id, cutoff_generation); + return Err(error); + } + }; page.console_replay_until_event_cursor .lock() .unwrap() .insert(session_id.to_owned(), replay_until); - enabled?; let remaining = timeout.saturating_sub(started.elapsed()); if page.observation_event_cursor.load(Ordering::SeqCst) < replay_until { - tokio::time::timeout(remaining, async { + let waited = tokio::time::timeout(remaining, async { while page.observation_event_cursor.load(Ordering::SeqCst) < replay_until { tokio::task::yield_now().await; } }) - .await - .map_err(|_| RwError::Timeout(timeout.as_millis() as u64))?; - } - let mut replay_windows = page.console_replay_until_event_cursor.lock().unwrap(); - if replay_windows.get(session_id) == Some(&replay_until) { - replay_windows.remove(session_id); + .await; + if waited.is_err() { + let mut replay_windows = page.console_replay_until_event_cursor.lock().unwrap(); + if replay_windows.get(session_id) == Some(&replay_until) { + replay_windows.remove(session_id); + } + return Err(RwError::Timeout(timeout.as_millis() as u64)); + } } enabled_sessions.insert(session_id.to_string()); - Ok(()) + Ok(replay_until) } const ATTACHED_IFRAME_SETUP_RETRY_LIMIT: usize = 1; @@ -45775,6 +47726,9 @@ fn spawn_attached_iframe_session_initialization( ); return; } + let auto_attach_lock = page + .iframe_setup_tasks + .auto_attach_lock_for_session(&session_pin.session_id); let token = page .iframe_setup_tasks .next_token @@ -45800,19 +47754,83 @@ fn spawn_attached_iframe_session_initialization( } let client = Arc::clone(&page.browser.client); drop(page); - let mut attempt = 0; - loop { - match enable_page_iframe_auto_attach(&client, &session_pin.session_id, timeout) - .await - { - Ok(()) => break, - Err(error) - if attempt < ATTACHED_IFRAME_SETUP_RETRY_LIMIT - && is_transient_attached_session_setup_error(&error) => - { - attempt += 1; + { + let _auto_attach_guard = auto_attach_lock.lock().await; + let worker_auto_attach_requested = weak_page + .upgrade() + .map(|page| { + page.frame_state + .lock() + .unwrap() + .worker_auto_attach_requested() + }) + .unwrap_or(false); + if worker_auto_attach_requested { + let mut attempt = 0; + loop { + match enable_worker_auto_attach(&client, &session_pin.session_id, timeout) + .await + { + Ok(()) => break, + Err(error) + if attempt < ATTACHED_IFRAME_SETUP_RETRY_LIMIT + && is_transient_attached_session_setup_error(&error) => + { + attempt += 1; + } + Err(error) => return Err(error), + } + } + } else { + let mut attempt = 0; + loop { + match enable_page_iframe_auto_attach( + &client, + &session_pin.session_id, + timeout, + ) + .await + { + Ok(()) => break, + Err(error) + if attempt < ATTACHED_IFRAME_SETUP_RETRY_LIMIT + && is_transient_attached_session_setup_error(&error) => + { + attempt += 1; + } + Err(error) => return Err(error), + } + } + let worker_auto_attach_requested = weak_page + .upgrade() + .map(|page| { + page.frame_state + .lock() + .unwrap() + .worker_auto_attach_requested() + }) + .unwrap_or(false); + if worker_auto_attach_requested { + let mut attempt = 0; + loop { + match enable_worker_auto_attach( + &client, + &session_pin.session_id, + timeout, + ) + .await + { + Ok(()) => break, + Err(error) + if attempt < ATTACHED_IFRAME_SETUP_RETRY_LIMIT + && is_transient_attached_session_setup_error(&error) => + { + attempt += 1; + } + Err(error) => return Err(error), + } + } } - Err(error) => return Err(error), } } @@ -46101,15 +48119,68 @@ fn spawn_page_oopif_event_listener(page: Weak) { spawn_page_sequence_gated_event_listener(page, events); } +#[cfg(test)] +struct PageLifecycleTestBarrier { + entered: watch::Sender, + released: Arc, + completed: watch::Sender, +} + +#[cfg(test)] +fn page_lifecycle_test_barriers( +) -> &'static Mutex> { + static BARRIERS: std::sync::LazyLock< + Mutex>, + > = std::sync::LazyLock::new(|| Mutex::new(HashMap::new())); + &BARRIERS +} + +#[cfg(test)] +fn install_page_lifecycle_test_barrier( + page: &Arc, + child_session_id: &str, +) -> ( + watch::Receiver, + watch::Receiver, + Arc, +) { + let (entered, entered_rx) = watch::channel(false); + let (completed, completed_rx) = watch::channel(false); + let released = Arc::new(tokio::sync::Notify::new()); + page_lifecycle_test_barriers().lock().unwrap().insert( + (Arc::as_ptr(page) as usize, child_session_id.to_string()), + PageLifecycleTestBarrier { + entered, + released: Arc::clone(&released), + completed, + }, + ); + (entered_rx, completed_rx, released) +} + +#[cfg(test)] +async fn wait_for_page_lifecycle_test_barrier(page: &PageInner, event: &Value) { + if event.get("method").and_then(Value::as_str) != Some("Target.attachedToTarget") { + return; + } + let Some(child_session_id) = event.pointer("/params/sessionId").and_then(Value::as_str) else { + return; + }; + let barrier = page_lifecycle_test_barriers().lock().unwrap().remove(&( + page as *const PageInner as usize, + child_session_id.to_string(), + )); + let Some(barrier) = barrier else { + return; + }; + let _ = barrier.entered.send(true); + barrier.released.notified().await; + let _ = barrier.completed.send(true); +} + fn advance_observation_event_cursor(page: &PageInner, cursor: u64) { - let previous = page - .observation_event_cursor + page.observation_event_cursor .fetch_max(cursor, Ordering::SeqCst); - let committed_cursor = previous.max(cursor); - page.console_replay_until_event_cursor - .lock() - .unwrap() - .retain(|_, replay_until| *replay_until == u64::MAX || committed_cursor < *replay_until); } fn spawn_page_sequence_gated_event_listener( @@ -46192,10 +48263,6 @@ async fn reconcile_page_oopif_events_after_lag( handle_page_oopif_event(Arc::clone(page), event).await; page.observation_event_cursor .store(replay_cursor, Ordering::SeqCst); - page.console_replay_until_event_cursor - .lock() - .unwrap() - .retain(|_, replay_until| *replay_until == u64::MAX || replay_cursor < *replay_until); } if overflowed { reconcile_page_frame_tree_authoritatively(page).await?; @@ -46544,6 +48611,136 @@ fn prune_page_navigation_epochs_to_contiguous_progress( .prune_navigation_epochs_through(low_water_mark); } +const WORKER_RESUME_HANDOFF_WATCHDOG: Duration = Duration::from_secs(1); + +async fn wait_for_worker_forwarding_capture( + page: &PageInner, + session_id: &str, + timeout: Duration, +) -> bool { + let mut updates = page.frame_state.lock().unwrap().subscribe_session_updates(); + let deadline = tokio::time::Instant::now() + timeout; + loop { + if page + .frame_state + .lock() + .unwrap() + .worker_capture_ready_or_unclaimed(session_id) + { + return true; + } + let now = tokio::time::Instant::now(); + if now >= deadline { + return false; + } + let remaining = deadline - now; + if tokio::time::timeout(remaining, updates.changed()) + .await + .is_err() + { + return false; + } + } +} +async fn resume_worker_after_claim( + page: &PageInner, + session_id: &str, + timeout: Duration, +) -> RwResult<()> { + let result = page + .browser + .client + .send( + "Runtime.runIfWaitingForDebugger", + json!({}), + Some(session_id), + timeout, + ) + .await + .map(|_| ()); + let mut state = page.frame_state.lock().unwrap(); + match result { + Ok(()) => { + state.complete_worker_resume_handoff(session_id); + Ok(()) + } + Err(error) => { + state.mark_worker_resume_handoff_failed(session_id); + Err(error) + } + } +} + +fn worker_resume_page_is_live(page: &PageInner) -> bool { + !page.lifecycle.is_closing_or_closed() + && !page.target_closed.load(Ordering::SeqCst) + && !page.browser.lifecycle.is_closing_or_closed() + && page.browser.client.is_connected() +} + +async fn worker_resume_watchdog_step( + page: &PageInner, + session_id: &str, + updates: &mut watch::Receiver, + watchdog: Duration, +) -> bool { + if !worker_resume_page_is_live(page) { + return false; + } + let handoff_finished = async { + loop { + let pending = page + .frame_state + .lock() + .unwrap() + .worker_resume_handoff_pending(session_id); + if !pending || !worker_resume_page_is_live(page) || updates.changed().await.is_err() { + return; + } + } + }; + if tokio::time::timeout(watchdog, handoff_finished) + .await + .is_ok() + { + return false; + } + if !worker_resume_page_is_live(page) { + return false; + } + let should_resume = page + .frame_state + .lock() + .unwrap() + .take_worker_resume_fallback(session_id); + if should_resume { + let _ = resume_worker_after_claim(page, session_id, Duration::from_secs(1)).await; + } + true +} + +fn spawn_worker_resume_watchdog(page: Arc, session_id: String) { + let weak_page = Arc::downgrade(&page); + let mut updates = page.frame_state.lock().unwrap().subscribe_session_updates(); + tokio::spawn(async move { + loop { + let Some(page) = weak_page.upgrade() else { + return; + }; + if !worker_resume_watchdog_step( + &page, + &session_id, + &mut updates, + WORKER_RESUME_HANDOFF_WATCHDOG, + ) + .await + { + return; + } + } + }); +} + async fn handle_page_oopif_event(page: Arc, event: Value) { let method = event.get("method").and_then(Value::as_str).unwrap_or(""); let event_sequence = cdp_event_sequence(&event); @@ -46554,6 +48751,8 @@ async fn handle_page_oopif_event(page: Arc, event: Value) { if !is_page_navigation { record_page_observation_event(&page, &event); } + #[cfg(test)] + wait_for_page_lifecycle_test_barrier(&page, &event).await; if method == "Target.targetCrashed" && event.pointer("/params/targetId").and_then(Value::as_str) == Some(page.target_id.as_str()) @@ -46572,6 +48771,7 @@ async fn handle_page_oopif_event(page: Arc, event: Value) { == Some(page.target_id.as_str()) { page.target_closed.store(true, Ordering::SeqCst); + page.clear_worker_resume_handoffs(); page.abort_iframe_setup_tasks(); return; } @@ -46582,6 +48782,7 @@ async fn handle_page_oopif_event(page: Arc, event: Value) { == Some(page.session_id.as_str())) { page.target_closed.store(true, Ordering::SeqCst); + page.clear_worker_resume_handoffs(); page.abort_iframe_setup_tasks(); return; } @@ -46606,6 +48807,50 @@ async fn handle_page_oopif_event(page: Arc, event: Value) { .get("type") .and_then(Value::as_str) .unwrap_or(""); + if target_type == "worker" { + let accepted = { + let mut state = page.frame_state.lock().unwrap(); + if let Some(target_id) = target_info.get("targetId").and_then(Value::as_str) { + state.record_worker_target_session_at_sequence( + target_id, + child_session_id, + event_sequence, + ) + } else { + state.worker_session_attachment_is_current(child_session_id, event_sequence) + && (state + .worker_session_recorded_at_sequence(child_session_id, event_sequence) + || state.record_worker_session_at_sequence( + child_session_id, + event_sequence, + )) + } + }; + if !accepted { + return; + } + let child_session_id = child_session_id.to_string(); + let resume_without_consumer = { + let mut state = page.frame_state.lock().unwrap(); + state.arm_worker_resume_handoff(&child_session_id, event_sequence); + !state.worker_event_interest() + && state.take_worker_resume_fallback(&child_session_id) + }; + if resume_without_consumer { + let page_for_resume = Arc::clone(&page); + let session_for_resume = child_session_id.clone(); + tokio::spawn(async move { + let _ = resume_worker_after_claim( + &page_for_resume, + &session_for_resume, + Duration::from_secs(1), + ) + .await; + }); + } + spawn_worker_resume_watchdog(Arc::clone(&page), child_session_id); + return; + } if target_type != "iframe" { let client = Arc::clone(&page.browser.client); let child_session_id = child_session_id.to_string(); @@ -46653,6 +48898,25 @@ async fn handle_page_oopif_event(page: Arc, event: Value) { state.seed_attached_frame_metadata(frame_id, &pin, target_name, target_url); } } + if page + .frame_state + .lock() + .unwrap() + .worker_auto_attach_requested() + { + let client = Arc::clone(&page.browser.client); + let child_session_id = child_session_id.to_string(); + tokio::spawn(async move { + let _ = client + .send( + "Runtime.runIfWaitingForDebugger", + json!({}), + Some(&child_session_id), + Duration::from_secs(1), + ) + .await; + }); + } return; } @@ -46661,12 +48925,23 @@ async fn handle_page_oopif_event(page: Arc, event: Value) { else { return; }; - let generations = { + let (worker_detached, generations) = { let mut state = page.frame_state.lock().unwrap(); + let worker_detached = + state.remove_worker_session_at_sequence(detached_session_id, event_sequence); let generations = state.setup_generations_for_session(detached_session_id); state.detach_session_at_sequence(detached_session_id, event_sequence); - generations + (worker_detached, generations) }; + page.browser + .client + .event_log + .lock() + .unwrap() + .finalize_console_replay_cutoff_on_detach(detached_session_id, event_sequence); + if worker_detached { + clear_console_capture_for_session(&page, detached_session_id).await; + } page.iframe_setup_tasks.abort_generations(generations); return; } @@ -49187,6 +51462,110 @@ fn launch_chromium_process( } } +const CHROMIUM_FEATURE_SWITCHES: [&str; 3] = [ + "--disable-features", + "--enable-features", + "--disable-blink-features", +]; + +fn chromium_feature_switch_index(arg: &str) -> Option { + CHROMIUM_FEATURE_SWITCHES.iter().position(|name| { + arg.strip_prefix(name) + .is_some_and(|value| value.starts_with('=')) + }) +} + +fn append_chromium_feature_values( + values: &mut Vec, + seen: &mut HashSet, + arg: &str, + name: &str, +) { + let Some(feature_list) = arg + .strip_prefix(name) + .and_then(|value| value.strip_prefix('=')) + else { + return; + }; + for feature in feature_list.split(',') { + let feature = feature.trim(); + if !feature.is_empty() && seen.insert(feature.to_string()) { + values.push(feature.to_string()); + } + } +} + +fn chromium_effective_launch_arg_parts(options: &LaunchOptions) -> (Vec, Vec) { + if options.ignore_all_default_args { + return (Vec::new(), options.args.clone()); + } + + let default_args = chromium_default_launch_args(options); + let mut effective_defaults = Vec::with_capacity(default_args.len()); + let mut effective_user_args = Vec::with_capacity(options.args.len()); + let mut feature_positions = [None; CHROMIUM_FEATURE_SWITCHES.len()]; + let mut feature_values: Vec> = (0..CHROMIUM_FEATURE_SWITCHES.len()) + .map(|_| Vec::new()) + .collect(); + let mut feature_seen: Vec> = (0..CHROMIUM_FEATURE_SWITCHES.len()) + .map(|_| HashSet::new()) + .collect(); + + for arg in default_args { + if launch_default_arg_ignored(&arg, &options.ignore_default_args) { + continue; + } + if let Some(index) = chromium_feature_switch_index(&arg) { + if feature_positions[index].is_none() { + feature_positions[index] = Some(effective_defaults.len()); + effective_defaults.push(String::new()); + } + append_chromium_feature_values( + &mut feature_values[index], + &mut feature_seen[index], + &arg, + CHROMIUM_FEATURE_SWITCHES[index], + ); + } else { + effective_defaults.push(arg); + } + } + + for arg in &options.args { + if let Some(index) = chromium_feature_switch_index(arg) { + if feature_positions[index].is_some() { + append_chromium_feature_values( + &mut feature_values[index], + &mut feature_seen[index], + arg, + CHROMIUM_FEATURE_SWITCHES[index], + ); + continue; + } + } + effective_user_args.push(arg.clone()); + } + + for (index, position) in feature_positions.into_iter().enumerate() { + if let Some(position) = position { + effective_defaults[position] = format!( + "{}={}", + CHROMIUM_FEATURE_SWITCHES[index], + feature_values[index].join(",") + ); + } + } + (effective_defaults, effective_user_args) +} + +#[cfg(test)] +fn chromium_effective_launch_args(options: &LaunchOptions) -> Vec { + let (mut effective_defaults, effective_user_args) = + chromium_effective_launch_arg_parts(options); + effective_defaults.extend(effective_user_args); + effective_defaults +} + fn chromium_default_launch_args(options: &LaunchOptions) -> Vec { let mut default_args = vec![ "--no-first-run".to_string(), @@ -49200,6 +51579,23 @@ fn chromium_default_launch_args(options: &LaunchOptions) -> Vec { "--disable-renderer-backgrounding".to_string(), "--disable-popup-blocking".to_string(), "--disable-prompt-on-repost".to_string(), + "--disable-back-forward-cache".to_string(), + "--disable-extensions".to_string(), + "--disable-component-update".to_string(), + "--disable-field-trial-config".to_string(), + "--metrics-recording-only".to_string(), + "--no-service-autorun".to_string(), + "--disable-sync".to_string(), + "--disable-default-apps".to_string(), + "--disable-hang-monitor".to_string(), + "--disable-ipc-flooding-protection".to_string(), + "--disable-breakpad".to_string(), + "--disable-client-side-phishing-detection".to_string(), + "--disable-component-extensions-with-background-pages".to_string(), + "--allow-pre-commit-input".to_string(), + "--force-color-profile=srgb".to_string(), + "--disable-search-engine-choice-screen".to_string(), + "--disable-features=DialMediaRouteProvider,GlobalMediaControls,MediaRouter,OptimizationHints,Translate,HttpsUpgrades,PaintHolding,ThirdPartyStoragePartitioning,DestroyProfileOnBrowserClose,AvoidUnnecessaryBeforeUnloadCheckSync,LensOverlay".to_string(), "--enable-features=CDPScreenshotNewSurface".to_string(), "--mute-audio".to_string(), ]; @@ -49264,13 +51660,10 @@ fn launch_chromium_attempt( } } - let default_args = chromium_default_launch_args(options); - if !options.ignore_all_default_args { - for arg in default_args { - if !launch_default_arg_ignored(&arg, &options.ignore_default_args) { - command.arg(arg); - } - } + let (effective_default_args, effective_user_args) = + chromium_effective_launch_arg_parts(options); + for arg in effective_default_args { + command.arg(arg); } for (key, value) in &options.env { command.env(key, value); @@ -49281,7 +51674,7 @@ fn launch_chromium_attempt( command.arg(format!("--proxy-bypass-list={bypass}")); } } - for arg in &options.args { + for arg in effective_user_args { command.arg(arg); } if single_process_fallback && !has_chromium_arg(&options.args, "--single-process") { @@ -50348,6 +52741,7 @@ fn page_event_envelope(seq: u64, kind: &str, payload: Value) -> Value { }) } +#[cfg(test)] fn process_page_observation_event( seq: u64, event: &Value, @@ -50356,6 +52750,23 @@ fn process_page_observation_event( requests: &Arc>, state: &mut PageEventStreamState, ) { + process_page_observation_event_with_page( + seq, event, event_log, session_id, requests, state, None, + ); +} + +fn process_page_observation_event_with_page( + seq: u64, + event: &Value, + event_log: &Arc>, + session_id: &str, + requests: &Arc>, + state: &mut PageEventStreamState, + _page: Option<&PageInner>, +) { + state.ensure_main_session(session_id); + state.sync_replay_cutoffs_for_event(event, event_log); + state.apply_target_transition(event, session_id); let method = event.get("method").and_then(Value::as_str).unwrap_or(""); let matched = match process_network_observation_event( seq, @@ -50368,7 +52779,7 @@ fn process_page_observation_event( ) { Ok(matched) => matched, Err(dropped) => { - *state = PageEventStreamState::new(); + state.reset_after_overflow(); state.ready.insert( seq, page_event_envelope(seq, "_overflow", json!({ "dropped": dropped })), @@ -50385,8 +52796,20 @@ fn process_page_observation_event( return; } - let matches_session = event.get("sessionId").and_then(Value::as_str) == Some(session_id); - if !matches_session { + let Some(event_session_id) = event.get("sessionId").and_then(Value::as_str) else { + return; + }; + if !state.owns_session(event_session_id) { + return; + } + if matches!( + method, + "Runtime.consoleAPICalled" | "Runtime.exceptionThrown" + ) && state + .replay_until + .get(event_session_id) + .is_some_and(|cutoff| *cutoff == u64::MAX || seq < *cutoff) + { return; } let matched = match method { @@ -50457,6 +52880,8 @@ fn append_ready_page_events( } } +const PAGE_EVENT_LOG_SCAN_CHUNK: usize = 256; + async fn wait_for_page_event_batch( events: &mut broadcast::Receiver, event_log: Arc>, @@ -50464,6 +52889,7 @@ async fn wait_for_page_event_batch( session_id: &str, requests: Arc>, state: &mut PageEventStreamState, + page: &PageInner, mut close_rx: watch::Receiver, mut alive_rx: watch::Receiver, timeout: Duration, @@ -50486,14 +52912,21 @@ async fn wait_for_page_event_batch( return (batch, false); } + let raw_limit = (max_events - batch.len()) + .min(PAGE_EVENT_LOG_SCAN_CHUNK) + .max(1); let (oldest_seq, entries) = { let log = event_log.lock().unwrap(); - (log.oldest_seq(), log.entries_since(*cursor)) + ( + log.oldest_seq(), + log.entries_since_limited(*cursor, raw_limit), + ) }; + let scanned_full_chunk = entries.len() >= raw_limit; if *cursor < oldest_seq { let dropped = oldest_seq - *cursor; *cursor = oldest_seq; - *state = PageEventStreamState::new(); + *state = PageEventStreamState::for_page(page); requests.lock().unwrap().reset_after_overflow(oldest_seq); batch.push(page_event_envelope( *cursor, @@ -50509,7 +52942,15 @@ async fn wait_for_page_event_batch( continue; } *cursor = seq.saturating_add(1); - process_page_observation_event(seq, &event, &event_log, session_id, &requests, state); + process_page_observation_event_with_page( + seq, + &event, + &event_log, + session_id, + &requests, + state, + Some(page), + ); expire_page_responses(state); append_ready_page_events(&mut batch, state, max_events); if batch.len() >= max_events { @@ -50519,6 +52960,9 @@ async fn wait_for_page_event_batch( if !batch.is_empty() { return (batch, false); } + if scanned_full_chunk { + continue; + } let now = tokio::time::Instant::now(); if now >= deadline { @@ -50711,59 +53155,6 @@ async fn wait_for_route_event( } } -async fn wait_for_auth_event( - events: &mut broadcast::Receiver, - session_id: &str, - timeout: Duration, -) -> RwResult { - let deadline = tokio::time::Instant::now() + timeout; - loop { - let now = tokio::time::Instant::now(); - if now >= deadline { - return Err(RwError::Timeout(timeout.as_millis() as u64)); - } - let remaining = deadline - now; - match tokio::time::timeout(remaining, events.recv()).await { - Ok(Ok(event)) => { - let matches_session = event - .get("sessionId") - .and_then(Value::as_str) - .map(|value| value == session_id) - .unwrap_or(false); - if !matches_session { - continue; - } - let method = event.get("method").and_then(Value::as_str); - if !matches!( - method, - Some("Fetch.authRequired") | Some("Fetch.requestPaused") - ) { - continue; - } - if let Some(params) = event.get("params") { - let mut payload = params.clone(); - if let Some(object) = payload.as_object_mut() { - object.insert( - "_event".to_string(), - Value::String( - method - .unwrap_or("") - .strip_prefix("Fetch.") - .unwrap_or("") - .to_string(), - ), - ); - } - return Ok(payload.to_string()); - } - } - Ok(Err(broadcast::error::RecvError::Lagged(_))) => continue, - Ok(Err(_)) => return Err(RwError::Message("CDP event stream closed".to_string())), - Err(_) => return Err(RwError::Timeout(timeout.as_millis() as u64)), - } - } -} - async fn wait_for_dialog_event( events: &mut broadcast::Receiver, session_id: &str, @@ -50802,7 +53193,7 @@ async fn wait_for_dialog_event( } } -async fn wait_for_console_event( +async fn wait_for_auth_event( events: &mut broadcast::Receiver, session_id: &str, timeout: Duration, @@ -50824,11 +53215,28 @@ async fn wait_for_console_event( if !matches_session { continue; } - if event.get("method").and_then(Value::as_str) != Some("Runtime.consoleAPICalled") { + let method = event.get("method").and_then(Value::as_str); + if !matches!( + method, + Some("Fetch.authRequired") | Some("Fetch.requestPaused") + ) { continue; } - if let Some(message) = console_from_event(&event) { - return Ok(message.to_string()); + if let Some(params) = event.get("params") { + let mut payload = params.clone(); + if let Some(object) = payload.as_object_mut() { + object.insert( + "_event".to_string(), + Value::String( + method + .unwrap_or("") + .strip_prefix("Fetch.") + .unwrap_or("") + .to_string(), + ), + ); + } + return Ok(payload.to_string()); } } Ok(Err(broadcast::error::RecvError::Lagged(_))) => continue, @@ -50838,6 +53246,426 @@ async fn wait_for_console_event( } } +fn json_value_is_truthy(value: &Value) -> bool { + match value { + Value::Null => false, + Value::Bool(value) => *value, + Value::Number(value) => value.as_f64().is_some_and(|value| value != 0.0), + Value::String(value) => !value.is_empty(), + Value::Array(value) => !value.is_empty(), + Value::Object(value) => !value.is_empty(), + } +} + +fn page_error_event_text(event: &Value) -> (String, String) { + let params = event.get("params").unwrap_or(&Value::Null); + let details = params + .get("exceptionDetails") + .filter(|value| value.is_object()) + .unwrap_or(params); + let exception = details + .get("exception") + .filter(|value| value.is_object()) + .unwrap_or(&Value::Null); + let description = ["description", "value"] + .iter() + .find_map(|key| { + exception + .get(*key) + .filter(|value| json_value_is_truthy(value)) + }) + .or_else(|| { + details + .get("text") + .filter(|value| json_value_is_truthy(value)) + }) + .map(|value| { + value + .as_str() + .map(ToString::to_string) + .unwrap_or_else(|| value.to_string()) + }) + .unwrap_or_else(|| "Page error".to_string()); + let mut text = description.lines().next().unwrap_or("").to_string(); + if let Some((prefix, rest)) = text.split_once(':') { + if prefix.ends_with("Error") + || matches!( + prefix, + "Error" | "TypeError" | "ReferenceError" | "SyntaxError" | "RangeError" + ) + { + let trimmed = rest.trim(); + if !trimmed.is_empty() { + text = trimmed.to_string(); + } + } + } + if text.is_empty() { + text = "Page error".to_string(); + } + let event_type = exception + .get("className") + .and_then(Value::as_str) + .unwrap_or("Error") + .to_string(); + (text, event_type) +} + +fn console_event_matches( + event: &Value, + event_type: Option<&str>, + text: Option<&str>, + exact_text: bool, +) -> bool { + let params = event.get("params").unwrap_or(&Value::Null); + if let Some(expected_type) = event_type { + if params.get("type").and_then(Value::as_str) != Some(expected_type) { + return false; + } + } + let Some(expected_text) = text else { + return true; + }; + let Some(payload) = console_from_event(event) else { + return false; + }; + let actual_text = payload.get("text").and_then(Value::as_str).unwrap_or(""); + if exact_text { + actual_text == expected_text + } else { + actual_text.contains(expected_text) + } +} + +fn page_error_event_matches( + event: &Value, + event_type: Option<&str>, + text: Option<&str>, + exact_text: bool, +) -> bool { + let (actual_text, actual_type) = page_error_event_text(event); + if event_type.is_some_and(|expected_type| expected_type != actual_type) { + return false; + } + text.map(|expected_text| { + if exact_text { + actual_text == expected_text + } else { + actual_text.contains(expected_text) + } + }) + .unwrap_or(true) +} + +fn console_wait_owner_error(page: &PageInner) -> Option { + if page.crashed.load(Ordering::SeqCst) { + return Some(RwError::PageCrashed); + } + if page.lifecycle.is_closing_or_closed() + || page.target_closed.load(Ordering::SeqCst) + || !page.browser.client.is_connected() + { + return Some(RwError::TargetClosed(TargetClosedKind::Page)); + } + None +} + +fn page_worker_session_from_attachment(page: &PageInner, event: &Value) -> Option { + if event.get("method").and_then(Value::as_str) != Some("Target.attachedToTarget") { + return None; + } + let event_sequence = cdp_event_sequence(event); + let parent_session_id = event.get("sessionId").and_then(Value::as_str)?; + let target_info = event.pointer("/params/targetInfo").unwrap_or(&Value::Null); + if target_info.get("type").and_then(Value::as_str) != Some("worker") { + return None; + } + let child_session_id = event.pointer("/params/sessionId").and_then(Value::as_str)?; + let mut state = page.frame_state.lock().unwrap(); + if !state.owns_session(parent_session_id) { + return None; + } + let accepted = if let Some(target_id) = target_info.get("targetId").and_then(Value::as_str) { + state.record_worker_target_session_at_sequence(target_id, child_session_id, event_sequence) + } else { + state.worker_session_attachment_is_current(child_session_id, event_sequence) + && (state.worker_session_recorded_at_sequence(child_session_id, event_sequence) + || state.record_worker_session_at_sequence(child_session_id, event_sequence)) + }; + if !accepted { + return None; + } + state.arm_worker_resume_handoff(child_session_id, event_sequence); + if !state.claim_worker_resume_handoff(child_session_id) { + return None; + } + Some(child_session_id.to_string()) +} + +fn remove_page_worker_session_from_detachment(page: &PageInner, event: &Value) -> Option { + if event.get("method").and_then(Value::as_str) != Some("Target.detachedFromTarget") { + return None; + } + let session_id = event.pointer("/params/sessionId").and_then(Value::as_str)?; + let accepted = page + .frame_state + .lock() + .unwrap() + .remove_worker_session_at_sequence(session_id, cdp_event_sequence(event)); + accepted.then(|| session_id.to_string()) +} + +async fn process_console_wait_event( + event: Value, + event_cursor: &mut u64, + replay_start_cursor: u64, + event_log: &Arc>, + session_id: &str, + replay_until: &HashMap, + event_kind: &str, + page: Option>, + mut page_state: Option<&mut PageEventStreamState>, + event_type: Option<&str>, + text: Option<&str>, + exact_text: bool, +) -> Option { + let event_sequence = cdp_event_sequence(&event); + let event_session_id = event.get("sessionId").and_then(Value::as_str); + let method = event.get("method").and_then(Value::as_str); + if event_sequence.is_some_and(|sequence| sequence < *event_cursor) { + return None; + } + if let Some(sequence) = event_sequence { + *event_cursor = sequence.saturating_add(1); + } + if let Some(state) = page_state.as_mut() { + state.sync_replay_cutoffs_for_event(&event, event_log); + state.apply_target_transition(&event, session_id); + } + let replay_cutoff = event_session_id.and_then(|event_session_id| { + page_state + .as_ref() + .and_then(|state| state.replay_until.get(event_session_id).copied()) + .or_else(|| replay_until.get(event_session_id).copied()) + }); + if matches!( + method, + Some("Runtime.consoleAPICalled") | Some("Runtime.exceptionThrown") + ) && replay_cutoff.is_some_and(|cutoff| { + event_sequence.is_some_and(|sequence| sequence < cutoff && sequence < replay_start_cursor) + }) { + return None; + } + + if let Some(page) = page.as_ref() { + if method == Some("Target.attachedToTarget") { + if let Some(child_session_id) = page_worker_session_from_attachment(page, &event) { + let page = Arc::clone(page); + tokio::spawn(async move { + let setup_result = enable_console_capture_for_session_if_requested( + &page, + &child_session_id, + Duration::from_secs(5), + ) + .await; + if setup_result.is_err() + || !wait_for_worker_forwarding_capture( + &page, + &child_session_id, + Duration::from_secs(5), + ) + .await + { + page.frame_state + .lock() + .unwrap() + .mark_worker_resume_handoff_failed(&child_session_id); + return; + } + let _ = + resume_worker_after_claim(&page, &child_session_id, Duration::from_secs(1)) + .await; + }); + } + return None; + } + if method == Some("Target.detachedFromTarget") { + if let Some(detached_session_id) = + remove_page_worker_session_from_detachment(page, &event) + { + page.browser + .client + .event_log + .lock() + .unwrap() + .finalize_console_replay_cutoff_on_detach(&detached_session_id, event_sequence); + clear_console_capture_for_session(page, &detached_session_id).await; + } + return None; + } + } + + let matches_session = event_session_id == Some(session_id) + || page.as_ref().is_some_and(|page| { + event_session_id.is_some_and(|event_session_id| { + let owns_page_session = page_state + .as_ref() + .is_none_or(|state| state.owns_session(event_session_id)); + let state = page.frame_state.lock().unwrap(); + owns_page_session + || (event_kind == "console" && state.owns_worker_session(event_session_id)) + }) + }); + if !matches_session { + return None; + } + if event_kind == "console" { + if method != Some("Runtime.consoleAPICalled") + || !console_event_matches(&event, event_type, text, exact_text) + { + return None; + } + let mut message = console_from_event(&event)?; + if let Some(sequence) = event_sequence { + if let Some(object) = message.as_object_mut() { + object.insert("__rustwright_cdp_event_seq".to_string(), json!(sequence)); + } + } + return Some(message.to_string()); + } + if method != Some("Runtime.exceptionThrown") + || !page_error_event_matches(&event, event_type, text, exact_text) + { + return None; + } + Some( + event + .get("params") + .cloned() + .unwrap_or(Value::Null) + .to_string(), + ) +} +async fn wait_for_console_event( + events: &mut broadcast::Receiver, + event_log: Arc>, + browser: Arc, + session_id: &str, + mut event_cursor: u64, + replay_until: &HashMap, + event_kind: &str, + page: Option>, + page_state: &mut Option, + event_type: Option<&str>, + text: Option<&str>, + exact_text: bool, + timeout: Duration, +) -> (RwResult, u64) { + let replay_start_cursor = event_cursor; + let deadline = tokio::time::Instant::now() + timeout; + loop { + if let Some(page) = page.as_deref() { + if let Some(error) = console_wait_owner_error(page) { + return (Err(error), event_cursor); + } + } + let now = tokio::time::Instant::now(); + if now >= deadline { + return ( + Err(RwError::Timeout(timeout.as_millis() as u64)), + event_cursor, + ); + } + let remaining = deadline - now; + let received = if page.is_some() { + tokio::select! { + event = events.recv() => Some(event), + _ = tokio::time::sleep(remaining.min(Duration::from_millis(25))) => None, + } + } else { + match tokio::time::timeout(remaining, events.recv()).await { + Ok(event) => Some(event), + Err(_) => { + return ( + Err(RwError::Timeout(timeout.as_millis() as u64)), + event_cursor, + ) + } + } + }; + let Some(received) = received else { + continue; + }; + match received { + Ok(event) => { + if let Some(result) = process_console_wait_event( + event, + &mut event_cursor, + replay_start_cursor, + &event_log, + session_id, + replay_until, + event_kind, + page.clone(), + page_state.as_mut(), + event_type, + text, + exact_text, + ) + .await + { + return (Ok(result), event_cursor); + } + } + Err(broadcast::error::RecvError::Lagged(_)) => { + let (replacement, oldest_seq, replay) = { + let log = event_log.lock().unwrap(); + let replacement = browser.client.subscribe(); + let oldest_seq = log.oldest_seq(); + let replay = log.entries_since(event_cursor.max(oldest_seq)); + (replacement, oldest_seq, replay) + }; + *events = replacement; + if event_cursor < oldest_seq { + let dropped = oldest_seq - event_cursor; + return ( + Err(RwError::Message(format!( + "CDP event log overflow: dropped {dropped} event(s)" + ))), + event_cursor, + ); + } + for (_, event) in replay { + if let Some(result) = process_console_wait_event( + event, + &mut event_cursor, + replay_start_cursor, + &event_log, + session_id, + replay_until, + event_kind, + page.clone(), + page_state.as_mut(), + event_type, + text, + exact_text, + ) + .await + { + return (Ok(result), event_cursor); + } + } + } + Err(_) => { + return ( + Err(RwError::Message("CDP event stream closed".to_string())), + event_cursor, + ); + } + } + } +} + async fn wait_for_websocket_event( events: &mut broadcast::Receiver, session_id: &str, @@ -51313,6 +54141,7 @@ fn list_service_workers_for_context( .to_string(); if let Ok(worker) = attach_existing_worker( Arc::clone(&browser_for_task), + None, target_id.to_string(), url, timeout, @@ -51434,63 +54263,152 @@ fn background_page_event_waiter_for_context( }) } +#[cfg(feature = "python")] +fn worker_from_attachment_event( + page: &Arc, + browser: &Arc, + event: &Value, +) -> RwResult> { + if event.get("method").and_then(Value::as_str) != Some("Target.attachedToTarget") { + return Ok(None); + } + let info = event.pointer("/params/targetInfo").unwrap_or(&Value::Null); + if info.get("type").and_then(Value::as_str) != Some("worker") { + return Ok(None); + } + let Some(parent_session_id) = event.get("sessionId").and_then(Value::as_str) else { + return Ok(None); + }; + let target_id = info + .get("targetId") + .and_then(Value::as_str) + .ok_or_else(|| RwError::Message("worker target did not include targetId".to_string()))? + .to_string(); + let session_id = event + .pointer("/params/sessionId") + .and_then(Value::as_str) + .ok_or_else(|| RwError::Message("worker target did not include sessionId".to_string()))? + .to_string(); + let event_sequence = cdp_event_sequence(event); + let accepted = { + let mut state = page.frame_state.lock().unwrap(); + state.owns_session(parent_session_id) + && state.record_worker_target_session_at_sequence( + &target_id, + &session_id, + event_sequence, + ) + }; + if !accepted { + return Ok(None); + } + let mut state = page.frame_state.lock().unwrap(); + state.arm_worker_resume_handoff(&session_id, event_sequence); + let url = info + .get("url") + .and_then(Value::as_str) + .unwrap_or("") + .to_string(); + Ok(Some(PyWorker { + browser: Arc::clone(browser), + page: Some(Arc::clone(page)), + target_id, + session_id, + url, + })) +} + #[cfg(feature = "python")] async fn wait_for_worker( events: &mut broadcast::Receiver, + event_log: Arc>, + mut event_cursor: u64, browser: Arc, - opener_target_id: &str, + page: Arc, timeout: Duration, -) -> RwResult { +) -> (RwResult, u64) { let deadline = tokio::time::Instant::now() + timeout; loop { let now = tokio::time::Instant::now(); if now >= deadline { - return Err(RwError::Timeout(timeout.as_millis() as u64)); + return ( + Err(RwError::Timeout(timeout.as_millis() as u64)), + event_cursor, + ); + } + let (oldest_seq, entries) = { + let log = event_log.lock().unwrap(); + ( + log.oldest_seq(), + log.entries_since_limited(event_cursor, PAGE_EVENT_LOG_SCAN_CHUNK), + ) + }; + if event_cursor < oldest_seq { + let dropped = oldest_seq - event_cursor; + return ( + Err(RwError::Message(format!( + "CDP event log overflow: dropped {dropped} event(s)" + ))), + event_cursor, + ); + } + for (sequence, event) in entries.iter() { + if *sequence < event_cursor { + continue; + } + event_cursor = sequence.saturating_add(1); + match worker_from_attachment_event(&page, &browser, event) { + Ok(Some(worker)) => return (Ok(worker), event_cursor), + Ok(None) => {} + Err(error) => return (Err(error), event_cursor), + } + } + if entries.len() >= PAGE_EVENT_LOG_SCAN_CHUNK { + continue; } let remaining = deadline - now; match tokio::time::timeout(remaining, events.recv()).await { Ok(Ok(event)) => { - if event.get("method").and_then(Value::as_str) != Some("Target.attachedToTarget") { - continue; + if let Some(sequence) = cdp_event_sequence(&event) { + if sequence < event_cursor { + continue; + } + event_cursor = sequence.saturating_add(1); } - let info = event.pointer("/params/targetInfo").unwrap_or(&Value::Null); - let target_type = info.get("type").and_then(Value::as_str).unwrap_or(""); - if target_type != "worker" { - continue; + match worker_from_attachment_event(&page, &browser, &event) { + Ok(Some(worker)) => return (Ok(worker), event_cursor), + Ok(None) => {} + Err(error) => return (Err(error), event_cursor), } - let opener = info.get("openerId").and_then(Value::as_str); - if opener.is_some() && opener != Some(opener_target_id) { - continue; + } + Ok(Err(broadcast::error::RecvError::Lagged(_))) => { + let (replacement, oldest_seq) = { + let log = event_log.lock().unwrap(); + (browser.client.subscribe(), log.oldest_seq()) + }; + *events = replacement; + if event_cursor < oldest_seq { + let dropped = oldest_seq - event_cursor; + return ( + Err(RwError::Message(format!( + "CDP event log overflow: dropped {dropped} event(s)" + ))), + event_cursor, + ); } - let target_id = info - .get("targetId") - .and_then(Value::as_str) - .ok_or_else(|| { - RwError::Message("worker target did not include targetId".to_string()) - })? - .to_string(); - let session_id = event - .pointer("/params/sessionId") - .and_then(Value::as_str) - .ok_or_else(|| { - RwError::Message("worker target did not include sessionId".to_string()) - })? - .to_string(); - let url = info - .get("url") - .and_then(Value::as_str) - .unwrap_or("") - .to_string(); - return Ok(PyWorker { - browser, - target_id, - session_id, - url, - }); } - Ok(Err(broadcast::error::RecvError::Lagged(_))) => continue, - Ok(Err(_)) => return Err(RwError::Message("CDP event stream closed".to_string())), - Err(_) => return Err(RwError::Timeout(timeout.as_millis() as u64)), + Ok(Err(_)) => { + return ( + Err(RwError::Message("CDP event stream closed".to_string())), + event_cursor, + ) + } + Err(_) => { + return ( + Err(RwError::Timeout(timeout.as_millis() as u64)), + event_cursor, + ) + } } } } @@ -51587,7 +54505,8 @@ async fn wait_for_service_worker( .and_then(Value::as_str) .unwrap_or("") .to_string(); - match attach_existing_worker(Arc::clone(&browser), target_id, url, remaining).await + match attach_existing_worker(Arc::clone(&browser), None, target_id, url, remaining) + .await { Ok(worker) => return Ok(worker), Err(_) => continue, @@ -51602,38 +54521,107 @@ async fn wait_for_service_worker( async fn wait_for_worker_close( events: &mut broadcast::Receiver, + event_log: Arc>, + mut event_cursor: u64, target_id: &str, session_id: &str, timeout: Duration, -) -> RwResult<()> { +) -> (RwResult<()>, u64) { let deadline = tokio::time::Instant::now() + timeout; loop { let now = tokio::time::Instant::now(); if now >= deadline { - return Err(RwError::Timeout(timeout.as_millis() as u64)); + return ( + Err(RwError::Timeout(timeout.as_millis() as u64)), + event_cursor, + ); + } + let (oldest_seq, entries) = { + let log = event_log.lock().unwrap(); + ( + log.oldest_seq(), + log.entries_since_limited(event_cursor, PAGE_EVENT_LOG_SCAN_CHUNK), + ) + }; + if event_cursor < oldest_seq { + let dropped = oldest_seq - event_cursor; + return ( + Err(RwError::Message(format!( + "CDP event log overflow: dropped {dropped} event(s)" + ))), + event_cursor, + ); + } + for (sequence, event) in entries.iter() { + if *sequence < event_cursor { + continue; + } + event_cursor = sequence.saturating_add(1); + let method = event.get("method").and_then(Value::as_str).unwrap_or(""); + if method == "Target.detachedFromTarget" + && event.pointer("/params/sessionId").and_then(Value::as_str) == Some(session_id) + { + return (Ok(()), event_cursor); + } + if method == "Target.targetDestroyed" + && event.pointer("/params/targetId").and_then(Value::as_str) == Some(target_id) + { + return (Ok(()), event_cursor); + } + } + if entries.len() >= PAGE_EVENT_LOG_SCAN_CHUNK { + continue; } let remaining = deadline - now; match tokio::time::timeout(remaining, events.recv()).await { Ok(Ok(event)) => { - let method = event.get("method").and_then(Value::as_str).unwrap_or(""); - if method == "Target.detachedFromTarget" { - let detached_session = - event.pointer("/params/sessionId").and_then(Value::as_str); - if detached_session == Some(session_id) { - return Ok(()); + if let Some(sequence) = cdp_event_sequence(&event) { + if sequence < event_cursor { + continue; } + event_cursor = sequence.saturating_add(1); } - if method == "Target.targetDestroyed" { - let destroyed_target = - event.pointer("/params/targetId").and_then(Value::as_str); - if destroyed_target == Some(target_id) { - return Ok(()); - } + let method = event.get("method").and_then(Value::as_str).unwrap_or(""); + if method == "Target.detachedFromTarget" + && event.pointer("/params/sessionId").and_then(Value::as_str) + == Some(session_id) + { + return (Ok(()), event_cursor); + } + if method == "Target.targetDestroyed" + && event.pointer("/params/targetId").and_then(Value::as_str) == Some(target_id) + { + return (Ok(()), event_cursor); } } - Ok(Err(broadcast::error::RecvError::Lagged(_))) => continue, - Ok(Err(_)) => return Err(RwError::Message("CDP event stream closed".to_string())), - Err(_) => return Err(RwError::Timeout(timeout.as_millis() as u64)), + Ok(Err(broadcast::error::RecvError::Lagged(_))) => { + let (replacement, oldest_seq) = { + let log = event_log.lock().unwrap(); + (events.resubscribe(), log.oldest_seq()) + }; + *events = replacement; + if event_cursor < oldest_seq { + let dropped = oldest_seq - event_cursor; + return ( + Err(RwError::Message(format!( + "CDP event log overflow: dropped {dropped} event(s)" + ))), + event_cursor, + ); + } + } + Ok(Err(_)) => { + return ( + Err(RwError::Message("CDP event stream closed".to_string())), + event_cursor, + ) + } + Err(_) => { + return ( + Err(RwError::Timeout(timeout.as_millis() as u64)), + event_cursor, + ) + } } } } @@ -52023,6 +55011,13 @@ fn navigation_timeout(deadline: OperationDeadline) -> RwError { RwError::Timeout(duration_millis_u64(deadline.timeout)) } +fn normalize_navigation_timeout(error: RwError, deadline: OperationDeadline) -> RwError { + match error { + RwError::Timeout(_) => navigation_timeout(deadline), + error => error, + } +} + fn navigation_ready_state_satisfies(state: &str, ready_state: &str) -> bool { match state { "domcontentloaded" => matches!(ready_state, "interactive" | "complete"), @@ -57187,3 +60182,105 @@ fn _rustwright(py: Python<'_>, module: &Bound<'_, PyModule>) -> PyResult<()> { module.add("_LOCATOR_FILL_TEMPLATE", LOCATOR_FILL_TEMPLATE)?; Ok(()) } + +#[cfg(test)] +mod event_waiter_matcher_tests { + use super::*; + + #[test] + fn console_matcher_supports_type_and_exact_or_substring_text() { + let event = json!({ + "params": { + "type": "error", + "args": [{"type": "string", "value": "prefix exact suffix"}] + } + }); + assert!(console_event_matches( + &event, + Some("error"), + Some("prefix exact suffix"), + true + )); + assert!(!console_event_matches( + &event, + Some("warning"), + Some("prefix exact suffix"), + true + )); + assert!(console_event_matches( + &event, + Some("error"), + Some("exact"), + false + )); + assert!(!console_event_matches( + &event, + Some("error"), + Some("exact"), + true + )); + } + + #[test] + fn page_error_text_uses_python_truthy_fallbacks() { + let fixtures = [ + ( + json!({ + "description": Value::Null, + "value": "TypeError: value fallback", + }), + "outer fallback", + "value fallback", + ), + ( + json!({ + "description": "", + "value": "", + }), + "ReferenceError: text fallback", + "text fallback", + ), + ]; + for (exception, details_text, expected) in fixtures { + let event = json!({ + "params": { + "exceptionDetails": { + "exception": exception, + "text": details_text, + }, + }, + }); + assert_eq!(page_error_event_text(&event).0, expected); + } + } + + #[test] + fn page_error_matcher_uses_error_name_and_normalized_message() { + let event = json!({ + "params": { + "exceptionDetails": { + "exception": { + "className": "TypeError", + "description": "TypeError: missing value\n at example.js:1" + } + } + } + }); + assert_eq!( + page_error_event_text(&event), + ("missing value".to_string(), "TypeError".to_string()) + ); + assert!(page_error_event_matches( + &event, + Some("TypeError"), + Some("missing"), + false + )); + assert!(!page_error_event_matches( + &event, + Some("Error"), + Some("missing value"), + true + )); + } +} diff --git a/tests/test_benchmark_memory.py b/tests/test_benchmark_memory.py index a2c9e47..0356c07 100644 --- a/tests/test_benchmark_memory.py +++ b/tests/test_benchmark_memory.py @@ -1,9 +1,11 @@ from __future__ import annotations +import argparse +import json import time from benchmarks import process_tree_memory -from tools.run_benchmark_matrix import aggregate_repetitions +from tools.run_benchmark_matrix import aggregate_repetitions, run_impl def test_process_tree_walk_and_rss_sum(monkeypatch) -> None: @@ -16,6 +18,9 @@ def fake_run_ps(args: list[str]) -> str | None: return str(rss_by_pid[int(args[-1])]) monkeypatch.setattr(process_tree_memory, "run_ps", fake_run_ps) + monkeypatch.setattr( + process_tree_memory, "procfs_process_tree_rss", lambda _root_pid: None + ) assert process_tree_memory.ps_process_tree(10) == [10, 12, 11, 13] assert process_tree_memory.sample_process_tree_rss( @@ -26,8 +31,47 @@ def fake_run_ps(args: list[str]) -> str | None: ) +def test_procfs_process_tree_walk_and_rss_sum(tmp_path) -> None: + def write_status(pid: int, parent_pid: int, rss_kb: int | None) -> None: + process_dir = tmp_path / str(pid) + process_dir.mkdir(exist_ok=True) + rss_line = "" if rss_kb is None else f"VmRSS:\t{rss_kb} kB\n" + (process_dir / "status").write_text( + f"Name:\tprocess-{pid}\nPPid:\t{parent_pid}\n{rss_line}", + encoding="utf-8", + ) + + write_status(10, 1, 100) + write_status(11, 10, 200) + write_status(12, 10, 300) + write_status(13, 11, 400) + write_status(99, 1, 500) + + assert process_tree_memory.procfs_process_tree_rss( + 10, tmp_path + ) == process_tree_memory.ProcessTreeRssSample( + rss_self_kb=100, + rss_tree_kb=1000, + ) + + write_status(13, 11, None) + assert process_tree_memory.procfs_process_tree_rss( + 10, tmp_path + ) == process_tree_memory.ProcessTreeRssSample( + rss_self_kb=100, + rss_tree_kb=None, + ) + + unreadable = tmp_path / "77" + unreadable.mkdir() + assert process_tree_memory.procfs_process_tree_rss(10, tmp_path) is None + + def test_unavailable_ps_returns_null_memory_without_crashing(monkeypatch) -> None: monkeypatch.setattr(process_tree_memory, "run_ps", lambda _args: None) + monkeypatch.setattr( + process_tree_memory, "procfs_process_tree_rss", lambda _root_pid: None + ) sample = process_tree_memory.sample_process_tree_rss(10) @@ -35,6 +79,73 @@ def test_unavailable_ps_returns_null_memory_without_crashing(monkeypatch) -> Non assert sample.rss_tree_kb is None +def test_failed_ps_tree_walk_does_not_report_self_as_tree(monkeypatch) -> None: + def fake_run_ps(args: list[str]) -> str | None: + if args == ["ps", "-axo", "pid=,ppid="]: + return None + return "100" + + monkeypatch.setattr(process_tree_memory, "run_ps", fake_run_ps) + monkeypatch.setattr( + process_tree_memory, "procfs_process_tree_rss", lambda _root_pid: None + ) + + assert process_tree_memory.sample_process_tree_rss( + 10 + ) == process_tree_memory.ProcessTreeRssSample( + rss_self_kb=100, + rss_tree_kb=None, + ) + + +def test_matrix_rejects_latency_result_without_memory(monkeypatch) -> None: + completed = argparse.Namespace(returncode=0, stdout="", stderr="") + monkeypatch.setattr( + "tools.run_benchmark_matrix.subprocess.run", + lambda *_args, **_kwargs: completed, + ) + args = argparse.Namespace( + lifecycle="warm-browser", + rebuild_rustwright=False, + skip_rustwright_rebuild=False, + iterations=1, + suite="strict", + case_filters=[], + timeout=10, + ) + + invalid_memory_blocks = [ + {"available": False}, + {"available": True}, + {"available": True, "rss_self_kb": 100, "rss_tree_kb": None}, + {"available": True, "rss_self_kb": 100, "rss_tree_kb": True}, + {"available": True, "rss_self_kb": 100, "rss_tree_kb": -1}, + {"available": True, "rss_self_kb": 500, "rss_tree_kb": 100}, + ] + for memory in invalid_memory_blocks: + completed.stdout = json.dumps( + { + "implementation": "rustwright", + "memory": memory, + } + ) + result = run_impl(args, "rustwright") + + assert result["status"] == "failed" + assert result["failure_kind"] == "memory_unavailable" + completed.stdout = json.dumps( + { + "implementation": "rustwright", + "memory": {"available": True, "rss_self_kb": 100, "rss_tree_kb": 500}, + } + ) + result = run_impl(args, "rustwright") + + assert result["status"] == "passed" + assert result["container_isolation"] == "separate_container" + assert result["memory"]["rss_tree_kb"] == 500 + + def test_decorator_attaches_background_peak_memory(monkeypatch) -> None: samples = iter( [ @@ -61,7 +172,7 @@ def benchmark() -> dict: assert result["memory"]["rss_tree_kb"] == 750 assert result["memory"]["statistic"] == "peak" assert result["memory"]["scope"] == "benchmark_process_and_descendants" - assert result["memory"]["sampling_mode"] == "background_thread_ps" + assert result["memory"]["sampling_mode"] == "background_thread_procfs_or_ps" assert result["memory"]["available"] is True diff --git a/tests/test_cli_mcp_integration.py b/tests/test_cli_mcp_integration.py index 37cb2d1..e3722cd 100644 --- a/tests/test_cli_mcp_integration.py +++ b/tests/test_cli_mcp_integration.py @@ -66,12 +66,6 @@ def test_mcp_cli_real_stdio_initialize_and_tools_list(): for variable in ( "RUSTWRIGHT_MCP_MAX_RESPONSE_BYTES", "RUSTWRIGHT_MCP_MAX_RESPONSE_LINES", - "RUSTWRIGHT_MCP_BUDGET", - "RUSTWRIGHT_MCP_DISTILL", - "RUSTWRIGHT_MCP_HEADER", - "RUSTWRIGHT_MCP_CONSOLE_DEDUP", - "RUSTWRIGHT_MCP_NET_NOTE", - "RUSTWRIGHT_MCP_LEAN_DESCRIPTIONS", ): environment.pop(variable, None) python_path = str(REPOSITORY / "python") diff --git a/tests/test_playwright_compat_optin.py b/tests/test_playwright_compat_optin.py index 9b639d6..d08a35b 100644 --- a/tests/test_playwright_compat_optin.py +++ b/tests/test_playwright_compat_optin.py @@ -93,10 +93,7 @@ def test_rustwright_import_does_not_install_legacy_aliases(): assert report["after_rustwright"] == expected_roots assert report["after_direct_compat"] == expected_roots assert report["direct_compat_identity"] is True - assert report["rustwright_all"] == [ - "disable_playwright_compat", - "enable_playwright_compat", - ] + assert report["rustwright_all"] == ["enable_playwright_compat"] def test_clean_python_without_pytest_enables_core_aliases_only(): @@ -143,7 +140,7 @@ def test_clean_python_without_pytest_enables_core_aliases_only(): assert report["plugin_targets_loaded"] == [] -def test_enable_disable_leave_sys_meta_path_untouched(): +def test_enable_leaves_sys_meta_path_untouched(): report = _run_probe( """ import json @@ -154,21 +151,16 @@ def test_enable_disable_leave_sys_meta_path_untouched(): before = list(sys.meta_path) rustwright.enable_playwright_compat() after_enable = list(sys.meta_path) - rustwright.disable_playwright_compat() - after_disable = list(sys.meta_path) print(json.dumps({ "enable_unchanged": len(before) == len(after_enable) and all( left is right for left, right in zip(before, after_enable) ), - "disable_unchanged": len(before) == len(after_disable) and all( - left is right for left, right in zip(before, after_disable) - ), }, sort_keys=True)) """ ) - assert report == {"disable_unchanged": True, "enable_unchanged": True} + assert report == {"enable_unchanged": True} def test_pytest_playwright_callback_type_export_is_eager_and_exact(): @@ -240,7 +232,7 @@ def use_callback(callback: CreateContextCallback) -> None: assert not any("Any" in line or "builtins.object" in line for line in revealed) -def test_enable_disable_two_cycles_restore_aliases_and_reload_child(): +def test_repeated_enable_is_idempotent_and_preserves_alias_identity(): report = _run_probe( """ import importlib @@ -260,48 +252,33 @@ def test_enable_disable_two_cycles_restore_aliases_and_reload_child(): "patchright.pytest_plugin", "pytest_playwright.pytest_playwright", ] - cycles = [] - for _ in range(2): - result = rustwright.enable_playwright_compat() - child = importlib.import_module("playwright.sync_api") - target = importlib.import_module("rustwright._compat.playwright.sync_api") - reloaded = importlib.reload(child) - cycles.append({ - "all_registered": all(name in sys.modules for name in tracked), - "child_is_target": child is target, - "reload_identity": reloaded is child, - "registered_result": all( - name in result.registered_aliases for name in tracked - ), - "skipped": list(result.skipped_aliases), - }) - rustwright.disable_playwright_compat() - cycles[-1]["all_restored"] = not any( - name in sys.modules for name in tracked - ) - - print(json.dumps({"cycles": cycles}, sort_keys=True)) + first = rustwright.enable_playwright_compat() + child = importlib.import_module("playwright.sync_api") + target = importlib.import_module("rustwright._compat.playwright.sync_api") + second = rustwright.enable_playwright_compat() + reloaded = importlib.reload(child) + + print(json.dumps({ + "all_registered": all(name in sys.modules for name in tracked), + "child_is_target": child is target, + "reload_identity": reloaded is child, + "results_equal": first == second, + "registered_result": all( + name in second.registered_aliases for name in tracked + ), + "skipped": list(second.skipped_aliases), + }, sort_keys=True)) """ ) - assert report["cycles"] == [ - { - "all_registered": True, - "all_restored": True, - "child_is_target": True, - "registered_result": True, - "reload_identity": True, - "skipped": [], - }, - { - "all_registered": True, - "all_restored": True, - "child_is_target": True, - "registered_result": True, - "reload_identity": True, - "skipped": [], - }, - ] + assert report == { + "all_registered": True, + "child_is_target": True, + "registered_result": True, + "reload_identity": True, + "results_equal": True, + "skipped": [], + } def test_enable_import_failure_leaves_aliases_and_state_unchanged(): @@ -336,8 +313,6 @@ def test_enable_import_failure_leaves_aliases_and_state_unchanged(): "enabled": compat._ENABLED, "pytest_enabled": compat._PYTEST_ALIASES_ENABLED, "result": compat._LAST_ENABLE_RESULT, - "modules": dict(compat._PREVIOUS_MODULES), - "attributes": dict(compat._PREVIOUS_PARENT_ATTRIBUTES), } real_import_module = importlib.import_module @@ -372,8 +347,6 @@ def failing_import_module(name, *args, **kwargs): compat._ENABLED is state_snapshot["enabled"] and compat._PYTEST_ALIASES_ENABLED is state_snapshot["pytest_enabled"] and compat._LAST_ENABLE_RESULT is state_snapshot["result"] - and compat._PREVIOUS_MODULES == state_snapshot["modules"] - and compat._PREVIOUS_PARENT_ATTRIBUTES == state_snapshot["attributes"] ), }, sort_keys=True)) """ @@ -510,13 +483,11 @@ def enable(): and results[0] == results[1] and all(name in sys.modules for name in aliases) ) - rustwright.disable_playwright_compat() print(json.dumps({ "all_threads_finished": not any(thread.is_alive() for thread in threads), "enabled_consistently": enabled_consistently, "errors": errors, - "restored": not any(name in sys.modules for name in aliases), }, sort_keys=True)) """ ) @@ -525,11 +496,10 @@ def enable(): "all_threads_finished": True, "enabled_consistently": True, "errors": [], - "restored": True, } -def test_reenable_upgrades_skipped_pytest_aliases_and_restores_baseline(): +def test_reenable_upgrades_skipped_pytest_aliases_without_republishing_core(): report = _run_probe( """ import importlib.util @@ -608,45 +578,15 @@ def conditional_find_spec(name, *args, **kwargs): ), "skipped": list(second.skipped_aliases), } - rustwright.disable_playwright_compat() - after_disable = { - "all_module_identities_restored": all( - sys.modules.get(name, missing) is module - for name, module in module_snapshot.items() - ), - "canonical_playwright_attribute_restored": ( - canonical_playwright.sync_api is canonical_playwright_attribute - ), - "playwright_attribute_restored": ( - foreign_playwright.sync_api is playwright_attribute - ), - "pytest_attribute_restored": ( - foreign_pytest_playwright.pytest_playwright is pytest_attribute - ), - "state_disabled": ( - not compat._ENABLED - and not compat._PYTEST_ALIASES_ENABLED - and not compat._PREVIOUS_MODULES - and not compat._PREVIOUS_PARENT_ATTRIBUTES - ), - } print(json.dumps({ "before_upgrade": before_upgrade, "after_upgrade": after_upgrade, - "after_disable": after_disable, }, sort_keys=True)) """ ) assert report == { - "after_disable": { - "canonical_playwright_attribute_restored": True, - "all_module_identities_restored": True, - "playwright_attribute_restored": True, - "pytest_attribute_restored": True, - "state_disabled": True, - }, "after_upgrade": { "core_root_unchanged": True, "pytest_aliases_registered": True, diff --git a/tests/test_playwright_parity_cases.py b/tests/test_playwright_parity_cases.py index fa76449..675fe46 100644 --- a/tests/test_playwright_parity_cases.py +++ b/tests/test_playwright_parity_cases.py @@ -39,11 +39,15 @@ def _run_parity(implementation: str) -> dict: proc = subprocess.run( [ sys.executable, - str(ROOT / "tools" / "run_parity_cases.py"), + str(ROOT / "benchmarks" / "run_benchmarks.py"), "--impl", implementation, "--reference-path", str(ROOT / ".audit-playwright"), + "--suite", + "parity", + "--iterations", + "1", "--json", ], cwd=ROOT, @@ -66,5 +70,4 @@ def test_core_automation_cases_match_playwright_and_rustwright(implementation): result = _run_parity(implementation) - assert result["total"] == len(CASES) - assert result["passed"] == result["total"] + assert set(result["cases"]) == {case.__name__ for case in CASES} diff --git a/tests/test_project_status_tools.py b/tests/test_repository_tools.py similarity index 65% rename from tests/test_project_status_tools.py rename to tests/test_repository_tools.py index ba9f9c4..dc84ac9 100644 --- a/tests/test_project_status_tools.py +++ b/tests/test_repository_tools.py @@ -8,190 +8,58 @@ import time from pathlib import Path -import pytest ROOT = Path(__file__).resolve().parents[1] -INTERNAL_DOCS = ROOT / "docs" / "internal" -pytestmark = pytest.mark.skipif( - not INTERNAL_DOCS.exists(), - reason="internal status docs are not included in OSS checkouts", -) -PHASE_1_LEDGER_STATUSES = { - "in_progress", - "local_covered_docker_gate_pending", - "docker_verified", -} - - -def load_requirements_status() -> dict: - return json.loads((INTERNAL_DOCS / "REQUIREMENTS_STATUS.json").read_text(encoding="utf-8")) - - -def test_phase_1_coverage_ledger_tracks_every_backlog_row(): - status = load_requirements_status() - backlog = status["execution_phase_plan"]["phase_1"]["coverage_backlog"] - ledger_rows = status["phase_1_coverage_ledger"]["rows"] - - assert status["phase_1_coverage_ledger"]["status"] in PHASE_1_LEDGER_STATUSES - assert [row["coverage_point"] for row in ledger_rows] == backlog - - for row in ledger_rows: - assert row["id"] - assert row["status"] in { - "docker_verified", - "covered_needs_current_docker_gate", - "partially_covered", - "missing_test", - "blocked", - } - assert row["evidence_type"] - assert row["evidence"], row["coverage_point"] - assert row["latest_result"] - assert row["remaining"] - -def test_project_status_tools_can_query_and_render_phase_1_ledger(): - query = subprocess.run( - [ - sys.executable, - str(ROOT / "tools" / "query_project_state.py"), - "--source", - "requirements", - "--path", - "requirements.phase_1_coverage_ledger.status", - ], - check=True, - cwd=ROOT, - text=True, - capture_output=True, - ) - assert json.loads(query.stdout) in PHASE_1_LEDGER_STATUSES - rendered = subprocess.run( +def test_benchmark_runner_rejects_abbreviated_impl_override(): + result = subprocess.run( [ sys.executable, - str(ROOT / "tools" / "render_project_tables.py"), - "--table", - "phase1", + str(ROOT / "benchmarks" / "run_benchmarks.py"), + "--impl", + "rustwright", + "--im=all", ], - check=True, cwd=ROOT, text=True, capture_output=True, ) - assert "## Phase 1 Coverage Ledger" in rendered.stdout - assert "Skyvern import and receiver-method surface" in rendered.stdout - -def test_project_status_summary_and_tables_include_phase_2_acceptance(): - summary = subprocess.run( - [sys.executable, str(ROOT / "tools" / "query_project_state.py"), "--summary"], - check=True, - cwd=ROOT, - text=True, - capture_output=True, - ) - data = json.loads(summary.stdout) - - assert data["phase_2_benchmark_acceptance"]["status"] == "accepted" - assert data["phase_2_benchmark_acceptance"]["accepted"] is True - assert data["phase_2_benchmark_acceptance"]["result_path"].endswith( - "bench-full-strict-warm-browser-20260613T061900Z.json" - ) - failed_check_names = {item["name"] for item in data["phase_2_benchmark_acceptance"]["failed_checks"]} - assert failed_check_names == set() - assert data["phase_2_benchmark_acceptance"]["observed"]["rustwright_reduction_pct"] > 30.0 - assert data["testbox_benchmark_path"]["latest_helper_update"]["status"] == "implemented_local_syntax_verified" - assert "RUSTWRIGHT_TESTBOX_RUN_COMMAND" in data["testbox_benchmark_path"]["latest_helper_update"]["summary"] - assert data["testbox_benchmark_path"]["latest_visibility_preflight"]["static_preflight_status"] == "preflight_ok" - assert data["testbox_benchmark_path"]["latest_visibility_preflight"]["status"] == "blacksmith_repo_visibility_blocked" - assert data["phase_1_current_gate_update"]["status"] == "docker_verified" - assert "local_ready=true" in data["phase_1_current_gate_update"]["local_validation"]["phase1_gate_checker"] - assert data["phase_1_current_gate_update"]["docker_execution"]["mac_mini_auth_url"].startswith( - "https://login.tailscale.com/a/" - ) - assert "reason=tailscale_ssh_web_auth_required" in data["phase_1_current_gate_update"]["local_validation"][ - "remote_wrapper_fast_auth_classification" - ] - assert "Docker Desktop started successfully" in data["phase_1_current_gate_update"]["docker_execution"][ - "local_docker_attempt" - ] - assert data["latest_external_launch_audit"]["status"] == "completed_prompt_mode" - assert data["latest_external_launch_audit"]["verdict"] == "not_ready" - assert data["skyvern_cloud_dependency_audit"]["status"] == "ok" - assert data["skyvern_cloud_dependency_audit"]["alias_symbol_coverage"]["status"] == "ok" - assert data["skyvern_cloud_dependency_audit"]["method_name_coverage"]["status"] == "ok" - assert data["skyvern_cloud_dependency_audit"]["typed_method_coverage"]["status"] == "ok" - assert data["skyvern_cloud_dependency_audit"]["latest_replacement_smoke"]["status"] == "ok" - assert data["skyvern_cloud_dependency_audit"]["latest_replacement_smoke"]["alias_failures"] == 0 - assert "alias_related" in data["skyvern_cloud_dependency_audit"]["latest_replacement_smoke"]["failure_policy"] - assert data["skyvern_cloud_dependency_audit"]["latest_replacement_smoke"]["skyvern_modules_imported"] == 9 - assert data["skyvern_cloud_dependency_audit"]["latest_replacement_smoke"]["skyvern_modules_failed"] == 0 - assert data["skyvern_cloud_dependency_audit"]["latest_replacement_smoke"]["skyvern_module_import_mode"] == "strict" - assert data["skyvern_cloud_dependency_audit"]["latest_overlay_pytest"]["status"] == "passed" - assert data["skyvern_cloud_dependency_audit"]["latest_overlay_pytest"]["alias_preflight_status"] == "ok" - assert data["skyvern_cloud_dependency_audit"]["latest_overlay_pytest"]["tests_passed"] == 40 - assert data["skyvern_cloud_dependency_audit"]["latest_overlay_pytest"]["default_conftest"]["status"] == "passed" - assert data["skyvern_cloud_dependency_audit"]["latest_overlay_pytest"]["default_conftest"]["pytest_summary"] == { - "passed": 40 - } - assert data["skyvern_cloud_dependency_audit"]["latest_overlay_pytest"]["noconftest"]["status"] == "passed" - assert data["skyvern_cloud_dependency_audit"]["latest_overlay_pytest"]["noconftest"]["pytest_summary"] == { - "passed": 40 - } - assert data["skyvern_cloud_dependency_audit"]["latest_alias_command_smoke"]["status"] == "passed" - assert data["skyvern_cloud_dependency_audit"]["latest_alias_command_smoke"]["alias_preflight_status"] == "ok" - assert data["skyvern_cloud_dependency_audit"]["latest_alias_command_smoke"]["command_status"] == "passed" - assert data["skyvern_cloud_dependency_audit"]["latest_alias_command_smoke"]["command_classification"] == "passed" - assert data["skyvern_cloud_dependency_audit"]["latest_alias_command_smoke"]["skyvern_module"] == ( - "skyvern.services.script_reviewer_v3.types" - ) - assert data["skyvern_cloud_dependency_audit"]["latest_alias_command_smoke"]["cloud_browser_import_smoke"][ - "status" - ] == "passed" - assert data["skyvern_cloud_dependency_audit"]["latest_prompt_overlay_smoke"]["status"] == "passed" - assert data["skyvern_cloud_dependency_audit"]["latest_prompt_overlay_smoke"]["alias_preflight_status"] == "ok" - assert data["skyvern_cloud_dependency_audit"]["latest_prompt_overlay_smoke"]["module_count"] == 3 - assert data["skyvern_cloud_dependency_audit"]["latest_prompt_overlay_smoke"]["environment_blocked_modules"] == 0 - assert "rows" not in data["phase_1_coverage_ledger"] - assert data["phase_1_coverage_ledger"]["row_count"] > 0 - assert len(summary.stdout) < 40000 - - full_summary = subprocess.run( - [sys.executable, str(ROOT / "tools" / "query_project_state.py"), "--full-summary"], - check=True, - cwd=ROOT, - text=True, - capture_output=True, - ) - full_data = json.loads(full_summary.stdout) - assert "rows" in full_data["phase_1_coverage_ledger"] - assert len(full_summary.stdout) > len(summary.stdout) - - rendered = subprocess.run( - [ - sys.executable, - str(ROOT / "tools" / "render_project_tables.py"), - "--table", - "benchmarks", - ], - check=True, - cwd=ROOT, - text=True, - capture_output=True, - ) + assert result.returncode == 2 + assert "unrecognized arguments: --im=all" in result.stderr - assert "## Phase 2 Benchmark Acceptance" in rendered.stdout - assert "all_runs_passed" in rendered.stdout - assert "accepted" in rendered.stdout +def test_launch_latency_claim_checker_rejects_smoke_only_blacksmith_run(tmp_path): + benchmark_json = tmp_path / "launch-smoke.json" + write_synthetic_launch_benchmark(benchmark_json) + data = json.loads(benchmark_json.read_text(encoding="utf-8")) + data["repetitions"] = 1 + data["results"] = data["results"][:2] + for implementation in ("rustwright", "playwright"): + data["aggregate"][implementation]["runs"] = 1 + data["aggregate"][implementation]["cases"] = { + "strict_case_0": {"median": 1.0} + } + data["aggregate"]["rustwright"]["total_mean_ms"] = {"median": 300.0} + data["aggregate"]["playwright"]["total_mean_ms"] = {"median": 200.0} + benchmark_json.write_text(json.dumps(data), encoding="utf-8") -def test_launch_latency_claim_checker_rejects_smoke_only_blacksmith_run(): result = subprocess.run( [ sys.executable, str(ROOT / "tools" / "check_launch_latency_claim.py"), + "--benchmark-json", + str(benchmark_json), + "--source", + "github-actions", + "--runner", + "blacksmith-2vcpu-ubuntu-2404", + "--artifact", + "smoke-artifact", + "--run-url", + "https://github.com/Skyvern-AI/rustwright/actions/runs/1", "--json", ], cwd=ROOT, @@ -397,71 +265,6 @@ def test_benchmark_artifact_checker_keeps_launch_rejections_nonfatal_without_enf assert report["checks"][0]["launch"]["status"] == "rejected" -def test_project_status_summary_and_tables_include_launch_latency_claim(): - summary = subprocess.run( - [sys.executable, str(ROOT / "tools" / "query_project_state.py"), "--summary"], - check=True, - cwd=ROOT, - text=True, - capture_output=True, - ) - data = json.loads(summary.stdout) - - assert data["launch_latency_claim"]["status"] == "rejected" - assert data["launch_latency_claim"]["accepted"] is False - assert data["remote_mac_mini_docker_wrapper"]["status"] == "remote_phase1_passed_standard_base" - assert data["remote_mac_mini_docker_wrapper"]["tool"] == "tools/run_remote_docker_test.py" - assert data["remote_mac_mini_docker_wrapper"]["transport"] == "tailscale-ssh" - assert data["remote_mac_mini_docker_wrapper"]["memory_limit_checks"]["7g"].startswith("accepted") - assert data["remote_mac_mini_docker_wrapper"]["remote_pull_check"]["status"] == "ready" - assert ( - data["remote_mac_mini_docker_wrapper"]["remote_pull_check"]["passed_check"] - == "remote_docker_pull:python:3.13-slim-bookworm" - ) - assert data["remote_mac_mini_docker_wrapper"]["latest_remote_phase1"]["status"] == "passed" - assert data["remote_mac_mini_docker_wrapper"]["latest_remote_phase1"]["memory_cap_bytes"] == 7516192768 - assert ( - data["remote_mac_mini_docker_wrapper"]["latest_remote_strict_benchmark"]["result_path"] - == ".benchmark-data/results/bench-full-strict-request-history-fetch-completion-macmini-3x10-20260613.json" - ) - assert data["remote_mac_mini_docker_wrapper"]["latest_remote_strict_benchmark"]["case_count"] == 78 - assert data["remote_mac_mini_docker_wrapper"]["latest_remote_strict_benchmark"]["iterations"] == 10 - assert data["remote_mac_mini_docker_wrapper"]["latest_remote_strict_benchmark"]["runs_passed"] == 6 - assert ( - data["remote_mac_mini_docker_wrapper"]["latest_remote_strict_benchmark"]["acceptance_status"] - == "rejected" - ) - assert ( - data["remote_mac_mini_docker_wrapper"]["latest_remote_strict_benchmark"]["speedup_vs_python_pct"] - < 30.0 - ) - failed_launch_checks = { - check["name"] - for check in data["launch_latency_claim"]["checks"] - if not check["passed"] - } - assert failed_launch_checks == {"testbox_backed"} - assert data["launch_latency_claim"]["observed"]["runner"] == "ubuntu-latest" - assert data["launch_latency_claim"]["observed"]["case_count"] == 78 - assert data["launch_latency_claim"]["observed"]["rustwright_reduction_pct"] > 30.0 - - rendered = subprocess.run( - [ - sys.executable, - str(ROOT / "tools" / "render_project_tables.py"), - "--table", - "launch", - ], - check=True, - cwd=ROOT, - text=True, - capture_output=True, - ) - - assert "## Launch Latency Claim" in rendered.stdout - assert "testbox_backed" in rendered.stdout - assert "rejected" in rendered.stdout - def test_remote_docker_test_dry_run_builds_capped_tailscale_ssh_command(): env = os.environ.copy() @@ -477,7 +280,7 @@ def test_remote_docker_test_dry_run_builds_capped_tailscale_ssh_command(): "--dry-run", "--json", "--", - "phase1", + "sampled", ], cwd=ROOT, text=True, @@ -490,10 +293,10 @@ def test_remote_docker_test_dry_run_builds_capped_tailscale_ssh_command(): assert report["status"] == "dry_run" assert report["host"] == "mac-mini.tailnet.example" assert report["memory_limit"] == "8g" - assert report["docker_args"] == ["phase1"] + assert report["docker_args"] == ["sampled"] assert report["remote_command"] == ( "cd /tmp/rustwright-checkout && " - "TEST_DOCKER_MEMORY_LIMIT=8g tools/docker_test.sh phase1" + "TEST_DOCKER_MEMORY_LIMIT=8g tools/docker_test.sh sampled" ) assert report["ssh_command"][:2] == ["ssh", "mac-mini.tailnet.example"] assert report["remote_command"] in report["ssh_command"][2] @@ -732,105 +535,6 @@ def test_remote_docker_test_check_only_requires_host(): assert report["checks"][0]["passed"] is False -def test_phase_1_gate_checker_reports_expanded_gate_docker_verified(): - local = subprocess.run( - [sys.executable, str(ROOT / "tools" / "check_phase1_gate.py")], - check=True, - cwd=ROOT, - text=True, - capture_output=True, - ) - local_report = json.loads(local.stdout) - - assert local_report["status"] == "complete" - assert local_report["local_ready"] is True - assert local_report["complete"] is True - assert local_report["docker_verified"] is True - assert local_report["recorded_docker_verified"] is True - assert any( - check["name"] == "docker_phase_1_gate_verified" and check["passed"] - for check in local_report["checks"] - ) - checks = {check["name"]: check for check in local_report["checks"]} - assert checks["skyvern_replacement_smoke_clean"]["passed"] is True - assert checks["skyvern_overlay_pytest_alias_clean"]["passed"] is True - assert checks["skyvern_alias_command_smoke_clean"]["passed"] is True - assert checks["skyvern_prompt_overlay_smoke_accounted"]["passed"] is True - - docker_required = subprocess.run( - [sys.executable, str(ROOT / "tools" / "check_phase1_gate.py"), "--require-docker"], - cwd=ROOT, - text=True, - capture_output=True, - ) - docker_report = json.loads(docker_required.stdout) - - assert docker_required.returncode == 0 - assert docker_report["local_ready"] is True - assert docker_report["docker_verified"] is True - assert docker_report["complete"] is True - - -def test_phase_1_docker_gate_is_documented_and_wired(): - docker_test = (ROOT / "tools" / "docker_test.sh").read_text(encoding="utf-8") - docker_verify = (ROOT / "tools" / "docker_verify.sh").read_text(encoding="utf-8") - dockerfile = (ROOT / "Dockerfile").read_text(encoding="utf-8") - testbox_workflow = (ROOT / ".github" / "workflows" / "benchmark-testbox.yml").read_text(encoding="utf-8") - benchmark_workflow = (ROOT / ".github" / "workflows" / "benchmark.yml").read_text(encoding="utf-8") - internal_readme = (INTERNAL_DOCS / "README-internal.md").read_text(encoding="utf-8") - requirements = (INTERNAL_DOCS / "REQUIREMENTS.md").read_text(encoding="utf-8") - progress = json.loads((INTERNAL_DOCS / "PROGRESS.json").read_text(encoding="utf-8")) - - assert "pycompile|focused|parity|sampled|phase1|full|bench|bench-full|mind2web|mind2web-full|webvoyager|webvoyager-full|antibot|antibot-smoke" in docker_test - assert "RUSTWRIGHT_DOCKER_BASE_IMAGE" in docker_test - assert "RUSTWRIGHT_DOCKER_LEGACY" in docker_test - assert "perl -0pe" in docker_test - assert '--build-arg "RUSTWRIGHT_DOCKER_BASE_IMAGE=${RUSTWRIGHT_DOCKER_BASE_IMAGE}"' in docker_test - testbox_helper = (ROOT / "tools" / "run_benchmark_testbox.sh").read_text(encoding="utf-8") - assert 'WORKFLOW="${WORKFLOW#.github/workflows/}"' in testbox_helper - assert "RUSTWRIGHT_TESTBOX_RUN_COMMAND" in testbox_helper - assert 'blacksmith testbox status --id "$testbox_id" --wait' in testbox_helper - assert 'blacksmith_testbox_run "$testbox_id" "$RUN_COMMAND"' in testbox_helper - assert "RUSTWRIGHT_TESTBOX_DOWNLOAD_RESULTS" in testbox_helper - assert "tools/check_phase1_gate.py --current-docker-run --require-docker" in docker_verify - assert "tools/check_native_extension.py" in docker_verify - assert "tools/check_launch_latency_claim.py" in docker_verify - assert "tools/check_testbox_visibility.py" in docker_verify - assert "tools/run_skyvern_replacement_smoke.py" in docker_verify - assert "tools/run_skyvern_cloud_overlay_tests.py" in docker_verify - assert "tools/run_skyvern_alias_command.py" in docker_verify - assert "tools/run_skyvern_prompt_overlay_smoke.py" in docker_verify - assert ".github" in docker_test - assert "tests/test_skyvern_replacement_smoke.py" in docker_verify - assert "tests/test_skyvern_cloud_overlay_tests.py" in docker_verify - assert "tests/test_skyvern_alias_command.py" in docker_verify - assert "tests/test_skyvern_prompt_overlay_smoke.py" in docker_verify - assert "tests/test_project_status_tools.py" in docker_verify - assert "tools/check_benchmark_artifacts.py" in benchmark_workflow - assert "--source github-actions" in benchmark_workflow - assert "--enforce-phase2" in benchmark_workflow - assert "tools/run_remote_docker_test.py" in docker_verify - assert "async_wait_helpers_yield_event_loop or async_navigation_helpers_yield_event_loop" in docker_verify - assert 'drag_and_drop_dispatches_native_pointer_mouse_events_like_playwright"' in docker_verify - assert "tools/run_remote_docker_test.py" in internal_readme - assert "tools/check_testbox_visibility.py --probe-warmup --json" in internal_readme - assert "tools/run_remote_docker_test.py" in requirements - assert "tools/check_testbox_visibility.py --probe-warmup --json" in requirements - assert "tailscale_ssh_web_auth_required" in internal_readme - assert "Tailscale SSH web-auth prompts must be classified" in requirements - benchmark_doc = (ROOT / "BENCHMARK.md").read_text(encoding="utf-8") - assert 'tools/run_benchmark_testbox.sh -- "Go @@ -12903,34 +12906,6 @@ def test_locator_click_dispatches_trusted_mouse_sequence_by_default(page, monkey assert page.evaluate("document.activeElement.id") == "go" -def test_locator_click_unsafe_dom_fastpath_opt_in_restores_dom_click(page, monkeypatch): - monkeypatch.setenv("RUSTWRIGHT_UNSAFE_DOM_FASTPATH", "1") - page.set_content( - """ - - - """ - ) - - page.locator("#go").click(timeout=1_000) - - events = page.evaluate("window.events") - assert [event["type"] for event in events] == ["focus", "click"] - assert events[0]["trusted"] is True - assert events[1]["trusted"] is False - assert page.evaluate("document.body.dataset.clicked") == "yes" - def test_native_actionability_templates_are_core_owned_and_structured_results_keep_shapes(page): from rustwright import _rustwright @@ -13205,20 +13180,6 @@ def core_fill(selector, value): _assert_fill_uses_browser_input_pipeline(page, core_fill) -def test_locator_simple_css_fast_fill_reaches_react_value_tracker_through_browser_input(page, monkeypatch): - from rustwright.sync_api import Locator - - monkeypatch.setenv("RUSTWRIGHT_UNSAFE_DOM_FASTPATH", "1") - - def reject_core_fallback(*args, **kwargs): - raise AssertionError("simple-CSS fill unexpectedly fell through to Locator._fill_apply") - - monkeypatch.setattr(Locator, "_fill_apply", reject_core_fallback) - - def simple_css_fill(selector, value): - page.locator(selector).fill(value, timeout=500) - - _assert_fill_uses_browser_input_pipeline(page, simple_css_fill) def test_locator_fill_routes_deadline_retry_and_result_classification_through_core(): @@ -13374,7 +13335,6 @@ async def run(): def test_locator_fill_rejects_pre_settle_value_as_canceled_unchanged_commit( page, monkeypatch ): - monkeypatch.delenv("RUSTWRIGHT_UNSAFE_DOM_FASTPATH", raising=False) page.set_content( """ @@ -13458,18 +13418,7 @@ def test_locator_fill_accepts_canceled_beforeinput_custom_commit_once( assert page.evaluate("() => window.__audit.handlerCount") == 1 -@pytest.mark.parametrize( - "unsafe_fastpath", - [False, True], - ids=["standard-loop", "fastpath-loop"], -) -def test_locator_fill_strict_observe_ignores_temporary_duplicate( - page, monkeypatch, unsafe_fastpath -): - if unsafe_fastpath: - monkeypatch.setenv("RUSTWRIGHT_UNSAFE_DOM_FASTPATH", "1") - else: - monkeypatch.delenv("RUSTWRIGHT_UNSAFE_DOM_FASTPATH", raising=False) +def test_locator_fill_strict_observe_ignores_temporary_duplicate(page): page.set_content( """ @@ -13508,7 +13457,6 @@ def test_locator_fill_strict_observe_ignores_temporary_duplicate( def test_locator_fill_slow_async_commit_dispatches_once(page, monkeypatch): - monkeypatch.delenv("RUSTWRIGHT_UNSAFE_DOM_FASTPATH", raising=False) page.set_content( """ @@ -14097,7 +14045,6 @@ def evaluate_in_fill_world(expression): def test_locator_fill_records_own_input_when_capture_formatter_expires_guard( page, monkeypatch ): - monkeypatch.delenv("RUSTWRIGHT_UNSAFE_DOM_FASTPATH", raising=False) page.set_content( """ @@ -14155,7 +14102,6 @@ def test_locator_fill_records_own_input_when_capture_formatter_expires_guard( def test_locator_fill_records_beforeinput_that_passivates_active_guard( page, monkeypatch ): - monkeypatch.delenv("RUSTWRIGHT_UNSAFE_DOM_FASTPATH", raising=False) page.set_content( """ @@ -14212,7 +14158,6 @@ def test_locator_fill_records_beforeinput_that_passivates_active_guard( def test_locator_fill_records_window_capture_formatter_after_stop_propagation( page, monkeypatch ): - monkeypatch.delenv("RUSTWRIGHT_UNSAFE_DOM_FASTPATH", raising=False) page.set_content( """ @@ -14245,7 +14190,6 @@ def test_locator_fill_records_window_capture_formatter_after_stop_propagation( def test_locator_fill_pins_dispatching_frame_when_frame_locator_retargets( page, monkeypatch ): - monkeypatch.delenv("RUSTWRIGHT_UNSAFE_DOM_FASTPATH", raising=False) page.set_content( """ @@ -14300,7 +14244,6 @@ def test_locator_fill_pins_dispatching_frame_when_frame_locator_retargets( def test_locator_value_like_fill_retry_keeps_retargeted_frame_pin(page, monkeypatch): - monkeypatch.delenv("RUSTWRIGHT_UNSAFE_DOM_FASTPATH", raising=False) page.set_content( """ @@ -14368,63 +14311,11 @@ def test_locator_fill_timeout_distinguishes_missing_dispatch_confirmation_from_e assert page.locator("#target").input_value() == "old" -@pytest.mark.parametrize("page_commits", [False, True], ids=["no-commit", "moved-commit"]) -def test_locator_fast_fill_pending_dispatch_observes_single_dispatch( - page, monkeypatch, page_commits -): - monkeypatch.setenv("RUSTWRIGHT_UNSAFE_DOM_FASTPATH", "1") - page.set_content( - """ - - - - """ - ) - page.evaluate( - "(pageCommits) => { window.__fillAudit.pageCommits = pageCommits; }", - page_commits, - ) - - if page_commits: - page.locator("#a").fill("blocked", timeout=500) - assert page.locator("#a").input_value() == "moved-blocked" - else: - with pytest.raises(TimeoutError): - page.locator("#a").fill("blocked", timeout=250) - - assert page.evaluate("window.__fillAudit.trustedBeforeInput") == 1 - assert page.evaluate( - """() => Object.getOwnPropertyNames(globalThis).filter( - key => key.startsWith('__rustwright_fill_guard_') - )""" - ) == [] - page.locator("#b").fill("works", timeout=1_000) - assert page.locator("#b").input_value() == "works" def test_locator_fill_timeout_does_not_interfere_with_sequential_fill( page, monkeypatch ): - monkeypatch.delenv("RUSTWRIGHT_UNSAFE_DOM_FASTPATH", raising=False) page.set_content( """ @@ -14445,7 +14336,6 @@ def test_locator_fill_timeout_does_not_interfere_with_sequential_fill( def test_locator_fill_ignores_pre_dispatch_synthetic_input_noise(page, monkeypatch): - monkeypatch.delenv("RUSTWRIGHT_UNSAFE_DOM_FASTPATH", raising=False) page.set_content( """ @@ -15889,7 +15779,6 @@ def test_native_value_fill_input_event_crosses_shadow_boundary(page): def test_native_value_fill_evaluator_retry_dispatches_events_once(page, monkeypatch): - monkeypatch.delenv("RUSTWRIGHT_UNSAFE_DOM_FASTPATH", raising=False) page.set_content( """ @@ -16077,148 +15966,6 @@ def test_locator_single_target_actions_are_strict(page): assert page.evaluate("document.body.dataset.clicked || null") is None -def test_named_role_button_click_fast_path_preserves_click_and_strictness(page, monkeypatch): - monkeypatch.setenv("RUSTWRIGHT_UNSAFE_DOM_FASTPATH", "1") - page.set_content( - """ - - - - """ - ) - - assert page.get_by_role("button", name="Save record")._try_fast_named_button_role_dom_click(timeout=500) is True - assert page.evaluate("document.body.dataset.clicked") == "save" - - with pytest.raises(Error, match="strict mode violation: locator resolved to 2 elements"): - page.get_by_role("button", name="Duplicate action")._try_fast_named_button_role_dom_click(timeout=500) - - assert page.evaluate("document.body.dataset.clicked") == "save" - - -def test_label_control_fast_paths_fill_check_and_preserve_strictness(page, monkeypatch): - monkeypatch.setenv("RUSTWRIGHT_UNSAFE_DOM_FASTPATH", "1") - page.set_content( - """ - - - - - - """ - ) - - assert page.get_by_label("Email")._try_fast_simple_label_fill("ada@example.com", action="fill", timeout=500, force=None) is True - assert page.locator("#email").input_value() == "ada@example.com" - - assert page.get_by_label("ARIA Address")._try_fast_simple_label_fill("aria@example.com", action="fill", timeout=500, force=None) is True - assert page.locator("#aria-email").input_value() == "aria@example.com" - - assert page.get_by_label("Urgent only")._try_fast_simple_label_check(timeout=500) is True - assert page.locator("#urgent").is_checked() - - with pytest.raises(Error, match="strict mode violation: locator resolved to 2 elements"): - page.get_by_label("Duplicate")._try_fast_simple_label_fill("nope", action="fill", timeout=500, force=None) - - assert page.locator("#first-duplicate").input_value() == "" - assert page.locator("#second-duplicate").input_value() == "" - - -def test_placeholder_fill_fast_path_preserves_fill_and_strictness(page, monkeypatch): - monkeypatch.setenv("RUSTWRIGHT_UNSAFE_DOM_FASTPATH", "1") - page.set_content( - """ - - - - - """ - ) - - assert page.get_by_placeholder("Search catalog")._try_fast_simple_placeholder_fill( - "travel", - action="fill", - timeout=500, - force=None, - ) is True - assert page.locator("#query").input_value() == "travel" - - assert page.get_by_placeholder("notes")._try_fast_simple_placeholder_fill( - "leave at desk", - action="fill", - timeout=500, - force=None, - ) is True - assert page.locator("#notes").input_value() == "leave at desk" - - with pytest.raises(Error, match="strict mode violation: locator resolved to 2 elements"): - page.get_by_placeholder("Duplicate")._try_fast_simple_placeholder_fill( - "nope", - action="fill", - timeout=500, - force=None, - ) - - assert page.locator("#first-duplicate").input_value() == "" - assert page.locator("#second-duplicate").input_value() == "" - - -def test_label_select_option_fast_path_preserves_selection_and_strictness(page, monkeypatch): - monkeypatch.setenv("RUSTWRIGHT_UNSAFE_DOM_FASTPATH", "1") - page.set_content( - """ - - - - - """ - ) - - assert page.get_by_label("Category")._try_fast_simple_label_select_option( - values=["travel"], - labels=[], - indexes=[], - timeout=500, - force=None, - ) == ["travel"] - assert page.locator("#category").input_value() == "travel" - - assert page.get_by_label("Priority")._try_fast_simple_label_select_option( - values=[], - labels=["P0", "P2"], - indexes=[], - timeout=500, - force=None, - ) == ["p0", "p2"] - assert page.locator("#priority").evaluate("(select) => Array.from(select.selectedOptions).map(option => option.value)") == [ - "p0", - "p2", - ] - - with pytest.raises(Error, match="strict mode violation: locator resolved to 2 elements"): - page.get_by_label("Duplicate")._try_fast_simple_label_select_option( - values=["one"], - labels=[], - indexes=[], - timeout=500, - force=None, - ) - - assert page.locator("#first-duplicate").input_value() == "one" - assert page.locator("#second-duplicate").input_value() == "one" - def test_locator_explicit_index_and_collection_helpers_allow_multiple_matches(page): page.set_content( @@ -24448,6 +24195,46 @@ def current_console_texts(): page.console_messages(filter="bogus") +def test_failed_navigation_preserves_buffered_console_history_sync_and_async( + playwright, http_server +): + sync_marker = "buffered console survives sync failed goto" + browser = playwright.chromium.launch(headless=True) + try: + page = browser.new_page() + page.set_content("
ready
") + page.evaluate("(marker) => console.log(marker)", sync_marker) + with pytest.raises(TimeoutError, match=r"Page\.goto: Timeout \d+ms exceeded\."): + page.goto(f"{http_server}/slow-timeout", timeout=50) + assert any(message.text == sync_marker for message in page.console_messages(filter="all")) + page.close() + finally: + browser.close() + + async def run_async() -> None: + from playwright.async_api import TimeoutError as AsyncTimeoutError + from playwright.async_api import async_playwright + + async with async_playwright() as async_manager: + async_browser = await async_manager.chromium.launch(headless=True) + try: + async_page = await async_browser.new_page() + await async_page.set_content("
ready
") + async_marker = "buffered console survives async failed goto" + await async_page.evaluate("(marker) => console.log(marker)", async_marker) + with pytest.raises( + AsyncTimeoutError, match=r"Page\.goto: Timeout \d+ms exceeded\." + ): + await async_page.goto(f"{http_server}/slow-timeout", timeout=50) + messages = await async_page.console_messages(filter="all") + assert any(message.text == async_marker for message in messages) + await async_page.close() + finally: + await async_browser.close() + + asyncio.run(run_async()) + + def test_pageerror_event_and_page_errors_storage(page): seen = [] @@ -33358,9 +33145,9 @@ async def run() -> None: original_boundary = page._sync._mark_navigation_history_boundary original_download_waiter = page._sync._download_event_waiter - def slow_boundary(): + def slow_boundary(before=None): time.sleep(0.2) - return original_boundary() + return original_boundary(before) def slow_download_waiter(): time.sleep(0.2) @@ -41218,7 +41005,6 @@ async def run() -> None: def test_async_page_click_default_waits_for_receives_events(monkeypatch): - monkeypatch.delenv("RUSTWRIGHT_UNSAFE_DOM_FASTPATH", raising=False) async def run() -> None: from playwright.async_api import TimeoutError, async_playwright @@ -41246,7 +41032,6 @@ async def run() -> None: def test_async_page_fill_default_rejects_readonly_input(monkeypatch): - monkeypatch.delenv("RUSTWRIGHT_UNSAFE_DOM_FASTPATH", raising=False) async def run() -> None: from playwright.async_api import TimeoutError, async_playwright @@ -41270,42 +41055,10 @@ async def run() -> None: asyncio.run(run()) -def test_async_page_click_and_fill_unsafe_dom_fastpath_stays_native(monkeypatch): - import rustwright.async_api as async_api - - monkeypatch.setenv("RUSTWRIGHT_UNSAFE_DOM_FASTPATH", "1") - - async def reject_sync_dispatch(*args, **kwargs): - raise AssertionError("unsafe optionless page click/fill should stay on the native path") - - monkeypatch.setattr(async_api, "_run_sync_wait_sliced", reject_sync_dispatch) - - async def run() -> None: - from playwright.async_api import async_playwright - - async with async_playwright() as p: - browser = await p.chromium.launch(headless=True) - page = await browser.new_page() - await page.set_content( - """ - - - """ - ) - - await page.click("#target", timeout=1_000) - await page.fill("#x", "new", timeout=1_000) - assert await page.evaluate("document.body.dataset.clicked") == "false" - assert await page.locator("#x").input_value() == "new" - await browser.close() - - asyncio.run(run()) - def test_async_page_click_default_dispatches_native_trusted_sequence(monkeypatch): import rustwright.async_api as async_api - monkeypatch.delenv("RUSTWRIGHT_UNSAFE_DOM_FASTPATH", raising=False) async def reject_sync_dispatch(*args, **kwargs): raise AssertionError("optionless page click should use native actionability") @@ -41363,7 +41116,6 @@ async def run() -> None: def test_async_page_fill_default_commits_natively_without_synthetic_change(monkeypatch): import rustwright.async_api as async_api - monkeypatch.delenv("RUSTWRIGHT_UNSAFE_DOM_FASTPATH", raising=False) async def reject_sync_dispatch(*args, **kwargs): raise AssertionError("optionless page fill should use native actionability") @@ -41413,7 +41165,6 @@ async def run() -> None: def test_async_page_actionable_error_messages_match_sync(monkeypatch): - monkeypatch.delenv("RUSTWRIGHT_UNSAFE_DOM_FASTPATH", raising=False) async def run() -> None: from playwright.async_api import Error, TimeoutError, async_playwright @@ -41472,7 +41223,7 @@ async def run() -> None: asyncio.run(run()) -def test_async_page_click_and_fill_native_eligibility_matches_sync_fastpath(monkeypatch): +def test_async_page_click_and_fill_native_eligibility_matches_sync(monkeypatch): import rustwright.async_api as async_api class FakeCore: @@ -41506,17 +41257,6 @@ async def run(): def locator_fill_actionable(self, *args): self.calls.append(("locator_fill_actionable", args)) - def click_async(self, *args): - async def run(): - self.calls.append(("click", args)) - - return run() - - def fill_async(self, *args): - async def run(): - self.calls.append(("fill", args)) - - return run() class FakeSyncPage: def __init__(self): @@ -41540,6 +41280,9 @@ def fill(self, *args, **kwargs): def _slow_mo(self): return None + def _begin_dialog_operation(self): + return lambda: None + class FakeLocator: _spec = {"kind": "css", "selector": "#target"} _index = 0 @@ -41558,7 +41301,6 @@ async def record_sync_dispatch(_owner, func, *args, **kwargs): monkeypatch.setattr(async_api, "_run_sync_wait_sliced", record_sync_dispatch) async def run() -> None: - monkeypatch.delenv("RUSTWRIGHT_UNSAFE_DOM_FASTPATH", raising=False) await page.click("#target") await page.fill("#target", "value") assert [call[0] for call in sync_page._core.calls] == [ @@ -41577,19 +41319,7 @@ async def run() -> None: assert (sync_page.mouse._x, sync_page.mouse._y) == (12.0, 34.0) assert sync_page.mouse._buttons == 2 - monkeypatch.setenv("RUSTWRIGHT_UNSAFE_DOM_FASTPATH", "1") - await page.click("#target") - await page.fill("#target", "value") - assert [call[0] for call in sync_page._core.calls] == [ - "click_actionable_wait", - "dispatch_mouse_click", - "fill_actionable", - "click", - "fill", - ] - assert sync_calls == [] - monkeypatch.delenv("RUSTWRIGHT_UNSAFE_DOM_FASTPATH", raising=False) await page.click("#target", force=False) await page.fill("#target", "value", force=False) assert [call[0] for call in sync_calls] == ["click"] @@ -41608,17 +41338,10 @@ async def run() -> None: "click_actionable_wait", "dispatch_mouse_click", "fill_actionable", - "click", - "fill", "locator_fill_actionable", "locator_fill_actionable", ] - monkeypatch.setenv("RUSTWRIGHT_UNSAFE_DOM_FASTPATH", "1") - sync_calls.clear() - await page.fill("#target", "value") - assert [call[0] for call in sync_calls] == ["fill"] - assert sync_page._core.calls[-1][0] == "locator_fill_actionable" asyncio.run(run()) @@ -41667,7 +41390,6 @@ class FakeLocator: monkeypatch.setattr(async_api, "_rustwright", fake_rustwright) monkeypatch.setattr(async_api, "_native_page_hot_path_supported", lambda _page: False) monkeypatch.setattr(async_api, "_native_selector_locator", lambda *args, **kwargs: FakeLocator()) - monkeypatch.delenv("RUSTWRIGHT_UNSAFE_DOM_FASTPATH", raising=False) async def run() -> None: fill = asyncio.create_task(page.fill("#target", "value")) @@ -41741,7 +41463,6 @@ class FakeLocator: page._sync = sync_page monkeypatch.setattr(async_api, "_native_page_hot_path_supported", lambda _page: True) monkeypatch.setattr(async_api, "_native_selector_locator", lambda *args, **kwargs: FakeLocator()) - monkeypatch.delenv("RUSTWRIGHT_UNSAFE_DOM_FASTPATH", raising=False) async def run() -> None: with pytest.raises(AsyncTimeoutError, match="Page.click: timed out after 5 ms"): @@ -47745,3 +47466,1288 @@ def test_native_launch_parser_preserves_value_error(): with pytest.raises(ValueError): _rustwright.launch_chromium("{") + + +def test_page_console_expectation_captures_worker_created_after_entry(page): + try: + with page.expect_console_message("worker created during expectation", timeout=3_000) as message_info: + page.evaluate( + """() => { + const source = ` + console.log('worker created during expectation'); + setInterval(() => {}, 1000); + `; + const url = URL.createObjectURL(new Blob([source], { type: 'text/javascript' })); + window.__workerCreatedDuringExpectation = new Worker(url); + }""" + ) + + message = message_info.value + assert message.text == "worker created during expectation" + finally: + page.evaluate("() => window.__workerCreatedDuringExpectation?.terminate()") + + +def test_worker_handler_can_enter_page_console_expectation(page): + completed = threading.Event() + result: dict[str, Any] = {} + + def on_worker(worker): + try: + with page.expect_console_message("reentrant worker handler", timeout=3_000) as message_info: + page.evaluate("() => console.log('reentrant worker handler')") + result["message"] = message_info.value + result["worker"] = worker + except BaseException as exc: + result["error"] = exc + finally: + completed.set() + + page.on("worker", on_worker) + try: + page.evaluate( + """() => { + const source = `setInterval(() => {}, 1000);`; + const url = URL.createObjectURL(new Blob([source], { type: 'text/javascript' })); + window.__reentrantWorker = new Worker(url); + }""" + ) + assert completed.wait(3), "worker handler did not complete" + assert result.get("error") is None, result.get("error") + assert result["message"].text == "reentrant worker handler" + finally: + page.remove_listener("worker", on_worker) + page.evaluate("() => window.__reentrantWorker?.terminate()") + + +def test_first_pageerror_wait_honors_short_timeout_on_fresh_page(page): + started = time.monotonic() + with pytest.raises(TimeoutError) as error_info: + page.wait_for_event("pageerror", timeout=50) + + elapsed = time.monotonic() - started + assert elapsed < 1.0, f"short pageerror timeout exceeded budget: {error_info.value}" + assert "50" in str(error_info.value) + + started = time.monotonic() + with pytest.raises(TimeoutError) as generic_error_info: + with page.expect_event("console", timeout=50): + pass + elapsed = time.monotonic() - started + assert elapsed < 1.0, f"short generic console timeout exceeded budget: {generic_error_info.value}" + assert "50" in str(generic_error_info.value) + + +def test_page_console_listener_removes_worker_forward_and_thread(page): + worker = None + + def on_console(_message): + pass + + page.on("console", on_console) + try: + with page.expect_worker() as worker_info: + page.evaluate( + """() => { + const source = `setInterval(() => {}, 1000);`; + const url = URL.createObjectURL(new Blob([source], { type: 'text/javascript' })); + window.__cleanupWorker = new Worker(url); + }""" + ) + worker = worker_info.value + + forwards = wait_until(lambda: list(page._worker_console_forwards.values())) + forward_worker = forwards[0][0] + assert forward_worker._target_id == worker._target_id + + def live_console_thread(): + with forward_worker._event_thread_lock: + thread = forward_worker._event_threads.get("console") + return thread if thread is not None and thread.is_alive() else None + + assert wait_until(live_console_thread).is_alive() + page.remove_listener("console", on_console) + assert page._worker_console_forwards == {} + + def no_live_worker_event_threads(): + with forward_worker._event_thread_lock: + return not any(thread.is_alive() for thread in forward_worker._event_threads.values()) + + assert wait_until(no_live_worker_event_threads) is True + finally: + page.remove_listener("console", on_console) + page.evaluate("() => window.__cleanupWorker?.terminate()") + + +def test_page_listener_removal_cancels_pending_snapshot_invocation(): + from rustwright.sync_api import Page + + class Core: + def combined_event_stream(self): + return object() + + def keyboard_primary_modifier(self): + return "Control" + + page = Page(Core(), _start_event_pump=False) + page._ensure_console_thread = lambda: None + page._attach_existing_worker_console_propagation = lambda: None + page._ensure_worker_thread = lambda: None + + seen: list[object] = [] + + def listener(event): + seen.append(event) + + page.on("console", listener) + snapshot_ready = threading.Event() + allow_snapshot_return = threading.Event() + original_snapshot = page._snapshot_event_handlers + + def blocked_snapshot(event, **kwargs): + handlers = original_snapshot(event, **kwargs) + snapshot_ready.set() + assert allow_snapshot_return.wait(2) + return handlers + + page._snapshot_event_handlers = blocked_snapshot + dispatch_thread = threading.Thread( + target=page._dispatch_console_event, + args=(object(),), + daemon=True, + ) + dispatch_thread.start() + assert snapshot_ready.wait(2) + + remove_returned = threading.Event() + + def remove(): + page.remove_listener("console", listener) + remove_returned.set() + + remove_thread = threading.Thread(target=remove, daemon=True) + remove_thread.start() + assert remove_returned.wait(1), "pending listener removal did not return" + allow_snapshot_return.set() + dispatch_thread.join(timeout=2) + remove_thread.join(timeout=2) + + assert not dispatch_thread.is_alive() + assert not remove_thread.is_alive() + assert seen == [] + + +def test_page_listener_removal_skips_later_same_dispatch_handler(): + from rustwright.sync_api import Page + + class Core: + def combined_event_stream(self): + return object() + + def keyboard_primary_modifier(self): + return "Control" + + page = Page(Core(), _start_event_pump=False) + page._ensure_console_thread = lambda: None + page._attach_existing_worker_console_propagation = lambda: None + page._ensure_worker_thread = lambda: None + seen: list[str] = [] + + def first(_event): + seen.append("first") + page.remove_listener("console", second) + + def second(_event): + seen.append("second") + + page.on("console", first) + page.on("console", second) + page._dispatch_event_handlers("console", object()) + assert seen == ["first"] + + +def test_page_listener_concurrent_self_removal_does_not_deadlock(): + from rustwright.sync_api import Page + + class Core: + def combined_event_stream(self): + return object() + + def keyboard_primary_modifier(self): + return "Control" + + page = Page(Core(), _start_event_pump=False) + page._ensure_console_thread = lambda: None + page._attach_existing_worker_console_propagation = lambda: None + page._ensure_worker_thread = lambda: None + entered = threading.Barrier(2) + completed: list[int] = [] + + def listener(_event): + entered.wait(2) + page.remove_listener("console", listener) + completed.append(1) + + page.on("console", listener) + threads = [ + threading.Thread( + target=page._dispatch_event_handlers, + args=("console", object()), + daemon=True, + ) + for _ in range(2) + ] + for thread in threads: + thread.start() + for thread in threads: + thread.join(timeout=2) + assert all(not thread.is_alive() for thread in threads) + assert len(completed) == 2 + + +def test_page_console_listener_ignores_event_log_history_before_registration(): + from rustwright.sync_api import Page + + class EventStream: + def __init__(self): + self._calls = 0 + + def wait_batch(self, _timeout_ms, _max_events): + self._calls += 1 + if self._calls == 1: + return json.dumps( + [ + { + "seq": 4, + "kind": "console", + "payload": {"type": "log", "text": "stale history"}, + } + ] + ) + return json.dumps([{"seq": 5, "kind": "_closed", "payload": None}]) + + class Core: + def __init__(self): + self.event_stream = EventStream() + + def combined_event_stream(self): + return self.event_stream + + def event_cursor(self): + return 5 + + def keyboard_primary_modifier(self): + return "Control" + + page = Page(Core(), _start_event_pump=False) + page._ensure_console_thread = lambda: None + page._attach_existing_worker_console_propagation = lambda: None + page._ensure_worker_thread = lambda: None + seen: list[str] = [] + page.on("console", lambda message: seen.append(message.text)) + + pump = threading.Thread(target=page._event_pump, daemon=True) + pump.start() + pump.join(timeout=2) + + assert not pump.is_alive() + assert seen == [] + + +def test_page_listener_added_during_worker_forward_detach_keeps_forward(): + from rustwright.sync_api import Page + + class Core: + def combined_event_stream(self): + return object() + + def keyboard_primary_modifier(self): + return "Control" + + class WorkerStub: + _target_id = "worker-target" + url = "blob://worker" + + def __init__(self): + self.listener = None + + def on(self, event, callback): + assert event == "console" + assert self.listener is None + self.listener = callback + + def remove_listener(self, event, callback): + assert event == "console" + if self.listener is callback: + self.listener = None + + page = Page(Core(), _start_event_pump=False) + worker = WorkerStub() + page._workers[worker._target_id] = worker + + first_listener = lambda _message: None + second_messages = [] + + def second_listener(message): + second_messages.append(message.text) + + page._event_handlers["console"] = [first_listener] + page._once_event_wrappers = [("console", first_listener, first_listener)] + page._attach_worker_console_propagation(worker) + + detach_started = threading.Event() + allow_detach = threading.Event() + original_detach = page._detach_worker_console_propagation + + def controlled_detach(): + detach_started.set() + assert allow_detach.wait(2) + original_detach() + + page._detach_worker_console_propagation = controlled_detach + page._ensure_console_thread = lambda: None + page._attach_existing_worker_console_propagation = lambda: None + page._ensure_worker_thread = lambda: None + + snapshot_errors = [] + + def snapshot(): + try: + page._snapshot_event_handlers("console") + except BaseException as exc: + snapshot_errors.append(exc) + + snapshot_thread = threading.Thread(target=snapshot, daemon=True) + snapshot_thread.start() + assert detach_started.wait(2) + + listener_thread = threading.Thread( + target=page.on, + args=("console", second_listener), + daemon=True, + ) + listener_thread.start() + listener_thread.join(timeout=2) + assert not listener_thread.is_alive() + + allow_detach.set() + snapshot_thread.join(timeout=2) + assert not snapshot_thread.is_alive() + assert snapshot_errors == [] + assert worker.listener is not None + + class Message: + type = "log" + text = "worker survives listener race" + timestamp = 1.0 + location = None + + message = Message() + message.worker = worker + worker.listener(message) + assert second_messages == ["worker survives listener race"] + + +def test_worker_forward_install_failure_removes_callback_and_mapping(): + from rustwright.sync_api import Page + + class Core: + def combined_event_stream(self): + return object() + + def keyboard_primary_modifier(self): + return "Control" + + class WorkerStub: + _target_id = "worker-target" + url = "blob://worker" + + def __init__(self): + self.listener = None + + def on(self, event, callback): + assert event == "console" + self.listener = callback + raise RuntimeError("worker listener install failed") + + def remove_listener(self, event, callback): + assert event == "console" + if self.listener is callback: + self.listener = None + + page = Page(Core(), _start_event_pump=False) + page._event_handlers["console"] = [lambda _message: None] + worker = WorkerStub() + + with pytest.raises(RuntimeError, match="worker listener install failed"): + page._attach_worker_console_propagation(worker) + + assert worker.listener is None + assert page._worker_console_forwards == {} + + +def test_async_listener_removal_cancels_queued_loop_callback(): + from rustwright.async_api import _wrap_async_event_handler + from rustwright.sync_api import Page + + class Core: + def combined_event_stream(self): + return object() + + def keyboard_primary_modifier(self): + return "Control" + + async def run() -> None: + loop = asyncio.get_running_loop() + page = Page(Core(), _start_event_pump=False) + owner = type("AsyncOwner", (), {})() + owner._sync = page + owner._loop = loop + owner._event_handler_wrappers = [] + page._ensure_console_thread = lambda: None + page._attach_existing_worker_console_propagation = lambda: None + page._ensure_worker_thread = lambda: None + seen: list[str] = [] + + async def listener(_event): + seen.append("called") + + wrapped = _wrap_async_event_handler(owner, "console", listener) + page.on("console", wrapped) + dispatch_thread = threading.Thread( + target=page._dispatch_event_handlers, + args=("console", object()), + kwargs={"event_sequence": 1}, + daemon=True, + ) + dispatch_thread.start() + dispatch_thread.join(timeout=2) + assert not dispatch_thread.is_alive() + page.remove_listener("console", wrapped) + await asyncio.sleep(0) + await asyncio.sleep(0) + assert seen == [] + + asyncio.run(run()) + + +def test_async_coroutine_registration_keeps_dispatch_horizon(): + from rustwright.async_api import _wrap_async_event_handler + from rustwright.sync_api import Page + + class Core: + def combined_event_stream(self): + return object() + + def keyboard_primary_modifier(self): + return "Control" + + + async def run() -> None: + loop = asyncio.get_running_loop() + page = Page(Core(), _start_event_pump=False) + owner = type("AsyncOwner", (), {})() + owner._sync = page + owner._loop = loop + owner._event_handler_wrappers = [] + page._ensure_console_thread = lambda: None + page._attach_existing_worker_console_propagation = lambda: None + page._ensure_worker_thread = lambda: None + cursors: list[int | None] = [] + seen: list[str] = [] + + async def second(_event): + seen.append("second") + + async def first(_event): + await asyncio.sleep(0) + cursors.append(page._listener_registration_cursor("console")) + page.on("console", _wrap_async_event_handler(owner, "console", second)) + + page.on("console", _wrap_async_event_handler(owner, "console", first)) + page._dispatch_event_handlers("console", object(), event_sequence=10) + for _ in range(5): + await asyncio.sleep(0) + if cursors: + break + assert cursors == [11] + page._dispatch_event_handlers("console", object(), event_sequence=11) + for _ in range(5): + await asyncio.sleep(0) + if seen: + break + assert seen == ["second"] + + asyncio.run(run()) + + +def test_worker_close_snapshot_runs_after_first_handler_raises(): + from rustwright.sync_api import Worker, _emit_worker_value + + worker = Worker("blob://worker") + seen: list[str] = [] + + def first(_worker): + seen.append("first") + raise RuntimeError("first close handler failed") + + def second(_worker): + seen.append("second") + + worker._event_handlers = {"close": [first, second]} + with pytest.raises(RuntimeError, match="first close handler failed"): + _emit_worker_value(worker, "close", worker) + assert seen == ["first", "second"] + assert worker._closed + +def test_async_event_string_and_dict_matchers_for_named_and_generic_apis(): + async def run() -> None: + from playwright.async_api import async_playwright + + async with async_playwright() as p: + browser = await p.chromium.launch(headless=True) + try: + page = await browser.new_page() + + async with page.expect_console_message("named async string") as named_string_info: + await page.evaluate("() => console.log('named async string')") + assert named_string_info.value.text == "named async string" + + async with page.expect_console_message( + {"text": "named async dictionary", "contains": True} + ) as named_dict_info: + await page.evaluate("() => console.log('named async dictionary payload')") + assert named_dict_info.value.text == "named async dictionary payload" + + async with page.expect_event("console", "generic async string") as generic_string_info: + await page.evaluate("() => console.log('generic async string')") + assert generic_string_info.value.text == "generic async string" + + async with page.expect_event( + "console", + {"text": "generic async dictionary", "contains": True}, + ) as generic_dict_info: + await page.evaluate("() => console.log('generic async dictionary payload')") + assert generic_dict_info.value.text == "generic async dictionary payload" + + await page.evaluate( + "() => setTimeout(() => console.log('generic async wait string'), 500)" + ) + waited_string = await page.wait_for_event( + "console", "generic async wait string", timeout=3_000 + ) + assert waited_string.text == "generic async wait string" + + await page.evaluate( + "() => setTimeout(() => console.log('generic async wait dictionary payload'), 500)" + ) + waited_dict = await page.wait_for_event( + "console", + {"text": "generic async wait dictionary", "contains": True}, + timeout=3_000, + ) + assert waited_dict.text == "generic async wait dictionary payload" + finally: + await browser.close() + + asyncio.run(run()) + + +def test_page_console_expectation_captures_cross_origin_oopif_sync(page, oopif_test_server): + page.goto(f"{oopif_test_server['top']}/oopif-top") + child = next(frame for frame in page.frames if frame.url.endswith("/oopif-child")) + marker = "sync oopif console" + + with page.expect_console_message(marker, timeout=3_000) as message_info: + child.evaluate("(marker) => console.log(marker)", marker) + + message = message_info.value + assert message.text == marker + assert message.page is page + assert message.worker is None + + +def test_page_console_expectation_captures_cross_origin_oopif_async( + oopif_test_server, +): + async def run() -> None: + from playwright.async_api import async_playwright + + async with async_playwright() as playwright: + browser = await playwright.chromium.launch(headless=True) + try: + page = await browser.new_page() + await page.goto(f"{oopif_test_server['top']}/oopif-top") + child = next(frame for frame in page.frames if frame.url.endswith("/oopif-child")) + marker = "async oopif console" + + async with page.expect_console_message(marker, timeout=3_000) as message_info: + await child.evaluate("(marker) => console.log(marker)", marker) + + message = message_info.value + assert message.text == marker + assert message.page is page + assert message.worker is None + finally: + await browser.close() + + asyncio.run(run()) + + +def test_page_console_expectation_enables_preexisting_worker(page): + with page.expect_worker() as worker_info: + page.evaluate( + """() => { + const source = `setInterval(() => {}, 1000);`; + const url = URL.createObjectURL(new Blob([source], { type: 'text/javascript' })); + window.__preexistingConsoleWorker = new Worker(url); + }""" + ) + worker = worker_info.value + marker = "preexisting worker console" + try: + with page.expect_console_message(marker, timeout=3_000) as message_info: + worker.evaluate("(marker) => console.log(marker)", marker) + assert message_info.value.text == marker + assert message_info.value.worker is worker + finally: + page.evaluate("() => window.__preexistingConsoleWorker?.terminate()") + + +def test_overlapping_page_console_expectations_keep_session_boundaries(page): + barrier = threading.Barrier(2) + results: dict[str, str] = {} + errors: list[BaseException] = [] + + def wait_for_marker(marker: str) -> None: + try: + with page.expect_console_message(marker, timeout=3_000) as message_info: + barrier.wait(timeout=2) + page.evaluate("(marker) => console.log(marker)", marker) + results[marker] = message_info.value.text + except BaseException as exc: + errors.append(exc) + + threads = [ + threading.Thread(target=wait_for_marker, args=(marker,), daemon=True) + for marker in ("overlap console one", "overlap console two") + ] + for thread in threads: + thread.start() + for thread in threads: + thread.join(timeout=5) + + assert not errors, errors + assert all(not thread.is_alive() for thread in threads) + assert results == { + "overlap console one": "overlap console one", + "overlap console two": "overlap console two", + } + + +@pytest.mark.parametrize( + ("matcher", "message_part"), + [ + ({"text": 123}, "predicate.text"), + ({"event_type": 123}, "predicate.event_type"), + ({"contains": "yes", "text": "x"}, "predicate.contains"), + ({"exact": 0, "text": "x"}, "predicate.exact"), + ({"unknown": "x", "text": "x"}, "unexpected key"), + ({"contains": True}, "expected text or event_type"), + ], +) +def test_console_dictionary_matchers_validate_before_waiter_creation( + page, matcher, message_part +): + with pytest.raises(Error, match=re.escape(message_part)): + page.wait_for_event("console", matcher, timeout=1) + with pytest.raises(Error, match=re.escape(message_part)): + page.expect_console_message(matcher) + + +def test_page_error_message_preserves_typeerror_without_suffix(): + from rustwright.sync_api import _page_error_message_from_description + + assert _page_error_message_from_description("TypeError:") == "TypeError:" + + +def test_async_console_dictionary_matchers_validate_before_waiter_creation(): + async def run() -> None: + from playwright.async_api import Error as AsyncError + from playwright.async_api import async_playwright + + async with async_playwright() as playwright: + browser = await playwright.chromium.launch(headless=True) + try: + page = await browser.new_page() + matcher = {"text": 123} + with pytest.raises(AsyncError, match="predicate.text"): + await page.wait_for_event("console", matcher, timeout=1) + with pytest.raises(AsyncError, match="predicate.text"): + page.expect_console_message(matcher) + finally: + await browser.close() + + asyncio.run(run()) + + +def test_page_console_listener_added_during_batch_uses_next_dispatch_sequence(): + from rustwright.sync_api import Page + + class EventStream: + def __init__(self): + self.calls = 0 + + def wait_batch(self, _timeout_ms, _max_events): + self.calls += 1 + if self.calls == 1: + return json.dumps( + [ + { + "seq": 10, + "kind": "console", + "payload": {"type": "log", "text": "first"}, + }, + { + "seq": 11, + "kind": "console", + "payload": {"type": "log", "text": "second"}, + }, + ] + ) + return json.dumps([{"seq": 12, "kind": "_closed", "payload": None}]) + + class Core: + def __init__(self): + self.event_stream = EventStream() + + def combined_event_stream(self): + return self.event_stream + + def event_cursor(self): + return 10 + + def keyboard_primary_modifier(self): + return "Control" + + page = Page(Core(), _start_event_pump=False) + page._ensure_console_thread = lambda: None + page._ensure_worker_thread = lambda: None + page._attach_existing_worker_console_propagation = lambda: None + seen: list[tuple[str, str]] = [] + + def first(message): + seen.append(("first", message.text)) + if message.text == "first": + page.on("console", lambda second: seen.append(("second", second.text))) + + page.on("console", first) + pump = threading.Thread(target=page._event_pump, daemon=True) + pump.start() + pump.join(timeout=2) + + assert not pump.is_alive() + assert seen == [("first", "first"), ("first", "second"), ("second", "second")] + + +def test_async_event_pump_preserves_envelope_sequence_for_sync_dispatch(): + from rustwright.async_api import AsyncPage + + class EventStream: + def __init__(self): + self.acknowledged = False + + def ack_batch(self): + self.acknowledged = True + + def rollback_batch(self): + raise AssertionError("batch unexpectedly rolled back") + + class SyncPage: + def __init__(self): + self._event_stream = EventStream() + self.calls: list[dict[str, Any]] = [] + + def _event_listeners_active(self): + return True + + def _reconcile_event_stream_overflow(self, _payload): + pass + + def _handle_observation_event(self, *_args, **kwargs): + self.calls.append(kwargs) + + async def run() -> None: + sync_page = SyncPage() + async_page = object.__new__(AsyncPage) + async_page._sync = sync_page + closed = await async_page._consume_event_batch( + [ + {"seq": 7, "kind": "console", "payload": {}}, + {"seq": True, "kind": "console", "payload": {}}, + ] + ) + assert closed is False + assert sync_page._event_stream.acknowledged + assert sync_page.calls == [{"event_sequence": 7}, {"event_sequence": None}] + + asyncio.run(run()) + + +def test_page_persistent_console_listener_captures_cross_origin_oopif(page, oopif_test_server): + messages: list[Any] = [] + page.on("console", messages.append) + page.goto(f"{oopif_test_server['top']}/oopif-top") + child = next(frame for frame in page.frames if frame.url.endswith("/oopif-child")) + marker = "persistent oopif console" + + child.evaluate("(marker) => console.log(marker)", marker) + + message = wait_until( + lambda: next((message for message in messages if message.text == marker), None) + ) + assert message.page is page + assert message.worker is None + + +def test_page_worker_listener_captures_worker_created_in_cross_origin_oopif( + page, oopif_test_server +): + page.goto(f"{oopif_test_server['top']}/oopif-top") + child = next(frame for frame in page.frames if frame.url.endswith("/oopif-child")) + worker_seen: list[Any] = [] + console_seen: list[Any] = [] + page.on("worker", worker_seen.append) + page.on("console", console_seen.append) + marker = "oopif worker console" + + child.evaluate( + """(marker) => { + const source = `console.log(${JSON.stringify(marker)}); setInterval(() => {}, 1000);`; + const url = URL.createObjectURL(new Blob([source], {type: 'text/javascript'})); + window.__oopifWorker = new Worker(url); + }""", + marker, + ) + + worker = wait_until(lambda: next(iter(worker_seen), None)) + message = wait_until( + lambda: next((message for message in console_seen if message.text == marker), None) + ) + assert worker in page.workers + assert message.worker is worker + child.evaluate("() => window.__oopifWorker?.terminate()") + + +def test_page_once_and_remove_listener_for_console(page): + for index in range(200): + seen: list[str] = [] + + def listener(message, seen=seen): + seen.append(message.text) + + page.once("console", listener) + page.evaluate("(index) => console.log(`once-${index}`)", index) + assert wait_until(lambda: seen) == [f"once-{index}"] + page.remove_listener("console", listener) + + +def test_async_listener_removal_from_unrelated_loop_task_cancels_suspended_handler(): + from rustwright.async_api import _wrap_async_event_handler + from rustwright.sync_api import Page + + class Core: + def combined_event_stream(self): + return object() + + def keyboard_primary_modifier(self): + return "Control" + + async def run() -> None: + loop = asyncio.get_running_loop() + page = Page(Core(), _start_event_pump=False) + owner = type("AsyncOwner", (), {})() + owner._sync = page + owner._loop = loop + owner._event_handler_wrappers = [] + page._ensure_console_thread = lambda: None + page._attach_existing_worker_console_propagation = lambda: None + page._ensure_worker_thread = lambda: None + entered = asyncio.Event() + after_await: list[str] = [] + never = asyncio.Event() + + async def listener(_event): + entered.set() + await never.wait() + after_await.append("invoked") + + wrapped = _wrap_async_event_handler(owner, "console", listener) + page.on("console", wrapped) + dispatch_thread = threading.Thread( + target=page._dispatch_event_handlers, + args=("console", object()), + kwargs={"event_sequence": 1}, + daemon=True, + ) + dispatch_thread.start() + dispatch_thread.join(timeout=2) + assert not dispatch_thread.is_alive() + await asyncio.wait_for(entered.wait(), timeout=1) + + async def remove_from_unrelated_task(): + page.remove_listener("console", wrapped) + + await asyncio.wait_for(asyncio.create_task(remove_from_unrelated_task()), timeout=1) + for _ in range(5): + await asyncio.sleep(0) + assert after_await == [] + with page._event_dispatch_condition: + assert not page._event_handler_registrations + + asyncio.run(run()) + + +def test_async_dispatch_horizon_is_scoped_to_source_page(): + from rustwright.async_api import _wrap_async_event_handler + from rustwright.sync_api import Page + + class Core: + def __init__(self, cursor): + self.cursor = cursor + + def combined_event_stream(self): + return object() + + def event_cursor(self): + return self.cursor + + def keyboard_primary_modifier(self): + return "Control" + + async def run() -> None: + loop = asyncio.get_running_loop() + page_a = Page(Core(10_001), _start_event_pump=False) + page_b = Page(Core(7), _start_event_pump=False) + for page in (page_a, page_b): + page._ensure_console_thread = lambda: None + page._attach_existing_worker_console_propagation = lambda: None + page._ensure_worker_thread = lambda: None + owner = type("AsyncOwner", (), {})() + owner._sync = page_a + owner._loop = loop + owner._event_handler_wrappers = [] + registered = asyncio.Event() + seen: list[str] = [] + + async def second(_event): + seen.append("second") + + wrapped_second = _wrap_async_event_handler(owner, "console", second) + + async def first(_event): + await asyncio.sleep(0) + page_b.on("console", wrapped_second) + registered.set() + + page_a.on("console", _wrap_async_event_handler(owner, "console", first)) + page_a._dispatch_event_handlers("console", object(), event_sequence=10_001) + await asyncio.wait_for(registered.wait(), timeout=1) + assert page_b._event_handler_cursors[("console", id(wrapped_second))] == 7 + + page_b._dispatch_event_handlers("console", object(), event_sequence=6) + await asyncio.sleep(0) + assert seen == [] + page_b._dispatch_event_handlers("console", object(), event_sequence=7) + for _ in range(5): + await asyncio.sleep(0) + if seen: + break + assert seen == ["second"] + + asyncio.run(run()) + +def test_worker_listener_claims_before_real_worker_forwarding_and_resume(): + import rustwright.sync_api as sync_api + + class Waiter: + def wait(self, _timeout=None): + raise sync_api.Error("test waiter complete") + + handoff_order: list[str] = [] + claim_entered = threading.Event() + capture_ready = threading.Event() + + class Core: + target_id = "worker-target" + session_id = "worker-session" + url = "blob://worker" + + def combined_event_stream(self): + return object() + + def keyboard_primary_modifier(self): + return "Control" + + def set_worker_event_interest(self, _interested, _timeout_ms): + return None + + def set_worker_forwarding_interest(self, _interested, _timeout_ms): + return None + + def claim_capture_handoff(self): + handoff_order.append("claim") + claim_entered.set() + return True + + def console_event_waiter(self, _timeout_ms): + assert claim_entered.is_set() + handoff_order.append("attach") + return Waiter() + + def close_event_waiter(self): + return Waiter() + + def mark_capture_ready(self): + assert handoff_order == ["claim", "attach"] + handoff_order.append("ready") + capture_ready.set() + return True + + def run_if_waiting_for_debugger(self, _timeout_ms): + assert capture_ready.is_set() + handoff_order.append("resume") + + page = sync_api.Page(Core(), _start_event_pump=False) + page._ensure_console_thread = lambda: None + page._ensure_worker_thread = lambda: None + page._attach_existing_worker_console_propagation = lambda: None + page.on("console", lambda _event: None) + worker = page._worker_from_core(Core()) + + assert worker is page._workers[Core.target_id] + assert handoff_order == ["claim", "attach", "ready", "resume"] + page.remove_listener("console", page._event_handlers["console"][0]) + +def test_page_event_waiter_interest_is_released_on_context_exit(): + from rustwright.sync_api import Page + + class Waiter: + def wait(self, _timeout=None): + raise AssertionError("the canceled context must not wait") + + class Core: + def __init__(self): + self.interest = [] + + def combined_event_stream(self): + return object() + + def keyboard_primary_modifier(self): + return "Control" + + def set_worker_event_interest(self, interested, _timeout_ms): + self.interest.append(interested) + + def worker_event_waiter(self, _timeout_ms, configure=True): + return Waiter() + + core = Core() + page = Page(core, _start_event_pump=False) + + try: + with page.expect_worker(): + assert core.interest == [True] + raise RuntimeError("cancel waiter") + except RuntimeError: + pass + assert core.interest == [True, False] + +def test_fully_canceled_dialog_snapshot_is_dismissed(): + from rustwright.sync_api import Page + + class Core: + def __init__(self): + self.dismissals = [] + + def combined_event_stream(self): + return object() + + def keyboard_primary_modifier(self): + return "Control" + + def handle_dialog(self, accepted, prompt_text, timeout_ms): + self.dismissals.append((accepted, prompt_text, timeout_ms)) + + core = Core() + page = Page(core, _start_event_pump=False) + handler_called = [] + + def handler(_dialog): + handler_called.append(True) + + page.on("dialog", handler) + original_activate = page._activate_event_handler + + def cancel_before_activation(state, owner): + page.remove_listener("dialog", handler) + return original_activate(state, owner) + + page._activate_event_handler = cancel_before_activation + page._handle_dialog_event({"type": "alert", "message": "blocked"}) + assert handler_called == [] + assert core.dismissals == [(False, None, page._default_timeout)] +def test_dialog_handler_exception_is_auto_dismissed_once(): + from rustwright.sync_api import Page + + class Core: + def __init__(self): + self.dismissals = [] + + def combined_event_stream(self): + return object() + + def keyboard_primary_modifier(self): + return "Control" + + def handle_dialog(self, accepted, prompt_text, timeout_ms): + self.dismissals.append((accepted, prompt_text, timeout_ms)) + + core = Core() + page = Page(core, _start_event_pump=False) + + def handler(_dialog): + raise RuntimeError("dialog handler failed") + + page.on("dialog", handler) + page._handle_dialog_event({"type": "alert", "message": "raises"}) + assert core.dismissals == [(False, None, page._default_timeout)] + + +def test_dialog_handler_that_returns_without_handling_is_auto_dismissed_once(): + from rustwright.sync_api import Page + + class Core: + def __init__(self): + self.dismissals = [] + + def combined_event_stream(self): + return object() + + def keyboard_primary_modifier(self): + return "Control" + + def handle_dialog(self, accepted, prompt_text, timeout_ms): + self.dismissals.append((accepted, prompt_text, timeout_ms)) + + core = Core() + page = Page(core, _start_event_pump=False) + page.on("dialog", lambda _dialog: None) + page._handle_dialog_event({"type": "alert", "message": "unhandled"}) + assert core.dismissals == [(False, None, page._default_timeout)] + + +def test_async_dialog_handler_cancellation_after_activation_is_auto_dismissed_once(): + from rustwright.async_api import _wrap_async_event_handler + from rustwright.sync_api import Page + + class Core: + def __init__(self): + self.dismissals = [] + + def combined_event_stream(self): + return object() + + def keyboard_primary_modifier(self): + return "Control" + + def handle_dialog(self, accepted, prompt_text, timeout_ms): + self.dismissals.append((accepted, prompt_text, timeout_ms)) + + async def run(): + core = Core() + page = Page(core, _start_event_pump=False) + owner = type("AsyncOwner", (), {})() + owner._sync = page + owner._loop = asyncio.get_running_loop() + owner._event_handler_wrappers = [] + entered = asyncio.Event() + never = asyncio.Event() + + async def handler(_dialog): + entered.set() + await never.wait() + + wrapped = _wrap_async_event_handler(owner, "dialog", handler) + page.on("dialog", wrapped) + page._handle_dialog_event({"type": "alert", "message": "canceled"}) + await asyncio.wait_for(entered.wait(), timeout=1) + page.remove_listener("dialog", wrapped) + for _ in range(3): + await asyncio.sleep(0) + assert core.dismissals == [(False, None, page._default_timeout)] + + asyncio.run(run()) + + +def test_worker_interest_turns_off_after_last_console_or_worker_listener(): + from rustwright.sync_api import Page + + class Core: + def __init__(self): + self.interest = [] + + def combined_event_stream(self): + return object() + + def keyboard_primary_modifier(self): + return "Control" + + def set_worker_event_interest(self, interested, _timeout_ms): + self.interest.append(interested) + + core = Core() + page = Page(core, _start_event_pump=False) + page._ensure_console_thread = lambda: None + page._ensure_worker_thread = lambda: None + page._attach_existing_worker_console_propagation = lambda: None + + console = lambda _event: None + worker = lambda _event: None + page.on("console", console) + page.on("worker", worker) + page.remove_listener("console", console) + assert core.interest[-1] is True + page.remove_listener("worker", worker) + assert core.interest[-1] is False + once_worker = lambda _event: None + page.once("worker", once_worker) + assert core.interest[-1] is True + page._dispatch_event_handlers("worker", object()) + assert core.interest[-1] is False + + +def test_worker_close_consumers_fire_terminal_snapshot_once(): + from rustwright.sync_api import Worker, _emit_worker_value + + worker = Worker("blob://worker") + seen: list[str] = [] + start = threading.Barrier(3) + + def on_close(_worker): + seen.append("close") + + worker._event_handlers = {"close": [on_close]} + + def consume(): + start.wait(timeout=2) + _emit_worker_value(worker, "close", worker) + + threads = [threading.Thread(target=consume, daemon=True) for _ in range(2)] + for thread in threads: + thread.start() + start.wait(timeout=2) + for thread in threads: + thread.join(timeout=2) + assert all(not thread.is_alive() for thread in threads) + assert seen == ["close"] diff --git a/tools/check_launch_latency_claim.py b/tools/check_launch_latency_claim.py index c377684..0983e98 100644 --- a/tools/check_launch_latency_claim.py +++ b/tools/check_launch_latency_claim.py @@ -12,7 +12,6 @@ ROOT = Path(__file__).resolve().parents[1] MAX_MEMORY_BYTES = 8 * 1024 * 1024 * 1024 DEFAULT_MAX_VARIANCE_RATIO = 1.2 -DEFAULT_PROGRESS = ROOT / "docs" / "internal" / "PROGRESS.json" def load_json(path: Path) -> dict[str, Any]: @@ -23,13 +22,6 @@ def check(condition: bool, name: str, detail: str) -> dict[str, Any]: return {"name": name, "passed": bool(condition), "detail": detail} -def dot_lookup(value: dict[str, Any], path: str) -> Any: - current: Any = value - for part in path.split("."): - if not isinstance(current, dict) or part not in current: - return None - current = current[part] - return current def memory_limit_bytes(value: Any) -> int | None: @@ -179,45 +171,6 @@ def matrix_json_to_evidence( } -def evaluate( - progress: dict[str, Any], - *, - evidence_path: str, - min_repetitions: int, - min_iterations: int, - min_case_count: int, - min_speedup_pct: float, - max_variance_ratio: float, - reference_impl: str, -) -> dict[str, Any]: - evidence = dot_lookup(progress, evidence_path) - if not isinstance(evidence, dict): - return { - "status": "missing", - "accepted": False, - "evidence_path": evidence_path, - "checks": [check(False, "evidence_present", f"no object found at {evidence_path}")], - "observed": {}, - "criteria": { - "min_repetitions": min_repetitions, - "min_iterations": min_iterations, - "min_case_count": min_case_count, - "min_speedup_pct": min_speedup_pct, - "max_variance_ratio": max_variance_ratio, - "reference_impl": reference_impl, - }, - } - return evaluate_evidence( - evidence, - evidence_path=evidence_path, - min_repetitions=min_repetitions, - min_iterations=min_iterations, - min_case_count=min_case_count, - min_speedup_pct=min_speedup_pct, - max_variance_ratio=max_variance_ratio, - reference_impl=reference_impl, - ) - def evaluate_evidence( evidence: dict[str, Any], @@ -370,17 +323,11 @@ def evaluate_evidence( def main() -> int: parser = argparse.ArgumentParser(description="Check whether recorded benchmark evidence is launch-ready latency evidence.") - parser.add_argument( - "--progress", - type=Path, - default=DEFAULT_PROGRESS, - help="Progress metadata JSON; defaults to the internal progress file when present.", - ) - parser.add_argument("--evidence-path", default="metrics.latest_blacksmith_benchmark_workflow") parser.add_argument( "--benchmark-json", type=Path, - help="Validate a saved tools/run_benchmark_matrix.py JSON artifact directly instead of reading progress metadata.", + required=True, + help="Validate a saved tools/run_benchmark_matrix.py JSON artifact.", ) parser.add_argument( "--source", @@ -403,49 +350,23 @@ def main() -> int: if min_case_count is None: min_case_count = check_phase2_benchmark.current_strict_case_count() - if args.benchmark_json: - evidence = matrix_json_to_evidence( - load_json(args.benchmark_json), - source=args.source, - runner=args.runner, - artifact=args.artifact, - run_url=args.run_url, - ) - report = evaluate_evidence( - evidence, - evidence_path=str(args.benchmark_json), - min_repetitions=args.min_repetitions, - min_iterations=args.min_iterations, - min_case_count=min_case_count, - min_speedup_pct=args.min_speedup_pct, - max_variance_ratio=args.max_variance_ratio, - reference_impl=args.reference_impl, - ) - elif args.progress.exists(): - report = evaluate( - load_json(args.progress), - evidence_path=args.evidence_path, - min_repetitions=args.min_repetitions, - min_iterations=args.min_iterations, - min_case_count=min_case_count, - min_speedup_pct=args.min_speedup_pct, - max_variance_ratio=args.max_variance_ratio, - reference_impl=args.reference_impl, - ) - else: - report = { - "status": "unavailable", - "accepted": False, - "checks": [ - check( - False, - "progress_metadata_available", - f"progress metadata not found at {args.progress}; pass --benchmark-json to validate a public artifact", - ) - ], - "observed": {"progress": str(args.progress)}, - "criteria": {}, - } + evidence = matrix_json_to_evidence( + load_json(args.benchmark_json), + source=args.source, + runner=args.runner, + artifact=args.artifact, + run_url=args.run_url, + ) + report = evaluate_evidence( + evidence, + evidence_path=str(args.benchmark_json), + min_repetitions=args.min_repetitions, + min_iterations=args.min_iterations, + min_case_count=min_case_count, + min_speedup_pct=args.min_speedup_pct, + max_variance_ratio=args.max_variance_ratio, + reference_impl=args.reference_impl, + ) if args.json: print(json.dumps(report, indent=2, sort_keys=True)) else: diff --git a/tools/check_phase1_gate.py b/tools/check_phase1_gate.py deleted file mode 100644 index 40216d4..0000000 --- a/tools/check_phase1_gate.py +++ /dev/null @@ -1,308 +0,0 @@ -#!/usr/bin/env python3 -from __future__ import annotations - -import argparse -import json -from pathlib import Path -from typing import Any - - -ROOT = Path(__file__).resolve().parents[1] -INTERNAL_DOCS = ROOT / "docs" / "internal" -PHASE_1_ROW_COMPLETE_STATUSES = {"covered_needs_current_docker_gate", "docker_verified"} -DOCKER_VERIFIED_STATUSES = {"docker_verified", "passed"} -MAX_TEST_MEMORY_BYTES = 8 * 1024 * 1024 * 1024 - - -def load_json(name: str) -> Any: - return json.loads((INTERNAL_DOCS / name).read_text(encoding="utf-8")) - - -def check(name: str, passed: bool, detail: str) -> dict[str, Any]: - return {"name": name, "passed": passed, "detail": detail} - - -def all_zero(values: dict[str, Any], keys: list[str]) -> bool: - return all(values.get(key) == 0 for key in keys) - - -def inside_container() -> bool: - if Path("/.dockerenv").exists(): - return True - cgroup = Path("/proc/1/cgroup") - if not cgroup.exists(): - return False - text = cgroup.read_text(encoding="utf-8", errors="ignore") - return any(token in text for token in ("docker", "containerd", "kubepods", "libpod")) - - -def read_memory_limit_bytes() -> int | None: - for path in (Path("/sys/fs/cgroup/memory.max"), Path("/sys/fs/cgroup/memory/memory.limit_in_bytes")): - if not path.exists(): - continue - value = path.read_text(encoding="utf-8").strip() - if value in {"", "max"}: - return None - try: - return int(value) - except ValueError: - return None - return None - - -def current_docker_gate_verified() -> tuple[bool, str]: - if not inside_container(): - return False, "not running inside a detected container" - limit = read_memory_limit_bytes() - if limit is None: - return False, "no parseable cgroup memory limit" - if limit > MAX_TEST_MEMORY_BYTES: - return False, f"container memory limit is above 8GB: {limit} bytes" - return True, f"detected capped container memory limit: {limit} bytes" - - -def build_report(*, current_docker_run: bool = False) -> dict[str, Any]: - requirements = load_json("REQUIREMENTS_STATUS.json") - progress = load_json("PROGRESS.json") - phase_1 = requirements["execution_phase_plan"]["phase_1"] - ledger = requirements["phase_1_coverage_ledger"] - rows = ledger["rows"] - backlog = phase_1["coverage_backlog"] - progress_metrics = progress["metrics"] - shared_case_count = progress_metrics["shared_parity_cases"]["value"] - incremental_shared = progress_metrics.get("post_full_gate_incremental_shared_parity", {}) - local_sampled = progress_metrics["latest_sampled_docker_gate"]["current_configured_selection"][ - "latest_local_validation" - ] - rustwright_full = progress_metrics["latest_rustwright_full_parity_run"] - playwright_full = progress_metrics["latest_playwright_full_parity_run"] - api_surface = progress_metrics["latest_sync_async_api_surface_audit"] - skyvern_dependency = progress["skyvern_cloud_dependency_audit"] - skyvern_audit = skyvern_dependency["latest_machine_audit"] - skyvern_replacement_smoke = skyvern_dependency.get("latest_replacement_smoke") or {} - skyvern_overlay = skyvern_dependency.get("latest_overlay_pytest") or {} - skyvern_alias_command = skyvern_dependency.get("latest_alias_command_smoke") or {} - skyvern_prompt_overlay = skyvern_dependency.get("latest_prompt_overlay_smoke") or {} - prompt_module_results = skyvern_prompt_overlay.get("module_results") or [] - prompt_failure_classifications = { - item.get("classification") - for item in prompt_module_results - if isinstance(item, dict) and item.get("status") != "passed" - } - incremental_total = int(incremental_shared.get("total") or 0) - incremental_rustwright_passed = int(incremental_shared.get("rustwright_passed") or 0) - incremental_playwright_passed = int(incremental_shared.get("playwright_reference_passed") or 0) - incremental_status = str(incremental_shared.get("status") or "") - incremental_local_passed = incremental_total == 0 or ( - incremental_status in {"local_focused_passed", "passed", "docker_verified"} - and incremental_total >= 0 - and incremental_rustwright_passed == incremental_total - and incremental_playwright_passed == incremental_total - ) - - row_status_counts: dict[str, int] = {} - for row in rows: - row_status_counts[row["status"]] = row_status_counts.get(row["status"], 0) + 1 - - checks = [ - check( - "coverage_backlog_matches_ledger", - [row["coverage_point"] for row in rows] == backlog, - f"{len(rows)} ledger rows for {len(backlog)} backlog entries", - ), - check( - "all_phase_1_rows_have_executable_evidence", - all(row["evidence"] and row["latest_result"] and row["remaining"] for row in rows), - "every row has evidence, latest_result, and remaining fields", - ), - check( - "all_phase_1_rows_locally_covered", - all(row["status"] in PHASE_1_ROW_COMPLETE_STATUSES for row in rows), - json.dumps(row_status_counts, sort_keys=True), - ), - check( - "local_sampled_gate_passed", - local_sampled["status"] == "passed" - and local_sampled["pytest"]["passed"] > 0 - and local_sampled["rustwright_parity_sample"]["passed"] == local_sampled["rustwright_parity_sample"]["total"] - and local_sampled["playwright_reference_parity_sample"]["passed"] - == local_sampled["playwright_reference_parity_sample"]["total"], - ( - f"pytest={local_sampled['pytest']['passed']} passed, " - f"rustwright={local_sampled['rustwright_parity_sample']['passed']}/" - f"{local_sampled['rustwright_parity_sample']['total']}, " - f"playwright={local_sampled['playwright_reference_parity_sample']['passed']}/" - f"{local_sampled['playwright_reference_parity_sample']['total']}" - ), - ), - check( - "full_local_rustwright_parity_recorded", - rustwright_full["status"] == "passed" - and rustwright_full["passed"] == rustwright_full["total"] - and rustwright_full["total"] > 0, - ( - f"{rustwright_full['passed']}/{rustwright_full['total']} recorded full run; " - f"current shared count {shared_case_count}" - ), - ), - check( - "full_local_playwright_reference_parity_recorded", - playwright_full["status"] == "passed" - and playwright_full["passed"] == playwright_full["total"] - and playwright_full["total"] > 0, - ( - f"{playwright_full['passed']}/{playwright_full['total']} recorded full run; " - f"current shared count {shared_case_count}" - ), - ), - check( - "current_shared_parity_cases_locally_covered", - incremental_local_passed - and rustwright_full["passed"] + incremental_rustwright_passed >= shared_case_count - and playwright_full["passed"] + incremental_playwright_passed >= shared_case_count, - ( - f"full={rustwright_full['passed']}/{playwright_full['passed']}, " - f"incremental={incremental_rustwright_passed}/{incremental_playwright_passed}/" - f"{incremental_total}, current shared count {shared_case_count}" - ), - ), - check( - "api_surface_audits_clean", - api_surface["sync_api"]["status"] == "passed" - and api_surface["async_api"]["status"] == "passed" - and all_zero(api_surface["sync_api"], ["missing_classes", "missing_members", "extra_members", "signature_diffs"]) - and all_zero( - api_surface["async_api"], - ["missing_classes", "missing_members", "extra_members", "signature_diffs"], - ), - "sync and async API surface audits report 0 missing, 0 extra, and 0 signature diffs", - ), - check( - "skyvern_dependency_audit_clean", - skyvern_audit["alias_symbol_coverage"]["missing_total"] == 0 - and skyvern_audit["alias_symbol_coverage"]["import_error_total"] == 0 - and skyvern_audit["method_name_coverage"]["missing_total"] == 0 - and skyvern_audit["typed_method_coverage"]["missing_total"] == 0, - ( - f"{skyvern_audit['scan_scope']['python_files_scanned']} Python files, " - f"{skyvern_audit['typed_method_coverage']['typed_call_count']} typed calls, " - f"{skyvern_audit['typed_method_coverage']['receiver_method_count']} receiver-method pairs" - ), - ), - check( - "skyvern_replacement_smoke_clean", - skyvern_replacement_smoke.get("status") == "ok" - and skyvern_replacement_smoke.get("audit_status") == "ok" - and skyvern_replacement_smoke.get("alias_failures") == 0 - and skyvern_replacement_smoke.get("skyvern_module_import_mode") == "strict" - and int(skyvern_replacement_smoke.get("skyvern_modules_failed") or 0) == 0 - and int(skyvern_replacement_smoke.get("skyvern_modules_imported") or 0) >= 9, - ( - f"status={skyvern_replacement_smoke.get('status')}, " - f"audit={skyvern_replacement_smoke.get('audit_status')}, " - f"alias_failures={skyvern_replacement_smoke.get('alias_failures')}, " - f"mode={skyvern_replacement_smoke.get('skyvern_module_import_mode')}, " - f"skyvern_modules_imported={skyvern_replacement_smoke.get('skyvern_modules_imported')}, " - f"skyvern_modules_failed={skyvern_replacement_smoke.get('skyvern_modules_failed')}" - ), - ), - check( - "skyvern_overlay_pytest_alias_clean", - skyvern_overlay.get("alias_preflight_status") == "ok" - and skyvern_overlay.get("status") == "passed" - and skyvern_overlay.get("tests_passed", 0) > 0 - and (skyvern_overlay.get("default_conftest") or {}).get("status") == "passed" - and (skyvern_overlay.get("noconftest") or {}).get("status") == "passed" - and (skyvern_overlay.get("default_conftest") or {}).get("classification") - not in {"alias_dependency", "alias_related"}, - ( - f"status={skyvern_overlay.get('status')}, " - f"alias_preflight={skyvern_overlay.get('alias_preflight_status')}, " - f"tests_passed={skyvern_overlay.get('tests_passed')}, " - f"default_conftest_classification=" - f"{(skyvern_overlay.get('default_conftest') or {}).get('classification')}" - ), - ), - check( - "skyvern_alias_command_smoke_clean", - skyvern_alias_command.get("status") == "passed" - and skyvern_alias_command.get("alias_preflight_status") == "ok" - and skyvern_alias_command.get("command_status") == "passed" - and skyvern_alias_command.get("command_classification") == "passed", - ( - f"status={skyvern_alias_command.get('status')}, " - f"alias_preflight={skyvern_alias_command.get('alias_preflight_status')}, " - f"command={skyvern_alias_command.get('command_status')}/" - f"{skyvern_alias_command.get('command_classification')}" - ), - ), - check( - "skyvern_prompt_overlay_smoke_accounted", - skyvern_prompt_overlay.get("alias_preflight_status") == "ok" - and skyvern_prompt_overlay.get("status") == "passed" - and int(skyvern_prompt_overlay.get("environment_blocked_modules") or 0) == 0 - and not (prompt_failure_classifications & {"alias_dependency", "alias_related"}), - ( - f"status={skyvern_prompt_overlay.get('status')}, " - f"alias_preflight={skyvern_prompt_overlay.get('alias_preflight_status')}, " - f"modules={skyvern_prompt_overlay.get('module_count')}, " - f"blocked={skyvern_prompt_overlay.get('environment_blocked_modules')}" - ), - ), - ] - recorded_docker_verified = ( - phase_1["status"] in DOCKER_VERIFIED_STATUSES - and ledger["status"] in DOCKER_VERIFIED_STATUSES - and ledger["docker_completion_status"] in DOCKER_VERIFIED_STATUSES - ) - current_docker_verified = False - current_docker_detail = "not requested" - if current_docker_run: - current_docker_verified, current_docker_detail = current_docker_gate_verified() - docker_verified = recorded_docker_verified or current_docker_verified - checks.append( - check( - "docker_phase_1_gate_verified", - docker_verified, - ( - f"phase1={phase_1['status']}, ledger={ledger['status']}, " - f"docker={ledger['docker_completion_status']}, current_run={current_docker_detail}" - ), - ) - ) - - local_checks = [item for item in checks if item["name"] != "docker_phase_1_gate_verified"] - local_ready = all(item["passed"] for item in local_checks) - complete = local_ready and docker_verified - status = "complete" if complete else "local_ready_docker_pending" if local_ready else "incomplete" - return { - "status": status, - "complete": complete, - "local_ready": local_ready, - "docker_verified": docker_verified, - "recorded_docker_verified": recorded_docker_verified, - "current_docker_verified": current_docker_verified, - "checks": checks, - } - - -def main() -> int: - parser = argparse.ArgumentParser(description="Check Phase 1 coverage and verification gate status.") - parser.add_argument("--require-docker", action="store_true", help="Exit nonzero unless the Docker gate is verified.") - parser.add_argument( - "--current-docker-run", - action="store_true", - help="Treat the current capped container as Docker evidence for this invocation.", - ) - parser.add_argument("--pretty", action="store_true", help="Pretty-print JSON output.") - args = parser.parse_args() - - report = build_report(current_docker_run=args.current_docker_run) - print(json.dumps(report, indent=2 if args.pretty else None, sort_keys=True)) - if args.require_docker: - return 0 if report["complete"] else 1 - return 0 if report["local_ready"] else 1 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/tools/docker_test.sh b/tools/docker_test.sh index 2fa5b7a..1489884 100755 --- a/tools/docker_test.sh +++ b/tools/docker_test.sh @@ -16,7 +16,7 @@ usage() { cat >&2 <<'USAGE' Usage: tools/docker_test.sh build [docker build args...] - tools/docker_test.sh [pycompile|focused|parity|sampled|phase1|full|bench|bench-full|mind2web|mind2web-full|webvoyager|webvoyager-full|antibot|antibot-smoke] [mode args...] + tools/docker_test.sh [pycompile|focused|parity|sampled|full|bench|bench-full|mind2web|webvoyager|antibot|antibot-smoke] [mode args...] Environment: RUSTWRIGHT_DOCKER_IMAGE Image name. Defaults to rustwright-verify. @@ -25,8 +25,6 @@ Environment: RUSTWRIGHT_DOCKER_BASE_IMAGE Build base image. Defaults to python:3.13-slim-bookworm. RUSTWRIGHT_DOCKER_LEGACY Set to 1 to build without BuildKit cache mounts. BENCHMARK_FULL_ITERATIONS Iterations for bench-full. Defaults to 10. - MIND2WEB_FULL_ITERATIONS Iterations for mind2web-full. Defaults to 1. - WEBVOYAGER_FULL_ITERATIONS Iterations for webvoyager-full. Defaults to 1. BENCHMARK_CHROMIUM_EXECUTABLE Optional in-container Chromium path used by all benchmarked Playwright-style implementations. RUSTWRIGHT_BENCH_REBUILD Set to 1 for tools/docker_test.sh bench Rustwright/all release-wheel rebuilds. RUSTWRIGHT_DOCKER_REBUILD_TARGET_CACHE @@ -114,19 +112,7 @@ case "$mode" in --iterations "${BENCHMARK_FULL_ITERATIONS:-10}" \ "$@" ;; - mind2web-full) - reject_memory_override_args "$@" - exec "${PYTHON:-python3}" "${HOST_WORKDIR}/tools/run_mind2web_matrix.py" \ - --iterations "${MIND2WEB_FULL_ITERATIONS:-1}" \ - "$@" - ;; - webvoyager-full) - reject_memory_override_args "$@" - exec "${PYTHON:-python3}" "${HOST_WORKDIR}/tools/run_webvoyager_matrix.py" \ - --iterations "${WEBVOYAGER_FULL_ITERATIONS:-1}" \ - "$@" - ;; - pycompile|focused|parity|sampled|phase1|full|bench|mind2web|webvoyager|antibot|antibot-smoke) + pycompile|focused|parity|sampled|full|bench|mind2web|webvoyager|antibot|antibot-smoke) run_args=( run --rm @@ -164,6 +150,11 @@ case "$mode" in do add_worktree_mount "$worktree_path" done + for readonly_worktree_path in agent/Cargo.toml agent/src; do + if [ -e "${HOST_WORKDIR}/${readonly_worktree_path}" ]; then + run_args+=(--volume "${HOST_WORKDIR}/${readonly_worktree_path}:/workspace/${readonly_worktree_path}:ro") + fi + done add_worktree_mount ".benchmark-data" for env_name in \ BENCHMARK_ITERATIONS \ diff --git a/tools/docker_verify.sh b/tools/docker_verify.sh index 717d3a5..37323af 100755 --- a/tools/docker_verify.sh +++ b/tools/docker_verify.sh @@ -114,19 +114,13 @@ pycompile() { tools/import_mind2web.py \ tools/import_webvoyager.py \ tools/run_mind2web_benchmark.py \ - tools/run_mind2web_matrix.py \ tools/run_webvoyager_benchmark.py \ - tools/run_webvoyager_matrix.py \ - tools/run_parity_cases.py \ tools/run_antibot_benchmarks.py \ tools/run_benchmark_matrix.py \ tools/render_benchmark_matrix.py \ tools/check_cross_library_speed_goal.py \ tools/check_launch_latency_claim.py \ tools/run_remote_docker_test.py \ - tools/query_project_state.py \ - tools/render_project_tables.py \ - tools/check_phase1_gate.py \ tools/check_native_extension.py \ tools/api_surface_audit.py \ tools/check_benchmark_artifacts.py \ @@ -135,7 +129,7 @@ pycompile() { tools/run_skyvern_cloud_overlay_tests.py \ tools/run_skyvern_alias_command.py \ tools/run_skyvern_prompt_overlay_smoke.py \ - tests/test_project_status_tools.py \ + tests/test_repository_tools.py \ tests/test_skyvern_replacement_smoke.py \ tests/test_skyvern_cloud_overlay_tests.py \ tests/test_skyvern_alias_command.py \ @@ -154,10 +148,12 @@ sampled() { tests/test_skyvern_cloud_overlay_tests.py \ tests/test_skyvern_alias_command.py \ tests/test_skyvern_prompt_overlay_smoke.py \ - tests/test_project_status_tools.py - run "$PYTHON_BIN" tools/run_parity_cases.py \ + tests/test_repository_tools.py + run "$PYTHON_BIN" benchmarks/run_benchmarks.py \ --impl rustwright \ --reference-path "$REFERENCE_PATH" \ + --suite parity \ + --iterations 1 \ --case context_viewport_screen_device_option_validation_matches_playwright \ --case context_no_viewport_and_viewport_none_disable_viewport_emulation \ --case context_environment_and_emulate_media_validation_matches_playwright \ @@ -202,9 +198,11 @@ sampled() { --case mouse_dblclick_delay_reuses_click_count_sequence_like_playwright \ --case drag_and_drop_dispatches_native_pointer_mouse_events_like_playwright \ --json - run "$PYTHON_BIN" tools/run_parity_cases.py \ + run "$PYTHON_BIN" benchmarks/run_benchmarks.py \ --impl playwright \ --reference-path "$REFERENCE_PATH" \ + --suite parity \ + --iterations 1 \ --case context_viewport_screen_device_option_validation_matches_playwright \ --case context_string_header_and_http_credentials_validation_matches_playwright \ --case geolocation_option_validation_matches_playwright \ @@ -260,38 +258,73 @@ focused() { parity() { pycompile local has_reference_path=0 + local impl_count=0 + local next_is_impl=0 + local selected_impl="" local arg for arg in "$@"; do + if [ "$next_is_impl" -eq 1 ]; then + selected_impl="$arg" + next_is_impl=0 + continue + fi case "$arg" in + --impl) + impl_count=$((impl_count + 1)) + next_is_impl=1 + ;; + --impl=*) + impl_count=$((impl_count + 1)) + selected_impl="${arg#--impl=}" + ;; --reference-path|--reference-path=*) has_reference_path=1 ;; + --suite|--suite=*|--iterations|--iterations=*|--lifecycle|--lifecycle=*) + echo "parity mode owns --suite, --iterations, and --lifecycle" >&2 + exit 2 + ;; esac done + if [ "$next_is_impl" -eq 1 ] || [ "$impl_count" -ne 1 ]; then + echo "parity mode requires exactly one --impl value" >&2 + exit 2 + fi + case "$selected_impl" in + rustwright|playwright) + ;; + *) + echo "parity mode requires --impl rustwright or --impl playwright" >&2 + exit 2 + ;; + esac if [ "$has_reference_path" -eq 1 ]; then - run "$PYTHON_BIN" tools/run_parity_cases.py "$@" + run "$PYTHON_BIN" benchmarks/run_benchmarks.py "$@" \ + --suite parity --iterations 1 --lifecycle warm-browser else - run "$PYTHON_BIN" tools/run_parity_cases.py --reference-path "$REFERENCE_PATH" "$@" + run "$PYTHON_BIN" benchmarks/run_benchmarks.py "$@" \ + --reference-path "$REFERENCE_PATH" \ + --suite parity --iterations 1 --lifecycle warm-browser fi } full() { pycompile run_pytest -q - run "$PYTHON_BIN" tools/run_parity_cases.py \ + run "$PYTHON_BIN" benchmarks/run_benchmarks.py \ --impl rustwright \ --reference-path "$REFERENCE_PATH" \ + --suite parity \ + --iterations 1 \ --json - run "$PYTHON_BIN" tools/run_parity_cases.py \ + run "$PYTHON_BIN" benchmarks/run_benchmarks.py \ --impl playwright \ --reference-path "$REFERENCE_PATH" \ + --suite parity \ + --iterations 1 \ --json } -phase1() { - sampled - run "$PYTHON_BIN" tools/check_phase1_gate.py --current-docker-run --require-docker --pretty -} bench() { local impl_args=(--impl all) @@ -373,10 +406,6 @@ case "$MODE" in enforce_container_memory_limit sampled "$@" ;; - phase1) - enforce_container_memory_limit - phase1 "$@" - ;; focused) enforce_container_memory_limit focused "$@" @@ -412,25 +441,22 @@ case "$MODE" in *) cat >&2 <<'USAGE' Usage inside the container: - docker run --rm --memory=8g --memory-swap=8g rustwright-verify [pycompile|focused|parity|sampled|phase1|full|bench|mind2web|webvoyager|antibot|antibot-smoke] + docker run --rm --memory=8g --memory-swap=8g rustwright-verify [pycompile|focused|parity|sampled|full|bench|mind2web|webvoyager|antibot|antibot-smoke] Uncapped Docker runs, or runs with a memory cap above 8GB, exit before verification starts. Preferred host usage: - tools/docker_test.sh [pycompile|focused|parity|sampled|phase1|full|bench|bench-full|mind2web|mind2web-full|webvoyager|webvoyager-full|antibot|antibot-smoke] + tools/docker_test.sh [pycompile|focused|parity|sampled|full|bench|bench-full|mind2web|webvoyager|antibot|antibot-smoke] Modes: pycompile Compile Python sources used by the verification loops. focused Compile Python sources, then run the supplied pytest selector. parity Compile Python sources, then run supplied shared parity cases. sampled Focused tests plus a stratified option/event/anti-bot/async parity sample. - phase1 Run sampled, then require tools/check_phase1_gate.py to pass in the current capped Docker container. full Full pytest plus full Rustwright and Playwright parity. bench Comparable benchmark run; set BENCHMARK_ITERATIONS to tune. Set RUSTWRIGHT_BENCH_REBUILD=1 to rebuild Rustwright release wheel for Rustwright/all runs. bench-full Host wrapper mode only: one capped Docker container per benchmark implementation. mind2web Imported Mind2Web quality benchmark run; set MIND2WEB_ITERATIONS to tune. - mind2web-full Host wrapper mode only: one capped Docker container per Mind2Web implementation. webvoyager Imported WebVoyager reliability benchmark run; set WEBVOYAGER_ITERATIONS to tune. - webvoyager-full Host wrapper mode only: one capped Docker container per WebVoyager implementation. antibot Run tools/run_antibot_benchmarks.py with supplied arguments. antibot-smoke Tier 0 smoke, local Tier 2 network, and fresh/warm matrix anti-bot checks. USAGE diff --git a/tools/generate_parity_map.py b/tools/generate_parity_map.py index a71f0cc..836f540 100644 --- a/tools/generate_parity_map.py +++ b/tools/generate_parity_map.py @@ -31,6 +31,7 @@ ROOT = Path(__file__).resolve().parents[1] DOC_PATH = ROOT / "docs" / "PARITY.md" CASES_PATH = ROOT / "benchmarks" / "automation_cases.py" +RUNNER_PATH = ROOT / "benchmarks" / "run_benchmarks.py" SUITE_PATH = ROOT / "tests" / "test_playwright_parity_cases.py" LIMITATIONS_PATH = ROOT / "LIMITATIONS.md" NODE_README_PATH = ROOT / "node" / "README.md" @@ -383,14 +384,13 @@ def validate_suite_uses_real_playwright_registry() -> None: isinstance(node, ast.Call) and isinstance(node.func, ast.Name) and node.func.id == "_run_parity" for node in ast.walk(tree) ) - checks_registry_length = any( - isinstance(node, ast.Call) - and isinstance(node.func, ast.Name) - and node.func.id == "len" - and any(isinstance(arg, ast.Name) and arg.id == "CASES" for arg in node.args) + constants = { + node.value for node in ast.walk(tree) - ) - if not all((imports_cases, mentions_playwright, runs_parity, checks_registry_length)): + if isinstance(node, ast.Constant) and isinstance(node.value, str) + } + uses_canonical_runner = "run_benchmarks.py" in constants and "parity" in constants + if not all((imports_cases, mentions_playwright, runs_parity, uses_canonical_runner)): raise RuntimeError("the shared parity test no longer proves that real Playwright runs the CASES registry") @@ -906,7 +906,8 @@ def render( source_digest = hashlib.sha256( b"\0".join( - path.read_bytes() for path in (CASES_PATH, SUITE_PATH, LIMITATIONS_PATH, NODE_README_PATH) + path.read_bytes() + for path in (CASES_PATH, RUNNER_PATH, SUITE_PATH, LIMITATIONS_PATH, NODE_README_PATH) ) ).hexdigest()[:16] lines.extend( @@ -925,9 +926,9 @@ def render( "- Method totals count methods, not properties. Properties remain in the detailed tables because " "they are part of the usable API and because their return annotations make chained receiver " "types resolvable.", - f"- The exercised state starts only from the {len(cases)} functions actually present in the `CASES` " - "registry. The generator verifies that `tests/test_playwright_parity_cases.py` imports that registry, " - "runs `_run_parity` for a `playwright` parameter, and checks `len(CASES)`.", + f"- The exercised state starts only from the {len(cases)} functions in the `CASES` registry. " + "The generator verifies that the parity test imports that registry and runs the canonical " + "`benchmarks/run_benchmarks.py --suite parity` entry point for both backends.", "- Exercise detection is conservative static analysis. It propagates `Page` and `Playwright` case " "parameters through assignments, reference return annotations, property chains, collection indexing, " "local helper calls, callback annotations, and literal event names. A member becomes green only when " @@ -940,7 +941,7 @@ def render( "case against real Playwright and Rustwright. It does not prove all options, errors, events, browser " "engines, or edge cases match.", "- The limitations and Node.js sections are parsed from `LIMITATIONS.md` and `node/README.md` on each run.", - f"- Source digest (case registry, parity test, limitations, Node README): `{source_digest}`.", + f"- Source digest (case registry, canonical runner, parity test, limitations, Node README): `{source_digest}`.", "", ] ) diff --git a/tools/query_project_state.py b/tools/query_project_state.py deleted file mode 100755 index 62f4510..0000000 --- a/tools/query_project_state.py +++ /dev/null @@ -1,1046 +0,0 @@ -#!/usr/bin/env python3 -from __future__ import annotations - -import argparse -import json -from pathlib import Path -from typing import Any, Iterable - -import check_launch_latency_claim -import check_phase2_benchmark - - -ROOT = Path(__file__).resolve().parents[1] -SOURCES = { - "progress": ROOT / "docs" / "internal" / "PROGRESS.json", - "requirements": ROOT / "docs" / "internal" / "REQUIREMENTS_STATUS.json", -} - - -def load_sources(names: Iterable[str]) -> dict[str, Any]: - selected = SOURCES.keys() if "all" in names else names - return {name: json.loads(SOURCES[name].read_text(encoding="utf-8")) for name in selected} - - -def lookup_path(value: Any, path: str) -> Any: - current = value - for part in path.split("."): - if isinstance(current, list): - current = current[int(part)] - elif isinstance(current, dict): - current = current[part] - else: - raise KeyError(path) - return current - - -def walk(value: Any, prefix: str = "") -> Iterable[tuple[str, Any]]: - if isinstance(value, dict): - for key, child in value.items(): - child_path = f"{prefix}.{key}" if prefix else str(key) - yield from walk(child, child_path) - elif isinstance(value, list): - for index, child in enumerate(value): - child_path = f"{prefix}.{index}" if prefix else str(index) - yield from walk(child, child_path) - else: - yield prefix, value - - -def print_json(value: Any) -> None: - print(json.dumps(value, indent=2, sort_keys=True)) - - -def shorten(value: Any, limit: int = 360) -> Any: - if not isinstance(value, str) or len(value) <= limit: - return value - return value[: limit - 3].rstrip() + "..." - - -def compact_string_list(values: Any, *, limit: int = 220) -> Any: - if not isinstance(values, list): - return values - return [shorten(value, limit) for value in values] - - -def configured_int(value: Any, default: int) -> int: - try: - return int(value) - except (TypeError, ValueError): - return default - - -def phase2_checker_args(criteria: dict[str, Any], path: Path) -> argparse.Namespace: - return argparse.Namespace( - json_path=path, - suite=criteria.get("suite", "strict"), - lifecycle=criteria.get("lifecycle", "warm-browser"), - reference_impl=criteria.get("reference_impl", "playwright"), - min_repetitions=configured_int(criteria.get("min_repetitions"), 3), - min_iterations=configured_int(criteria.get("min_iterations"), 1), - min_case_count=configured_int( - criteria.get("min_case_count"), check_phase2_benchmark.current_strict_case_count() - ), - min_speedup_pct=float(criteria.get("min_speedup_pct", 30.0)), - max_variance_ratio=float( - criteria.get("max_variance_ratio", check_phase2_benchmark.DEFAULT_MAX_VARIANCE_RATIO) - ), - ) - - -def latest_github_hosted_strict_diagnostic(progress: dict[str, Any]) -> dict[str, Any] | None: - latest = progress.get("metrics", {}).get("latest_github_hosted_full_strict_diagnostic") - if not isinstance(latest, dict) or not latest.get("result_artifact"): - return None - return latest - - -def recorded_phase2_report( - latest: dict[str, Any], - criteria: dict[str, Any], - *, - result_path: str, -) -> dict[str, Any] | None: - phase2 = latest.get("phase2_report") - benchmark = latest.get("benchmark") or {} - if not isinstance(phase2, dict): - return None - variance = benchmark.get("variance_ratio") or {} - failed_checks = [ - {"name": str(name), "passed": False, "detail": "recorded failed check"} - for name in phase2.get("failed_checks", []) - ] - repetitions = benchmark.get("repetitions") - case_count = benchmark.get("case_count") or check_phase2_benchmark.current_strict_case_count() - observed = { - "iterations": benchmark.get("iterations"), - "lifecycle": benchmark.get("lifecycle"), - "non_passed_runs": [], - "reference_case_count": phase2.get("reference_case_count") or case_count, - "reference_passed_repetitions": phase2.get("reference_passed_repetitions") or repetitions, - "reference_total_median_ms": phase2.get("reference_total_median_ms"), - "reference_variance_ratio": phase2.get("reference_variance_ratio") or variance.get("playwright"), - "repetitions": repetitions, - "rustwright_case_count": phase2.get("rustwright_case_count") or case_count, - "rustwright_passed_repetitions": phase2.get("rustwright_passed_repetitions") or repetitions, - "rustwright_reduction_pct": phase2.get("rustwright_reduction_pct"), - "rustwright_total_median_ms": phase2.get("rustwright_total_median_ms"), - "rustwright_variance_ratio": phase2.get("rustwright_variance_ratio") or variance.get("rustwright"), - "suite": benchmark.get("suite"), - } - accepted = bool(phase2.get("accepted")) - checks = [ - { - "name": "all_runs_passed", - "passed": not observed["non_passed_runs"], - "detail": "recorded Phase 2 report has no non-passed runs", - }, - { - "name": "speedup", - "passed": accepted and not any(item["name"] == "speedup" for item in failed_checks), - "detail": f"recorded Rustwright reduction percent {observed['rustwright_reduction_pct']}", - }, - *failed_checks, - ] - return { - "status": "accepted" if accepted else "rejected", - "accepted": accepted, - "result_path": result_path, - "checks": checks, - "failed_checks": failed_checks, - "observed": observed, - "criteria": criteria, - "source": "recorded_progress_metadata", - } - - -def github_hosted_diagnostic_evidence(latest: dict[str, Any]) -> dict[str, Any] | None: - benchmark = latest.get("benchmark") or {} - phase2 = latest.get("phase2_report") or {} - docker = latest.get("docker") or {} - totals = benchmark.get("total_median_ms") or {} - variance = benchmark.get("variance_ratio") or {} - implementations = benchmark.get("implementations") or ["rustwright", "playwright"] - repetitions = int(benchmark.get("repetitions") or 0) - runs_total = repetitions * len(implementations) - - if not totals or not variance: - return None - - return { - "source": "github-actions-recorded", - "artifact": "rustwright-benchmark-results", - "result_path": latest.get("result_artifact"), - "run_url": latest.get("run_url"), - "benchmark": { - "suite": benchmark.get("suite"), - "lifecycle": benchmark.get("lifecycle"), - "iterations": benchmark.get("iterations"), - "repetitions": repetitions, - "case": benchmark.get("case"), - "case_count": phase2.get("rustwright_case_count") - or benchmark.get("case_count") - or check_phase2_benchmark.current_strict_case_count(), - "implementations": implementations, - "runs_passed": runs_total, - "runs_total": runs_total, - "total_median_ms": totals, - # The recorded Phase 2 report stores p75/p25 ratios, not raw p25/p75. - # These normalized values preserve the variance guard semantics for - # token-light summaries when the ignored benchmark artifact is absent. - "total_p25_ms": {"playwright": 1.0, "rustwright": 1.0}, - "total_p75_ms": { - "playwright": variance.get("playwright"), - "rustwright": variance.get("rustwright"), - }, - "result_path": latest.get("result_artifact"), - }, - "environment": { - "source": "github-actions-recorded", - "source_type": "github-actions-recorded", - "runner": latest.get("runner_label"), - "container_isolation": latest.get("container_isolation"), - "docker_memory_limit": docker.get("memory_limit"), - "docker_memory_swap_limit": docker.get("memory_swap_limit"), - "docker_image": docker.get("image"), - "docker_image_id": docker.get("image_id"), - "docker_cpu_host_info": docker.get("cpu_host_info"), - "docker_cpu_quota": docker.get("cpu_quota"), - "git_rev": latest.get("commit"), - }, - } - - -def phase_2_benchmark_acceptance(progress: dict[str, Any]) -> dict[str, Any]: - configured = progress.get("benchmarks", {}).get("phase_2_benchmark_acceptance_checker") or {} - criteria = configured.get("default_criteria") or {} - hosted = latest_github_hosted_strict_diagnostic(progress) - if hosted: - result_path = hosted["result_artifact"] - path = ROOT / result_path - if path.exists(): - args = phase2_checker_args(criteria, path) - report = check_phase2_benchmark.evaluate(check_phase2_benchmark.load_json(path), args) - failed_checks = [item for item in report["checks"] if not item["passed"]] - return { - "status": report["status"], - "accepted": report["accepted"], - "result_path": result_path, - "checks": report["checks"], - "failed_checks": failed_checks, - "observed": report["observed"], - "criteria": report["criteria"], - "source": "github_actions_artifact", - } - recorded = recorded_phase2_report(hosted, criteria, result_path=result_path) - if recorded is not None: - return recorded - - latest = ( - progress.get("benchmarks", {}).get("latest_current_tree_strict_repeated_attempt") - or progress.get("benchmarks", {}).get("latest_current_tree_strict_api_smoke") - or progress.get("benchmarks", {}).get("latest_strict_api_benchmark_smoke") - ) - result_path = (latest or {}).get("result_path") - if not result_path: - return {"status": "missing", "accepted": False, "reason": "no strict benchmark result path recorded"} - - path = ROOT / result_path - if not path.exists(): - recorded = (latest or {}).get("acceptance_checker") or {} - if recorded.get("status") == "accepted": - speedup = ((latest or {}).get("speedup") or {}).get( - "vs_python_playwright_reference_lower_total_median_percent" - ) - totals = (latest or {}).get("total_median_ms") or {} - return { - "status": "accepted", - "accepted": True, - "result_path": result_path, - "checks": [ - { - "name": "all_runs_passed", - "passed": True, - "detail": "recorded accepted benchmark evidence; result JSON is intentionally ignored", - }, - { - "name": "speedup", - "passed": True, - "detail": f"recorded Rustwright reduction percent {speedup}", - }, - ], - "failed_checks": [], - "observed": { - "iterations": (latest or {}).get("iterations"), - "lifecycle": (latest or {}).get("lifecycle"), - "non_passed_runs": [], - "reference_case_count": (latest or {}).get("sample_count"), - "reference_passed_repetitions": ((latest or {}).get("passed_repetitions") or {}).get( - "python_playwright_reference" - ), - "reference_total_median_ms": totals.get("python_playwright_reference"), - "repetitions": (latest or {}).get("repetitions"), - "rustwright_case_count": (latest or {}).get("sample_count"), - "rustwright_passed_repetitions": ((latest or {}).get("passed_repetitions") or {}).get("rustwright"), - "rustwright_reduction_pct": speedup, - "rustwright_total_median_ms": totals.get("rustwright"), - "suite": (latest or {}).get("suite"), - }, - "criteria": criteria, - "source": "recorded_progress_metadata", - } - return {"status": "missing", "accepted": False, "result_path": result_path, "reason": "result file missing"} - - args = phase2_checker_args(criteria, path) - report = check_phase2_benchmark.evaluate(check_phase2_benchmark.load_json(path), args) - failed_checks = [item for item in report["checks"] if not item["passed"]] - return { - "status": report["status"], - "accepted": report["accepted"], - "result_path": result_path, - "failed_checks": failed_checks, - "observed": report["observed"], - "criteria": report["criteria"], - } - - -def launch_latency_claim(progress: dict[str, Any]) -> dict[str, Any]: - hosted = latest_github_hosted_strict_diagnostic(progress) - if hosted: - result_path = hosted["result_artifact"] - path = ROOT / result_path - if path.exists(): - evidence = check_launch_latency_claim.matrix_json_to_evidence( - check_launch_latency_claim.load_json(path), - source="github-actions", - runner=hosted.get("runner_label"), - artifact="rustwright-benchmark-results", - run_url=hosted.get("run_url"), - ) - else: - evidence = github_hosted_diagnostic_evidence(hosted) - if evidence is not None: - return check_launch_latency_claim.evaluate_evidence( - evidence, - evidence_path="metrics.latest_github_hosted_full_strict_diagnostic", - min_repetitions=3, - min_iterations=1, - min_case_count=check_phase2_benchmark.current_strict_case_count(), - min_speedup_pct=30.0, - max_variance_ratio=check_launch_latency_claim.DEFAULT_MAX_VARIANCE_RATIO, - reference_impl="playwright", - ) - - return check_launch_latency_claim.evaluate( - progress, - evidence_path="metrics.latest_blacksmith_benchmark_workflow", - min_repetitions=3, - min_iterations=1, - min_case_count=check_phase2_benchmark.current_strict_case_count(), - min_speedup_pct=30.0, - max_variance_ratio=check_launch_latency_claim.DEFAULT_MAX_VARIANCE_RATIO, - reference_impl="playwright", - ) - - -def compact_phase_plan(plan: dict[str, Any]) -> dict[str, Any]: - return { - name: { - "name": phase.get("name"), - "status": phase.get("status"), - "focus": phase.get("focus"), - "completion_criteria_count": len(phase.get("completion_criteria") or []), - "coverage_backlog_count": len(phase.get("coverage_backlog") or []), - "primary_target": phase.get("primary_target"), - } - for name, phase in plan.items() - if isinstance(phase, dict) - } - - -def compact_phase_1_ledger(ledger: dict[str, Any]) -> dict[str, Any]: - rows = ledger.get("rows") or [] - status_counts: dict[str, int] = {} - remaining: list[dict[str, Any]] = [] - for row in rows: - status = str(row.get("status") or "unknown") - status_counts[status] = status_counts.get(status, 0) + 1 - if status != "docker_verified": - remaining.append( - { - "id": row.get("id"), - "coverage_point": row.get("coverage_point"), - "status": status, - "remaining": row.get("remaining"), - } - ) - return { - "status": ledger.get("status"), - "row_count": len(rows), - "status_counts": status_counts, - "remaining_rows": remaining, - } - - -def compact_gate(gate: dict[str, Any] | None) -> dict[str, Any] | None: - if not isinstance(gate, dict): - return gate - current = gate.get("current_configured_selection") or {} - latest = current.get("latest_docker_attempt") or {} - return { - "command": gate.get("command") or latest.get("command"), - "status": latest.get("status") or current.get("docker_status") or gate.get("status"), - "memory_cap": gate.get("memory_cap"), - "pytest": latest.get("pytest") or gate.get("pytest"), - "rustwright_parity_sample": latest.get("rustwright_parity_sample") or gate.get("rustwright_parity_sample"), - "playwright_reference_parity_sample": latest.get("playwright_reference_parity_sample") - or gate.get("playwright_parity_sample"), - "phase_1_gate_checker": latest.get("phase_1_gate_checker"), - } - - -def compact_phase1_gate_update(update: dict[str, Any] | None) -> dict[str, Any] | None: - if not isinstance(update, dict): - return update - docker = update.get("docker_execution") if isinstance(update.get("docker_execution"), dict) else {} - local = update.get("local_validation") if isinstance(update.get("local_validation"), dict) else {} - return { - "date": update.get("date"), - "status": update.get("status"), - "change_summary": shorten(update.get("change_summary"), 240), - "local_validation": { - key: shorten(local.get(key), 260 if key == "remote_wrapper_fast_auth_classification" else 160) - for key in ( - "phase1_gate_checker", - "pytest", - "py_compile", - "shell_syntax", - "remote_wrapper_fast_auth_classification", - ) - if local.get(key) is not None - }, - "docker_execution": { - key: shorten(docker.get(key), 220) - for key in ( - "local_docker_attempt", - "mac_mini_attempt", - "mac_mini_auth_url", - "next_required_evidence", - ) - if docker.get(key) is not None - }, - } - - -def compact_benchmark(benchmark: dict[str, Any] | None) -> dict[str, Any] | None: - if not isinstance(benchmark, dict): - return benchmark - return { - "status": benchmark.get("status"), - "command": benchmark.get("command"), - "result_path": benchmark.get("result_path") or benchmark.get("latest_result_path"), - "suite": benchmark.get("suite"), - "lifecycle": benchmark.get("lifecycle"), - "iterations": benchmark.get("iterations"), - "repetitions": benchmark.get("repetitions"), - "sample_count": benchmark.get("sample_count"), - "container_isolation": benchmark.get("container_isolation"), - "total_median_ms": benchmark.get("total_median_ms") or benchmark.get("latest_total_median_ms"), - "speedup": benchmark.get("speedup"), - "measured_failures": benchmark.get("measured_failures"), - } - - -def compact_lifecycle_smokes(smokes: dict[str, Any] | None) -> dict[str, Any] | None: - if not isinstance(smokes, dict): - return smokes - compact: dict[str, Any] = {} - for name, smoke in smokes.items(): - if not isinstance(smoke, dict): - compact[name] = smoke - continue - compact[name] = { - key: smoke.get(key) - for key in ( - "status", - "rustwright_median_ms", - "python_playwright_reference_median_ms", - "typescript_playwright_reference_mean_ms", - ) - if smoke.get(key) is not None - } - return compact - - -def compact_performance_plan(plan: dict[str, Any] | None) -> dict[str, Any] | None: - if not isinstance(plan, dict): - return plan - hotspots = plan.get("first_tranche_hotspots") or [] - pipe_probe = plan.get("latest_pipe_transport_probe") or {} - request_probe = plan.get("latest_default_request_history_probe") or {} - request_fetch_probe = plan.get("latest_request_history_fetch_completion_probe") or {} - nodelay_probe = plan.get("latest_websocket_nodelay_probe") or {} - nodelay_full = nodelay_probe.get("macmini_full_strict") if isinstance(nodelay_probe, dict) else {} - return { - "status": plan.get("status"), - "baseline_result_path": plan.get("baseline_result_path"), - "latest_result_path": plan.get("latest_result_path"), - "latest_total_median_ms": plan.get("latest_total_median_ms"), - "latest_macmini_hotspot_confirmation": plan.get("latest_macmini_hotspot_confirmation"), - "latest_websocket_nodelay_probe": { - "status": nodelay_probe.get("status"), - "full_lower_pct": ( - (nodelay_full.get("speedup") or {}).get("phase2_metric_rustwright_lower_percent") - if isinstance(nodelay_full, dict) - else None - ), - } - if isinstance(nodelay_probe, dict) - else nodelay_probe, - "latest_pipe_transport_probe": { - "status": pipe_probe.get("status"), - "transport": pipe_probe.get("transport"), - "decision": pipe_probe.get("decision"), - "macmini_response_slice": pipe_probe.get("macmini_response_slice"), - "macmini_hotspot_slice": pipe_probe.get("macmini_hotspot_slice"), - } - if isinstance(pipe_probe, dict) - else pipe_probe, - "latest_default_request_history_probe": { - "status": request_probe.get("status"), - "macmini_focused_request_history": request_probe.get("macmini_focused_request_history"), - "macmini_hotspot_followup": request_probe.get("macmini_hotspot_followup"), - "next_tranche": request_probe.get("next_tranche"), - } - if isinstance(request_probe, dict) - else request_probe, - "latest_request_history_fetch_completion_probe": { - "status": request_fetch_probe.get("status"), - "macmini_hotspot": request_fetch_probe.get("macmini_hotspot"), - "macmini_full_strict": request_fetch_probe.get("macmini_full_strict"), - } - if isinstance(request_fetch_probe, dict) - else request_fetch_probe, - "targets": plan.get("targets"), - "top_hotspots": [ - { - "case": item.get("case"), - "status": item.get("status"), - "latest_delta_ms": item.get("latest_delta_ms"), - "planned_optimization": item.get("planned_optimization"), - } - for item in hotspots[:5] - ], - } - - -def compact_dependency_audit(audit: dict[str, Any] | None) -> dict[str, Any] | None: - if not isinstance(audit, dict): - return audit - latest = audit.get("latest_machine_audit") or audit.get("latest_result") or audit.get("latest_audit") or {} - latest_smoke = audit.get("latest_replacement_smoke") if isinstance(audit.get("latest_replacement_smoke"), dict) else {} - latest_overlay = audit.get("latest_overlay_pytest") if isinstance(audit.get("latest_overlay_pytest"), dict) else {} - latest_alias_command = ( - audit.get("latest_alias_command_smoke") if isinstance(audit.get("latest_alias_command_smoke"), dict) else {} - ) - latest_prompt_overlay = ( - audit.get("latest_prompt_overlay_smoke") if isinstance(audit.get("latest_prompt_overlay_smoke"), dict) else {} - ) - alias_coverage = latest.get("alias_symbol_coverage") or {} - method_coverage = latest.get("method_name_coverage") or {} - typed_coverage = latest.get("typed_method_coverage") or {} - child_statuses = [ - alias_coverage.get("status"), - method_coverage.get("status"), - typed_coverage.get("status"), - ] - derived_status = audit.get("status") or latest.get("status") - if derived_status is None and all(status == "ok" for status in child_statuses): - derived_status = "ok" - elif derived_status is None: - derived_status = next((status for status in child_statuses if status), None) - return { - "status": derived_status, - "audit_date": audit.get("audit_date") or latest.get("audit_date"), - "source_path": audit.get("source_path"), - "mode": audit.get("mode"), - "output_path": latest.get("output_path"), - "python_files_scanned": latest.get("python_files_scanned") - or (latest.get("scan_scope") or {}).get("python_files_scanned"), - "text_files_scanned": latest.get("text_files_scanned") or (latest.get("scan_scope") or {}).get("text_files_scanned"), - "alias_symbol_coverage": { - key: alias_coverage.get(key) - for key in ("status", "missing_total", "import_error_total") - if alias_coverage.get(key) is not None - }, - "method_name_coverage": { - key: method_coverage.get(key) - for key in ("status", "missing_total", "import_error_total", "method_count") - if method_coverage.get(key) is not None - }, - "typed_method_coverage": { - key: typed_coverage.get(key) - for key in ( - "status", - "missing_total", - "import_error_total", - "typed_call_count", - "receiver_method_count", - ) - if typed_coverage.get(key) is not None - }, - "top_cdp_methods": (latest.get("top_cdp_methods") or [])[:8], - "requirement_area_counts": latest.get("requirement_area_counts"), - "latest_replacement_smoke": { - key: shorten(latest_smoke.get(key), 260) - for key in ( - "date", - "status", - "output_path", - "alias_failures", - "audit_status", - "failure_policy", - "skyvern_module_import_mode", - "skyvern_modules_imported", - "skyvern_modules_failed", - "skyvern_module_import_warning", - ) - if latest_smoke.get(key) is not None - }, - "latest_overlay_pytest": { - key: shorten(latest_overlay.get(key), 260) - for key in ( - "status", - "alias_preflight_status", - "alias_modules_checked", - "target_count", - "tests_passed", - ) - if latest_overlay.get(key) is not None - } - | { - "default_conftest": { - key: shorten((latest_overlay.get("default_conftest") or {}).get(key), 260) - for key in ( - "status", - "classification", - "missing_module", - "pytest_summary", - ) - if (latest_overlay.get("default_conftest") or {}).get(key) is not None - }, - "noconftest": { - key: shorten((latest_overlay.get("noconftest") or {}).get(key), 260) - for key in ( - "status", - "pytest_summary", - ) - if (latest_overlay.get("noconftest") or {}).get(key) is not None - }, - }, - "latest_alias_command_smoke": { - key: shorten(latest_alias_command.get(key), 260) - for key in ( - "status", - "alias_preflight_status", - "command_status", - "command_classification", - "skyvern_module", - "output_path", - ) - if latest_alias_command.get(key) is not None - } - | { - "cloud_browser_import_smoke": { - key: shorten((latest_alias_command.get("cloud_browser_import_smoke") or {}).get(key), 260) - for key in ( - "status", - "classification", - "output_path", - ) - if (latest_alias_command.get("cloud_browser_import_smoke") or {}).get(key) is not None - } - }, - "latest_prompt_overlay_smoke": { - key: shorten(latest_prompt_overlay.get(key), 260) - for key in ( - "status", - "alias_preflight_status", - "module_count", - "environment_blocked_modules", - "missing_module", - "output_path", - ) - if latest_prompt_overlay.get(key) is not None - }, - } - - -def compact_p0_status(status: dict[str, Any] | None) -> dict[str, Any] | None: - if not isinstance(status, dict): - return status - return { - key: { - "status": value.get("status"), - "current": shorten(value.get("current")), - "remaining": shorten(value.get("remaining")), - } - for key, value in status.items() - if isinstance(value, dict) - } - - -def compact_goal_completion(goal: dict[str, Any] | None) -> dict[str, Any] | None: - if not isinstance(goal, dict): - return goal - return { - "short_goal": shorten(goal.get("short_goal"), 360), - "active_goal": shorten(goal.get("active_goal"), 360), - "required_for_completion": compact_string_list(goal.get("required_for_completion"), limit=260), - "completion_boundary": shorten(goal.get("completion_boundary"), 260), - "no_hidden_third_gates": goal.get("no_hidden_third_gates"), - } - - -def compact_external_audit(audit: dict[str, Any] | None) -> dict[str, Any] | None: - if not isinstance(audit, dict): - return audit - return { - "date": audit.get("date"), - "status": audit.get("status"), - "verdict": audit.get("verdict"), - "tool": audit.get("tool"), - "artifact_path": audit.get("artifact_path"), - "summary": shorten(audit.get("summary"), 220), - "ranked_next_actions": compact_string_list((audit.get("ranked_next_actions") or [])[:2], limit=180), - } - - -def compact_phase2_acceptance(report: dict[str, Any]) -> dict[str, Any]: - compact = dict(report) - compact.pop("checks", None) - return compact - - -def compact_launch_claim(report: dict[str, Any]) -> dict[str, Any]: - observed = report.get("observed") if isinstance(report.get("observed"), dict) else {} - criteria = report.get("criteria") if isinstance(report.get("criteria"), dict) else {} - return { - "status": report.get("status"), - "accepted": report.get("accepted"), - "evidence_path": report.get("evidence_path"), - "checks": [ - { - "name": item.get("name"), - "passed": item.get("passed"), - "detail": shorten(item.get("detail"), 120), - } - for item in report.get("checks", []) - if isinstance(item, dict) - ], - "criteria": { - key: criteria.get(key) - for key in ( - "min_case_count", - "min_repetitions", - "min_iterations", - "min_speedup_pct", - "reference_impl", - ) - if criteria.get(key) is not None - }, - "observed": { - key: observed.get(key) - for key in ( - "runner", - "case_count", - "suite", - "lifecycle", - "iterations", - "repetitions", - "runs_passed", - "runs_total", - "rustwright_reduction_pct", - "result_path", - "run_url", - ) - if observed.get(key) is not None - }, - } - - -def compact_remote_wrapper(value: dict[str, Any] | None) -> dict[str, Any] | None: - if not isinstance(value, dict): - return value - latest = value.get("latest_check") if isinstance(value.get("latest_check"), dict) else {} - remote_docker = latest.get("remote_docker") if isinstance(latest.get("remote_docker"), dict) else {} - memory_checks = latest.get("memory_limit_checks") if isinstance(latest.get("memory_limit_checks"), dict) else {} - remote_pull_check = latest.get("remote_pull_check") if isinstance(latest.get("remote_pull_check"), dict) else {} - latest_phase1 = latest.get("latest_remote_phase1") if isinstance(latest.get("latest_remote_phase1"), dict) else {} - latest_strict = ( - latest.get("latest_remote_strict_benchmark") - if isinstance(latest.get("latest_remote_strict_benchmark"), dict) - else {} - ) - return { - "status": value.get("status"), - "tool": value.get("tool"), - "host": latest.get("host"), - "transport": latest.get("transport"), - "remote_docker": remote_docker, - "memory_limit_checks": memory_checks, - "remote_pull_check": { - key: remote_pull_check.get(key) - for key in ("status", "failed_check", "passed_check") - if remote_pull_check.get(key) is not None - }, - "latest_remote_phase1": { - key: latest_phase1.get(key) - for key in ( - "status", - "image", - "memory_cap_bytes", - ) - if latest_phase1.get(key) is not None - }, - "latest_remote_strict_benchmark": { - **{ - key: latest_strict.get(key) - for key in ( - "status", - "result_path", - "case_count", - "repetitions", - "iterations", - "docker_memory_limit", - "runs_passed", - "runs_total", - ) - if latest_strict.get(key) is not None - }, - "speedup_vs_python_pct": ( - (latest_strict.get("speedup") or {}).get( - "vs_python_playwright_reference_lower_total_mean_distribution_median_percent" - ) - or (latest_strict.get("speedup") or {}).get("vs_playwright_median_reduction_pct") - ), - "acceptance_status": ((latest_strict.get("acceptance_checker") or {}).get("status")), - }, - "docker_build_status": latest.get("docker_build_status"), - } - - -def compact_strict_local_profile(value: dict[str, Any] | None) -> dict[str, Any] | None: - if not isinstance(value, dict): - return value - return { - key: value.get(key) - for key in ( - "date", - "status", - "suite", - "lifecycle", - "iterations", - "case_count", - "docker", - "failures", - "remote_mac_mini", - "total_mean_ms", - "speedup", - ) - if value.get(key) is not None - } - - -def compact_testbox_path(value: dict[str, Any] | None) -> dict[str, Any] | None: - if not isinstance(value, dict): - return value - warmup = value.get("latest_warmup_attempt") if isinstance(value.get("latest_warmup_attempt"), dict) else {} - helper_update = value.get("latest_helper_update") if isinstance(value.get("latest_helper_update"), dict) else {} - visibility = ( - value.get("latest_visibility_preflight") - if isinstance(value.get("latest_visibility_preflight"), dict) - else {} - ) - skyvern_cloud = ( - value.get("latest_skyvern_cloud_testbox_attempt") - if isinstance(value.get("latest_skyvern_cloud_testbox_attempt"), dict) - else {} - ) - return { - "status": value.get("status"), - "workflow": value.get("workflow"), - "helper": value.get("helper"), - "evidence_policy": shorten(value.get("evidence_policy")), - "latest_helper_update": { - key: value - for key, value in { - "date": helper_update.get("date"), - "status": helper_update.get("status"), - "summary": "RUSTWRIGHT_TESTBOX_RUN_COMMAND wired" - if helper_update.get("summary") - else None, - }.items() - if value is not None - }, - "latest_visibility_preflight": { - key: shorten(visibility.get(key), 180) - for key in ( - "status", - "static_preflight_status", - ) - if visibility.get(key) is not None - }, - "latest_warmup_attempt": { - key: shorten(warmup.get(key), 220) - for key in ( - "date", - "status", - "command", - "blacksmith_error", - "main_push_attempt", - "pr_merge_attempt", - "workflow_fix_pr", - ) - if warmup.get(key) is not None - }, - "latest_skyvern_cloud_testbox_attempt": { - key: shorten(skyvern_cloud.get(key), 220) - for key in ( - "date", - "status", - "testbox_id", - "run_url", - "hostname", - "failure", - "interpretation", - ) - if skyvern_cloud.get(key) is not None - }, - } - - -def build_summary(data: dict[str, Any], *, full: bool = False) -> dict[str, Any]: - progress = data.get("progress", {}) - requirements = data.get("requirements", {}) - matrix = requirements.get("feature_coverage_matrix") or [] - top_rice = sorted(matrix, key=lambda item: item.get("rice", 0), reverse=True)[:5] - latest_benchmark = ( - progress.get("benchmarks", {}).get("latest_repeated_bench_full_matrix") - or progress.get("benchmarks", {}).get("latest_bench_full_baseline") - or progress.get("benchmarks", {}).get("latest_broadened_docker_suite") - or progress.get("benchmarks", {}).get("latest_broadened_local_suite") - ) - latest_strict_benchmark = progress.get("benchmarks", {}).get("latest_strict_api_benchmark_smoke") - phase_1_ledger = requirements.get("phase_1_coverage_ledger") - phase_plan = requirements.get("execution_phase_plan") - strict_plan = progress.get("benchmarks", {}).get("strict_api_performance_plan") - dependency_audit = progress.get("skyvern_cloud_dependency_audit") or requirements.get("skyvern_cloud_dependency_audit") - return { - "shared_parity_cases": progress.get("metrics", {}).get("shared_parity_cases", {}).get("value"), - "strict_api_cases": progress.get("metrics", {}).get("strict_api_cases", {}).get("value"), - "latest_sampled_docker_gate": progress.get("metrics", {}).get("latest_sampled_docker_gate") - if full - else compact_gate(progress.get("metrics", {}).get("latest_sampled_docker_gate")), - "phase_1_current_gate_update": progress.get("metrics", {}).get("latest_phase1_gate_wiring_update") - if full - else compact_phase1_gate_update(progress.get("metrics", {}).get("latest_phase1_gate_wiring_update")), - "remote_mac_mini_docker_wrapper": progress.get("metrics", {}).get("remote_mac_mini_docker_wrapper") - if full - else compact_remote_wrapper(progress.get("metrics", {}).get("remote_mac_mini_docker_wrapper")), - "testbox_benchmark_path": progress.get("metrics", {}).get("testbox_benchmark_path") - if full - else compact_testbox_path(progress.get("metrics", {}).get("testbox_benchmark_path")), - "latest_external_launch_audit": progress.get("metrics", {}).get("latest_external_launch_audit") - if full - else compact_external_audit(progress.get("metrics", {}).get("latest_external_launch_audit")), - "latest_benchmark": latest_benchmark if full else compact_benchmark(latest_benchmark), - "latest_strict_benchmark": latest_strict_benchmark if full else compact_benchmark(latest_strict_benchmark), - "latest_strict_local_profile": progress.get("benchmarks", {}).get("latest_strict_local_profile") - if full - else compact_strict_local_profile(progress.get("benchmarks", {}).get("latest_strict_local_profile")), - "latest_benchmark_harness_overhead_fix": progress.get("benchmarks", {}).get("latest_benchmark_harness_overhead_fix"), - "phase_2_benchmark_acceptance": phase_2_benchmark_acceptance(progress) - if full - else compact_phase2_acceptance(phase_2_benchmark_acceptance(progress)), - "launch_latency_claim": launch_latency_claim(progress) - if full - else compact_launch_claim(launch_latency_claim(progress)), - "strict_api_performance_plan": strict_plan if full else compact_performance_plan(strict_plan), - "skyvern_cloud_dependency_audit": dependency_audit if full else compact_dependency_audit(dependency_audit), - "benchmark_lifecycle_smokes": progress.get("benchmarks", {}).get("latest_lifecycle_smokes") - if full - else compact_lifecycle_smokes(progress.get("benchmarks", {}).get("latest_lifecycle_smokes")), - "top_next_work": progress.get("top_next_work") if full else compact_string_list(progress.get("top_next_work")), - "top_rice_features": [ - { - "id": item.get("id"), - "feature_area": item.get("feature_area"), - "rice": item.get("rice"), - "chromium_sync": item.get("chromium_sync"), - "firefox": item.get("firefox"), - "webkit": item.get("webkit"), - } - for item in top_rice - ], - "goal_completion_definition": requirements.get("goal_completion_definition") - if full - else compact_goal_completion(requirements.get("goal_completion_definition")), - "execution_phase_plan": phase_plan if full else compact_phase_plan(phase_plan or {}), - "phase_1_coverage_ledger": phase_1_ledger if full else compact_phase_1_ledger(phase_1_ledger or {}), - "p0_status": requirements.get("p0_status") if full else compact_p0_status(requirements.get("p0_status")), - "known_major_gaps": requirements.get("known_major_gaps") - if full - else compact_string_list(requirements.get("known_major_gaps")), - } - -def main() -> int: - parser = argparse.ArgumentParser(description="Query compact project status JSON companions.") - parser.add_argument( - "--source", - choices=["progress", "requirements", "all"], - action="append", - default=None, - help="JSON source to query. Defaults to all.", - ) - parser.add_argument("--path", help="Dot path to print, for example progress.metrics.shared_parity_cases.value") - parser.add_argument("--grep", help="Case-insensitive search across leaf paths and values.") - parser.add_argument("--list-paths", action="store_true", help="List leaf paths for the selected source(s).") - parser.add_argument("--summary", action="store_true", help="Print a compact summary. This is the default action.") - parser.add_argument("--full-summary", action="store_true", help="Print the older verbose summary shape.") - args = parser.parse_args() - - requested_sources = args.source or ["all"] - source_names = ["all"] if "all" in requested_sources else requested_sources - data = load_sources(source_names) - - if args.path: - root, _, subpath = args.path.partition(".") - if root not in data or not subpath: - raise SystemExit(f"--path must start with one of: {', '.join(data)}") - print_json(lookup_path(data[root], subpath)) - return 0 - - if args.grep: - needle = args.grep.lower() - matches: dict[str, Any] = {} - for source, value in data.items(): - for path, leaf in walk(value, source): - text = f"{path} {leaf}".lower() - if needle in text: - matches[path] = leaf - print_json(matches) - return 0 - - if args.list_paths: - for source, value in data.items(): - for path, _ in walk(value, source): - print(path) - return 0 - - print_json(build_summary(data, full=args.full_summary)) - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/tools/render_project_tables.py b/tools/render_project_tables.py deleted file mode 100755 index f572e3e..0000000 --- a/tools/render_project_tables.py +++ /dev/null @@ -1,286 +0,0 @@ -#!/usr/bin/env python3 -from __future__ import annotations - -import argparse -import json -from pathlib import Path -from typing import Any - -import query_project_state - - -ROOT = Path(__file__).resolve().parents[1] -INTERNAL_DOCS = ROOT / "docs" / "internal" -REFERENCE_IMPLEMENTATIONS = ( - ("python_playwright_reference", "Python Playwright reference", "vs_python_playwright_reference"), - ("typescript_playwright_reference", "TypeScript Playwright", "vs_typescript_playwright_reference"), - ("typescript_puppeteer_reference", "TypeScript Puppeteer", "vs_typescript_puppeteer_reference"), -) - - -def ms(value: float | None) -> str: - return "not measured" if value is None else f"{value:.2f}" - - -def comparison(percent: float | None) -> str: - if percent is None: - return "not measured" - if percent >= 0: - return f"Rustwright lower by {percent:.1f}%" - return f"Rustwright higher by {abs(percent):.1f}%" - - -def comparison_for(speedup: dict[str, Any], prefix: str, metric: str) -> str: - return comparison(speedup.get(f"{prefix}_lower_{metric}_percent")) - - -def load_json(name: str) -> Any: - return json.loads((INTERNAL_DOCS / name).read_text(encoding="utf-8")) - - -def markdown_table(headers: list[str], rows: list[list[Any]]) -> str: - def cell(value: Any) -> str: - text = "" if value is None else str(value) - return text.replace("|", "\\|") - - lines = [ - "| " + " | ".join(headers) + " |", - "| " + " | ".join("---" for _ in headers) + " |", - ] - for row in rows: - lines.append("| " + " | ".join(cell(value) for value in row) + " |") - return "\n".join(lines) - - -def requirements_matrix() -> str: - data = load_json("REQUIREMENTS_STATUS.json") - rows = [] - for item in sorted(data["feature_coverage_matrix"], key=lambda row: row["rice"], reverse=True): - rows.append( - [ - item["feature_area"], - item["usage_frequency"], - item["chromium_sync"], - item["chromium_async"], - item["firefox"], - item["webkit"], - item["rice"], - ] - ) - return markdown_table( - ["Feature area", "Frequency", "Chromium sync", "Chromium async", "Firefox", "WebKit", "RICE"], - rows, - ) - - -def benchmark_table() -> str: - progress = load_json("PROGRESS.json") - latest = ( - progress["benchmarks"].get("latest_repeated_bench_full_matrix") - or progress["benchmarks"].get("latest_bench_full_baseline") - or progress["benchmarks"].get("latest_broadened_docker_suite") - or progress["benchmarks"]["latest_broadened_local_suite"] - ) - - if "total_median_ms" in latest: - medians = latest["total_median_ms"] - p25s = latest.get("total_p25_ms", {}) - p75s = latest.get("total_p75_ms", {}) - speedup = latest["speedup"] - - rows = [ - [ - "Rustwright", - latest["repetitions"], - latest["iterations"], - ms(medians.get("rustwright")), - ms(p25s.get("rustwright")), - ms(p75s.get("rustwright")), - "baseline", - ], - ] - always_show = {"python_playwright_reference", "typescript_playwright_reference"} - for key, label, speedup_prefix in REFERENCE_IMPLEMENTATIONS: - if key not in medians and key not in always_show: - continue - rows.append( - [ - label, - latest["repetitions"], - latest["iterations"], - ms(medians.get(key)), - ms(p25s.get(key)), - ms(p75s.get(key)), - comparison_for(speedup, speedup_prefix, "total_median"), - ] - ) - result_table = markdown_table( - ["Implementation", "Repetitions", "Iterations", "Median ms", "p25 ms", "p75 ms", "Comparison"], - rows, - ) - condition_rows = [ - ["Suite", latest.get("suite", "not recorded")], - ["Lifecycle", latest.get("lifecycle", "not recorded")], - ["Container isolation", latest.get("container_isolation", "not recorded")], - ["Resource model", latest.get("resource_model", "not recorded")], - ["Failure policy", latest.get("failure_policy", "not recorded")], - ["Measured failures", ", ".join(latest.get("measured_failures") or []) or "none recorded"], - ["Result path", latest.get("result_path", "not recorded")], - ] - return result_table + "\n\n" + markdown_table(["Condition", "Value"], condition_rows) - - latest = ( - progress["benchmarks"].get("latest_bench_full_baseline") - or progress["benchmarks"].get("latest_broadened_docker_suite") - or progress["benchmarks"]["latest_broadened_local_suite"] - ) - means = latest["total_mean_ms"] - speedup = latest["speedup"] - - rows = [ - ["Rustwright", latest["sample_count"], latest["iterations"], ms(means["rustwright"]), "baseline"], - [ - "Python Playwright reference", - latest["sample_count"], - latest["iterations"], - ms(means["python_playwright_reference"]), - comparison_for(speedup, "vs_python_playwright_reference", "total_mean"), - ], - ] - for key, label, speedup_prefix in REFERENCE_IMPLEMENTATIONS[1:3]: - if key not in means: - if key == "typescript_playwright_reference": - rows.append( - [ - label, - "n/a", - "n/a", - "not measured", - str(speedup.get(key, "not measured")).replace("_", " "), - ] - ) - continue - rows.append( - [ - label, - latest["sample_count"], - latest["iterations"], - ms(means[key]), - comparison_for(speedup, speedup_prefix, "total_mean"), - ] - ) - result_table = markdown_table( - ["Implementation", "Samples", "Iterations", "Total mean ms", "Comparison"], - rows, - ) - condition_rows = [ - ["Container isolation", latest.get("container_isolation", "not recorded")], - ["Resource model", latest.get("resource_model", "not recorded")], - ["Failure policy", latest.get("failure_policy", "not recorded")], - ["Measured failures", ", ".join(latest.get("measured_failures") or []) or "none recorded"], - ] - return result_table + "\n\n" + markdown_table(["Condition", "Value"], condition_rows) - - -def phase_2_acceptance_table() -> str: - progress = load_json("PROGRESS.json") - report = query_project_state.phase_2_benchmark_acceptance(progress) - observed = report["observed"] - summary_rows = [ - ["Status", report["status"]], - ["Accepted", report["accepted"]], - ["Result path", report.get("result_path")], - ["Source", report.get("source")], - ["Rustwright median ms", observed.get("rustwright_total_median_ms")], - ["Reference median ms", observed.get("reference_total_median_ms")], - ["Rustwright reduction percent", observed.get("rustwright_reduction_pct")], - ["Non-passed runs", len(observed.get("non_passed_runs") or [])], - ] - check_rows = [[item["name"], item["passed"], item["detail"]] for item in report.get("checks", [])] - return markdown_table(["Field", "Value"], summary_rows) + "\n\n" + markdown_table(["Check", "Passed", "Detail"], check_rows) - - -def launch_latency_claim_table() -> str: - progress = load_json("PROGRESS.json") - report = query_project_state.launch_latency_claim(progress) - observed = report["observed"] - summary_rows = [ - ["Status", report["status"]], - ["Accepted", report["accepted"]], - ["Evidence path", report["evidence_path"]], - ["Runner", observed.get("runner")], - ["Suite", observed.get("suite")], - ["Case", observed.get("case")], - ["Case count", observed.get("case_count")], - ["Repetitions", observed.get("repetitions")], - ["Iterations", observed.get("iterations")], - ["Rustwright reduction percent", observed.get("rustwright_reduction_pct")], - ["Result path", observed.get("result_path")], - ["Run URL", observed.get("run_url")], - ] - check_rows = [[item["name"], item["passed"], item["detail"]] for item in report["checks"]] - return markdown_table(["Field", "Value"], summary_rows) + "\n\n" + markdown_table(["Check", "Passed", "Detail"], check_rows) - - -def sampled_gate_table() -> str: - progress = load_json("PROGRESS.json") - gate = progress["metrics"]["latest_sampled_docker_gate"] - rows = [ - ["pytest", gate["pytest"]["passed"], "", gate["pytest"]["deselected"]], - ["Rustwright parity sample", gate["rustwright_parity_sample"]["passed"], gate["rustwright_parity_sample"]["total"], ""], - ["Playwright parity sample", gate["playwright_parity_sample"]["passed"], gate["playwright_parity_sample"]["total"], ""], - ] - return markdown_table(["Gate", "Passed", "Total", "Deselected"], rows) - - -def phase_1_coverage_table() -> str: - requirements = load_json("REQUIREMENTS_STATUS.json") - ledger = requirements["phase_1_coverage_ledger"] - rows = [] - for item in ledger["rows"]: - rows.append( - [ - item["coverage_point"], - item["status"], - item["evidence_type"], - item["latest_result"], - item["remaining"], - ] - ) - return markdown_table(["Coverage point", "Status", "Evidence", "Latest result", "Remaining"], rows) - - -def main() -> int: - parser = argparse.ArgumentParser(description="Render project status JSON as Markdown tables.") - parser.add_argument( - "--table", - choices=["all", "requirements", "benchmarks", "launch", "sampled", "phase1"], - default="all", - help="Table to render. Defaults to all.", - ) - args = parser.parse_args() - - sections: list[tuple[str, str]] = [] - if args.table in {"all", "requirements"}: - sections.append(("Feature Coverage And RICE", requirements_matrix())) - if args.table in {"all", "benchmarks"}: - sections.append(("Benchmark Snapshot", benchmark_table())) - sections.append(("Phase 2 Benchmark Acceptance", phase_2_acceptance_table())) - if args.table in {"all", "benchmarks", "launch"}: - sections.append(("Launch Latency Claim", launch_latency_claim_table())) - if args.table in {"all", "sampled"}: - sections.append(("Latest Docker Sampled Gate", sampled_gate_table())) - if args.table in {"all", "phase1"}: - sections.append(("Phase 1 Coverage Ledger", phase_1_coverage_table())) - - for index, (title, table) in enumerate(sections): - if index: - print() - print(f"## {title}") - print() - print(table) - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/tools/run_benchmark_matrix.py b/tools/run_benchmark_matrix.py index c07cfac..83e25b0 100644 --- a/tools/run_benchmark_matrix.py +++ b/tools/run_benchmark_matrix.py @@ -85,7 +85,25 @@ def run_impl(args: argparse.Namespace, implementation: str) -> dict[str, Any]: result["failure_kind"] = "docker_daemon_error" return result result = extract_json(combined) - result["status"] = "passed" + memory = result.get("memory") + memory_available = ( + isinstance(memory, dict) + and memory.get("available") is True + and all( + type(memory.get(field)) is int and memory[field] > 0 + for field in ("rss_self_kb", "rss_tree_kb") + ) + and memory["rss_tree_kb"] >= memory["rss_self_kb"] + ) + if memory_available: + result["status"] = "passed" + else: + result["status"] = "failed" + result["failure_kind"] = "memory_unavailable" + result["output_tail"] = ( + "benchmark completed, but process-tree RSS was unavailable; " + "canonical matrix runs require paired latency and memory results" + ) result["container_isolation"] = "separate_container" result["command"] = " ".join(command) result["rustwright_rebuild"] = env["RUSTWRIGHT_BENCH_REBUILD"] == "1" diff --git a/tools/run_mind2web_matrix.py b/tools/run_mind2web_matrix.py deleted file mode 100644 index 33284bf..0000000 --- a/tools/run_mind2web_matrix.py +++ /dev/null @@ -1,286 +0,0 @@ -#!/usr/bin/env python3 -from __future__ import annotations - -import argparse -import datetime as dt -import json -import os -import platform -import statistics -import subprocess -import sys -from pathlib import Path -from typing import Any - -ROOT = Path(__file__).resolve().parents[1] -DEFAULT_IMPLS = ["rustwright-py", "playwright", "rustwright-ts", "typescript-playwright", "typescript-puppeteer"] -LEGACY_IMPL_ALIASES = { - "rustwright": "rustwright-py", - "typescript-rustwright-binding": "rustwright-ts", - "typescript-rustwright-cdp": "rustwright-ts-cdp", -} - - -def canonical_impl(implementation: str) -> str: - return LEGACY_IMPL_ALIASES.get(implementation, implementation) - - -def default_output_path(args: argparse.Namespace) -> Path: - percent = str(args.percentage).replace(".", "p") - return ROOT / ".benchmark-data" / "results" / ( - f"mind2web-{percent}pct-seed{args.seed}-{args.repetitions}x{args.iterations}.json" - ) - - -def command_output(command: list[str]) -> str | None: - try: - proc = subprocess.run(command, cwd=ROOT, text=True, capture_output=True, timeout=10) - except Exception: - return None - if proc.returncode != 0: - return None - return proc.stdout.strip() - - -def docker_health_check(timeout: int = 10) -> dict[str, Any]: - try: - proc = subprocess.run(["docker", "info"], cwd=ROOT, text=True, capture_output=True, timeout=timeout) - except subprocess.TimeoutExpired as exc: - output = ((exc.stdout or "") + (exc.stderr or "")) if isinstance(exc.stdout, str) else "" - return { - "status": "unhealthy", - "failure_kind": "docker_daemon_error", - "output_tail": "\n".join(output.splitlines()[-40:]), - } - except OSError as exc: - return {"status": "unhealthy", "failure_kind": "docker_daemon_error", "output_tail": str(exc)} - combined = proc.stdout + proc.stderr - if proc.returncode == 0: - return {"status": "healthy", "returncode": 0, "output_tail": "\n".join(combined.splitlines()[-20:])} - return { - "status": "unhealthy", - "returncode": proc.returncode, - "failure_kind": "docker_daemon_error", - "output_tail": "\n".join(combined.splitlines()[-40:]), - } - - -def extract_json(output: str) -> dict[str, Any]: - decoder = json.JSONDecoder() - for index, char in enumerate(output): - if char != "{": - continue - try: - value, _ = decoder.raw_decode(output[index:]) - except json.JSONDecodeError: - continue - if isinstance(value, dict): - return value - raise ValueError(f"could not find JSON in output:\n{output}") - - -def run_impl(args: argparse.Namespace, implementation: str) -> dict[str, Any]: - env = {**os.environ, "MIND2WEB_ITERATIONS": str(args.iterations)} - command = [ - str(ROOT / "tools" / "docker_test.sh"), - "mind2web", - "--impl", - implementation, - "--manifest", - args.manifest, - "--percentage", - str(args.percentage), - "--seed", - str(args.seed), - "--json", - ] - if args.max_tasks is not None: - command.extend(["--max-tasks", str(args.max_tasks)]) - try: - proc = subprocess.run(command, cwd=ROOT, text=True, capture_output=True, env=env, timeout=args.timeout) - except subprocess.TimeoutExpired as exc: - combined = ((exc.stdout or "") + (exc.stderr or "")) if isinstance(exc.stdout, str) else "" - return { - "implementation": implementation, - "status": "failed", - "failure_kind": "timeout", - "timeout_s": args.timeout, - "output_tail": "\n".join(combined.splitlines()[-40:]), - } - combined = proc.stdout + proc.stderr - if proc.returncode != 0: - return { - "implementation": implementation, - "status": "failed", - "returncode": proc.returncode, - "output_tail": "\n".join(combined.splitlines()[-40:]), - } - result = extract_json(combined) - result.setdefault("status", "passed") - result["container_isolation"] = "separate_container" - result["command"] = " ".join(command) - return result - - -def aggregate_values(values: list[float]) -> dict[str, float]: - if not values: - return {"mean": 0.0, "median": 0.0, "p25": 0.0, "p75": 0.0, "min": 0.0, "max": 0.0, "stdev": 0.0} - ordered = sorted(values) - def pct(percent: float) -> float: - if len(ordered) == 1: - return ordered[0] - position = (len(ordered) - 1) * percent - lower = int(position) - upper = min(lower + 1, len(ordered) - 1) - fraction = position - lower - return ordered[lower] + (ordered[upper] - ordered[lower]) * fraction - return { - "mean": statistics.mean(values), - "median": statistics.median(values), - "p25": pct(0.25), - "p75": pct(0.75), - "min": min(values), - "max": max(values), - "stdev": statistics.stdev(values) if len(values) > 1 else 0.0, - } - - -def aggregate_results(results: list[dict[str, Any]]) -> dict[str, Any]: - grouped: dict[str, list[dict[str, Any]]] = {} - for item in results: - if item.get("status") == "passed" and "quality" in item: - grouped.setdefault(item["implementation"], []).append(item) - aggregate = {} - for implementation, items in grouped.items(): - aggregate[implementation] = { - "runs": len(items), - "success_rate": aggregate_values([float(item["quality"]["success_rate"]) for item in items]), - "passed_runs": sum(int(item["quality"]["passed_runs"]) for item in items), - "failed_runs": sum(int(item["quality"]["failed_runs"]) for item in items), - "skipped_runs": sum(int(item["quality"]["skipped_runs"]) for item in items), - "total_mean_ms": aggregate_values([float(item["total_mean_ms"]) for item in items]), - } - return aggregate - - -def matrix_metadata(args: argparse.Namespace, implementations: list[str], docker_preflight: dict[str, Any]) -> dict[str, Any]: - image = os.environ.get("RUSTWRIGHT_DOCKER_IMAGE", "rustwright-verify") - healthy = docker_preflight.get("status") == "healthy" - docker_memory_limit = os.environ.get("TEST_DOCKER_MEMORY_LIMIT", "8g") - docker_memory_swap_limit = os.environ.get("TEST_DOCKER_MEMORY_SWAP_LIMIT", docker_memory_limit) - return { - "created_at": dt.datetime.now(dt.timezone.utc).isoformat(), - "command": " ".join(sys.argv), - "workdir": str(ROOT), - "suite": "mind2web", - "manifest": args.manifest, - "percentage": args.percentage, - "seed": args.seed, - "iterations": args.iterations, - "repetitions": args.repetitions, - "implementations": implementations, - "container_isolation": "one_container_per_implementation_per_repetition", - "parallelism": "sequential", - "docker_memory_limit": docker_memory_limit, - "docker_memory_swap_limit": docker_memory_swap_limit, - "docker_cpu_quota": "unbounded_by_wrapper", - "docker_cpu_host_info": command_output(["docker", "info", "--format", "{{.NCPU}} logical CPUs available to Docker host"]) if healthy else None, - "docker_image": image, - "docker_image_id": command_output(["docker", "image", "inspect", image, "--format", "{{.Id}}"]) if healthy else None, - "docker_image_created": command_output(["docker", "image", "inspect", image, "--format", "{{.Created}}"]) if healthy else None, - "docker_preflight": docker_preflight, - "git_rev": command_output(["git", "rev-parse", "HEAD"]), - "git_status_short": command_output(["git", "status", "--short"]), - "python": sys.version.split()[0], - "platform": platform.platform(), - "machine": platform.machine(), - } - - -def markdown_table(result: dict[str, Any]) -> str: - rows = [ - "| Implementation | Runs | Success Rate Median | Passed | Failed | Skipped | Total Median ms |", - "| --- | ---: | ---: | ---: | ---: | ---: | ---: |", - ] - for implementation, item in result.get("aggregate", {}).items(): - rows.append( - f"| {implementation} | {item['runs']} | {float(item['success_rate']['median']) * 100:.1f}% | " - f"{item['passed_runs']} | {item['failed_runs']} | {item['skipped_runs']} | " - f"{float(item['total_mean_ms']['median']):.2f} |" - ) - for item in result["results"]: - if item.get("status") == "skipped": - rows.append(f"| {item['implementation']} | 0 | skipped | 0 | 0 | 0 | |") - elif item.get("status") == "failed": - rows.append(f"| {item['implementation']} | 0 | failed | 0 | 1 | 0 | |") - return "\n".join(rows) - - -def main() -> int: - parser = argparse.ArgumentParser(description="Run Mind2Web benchmark implementations in separate Docker containers.") - parser.add_argument("--manifest", default=str(ROOT / ".benchmark-data" / "manifests" / "mind2web_tasks.json")) - parser.add_argument("--percentage", type=float, default=30.0) - parser.add_argument("--seed", type=int, default=0) - parser.add_argument("--max-tasks", type=int, default=None) - parser.add_argument("--iterations", type=int, default=int(os.environ.get("MIND2WEB_FULL_ITERATIONS", "1"))) - parser.add_argument("--repetitions", type=int, default=1) - parser.add_argument("--impl", action="append", help="Implementation to run. Defaults to all Mind2Web targets.") - parser.add_argument("--timeout", type=int, default=int(os.environ.get("MIND2WEB_CONTAINER_TIMEOUT", "3600"))) - parser.add_argument("--docker-preflight-timeout", type=int, default=10) - parser.add_argument("--output", default=None) - parser.add_argument("--json", action="store_true") - parser.add_argument("--allow-failures", action="store_true") - args = parser.parse_args() - - implementations = [canonical_impl(implementation) for implementation in (args.impl or list(DEFAULT_IMPLS))] - planned = [(repetition, implementation) for repetition in range(args.repetitions) for implementation in implementations] - docker_preflight = docker_health_check(args.docker_preflight_timeout) - output_path = Path(args.output) if args.output else default_output_path(args) - if not output_path.is_absolute(): - output_path = ROOT / output_path - - def current_output(results: list[dict[str, Any]]) -> dict[str, Any]: - return { - "suite": "mind2web", - "iterations": args.iterations, - "repetitions": args.repetitions, - "metadata": matrix_metadata(args, implementations, docker_preflight), - "results": results, - "aggregate": aggregate_results(results), - "result_path": str(output_path.relative_to(ROOT) if output_path.is_relative_to(ROOT) else output_path), - } - - def write_current(results: list[dict[str, Any]]) -> None: - output_path.parent.mkdir(parents=True, exist_ok=True) - output_path.write_text(json.dumps(current_output(results), indent=2, sort_keys=True) + "\n", encoding="utf-8") - - if docker_preflight.get("status") != "healthy": - results = [ - { - "implementation": implementation, - "status": "skipped", - "reason": "skipped_after_docker_preflight_failure", - "failure_kind": "docker_daemon_error", - "repetition": repetition + 1, - } - for repetition, implementation in planned - ] - else: - results = [] - for repetition, implementation in planned: - result = run_impl(args, implementation) - result["repetition"] = repetition + 1 - results.append(result) - write_current(results) - output = current_output(results) - write_current(results) - if args.json: - print(json.dumps(output, indent=2, sort_keys=True)) - else: - print(markdown_table(output)) - failed = [item for item in results if item.get("status") not in {"passed", "skipped"}] - return 0 if args.allow_failures or not failed else 1 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/tools/run_parity_cases.py b/tools/run_parity_cases.py deleted file mode 100644 index 95be185..0000000 --- a/tools/run_parity_cases.py +++ /dev/null @@ -1,151 +0,0 @@ -from __future__ import annotations - -import argparse -import fnmatch -import inspect -import importlib -import json -import sys -from pathlib import Path -from typing import Any, Callable - -ROOT = Path(__file__).resolve().parents[1] -if str(ROOT) not in sys.path: - sys.path.insert(0, str(ROOT)) - -from benchmarks.automation_cases import CASES # noqa: E402 - - -def _is_connected(browser: Any) -> bool: - try: - return bool(browser.is_connected()) - except Exception: - return False - - -def _safe_close(target: Any) -> None: - try: - target.close() - except Exception: - pass - - -def _launch_chromium(playwright: Any) -> Any: - try: - return playwright.chromium.launch(headless=True) - except Exception as exc: - message = str(exc) - if "mach_port_rendezvous" not in message and "bootstrap_check_in" not in message: - raise - return playwright.chromium.launch(headless=True, args=["--single-process"]) - - -def _load_sync_playwright(implementation: str, reference_path: str | None) -> Callable[..., Any]: - if implementation == "rustwright": - from rustwright.sync_api import sync_playwright - - return sync_playwright - - if implementation != "playwright": - raise ValueError(f"unknown implementation: {implementation}") - - if reference_path: - path = str(Path(reference_path).resolve()) - if path not in sys.path: - sys.path.insert(0, path) - for name in list(sys.modules): - if name == "playwright" or name.startswith("playwright."): - del sys.modules[name] - module = importlib.import_module("playwright.sync_api") - module_path = Path(getattr(module, "__file__", "")).resolve() - if ROOT / "python" in module_path.parents or module_path.name == "sync_api.py" and "rustwright" in str(module_path): - raise RuntimeError( - "The local drop-in playwright alias is shadowing real Playwright. " - "Pass --reference-path .audit-playwright or run outside the editable repo environment." - ) - return module.sync_playwright - - -def _select_cases(patterns: list[str]) -> list[Callable[..., Any]]: - if not patterns: - return list(CASES) - selected = [ - case - for case in CASES - if any(fnmatch.fnmatchcase(case.__name__, pattern) for pattern in patterns) - ] - missing = [pattern for pattern in patterns if not any(fnmatch.fnmatchcase(case.__name__, pattern) for case in CASES)] - if missing: - raise ValueError(f"no parity cases matched: {', '.join(missing)}") - return selected - - -def run_cases(sync_playwright: Callable[..., Any], cases: list[Callable[..., Any]] | None = None) -> list[dict[str, str]]: - cases = cases or list(CASES) - results: list[dict[str, str]] = [] - with sync_playwright() as p: - browser = _launch_chromium(p) - try: - for case in cases: - if not _is_connected(browser): - _safe_close(browser) - browser = _launch_chromium(p) - try: - page = browser.new_page() - except Exception: - if _is_connected(browser): - raise - _safe_close(browser) - browser = _launch_chromium(p) - page = browser.new_page() - try: - parameters = inspect.signature(case).parameters - if "playwright" in parameters: - case(page, playwright=p) - else: - case(page) - results.append({"case": case.__name__, "status": "passed"}) - finally: - _safe_close(page) - finally: - _safe_close(browser) - return results - - -def main() -> int: - parser = argparse.ArgumentParser(description="Run shared core automation parity cases.") - parser.add_argument("--impl", choices=["rustwright", "playwright"], required=True) - parser.add_argument( - "--case", - action="append", - default=[], - help="Run only parity cases whose names match this exact name or shell-style glob. May be repeated.", - ) - parser.add_argument( - "--reference-path", - default=str(ROOT / ".audit-playwright"), - help="Path containing a real Playwright installation for --impl playwright.", - ) - parser.add_argument("--json", action="store_true") - args = parser.parse_args() - - sync_playwright = _load_sync_playwright(args.impl, args.reference_path) - cases = _select_cases(args.case) - results = run_cases(sync_playwright, cases) - payload = { - "implementation": args.impl, - "cases": results, - "passed": sum(1 for result in results if result["status"] == "passed"), - "total": len(results), - } - if args.json: - print(json.dumps(payload, indent=2, sort_keys=True)) - else: - print(f"{args.impl}: {payload['passed']}/{payload['total']} parity cases passed") - for result in results: - print(f"{result['case']}: {result['status']}") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/tools/run_webvoyager_matrix.py b/tools/run_webvoyager_matrix.py deleted file mode 100644 index b97ac65..0000000 --- a/tools/run_webvoyager_matrix.py +++ /dev/null @@ -1,299 +0,0 @@ -#!/usr/bin/env python3 -from __future__ import annotations - -import argparse -import datetime as dt -import json -import os -import platform -import statistics -import subprocess -import sys -from pathlib import Path -from typing import Any - -ROOT = Path(__file__).resolve().parents[1] -DEFAULT_IMPLS = ["rustwright", "playwright"] - - -def default_output_path(args: argparse.Namespace) -> Path: - percent = str(args.percentage).replace(".", "p") - return ROOT / ".benchmark-data" / "results" / ( - f"webvoyager-{percent}pct-seed{args.seed}-{args.repetitions}x{args.iterations}.json" - ) - - -def command_output(command: list[str]) -> str | None: - try: - proc = subprocess.run(command, cwd=ROOT, text=True, capture_output=True, timeout=10) - except Exception: - return None - if proc.returncode != 0: - return None - return proc.stdout.strip() - - -def docker_health_check(timeout: int = 10) -> dict[str, Any]: - try: - proc = subprocess.run(["docker", "info"], cwd=ROOT, text=True, capture_output=True, timeout=timeout) - except subprocess.TimeoutExpired as exc: - output = ((exc.stdout or "") + (exc.stderr or "")) if isinstance(exc.stdout, str) else "" - return { - "status": "unhealthy", - "failure_kind": "docker_daemon_error", - "output_tail": "\n".join(output.splitlines()[-40:]), - } - except OSError as exc: - return {"status": "unhealthy", "failure_kind": "docker_daemon_error", "output_tail": str(exc)} - combined = proc.stdout + proc.stderr - if proc.returncode == 0: - return {"status": "healthy", "returncode": 0, "output_tail": "\n".join(combined.splitlines()[-20:])} - return { - "status": "unhealthy", - "returncode": proc.returncode, - "failure_kind": "docker_daemon_error", - "output_tail": "\n".join(combined.splitlines()[-40:]), - } - - -def extract_json(output: str) -> dict[str, Any]: - decoder = json.JSONDecoder() - for index, char in enumerate(output): - if char != "{": - continue - try: - value, _ = decoder.raw_decode(output[index:]) - except json.JSONDecodeError: - continue - if isinstance(value, dict): - return value - raise ValueError(f"could not find JSON in output:\n{output}") - - -def run_impl(args: argparse.Namespace, implementation: str) -> dict[str, Any]: - env = {**os.environ, "WEBVOYAGER_ITERATIONS": str(args.iterations)} - command = [ - str(ROOT / "tools" / "docker_test.sh"), - "webvoyager", - "--impl", - implementation, - "--manifest", - args.manifest, - "--percentage", - str(args.percentage), - "--seed", - str(args.seed), - "--navigation-timeout", - str(args.navigation_timeout), - "--network-warmup-url", - args.network_warmup_url, - "--network-warmup-timeout", - str(args.network_warmup_timeout), - "--browser-lifecycle", - args.browser_lifecycle, - "--task-retries", - str(args.task_retries), - "--json", - ] - if args.max_tasks is not None: - command.extend(["--max-tasks", str(args.max_tasks)]) - try: - proc = subprocess.run(command, cwd=ROOT, text=True, capture_output=True, env=env, timeout=args.timeout) - except subprocess.TimeoutExpired as exc: - combined = ((exc.stdout or "") + (exc.stderr or "")) if isinstance(exc.stdout, str) else "" - return { - "implementation": implementation, - "status": "failed", - "failure_kind": "timeout", - "timeout_s": args.timeout, - "output_tail": "\n".join(combined.splitlines()[-40:]), - } - combined = proc.stdout + proc.stderr - if proc.returncode != 0: - return { - "implementation": implementation, - "status": "failed", - "returncode": proc.returncode, - "output_tail": "\n".join(combined.splitlines()[-40:]), - } - output = extract_json(combined) - result = (output.get("results") or [{}])[0] - result.setdefault("status", "passed") - result["container_isolation"] = "separate_container" - result["command"] = " ".join(command) - return result - - -def aggregate_values(values: list[float]) -> dict[str, float]: - if not values: - return {"mean": 0.0, "median": 0.0, "p25": 0.0, "p75": 0.0, "min": 0.0, "max": 0.0, "stdev": 0.0} - ordered = sorted(values) - - def pct(percent: float) -> float: - if len(ordered) == 1: - return ordered[0] - position = (len(ordered) - 1) * percent - lower = int(position) - upper = min(lower + 1, len(ordered) - 1) - fraction = position - lower - return ordered[lower] + (ordered[upper] - ordered[lower]) * fraction - - return { - "mean": statistics.mean(values), - "median": statistics.median(values), - "p25": pct(0.25), - "p75": pct(0.75), - "min": min(values), - "max": max(values), - "stdev": statistics.stdev(values) if len(values) > 1 else 0.0, - } - - -def aggregate_results(results: list[dict[str, Any]]) -> dict[str, Any]: - grouped: dict[str, list[dict[str, Any]]] = {} - for item in results: - if item.get("status") == "passed" and "quality" in item: - grouped.setdefault(item["implementation"], []).append(item) - aggregate = {} - for implementation, items in grouped.items(): - aggregate[implementation] = { - "runs": len(items), - "success_rate": aggregate_values([float(item["quality"]["success_rate"]) for item in items]), - "passed_runs": sum(int(item["quality"]["passed_runs"]) for item in items), - "failed_runs": sum(int(item["quality"]["failed_runs"]) for item in items), - "skipped_runs": sum(int(item["quality"]["skipped_runs"]) for item in items), - "total_mean_ms": aggregate_values([float(item["total_mean_ms"]) for item in items]), - } - return aggregate - - -def matrix_metadata(args: argparse.Namespace, implementations: list[str], docker_preflight: dict[str, Any]) -> dict[str, Any]: - image = os.environ.get("RUSTWRIGHT_DOCKER_IMAGE", "rustwright-verify") - healthy = docker_preflight.get("status") == "healthy" - return { - "created_at": dt.datetime.now(dt.timezone.utc).isoformat(), - "command": " ".join(sys.argv), - "workdir": str(ROOT), - "suite": "webvoyager", - "manifest": args.manifest, - "percentage": args.percentage, - "seed": args.seed, - "iterations": args.iterations, - "repetitions": args.repetitions, - "navigation_timeout": args.navigation_timeout, - "network_warmup_url": args.network_warmup_url, - "network_warmup_timeout": args.network_warmup_timeout, - "browser_lifecycle": args.browser_lifecycle, - "task_retries": args.task_retries, - "implementations": implementations, - "container_isolation": "one_container_per_implementation_per_repetition", - "parallelism": "sequential", - "docker_memory_limit": "8g", - "docker_memory_swap_limit": "8g", - "docker_cpu_quota": "unbounded_by_wrapper", - "docker_cpu_host_info": command_output(["docker", "info", "--format", "{{.NCPU}} logical CPUs available to Docker host"]) if healthy else None, - "docker_image": image, - "docker_image_id": command_output(["docker", "image", "inspect", image, "--format", "{{.Id}}"]) if healthy else None, - "docker_image_created": command_output(["docker", "image", "inspect", image, "--format", "{{.Created}}"]) if healthy else None, - "docker_preflight": docker_preflight, - "git_rev": command_output(["git", "rev-parse", "HEAD"]), - "git_status_short": command_output(["git", "status", "--short"]), - "python": sys.version.split()[0], - "platform": platform.platform(), - "machine": platform.machine(), - } - - -def markdown_table(result: dict[str, Any]) -> str: - rows = [ - "| Implementation | Runs | Success Rate Median | Passed | Failed | Skipped | Total Median ms |", - "| --- | ---: | ---: | ---: | ---: | ---: | ---: |", - ] - for implementation, item in result.get("aggregate", {}).items(): - rows.append( - f"| {implementation} | {item['runs']} | {float(item['success_rate']['median']) * 100:.1f}% | " - f"{item['passed_runs']} | {item['failed_runs']} | {item['skipped_runs']} | " - f"{float(item['total_mean_ms']['median']):.2f} |" - ) - for item in result["results"]: - if item.get("status") == "skipped": - rows.append(f"| {item['implementation']} | 0 | skipped | 0 | 0 | 0 | |") - elif item.get("status") == "failed": - rows.append(f"| {item['implementation']} | 0 | failed | 0 | 1 | 0 | |") - return "\n".join(rows) - - -def main() -> int: - parser = argparse.ArgumentParser(description="Run WebVoyager reliability benchmark implementations in separate Docker containers.") - parser.add_argument("--manifest", default=str(ROOT / ".benchmark-data" / "manifests" / "webvoyager_tasks.json")) - parser.add_argument("--percentage", type=float, default=30.0) - parser.add_argument("--seed", type=int, default=0) - parser.add_argument("--max-tasks", type=int, default=None) - parser.add_argument("--iterations", type=int, default=int(os.environ.get("WEBVOYAGER_FULL_ITERATIONS", "1"))) - parser.add_argument("--repetitions", type=int, default=1) - parser.add_argument("--impl", action="append", help="Implementation to run. Defaults to Rustwright and Python Playwright.") - parser.add_argument("--navigation-timeout", type=float, default=float(os.environ.get("WEBVOYAGER_NAVIGATION_TIMEOUT", "15000"))) - parser.add_argument("--network-warmup-url", default=os.environ.get("WEBVOYAGER_NETWORK_WARMUP_URL", "https://example.com/")) - parser.add_argument("--network-warmup-timeout", type=float, default=float(os.environ.get("WEBVOYAGER_NETWORK_WARMUP_TIMEOUT", "10000"))) - parser.add_argument("--browser-lifecycle", choices=["warm-browser", "cold-browser"], default=os.environ.get("WEBVOYAGER_BROWSER_LIFECYCLE", "cold-browser")) - parser.add_argument("--task-retries", type=int, default=int(os.environ.get("WEBVOYAGER_TASK_RETRIES", "1"))) - parser.add_argument("--timeout", type=int, default=int(os.environ.get("WEBVOYAGER_CONTAINER_TIMEOUT", "1800"))) - parser.add_argument("--docker-preflight-timeout", type=int, default=10) - parser.add_argument("--output", default=None) - parser.add_argument("--json", action="store_true") - parser.add_argument("--allow-failures", action="store_true") - args = parser.parse_args() - - implementations = args.impl or list(DEFAULT_IMPLS) - planned = [(repetition, implementation) for repetition in range(args.repetitions) for implementation in implementations] - docker_preflight = docker_health_check(args.docker_preflight_timeout) - output_path = Path(args.output) if args.output else default_output_path(args) - if not output_path.is_absolute(): - output_path = ROOT / output_path - - def current_output(results: list[dict[str, Any]]) -> dict[str, Any]: - return { - "suite": "webvoyager", - "comparison_mode": "webvoyager_navigation_reliability", - "iterations": args.iterations, - "repetitions": args.repetitions, - "metadata": matrix_metadata(args, implementations, docker_preflight), - "results": results, - "aggregate": aggregate_results(results), - "result_path": str(output_path.relative_to(ROOT) if output_path.is_relative_to(ROOT) else output_path), - } - - def write_current(results: list[dict[str, Any]]) -> None: - output_path.parent.mkdir(parents=True, exist_ok=True) - output_path.write_text(json.dumps(current_output(results), indent=2, sort_keys=True) + "\n", encoding="utf-8") - - if docker_preflight.get("status") != "healthy": - results = [ - { - "implementation": implementation, - "status": "skipped", - "reason": "skipped_after_docker_preflight_failure", - "failure_kind": "docker_daemon_error", - "repetition": repetition + 1, - } - for repetition, implementation in planned - ] - else: - results = [] - for repetition, implementation in planned: - result = run_impl(args, implementation) - result["repetition"] = repetition + 1 - results.append(result) - write_current(results) - output = current_output(results) - write_current(results) - if args.json: - print(json.dumps(output, indent=2, sort_keys=True)) - else: - print(markdown_table(output)) - failed = [item for item in results if item.get("status") not in {"passed", "skipped"}] - return 0 if args.allow_failures or not failed else 1 - - -if __name__ == "__main__": - raise SystemExit(main())