feat(cdp): implement DOM.setFileInputFiles for file uploads (#369)

Puppeteer's ElementHandle.uploadFile and Playwright's setInputFiles drive
an <input type=file> 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\<name>
to match Chrome.

Fixes #359.
This commit is contained in:
SGavrl authored and GitHub committed 2026-07-09 10:51:18 +02:00
1 parent 8f26dfa331
commit 6788996b0b
2 files changed
+132

No files matched your search

+91
View File
@@ -31,6 +31,62 @@ fn resolve_node_id(page: &mut Page, params: &Value) -> Result<u64, String> {
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 <input type=file> 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<String> = 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) {
+41
View File
@@ -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\<first filename>.
return (this._files && this._files.length) ? ('C:\\fakepath\\' + this._files[0].name) : '';
}
}
return this.getAttribute("value") || "";
}
// FileList for <input type=file>, 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 <input type=file>'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); }