From 6788996b0ba9890bbbd359863d355a0a6e395747 Mon Sep 17 00:00:00 2001
From: SGavrl <277843151+SGavrl@users.noreply.github.com>
Date: Thu, 9 Jul 2026 08:51:18 +0000
Subject: [PATCH] feat(cdp): implement DOM.setFileInputFiles for file uploads
(#369)
Puppeteer's ElementHandle.uploadFile and Playwright's setInputFiles drive
an through CDP DOM.setFileInputFiles, which obscura
rejected as an unknown method, so any upload flow failed (issue #359).
Implement it: resolve the node, read each local file, and hand the bytes
(base64) to the JS layer, which builds real File objects (backed by the
bytes, so page code can read them via FileReader or upload them via
fetch/FormData) and fires input+change like a genuine selection. The file
input exposes a FileList via .files, and .value reads C:\fakepath\
to match Chrome.
Fixes #359.
---
crates/obscura-cdp/src/domains/dom.rs | 91 +++++++++++++++++++++++++++
crates/obscura-js/js/bootstrap.js | 41 ++++++++++++
2 files changed, 132 insertions(+)
diff --git a/crates/obscura-cdp/src/domains/dom.rs b/crates/obscura-cdp/src/domains/dom.rs
index 53034b2..dbeed0b 100644
--- a/crates/obscura-cdp/src/domains/dom.rs
+++ b/crates/obscura-cdp/src/domains/dom.rs
@@ -31,6 +31,62 @@ fn resolve_node_id(page: &mut Page, params: &Value) -> Result {
Err("nodeId, backendNodeId, or objectId required".to_string())
}
+/// Standard base64 (with padding). Used to ferry file bytes to the JS layer for
+/// DOM.setFileInputFiles without pulling in a dependency.
+fn encode_base64(input: &[u8]) -> String {
+ const T: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
+ let mut out = String::with_capacity((input.len() + 2) / 3 * 4);
+ for chunk in input.chunks(3) {
+ let b0 = chunk[0] as u32;
+ let b1 = *chunk.get(1).unwrap_or(&0) as u32;
+ let b2 = *chunk.get(2).unwrap_or(&0) as u32;
+ let n = (b0 << 16) | (b1 << 8) | b2;
+ out.push(T[((n >> 18) & 63) as usize] as char);
+ out.push(T[((n >> 12) & 63) as usize] as char);
+ out.push(if chunk.len() > 1 { T[((n >> 6) & 63) as usize] as char } else { '=' });
+ out.push(if chunk.len() > 2 { T[(n & 63) as usize] as char } else { '=' });
+ }
+ out
+}
+
+/// Best-effort MIME type from a file extension, for the File objects created by
+/// DOM.setFileInputFiles. Defaults to application/octet-stream.
+fn guess_mime(path: &str) -> &'static str {
+ let ext = std::path::Path::new(path)
+ .extension()
+ .and_then(|e| e.to_str())
+ .unwrap_or("")
+ .to_ascii_lowercase();
+ match ext.as_str() {
+ "jpg" | "jpeg" => "image/jpeg",
+ "png" => "image/png",
+ "gif" => "image/gif",
+ "webp" => "image/webp",
+ "svg" => "image/svg+xml",
+ "bmp" => "image/bmp",
+ "ico" => "image/x-icon",
+ "pdf" => "application/pdf",
+ "txt" => "text/plain",
+ "html" | "htm" => "text/html",
+ "css" => "text/css",
+ "js" | "mjs" => "text/javascript",
+ "json" => "application/json",
+ "xml" => "application/xml",
+ "csv" => "text/csv",
+ "zip" => "application/zip",
+ "gz" => "application/gzip",
+ "mp4" => "video/mp4",
+ "webm" => "video/webm",
+ "mp3" => "audio/mpeg",
+ "wav" => "audio/wav",
+ "doc" => "application/msword",
+ "docx" => "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
+ "xls" => "application/vnd.ms-excel",
+ "xlsx" => "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
+ _ => "application/octet-stream",
+ }
+}
+
pub async fn handle(
method: &str,
params: &Value,
@@ -180,6 +236,41 @@ pub async fn handle(
let _ = page.evaluate(&code);
Ok(json!({}))
}
+ "setFileInputFiles" => {
+ // Puppeteer's ElementHandle.uploadFile / Playwright's setInputFiles
+ // drive an through this CDP call (issue #359). Read
+ // each local file, then hand its bytes (base64) to the JS layer, which
+ // builds real File objects and fires input+change like a real
+ // selection so page code can read/upload them.
+ let page = ctx.get_session_page_mut(session_id).ok_or("No page")?;
+ let node_id = resolve_node_id(page, params)?;
+ let paths: Vec = params
+ .get("files")
+ .and_then(|v| v.as_array())
+ .map(|a| a.iter().filter_map(|v| v.as_str().map(String::from)).collect())
+ .unwrap_or_default();
+
+ let mut specs = Vec::with_capacity(paths.len());
+ for p in &paths {
+ let bytes = std::fs::read(p)
+ .map_err(|e| format!("setFileInputFiles: cannot read '{}': {}", p, e))?;
+ let name = std::path::Path::new(p)
+ .file_name()
+ .and_then(|n| n.to_str())
+ .unwrap_or("file")
+ .to_string();
+ specs.push(json!({ "name": name, "type": guess_mime(p), "b64": encode_base64(&bytes) }));
+ }
+
+ let specs_json = serde_json::to_string(&specs).unwrap_or_else(|_| "[]".to_string());
+ let code = format!(
+ "(function() {{ var el = globalThis._wrap && globalThis._wrap({0}); \
+ if (el && globalThis.__obscura_setInputFiles) {{ globalThis.__obscura_setInputFiles(el, {1}); return true; }} return false; }})()",
+ node_id, specs_json
+ );
+ let _ = page.evaluate(&code);
+ Ok(json!({}))
+ }
"getBoxModel" => {
let page = ctx.get_session_page_mut(session_id).ok_or("No page")?;
let node_id = match resolve_node_id(page, params) {
diff --git a/crates/obscura-js/js/bootstrap.js b/crates/obscura-js/js/bootstrap.js
index 8686dbd..c91f673 100644
--- a/crates/obscura-js/js/bootstrap.js
+++ b/crates/obscura-js/js/bootstrap.js
@@ -1584,9 +1584,21 @@ class Element extends Node {
const attr = this.getAttribute('value');
return attr !== null ? attr : 'on';
}
+ if (itype === 'file') {
+ // Chrome exposes a file input's value as C:\fakepath\.
+ return (this._files && this._files.length) ? ('C:\\fakepath\\' + this._files[0].name) : '';
+ }
}
return this.getAttribute("value") || "";
}
+ // FileList for , populated by DOM.setFileInputFiles (Puppeteer
+ // uploadFile / Playwright setInputFiles). null for non-file inputs, matching
+ // the DOM. See __obscura_setInputFiles (issue #359).
+ get files() {
+ if (this.localName !== 'input') return undefined;
+ if ((this.getAttribute('type') || '').toLowerCase() !== 'file') return null;
+ return this._files || _emptyFileList();
+ }
set value(v) {
const tag = this.localName;
if (tag === 'select') {
@@ -4440,6 +4452,35 @@ globalThis.__obscura_setFieldValue = function(el, field, value) {
} catch (_e) {}
el[field] = value;
};
+
+// Build a FileList-like object: an array with the DOM's `item(i)` accessor.
+function _makeFileList(files) {
+ const list = files.slice();
+ Object.defineProperty(list, "item", { value: (i) => list[i] || null, enumerable: false });
+ return list;
+}
+function _emptyFileList() { return _makeFileList([]); }
+
+// Populate an 's FileList from the CDP DOM.setFileInputFiles
+// call (Puppeteer uploadFile / Playwright setInputFiles). `specs` is an array of
+// { name, type, b64 } where b64 is the base64-encoded file bytes read on the
+// Rust side. Real File objects (backed by the bytes) are created so page code can
+// read them via FileReader or upload them via fetch/FormData, then input+change
+// fire as a genuine selection would (issue #359).
+globalThis.__obscura_setInputFiles = function(el, specs) {
+ const files = (specs || []).map((s) => {
+ let bytes;
+ try {
+ const bin = atob(s.b64 || "");
+ bytes = new Uint8Array(bin.length);
+ for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
+ } catch (_e) { bytes = new Uint8Array(0); }
+ return new File([bytes], s.name || "", { type: s.type || "" });
+ });
+ el._files = _makeFileList(files);
+ try { el.dispatchEvent(new Event("input", { bubbles: true })); } catch (_e) {}
+ try { el.dispatchEvent(new Event("change", { bubbles: true })); } catch (_e) {}
+};
globalThis.Event = class Event {
constructor(t,o={}) { this.type=t;this.bubbles=!!o.bubbles;this.cancelable=!!o.cancelable;this.composed=!!o.composed;this.defaultPrevented=false;this.target=null;this.currentTarget=null;this.eventPhase=0;this.timeStamp=Date.now();this._propagationStopped=false;this._immediatePropagationStopped=false; }
get isTrusted() { return _trustedEvents.has(this); }