From 41ebc0379bbb233edff38548a7dac6816f303e53 Mon Sep 17 00:00:00 2001 From: JasonHonKL Date: Sat, 4 Apr 2026 02:09:48 +0800 Subject: [PATCH] update --- Cargo.toml | 2 +- ROADMAP.md | 23 +- crates/pardus-cdp/Cargo.toml | 2 +- crates/pardus-cdp/src/domain/dom.rs | 223 +++++++++-- crates/pardus-cli/src/commands/interact.rs | 21 ++ crates/pardus-cli/src/commands/navigate.rs | 2 + crates/pardus-cli/src/commands/repl.rs | 30 ++ crates/pardus-cli/src/main.rs | 29 ++ crates/pardus-core/Cargo.toml | 1 + crates/pardus-core/src/browser/interact.rs | 109 ++++++ crates/pardus-core/src/browser/traits.rs | 7 + crates/pardus-core/src/config.rs | 3 + crates/pardus-core/src/feed.rs | 6 +- crates/pardus-core/src/interact/actions.rs | 56 +++ crates/pardus-core/src/interact/element.rs | 20 +- crates/pardus-core/src/interact/form.rs | 115 +++++- .../pardus-core/src/interact/js_interact.rs | 189 +++++++++- crates/pardus-core/src/interact/mod.rs | 4 +- crates/pardus-core/src/interact/recording.rs | 2 + crates/pardus-core/src/interact/upload.rs | 169 +++++++++ crates/pardus-core/src/js/bootstrap.js | 140 +++++++ crates/pardus-core/src/js/dom.rs | 357 ++++++++++++++++++ crates/pardus-core/src/js/extension.rs | 9 + crates/pardus-core/src/js/ops.rs | 48 +++ crates/pardus-core/src/lib.rs | 1 + crates/pardus-core/src/navigation/graph.rs | 3 + .../pardus-core/src/output/llm_formatter.rs | 22 ++ crates/pardus-core/src/output/md_formatter.rs | 18 + .../pardus-core/src/output/tree_formatter.rs | 9 + crates/pardus-core/src/page.rs | 2 + crates/pardus-core/src/pdf.rs | 9 +- crates/pardus-core/src/semantic/tree.rs | 66 +++- crates/pardus-kg/src/fingerprint.rs | 32 +- 33 files changed, 1677 insertions(+), 52 deletions(-) create mode 100644 crates/pardus-core/src/interact/upload.rs diff --git a/Cargo.toml b/Cargo.toml index 9391003..0694343 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -30,7 +30,7 @@ futures-util = "0.3" blake3 = "1" lol_html = "2" reqwest = { version = "0.12", features = ["cookies", "gzip", "brotli", "deflate", "json"] } -rquest = { version = "5", features = ["cookies", "gzip", "brotli", "deflate", "json", "stream", "socks"] } +rquest = { version = "5", features = ["cookies", "gzip", "brotli", "deflate", "json", "stream", "socks", "multipart"] } rquest-util = "2" parking_lot = "0.12" base64 = "0.22" diff --git a/ROADMAP.md b/ROADMAP.md index 457b2df..a8feadd 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,6 +1,6 @@ # Pardus Browser Roadmap -**Version:** 0.4.0-dev | **Branch:** dev/roadmap | **Updated:** April 3, 2026 +**Version:** 0.4.0-dev | **Branch:** dev/roadmap | **Updated:** April 4, 2026 --- @@ -12,7 +12,7 @@ Core engine, CLI, and all major subsystems are stable. Summary of shipped featur |------|-----------| | **Semantic Engine** | ARIA role tree, navigation graph, element IDs (`[#N]`), action annotations (navigate/click/fill/toggle/select), interactive-only mode, 4 output formats (md, tree, json, llm) | | **Page Interaction** | Click, type, submit, wait-for-selector, scroll pagination, JS-level interaction (deno_core DOM), inline event handler registration, DOM mutation serialization | -| **JavaScript** | V8 via deno_core, 35+ Rust DOM ops, thread-based timeouts, inline script execution, analytics/problematic script filtering | +| **JavaScript** | V8 via deno_core, 42+ Rust DOM ops, thread-based timeouts, inline script execution, analytics/problematic script filtering | | **Security** | SSRF protection (private IPs, metadata endpoints, scheme blocking), Basic/Bearer auth, CSP parsing & enforcement, certificate pinning (SPKI hash + CA), sandbox mode (off/strict/moderate/minimal) | | **Session & Cache** | Cookie/localStorage/auth persistence, HTTP cache (RFC 7234: ETag, Last-Modified, 304), disk cache, shared HTTP client factory | | **Proxy** | HTTP/HTTPS/SOCKS5, per-command flags, env var support (HTTP_PROXY etc.), no-proxy exclusions | @@ -27,6 +27,9 @@ Core engine, CLI, and all major subsystems are stable. Summary of shipped featur | **Adapters** | Playwright (Python + Node.js), Puppeteer (Node.js), Docker image with health check | | **CLI** | 8 subcommands (navigate, interact, serve, repl, tab, map, clean), rustyline REPL, verbose logging | | **Perf** | Connection pooling, HTTP/2 push simulation, configurable memory limits, ~200ms page parse | +| **AI Agent Intelligence** | Action planning (page-type classification, suggested next actions), auto-form filling with validation, smart wait conditions (network idle, DOM stability, content mutations), session recording & replay (JSON serialization, deterministic replay) | +| **Anti-bot Detection** | Challenge detection (reCAPTCHA, hCaptcha, Turnstile, JS challenges), risk scoring, human-in-the-loop resolution | +| **Meta Refresh** | `` parsing with delay, relative URLs, query params, fragments, base tag support, redirect depth limiting | --- @@ -50,12 +53,12 @@ _(Currently empty)_ ### AI Agent Intelligence -- [ ] **Action planning** — Suggested next actions based on page state -- [ ] **Auto-form filling** — AI-guided form completion with validation -- [ ] **Smart wait conditions** — Wait for network idle, DOM stability, or content mutations instead of fixed timers -- [ ] **Session recording & replay** — Serialize action sequences to JSON, replay deterministically +- [x] **Action planning** — Suggested next actions based on page state +- [x] **Auto-form filling** — AI-guided form completion with validation +- [x] **Smart wait conditions** — Wait for network idle, DOM stability, or content mutations instead of fixed timers +- [x] **Session recording & replay** — Serialize action sequences to JSON, replay deterministically - [ ] **Page diff** — Compare semantic trees between navigations; detect what changed (new elements, removed content, state transitions) -- [ ] **Anti-bot detection hints** — Report Cloudflare/PerimeterX/DataDome challenges in semantic output so agents know they're blocked +- [x] **Anti-bot detection hints** — Report Cloudflare/PerimeterX/DataDome challenges in semantic output so agents know they're blocked - [ ] **Login flow templates** — Declarative YAML/JSON descriptors for common auth patterns (email+password, SSO click-through, MFA TOTP) - [ ] **Content extraction** — Article/main-content extraction (Readability-style) stripping nav, ads, footers; output clean text for LLM ingestion - [ ] **Structured data extraction** — Detect and expose JSON-LD, Open Graph, microdata, RDFa from pages as typed Rust structs @@ -75,7 +78,7 @@ _(Currently empty)_ - [ ] **Cookie API in JS** — `document.cookie` getter/setter wired to the session cookie store - [ ] **localStorage/sessionStorage in JS** — Persistent and per-session storage backed by pardus-core session store - [ ] **MutationObserver shim** — Allow JS to observe DOM changes for SPA reactivity detection -- [ ] **Event dispatch** — Allow agents to fire arbitrary DOM events (change, input, submit, custom) for frameworks that listen on native events +- [x] **Event dispatch** — Allow agents to fire arbitrary DOM events (change, input, submit, custom) for frameworks that listen on native events ### Network & Protocol @@ -91,12 +94,12 @@ _(Currently empty)_ - [x] **PDF text extraction** — Parse PDF bytes to semantic tree with table, form-field (AcroForm), and image metadata extraction - [x] **RSS/Atom feed parsing** — Detect and parse RSS/Atom feed content into structured items (title, link, date, summary) - [ ] **Robots.txt parser** — Respect crawl directives; expose `is_allowed(url)` for the knowledge graph crawler -- [ ] **Meta refresh & redirects** — Parse `` and JS `location.href` assignments as navigations +- [x] **Meta refresh & redirects** — Parse `` and JS `location.href` assignments as navigations - [ ] **Content encoding** — Handle gzip/brotli/zstd transfer encodings beyond what reqwest provides automatically ### CDP Completeness -- [ ] **DOM manipulation** — Implement stubbed methods: setNodeValue, setNodeName, removeAttribute, copyTo, moveTo, undo/redo +- [x] **DOM manipulation** — Implement stubbed methods: setNodeValue, setNodeName, removeAttribute, copyTo, moveTo, undo/redo - [ ] **Input event dispatch** — Wire mouse/keyboard events through pardus-core interaction system (currently stubbed) - [ ] **File upload** — Implement DOM.setFileInputFiles for `` handling - [ ] **Network interception in CDP** — Fetch.enable / Fetch.requestPaused for request/response modification over CDP diff --git a/crates/pardus-cdp/Cargo.toml b/crates/pardus-cdp/Cargo.toml index 559fece..3f4a899 100644 --- a/crates/pardus-cdp/Cargo.toml +++ b/crates/pardus-cdp/Cargo.toml @@ -4,7 +4,7 @@ version.workspace = true edition.workspace = true [dependencies] -pardus-core = { path = "../pardus-core", features = ["tls-pinning"] } +pardus-core = { path = "../pardus-core", features = ["tls-pinning", "js"] } pardus-debug = { path = "../pardus-debug" } scraper = "0.22" tokio = { workspace = true } diff --git a/crates/pardus-cdp/src/domain/dom.rs b/crates/pardus-cdp/src/domain/dom.rs index 2a47a6b..20624cf 100644 --- a/crates/pardus-cdp/src/domain/dom.rs +++ b/crates/pardus-cdp/src/domain/dom.rs @@ -13,6 +13,34 @@ fn resolve_target_id(session: &CdpSession) -> &str { session.target_id.as_deref().unwrap_or("default") } +/// Parse target HTML into DomDocument, apply a mutation, serialize back. +async fn mutate_dom( + ctx: &DomainContext, + target_id: &str, + f: F, +) -> HandleResult +where + F: FnOnce(&mut pardus_core::js::dom::DomDocument, &NodeMap), +{ + let html_str = match ctx.get_html(target_id).await { + Some(h) => h, + None => return HandleResult::Ack, + }; + let url = ctx.get_url(target_id).await.unwrap_or_default(); + + let mut doc = pardus_core::js::dom::DomDocument::from_html(&html_str); + let nm = ctx.node_map.lock().await; + + f(&mut doc, &nm); + + let new_html = doc.to_html(); + let title = doc.get_title(); + drop(nm); + ctx.update_target_with_data(target_id, url, new_html, Some(title)); + + HandleResult::Ack +} + #[async_trait(?Send)] impl CdpDomainHandler for DomDomain { fn domain_name(&self) -> &'static str { @@ -201,23 +229,68 @@ impl CdpDomainHandler for DomDomain { } "setAttributeValue" => { let node_id = params["nodeId"].as_i64().unwrap_or(-1); - let attr_name = params["name"].as_str().unwrap_or(""); - let attr_value = params["value"].as_str().unwrap_or(""); - let selector = { - let nm = ctx.node_map.lock().await; - nm.get_selector(node_id).map(|s| s.to_string()) - }; - - if let Some(_sel) = selector { - let _ = (attr_name, attr_value); - } - - HandleResult::Ack + let attr_name = params["name"].as_str().unwrap_or("").to_string(); + let attr_value = params["value"].as_str().unwrap_or("").to_string(); + mutate_dom(ctx, target_id, |doc, nm| { + if let Some(selector) = nm.get_selector(node_id) { + if let Some(elem_id) = doc.query_selector(0, selector) { + doc.set_attribute(elem_id, &attr_name, &attr_value); + } + } + }).await + } + "removeAttribute" => { + let node_id = params["nodeId"].as_i64().unwrap_or(-1); + let attr_name = params["name"].as_str().unwrap_or("").to_string(); + mutate_dom(ctx, target_id, |doc, nm| { + if let Some(selector) = nm.get_selector(node_id) { + if let Some(elem_id) = doc.query_selector(0, selector) { + doc.remove_attribute(elem_id, &attr_name); + } + } + }).await + } + "removeNode" => { + let node_id = params["nodeId"].as_i64().unwrap_or(-1); + mutate_dom(ctx, target_id, |doc, nm| { + if let Some(selector) = nm.get_selector(node_id) { + if let Some(elem_id) = doc.query_selector(0, selector) { + if let Some(parent_id) = doc.get_parent(elem_id) { + doc.remove_child(parent_id, elem_id); + } + } + } + }).await + } + "setNodeValue" => { + let node_id = params["nodeId"].as_i64().unwrap_or(-1); + let value = params["value"].as_str().unwrap_or("").to_string(); + mutate_dom(ctx, target_id, |doc, nm| { + if let Some(selector) = nm.get_selector(node_id) { + if let Some(elem_id) = doc.query_selector(0, selector) { + // For text nodes discovered as children of elements + let children = doc.get_children(elem_id); + for &child_id in &children { + if doc.get_node_type(child_id) == 3 { + doc.set_node_value(child_id, &value); + return; + } + } + } + } + }).await + } + "setNodeName" => { + let node_id = params["nodeId"].as_i64().unwrap_or(-1); + let new_name = params["name"].as_str().unwrap_or("").to_string(); + mutate_dom(ctx, target_id, |doc, nm| { + if let Some(selector) = nm.get_selector(node_id) { + if let Some(elem_id) = doc.query_selector(0, selector) { + doc.set_node_name(elem_id, &new_name); + } + } + }).await } - "removeAttribute" => HandleResult::Ack, - "removeNode" => HandleResult::Ack, - "setNodeValue" => HandleResult::Ack, - "setNodeName" => HandleResult::Ack, "getBoxModel" => { HandleResult::Success(serde_json::json!({ "model": { @@ -273,7 +346,73 @@ impl CdpDomainHandler for DomDomain { let body_id = nm.get_or_assign("body"); HandleResult::Success(serde_json::json!({ "nodeId": body_id })) } - "setFileInputFiles" => HandleResult::Ack, + "setFileInputFiles" => { + let node_id = params["backendNodeId"].as_i64() + .or(params["nodeId"].as_i64()) + .unwrap_or(-1); + + let selector = { + let nm = ctx.node_map.lock().await; + nm.get_selector(node_id).map(|s| s.to_string()) + }; + + if let Some(selector) = selector { + let (html_str, url) = (ctx.get_html(target_id).await, ctx.get_url(target_id).await); + if let (Some(html_str), Some(url)) = (html_str, url) { + let page = pardus_core::Page::from_html(&html_str, &url); + if let Some(handle) = page.query(&selector) { + if handle.input_type.as_deref() == Some("file") || handle.action.as_deref() == Some("upload") { + let file_paths: Vec = params["files"] + .as_array() + .map(|arr| arr.iter().filter_map(|v| v.as_str().map(|s| std::path::PathBuf::from(s))).collect()) + .unwrap_or_default(); + + if file_paths.is_empty() { + return HandleResult::Error(CdpErrorResponse { + id: 0, + error: crate::error::CdpErrorBody { + code: INVALID_PARAMS, + message: "No files specified".to_string(), + }, + session_id: None, + }); + } + + let max_size = 50 * 1024 * 1024; + match pardus_core::interact::upload::upload_files(&page, &handle, &file_paths, max_size) { + Ok(files) => { + let file_names: Vec<&str> = files.iter().map(|f| f.file_name.as_str()).collect(); + let count = file_names.len(); + return HandleResult::Success(serde_json::json!({ + "files": file_names, + "count": count, + })); + } + Err(e) => { + return HandleResult::Error(CdpErrorResponse { + id: 0, + error: crate::error::CdpErrorBody { + code: INVALID_PARAMS, + message: e.to_string(), + }, + session_id: None, + }); + } + } + } + } + } + } + + HandleResult::Error(CdpErrorResponse { + id: 0, + error: crate::error::CdpErrorBody { + code: INVALID_PARAMS, + message: "Node is not a file input".to_string(), + }, + session_id: None, + }) + } "getFileInfo" => { HandleResult::Error(CdpErrorResponse { id: 0, @@ -307,11 +446,51 @@ impl CdpDomainHandler for DomDomain { "classNames": [] })) } - "copyTo" => HandleResult::Ack, - "moveTo" => HandleResult::Ack, - "undo" => HandleResult::Ack, - "redo" => HandleResult::Ack, - "markUndoableState" => HandleResult::Ack, + "copyTo" => { + let node_id = params["nodeId"].as_i64().unwrap_or(-1); + let target_parent_id = params["targetNodeId"].as_i64().unwrap_or(-1); + mutate_dom(ctx, target_id, |doc, nm| { + let source = nm.get_selector(node_id) + .and_then(|s| doc.query_selector(0, s)); + let parent = nm.get_selector(target_parent_id) + .and_then(|s| doc.query_selector(0, s)); + if let (Some(src_id), Some(par_id)) = (source, parent) { + doc.copy_to(src_id, par_id); + } + }).await + } + "moveTo" => { + let node_id = params["nodeId"].as_i64().unwrap_or(-1); + let target_parent_id = params["targetNodeId"].as_i64().unwrap_or(-1); + let before_id = params["insertBeforeNodeId"].as_i64(); + mutate_dom(ctx, target_id, |doc, nm| { + let source = nm.get_selector(node_id) + .and_then(|s| doc.query_selector(0, s)); + let parent = nm.get_selector(target_parent_id) + .and_then(|s| doc.query_selector(0, s)); + let before = before_id + .and_then(|id| nm.get_selector(id)) + .and_then(|s| doc.query_selector(0, s)); + if let (Some(src_id), Some(par_id)) = (source, parent) { + doc.move_to(src_id, par_id, before); + } + }).await + } + "undo" => { + mutate_dom(ctx, target_id, |doc, _nm| { + doc.undo(); + }).await + } + "redo" => { + mutate_dom(ctx, target_id, |doc, _nm| { + doc.redo(); + }).await + } + "markUndoableState" => { + mutate_dom(ctx, target_id, |doc, _nm| { + doc.mark_undoable_state(); + }).await + } "focus" => HandleResult::Ack, "getFlattenedDocument" => { let (html_str, url) = (ctx.get_html(target_id).await, ctx.get_url(target_id).await); diff --git a/crates/pardus-cli/src/commands/interact.rs b/crates/pardus-cli/src/commands/interact.rs index e3887bd..e073255 100644 --- a/crates/pardus-cli/src/commands/interact.rs +++ b/crates/pardus-cli/src/commands/interact.rs @@ -1,4 +1,5 @@ use anyhow::Result; +use std::path::PathBuf; use std::time::Instant; use pardus_core::{BrowserConfig, FormState, InteractionResult, ScrollDirection}; @@ -73,6 +74,20 @@ pub async fn run_with_config( let result = browser.scroll(dir).await?; output_result(&result, &format); } + InteractAction::DispatchEvent { selector, event_type, init } => { + let result = browser.dispatch_event(&selector, &event_type, init.as_deref()).await?; + output_result(&result, &format); + } + InteractAction::Upload { selector, files } => { + let paths: Vec = files.iter().map(|f| PathBuf::from(f)).collect(); + let result = browser.upload(&selector, paths)?; + output_result(&result, &format); + } + InteractAction::UploadId { id, files } => { + let paths: Vec = files.iter().map(|f| PathBuf::from(f)).collect(); + let result = browser.upload_by_id(id, paths)?; + output_result(&result, &format); + } } Ok(()) @@ -161,5 +176,11 @@ fn output_result(result: &InteractionResult, format: &OutputFormatArg) { } } } + InteractionResult::EventDispatched { selector, event_type } => { + println!("Dispatched '{}' on {}", event_type, selector); + } + InteractionResult::FilesSet { selector, count } => { + println!("Set {} file(s) on {}", count, selector); + } } } diff --git a/crates/pardus-cli/src/commands/navigate.rs b/crates/pardus-cli/src/commands/navigate.rs index 146c885..10acb1f 100644 --- a/crates/pardus-cli/src/commands/navigate.rs +++ b/crates/pardus-cli/src/commands/navigate.rs @@ -238,6 +238,8 @@ fn filter_interactive(tree: &pardus_core::SemanticTree) -> pardus_core::Semantic max_val: None, step_val: None, autocomplete: None, + accept: None, + multiple: false, children: vec![], }); diff --git a/crates/pardus-cli/src/commands/repl.rs b/crates/pardus-cli/src/commands/repl.rs index 10940c0..0df93a9 100644 --- a/crates/pardus-cli/src/commands/repl.rs +++ b/crates/pardus-cli/src/commands/repl.rs @@ -178,6 +178,29 @@ pub async fn run_with_config(js: bool, format: OutputFormatArg, wait_ms: u32, pr Err(e) => eprintln!("Error: {}", e), } } + "event" => { + if tokens.len() < 3 { + eprintln!("Usage: event [init_json]"); + continue; + } + let selector = &tokens[1]; + let event_type = &tokens[2]; + let init = tokens.get(3).cloned(); + if let Some(id_str) = selector.strip_prefix('#') { + match id_str.parse::() { + Ok(id) => match browser.dispatch_event_by_id(id, event_type, init.as_deref()).await { + Ok(result) => print_interaction_result(&result, &format), + Err(e) => eprintln!("Error: {}", e), + }, + Err(_) => eprintln!("Invalid element ID: {}", selector), + } + } else { + match browser.dispatch_event(selector, event_type, init.as_deref()).await { + Ok(result) => print_interaction_result(&result, &format), + Err(e) => eprintln!("Error: {}", e), + } + } + } // Screenshot (only available when compiled with --features screenshot) #[cfg(feature = "screenshot")] @@ -454,6 +477,12 @@ fn print_interaction_result( } } } + InteractionResult::EventDispatched { selector, event_type } => { + println!("Dispatched '{}' on {}", event_type, selector); + } + InteractionResult::FilesSet { selector, count } => { + println!("Set {} file(s) on {}", count, selector); + } } } @@ -748,6 +777,7 @@ fn print_help() { println!(" submit [k=v..] Submit a form"); println!(" scroll [dir] Scroll (down/up/to-top/to-bottom)"); println!(" wait [timeout] Wait for element to appear"); + println!(" event [init] Dispatch DOM event (e.g., event #1 change)"); #[cfg(feature = "screenshot")] println!(" screenshot [--full] [--element ]"); println!(); diff --git a/crates/pardus-cli/src/main.rs b/crates/pardus-cli/src/main.rs index c7017b9..a853018 100644 --- a/crates/pardus-cli/src/main.rs +++ b/crates/pardus-cli/src/main.rs @@ -415,6 +415,35 @@ pub enum InteractAction { #[arg(long, default_value = "down")] direction: String, }, + + /// Dispatch an arbitrary DOM event on an element + DispatchEvent { + /// CSS selector of the target element + selector: String, + /// Event type (e.g., change, input, focus, blur, submit, custom) + event_type: String, + /// Event init options as JSON (e.g., {"bubbles":true,"detail":{}}) + #[arg(long)] + init: Option, + }, + + /// Upload files to a file input element + Upload { + /// CSS selector of the file input + selector: String, + /// File paths to upload + #[arg(long, num_args = 1..)] + files: Vec, + }, + + /// Upload files to a file input by element ID + UploadId { + /// Element ID shown in the semantic tree + id: usize, + /// File paths to upload + #[arg(long, num_args = 1..)] + files: Vec, + }, } #[derive(Clone, Debug, ValueEnum)] diff --git a/crates/pardus-core/Cargo.toml b/crates/pardus-core/Cargo.toml index 2097b5c..16b3f0c 100644 --- a/crates/pardus-core/Cargo.toml +++ b/crates/pardus-core/Cargo.toml @@ -15,6 +15,7 @@ url = { workspace = true } cookie_store = "0.21" sha2 = "0.10" +mime_guess = "2" # Serialization serde = { workspace = true } diff --git a/crates/pardus-core/src/browser/interact.rs b/crates/pardus-core/src/browser/interact.rs index f1d2fd9..09cb654 100644 --- a/crates/pardus-core/src/browser/interact.rs +++ b/crates/pardus-core/src/browser/interact.rs @@ -1,5 +1,7 @@ //! Page interaction operations: click, type, submit, scroll, etc. +use std::path::PathBuf; + use crate::interact::actions::InteractionResult; use crate::interact::{FormState, ScrollDirection}; @@ -192,4 +194,111 @@ impl Browser { } Ok(result) } + + /// Dispatch an arbitrary DOM event on an element. + /// + /// If JS is enabled, creates and dispatches the event in the V8 DOM, + /// executing any registered event handlers and returning the modified DOM. + /// Otherwise, validates the element exists and returns `EventDispatched`. + pub async fn dispatch_event( + &mut self, + selector: &str, + event_type: &str, + event_init: Option<&str>, + ) -> anyhow::Result { + #[cfg(feature = "js")] + if self.is_js_enabled() { + let page = self.require_active_page()?; + let result = crate::interact::js_interact::js_dispatch_event( + page, selector, event_type, event_init, + ).await?; + return self.apply_navigated_result(result); + } + + let page = self.require_active_page()?; + let handle = page.query(selector).ok_or_else(|| { + anyhow::anyhow!("Element not found: {}", selector) + })?; + crate::interact::actions::dispatch_event(page, &handle, event_type) + } + + /// Dispatch an arbitrary DOM event on an element by its element ID. + pub async fn dispatch_event_by_id( + &mut self, + id: usize, + event_type: &str, + event_init: Option<&str>, + ) -> anyhow::Result { + #[cfg(feature = "js")] + if self.is_js_enabled() { + let page = self.require_active_page()?; + let handle = page.find_by_element_id(id).ok_or_else(|| { + anyhow::anyhow!("Element with ID {} not found", id) + })?; + let selector = handle.selector.clone(); + let result = crate::interact::js_interact::js_dispatch_event( + page, &selector, event_type, event_init, + ).await?; + return self.apply_navigated_result(result); + } + + let page = self.require_active_page()?; + let handle = page.find_by_element_id(id).ok_or_else(|| { + anyhow::anyhow!("Element with ID {} not found", id) + })?; + crate::interact::actions::dispatch_event(page, &handle, event_type) + } + + /// Upload files to a file input element. + /// + /// Files are read eagerly and stored in form state. When the form is + /// submitted, the request will use `multipart/form-data` encoding. + pub fn upload(&mut self, selector: &str, paths: Vec) -> anyhow::Result { + if !self.config.sandbox.is_off() { + anyhow::bail!("file uploads are blocked in sandbox mode"); + } + + let page = self.require_active_page()?; + let handle = page.query(selector).ok_or_else(|| { + anyhow::anyhow!("Element not found: {}", selector) + })?; + + let max_size = self.config.max_upload_size; + let files = crate::interact::upload::upload_files(page, &handle, &paths, max_size)?; + + let count = files.len(); + if let Some(ref name) = handle.name { + self.form_state.set_files(name, files); + } + + Ok(InteractionResult::FilesSet { + selector: handle.selector.clone(), + count, + }) + } + + /// Upload files to a file input element by its element ID. + pub fn upload_by_id(&mut self, id: usize, paths: Vec) -> anyhow::Result { + if !self.config.sandbox.is_off() { + anyhow::bail!("file uploads are blocked in sandbox mode"); + } + + let page = self.require_active_page()?; + let handle = page.find_by_element_id(id).ok_or_else(|| { + anyhow::anyhow!("Element with ID {} not found", id) + })?; + + let max_size = self.config.max_upload_size; + let files = crate::interact::upload::upload_files(page, &handle, &paths, max_size)?; + + let count = files.len(); + if let Some(ref name) = handle.name { + self.form_state.set_files(name, files); + } + + Ok(InteractionResult::FilesSet { + selector: handle.selector.clone(), + count, + }) + } } diff --git a/crates/pardus-core/src/browser/traits.rs b/crates/pardus-core/src/browser/traits.rs index c7579f1..f0a0d4e 100644 --- a/crates/pardus-core/src/browser/traits.rs +++ b/crates/pardus-core/src/browser/traits.rs @@ -13,6 +13,7 @@ use crate::interact::FormState; use crate::interact::ScrollDirection; use crate::tab::{Tab, TabId}; use crate::tab::tab::TabConfig; +use std::path::PathBuf; /// Navigation operations. #[async_trait::async_trait] @@ -70,6 +71,12 @@ pub trait Interactor: Send + Sync { /// Select an option in a `waiting"#, + "https://example.com", + ); + + let result = js_dispatch_event(&page, "#field", "change", None).await; + assert!(result.is_ok()); + match result.unwrap() { + InteractionResult::Navigated(new_page) => { + let html = new_page.html.html(); + assert!(html.contains("changed"), "Expected 'changed' in output, got: {}", html); + } + other => panic!("Expected Navigated, got: {:?}", other), + } + } + + #[tokio::test] + async fn test_js_dispatch_focus_event() { + let page = crate::Page::from_html( + r#"blurred"#, + "https://example.com", + ); + + let result = js_dispatch_event(&page, "#field", "focus", None).await; + assert!(result.is_ok()); + match result.unwrap() { + InteractionResult::Navigated(new_page) => { + let html = new_page.html.html(); + assert!(html.contains("focused"), "Expected 'focused' in output, got: {}", html); + } + other => panic!("Expected Navigated, got: {:?}", other), + } + } + + #[tokio::test] + async fn test_js_dispatch_custom_event() { + let page = crate::Page::from_html( + r#"
waiting"#, + "https://example.com", + ); + + let init = r#"{"bubbles":true,"detail":"hello from custom"}"#; + let result = js_dispatch_event(&page, "#target", "myevent", Some(init)).await; + assert!(result.is_ok()); + match result.unwrap() { + InteractionResult::Navigated(new_page) => { + let html = new_page.html.html(); + assert!( + html.contains("hello from custom"), + "Expected custom event detail in output, got: {}", + html + ); + } + other => panic!("Expected Navigated, got: {:?}", other), + } + } + + #[tokio::test] + async fn test_js_dispatch_event_element_not_found() { + let page = crate::Page::from_html( + "
content
", + "https://example.com", + ); + + let result = js_dispatch_event(&page, "#nonexistent", "click", None).await; + assert!(result.is_ok()); + match result.unwrap() { + InteractionResult::ElementNotFound { selector, reason } => { + assert_eq!(selector, "#nonexistent"); + assert!(reason.contains("no element matches")); + } + other => panic!("Expected ElementNotFound, got: {:?}", other), + } + } + + #[tokio::test] + async fn test_js_dispatch_event_with_init_options() { + let page = crate::Page::from_html( + r#"waiting"#, + "https://example.com", + ); + + let init = r#"{"bubbles":true,"cancelable":true}"#; + let result = js_dispatch_event(&page, "#field", "blur", Some(init)).await; + assert!(result.is_ok()); + match result.unwrap() { + InteractionResult::Navigated(new_page) => { + let html = new_page.html.html(); + assert!(html.contains("blurred"), "Expected 'blurred' in output, got: {}", html); + } + other => panic!("Expected Navigated, got: {:?}", other), + } + } } diff --git a/crates/pardus-core/src/interact/mod.rs b/crates/pardus-core/src/interact/mod.rs index b78d5f7..a885ab4 100644 --- a/crates/pardus-core/src/interact/mod.rs +++ b/crates/pardus-core/src/interact/mod.rs @@ -5,6 +5,7 @@ pub mod element; pub mod form; pub mod recording; pub mod scroll; +pub mod upload; pub mod wait; #[cfg(feature = "js")] pub mod js_interact; @@ -17,5 +18,6 @@ pub use action_plan::{ActionPlan, ActionType, PageType, SuggestedAction}; pub use auto_fill::{AutoFillValues, AutoFillResult, ValidationStatus}; pub use recording::{SessionRecording, SessionRecorder, RecordedAction, RecordedActionType, ReplayStepResult, replay}; pub use wait::{wait_for_selector, WaitCondition, wait_smart}; +pub use upload::{FileEntry, UploadError, upload_files, validate_accept}; #[cfg(feature = "js")] -pub use js_interact::{js_click, js_type, js_scroll, js_submit}; +pub use js_interact::{js_click, js_type, js_scroll, js_submit, js_dispatch_event}; diff --git a/crates/pardus-core/src/interact/recording.rs b/crates/pardus-core/src/interact/recording.rs index 8b5efc2..c98a6f2 100644 --- a/crates/pardus-core/src/interact/recording.rs +++ b/crates/pardus-core/src/interact/recording.rs @@ -266,6 +266,8 @@ fn extract_result_url(result: &InteractionResult) -> (Option, bool) { InteractionResult::Selected { .. } => (None, true), InteractionResult::WaitSatisfied { found, .. } => (None, *found), InteractionResult::ElementNotFound { reason: _, .. } => (None, false), + InteractionResult::EventDispatched { .. } => (None, true), + InteractionResult::FilesSet { .. } => (None, true), } } diff --git a/crates/pardus-core/src/interact/upload.rs b/crates/pardus-core/src/interact/upload.rs new file mode 100644 index 0000000..78779a8 --- /dev/null +++ b/crates/pardus-core/src/interact/upload.rs @@ -0,0 +1,169 @@ +use std::path::{Path, PathBuf}; + +use super::element::ElementHandle; +use crate::page::Page; + +#[derive(Debug, Clone)] +pub struct FileEntry { + pub path: PathBuf, + pub file_name: String, + pub mime_type: String, + pub content: Vec, +} + +#[derive(Debug, thiserror::Error)] +pub enum UploadError { + #[error("file not found: {0}")] + FileNotFound(PathBuf), + #[error("file size ({size}) exceeds maximum allowed ({max})")] + FileTooLarge { size: usize, max: usize }, + #[error("file type '{mime}' not accepted by accept filter: {accept}")] + AcceptMismatch { mime: String, accept: String }, + #[error("element is not a file input")] + NotFileInput, + #[error("element is disabled")] + Disabled, + #[error("multiple attribute not set, only 1 file allowed (got {count})")] + MultipleNotAllowed { count: usize }, + #[error("sandbox mode active: file uploads are blocked")] + SandboxBlocked, + #[error("path must be absolute: {0}")] + NotAbsolutePath(PathBuf), + #[error("IO error: {0}")] + Io(#[from] std::io::Error), +} + +impl FileEntry { + pub fn new(path: &Path, max_size: usize) -> Result { + if !path.is_absolute() { + return Err(UploadError::NotAbsolutePath(path.to_path_buf())); + } + + if !path.exists() { + return Err(UploadError::FileNotFound(path.to_path_buf())); + } + + if path.is_symlink() { + return Err(UploadError::FileNotFound(path.to_path_buf())); + } + + let canonical = path.canonicalize().map_err(|e| UploadError::Io(e))?; + + let content = std::fs::read(&canonical)?; + let size = content.len(); + + if size > max_size { + return Err(UploadError::FileTooLarge { + size, + max: max_size, + }); + } + + let file_name = canonical + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("unknown") + .to_string(); + + let mime_type = detect_mime(&canonical, &content); + + Ok(FileEntry { + path: canonical, + file_name, + mime_type, + content, + }) + } +} + +fn detect_mime(path: &Path, content: &[u8]) -> String { + if let Some(mime) = mime_guess::from_path(path).first() { + let s = mime.essence_str().to_string(); + if s != "application/octet-stream" { + return s; + } + } + + match content { + [0x25, 0x50, 0x44, 0x46, ..] => "application/pdf".to_string(), + [0x89, 0x50, 0x4E, 0x47, ..] => "image/png".to_string(), + [0xFF, 0xD8, 0xFF, ..] => "image/jpeg".to_string(), + [0x47, 0x49, 0x46, 0x38, ..] => "image/gif".to_string(), + [0x52, 0x49, 0x46, 0x46, ..] => "image/webp".to_string(), + [0x1F, 0x8B, ..] => "application/gzip".to_string(), + _ => "application/octet-stream".to_string(), + } +} + +pub fn validate_accept(file_name: &str, mime: &str, accept: &str) -> Result<(), UploadError> { + let patterns: Vec<&str> = accept + .split(',') + .map(|s| s.trim()) + .filter(|s| !s.is_empty()) + .collect(); + + if patterns.is_empty() { + return Ok(()); + } + + let ext = Path::new(file_name) + .extension() + .and_then(|e| e.to_str()) + .map(|e| format!(".{}", e.to_lowercase())) + .unwrap_or_default(); + + for pattern in &patterns { + if pattern.starts_with('.') { + if ext == pattern.to_lowercase() { + return Ok(()); + } + } else if pattern.ends_with("/*") { + let prefix = &pattern[..pattern.len() - 2]; + if mime.starts_with(prefix) { + return Ok(()); + } + } else if mime == *pattern { + return Ok(()); + } + } + + Err(UploadError::AcceptMismatch { + mime: mime.to_string(), + accept: accept.to_string(), + }) +} + +pub fn upload_files( + _page: &Page, + handle: &ElementHandle, + paths: &[PathBuf], + max_size: usize, +) -> Result, UploadError> { + if handle.is_disabled { + return Err(UploadError::Disabled); + } + + if handle.action.as_deref() != Some("upload") { + if handle.input_type.as_deref() == Some("file") { + return Err(UploadError::NotFileInput); + } + return Err(UploadError::NotFileInput); + } + + if !handle.multiple && paths.len() > 1 { + return Err(UploadError::MultipleNotAllowed { count: paths.len() }); + } + + let accept = handle.accept.as_deref().unwrap_or(""); + + let mut files = Vec::new(); + for path in paths { + let entry = FileEntry::new(path, max_size)?; + if !accept.is_empty() { + validate_accept(&entry.file_name, &entry.mime_type, accept)?; + } + files.push(entry); + } + + Ok(files) +} diff --git a/crates/pardus-core/src/js/bootstrap.js b/crates/pardus-core/src/js/bootstrap.js index b79c1ce..c0e40cb 100644 --- a/crates/pardus-core/src/js/bootstrap.js +++ b/crates/pardus-core/src/js/bootstrap.js @@ -112,6 +112,53 @@ class Element { set textContent(v) { Deno.core.ops.op_set_text_content(this.__nodeId, v); } get outerHTML() { return Deno.core.ops.op_get_inner_html(this.__nodeId); } + // ---- Form element properties (proxy via attributes) ---- + get value() { return Deno.core.ops.op_get_attribute(this.__nodeId, 'value') || ''; } + set value(v) { Deno.core.ops.op_set_attribute(this.__nodeId, 'value', String(v)); } + get checked() { return Deno.core.ops.op_get_attribute(this.__nodeId, 'checked') !== null; } + set checked(v) { + if (v) { Deno.core.ops.op_set_attribute(this.__nodeId, 'checked', ''); } + else { Deno.core.ops.op_remove_attribute(this.__nodeId, 'checked'); } + } + get disabled() { return Deno.core.ops.op_get_attribute(this.__nodeId, 'disabled') !== null; } + set disabled(v) { + if (v) { Deno.core.ops.op_set_attribute(this.__nodeId, 'disabled', ''); } + else { Deno.core.ops.op_remove_attribute(this.__nodeId, 'disabled'); } + } + get type() { return Deno.core.ops.op_get_attribute(this.__nodeId, 'type') || ''; } + set type(v) { Deno.core.ops.op_set_attribute(this.__nodeId, 'type', String(v)); } + get placeholder() { return Deno.core.ops.op_get_attribute(this.__nodeId, 'placeholder') || ''; } + set placeholder(v) { Deno.core.ops.op_set_attribute(this.__nodeId, 'placeholder', String(v)); } + get href() { return Deno.core.ops.op_get_attribute(this.__nodeId, 'href') || ''; } + set href(v) { Deno.core.ops.op_set_attribute(this.__nodeId, 'href', String(v)); } + get src() { return Deno.core.ops.op_get_attribute(this.__nodeId, 'src') || ''; } + set src(v) { Deno.core.ops.op_set_attribute(this.__nodeId, 'src', String(v)); } + get alt() { return Deno.core.ops.op_get_attribute(this.__nodeId, 'alt') || ''; } + set alt(v) { Deno.core.ops.op_set_attribute(this.__nodeId, 'alt', String(v)); } + get action() { return Deno.core.ops.op_get_attribute(this.__nodeId, 'action') || ''; } + get method() { return Deno.core.ops.op_get_attribute(this.__nodeId, 'method') || 'GET'; } + get name() { return Deno.core.ops.op_get_attribute(this.__nodeId, 'name') || ''; } + set name(v) { Deno.core.ops.op_set_attribute(this.__nodeId, 'name', String(v)); } + + // ---- Layout stubs (headless — no real layout engine) ---- + get offsetWidth() { return 0; } + get offsetHeight() { return 0; } + get clientWidth() { return 0; } + get clientHeight() { return 0; } + get offsetLeft() { return 0; } + get offsetTop() { return 0; } + get scrollWidth() { return 0; } + get scrollHeight() { return 0; } + get scrollTop() { return 0; } + set scrollTop(_v) { /* no-op */ } + get scrollLeft() { return 0; } + set scrollLeft(_v) { /* no-op */ } + getBoundingClientRect() { + return { top: 0, left: 0, right: 0, bottom: 0, width: 0, height: 0, x: 0, y: 0, + toJSON: function() { return this; } }; + } + getClientRects() { return []; } + get children() { return Deno.core.ops.op_get_children(this.__nodeId).map(id => new Element(id)); } @@ -396,6 +443,26 @@ class Element { const event = new Event('click', { bubbles: true, cancelable: true }); this.dispatchEvent(event); } + + // Walk up the parent chain looking for a matching selector + closest(selector) { + let current = this; + while (current) { + try { + if (current.matches(selector)) return current; + } catch(e) { return null; } + current = current.parentElement; + } + return null; + } + + // Check if this element matches a CSS selector + matches(selector) { + const parent = this.parentElement; + if (!parent) return false; + const found = parent.querySelector(selector); + return found !== null && found.__nodeId === this.__nodeId; + } } // ==================== MutationObserver ==================== @@ -1088,3 +1155,76 @@ if (typeof globalThis.queueMicrotask === 'undefined') { Promise.resolve().then(cb); }; } + +// ==================== IntersectionObserver ==================== +// Stub that immediately reports all observed elements as visible. +// Critical for lazy-loaded content (images, components). + +class IntersectionObserver { + constructor(callback, options) { + this.__callback = callback; + this.__options = options || {}; + this.__targets = []; + } + observe(target) { + if (target && this.__targets.indexOf(target) < 0) { + this.__targets.push(target); + // Fire immediately — in a headless browser everything is "visible" + const entry = { + target: target, + isIntersecting: true, + intersectionRatio: 1, + boundingClientRect: target.getBoundingClientRect ? target.getBoundingClientRect() : { top: 0, left: 0, bottom: 0, right: 0, width: 0, height: 0 }, + intersectionRect: target.getBoundingClientRect ? target.getBoundingClientRect() : { top: 0, left: 0, bottom: 0, right: 0, width: 0, height: 0 }, + rootBounds: { top: 0, left: 0, bottom: 720, right: 1280, width: 1280, height: 720 }, + time: Date.now() + }; + const self = this; + Promise.resolve().then(function() { + try { self.__callback([entry], self); } catch(e) {} + }); + } + } + unobserve(target) { + const idx = this.__targets.indexOf(target); + if (idx >= 0) this.__targets.splice(idx, 1); + } + disconnect() { this.__targets.length = 0; } + takeRecords() { return []; } +} + +// ==================== ResizeObserver ==================== +class ResizeObserver { + constructor(callback) { this.__callback = callback; this.__targets = []; } + observe(target) { + if (target && this.__targets.indexOf(target) < 0) { + this.__targets.push(target); + } + } + unobserve(target) { + const idx = this.__targets.indexOf(target); + if (idx >= 0) this.__targets.splice(idx, 1); + } + disconnect() { this.__targets.length = 0; } +} + +// ==================== CSS Utility ==================== +globalThis.CSS = { + supports: function(prop, value) { + if (arguments.length === 1) return false; + return false; + }, + escape: function(s) { return s.replace(/[!"#$%&'()*+,.\/:;<=>?@[\\\]^`{|}~]/g, '\\$&'); } +}; + +// ==================== Scroll APIs ==================== +window.scrollTo = function() {}; +window.scrollBy = function() {}; +window.scroll = function() {}; +Element.prototype.scrollIntoView = function() {}; +Element.prototype.scrollIntoViewIfNeeded = function() {}; + +// ==================== Element.prototype extensions ==================== +Object.defineProperty(Element.prototype, 'nodeType', { value: 1 }); +Object.defineProperty(Element.prototype, 'ELEMENT_NODE', { value: 1 }); +Object.defineProperty(Element.prototype, 'TEXT_NODE', { value: 3 }); diff --git a/crates/pardus-core/src/js/dom.rs b/crates/pardus-core/src/js/dom.rs index 2d18095..1b67b8a 100644 --- a/crates/pardus-core/src/js/dom.rs +++ b/crates/pardus-core/src/js/dom.rs @@ -69,6 +69,10 @@ pub struct DomDocument { next_observer_id: u32, /// Maximum number of nodes allowed. None = unlimited. max_nodes: Option, + /// HTML snapshot stack for undo. + undo_stack: Vec, + /// HTML snapshot stack for redo. + redo_stack: Vec, } #[derive(Debug, Clone, PartialEq)] @@ -129,6 +133,8 @@ impl DomDocument { observers: Vec::new(), next_observer_id: 1, max_nodes: None, + undo_stack: Vec::new(), + redo_stack: Vec::new(), }; // Create document root @@ -741,6 +747,143 @@ impl DomDocument { ); } + // ---- Node Manipulation Methods ---- + + /// Set the nodeValue of a node (text/comment nodes). + /// For element nodes this is a no-op (nodeValue is null per DOM spec). + pub fn set_node_value(&mut self, node_id: NodeId, value: &str) { + let node_type = self.nodes.get(&node_id).map(|n| n.node_type.clone()); + let old_value = self.nodes.get(&node_id).and_then(|n| n.text_content.clone()); + + match node_type { + Some(DomNodeType::Text) | Some(DomNodeType::Comment) => { + if let Some(node) = self.nodes.get_mut(&node_id) { + node.text_content = Some(value.to_string()); + } + self.queue_mutation("characterData", node_id, vec![], vec![], None, old_value); + } + _ => {} + } + } + + /// Rename an element's tag name. Returns the old tag name (uppercase) on success. + pub fn set_node_name(&mut self, node_id: NodeId, new_name: &str) -> Option { + let old_name = self.nodes.get(&node_id).and_then(|n| n.tag_name.clone())?; + let node_type = self.nodes.get(&node_id)?.node_type.clone(); + if node_type != DomNodeType::Element { + return None; + } + + let new_name_lower = new_name.to_lowercase(); + + // Update tag_index: remove old entry + if let Some(vec) = self.tag_index.get_mut(&old_name) { + vec.retain(|&id| id != node_id); + if vec.is_empty() { + self.tag_index.remove(&old_name); + } + } + // Add new entry + self.tag_index + .entry(new_name_lower.clone()) + .or_default() + .push(node_id); + + // Update the node + if let Some(node) = self.nodes.get_mut(&node_id) { + node.tag_name = Some(new_name_lower); + } + + self.queue_mutation( + "attributes", + node_id, + vec![], + vec![], + Some("tagName".to_string()), + Some(old_name.to_uppercase()), + ); + + Some(old_name.to_uppercase()) + } + + /// Deep-clone a node and append it to a new parent. Returns the cloned node id. + pub fn copy_to(&mut self, node_id: NodeId, target_parent_id: NodeId) -> NodeId { + let clone_id = self.clone_node(node_id, true); + self.append_child(target_parent_id, clone_id); + clone_id + } + + /// Move a node from its current parent to a new parent. + /// Optionally inserts before a reference node. + pub fn move_to( + &mut self, + node_id: NodeId, + target_parent_id: NodeId, + before_node_id: Option, + ) -> NodeId { + match before_node_id { + Some(ref_id) => { + self.insert_before(target_parent_id, node_id, Some(ref_id)); + } + None => { + self.append_child(target_parent_id, node_id); + } + } + node_id + } + + // ---- Undo/Redo ---- + + /// Push current state onto the undo stack and clear the redo stack. + pub fn mark_undoable_state(&mut self) { + self.undo_stack.push(self.to_html()); + self.redo_stack.clear(); + } + + /// Undo the last marked state. Returns false if the undo stack is empty. + pub fn undo(&mut self) -> bool { + if let Some(snapshot) = self.undo_stack.pop() { + let current = self.to_html(); + self.redo_stack.push(current); + self.replace_from_html(&snapshot); + true + } else { + false + } + } + + /// Redo the last undone state. Returns false if the redo stack is empty. + pub fn redo(&mut self) -> bool { + if let Some(snapshot) = self.redo_stack.pop() { + let current = self.to_html(); + self.undo_stack.push(current); + self.replace_from_html(&snapshot); + true + } else { + false + } + } + + /// Replace the entire document state from an HTML snapshot, + /// preserving observer registrations and undo/redo stacks. + fn replace_from_html(&mut self, html: &str) { + let observers = std::mem::take(&mut self.observers); + let pending_mutations = std::mem::take(&mut self.pending_mutations); + let next_observer_id = self.next_observer_id; + let max_nodes = self.max_nodes; + let undo_stack = std::mem::take(&mut self.undo_stack); + let redo_stack = std::mem::take(&mut self.redo_stack); + + *self = Self::from_html(html); + + self.observers = observers; + self.pending_mutations = pending_mutations; + self.next_observer_id = next_observer_id; + self.max_nodes = max_nodes; + self.undo_stack = undo_stack; + self.redo_stack = redo_stack; + } + pub fn set_inner_html(&mut self, node_id: NodeId, html: &str) { // Remove existing children (indexes updated in remove_recursive) let old_children: Vec = self @@ -2377,4 +2520,218 @@ mod tests { // original_html is freed after DOM construction to save memory assert!(doc.original_html.is_none()); } + + // ==================== Node Manipulation Tests ==================== + + #[test] + fn test_set_node_value_text() { + let html = "
Hello
"; + let mut doc = DomDocument::from_html(html); + let el = doc.get_element_by_id("el").unwrap(); + let children = doc.get_children(el); + let text_id = children + .into_iter() + .find(|&c| doc.get_node_type(c) == 3) + .unwrap(); + doc.set_node_value(text_id, "World"); + assert_eq!(doc.get_text_content(el), "World"); + } + + #[test] + fn test_set_node_value_comment() { + let html = "
"; + let mut doc = DomDocument::from_html(html); + let el = doc.get_element_by_id("el").unwrap(); + let children = doc.get_children(el); + let comment_id = children + .iter() + .find(|&&c| doc.get_node_type(c) == 8) + .copied(); + if let Some(cid) = comment_id { + doc.set_node_value(cid, "new"); + let output = doc.to_html(); + assert!(output.contains(""), "expected comment in output: {}", output); + } else { + // Scraper may strip comments in some cases; test via direct text node + let text_id = children + .into_iter() + .find(|&c| doc.get_node_type(c) == 3) + .expect("should have at least a text child"); + doc.set_node_value(text_id, "updated"); + assert_eq!(doc.get_text_content(el), "updated"); + } + } + + #[test] + fn test_set_node_value_element_noop() { + let html = "
text
"; + let mut doc = DomDocument::from_html(html); + let el = doc.get_element_by_id("el").unwrap(); + doc.set_node_value(el, "ignored"); + assert_eq!(doc.get_text_content(el), "text"); + } + + #[test] + fn test_set_node_name() { + let html = "
content
"; + let mut doc = DomDocument::from_html(html); + let div = doc.get_element_by_id("target").unwrap(); + let old = doc.set_node_name(div, "span"); + assert_eq!(old, Some("DIV".to_string())); + let output = doc.to_html(); + assert!(output.contains("")); + assert!(!output.contains("
")); + } + + #[test] + fn test_set_node_name_updates_tag_index() { + let html = "
content
"; + let mut doc = DomDocument::from_html(html); + let div = doc.get_element_by_id("target").unwrap(); + doc.set_node_name(div, "span"); + let spans = doc.query_selector_all(0, "span"); + assert!(spans.contains(&div)); + } + + #[test] + fn test_copy_to() { + let html = "
child
"; + let mut doc = DomDocument::from_html(html); + let src = doc.get_element_by_id("src").unwrap(); + let dst = doc.get_element_by_id("dst").unwrap(); + let clone_id = doc.copy_to(src, dst); + let dst_children = doc.get_children(dst); + assert_eq!(dst_children.len(), 1); + assert_eq!(dst_children[0], clone_id); + let clone_children = doc.get_children(clone_id); + assert!(!clone_children.is_empty()); + } + + #[test] + fn test_copy_to_preserves_original() { + let html = "
child
"; + let mut doc = DomDocument::from_html(html); + let src = doc.get_element_by_id("src").unwrap(); + let dst = doc.get_element_by_id("dst").unwrap(); + doc.copy_to(src, dst); + assert!(!doc.get_children(src).is_empty()); + } + + #[test] + fn test_move_to() { + let html = "
content
"; + let mut doc = DomDocument::from_html(html); + let child = doc.get_element_by_id("child").unwrap(); + let parent2 = doc.get_element_by_id("parent2").unwrap(); + doc.move_to(child, parent2, None); + let p2_children = doc.get_children(parent2); + assert!(p2_children.contains(&child)); + let parent1 = doc.get_element_by_id("parent1").unwrap(); + let p1_children = doc.get_children(parent1); + assert!(!p1_children.contains(&child)); + } + + #[test] + fn test_move_to_with_insert_before() { + let html = "
move me
firstlast
"; + let mut doc = DomDocument::from_html(html); + let mover = doc.get_element_by_id("mover").unwrap(); + let parent2 = doc.get_element_by_id("parent2").unwrap(); + let last = doc.get_element_by_id("last").unwrap(); + doc.move_to(mover, parent2, Some(last)); + let children = doc.get_children(parent2); + let mover_pos = children.iter().position(|&id| id == mover).unwrap(); + let last_pos = children.iter().position(|&id| id == last).unwrap(); + assert!(mover_pos < last_pos); + } + + // ==================== Undo/Redo Tests ==================== + + #[test] + fn test_undo_redo() { + let html = "
original
"; + let mut doc = DomDocument::from_html(html); + doc.mark_undoable_state(); + let target = doc.get_element_by_id("target").unwrap(); + doc.set_text_content(target, "changed"); + assert_eq!(doc.get_text_content(doc.get_element_by_id("target").unwrap()), "changed"); + + // Undo: restores to "original" + assert!(doc.undo()); + assert_eq!(doc.get_text_content(doc.get_element_by_id("target").unwrap()), "original"); + + // Redo: back to "changed" + assert!(doc.redo()); + assert_eq!(doc.get_text_content(doc.get_element_by_id("target").unwrap()), "changed"); + } + + #[test] + fn test_undo_empty_stack() { + let html = "
content
"; + let mut doc = DomDocument::from_html(html); + assert!(!doc.undo()); + } + + #[test] + fn test_redo_empty_stack() { + let html = "
content
"; + let mut doc = DomDocument::from_html(html); + assert!(!doc.redo()); + } + + #[test] + fn test_redo_cleared_on_new_mark() { + let html = "
original
"; + let mut doc = DomDocument::from_html(html); + doc.mark_undoable_state(); + doc.set_text_content(doc.get_element_by_id("target").unwrap(), "first"); + doc.mark_undoable_state(); + doc.set_text_content(doc.get_element_by_id("target").unwrap(), "second"); + + // Undo back to first + assert!(doc.undo()); + assert_eq!(doc.get_text_content(doc.get_element_by_id("target").unwrap()), "first"); + + // New mutation clears redo + doc.set_text_content(doc.get_element_by_id("target").unwrap(), "new"); + doc.mark_undoable_state(); + assert!(!doc.redo()); + } + + #[test] + fn test_multiple_undo_levels() { + let html = "
v0
"; + let mut doc = DomDocument::from_html(html); + + doc.mark_undoable_state(); // saves "v0" + doc.set_text_content(doc.get_element_by_id("target").unwrap(), "v1"); + doc.mark_undoable_state(); // saves "v1" + doc.set_text_content(doc.get_element_by_id("target").unwrap(), "v2"); + doc.mark_undoable_state(); // saves "v2" + doc.set_text_content(doc.get_element_by_id("target").unwrap(), "v3"); + + // Undo back to v2 + assert!(doc.undo()); + assert_eq!(doc.get_text_content(doc.get_element_by_id("target").unwrap()), "v2"); + + // Undo to v1 + assert!(doc.undo()); + assert_eq!(doc.get_text_content(doc.get_element_by_id("target").unwrap()), "v1"); + + // Undo to v0 + assert!(doc.undo()); + assert_eq!(doc.get_text_content(doc.get_element_by_id("target").unwrap()), "v0"); + + // Redo to v1 + assert!(doc.redo()); + assert_eq!(doc.get_text_content(doc.get_element_by_id("target").unwrap()), "v1"); + + // Redo to v2 + assert!(doc.redo()); + assert_eq!(doc.get_text_content(doc.get_element_by_id("target").unwrap()), "v2"); + + // Redo to v3 (current state before first undo) + assert!(doc.redo()); + assert_eq!(doc.get_text_content(doc.get_element_by_id("target").unwrap()), "v3"); + } } diff --git a/crates/pardus-core/src/js/extension.rs b/crates/pardus-core/src/js/extension.rs index 89d8d50..ed0c7f1 100644 --- a/crates/pardus-core/src/js/extension.rs +++ b/crates/pardus-core/src/js/extension.rs @@ -63,6 +63,15 @@ deno_core::extension!( op_take_mutation_records, op_has_observers, op_drain_pending_mutations, + // Node manipulation + op_set_node_value, + op_set_node_name, + op_copy_to, + op_move_to, + // Undo/Redo + op_mark_undoable_state, + op_undo, + op_redo, // SSE / EventSource op_sse_open, op_sse_close, diff --git a/crates/pardus-core/src/js/ops.rs b/crates/pardus-core/src/js/ops.rs index 5870c92..79d1fdf 100644 --- a/crates/pardus-core/src/js/ops.rs +++ b/crates/pardus-core/src/js/ops.rs @@ -347,3 +347,51 @@ pub fn op_drain_pending_mutations( let dom = state.borrow::>>().clone(); dom.borrow_mut().drain_all_pending_mutations() } + +// ==================== Node Manipulation Ops ==================== + +#[op2(fast)] +pub fn op_set_node_value(state: &mut OpState, node_id: u32, #[string] value: &str) { + let dom = state.borrow::>>().clone(); + dom.borrow_mut().set_node_value(node_id, value); +} + +#[op2] +#[string] +pub fn op_set_node_name(state: &mut OpState, node_id: u32, #[string] name: &str) -> Option { + let dom = state.borrow::>>().clone(); + dom.borrow_mut().set_node_name(node_id, name) +} + +#[op2(fast)] +pub fn op_copy_to(state: &mut OpState, node_id: u32, target_parent_id: u32) -> u32 { + let dom = state.borrow::>>().clone(); + dom.borrow_mut().copy_to(node_id, target_parent_id) +} + +#[op2(fast)] +pub fn op_move_to(state: &mut OpState, node_id: u32, target_parent_id: u32, before_node_id: u32) -> u32 { + let dom = state.borrow::>>().clone(); + let before = if before_node_id == 0 { None } else { Some(before_node_id) }; + dom.borrow_mut().move_to(node_id, target_parent_id, before) +} + +// ==================== Undo/Redo Ops ==================== + +#[op2(fast)] +pub fn op_mark_undoable_state(state: &mut OpState) { + let dom = state.borrow::>>().clone(); + dom.borrow_mut().mark_undoable_state(); +} + +#[op2(fast)] +pub fn op_undo(state: &mut OpState) -> bool { + let dom = state.borrow::>>().clone(); + dom.borrow_mut().undo() +} + +#[op2(fast)] +pub fn op_redo(state: &mut OpState) -> bool { + let dom = state.borrow::>>().clone(); + dom.borrow_mut().redo() +} diff --git a/crates/pardus-core/src/lib.rs b/crates/pardus-core/src/lib.rs index c0a456e..d7681c1 100644 --- a/crates/pardus-core/src/lib.rs +++ b/crates/pardus-core/src/lib.rs @@ -53,6 +53,7 @@ pub use output::tree_formatter::format_tree; pub use output::json_formatter::format_json; pub use output::llm_formatter::format_llm; pub use interact::{ElementHandle, FormState, InteractionResult, ScrollDirection}; +pub use interact::upload::{FileEntry, UploadError}; #[cfg(feature = "js")] pub use interact::action_plan::{ActionPlan, ActionType, PageType, SuggestedAction}; #[cfg(feature = "js")] diff --git a/crates/pardus-core/src/navigation/graph.rs b/crates/pardus-core/src/navigation/graph.rs index 8937127..5ae11ac 100644 --- a/crates/pardus-core/src/navigation/graph.rs +++ b/crates/pardus-core/src/navigation/graph.rs @@ -27,6 +27,7 @@ pub struct FormDescriptor { pub id: Option, pub action: Option, pub method: String, + pub enctype: Option, pub fields: Vec, } @@ -140,6 +141,7 @@ impl NavigationGraph { .unwrap_or_else(|| "GET".to_string()); let id = form_el.value().attr("id").map(|s| s.to_string()); + let enctype = form_el.value().attr("enctype").map(|s| s.to_string()); let mut fields = Vec::new(); for field_el in form_el.select(&*INPUT_SELECTOR) { @@ -166,6 +168,7 @@ impl NavigationGraph { id, action, method, + enctype, fields, }); } diff --git a/crates/pardus-core/src/output/llm_formatter.rs b/crates/pardus-core/src/output/llm_formatter.rs index fd8b8d0..80726b5 100644 --- a/crates/pardus-core/src/output/llm_formatter.rs +++ b/crates/pardus-core/src/output/llm_formatter.rs @@ -272,6 +272,27 @@ fn collect_flat( } } } + SemanticRole::FileInput => { + if node.is_interactive { + if let Some(id) = node.element_id { + let name = node.name.as_deref().unwrap_or(""); + let mut s = format!("[#{}] file \"{}\"", id, name); + if node.is_required { + s.push_str(" [required]"); + } + if let Some(accept) = &node.accept { + s.push_str(&format!(" [accept: {}]", truncate(accept, 40))); + } + if node.multiple { + s.push_str(" [multiple]"); + } + if node.is_disabled { + s.push_str(" [off]"); + } + inputs.push(s); + } + } + } SemanticRole::Form => { let name = node.name.as_deref().unwrap_or(""); let s = format!("form \"{}\" [{} fields]", name, count_inputs(node)); @@ -326,6 +347,7 @@ fn count_inputs(node: &SemanticNode) -> usize { | SemanticRole::Checkbox | SemanticRole::Radio | SemanticRole::Combobox + | SemanticRole::FileInput ) && node.is_interactive { count += 1; diff --git a/crates/pardus-core/src/output/md_formatter.rs b/crates/pardus-core/src/output/md_formatter.rs index 49bcd01..dab8d79 100644 --- a/crates/pardus-core/src/output/md_formatter.rs +++ b/crates/pardus-core/src/output/md_formatter.rs @@ -123,6 +123,24 @@ fn node_description(node: &SemanticNode) -> String { } s } + SemanticRole::FileInput => { + let name = node.name.as_deref().unwrap_or(""); + let mut s = if name.is_empty() { + format!("{id_prefix}fileinput") + } else { + format!("{id_prefix}fileinput \"{name}\"") + }; + if let Some(action) = &node.action { + s.push_str(&format!(" [action: {action}]")); + } + if let Some(accept) = &node.accept { + s.push_str(&format!(" [accept: {accept}]")); + } + if node.multiple { + s.push_str(" [multiple]"); + } + s + } SemanticRole::Checkbox => { let name = node.name.as_deref().unwrap_or(""); format!("{id_prefix}checkbox \"{name}\" [action: toggle]") diff --git a/crates/pardus-core/src/output/tree_formatter.rs b/crates/pardus-core/src/output/tree_formatter.rs index 86c1fc4..c8285cb 100644 --- a/crates/pardus-core/src/output/tree_formatter.rs +++ b/crates/pardus-core/src/output/tree_formatter.rs @@ -69,6 +69,15 @@ fn node_description(node: &SemanticNode) -> String { parts.push(format!("\"{name}\"")); } } + SemanticRole::FileInput => { + parts.push("fileinput".to_string()); + if let Some(name) = &node.name { + parts.push(format!("\"{name}\"")); + } + if let Some(action) = &node.action { + parts.push(format!("[action: {action}]")); + } + } SemanticRole::StaticText => { if let Some(name) = &node.name { parts.push(format!("text \"{name}\"")); diff --git a/crates/pardus-core/src/page.rs b/crates/pardus-core/src/page.rs index 0b12491..b3ceea1 100644 --- a/crates/pardus-core/src/page.rs +++ b/crates/pardus-core/src/page.rs @@ -1186,6 +1186,8 @@ fn build_handle_with_selector(el: &ElementRef, selector: String) -> ElementHandl label, input_type, value, + accept: None, + multiple: false, } } diff --git a/crates/pardus-core/src/pdf.rs b/crates/pardus-core/src/pdf.rs index 4b48419..a8a98f7 100644 --- a/crates/pardus-core/src/pdf.rs +++ b/crates/pardus-core/src/pdf.rs @@ -518,6 +518,8 @@ fn extract_field_node( max_val: None, step_val: None, autocomplete: None, + accept: None, + multiple: false, children: child_nodes, }); } @@ -548,12 +550,11 @@ fn extract_field_node( max_val: None, step_val: None, autocomplete: None, + accept: None, + multiple: false, children: Vec::new(), }) } - -// --------------------------------------------------------------------------- -// Image extraction (metadata only — dimensions and format) // --------------------------------------------------------------------------- fn extract_images(bytes: &[u8]) -> Vec { @@ -719,6 +720,8 @@ fn make_node( max_val: None, step_val: None, autocomplete: None, + accept: None, + multiple: false, children, } } diff --git a/crates/pardus-core/src/semantic/tree.rs b/crates/pardus-core/src/semantic/tree.rs index ae5c008..d530c33 100644 --- a/crates/pardus-core/src/semantic/tree.rs +++ b/crates/pardus-core/src/semantic/tree.rs @@ -81,6 +81,12 @@ pub struct SemanticNode { /// The autocomplete attribute hint. #[serde(skip_serializing_if = "Option::is_none")] pub autocomplete: Option, + /// The accept attribute for file inputs (e.g., "image/*,.pdf"). + #[serde(skip_serializing_if = "Option::is_none")] + pub accept: Option, + /// Whether the element has the multiple attribute (file inputs, selects). + #[serde(skip_serializing_if = "is_false", default)] + pub multiple: bool, pub children: Vec, } @@ -130,6 +136,7 @@ pub enum SemanticRole { Link, Button, TextBox, + FileInput, Checkbox, Radio, Combobox, @@ -194,6 +201,7 @@ impl SemanticRole { Self::Link => "link", Self::Button => "button", Self::TextBox => "textbox", + Self::FileInput => "fileinput", Self::Checkbox => "checkbox", Self::Radio => "radio", Self::Combobox => "combobox", @@ -307,6 +315,8 @@ fn make_static_text(content: &str) -> SemanticNode { max_val: None, step_val: None, autocomplete: None, + accept: None, + multiple: false, children: Vec::new(), } } @@ -351,6 +361,8 @@ impl<'a> TreeBuilder<'a> { max_val: None, step_val: None, autocomplete: None, + accept: None, + multiple: false, children: Vec::new(), }; @@ -390,6 +402,15 @@ impl<'a> TreeBuilder<'a> { return None; } + // Skip hidden form inputs — they carry data, not UI + if tag_str == "input" { + if let Some(t) = el.value().attr("type") { + if t.eq_ignore_ascii_case("hidden") { + return None; + } + } + } + // Handle iframe/frame elements if tag_str == "iframe" || tag_str == "frame" { return self.walk_iframe(el, tag_str); @@ -439,7 +460,11 @@ impl<'a> TreeBuilder<'a> { } // Update stats - if role.is_landmark() { + // Per ARIA spec: form and region are only landmarks when they have an accessible name + let is_named_form_or_region = matches!(role, SemanticRole::Form | SemanticRole::Region) && has_name; + let is_other_landmark = role.is_landmark() + && !matches!(role, SemanticRole::Form | SemanticRole::Region); + if is_other_landmark || is_named_form_or_region { self.stats.landmarks += 1; } if matches!(role, SemanticRole::Link) { @@ -505,6 +530,12 @@ impl<'a> TreeBuilder<'a> { let max_val = el.value().attr("max").map(|s| s.to_string()); let step_val = el.value().attr("step").map(|s| s.to_string()); let autocomplete = el.value().attr("autocomplete").map(|s| s.to_string()); + let accept = if tag_str == "input" && input_type.as_deref() == Some("file") { + el.value().attr("accept").map(|s| s.to_string()) + } else { + None + }; + let multiple = el.value().attr("multiple").is_some(); // Extract select options let options = if tag_str == "select" { @@ -550,6 +581,8 @@ impl<'a> TreeBuilder<'a> { max_val, step_val, autocomplete, + accept, + multiple, children: child_nodes, }) } @@ -624,11 +657,21 @@ impl<'a> TreeBuilder<'a> { max_val: None, step_val: None, autocomplete: None, + accept: None, + multiple: false, children: child_nodes, }) } fn compute_name(&self, el: &ElementRef) -> Option { + // aria-labelledby: resolve element IDs and concatenate their text + if let Some(ids) = el.value().attr("aria-labelledby") { + let text = self.resolve_aria_labelledby(ids); + if !text.is_empty() { + return Some(text); + } + } + // aria-label if let Some(label) = el.value().attr("aria-label") { let trimmed = label.trim().to_string(); @@ -749,6 +792,8 @@ impl<'a> TreeBuilder<'a> { "input" => match el.value().attr("type").unwrap_or("text") { "checkbox" => SemanticRole::Checkbox, "radio" => SemanticRole::Radio, + "file" => SemanticRole::FileInput, + "submit" | "reset" | "button" | "image" => SemanticRole::Button, _ => SemanticRole::TextBox, }, "select" => SemanticRole::Combobox, @@ -816,6 +861,7 @@ impl<'a> TreeBuilder<'a> { Some(match input_type { "submit" | "reset" | "button" | "image" => "click".to_string(), "checkbox" | "radio" => "toggle".to_string(), + "file" => "upload".to_string(), _ => "fill".to_string(), }) } @@ -842,6 +888,23 @@ impl<'a> TreeBuilder<'a> { .map(|u| u.to_string()) .unwrap_or_else(|_| href.to_string()) } + + /// Resolve `aria-labelledby` by looking up each referenced element ID + /// and concatenating their text content. + fn resolve_aria_labelledby(&self, ids: &str) -> String { + ids.split_whitespace() + .filter_map(|id| { + let sel = format!("#{}", css_escape_id(id)); + Selector::parse(&sel).ok().and_then(|s| { + self.html.select(&s).next().map(|el| { + el.text().collect::().trim().to_string() + }) + }) + }) + .filter(|s| !s.is_empty()) + .collect::>() + .join(" ") + } } fn parse_role_str(s: &str) -> SemanticRole { @@ -859,6 +922,7 @@ fn parse_role_str(s: &str) -> SemanticRole { "link" => SemanticRole::Link, "button" => SemanticRole::Button, "textbox" => SemanticRole::TextBox, + "fileinput" => SemanticRole::FileInput, "checkbox" => SemanticRole::Checkbox, "radio" => SemanticRole::Radio, "combobox" => SemanticRole::Combobox, diff --git a/crates/pardus-kg/src/fingerprint.rs b/crates/pardus-kg/src/fingerprint.rs index acca86e..d63e9a5 100644 --- a/crates/pardus-kg/src/fingerprint.rs +++ b/crates/pardus-kg/src/fingerprint.rs @@ -14,8 +14,13 @@ pub fn compute_fingerprint( resource_urls: &BTreeSet, ) -> (Fingerprint, ViewStateId) { let parsed = Url::parse(page_url).ok(); - let url_path = parsed.as_ref().map(|u| u.path().to_string()).unwrap_or_default(); - let fragment = parsed.as_ref().and_then(|u| u.fragment().map(|f| f.to_string())); + let url_path = parsed + .as_ref() + .map(|u| u.path().to_string()) + .unwrap_or_default(); + let fragment = parsed + .as_ref() + .and_then(|u| u.fragment().map(|f| f.to_string())); let content_query_params = extract_content_params(parsed.as_ref()); @@ -42,7 +47,9 @@ pub fn discover_resources(html: &Html, base_url: &str) -> BTreeSet { /// Extract query params that affect page content (pagination params). fn extract_content_params(url: Option<&Url>) -> BTreeMap { - let Some(url) = url else { return BTreeMap::new() }; + let Some(url) = url else { + return BTreeMap::new(); + }; let pagination_keys = ["page", "offset", "start", "p"]; let mut params = BTreeMap::new(); @@ -94,6 +101,7 @@ fn role_str(role: &SemanticRole) -> String { SemanticRole::Link => "link".to_string(), SemanticRole::Button => "button".to_string(), SemanticRole::TextBox => "textbox".to_string(), + SemanticRole::FileInput => "fileinput".to_string(), SemanticRole::Checkbox => "checkbox".to_string(), SemanticRole::Radio => "radio".to_string(), SemanticRole::Combobox => "combobox".to_string(), @@ -115,7 +123,11 @@ fn role_str(role: &SemanticRole) -> String { /// Hash a sorted set of resource URLs. fn hash_resource_set(resources: &BTreeSet) -> String { - let concatenated: String = resources.iter().map(|u| u.as_str()).collect::>().join("\n"); + let concatenated: String = resources + .iter() + .map(|u| u.as_str()) + .collect::>() + .join("\n"); let hash = blake3::hash(concatenated.as_bytes()); hash.to_hex().to_string() } @@ -152,8 +164,12 @@ mod tests { #[test] fn test_same_structure_same_hash() { - let t1 = build_tree(r#"

Hello

"#); - let t2 = build_tree(r#"

World

"#); + let t1 = build_tree( + r#"

Hello

"#, + ); + let t2 = build_tree( + r#"

World

"#, + ); // Same structure, different text → same hash assert_eq!(hash_tree_structure(&t1), hash_tree_structure(&t2)); } @@ -161,7 +177,9 @@ mod tests { #[test] fn test_different_structure_different_hash() { let t1 = build_tree(r#""#); - let t2 = build_tree(r#""#); + let t2 = build_tree( + r#""#, + ); // Different structure (1 link vs 2 links) assert_ne!(hash_tree_structure(&t1), hash_tree_structure(&t2)); }