Commit Graph
83 Commits
Author SHA1 Message Date
SGavrl 3b2c6c0deb WPT conformance: legacy-charset URL encoding, IDL attribute reflection, FileAPI, Storage, Range (#237)
* dom: complete detached documents; add ProgressEvent and parseHTMLUnsafe

createHTMLDocument returned a document whose body was null: parsing the
skeleton string as innerHTML of <html> collapsed through the fragment parser
and kept only <title>. Build head>title and body explicitly instead. The
DOMParser doc node also gains appendChild/removeChild/insertBefore and a
doctype getter, and createDocument carries its doctype argument.

This lets dom/common.js setupRangeTests() finish (it builds fixtures with
new Document(), createHTMLDocument(), createDocument()) instead of aborting at
harness setup, so the dom/ranges files run. Range-collapse 186/186,
Range-attributes 1/1, Range-cloneRange 55/62, StaticRange-constructor 14/17.
The large O(ranges x points) matrix files (Range-set, comparePoint,
compareBoundaryPoints) still time out: the boundary-point comparison walks the
tree through per-step DOM ops, which is too slow for those matrices. Speeding
that up (a Rust node-position op) is a separate change.

Also add globalThis.ProgressEvent (Event subclass with lengthComputable/
loaded/total) and the static Document.parseHTMLUnsafe(html).

* perf(dom): compute Range tree-order in Rust ops

The Range boundary-point algorithms compared nodes by walking ancestor chains
and sibling lists from JS, one DOM op per step. The dom/ranges matrices
(testRanges x testPoints, thousands of comparisons) made that time out.

Add node_index, compare_order, and node_root ops that do the walk in Rust over
the DOM tree (slab lookups, no per-step FFI), and use them in _rngNodeIndex,
_rngOrder, and _rngRoot. Range-comparePoint 3534/5580, Range-isPointInRange
5197/5733, Range-compareBoundaryPoints 6441/9313, all completing in under 10s
where they previously did not finish. Not on any engine hot path (querySelector,
mutation, navigation, fetch do not use these).

* fileapi: real byte-backed Blob/File, FileReader state machine, Request.blob

Blob was a one-line stub that string-joined its parts (wrong size for binary
or multibyte input) and exposed only text(). Replace it with a Uint8Array-backed
Blob: normalizes string/ArrayBuffer/typed-array/Blob parts, correct size,
slice(), text()/arrayBuffer()/bytes(), type normalization (lowercased, cleared
when non-ASCII), the endings option (native CRLF normalization), and
Symbol.toStringTag. File adds name/lastModified, the 2-argument requirement, and
its own toStringTag.

FileReader becomes a real state machine: EMPTY/LOADING/DONE, throws
InvalidStateError when a read starts while already LOADING, and fires
loadstart/progress/load/loadend (and abort) to both on* handlers and
addEventListener. readAsText/DataURL/ArrayBuffer/BinaryString produce correct
results. Request gains blob().

FileReader-multiple-reads 6/6, File-constructor-endings 10/11, Blob-slice
141/150.

* encoding: TextDecoder supports legacy charsets via encoding_rs

TextDecoder was a UTF-8-only JS shim that ignored its label, so
new TextDecoder('shift_jis'|'euc-jp'|'big5'|'gbk'|'windows-1252'|'iso-8859-1')
.decode() returned garbage. That matters for scraping: pages and scripts decode
legacy-encoded bytes (XHR/fetch responses, File/Blob text) all the time.

Back it with encoding_rs (already used by the net layer for response bodies):
obscura-net gains label_name() and decode_with_label(), exposed to JS as
op_encoding_for_label and op_text_decode. The constructor validates the label
(RangeError) and sets the canonical .encoding/.fatal/.ignoreBOM; decode()
supports fatal mode (TypeError on invalid input) and BOM handling.

Plain UTF-8 non-fatal decoding keeps a pure-JS fast path (no op, no JSON), so
Response.text()/Blob.text() on large bodies are unchanged; the op only runs for
legacy encodings or fatal mode.

textdecoder-labels 222/222, textdecoder-fatal 36/36, byte-order-marks 3/3,
utf16-surrogates 10/10, single-byte-decoder 168/504.

* selection: real Selection over Range (single instance per document)

getSelection() returned a fresh plain object each call, so it was not a Selection
instance, had no identity (window.getSelection() !== document.getSelection()),
and was missing removeRange/empty/collapseToStart/collapseToEnd/setPosition/
setBaseAndExtent/modify while collapse/extend/getRangeAt were broken stubs. That
was ~4862 failing 'selection[method] is not a function' subtests.

Replace it with a real Selection class built on the live Range: one cached
instance per document (window.getSelection() === document.getSelection(), null
for documents with no defaultView), an internal range + direction, derived
anchor/focus/isCollapsed/type/rangeCount, and the full method set
(getRangeAt with IndexSizeError, addRange, removeRange, removeAllRanges/empty,
collapse/setPosition, collapseToStart/End, extend, setBaseAndExtent,
selectAllChildren, containsNode, toString) with the spec DOMException names.
modify() stays a no-op (needs visual layout). No ops.rs changes; not on any hot
path.

setBaseAndExtent 120/120, removeAllRanges 104/104, collapseToStartEnd 51/51,
type 26/26, extend-exception 1/1.

* forms: HTMLInputElement valueAsNumber/valueAsDate/stepUp/stepDown

These were absent. Implement the number/date conversion API for the applicable
types (number, range, date, month, week, time, datetime-local): parsers and
formatters per type (Date.UTC for date/datetime-local, ISO-week Monday math for
week, months-since-epoch for month), valueAsNumber get/set, valueAsDate get/set,
and stepUp/stepDown(n) with the step scale/default per type, step-base snapping,
and min/max clamping. Non-applicable types and step="any" throw InvalidStateError;
non-finite valueAsNumber throws TypeError; range with empty value reads as the
midpoint (50 for 0..100). Plus min/max/step attribute reflection.

All on Element accessors gated by input type, only evaluated on explicit access;
not on any hot path.

input-stepup 51/53, input-valueasnumber 60/64, input-valueasdate 27/30.

* perf(encoding): skip label op for no-arg TextDecoder (Response/Blob text path)

new TextDecoder() with no argument (what Response.text()/Blob.text() and most
pages use) is UTF-8; resolve that without the op_encoding_for_label round trip.
Explicit labels still validate via the op.

* core: HTMLDialogElement, Node.normalize, Range.createContextualFragment

HTMLDialogElement was a bare alias of Element, so ~40 Core files crashed at the
first dialog.show()/showModal(). Add the dialog members on Element.prototype
(matching the popover/input convention): open reflection, returnValue (internal
slot, String coercion), oncancel/onclose defaulting to null, show()/showModal()
/close()/requestClose() with the open-state transitions, InvalidStateError
throwing (show-on-modal, showModal-on-open, showModal-disconnected), the async
non-bubbling close event, the synchronous cancelable cancel event (no re-entrant
cancel), beforetoggle/toggle, and closedBy. matches(':modal') reports a modally
shown dialog (like :popover-open). Modal top-layer/focus/render stays unhandled
(layout).

Node.normalize() now merges adjacent Text nodes and drops empty ones (was a
no-op). Range.createContextualFragment(html) parses a fragment.

createContextualFragment 34/35, dialog-open 3/3, dialog-return-value 1/1,
dialog-showModal 5/10, dialog-requestclose 22/75. Not on hot paths.

* custom-elements: CustomElementRegistry class, getName, ElementInternals, HTMLUnknownElement

customElements was a bare object literal, so 'CustomElementRegistry is not
defined' crashed ~22 Core files at top level (idlharness/feature-detect), and
HTMLUnknownElement/ElementInternals/attachInternals/getName were missing.

Promote the registry to a real CustomElementRegistry class (window.customElements
is now an instance, so 'define in CustomElementRegistry.prototype' works and the
class is exposed). Add spec define() validation/throw order (TypeError for a
non-constructor, SyntaxError for an invalid name, NotSupportedError for a
duplicate name/constructor or reentrant define), getName(), and a SyntaxError
reject in whenDefined for invalid names. The existing define -> upgrade ->
connectedCallback path (used by lit/Polymer on real pages) is preserved.

Add HTMLUnknownElement, ElementInternals (setFormValue/setValidity/checkValidity/
validity/validationMessage/form/states), and Element.attachInternals() with its
NotSupportedError cases.

Does NOT include the [[HTMLConstructor]] (new.target) construction rework or the
reaction wiring into appendChild/setAttribute (hot paths) - deferred to avoid
element-model and perf risk. getName 4/4.

* shadow-dom: ShadowRoot.adoptedStyleSheets (mirror Document)

ShadowRoot lacked adoptedStyleSheets; constructible-stylesheet adoption into a
shadow root threw. Add the getter/setter on ShadowRoot.prototype, matching
Document.adoptedStyleSheets.

* conformance: charset/URL encoding override, IDL reflection, Range and Storage fixes

Encoding: track the detected document character encoding and expose it as
document.characterSet/charset/inputEncoding instead of hardcoding UTF-8. Add the
WHATWG URL encoding override for <a>/<area> hrefs, so a legacy-charset document
percent-encodes the query in its own encoding (EUC-JP U+8108 becomes %CC%AE).
ASCII delimiters stay literal so structured queries are unaffected, and
unmappable code points become %26%23NNN%3B. UTF-8 documents take an early-return
so the href getter hot path is unchanged. fetch()/XHR text() now decode using
the response Content-Type charset instead of always UTF-8. Adds op_url_encode_query
and a document_encoding op; encoding state is plumbed through Page and the runtime.

Reflection: install the reflected IDL attribute layer once on Element.prototype
(no per-element defineProperty, so element creation and query paths are
untouched): global attributes (title, lang, dir, accessKey, slot, autofocus,
hidden, tabIndex) with correct type coercion and enum canonicalization, plus the
ARIAMixin (aria-* reflected as the ariaXxx nullable DOMString properties).

Range: compareBoundaryPoints ToUint16-converts the how argument (NaN/Infinity
no longer false-throw) and raises a real WrongDocumentError DOMException for
ranges that do not share a root.

Storage: reimplement localStorage/sessionStorage as a WHATWG Proxy so property
access (localStorage.foo = x, delete, in, Object.keys) stays in sync with
length, key() and iteration.
2026-06-04 15:19:29 +00:00
SGavrl 9c3dd6b21a WPT conformance: dom/ranges, FileAPI, detached documents (#236)
* dom: complete detached documents; add ProgressEvent and parseHTMLUnsafe

createHTMLDocument returned a document whose body was null: parsing the
skeleton string as innerHTML of <html> collapsed through the fragment parser
and kept only <title>. Build head>title and body explicitly instead. The
DOMParser doc node also gains appendChild/removeChild/insertBefore and a
doctype getter, and createDocument carries its doctype argument.

This lets dom/common.js setupRangeTests() finish (it builds fixtures with
new Document(), createHTMLDocument(), createDocument()) instead of aborting at
harness setup, so the dom/ranges files run. Range-collapse 186/186,
Range-attributes 1/1, Range-cloneRange 55/62, StaticRange-constructor 14/17.
The large O(ranges x points) matrix files (Range-set, comparePoint,
compareBoundaryPoints) still time out: the boundary-point comparison walks the
tree through per-step DOM ops, which is too slow for those matrices. Speeding
that up (a Rust node-position op) is a separate change.

Also add globalThis.ProgressEvent (Event subclass with lengthComputable/
loaded/total) and the static Document.parseHTMLUnsafe(html).

* perf(dom): compute Range tree-order in Rust ops

The Range boundary-point algorithms compared nodes by walking ancestor chains
and sibling lists from JS, one DOM op per step. The dom/ranges matrices
(testRanges x testPoints, thousands of comparisons) made that time out.

Add node_index, compare_order, and node_root ops that do the walk in Rust over
the DOM tree (slab lookups, no per-step FFI), and use them in _rngNodeIndex,
_rngOrder, and _rngRoot. Range-comparePoint 3534/5580, Range-isPointInRange
5197/5733, Range-compareBoundaryPoints 6441/9313, all completing in under 10s
where they previously did not finish. Not on any engine hot path (querySelector,
mutation, navigation, fetch do not use these).

* fileapi: real byte-backed Blob/File, FileReader state machine, Request.blob

Blob was a one-line stub that string-joined its parts (wrong size for binary
or multibyte input) and exposed only text(). Replace it with a Uint8Array-backed
Blob: normalizes string/ArrayBuffer/typed-array/Blob parts, correct size,
slice(), text()/arrayBuffer()/bytes(), type normalization (lowercased, cleared
when non-ASCII), the endings option (native CRLF normalization), and
Symbol.toStringTag. File adds name/lastModified, the 2-argument requirement, and
its own toStringTag.

FileReader becomes a real state machine: EMPTY/LOADING/DONE, throws
InvalidStateError when a read starts while already LOADING, and fires
loadstart/progress/load/loadend (and abort) to both on* handlers and
addEventListener. readAsText/DataURL/ArrayBuffer/BinaryString produce correct
results. Request gains blob().

FileReader-multiple-reads 6/6, File-constructor-endings 10/11, Blob-slice
141/150.
2026-06-03 21:20:42 +00:00
SGavrl 07c6e735fa WPT conformance: WHATWG URL, DOM Range, and post-navigation event-loop settle (#233)
* fix: restore regressions introduced by #227

#227 deleted build.rs (reverting #224's version-from-tag feature),
removed DOM cycle guards in append_child/insert_before that prevent
infinite loops on self-append/self-insert, dropped the CharacterData
textContent fast-path, removed PI/Doctype Rust ops, and cut the
env_allows_private_network() check from validate_fetch_url.

Restored:
- crates/obscura-cli/build.rs (version from tag/env)
- OBSCURA_BUILD_VERSION in version string and banner
- allow_private_network env var propagation in main.rs
- append_child/insert_before self-cycle guards in tree.rs
- textContent CharacterData fast-path in tree.rs
- create_processing_instruction, create_doctype, pi_target,
  doctype_name, doctype_public_id ops in ops.rs
- ProcessingInstruction branch in set_text_content op
- env_allows_private_network() check in validate_fetch_url

* fix(js): Europe/Berlin timezone, plugins iterator recursion

Two bugs in the stealth bootstrap code from #227:
- 'Europe/Germany' is not a valid IANA timezone; Intl.DateTimeFormat
  silently fell back to UTC. Changed to 'Europe/Berlin'.
- plugins[Symbol.iterator] called this[Symbol.iterator]() recursively,
  blowing the stack on Array.from(navigator.plugins). Fixed to bind
  Array.prototype[Symbol.iterator] directly to the array.

* url: back URL/URLSearchParams with the url crate, add <a>/<area> decomposition

The URL global was a regex shim: it mis-parsed userinfo (hostname came back as
the username, port as "pass@host"), had no username/password, and its setters
were no-ops. Replace it with two ops (op_url_parse, op_url_set) backed by the
WHATWG-compliant url crate, and add the HTMLHyperlinkElementUtils members
(protocol/host/hostname/port/pathname/search/hash/origin/username/password and
their setters) to <a> and <area>, which had none.

This was the single largest WPT failure. a.search returned undefined, so the
encoding encode-href tests threw "Cannot read properties of undefined (reading
'substr')" about 225k times. With the fix url-constructor passes 831/888,
url-setters 192/279, urlsearchparams-sort 17/17, url/a-element 415/887, and the
encode-href files run their assertions instead of crashing.

op_url_set runs the url crate inside catch_unwind: a few setters panic on the
pathological inputs the url-setters tests use, and a panicking setter must be a
no-op (the WHATWG "do nothing on invalid value" rule) rather than abort.

URLSearchParams also gained getAll/sort/size/keys/values/entries/iterator and a
back-reference so mutations write through to the bound URL.

* dom: real Range/StaticRange, createDocumentType, XML Document type

Range was a no-op stub (setStart/setEnd discarded their arguments, so
startContainer/endContainer read back undefined and every dom/ranges helper
that walked range.endContainer crashed with "reading 'nodeType' of undefined").
Replace it with a property-storing Range over the live tree: setStart/End and
the *Before/*After/selectNode/selectNodeContents/collapse positioning, plus the
boundary-point algorithms comparePoint, compareBoundaryPoints, isPointInRange,
intersectsNode, commonAncestorContainer, cloneRange and a real stringifier, all
built on a correct preorder tree-order comparison. Add StaticRange.

new Document() now reports an XML contentType (it has no backing node id), so
createCDATASection no longer throws NotSupportedError on it. Add
DOMImplementation.createDocumentType with the lenient name validation browsers
use and an owner-document backref so DocumentType.ownerDocument is correct.

StaticRange-constructor 14/17. This does not fully unblock dom/ranges yet:
dom/common.js also needs createHTMLDocument()/createDocument() to return
documents with a working body/createComment/etc., which is a separate fix.

* fix(browser,cli): settle the event loop after navigation

obscura fetch read the page at the load event and never pumped the event
loop afterward on the eval/dump path, so --wait was effectively ignored
there. Pages that schedule timers, do async work, or only report through a
completion callback that runs after load (testharness add_completion_callback,
SPAs) were observed frozen at the load event and never produced their result.

Add Page::settle, which drives run_event_loop until the loop goes idle or
--wait elapses (returns early when idle, so static pages stay fast), and run
it after navigation in the fetch path.
2026-06-03 20:15:07 +00:00
SGavrl 6b3cc1a686 Fix/rescue 227 regressions (#232)
* fix: restore regressions introduced by #227

#227 deleted build.rs (reverting #224's version-from-tag feature),
removed DOM cycle guards in append_child/insert_before that prevent
infinite loops on self-append/self-insert, dropped the CharacterData
textContent fast-path, removed PI/Doctype Rust ops, and cut the
env_allows_private_network() check from validate_fetch_url.

Restored:
- crates/obscura-cli/build.rs (version from tag/env)
- OBSCURA_BUILD_VERSION in version string and banner
- allow_private_network env var propagation in main.rs
- append_child/insert_before self-cycle guards in tree.rs
- textContent CharacterData fast-path in tree.rs
- create_processing_instruction, create_doctype, pi_target,
  doctype_name, doctype_public_id ops in ops.rs
- ProcessingInstruction branch in set_text_content op
- env_allows_private_network() check in validate_fetch_url

* fix(js): Europe/Berlin timezone, plugins iterator recursion

Two bugs in the stealth bootstrap code from #227:
- 'Europe/Germany' is not a valid IANA timezone; Intl.DateTimeFormat
  silently fell back to UTC. Changed to 'Europe/Berlin'.
- plugins[Symbol.iterator] called this[Symbol.iterator]() recursively,
  blowing the stack on Array.from(navigator.plugins). Fixed to bind
  Array.prototype[Symbol.iterator] directly to the array.
2026-06-03 18:16:20 +00:00
rifuki f740c19d4e fix(js): implement DocumentFragment getElementById (#222) 2026-06-03 08:39:51 +00:00
Sander Spilleman 9ae36764da feat(stealth): many stealth improvements 2026-06-03 08:21:14 +00:00
SGavrl ee10e8ddb5 feat(engine): DOM + AbortSignal + storage fixes surfaced by WPT conformance work (#219)
Engine-only changes pulled out of the WPT conformance branch. The
tests, runner, and harness vendoring that drove these have moved to a
separate benchmark repo (obscura-bench).

Real bug fixes:

- Tree::insert_before created a self-cycle when the inserted node was
  its own reference (the spec-conformant `child.after(x)` path that
  the testharness's ChildNode helpers exercise). The fixup wrote
  node.prev_sibling = node.next_sibling = own id; every subsequent
  childNodes / querySelectorAll walk then looped forever and obscura
  burned RAM until OOM. Per spec the op is a no-op; we now early-return.

- Tree::text_content extended to return own data for direct
  CharacterData access (Text / Comment / ProcessingInstruction).

- Element.namespaceURI now reflects the namespace passed to
  createElementNS instead of collapsing every non-svg namespace to
  xhtml. Without this, code that creates elements in MathML, custom
  XML namespaces, or the empty namespace gets misclassified as HTML.

- AbortController / AbortSignal: the previous shim was a plain object
  whose addEventListener was a no-op, so listeners for 'abort'
  silently never fired and any AbortSignal.timeout / AbortSignal.any
  chains broke. New impl tracks aborted/reason, has a real
  addEventListener / removeEventListener / dispatchEvent surface,
  honors `once`, and implements the static abort / timeout / any
  factories crawlers use for budgeted fetches.

- StorageEvent class added. localStorage / sessionStorage mutations
  now dispatch 'storage' events on the parent window and every iframe
  window so cross-frame onstorage listeners fire.

- ProcessingInstruction (nodeType 7) and DocumentType (nodeType 10)
  constructors plus document.createProcessingInstruction and
  document.implementation.createDocumentType. textContent returns
  null for Document and DocumentType per spec.

- insertAdjacentText / insertAdjacentElement on Element. Without
  these the testharness output renderer threw in a forEach without
  try/catch, breaking the completion callback chain for every test
  with subtests.

- Window + Node addEventListener filter non-callable listeners,
  accept the EventListenerObject shape (object with handleEvent),
  dedupe by (type, callback, capture), honor the `once` option, and
  snapshot the listener list before dispatch so a `once: true`
  removal mid-iteration doesn't corrupt the loop.

- Iframe HTML loader now extracts head <script> blocks and body
  on-handler attributes (`<body onstorage=...>`), evals scripts in
  the parent realm after the iframe window is built, re-attaches
  handlers as window listeners. Same iframe element reusing its src
  no longer double-registers in the iframe registry.

- ops.rs validate_fetch_url honors the OBSCURA_ALLOW_PRIVATE_NETWORK
  env var, and the CLI --allow-private-network flag mirrors itself
  into that env so the js-side fetch gate matches the http_client
  gate. Without this, iframe loads of 127.0.0.1 resources were
  blocked even with the flag set.

- ops.rs: create_processing_instruction, create_doctype, pi_target,
  doctype_name, doctype_public_id ops added; set_text_content extended
  to handle PI nodes.
2026-06-02 08:45:50 +02:00
SGavrl fd2421940b Perf/cdp fetch cache and cookie alloc (#216)
* perf: cache HTTP clients + selector parsing + exponential wait backoff

Three low-risk wins discovered while reading the hot paths.

1. op_fetch_url cached its reqwest::Client. Previously every JS-side
   fetch(), XHR, and dynamic script load built a fresh client, which
   re-loaded TLS roots and started with an empty connection pool. On
   an asset-heavy page with 30+ subresources this added ~150ms of
   wasted setup with zero connection reuse. The cache is a process-
   wide map keyed by proxy URL (empty string for no-proxy), so the
   first fetch on each proxy pays the build cost once and every
   subsequent fetch reuses the same pool.
   Also bumped pool_idle_timeout to 5 minutes and tcp_keepalive to
   60s so connections stay warm for SPA-heavy workloads that hit
   the same origin dozens of times across a navigation.

2. DomTree::parse_selector now caches parsed selectors in a thread-
   local HashMap (cap 256). querySelector / querySelectorAll re-
   parsed the selector string every call before; batch DOM access
   patterns (an agent scraping a table row-by-row, a framework
   polling for elements) paid the cssparser cost N times.

3. browser_wait_for / browser_wait_for_text now exponentially back
   off (5 -> 10 -> 20 -> ... -> 200 ms). The old fixed 200ms tick
   meant a selector that appeared 30ms after the call still took
   the next full 200ms tick to be detected. Worst case is still
   200ms per check, but the median wait drops sharply for fast
   appearances.

28/28 integration tests pass. Asset-heavy bench (rust-lang/rust,
docs.stripe.com, etc) doesn't regress.

* perf: module loader client cache, fetch concurrency cap, snapshot hide list, cookie alloc

Round two of perf wins after a deep code audit.

1. ES module loader (module_loader.rs) was building a fresh
   reqwest::Client on every dynamic import. Modern SPAs (Next, Vite)
   load 20-50 chunks via dynamic import; each one used to spin up an
   empty connection pool with fresh TLS init. Now reuses the
   process-wide FETCH_CLIENT_CACHE that op_fetch_url already uses
   (the cached_request_client helper). Saves ~30-100ms on chunk-
   heavy first navigations.

2. Script and CSS fetches in execute_scripts used futures::join_all
   with unbounded concurrency. A 100-script page would open 100 TCP
   sockets at once, exhausting the connection pool and the OS
   ephemeral port range. Capped at 16 in-flight (well above the per-
   host pool ceiling real Chrome uses).

3. bootstrap.js __obscura_init() ran Object.keys(globalThis).filter()
   on every page setup to find internals to hide. The set of keys
   is static at snapshot-build time, so we now precompute it once
   (top-level statement just below the function) and __obscura_init
   just iterates the precomputed array. Was 5-40ms per page on heavy
   SPAs (1000+ globals); now ~0.2ms per page.

4. CookieJar::domain_matches allocated 2 lowercase Strings and a
   format! per call. The lookup runs per fetch per cookie domain;
   on a heavily-cookied site with 100 cookies across 30 domains
   that was thousands of allocations per page. Now zero-alloc with
   eq_ignore_ascii_case and char-boundary checks.
2026-06-01 00:00:22 +02:00
SGavrl 282b4646d3 perf(js): resolve_promises_until exits as soon as the awaited IIFE finishes (#213)
Every Puppeteer click ran isIntersectingViewport (an actionability
check that wraps IntersectionObserver in a promise). Our IO.observe
schedules re-fires at 120/500/1500/3500/7000ms to cover infinite-scroll
sentinels. Puppeteer's callback disconnects the observer on the first
fire, so the re-fires are no-ops, but they keep the deno_core event
loop 'not idle' until they all fire.

resolve_promises was waiting for the loop to go idle (5s hard cap), so
every awaitPromise call paid the full 5s even though the result was
ready in <1ms. Effect: page.click() took ~7s instead of ~150ms because
clickableBox + isIntersectingViewport each hit the cap.

Fix: each evaluate / callFunctionOn now writes a unique sentinel
(globalThis.__obscura_done_<counter> = true) in a finally block. The
new resolve_promises_until polls that sentinel with a 1ms / 2ms / ...
backoff up to 50ms and exits as soon as the IIFE is done, instead of
waiting for unrelated timers to drain. Worst-case miss is one tick
(50ms).

Measured impact (Puppeteer CDP scenarios, lower is better):

  scenario               before   after
  data: click button     7420ms   ~530ms    (14x)
  data: addEvt click    14536ms   ~680ms    (21x)
  data: form submit      7676ms   ~810ms     (9x)
  CDP bench total       35190ms  ~7400ms    (~5x)

28/28 integration tests still pass.
2026-05-31 12:34:11 +00:00
SGavrl 50462d41a9 fix(js): load_module actually fetches the module source (#205) (#212)
load_module registered an empty string as the module body and called
it loaded. Every Vite / Next / SvelteKit external module bundle
'loaded' in 1ms with zero code, so the framework never executed and
the SPA shell sat empty (#root.children.length == 0).

Now fetches the URL through the page's http_client and passes the
real source. getvix.dev/docs (Vite + React) goes from
rootChildren=0 / bodyTextLength=10 (SSR shell only) to
rootChildren=4 / bodyTextLength=3708 (React app mounted).

Closes #205.
2026-05-31 11:29:25 +00:00
SGavrl 326096e20f Engine tier1 compat fixes (#208)
* engine: document.currentScript, data: subresources, dynamic module scripts

document.currentScript:
- ScriptInfo now carries the script element's nid.
- Before each script body runs, set globalThis.__currentScriptNid; clear after.
- Document.currentScript getter wraps the current nid. Next.js / Turbopack
  reads document.currentScript.src to derive the chunk base path. Without
  this the loader saw null, threw 'chunk path empty', and hydration stalled.
- Also threaded through the dynamic-script appendChild path so synchronously
  injected <script> elements see their own element as currentScript.

data: URIs as subresources:
- subresource_allowed permits 'data:' alongside http/https. The bytes are
  inline in the URI, no network fetch, no SSRF.
- Script-fetch loop short-circuits data: URLs and decodes them locally
  instead of routing through the HTTP client.
- Real Chrome allows this by default; Instagram and most Meta properties
  ship their bootstrap as <script src=data:application/x-javascript;base64,...>.

Dynamic <script type=module>:
- appendChild SCRIPT handler now accepts type='module' instead of dropping it.
- For external module src, uses dynamic import() instead of fetch + eval.
- For inline modules, wraps the source in a data: URL and import()s that
  so module syntax (import/export) parses correctly.

Before / after on previously broken sites (HTML bytes rendered):
  vercel.com          0   -> 960 KB
  instagram.com       0   -> 669 KB
  nextjs.org          0   -> 290 KB
  music.apple.com     0   -> 1.88 MB
  tailwindcss.com     0   -> 900 KB
  linear.app          0   -> 2.4 MB
  github.com PR       0   -> 468 KB

28/28 integration tests pass.

* engine: customElements.define upgrades existing matches + connectedCallback

The old customElements was a registry stub: define() recorded the class,
but never instantiated it on matching elements, never called the lifecycle
callbacks, and whenDefined() resolved synchronously with whatever was
already registered (or undefined forever for late-defined components).

New behavior matches the Web Components spec well enough for lit /
MusicKit / Polymer to wire up:

- define(): on register, querySelectorAll the document for matching tags,
  copy the prototype methods onto each element instance, call the class
  constructor with the element as 'this' for init logic, then fire
  connectedCallback if the element is currently in the document.
- whenDefined(): returns a real Promise that resolves when the class is
  later defined, not before.
- upgrade(root): walks the subtree, upgrades any matching unupgraded
  element. Used by frameworks that mount components asynchronously.

We don't truly subclass HTMLElement per spec (Element is shared across
the engine), so the upgrade copies prototype descriptors onto the
element instance instead of swapping its [[Prototype]]. Behavior is
identical for the common patterns (custom methods, shadow DOM attach
in constructor, connectedCallback render).

28/28 integration tests pass.

* scraping: fix puppeteer page.$, click, page.$$ + form / radio / checkbox actions

These bugs broke every realistic Puppeteer scraping flow. Repro: type into
a search box, click a button, scrape rows. Before this commit the very
first step (page.$('selector')) returned null on every site.

Runtime.evaluate (and the two related wrappers) put the user expression
on a single line as '({expr})'. Puppeteer's bundled injected.js ends with
a '//# sourceURL=pptr:internal' line comment; the comment swallowed our
closing paren and the wrapped function failed to parse with a SyntaxError
that never propagated back. Result: every page.$, page.$$, type(),
hover(), click(), waitForSelector(), getByRole(), etc. silently returned
null. Fix: newline before the trailing ')' so the comment can't eat the
wrapper. Applied to all three wrap paths (eval, store_object_with_meta,
wrap_expression).

documentElement and body now return viewport dimensions for clientWidth /
clientHeight / scrollWidth / scrollHeight / offsetWidth / offsetHeight and
for getBoundingClientRect itself. Puppeteer's #clickableBox clips each
candidate quad to document.documentElement.clientWidth/Height; with the
old 100x20 stub every synthesized element bbox was clipped to empty and
clickablePoint threw 'Node is either not clickable or not an Element'.
Also aligned innerWidth / innerHeight to 1280x720 to match
Page.getLayoutMetrics.

document.elementFromPoint now does real hit testing against the synthetic
bboxes (depth-first, deepest match wins) instead of always returning
<body>. Input.dispatchMouseEvent uses it to pick the click target, which
removed the dependency on a __obscura_click_target side-channel that
the previous getBoundingClientRect call had to set right before the
dispatch.

Element.dispatchEvent now fires inline event handlers
(onclick='...' content attribute and the IDL onclick property) in
addition to addEventListener registrations. Without this, every site
that uses inline onclick / onsubmit / onchange was a no-op.

Input.dispatchMouseEvent's click path now triggers the right default
action by element kind: submit button -> form.submit(); checkbox /
radio -> toggle checked + dispatch 'change'; anchor with href -> nav.
Previously only anchor was handled, so checking a checkbox or submitting
a form by clicking its button did nothing through CDP.

Runtime.getProperties now allocates a stable child objectId for every
object-valued property and stashes the value in __obscura_objects.
Items whose value has a numeric nodeType also get subtype:'node' +
className so Puppeteer's JSHandle.asElement() wraps them as
ElementHandle. Without this, page.$$('selector') returned an array
of plain JSHandles and checkboxes[0].click() raised
'click is not a function'.

Real-world Puppeteer scrape coverage (test_all.py style scenarios):
- HN top stories scrape:          fail -> pass
- GitHub repo file rows scrape:   fail -> pass
- Wikipedia search input type:    fail -> pass
- httpbin form fill + check:      fail -> pass
- Cookies persistence across nav: pass -> pass

28/28 integration tests pass.

* scraping: inline scripts run on DCL, JS nav emits CDP events, hit testing

Three remaining gaps from realistic Puppeteer scraping flows:

1. DCL path skipped execute_scripts entirely. Returning early on
   waitUntil=domcontentloaded meant every inline <script> was silently
   dropped: addEventListener calls in onload bootstraps never ran,
   form submit handlers never registered, page.click() hit no-ops.
   Spec: DOMContentLoaded fires AFTER scripts. Now scripts run before
   the lifecycle flips to DCL regardless of waitUntil.

   To keep heavy SPAs from blowing the 25s Puppeteer goto timeout
   now that scripts always run, added a soft deadline on the
   script-execution phase (default 10s, OBSCURA_SCRIPT_DEADLINE_MS
   to override). Both fetch and per-script execution check it.

2. JS-initiated navigation (form.submit, location.assign) did not
   emit CDP frameNavigated / lifecycleEvent. Puppeteer's
   waitForNavigation hung forever. Runtime.evaluate and
   Runtime.callFunctionOn now drain pending_navigation through a
   helper that fires the same post-nav event sequence Page.navigate
   does.

3. Document.elementFromPoint walked the tree only descending into
   ancestors containing (x,y). Our synthetic per-nid bboxes do not
   form a containment hierarchy, so a deep <input> inside <label><p>
   was never reached and clicks resolved to <body>. Switched to a
   flat sweep, deepest match wins (highest nid as a proxy for tree
   depth).

Realistic Puppeteer scrape scenarios: 18 of 18 assertions pass across
HN, Wikipedia, GitHub, httpbin forms, cookie persistence, and inline
JS submit handlers. 28/28 integration tests pass.

* scraping: fetch in page context, textarea, select, basic history

Four more gaps from the broad API sweep:

1. fetch() and XHR from page JS hung forever. set_intercept_tx() in
   the runtime auto-set state.intercept_enabled = true as a side
   effect, so every navigation silently enabled Fetch interception
   even when the CDP client never called Fetch.enable. op_fetch_url
   then routed every request through the intercept channel waiting
   for a Fetch.continueRequest that the client wasn't going to send.
   Split the flag from the channel: set_intercept_tx wires the
   channel without enabling; new set_intercept_enabled toggles the
   flag. Fetch.enable / Fetch.disable now call both, and the nav
   path only wires the channel.

2. page.type into textarea lost everything past the first newline.
   The dispatchKeyEvent handler skipped text=='\n' entirely, so
   'multi\nline' inserted only 'multi'. Also Enter on a textarea
   tried to submit the nearest form instead of inserting a newline.
   Now Enter inserts \n inside textareas and only submits forms
   from input fields, matching Chrome.

3. <select>.value did not reflect the selected option. Generic value
   getter returned the select's value attribute (empty) instead of
   the matching option. Added select-aware getter that walks options
   for the selected one and falls back to the first; setter accepts
   page.select() and dispatches a 'change' event.

4. Basic navigation history. Pages now track a visited-URL stack
   and history_index. Page.getNavigationHistory returns the real
   history; Page.navigateToHistoryEntry honors entryId and emits the
   same CDP nav-event sequence as Page.navigate so the response and
   lifecycleEvent reach waiting clients. resetNavigationHistory
   clears the stack. Push happens after every successful nav (skips
   consecutive duplicates so Page.reload doesn't grow the stack).

Broad API sweep across 13 feature groups: 43/44 PASS.
All categories solid (navigation, selectors, typing, waitForSelector,
evaluate, cookies, keyboard, attributes, events, fetch/XHR, storage,
timers). goBack reaches its target URL but Puppeteer client-side
waitForNavigation still hangs on it; the CDP frameNavigated +
lifecycleEvent fire correctly, so the remaining gap is in event
shape rather than logic. 28/28 integration tests pass.
2026-05-30 19:11:34 +00:00
SGavrl b98e457428 engine: large compat + reliability pass (#206)
CDP / navigation:
- Route all Page.navigate through process_with_interception so the
  parent dispatcher stays responsive during long navs (fixes
  Target.createTarget timeouts after a handful of newPage calls).
- Default waitUntil to domcontentloaded. Puppeteer and Playwright
  don't pass it server-side and wait client-side via lifecycleEvent,
  so the old Load default blocked nearly every nav on slow sites.
- 30s navigation hard ceiling, OBSCURA_NAV_TIMEOUT_MS to override.
- execute_scripts loop now checks wall-clock deadline per iteration.
- Emit Network.requestWillBeSent before Page.frameNavigated so
  Playwright's FrameManager attaches the navigation response (#189).
- Use loaderId as the Document request id so page.goto() resolves
  to a Response instead of null (#189).
- Fresh executionContextId per navigation, no more 'Cannot find
  context with specified id' after the first nav (#192).
- POST navigation params plumbed through the intercept path.

DOM / Web API surface:
- MutationObserver: subtree filter walks parent chain, fires on
  textContent / CharacterData.data / innerHTML, type filter respected,
  oldValue passed.
- IntersectionObserver: multi-fire (initial + 120/500/1500ms),
  disconnect / unobserve / takeRecords, root / rootMargin / thresholds.
- ResizeObserver: per-target firing from getBoundingClientRect, drop
  the no-op override that was clobbering the real class.
- crypto.subtle.digest: real SHA-1/256/384/512 via op_subtle_digest.
- DOMParser / document.implementation.createHTMLDocument /
  createDocument: detached doc with body, head, documentElement,
  querySelector*, getElementById, getElementsByTagName.
- WebSocket / EventSource / BroadcastChannel: real EventTarget,
  open fires on microtask.
- indexedDB: request, object store, transaction, database with
  async onsuccess and in-memory Map storage.
- getComputedStyle: width/height/top/left from bbox so React
  virtualization libs render items.

Site compatibility went from 3/40 to 175/175 reachable sites in
the sweep. Perf is equal or faster than pristine main on every
site tested (DCL default removes the block on long-tail subresources).

28/28 integration tests pass.
2026-05-30 10:21:50 +02:00
ea89f050cd Fix puppeteer exposeFunction end-to-end: real bindings, preload timing, URL coercion (#166)
* fix(js): coerce URL constructor input/base to string

The polyfill at bootstrap.js called `base.match(...)` directly, which
threw `TypeError: base.match is not a function` whenever callers passed
a Location object (e.g. `new URL(href, window.location)`), a URL
instance, or any other non-string with a sensible toString. Per the
WHATWG URL Standard both `url` and `base` are stringified before
parsing.

Coerce explicitly so the existing regex paths keep working and the
constructor matches real-browser behaviour. AngularJS, jQuery, and
anything that resolves a relative URL against `window.location` now
loads under Obscura.

* fix(browser,cdp): run preload scripts before page scripts

`Page.addScriptToEvaluateOnNewDocument` was executed AFTER the page's
own scripts (and even after the load event), which silently broke
every CDP contract that depends on early injection — most visibly
puppeteer's `page.exposeFunction`, whose user-facing wrapper has to
override `globalThis[name]` before any `<script>` tag in the document
runs.

Move the registered preload sources onto `Page` and execute them
inside `navigate_single`, right after `__documentReadyState__ =
'loading'` and before the parser-discovered scripts. The CDP layer
hands the current preload set to the page via `set_preload_scripts`
before kicking off navigation, replacing the two post-navigate
`execute_preload_script` loops in `domains/page.rs` and `server.rs`.

* feat(cdp,js): emit Runtime.bindingCalled for addBinding shims

`Runtime.addBinding` was a no-op stub that installed an empty
`globalThis[name] = function () { return null; }` placeholder. Page JS
calls were silently swallowed and the Node-side callback registered
via puppeteer's `page.exposeFunction` never fired.

Wire the binding all the way through:

1. New `op_binding_called(name, payload)` op forwards every page-side
   call into a `pending_binding_calls` queue on `ObscuraState`.
2. `Page::take_pending_binding_calls` drains the queue.
3. `dispatch()` empties every page's queue after each CDP request and
   pushes one `Runtime.bindingCalled` event per entry into
   `ctx.pending_events`, so the writer task forwards them to the
   client like any other CDP event.
4. The `addBinding` handler installs a real shim that calls the op,
   registers it as a preload script (keyed `__obscura_binding__<name>`)
   so it survives navigations, and evaluates it now so the binding is
   usable on the current document.
5. Implement `removeBinding` to mirror `addBinding`.

The shim coerces non-string arguments via `JSON.stringify`, matching
the wire contract that puppeteer's `exposeFunction` wrapper depends
on (`Runtime.bindingCalled.params.payload` is always a string).

* fix(cdp): emit canAccessOpener in all TargetInfo payloads, add aarch64 cross-compile CI

Merges PR #165 and PR #172.

PR #165: The CDP spec marks canAccessOpener as a required boolean on
TargetInfo but Obscura was omitting it. Strict Rust clients like
chromiumoxide reject the event entirely and panic with "Created target
not present" when the field is absent. Added canAccessOpener: false
to every TargetInfo emitted by setDiscoverTargets, getTargets,
createTarget, attachToBrowserTarget, attachToTarget, and getTargetInfo.

PR #172: Added a cross-compile matrix entry for aarch64-unknown-linux-gnu
running on ubuntu-22.04 x86_64, producing obscura-aarch64-linux-cross.
This is a fallback for when GitHub ARM runners are unavailable without
removing the native ARM build.

* fix(cdp): keep HTTP control plane reachable while V8 JS evaluation blocks (#173)

fix(cdp): unblock HTTP control plane during synchronous V8 execution

Fixes #62.

Previously, the TCP accept loop and CDP processor shared a single-threaded
tokio LocalSet. Long synchronous JS evaluations starved the accept loop,
causing incoming connections to hang in the kernel backlog and effectively
killing the HTTP control plane.

This moves the TCP listener and `/json/*` endpoints to a dedicated OS
thread using `std::net::TcpListener`. HTTP requests are now handled
directly via blocking I/O on this thread, completely bypassing V8.

WebSocket connections are handed off to the LocalSet via a bounded mpsc
channel (capacity 128). If V8 stalls and the queue fills, new WS
connections are dropped (ECONNRESET) rather than buffering unboundedly
to prevent OOMs.

Includes a new integration test to verify the control plane stays
responsive during a 5s JS loop, plus inline documentation clarifying
thread lifetime semantics and a minor test TOCTOU race.

* fix(js,dom): add Storage global, CharacterData ChildNode methods, fix remove/isConnected (#176)

fix(dom): add missing DOM/JS shims for modern frameworks

Closes #163.

Adds missing DOM and JS shims required by modern frameworks (Svelte 5, 
Vue, Lit) and consent-management SDKs to execute without errors:

- Define the `Storage` global constructor to resolve `ReferenceError`s 
  and fix `Storage.prototype` access.
- Apply standard ChildNode methods (`before`, `after`, `replaceWith`, 
  `remove`) to `CharacterData.prototype`. This prevents `TypeError`s when 
  frameworks call these on Text or Comment nodes during hydration.
- Update `Element.prototype.isConnected` to perform a genuine parent-walk 
  rather than returning a hardcoded `true`.
- Fix `Element.prototype.remove` to properly clean up the internal ID 
  index so removed elements are no longer queryable.
- Remove outdated no-op stubs on the `Element` class that were shadowing 
  the proper polyfill implementations.

* feat(cli): add --dump assets to inventory page sub-resources (124) (#174)

feat: add `--dump assets` to enumerate page sub-resources

Adds a `--dump assets` flag to output all page-referenced sub-resources 
(scripts, stylesheets, images, iframes, media, embeds, objects) as NDJSON, 
allowing users to easily replay the asset graph.

- Output format: `{"url":"https://example.test/app.js","type":"script"}`
- URLs are resolved against the page origin (matching `--dump links`).
- The `type` tag is derived from the source element. `<link>` elements map 
  their `rel` attribute to specific types (stylesheet, icon, manifest, preload, 
  prefetch, modulepreload, dns-prefetch, preconnect, alternate), falling back 
  to "link" if missing or unknown.
- Adds 7 tests covering URL resolution, rel-mapping, supported selectors, 
  and empty-attribute skipping.
- Updates README dump-format table and examples.

* fix(cdp,js): playwright actionability stubs and objectId resolve (#178)

Playwright fails locator actions on Obscura for two avoidable reasons
that are separate from the missing layout engine. Both have trivial
fixes that unblock the actionability polling loop.

- Element.checkVisibility(opts) was undefined. Playwright >= 1.25 calls
  it before every input event and retries until its action timeout if
  the method isn't a function. Add a stub that returns true. Without a
  layout pass we can't compute it for real, but a permissive stub still
  beats the undefined-function path.

- ARIA reflection properties were missing on Element (role, ariaLabel,
  ariaSelected, ariaExpanded, ariaHidden, ariaChecked, ariaDisabled,
  ariaRoleDescription). Playwright's getByRole and getByLabel locators
  read them. Reflect through to the underlying aria-* attributes for
  both read and write.

- DOM.getBoxModel and DOM.getContentQuads ignored the objectId param.
  Playwright passes only objectId (from a prior DOM.resolveNode), and
  the handlers silently defaulted to node 0, so dispatchMouseEvent
  always clicked the wrong element. Extract a resolve_node_id helper
  that honors nodeId, backendNodeId, and objectId in that order.

- Page.captureScreenshot and Page.captureSnapshot now return a clear
  "not supported, use a real Chromium for the screenshot leg" error
  instead of the generic "Unknown Page method" fallback. Matches the
  pattern already in place for Page.printToPDF.

Partial fix for #45. The architectural blocker (no layout engine, so
multi-element click coordinates are wrong) is unchanged.

* fix(mcp): allow browser MCP clients through CORS preflight (#179)

The OPTIONS preflight hardcoded Access-Control-Allow-Headers to just
Content-Type. Any browser MCP client sending mcp-protocol-version (per
the MCP spec) or Authorization / X-API-Key (typical for hosted
deployments) failed the preflight and the browser blocked the actual
request.

Add those three to the allow-list and a 24h Max-Age so browsers don't
re-preflight every call. Adds an integration test that drives the
server with a real preflight request.

Closes #175.

* fix(js,dom): scope element queries, real timers, form/prepend/equal (#180)

- Element.querySelector / querySelectorAll scope to the receiver subtree
  via a new query_selector_scoped Rust op, not always document root.
  Same for DocumentFragment.
- setTimeout respects its delay argument via a new async op_sleep.
  setInterval is a real repeating timer instead of an alias to setTimeout.
- document.forms / images / links return live querySelectorAll results
  instead of hardcoded [].
- HTMLFormElement subclasses Element with .elements / .length, and form
  nodes are wrapped as HTMLFormElement so instanceof works. submit()
  stays on Element.prototype to preserve the existing submit-event
  dispatch and form-data navigation path.
- Element.prepend has a real implementation.
- Node.isEqualNode does a structural compare (nodeType, nodeName,
  nodeValue, attributes, recursive children) instead of nid identity.
- Fix long-standing insert_before arg-order bug: bootstrap.js was
  passing (parent, new, ref) but _dom only forwards two args, so ref
  was silently dropped. Pass (new, ref) directly. Same fix in
  replaceChild.

Adds 3 selector unit tests and 6 runtime regression tests covering
the JS shim fixes.

Closes #105.

* fix(cdp): unblock puppeteer connect, bound Runtime.evaluate, full TargetInfo (#181)

Three independent gaps that hurt browser-based MCP clients connecting
to obscura's CDP endpoint:

- Runtime.enable returned "No page" when called before a session was
  attached. puppeteer-extra's FrameManager.initialize fires it on the
  browser-level WebSocket BEFORE any page target exists, so the
  standard puppeteer.connect / browser.newPage flow died on connect.
  Now reply with {} when no session is attached and let the next
  Target.attachToTarget emit executionContextCreated.

- Runtime.evaluate had no per-call timeout. A long synchronous JS
  expression held the global V8 lock indefinitely and starved every
  other CDP command on the session. Honor the CDP `timeout` parameter
  and default to 30s (Chrome's protocolTimeout). Returns a clear error
  on expiry so client-side deadlines fire cleanly instead of hanging.

- Target.getTargetInfo for the browser target omitted canAccessOpener.
  Strict CDP clients (chromiumoxide) panic when the field is missing.
  Fill it in (false for the browser target itself).

Partial fix for #122. The remaining items (networkIdle hard-deadline
on SPAs, /json/list hardcoded data, docs on cheap-vs-queued methods)
need bigger or non-code changes and are deferred.

* fix(net,browser): detect response charset, decode non-UTF-8 pages (#182)

obscura was calling String::from_utf8_lossy on every response body, which
silently corrupts every non-UTF-8 page (GBK, Big5, Shift-JIS, Windows-125x,
EUC-KR, ISO-8859-x). Whole language families came through as replacement
characters.

Add an encoding module (obscura-net::encoding) that picks the decoder
the way real browsers do per HTML5 § 8.2.2.4:

  1. HTTP Content-Type charset=... parameter (authoritative when present)
  2. <meta charset> or <meta http-equiv=Content-Type> sniffed from the
     first 1KB of the body (HTML only)
  3. UTF-8 fallback

Use it in:
  - obscura-net Response::text (signature changed: String, not Result)
  - obscura-browser page.rs HTML body decode (the primary scrape path)
  - obscura-browser page.rs JS body decode
  - obscura-browser page.rs CSS body decode

For JS / CSS we deliberately skip the meta sniff: those bodies are not
HTML and any '<meta charset=...>' substring inside them is not a charset
declaration.

Adds 7 unit tests for the encoding module covering header precedence,
quoted charset values, meta charset sniff, http-equiv form, the 1KB
sniff window, and a round-trip decode of GBK bytes. Adds a Python
feature test exercising file:// + meta charset and a live HTTP server
serving Shift_JIS with the wrong meta tag.

Closes #113.

* feat(cdp): implement Network.setCookie + deleteCookies, full field fidelity (#170)

* feat(cdp): implement Network.setCookie + deleteCookies, full field fidelity

Closes #168.

Adds the missing Network.setCookie (singular) and Network.deleteCookies
methods and upgrades the existing Network.setCookies/getCookies handlers
so sameSite, expires, httpOnly, secure, and the url-fallback parameter
all round-trip correctly.

Network and Storage cookie handlers share a single parse_cdp_cookie /
parse_delete_cookies_params helper to avoid drift.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>

* fix(cdp): fall back to default cookie jar, add Network.getAllCookies

Addresses @SGavrl review on PR #170:

- Network.{set,get,delete}Cookie(s)/clearBrowserCookies now resolve the
  cookie jar via a shared helper that prefers the session page's jar and
  falls back to ctx.default_context.cookie_jar when no target has been
  attached. Mirrors how Storage.setCookies already routes when there is
  no session, so Puppeteer/Playwright flows that issue cookie methods
  before Target.attachToTarget no longer hit "No page".
- Add Network.getAllCookies (separate CDP method per spec); aliases the
  existing getCookies handler so both arms return the full jar.

Tests: 6 new #[tokio::test] cases in domains::network::tests cover the
no-session fallback for set/get/setMany/delete/clear and the new
getAllCookies arm. RED on prod-reverted file (6/6 fail with "No page" /
"Unknown Network method: getAllCookies"), GREEN on patched
(22 -> 30 obscura-cdp lib tests, obscura-net 22/22 baseline preserved).

* fix(net): use bundled webpki roots in stealth client (#184) (#185)

set_default_paths only resolves OpenSSL's compile-time CA dirs, which
  exist on Linux but are empty on Windows. The stealth client's cert store
  ended up empty there and every TLS handshake failed with
  CERTIFICATE_VERIFY_FAILED inside BoringSSL handshake.cc.

  CertStore::default() uses wreq's bundled webpki-root-certs (Mozilla's
  trust list), which behaves the same on every platform and removes the
  dependency on a system CA store.

* fix(js): Element.attributes, on* event defaults, per-nid getBoundingClientRect (#45) (#186)

Three DOM-completeness gaps that broke jQuery 1.11.3 init and Playwright
  actionability:

  1. Element.attributes was undefined, so legacy IE feature-detect code
     like div.attributes[eventName].expando threw TypeError. Added a
     NamedNodeMap-like backed by a new attribute_names DOM op (length,
     numeric index, string name lookup, getNamedItem/setNamedItem,
     iterable).

  2. Standard on* event handler properties were missing from window, so
     ('on' + ev) in window returned false and forced jQuery into the
     legacy IE branch. Initialised the 79 HTML-spec on* handlers to null.

  3. getBoundingClientRect returned the same {x:8,y:8,width:100,height:20}
     literal for every element, so Playwright's hit-testing couldn't tell
     elements apart. Replaced with a deterministic synthesis from _nid
     mapped into a 12-column grid sized to a 1280x720 viewport. Still not
     pixel-accurate (no layout engine), but stable across reads and
     distinct per node.

* feat: cookie jar persistence + --storage-dir flag + fix HTTP cookie header filtering (#114)

* feat: cookie jar persistence + storage-dir + filtering fix (clean v4)

* fix: resolve build errors, add storage_dir param throughout server chain

* fix: storage_dir end-to-end, plus two test-literal field additions

  Five issues found while testing PR 114 end-to-end.

  Build fixes (two test literals out of sync with new struct fields):
  - crates/obscura-net/src/cookies.rs: CookieInfo literal in
    test_domain_matches_subdomain_without_leading_dot was missing the
    same_site and expires fields added in #170.
  - crates/obscura-cli/src/main.rs: Command::Fetch literal in
    matcher_still_uses_fetch_variant was missing the new storage_dir
    field. Without these cargo test --workspace would not compile.

  run_fetch was branching between BrowserContext::with_storage (which
  hardcodes proxy=None, stealth=false, user_agent=None) and
  with_options (which has no storage_dir slot), so --storage-dir +
  --proxy + --stealth silently dropped the proxy and stealth flags.
  Same bug SGavrl flagged on dispatch.rs in the previous round, just on
  the other code path. Switched to with_storage_full which carries all
  four. Verified the fetch now errors out against an invalid proxy.

  run_fetch --eval short-circuited the function before
  context.save_cookies() ran, so cookies set during an eval session
  were never persisted to {storage_dir}/cookies.json. Moved the save
  above the return.

  obscura serve loaded cookies on startup but never saved them
  anywhere, so a session that set cookies via Network.setCookie or
  navigation dropped them on shutdown. Subscribed to Ctrl-C once in
  put a single save_cookies() after the loop so both the Ctrl-C path
  and the channel-closed path persist cookies. Verified end-to-end.

---------

Co-authored-by: zhangyang-crazy-one <[email protected]>
Co-authored-by: SGavrl <[email protected]>

* fix(cdp): page.goto returns Response over Puppeteer and Playwright (#189) (#190)

Two correlation invariants real Chrome holds were missing, so the
  nav Response was never attached to goto().

  requestId === loaderId for the main Document event. Both clients
  gate isNavigationRequest() on this; we were emitting a synthetic
  per-page id.

  Network.requestWillBeSent for the Document fires before
  Page.frameNavigated. Playwright's FrameManager needs the request
  present as pendingDocument before the frame commits, otherwise
  currentDocument lands with request=undefined.

  Subresource events and the Document's responseReceived /
  loadingFinished stay where they were.

  Verified end-to-end on both clients against example.com (200),
  google.com (200), httpbin/404 (404).

* fix(browser,cdp): run preloads on about:blank, tighten binding shim arity

Two follow-ups from PR #166 review:

- navigate_single's about: branch returned before any preload sources
  ran, so addScriptToEvaluateOnNewDocument scripts and the
  Runtime.addBinding shim never installed on about:blank. Puppeteer
  hits this every session: browser.newPage() lands on about:blank and
  exposeFunction is dead until the first real navigation. Now the
  about: branch runs the preload set after init_js(), guarded by the
  same 5s timeout as regular preloads.

- The Runtime.addBinding shim used JSON.stringify on non-string args
  and silently accepted any arity. Chromium's V8InspectorImpl rejects
  calls without exactly one argument and ToString-coerces that arg
  before emitting Runtime.bindingCalled. Match that: guard on
  arguments.length and use String(arg) for the non-string branch.
  Single-string calls (the path puppeteer's exposeFunction wrapper
  uses) round-trip unchanged.

* fix(cdp): allocate fresh execution context ids per isolated world (#192) (#200)

Page.createIsolatedWorld and do_navigate both hardcoded context id 100
  for every isolated world emission. After the second navigation Playwright
  expected a fresh incrementing id (101, 102) while obscura kept re-using
  100. Runtime.evaluate then failed with 'Cannot find context with
  specified id: 101' because the id was never registered in
  valid_context_ids and the client's bookkeeping had diverged from the
  server's.

  Added next_isolated_context_id counter on CdpContext (starts at 100) and
  a next_isolated_context() helper that claims an id, registers it in
  valid_context_ids, and increments. Both call sites now use it.

* feat(cli): default V8 heap to 4 GB on 64-bit so heavy SPAs don't OOM (#199) (#202)

V8 embedded defaults to ~1.4 GB max-old-space on 64-bit. Pages with
  heavy fingerprinting or analytics bundles (e.g. demo.fingerprint.com/
  web-scraping) blow past it and the worker dies with
  'Fatal JavaScript out of memory' and SIGTRAP, taking the CDP session
  with it.

  Always set --max-old-space-size=4096 (1024 on 32-bit targets) before
  V8 startup, matching headless Chrome's default. User-supplied
  --v8-flags are appended after the default so they win (V8 parses
  left-to-right and the last value wins), which keeps the existing
  escape hatch intact for memory-constrained environments.

---------

Co-authored-by: SGavrl <[email protected]>
Co-authored-by: Ben Younes <[email protected]>
Co-authored-by: zhangyang-crazy-one <[email protected]>
2026-05-29 18:20:46 +00:00
SGavrl d15cac6149 fix(js): Element.attributes, on* event defaults, per-nid getBoundingClientRect (#45) (#186)
Three DOM-completeness gaps that broke jQuery 1.11.3 init and Playwright
  actionability:

  1. Element.attributes was undefined, so legacy IE feature-detect code
     like div.attributes[eventName].expando threw TypeError. Added a
     NamedNodeMap-like backed by a new attribute_names DOM op (length,
     numeric index, string name lookup, getNamedItem/setNamedItem,
     iterable).

  2. Standard on* event handler properties were missing from window, so
     ('on' + ev) in window returned false and forced jQuery into the
     legacy IE branch. Initialised the 79 HTML-spec on* handlers to null.

  3. getBoundingClientRect returned the same {x:8,y:8,width:100,height:20}
     literal for every element, so Playwright's hit-testing couldn't tell
     elements apart. Replaced with a deterministic synthesis from _nid
     mapped into a 12-column grid sized to a 1280x720 viewport. Still not
     pixel-accurate (no layout engine), but stable across reads and
     distinct per node.
2026-05-25 22:08:49 +02:00
SGavrl 71b9b4fbab fix(js,dom): scope element queries, real timers, form/prepend/equal (#180)
- Element.querySelector / querySelectorAll scope to the receiver subtree
  via a new query_selector_scoped Rust op, not always document root.
  Same for DocumentFragment.
- setTimeout respects its delay argument via a new async op_sleep.
  setInterval is a real repeating timer instead of an alias to setTimeout.
- document.forms / images / links return live querySelectorAll results
  instead of hardcoded [].
- HTMLFormElement subclasses Element with .elements / .length, and form
  nodes are wrapped as HTMLFormElement so instanceof works. submit()
  stays on Element.prototype to preserve the existing submit-event
  dispatch and form-data navigation path.
- Element.prepend has a real implementation.
- Node.isEqualNode does a structural compare (nodeType, nodeName,
  nodeValue, attributes, recursive children) instead of nid identity.
- Fix long-standing insert_before arg-order bug: bootstrap.js was
  passing (parent, new, ref) but _dom only forwards two args, so ref
  was silently dropped. Pass (new, ref) directly. Same fix in
  replaceChild.

Adds 3 selector unit tests and 6 runtime regression tests covering
the JS shim fixes.

Closes #105.
2026-05-24 20:14:17 +00:00
SGavrl a3b21bbb70 fix(cdp,js): playwright actionability stubs and objectId resolve (#178)
Playwright fails locator actions on Obscura for two avoidable reasons
that are separate from the missing layout engine. Both have trivial
fixes that unblock the actionability polling loop.

- Element.checkVisibility(opts) was undefined. Playwright >= 1.25 calls
  it before every input event and retries until its action timeout if
  the method isn't a function. Add a stub that returns true. Without a
  layout pass we can't compute it for real, but a permissive stub still
  beats the undefined-function path.

- ARIA reflection properties were missing on Element (role, ariaLabel,
  ariaSelected, ariaExpanded, ariaHidden, ariaChecked, ariaDisabled,
  ariaRoleDescription). Playwright's getByRole and getByLabel locators
  read them. Reflect through to the underlying aria-* attributes for
  both read and write.

- DOM.getBoxModel and DOM.getContentQuads ignored the objectId param.
  Playwright passes only objectId (from a prior DOM.resolveNode), and
  the handlers silently defaulted to node 0, so dispatchMouseEvent
  always clicked the wrong element. Extract a resolve_node_id helper
  that honors nodeId, backendNodeId, and objectId in that order.

- Page.captureScreenshot and Page.captureSnapshot now return a clear
  "not supported, use a real Chromium for the screenshot leg" error
  instead of the generic "Unknown Page method" fallback. Matches the
  pattern already in place for Page.printToPDF.

Partial fix for #45. The architectural blocker (no layout engine, so
multi-element click coordinates are wrong) is unchanged.
2026-05-24 20:13:43 +00:00
Ben Younes 183c444a56 fix(js,dom): add Storage global, CharacterData ChildNode methods, fix remove/isConnected (#176)
fix(dom): add missing DOM/JS shims for modern frameworks

Closes #163.

Adds missing DOM and JS shims required by modern frameworks (Svelte 5, 
Vue, Lit) and consent-management SDKs to execute without errors:

- Define the `Storage` global constructor to resolve `ReferenceError`s 
  and fix `Storage.prototype` access.
- Apply standard ChildNode methods (`before`, `after`, `replaceWith`, 
  `remove`) to `CharacterData.prototype`. This prevents `TypeError`s when 
  frameworks call these on Text or Comment nodes during hydration.
- Update `Element.prototype.isConnected` to perform a genuine parent-walk 
  rather than returning a hardcoded `true`.
- Fix `Element.prototype.remove` to properly clean up the internal ID 
  index so removed elements are no longer queryable.
- Remove outdated no-op stubs on the `Element` class that were shadowing 
  the proper polyfill implementations.
2026-05-24 17:36:19 +00:00
SGavrl e371e942fc fix(js): re-validate redirect targets in op_fetch_url
GHSA-8v6v-g4rh-jmcm: op_fetch_url validated the initial URL through
validate_fetch_url, but reqwest auto-followed up to 10 redirects with
no further checks. An attacker-allowed origin could 302 to
http://127.0.0.1 (or any private/internal address) and read the body
back to page JS. Real-world impact: AWS instance metadata, kube
debug consoles, internal admin endpoints with permissive CORS all
become readable from a page hosted anywhere on the open web.

Disable reqwest's auto-redirect and follow manually. Apply
validate_fetch_url to every redirect target before requesting it.
Cap the chain at 10 hops to match prior behaviour. Browser-standard
method/body rules apply on cross-method redirects (301/302/303
downgrade to GET with no body, 307/308 preserve).

Verified vs unpatched build (pr69):
  - Patched: attacker-page -> 302 -> http://127.0.0.1:N is blocked
    with "Redirect to forbidden URL blocked: Access to private IP..."
    before the internal hop runs. Legitimate 3-hop redirect chain
    between allowed origins still completes.
  - Unpatched: internal-service body leaks through to page JS via
    fetch().then(r => r.text()).
2026-05-16 15:26:58 +00:00
SGavrl 935cb5c0da fix(cli,js): add --version flag and script-error-resilience test (#147)
Two improvements while investigating #147:

1. `obscura --version` / `-V` now print the CARGO_PKG_VERSION instead of
   erroring out. The issue reporter explicitly called out that there was
   no version flag, and the existing `obscura serve` banner is the only
   way to discover the version.

2. New regression test in obscura-js verifies that a TypeError thrown
   from one script does not poison the runtime: subsequent scripts and
   DOM queries must continue to work. The reporter's symptom was that
   offside.js crashing collapsed --dump text output to 1 byte; that
   cascade should never happen and this test pins it down.

The classList error itself is offside.js's own bug (it indexes into the
querySelectorAll result without checking for an empty array) and only
fires when the .slideout-navigation element is absent from the DOM.
On current main that element parses correctly and offside.js runs
without error against a captured copy of the affected page.
2026-05-16 15:11:18 +00:00
Alexandr KondakovandSGavrl 6dd1e4e65f Fix CDP click submit navigation parity (#69)
* Fix Runtime.evaluate awaitPromise settling

* Fix DOM event navigation side effects

* Fix CDP click-triggered navigation parity

* Strengthen CDP inline script click parity test

* Handle positions-like submit URLs in CDP parity


Co-authored-by: SGavrl <[email protected]>
2026-05-15 20:38:54 +00:00
Adam Ma cb701fc2d8 feat(cli): add --v8-flags plumbing to configure V8 at startup (#130) (#131)
* feat(cli): add --v8-flags plumbing to configure V8 at startup (#130)

Obscura had no way to pass flags to V8, so users hitting OOM on pages
larger than ~5MB had no escape hatch. Adds a generic passthrough rather
than a one-off heap knob, so future V8 tuning needs no new code.

- New `obscura_js::set_v8_flags` (Once-guarded, empty-string no-op).
- Global `--v8-flags <FLAGS>` arg with `allow_hyphen_values = true`,
  applied once in `main` before any isolate is created.
- Usage: `obscura --v8-flags "--max-old-space-size=4096" fetch <url>`.

Closes #130.

* test(cli): expand --v8-flags coverage with normalize helper

Extract normalize_v8_flags() from main() so the trim/empty-check logic
is unit-testable without needing to invoke V8. Adds parse-coverage tests
for --v8-flags with serve/scrape subcommands and empty-string values,
plus pure-function tests for the normalizer.
2026-05-15 19:46:03 +00:00
Ben Younes f3c783f6f5 fix(net,js): route op_fetch_url + ES module loader through proxy_url (#139) (#151)
Fixes the remaining two paths from #139: JS-level fetch()/XHR
(op_fetch_url) and dynamic ES module imports (ObscuraModuleLoader) both
built their reqwest clients with `Client::builder().build()` — no
proxy — so every JS-initiated network call silently bypassed
BrowserContext.proxy_url. (The third path, StealthHttpClient, was
already fixed in main: page.rs:61 uses with_proxy.)

Changes:

* obscura-net: expose ObscuraHttpClient::proxy_url() so callers outside
  the crate can route their own reqwest requests through the same
  upstream proxy. The field was already there — just lacked an accessor.

* obscura-js/module_loader: add `proxy_url: Option<String>` and
  `with_proxy(base_url, proxy_url)`. ObscuraModuleLoader::new keeps the
  pre-#139 direct-connection behaviour. load() builds a reqwest client
  per import, threading the proxy and bubbling up "Invalid module proxy"
  on bad URLs.

* obscura-js/ops.rs: replace the process-wide
  `static SHARED_HTTP_CLIENT: OnceLock<reqwest::Client>` (which could
  never be reconfigured after first call) with a per-request
  `build_request_client(proxy_url)` that pulls the configured proxy out
  of SharedState.http_client.proxy_url() each call. Per-request build
  cost is ~1ms vs the network round-trip, and we no longer have a
  global singleton that lies about its config.

* obscura-js/runtime: add ObscuraJsRuntime::with_base_url_and_proxy.
  with_base_url delegates to it with None so existing tests / callers
  are unaffected.

* obscura-browser/page.rs: thread context.proxy_url through to
  with_base_url_and_proxy when constructing the JS runtime.
2026-05-14 08:25:55 +00:00
Ben YounesandOra Studio d8b7c5fad1 feat(cli): add --dump markdown output mode (#54) (#78)
Surfaces the existing LP.getMarkdown CDP machinery as a CLI flag so
text-pipeline users (LLM agents, scrapers feeding markdown to other
tools) don't need a separate HTML-to-markdown converter step.

  obscura fetch --dump markdown 'https://example.com'

Layout:
- crates/obscura-js/src/markdown.rs holds HTML_TO_MARKDOWN_JS as a
  pub const so every layer can share the same DOM-walker. obscura-js
  is at the bottom of the dep DAG (obscura-browser -> obscura-js,
  obscura-cdp -> obscura-js, obscura-cli -> obscura-js) so a single
  source of truth works without a circular dependency.
- crates/obscura-browser/src/lib.rs re-exports it for ergonomics.
- crates/obscura-cdp/src/domains/lp.rs drops its inline duplicate
  and calls the shared const.
- crates/obscura-cli/src/main.rs adds the `Markdown` DumpFormat
  variant + dump_markdown(&mut Page) which page.evaluate's the
  shared script and prints the result.

Net diff: a new 75-line markdown.rs, a 70+ line deletion in lp.rs
that's reborn as 3 lines, and ~10 lines added across the other four
files. No behavior change for `--dump html|text|links`, and
LP.getMarkdown over CDP keeps the exact same JSON shape.

Four new unit tests in obscura-js cover headings, inline emphasis +
links, ordered/unordered lists, and that <script> / <style> contents
do not leak into the output.

Generated by Ora Studio
Vibe coded by ousamabenyounes

Co-authored-by: Ora Studio <[email protected]>
2026-05-13 22:36:17 +02:00
Timothy Olaleke 85739d3f6a fix(#19): serialize V8 work across pages with process-wide async lock (#36)
* fix(#19): serialize V8 work across pages with process-wide async lock

Concurrent CDP clients (≥5) trip V8's
`heap->isolate() == Isolate::TryGetCurrent()` invariant and abort the
process via V8_Fatal — no Rust panic, just abort(3). Each Page owns its
own JsRuntime/Isolate and they all run on a single OS thread via
LocalSet+spawn_local; when two pages' V8-touching futures interleave
across an .await, V8 trips the check.

This adds a process-wide tokio::sync::Mutex (obscura_js::v8_lock) that
is acquired:

  - At the top of obscura_cdp::dispatch::dispatch (covers every CDP
    handler that may call into a JsRuntime).
  - In server.rs's process_with_interception spawn_local that runs
    page.navigate_with_wait + execute_preload_script (this path
    bypasses dispatch).

The guard keeps each V8-touching block contiguous on the thread: V8
fully exits one Isolate before another page is allowed in. Trade-off:
non-V8 handlers (Browser.*, Storage.*, Emulation.*) also serialize,
but those are cheap and the safety win dominates.

The properly concurrent fix is to pin each JsRuntime to its own OS
thread and message-pass; tracked as the larger #19 follow-up.

Adds an opt-in smoke test (`--ignored`) that boots the CDP server and
runs 5 parallel clients through createTarget+navigate. Note: the
deterministic abort needs subresource-fetch yields (page.rs:285
join_all) which data: URLs skip — full repro requires JS-heavy real
traffic (issue author used scrapegraphai.com).

* fix(#19): suspend other Isolates and defer foreign CDP during interception nav

Address maintainer feedback on PR #36 (comment 4328238087): the V8 fatal
still reproduces with `Fetch.enable patterns:["*"]` + 5 concurrent
`Page.navigate`s. The `obscura_js::v8_lock` mutex was insufficient
because V8's actual invariant is "only one Isolate may be entered per
OS thread", and the mutex can't enforce that across `.await` points
inside V8 ops.

Two distinct paths violated the invariant:

1. Foreign CDP messages dispatched while a nav task was running.
   `process_with_interception`'s `select!` routed every non-Fetch
   message through `process_cdp_message → dispatch → page::handle::*`.
   `get_session_page_mut` then `suspend_js`'es other pages (drops
   their `JsRuntime` → `Isolate::Exit`) while the spawned nav task is
   parked at `op_fetch_url`'s `resolve_rx.await` with its Isolate
   still entered. Drop trips
   `heap->isolate() == Isolate::TryGetCurrent()`.

2. Subsequent navs through `process_with_interception` did not
   suspend prior pages. After nav-1 completes (page-1's JsRuntime
   stays alive in `ctx.pages`), nav-2's `init_js` constructs
   Isolate-2 while Isolate-1 is still current. The next V8 scope
   unwind aborts via `Context::Exit`.

Fix (3 changes in `crates/obscura-cdp/src/server.rs`):

- `cdp_processor`: add a `VecDeque<ServerMessage> deferred` drained
  before each new `rx.recv()`, so deferred messages are processed
  with no nav task in flight.
- `process_with_interception` `select!`: only handle `NewConnection`
  and `Fetch.{continue,fulfill,fail}Request` inline (safe — no V8
  enter on this side); push everything else into `deferred`.
- `process_with_interception` entry: explicitly `suspend_js()` every
  other page in `ctx.pages` before spawning the nav task, mirroring
  the invariant the regular dispatch path already enforces via
  `get_session_page_mut`.

Verification:

- New test `crates/obscura-cdp/tests/concurrent_navigations_with_fetch.rs`
  is the maintainer's exact repro (5 clients, `Fetch.enable patterns:["*"]`,
  concurrent `Page.navigate` to https://example.com, auto-reply
  `Fetch.continueRequest`). Aborts on PR #36 head, passes after fix
  (5/5 stable runs, ~1.2s each).
- Existing data-URL smoke test (`concurrent_navigations`) still passes.
- Full workspace test suite (90 tests) still passes.

Run with:

  cargo test -p obscura-cdp --test concurrent_navigations_with_fetch \
    -- --nocapture --ignored

Honest caveat. This is still the lock/serialize approach — it converts
the abort into latency. The properly concurrent fix (Option 2 in
issue #19: pin each `JsRuntime` to its own OS thread and message-pass)
remains the right long-term direction.

* fix(cdp): bound deferred message queue to prevent OOM during stalled navigation

PR #36 comment 4341743194: process_with_interception deferred foreign
CDP messages into an unbounded VecDeque while a navigation task was in
flight. A stalled navigation (e.g. waiting for Fetch interception
resolution) could accumulate arbitrary queued messages and OOM the
process.

Add MAX_DEFERRED_MESSAGES = 256. When the cap is reached, return an
explicit CDP error (-32000) to the client instead of silently dropping
or growing without bound.

All 90 workspace tests pass.
2026-05-09 18:26:38 +02:00
Adam Ma a8b1ec0968 chore: silence unused import/variable/dead-code warnings (#101) (#126)
- obscura-net/cookies.rs: #[allow(dead_code)] on CookieEntry.same_site
  (parsed today, kept for future SameSite enforcement).
- obscura-js/runtime.rs: drop vestigial BOOTSTRAP_JS const; bootstrap.js
  is already embedded into the V8 snapshot via build.rs.
- obscura-js/ops.rs: prefix the four unused fields in
  InterceptResolution::Continue with _.
- obscura-cdp/dispatch.rs: drop unused InterceptResolution import.
- obscura-cdp/domains/fetch.rs: drop unused Arc / Mutex / CdpEvent imports.
- obscura-cdp/domains/input.rs: prefix _button / _click_count; CDP
  parses them but the JS click stub is hardcoded for now.
- obscura-cdp/server.rs: prefix _processor_handle and _ctx; drop dead
  initial Ok(())/None for navigate_result/page_back - they are
  unconditionally overwritten in the only loop break arm.

cargo build --workspace: 0 warnings.
cargo test --workspace: 113 passed.
2026-05-09 17:52:32 +02:00
Ben YounesandOra Studio 111150a4f8 fix(js): add elementFromPoint / elementsFromPoint stubs (#63) (#75)
document.elementFromPoint(x, y) was undefined, which caused Google
Publisher Tag, viewability libs and any code doing hit-testing to
throw on bootstrap and trigger retry loops in the surrounding
framework. This is one of the throwing APIs feeding into the wedge
described in #62.

Without a layout engine we can't return the actual topmost element,
but a non-throwing stub is strictly better than "undefined" for the
headless-scraping use case:

- coords inside the viewport return document.body (or
  document.documentElement as a fallback) so callers that just want
  a hit target stop throwing
- coords outside the viewport (negative, > innerWidth/innerHeight,
  NaN/Infinity) return null per spec
- elementsFromPoint mirrors the same shape but returns a one- or
  zero-length array
- ShadowRoot.prototype gets the same treatment for completeness

Five new unit tests in obscura-js cover function presence, in-viewport
hit, out-of-viewport coords, non-numeric input, and the array shape.

Generated by Ora Studio
Vibe coded by ousamabenyounes

Co-authored-by: Ora Studio <[email protected]>
2026-05-03 17:19:17 +02:00
Ben YounesandOra Studio d50dd0f553 fix(js): make createEvent type-aware and add initCustomEvent (#41) (#77)
document.createEvent('CustomEvent') was returning a generic Event,
so the legacy DOM Level 2 pattern

  var e = document.createEvent('CustomEvent');
  e.initCustomEvent('foo', true, false, payload);

threw "TypeError: e.initCustomEvent is not a function" because the
returned object was an Event, not a CustomEvent — and CustomEvent
didn't define initCustomEvent anyway. The Starbucks China bundle
(and a long tail of other CDN bundles using the same shim) depended
on this and aborted bootstrap.

Two changes in crates/obscura-js/js/bootstrap.js:

1. Add CustomEvent.prototype.initCustomEvent(type, bubbles, cancelable,
   detail) matching the spec.
2. Rewrite Document.prototype.createEvent so it dispatches to the
   right class based on the type string ('CustomEvent', 'MouseEvent',
   'MouseEvents', 'KeyboardEvent', 'FocusEvent', 'InputEvent',
   'UIEvent', 'WheelEvent', 'PointerEvent', 'ErrorEvent',
   'PopStateEvent', 'AnimationEvent', 'TransitionEvent', and
   case-insensitive 'Events' aliases). The HTMLDocument variant on
   the second Document class delegates to the canonical impl.

Spec note: createEvent returns the event with an empty type until
init*Event() is called — matched exactly.

Four new unit tests cover initCustomEvent presence and field setters,
class dispatch for CustomEvent / MouseEvent / MouseEvents / KeyboardEvent,
and the unknown-type fallback to Event.

Generated by Ora Studio
Vibe coded by ousamabenyounes

Co-authored-by: Ora Studio <[email protected]>
2026-05-03 17:18:30 +02:00
KuaaMUandKuaaMU 86a868bfd6 fix: implement CharacterData DOM API for jQuery DataTables compatibility (#73)
jQuery DataTables references the standard CharacterData interface which
was undefined in obscura's DOM environment, causing ReferenceError and
empty page renders.

- Add CharacterData class extending Node with data/length/substringData/
  appendData/insertData/deleteData/replaceData
- Make Text and Comment extend CharacterData instead of Node
- Update _wrap() to create proper Text/Comment instances by nodeType
- Add create_comment_node op for Document.createComment()
- Fix set_text_content to handle Comment node mutations

Fixes #72

Co-authored-by: KuaaMU <[email protected]>
2026-05-03 17:16:26 +02:00
rifuki 53a04fa905 fix(wasm): preserve binary fetch responses (#48)
* fix wasm response handling

* fix URL inputs in fetch polyfill

* test(wasm): cover binary fetch responses
2026-04-27 17:31:54 +02:00
h4ckf0r0day 00ee2db9cb Merge pull request #17 from ben1009/feat/support-file-urls
feat: support file:// URL scheme for local HTML testing
2026-04-25 12:36:57 +02:00
Mike Mooring 9e3e1e4ef7 fix(cdp): five Playwright drop-in compatibility patches
Brings Obscura's CDP server in line with what Playwright's
`chromium.connectOverCDP()` expects. Each fix is small and surgical;
together they make `connect → newContext → newPage → goto → page.title()`
work end-to-end against `https://example.com`.

1. **server.rs — emit pending events before command response.**
   Chromium CDP semantics require side-effect events (e.g.
   `Target.targetCreated` + `Target.attachedToTarget` from
   `Target.createTarget`) to arrive *before* the originating command's
   response. Playwright's wire reader assumes this ordering — it
   resolves the response promise on the response and then synchronously
   looks up the freshly-created session. Reversing the order makes that
   lookup miss and the page hang at `newPage()`.

2. **domains/browser.rs — accept Browser.setWindowBounds as a no-op.**
   Playwright calls `Browser.setWindowBounds` during teardown. Returning
   "Unknown Browser method" causes Playwright to treat the target as
   broken and tear it down mid-test, surfacing as "Frame has been
   detached" on the next `page.title()`.

3. **obscura-browser/page.rs — frame_id == target_id for main frame.**
   Was `format!("{id}.1", id)`. Playwright's frame manager indexes
   frames by frame id and looks up the main frame by target id, so the
   `.1` suffix made every main-frame lookup miss.

4. **domains/page.rs + dispatch.rs — track isolated worlds and re-emit
   them post-navigation.** Playwright registers a utility world via
   `Page.createIsolatedWorld` and expects `Runtime.executionContextCreated`
   for each registered world after every navigation. Without re-emission,
   the utility-world handle goes stale post-`goto` and `page.title()`
   hangs waiting for the world's execution context. Falls back to the
   legacy `__puppeteer_utility_world__24.40.0` name when no worlds have
   been registered, preserving prior Puppeteer support.

5. **obscura-js/runtime.rs — strip trailing semicolons before wrapping.**
   Playwright's utility-script expression is an IIFE ending in `})();`.
   Both `wrap_expression` (CDP `Runtime.evaluate`) and `evaluate_for_cdp`
   (the `meta_code` IIFE) embed the user expression inside `({EXPR})` /
   `return ({EXPR});`. With the trailing `;` left in, this produces
   `({...;})` — a SyntaxError. Parse errors are not catchable, so the
   surrounding try/catch never fires and the call silently returns
   `undefined`, surfacing as `page.title() === ""`.

Verified end-to-end against `https://example.com`:
  [probe] connect OK
  [probe] newContext OK
  [probe] newPage OK
  [probe] goto OK
  [probe] title = Example Domain
2026-04-24 23:27:18 -07:00
ben1009 21136ca819 feat: support file:// URL scheme for local HTML testing
Allow loading local HTML/CSS/JS files via the file:// protocol,
enabling Obscura to be used for testing static sites without
running a local HTTP server.

Changes:
- Allow "file" scheme in validate_url() and validate_fetch_url()
- Add fetch_file_url() to read local files and return synthetic
  HTTP responses with appropriate content-type headers
- Skip private IP / localhost checks for file:// URLs

Closes #16
2026-04-25 00:18:31 +08:00
h4ckf0r0day 8749725b45 Initial Release 2026-04-13 12:30:28 +02:00