"));
+ }
+
+ #[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));
}