Commit Graph
72 Commits
Author SHA1 Message Date
SGavrl a16e8d4fe3 fix(js): default checkbox and radio value to "on"
A checkbox or radio input with no value attribute returns "on" in a real
browser; obscura returned the empty string. Page scripts that read
input.value to build form submissions (and browser-environment checks)
saw the wrong default. An explicit value attribute still wins.

Refs #335.
2026-06-30 15:18:21 +00:00
SGavrl 50ef1eecbd fix: SVGAnimatedString, :has() selectors, and cookie persistence on serve 2026-06-29 11:00:22 +00:00
SGavrl 117415848e js: implement SVGAnimatedString for SVG className and href (#330)
SVG className and href reflected as plain strings, so el.className.animVal
and el.href.animVal were undefined, which made new URL(svgEl.href.animVal)
throw and broke any SVG-aware script. Add an SVGAnimatedString class
(baseVal/animVal both read the live attribute; baseVal writable) and make
the className and href getters return it for SVG-namespace elements. href
falls back to the legacy xlink:href. HTML elements are unchanged.
2026-06-29 10:48:20 +00:00
RezoandSGavrl 260c4c01fa Add fetch body serialization support for Blob, ArrayBuffer, and TypedArray (#329)
* Add fetch body serialization support for Blob, ArrayBuffer, and TypedArray

* js: fix Blob fetch body serialization

The Blob branch passed the Blob to _bodyToUint8Array, which did not know
about obscura's Blob (its data lives in _bytes) and fell through to
encoding String(blob), so a Blob body was sent as "[object Blob]".
Teach _bodyToUint8Array about _bytes, and build the binary string with
the loop-based _bytesToBinaryString instead of String.fromCharCode.apply
so large blobs do not overflow the argument limit.

---------

Co-authored-by: SGavrl <[email protected]>
2026-06-26 20:43:19 +00:00
SGavrl 8582fcceca Merge stealth-overhaul into main
Resolved conflicts:
- page.rs: keep both set_blocked_urls (#315) and stealth client wiring
- ops.rs: keep SHA-512/224 + SHA-512/256 from stealth branch; keep
  the unreachable catch-all (vec![]) from #314 instead of silent
  SHA-256 fallback; updated comment accordingly
- bootstrap.js: extend the digest algorithm allowlist to include
  SHA-512/224 and SHA-512/256 to match the Rust additions
2026-06-23 18:05:55 +00:00
SGavrl db24d7a18c js: prompt for sensitive permissions in navigator.permissions.query
permissions.query returned "granted" for every permission except notifications.
Real Chrome defaults camera, microphone, geolocation, and midi to "prompt", so
answering "granted" for them is a bot tell. Return "prompt" for those (keeping
notifications in sync with Notification.permission) and "granted" for the rest.
2026-06-23 17:35:37 +00:00
SGavrl 3eb28da5c6 js: serialize FormData bodies to multipart/form-data in fetch
fetch() and XHR did String(body), so passing a FormData as the request body
sent the literal "[object Object]" and lost the multipart payload along with
its boundary. Servers expecting multipart/form-data replied 400 "Invalid
boundary for multipart/form-data", which is what blocked booking.com: the AWS
WAF challenge POSTs its proof-of-work solution to /mp_verify as FormData, so
the verify always failed and no aws-waf-token was issued. Serialize FormData to
a multipart/form-data body with a generated boundary and set the matching
Content-Type, the way a real browser does; URLSearchParams bodies get
application/x-www-form-urlencoded. XHR.send routes through fetch() so it is
covered too. With this, obscura --stealth renders the real booking.com landing
page the same as headless Chrome.
2026-06-23 10:52:57 +00:00
SGavrl 402de261aa js: fix Blob-URL Worker race and update location synchronously on navigation
URL.createObjectURL stored blob content asynchronously (blob.text().then) but
the Worker constructor read __blobStore synchronously, so new Worker(blobURL)
in the same tick raced the store, fell through to fetch(blobURL), and failed
with net::ERR_FAILED. This broke Blob-URL workers including the AWS WAF
challenge proof-of-work worker. Store synchronously from Blob._bytes, which the
obscura Blob materializes in its constructor.

location.reload was a no-op, so challenges that reload after setting a token
cookie never re-fetched. It now navigates like assign/replace.

location.href/assign/replace/reload cleared __virtualUrl immediately, so a
script that set location.href and re-read it saw the old URL until the async
navigation completed; real browsers update location.href synchronously. Preview
the resolved target in __virtualUrl and clear it in __obscura_init once the
navigation completes, so immediate reads match and redirects still win.
2026-06-23 10:28:42 +00:00
SGavrl 4309935abd stealth: keep the Chrome 145 identity consistent across TLS, JS, and WebGL
Stealth mode forced a Linux navigator UA and platform but reported Windows
in userAgentData.getHighEntropyValues and served Windows Direct3D11 WebGL
renderers, a combination no real Linux Chrome ever produces and that
cross-layer fingerprint checks flag. Present one consistent Windows Chrome
145 identity instead: the wreq emulation uses Platform::Windows, and the
navigator surfaces read the UA and platform from shared constants so the
HTTP User-Agent header, sec-ch-ua-platform, navigator.userAgent, and
userAgentData all agree. userAgentData brands are now derived from the
Chrome major version via Chromium's GREASE algorithm rather than a single
hardcoded token, and a Linux WebGL renderer pool is added so a Linux UA can
never report a Direct3D11 renderer.
2026-06-22 22:19:16 +00:00
Gabriel edde67ddd4 crypto.subtle.digest: reject unsupported algorithms (#314) (#319)
Unknown algorithm names (SHA-512/224, MD5, etc.) were silently falling
back to SHA-256, producing wrong bytes with no error. Now throws
DOMException('NotSupportedError') to match Chrome behavior.

Supported: SHA-1, SHA-256, SHA-384, SHA-512.
2026-06-22 13:02:05 +00:00
Gabriel a8358cc82d add Plugin, MimeType, MimeTypeArray globals (#305) (#318)
These three interfaces were missing from the global scope entirely.
Site bundles that reference Plugin as a constructor threw
"ReferenceError: Plugin is not defined".

Added as plain function declarations (same pattern as PluginArray) so
they survive the V8 snapshot. navigator.plugins now returns real Plugin
instances and navigator.mimeTypes returns a real MimeTypeArray with
MimeType instances, so instanceof checks work correctly.
2026-06-22 13:01:29 +00:00
Rohit Madhu 5d53dfef03 Add XPath compatibility for Maps scraping 2026-06-17 23:11:05 -04:00
Gabriel d526a4da7d js: make event.isTrusted false for page-constructed events
isTrusted was hardcoded to true for every Event, so new Event('x').isTrusted returned true. Real browsers return false for script-constructed events and true only for user-agent dispatched ones, so this was a trivial bot-detection tell (Closes #303).

Track trusted events in a closure-private WeakSet that page JS cannot read or forge; default isTrusted to false. The CDP input pipeline marks its synthesized mouse/key events trusted via a non-enumerable __obscura_markTrusted helper, so automation clicks stay trusted like real Chrome over CDP while page-built events report false.
2026-06-17 15:15:25 +00:00
SGavrl 6264e6bfad js: make event.isTrusted false for page-constructed events
isTrusted was hardcoded to true for every Event, so new Event('x').isTrusted
returned true. Real browsers return false for script-constructed events and
true only for user-agent dispatched ones, so this was a trivial bot-detection
tell (issue #303).

Track trusted events in a closure-private WeakSet that page JS cannot read or
forge; default isTrusted to false. The CDP input pipeline marks its synthesized
mouse/key events trusted via a non-enumerable __obscura_markTrusted helper, so
automation clicks stay trusted like real Chrome over CDP while page-built
events report false.
2026-06-17 15:12:04 +00:00
Gabriel 1a3888c056 js: mark Event constructor as native for stealth compatibility
Mark the Event constructor as native so that Event.toString() returns "[native code]" instead of leaking the JavaScript implementation. This closes a stealth fingerprinting vector.
2026-06-17 14:54:15 +00:00
yzyf1312 37b33c1109 js: mark Event constructor as native for stealth compatibility 2026-06-14 22:38:04 +08:00
Rohit Madhu 570ca1544c add Network.getResponseBody support 2026-06-13 20:26:26 -04:00
SGavrl 09fe30ec39 js: implement Element.getAttributeNames()
The method was missing, so calling it threw TypeError: ... is not a
function, which broke Vue 3/Nuxt client hydration (issue #297). Back it with
the existing attribute_names op, the same source as the attributes getter, so
the order matches attributes and outerHTML.
2026-06-13 15:05:29 +00:00
SGavrl cd889d5659 Fix/stealth consistency
hasIframeProxy fix contentWindow now returns null when parentNode === null (iframe never inserted into any tree). The previous attempt used isConnected, which broke getBehemothIframe by returning null for iframes inside the phantom iframe's detached document. That caused PHANTOM_DARKNESS to fall back to self (main window), making all DOM measurement ops run on the main document instead of the isolated iframe body, which corrupted the fingerprint element lookup.

Worker auto-execution Worker constructor now resolves relative URLs against location.href, fetches the script, and auto-runs it. The worker scope exposes WorkerGlobalScope so IS_WORKER_SCOPE resolves correctly in creepjs. All three worker types now complete within the 3s timeout instead of silently timing out.

AudioBuffer + AudioContext AudioBuffer class added to globalThis with getChannelData, copyFromChannel, copyToChannel. AudioContext.createBuffer returns a real instance. AudioContext.addEventListener was a no-op; startRendering now dispatches to stored listeners so the complete event fires.

Crypto Crypto class added to globalThis with randomUUID and getRandomValues on its prototype. Fixes ReferenceError in getPlatformEstimate.

NetworkInformation + ContentIndex NetworkInformation class with downlinkMax on the prototype clears noDownlinkMax. ContentIndex stub on globalThis clears noContentIndex. navigator.connection is now a real NetworkInformation instance.

getElementById / querySelector stale index fix id_index only registers nodes at creation time. When replaceChild(fragment, el) swapped in new nodes their IDs hit .or_insert() and were skipped, leaving the index pointing at the detached original. getElementById now verifies the indexed node is in the live document and falls back to a full attribute-selector scan. query_selector_from falls through to the full scan on both index miss and stale entry instead of returning None early.

matchMedia Handles common queries instead of always returning false. prefers-color-scheme: dark returns true (headless Chrome defaults to light mode, so returning true for light is a bot signal). any-pointer: fine, hover: hover, prefers-reduced-motion: no-preference, min/max-width, and (color) return sensible values.

Fingerprint consistency jsHeapSizeLimit raised to 4294705152 (4 GB, matching 64-bit Chrome). getHighEntropyValues platform corrected from "Linux" to "Windows", platformVersion from "6.1.0" to "10.0.0" to match the Windows NT 10.0 UA string. Canvas: real PNG encoder, arc fill, multiply blend mode. Screen class, performance.now, chrome runtime errors fixed.

Build note deviceMemory and hardwareConcurrency use the high-value pools ([4, 8] GB and [4, 6, 8, 12, 16] cores) only when built with --features stealth.
2026-06-11 22:27:44 +00:00
SGavrl 4f722dcfd3 stealth: fix fingerprint consistency, matchMedia, jsHeapSizeLimit
jsHeapSizeLimit raised to 4294705152 (~4 GB) to match 64-bit Chrome.
Was 2172649472 (~2 GB).

getHighEntropyValues platform corrected to "Windows" and platformVersion
to "10.0.0" to match the Windows NT 10.0 user agent string. Was "Linux"
/ "6.1.0", which mismatched the UA and could be detected.

matchMedia now handles common queries rather than always returning false:
prefers-color-scheme:dark returns true (avoiding prefersLightColor headless
flag — headless Chrome defaults to light so true is a bot signal),
any-pointer:fine, hover:hover, prefers-reduced-motion:no-preference,
min/max-width, and (color) return sensible values.

Result: 0% like-headless, 0% headless, 0% stealth, 33/33 obstacle course.
Note: build must include --features stealth for deviceMemory and
hardwareConcurrency to use the high-value pools ([4,8] and [4,6,8,12,16]).
2026-06-11 22:14:11 +00:00
SGavrl 4e8960e323 stealth: add NetworkInformation and ContentIndex shims
NetworkInformation class with downlinkMax on its prototype clears
noDownlinkMax from the like-headless flag list. creepjs checks
'downlinkMax' in (window.NetworkInformation?.prototype || {}).

ContentIndex stub on globalThis clears noContentIndex. creepjs checks
'ContentIndex' in window.

navigator.connection is now a real NetworkInformation instance instead
of a plain object literal.

Result: 0% like-headless, 0% headless, 0% stealth on creepjs. 33/33
obstacle course unchanged.
2026-06-11 22:01:41 +00:00
SGavrl 93b2fba6a1 fix: creepjs stealth, AudioBuffer, Crypto, Worker, hasIframeProxy
Worker auto-execution: Worker constructor resolves relative URLs against
location.href, fetches the script, and auto-runs it. WorkerGlobalScope is
exposed so IS_WORKER_SCOPE resolves correctly. Worker data (navigator,
locale, timezone) now arrives via postMessage within the 3s timeout.

AudioBuffer class added to globalThis with getChannelData, copyFromChannel,
copyToChannel. AudioContext.createBuffer returns a real AudioBuffer. Fixes
ReferenceError that blocked the audio fingerprint section.

Crypto class added to globalThis with randomUUID and getRandomValues on its
prototype. Fixes ReferenceError in getPlatformEstimate.

AudioContext.addEventListener was a no-op; startRendering only fired
oncomplete. Add _listeners map and dispatch to stored listeners.

getElementById and querySelector('#id') returned null for elements inserted
via replaceChild(documentFragment, el). id_index only registers nodes at
creation time; .or_insert skipped IDs on cloned fragments; None caused
query_selector_from to short-circuit the full scan.
Fix querySelector: fall through to full scan on index miss or stale entry.
Fix getElementById: verify node is in live document; fall back to attribute
selector scan if not.

contentWindow getter: return null when parentNode is null (iframe was never
inserted into any tree). Previously returning null for all disconnected
iframes broke getBehemothIframe, which caused PHANTOM_DARKNESS to fall back
to self (main window) instead of the phantom iframe. That made DOC point to
the main document, so rect and font measurements wrote fragment nodes into
the main body, and the subsequent full-DOM scan in getElementById could not
find fingerprint-data through nested fragments in time.

Limiting the null return to parentNode===null (never inserted) means
getBehemothIframe gets a valid nested contentWindow as expected. creepjs
now reports hasIframeProxy: false and 0% stealth detection.

DocumentFragment constructor: pass nid through when provided.
Range.getBoundingClientRect / getClientRects: return commonAncestor rect.
2026-06-11 21:51:03 +00:00
SGavrl f58bb45956 fix(stealth): synchronize TLS and JS fingerprints to Chrome 145
Upgraded wreq to 6.0.0-rc.29 and wreq-util to 3.0.0-rc.12 to apply the latest Chrome 145 TLS fingerprint (16 cipher suites, proper extension order, ALPS, GREASE).
 Introduced __obscura_stealth global to isolate stealth-specific JS behaviors from the standard execution path.
 Overrode navigator.userAgentData and getHighEntropyValues() in stealth mode to perfectly match the sec-ch-ua network headers.
 Restricted hardwareConcurrency and deviceMemory pools in stealth mode to desktop-realistic values (e.g., minimum 4 cores, 4GB RAM) to eliminate mobile-tier leaks.
 Fixed usedJSHeapSize occasionally exceeding totalJSHeapSize.
2026-06-11 17:53:15 +00:00
SGavrl 56ab54e615 stealth: gate JS behaviours, upgrade wreq, fix fp consistency
wreq upgraded to 6.0.0-rc.29 and wreq-util to 3.0.0-rc.12 to apply the
latest Chrome 145 TLS fingerprint (16 cipher suites, proper extension
order, ALPS, GREASE).

__obscura_stealth global variable introduced and passed from Rust to JS,
allowing stealth-specific JS behaviors to run only when the stealth flag
is enabled.

Tightened hardwareConcurrency and deviceMemory pools in stealth mode
to desktop-realistic values (e.g. min 4GB RAM, 4 cores), eliminating
mobile-tier values that leak the spoof.

navigator.userAgentData and getHighEntropyValues() are now overridden
in stealth mode to perfectly match the sec-ch-ua header sent by
wreq_util::Profile::Chrome145.

Fixed usedJSHeapSize sometimes exceeding totalJSHeapSize.
2026-06-11 17:22:50 +00:00
SGavrl dc2c585511 stealth: fix audio fingerprint, Screen class, chrome errors, performance.now
OfflineAudioContext.startRendering() now fires oncomplete asynchronously
(microtask) so callers that set ctx.oncomplete after calling startRendering
(fpCollect pattern) get the callback. The rendered buffer contains a
triangle wave scaled to produce the Chrome Linux audio fingerprint sum
(~124.04). createBuffer() now uses persistent Float32Array channels so
written sample values are read back correctly.

screen is now an instance of a Screen class with native getters on the
prototype. Object.getOwnPropertyDescriptor(Object.getPrototypeOf(screen),
"width").get.toString() now returns the expected native-code format instead
of throwing.

chrome.runtime.connect() and sendMessage() now throw the Chrome error
("Could not establish connection. Receiving end does not exist.") instead
of returning silently, matching what fpCollect records for detailChrome.

performance.now() now advances by 0.1ms per call within the same clock
tick, simulating ~100us timer resolution. Previously it aliased Date.now()
which returns the same integer for all calls in one tick, making
redPill2 = Infinity (serialized as null).

WebSocket constructor now throws SyntaxError for non-ws/wss URLs, matching
Chrome's behavior and populating errorsGenerated correctly.
2026-06-11 12:38:53 +00:00
SGavrl b29f0c0eb4 canvas: real PNG encoder, arc fill, multiply blend mode
toDataURL() now encodes the real pixel buffer as a valid PNG using
stored-block DEFLATE, CRC32, and Adler32. The 106-byte stub it replaced
was a static value that any canvas fingerprinter could trivially flag.

fill() rasterizes arc paths from this._path using a distance-check
circle fill, so arc-based canvas tests produce real pixel data.

_setPixel() handles globalCompositeOperation='multiply' so fpCollect's
colored-circle blend test composites correctly.

Canvas dimensions now read from getAttribute('width'/'height') so
explicitly sized canvases (e.g. 400x200) use their declared dimensions
instead of always defaulting to 300x150.
2026-06-11 10:39:52 +00:00
SGavrl a7a7b0f4d8 stealth: add HTMLMediaElement with canPlayType, Navigator constructor
canPlayType was undefined on <audio> and <video> elements. Adds HTMLMediaElement
extending Element with canPlayType returning values that match Chrome 145 on Linux
(probably/maybe/empty per MIME type). HTMLAudioElement and HTMLVideoElement extend
HTMLMediaElement. _elementClassFor routes AUDIO and VIDEO tags to the correct class.

Navigator: adds a global Navigator constructor so typeof Navigator !== 'undefined'.
Links it into the navigator prototype chain (_wdProto = Object.create(Navigator.prototype))
so navigator instanceof Navigator returns true, matching Chrome.

These are two of the signals in server-side fingerprint classifiers (fpCollect et al).
2026-06-11 10:21:09 +00:00
SGavrl a59e756da4 stealth: add PluginArray class, move webdriver to navigator proto
PluginArray: real browsers expose a native PluginArray type. navigator.plugins
was returning a plain Array, so `navigator.plugins instanceof PluginArray` and
`navigator.plugins[0].toString()` (which should return [object Plugin]) were
failing sannysoft-style checks. Adds a PluginArray constructor whose prototype
inherits from Array.prototype, with Symbol.toStringTag='PluginArray'. Each
plugin entry now carries Symbol.toStringTag='Plugin' so Object.prototype.toString
returns [object Plugin].

webdriver: moved the webdriver getter off the navigator instance and onto a
thin prototype so Object.getOwnPropertyDescriptor(navigator, 'webdriver')
returns undefined, matching Chrome where the property lives on Navigator.prototype.
Getter is marked native. sannysoft "WebDriver (New)" now reports "missing (passed)".
2026-06-11 10:02:37 +00:00
SGavrl ac75cf52a6 stealth: fix platform/UA mismatch and hide internal globals from Object.keys
navigator.platform returned "Win32" while navigator.userAgent said Linux when
using the stealth client. page.rs now calls set_platform("Linux x86_64", "Linux", "")
alongside set_user_agent when the stealth client is active.

bootstrap.js: pre-declare 71 internal _* globals as enumerable:false before
var runs, so they don't show up in Object.keys(window) on real pages.
2026-06-11 09:35:58 +00:00
SGavrl 3c92c36862 fix(js): console.log(Error) must not trigger Error.prepareStackTrace
deviceandbrowserinfo.com detects CDP by setting Error.prepareStackTrace as a spy, then calling console.log(new Error()). _consoleFn accessed a.stack, which fires prepareStackTrace if set. Fix: save/clear/restore around the .stack access.
2026-06-10 20:48:45 +00:00
SGavrl 534de77882 Add EventTarget methods to navigator.connection and serviceWorker
NetworkInformation (navigator.connection) and ServiceWorkerContainer (navigator.serviceWorker) are EventTargets in real browsers. React 18 and mobile SPAs call addEventListener on them; the stubs lacked it, so the app bundle crashed with 'addEventListener is not a function' (e.g. goofish.com).
2026-06-10 19:47:23 +00:00
SGavrl fa4928faa6 Trim redundant comments 2026-06-10 19:15:59 +00:00
Ben Younes 38fa85946c fix(js): add insertAdjacentText/insertAdjacentElement polyfills (#285) (#286)
DDoS-Guard's view.js challenge calls Element.prototype.insertAdjacentText
and dies with 'TypeError: t.insertAdjacentText is not a function', blocking
any DDoS-Guard-protected site (e.g. pikabu.ru). insertAdjacentElement was
missing too. insertAdjacentHTML was already implemented, so this just lifts
the same insertion pattern for Text-node and Element insertion semantics.

Both methods mirror the spec: case-insensitive position keywords, four
insertion points (beforebegin/afterbegin/beforeend/afterend), and
insertAdjacentElement returns the inserted element (or null when
beforebegin/afterend has no parent). Registered with _markNative so
Function.prototype.toString keeps reporting [native code] — consistent
with the existing Element.prototype.insertAdjacentHTML stealth registration.
2026-06-10 19:08:46 +00:00
zhangyang-crazy-oneandSGavrl 5225f6d985 fix(js): serialize dynamic module imports + re-land base href for SPA lazy chunks (#246) (#275)
* fix(js): serialize dynamic module imports + re-land base href for SPA lazy chunks (#246)

Add a serialized import queue (__dynScriptQueue + __processDynScriptQueue)
in bootstrap.js so that dynamically inserted <script module> tags execute
one at a time rather than concurrently. This prevents re-entrant RefCell
panics in deno_core's futures_unordered_driver when SPAs (Angular, etc.)
append multiple lazy chunks at once.

Also re-land dynamic import <base href> resolution (previously reverted
as 88af239). The original panic was caused by concurrency, not the URL
resolution logic — now safe because imports are serialized.

Inline module scripts also route through the same queue for consistency.

Fixes #246
Closes #242 (dynamic import base href)

* Fix base-href resolution, queue wedge, and double-fired load event

- Resolve <base href> to an absolute URL before resolving the script src, and
  guard the new URL() call. A path-only base like <base href="/"> (the standard
  Angular form) was passed raw to new URL(src, base), which threw "Invalid URL"
  out of appendChild and broke the SPAs this change targets.
- Wrap the dynamic-script queue drain in try/finally so __dynScriptBusy is always
  cleared. An unexpected throw used to wedge the queue and silently block every
  later dynamic script on the page.
- Fire load/error through dispatchEvent only. dispatchEvent already invokes the
  element's onload property handler, so the extra explicit onload() call
  double-fired it. This also drops the stale enqueue-time callback snapshot and
  reads handlers live off the element.

---------

Co-authored-by: SGavrl <[email protected]>
2026-06-09 10:27:50 +02:00
Fabián DelgadoandSGavrl 179d9f3369 fix(js): implement CSS.supports() feature detection (#209)
* fix(js): implement CSS.supports() feature detection

CSS.supports() was a stub that always returned false. Sites that
feature-detect CSS support (e.g. CSS.supports('display','flex'))
treated the engine as lacking flexbox and redirected to their
"unsupported browser" page, so the SPA never rendered.

Implement both call signatures — supports(property, value) and
supports(conditionText) — and return true for well-formed standard
CSS declarations, matching modern browser behaviour. Malformed input
(empty property/value, or a condition string with no colon) returns
false.

* Revert CSS.escape change, keep PR scoped to CSS.supports

The escape stub was switched to String(s), which is unrelated to the
feature-detection fix and still not a spec CSS.escape. Restore the original
stub so this PR only touches CSS.supports.

---------

Co-authored-by: SGavrl <[email protected]>
2026-06-09 09:30:41 +02:00
SGavrl 82b7a0baa8 Make navigator.geolocation coordinates configurable (#272)
The geolocation shim returned a fixed Frankfurt position. With the timezone now
configurable, a deployment in another region would report a location that does
not match its exit IP or timezone, the mismatch issue #228 describes.

Set OBSCURA_GEOLOCATION="lat,lon" to override the reported coordinates; the
value is range-checked and falls back to the Frankfurt default when unset or
malformed. The existing positional jitter is preserved.
2026-06-08 21:40:38 +00:00
johnnogueiraandSGavrl a23dad7cd9 Stealth profile consistency (#269)
* feat(stealth): randomize browser profile per session

Add a pool of 8 realistic browser profiles (Win10/Win11/macOS, Chrome
143-146). One is picked randomly per BrowserContext instead of always
advertising Linux, which is a strong bot signal.

Each profile keeps navigator.platform, userAgentData.platform, and
userAgentData.platformVersion internally consistent with the UA string.

- profiles.rs: static PROFILES pool + random_profile() picker
- context.rs: pick profile on construction, store platform fields
- page.rs: call rt.set_platform() alongside rt.set_user_agent()
- runtime.rs: add set_platform() to inject __obscura_platform globals
- bootstrap.js: read platform/userAgentData from globals (default Win32)

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>

* feat(stealth): match GPU renderer pool to OS profile

Windows profiles get ANGLE Direct3D11 renderers; macOS profiles get
ANGLE Metal renderers. Vendor strings updated to match (Google Inc. (Apple)
for Apple Silicon/Intel Mac, Google Inc. (NVIDIA/AMD/Intel) for Windows).

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>

* fix(stealth): navigator.webdriver returns false instead of undefined

Browsers set navigator.webdriver to false when automation is not active.
Returning undefined is a detectable bot signal — fingerprinting scripts
check typeof navigator.webdriver === 'boolean' as part of headless detection.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>

* Make stealth profile stable by default, rotation opt-in

Picking a random profile per BrowserContext meant one address would cycle
through different browser identities, which is itself a bot signal, and the
rotated profile carries no matching TLS or timezone fingerprint. Default to a
single stable profile; opt into rotation with OBSCURA_ROTATE_PROFILE, or pin
one with OBSCURA_PROFILE=<index>.

Drive timezone from the process TZ so Intl.DateTimeFormat and Date report one
consistent zone. Default Europe/Berlin; override with OBSCURA_TIMEZONE.

Update navigator.webdriver test to expect false, matching real Chrome.

---------

Co-authored-by: SGavrl <[email protected]>
2026-06-08 21:23:50 +00:00
SGavrl 6754c1caac Make AbortSignal a real constructor (prototype, throwIfAborted, events) (#267)
The AbortController/AbortSignal globals were minimal stubs: AbortSignal had no
prototype and no throwIfAborted, and AbortController.abort() only set a flag
without firing listeners. Pages that read AbortSignal.prototype or call
throwIfAborted threw non-fatal TypeErrors.

AbortSignal is now a class with a prototype, aborted/reason, throwIfAborted(),
working addEventListener/onabort firing, and static abort()/timeout()/any().
AbortController.abort(reason) fires the abort event. Bootstrap-only, no perf
impact.
2026-06-08 15:20:37 +02:00
SGavrl fb2841b7ab fix: CJK browser_search panic and multi-tab tab_close crash (#257, #258)
* Fix UTF-8 char boundary panic in browser_search

The search snippet window sliced body[..start] / body[end..] at byte
offsets derived from char counts and needle.len(), which can land inside
a multi-byte (CJK) character and panic, killing the MCP stdio transport.
Snap start/end to char boundaries before slicing and advance past
whitespace by the actual char width. Closes #257.

* Add setLineDash and friends to the canvas 2D mock

Charting libraries (Highcharts, ECharts) call setLineDash/getLineDash/
ellipse every animation frame. The missing setLineDash threw 'is not a
function' from a timer each tick, spamming the console. Adds the
line-dash methods plus ellipse/roundRect/createConicGradient/
getContextAttributes stubs. Addresses the error spam in #258; the
multi-tab isolate-teardown crash is a separate deeper issue.

* Fix multi-tab tab_close V8 isolate abort

rusty_v8 enters each isolate on creation and requires them dropped in
reverse order of creation. The MCP gave each tab its own isolate and
dropped them in arbitrary (user-chosen) order on tab_close, so closing a
non-newest tab aborted the process (Check failed: heap->isolate() ==
Isolate::TryGetCurrent()). Pages with active timers (Highcharts/ECharts)
made it easy to hit.

Enforce the CDP server's single-live-isolate discipline in the MCP: at
most one tab's isolate is ever live. page_mut calls a new activate() that
suspends every other tab (drops its isolate, keeps its DOM) and resumes
the active one. close_tab drops the closed tab's isolate via suspend_js
while it is still the only entered isolate, and tool_close suspends all
tabs before clearing. Confined to obscura-mcp; the engine, CDP server and
obstacle course are untouched, and peak memory drops to one isolate
instead of N. Closes #258.
2026-06-07 11:44:49 +02:00
SGavrl c1e0b0fb5a fix: resolve element .src to an absolute URL (#255) (#260)
HTMLScriptElement/HTMLImageElement .src reflected the literal attribute
instead of the resolved absolute URL. Loaders that derive their base with
new URL(document.currentScript.src).origin threw "Invalid URL" on a
relative src and aborted, so the page bundle never loaded and the DOM
stayed empty (issue #255). getAttribute("src") still returns the literal.
2026-06-07 11:42:56 +02:00
SGavrl 5135e3cb4d perf: DOM read fast paths (constant nodeType, borrowed op reads, base64 table) (#259)
* DOM read fast paths: constant nodeType, fewer ops and allocations

- Element.nodeType is now a constant getter instead of inheriting Node's
  dynamic op-backed getter. nodeType is invariant (1) for element wrappers
  (wrappers are only built for element nodes and node ids are never freed
  and reused), so this drops one JS/Rust op per read on a pervasive path.
  Micro-bench: ~600k nodeType reads go from ~380ms to ~5ms.
- attributes getter reads each attribute once instead of twice.
- dataset Proxy is cached per element instead of reallocated on every
  access, which also makes el.dataset === el.dataset (spec-correct).
- stylesheet <link> scan borrows the node (with_node) instead of deep
  cloning it and compares rel via eq_ignore_ascii_case, dropping a clone
  and a lowercased String alloc per link per navigation.

No regression: obstacle course 33/33, WPT dom/nodes Node- subset identical
to baseline (3551/4163 subtests), targeted DOM probe identical.

* DOM op reads: borrow the node instead of deep-cloning it

The scalar read arms in op_dom (node_type, node_name, tag_name,
get_attribute, attribute_names, parent/first/last/next/prev lookups,
has_child_nodes, pi_target, doctype_name, doctype_public_id) and the
node_root / node_ancestors / node_child_index walks called get_node,
which clones the whole Node (for elements a deep clone of the attribute
Vec and its Strings) just to read one field. Switch them to with_node,
which borrows under the RefCell. element_children and the
document_element / document_doctype child scans stay on get_node on
purpose (they iterate, so the borrow story is left untouched).

Output is identical. Traversal/attribute micro-bench ~25-30% faster with
fewer allocations per op. No regression: obstacle course 33/33, WPT
dom/nodes identical to baseline (7057/10620 across 314 files).

* base64 decode: reverse-lookup table instead of indexOf scan

_base64ToUint8Array decodes every fetch/XHR response body. It ran an
O(64) alphabet.indexOf plus a one-char substring alloc per character.
Build a charCode->6-bit reverse table once at module load and index it by
charCodeAt, folding out-of-range (NaN or code >= 128) to -1 so the
bitmath stays byte-identical to the old indexOf path on valid and
malformed input alike.

~20-25% faster decoding a 2MB body; output byte-identical (differential
test across valid, unpadded, '='-position, >127, non-alphabet and
truncated inputs matches the old code exactly). No regression: obstacle
course 33/33.
2026-06-07 11:39:18 +02:00
SGavrl 24cf7cad56 Give relList and sandbox their supported tokens (#256)
DOMTokenList.supports() threw "DOMTokenList has no supported tokens"
for relList and sandbox because they were created without a supported
set. Vite's modulepreload polyfill runs link.relList.supports('modulepreload')
at the top of every bundle, so the throw aborted the whole ES module and
React/Vite SPAs rendered blank. relList and sandbox now carry their token
sets; classList still has none and correctly throws.
2026-06-06 18:14:51 +00:00
zhangyang-crazy-oneandzhangyang-crazy-one 98b62e53e8 fix(api/js): Browser cookie jar + base href + Node.baseURI + XHR EventTarget + Page::settle (closes #241,#242,#244,#245,#247) (#243)
* fix(api): Browser::cookies() now shares context's CookieJar

Previously Browser::build() created its own empty CookieJar,
while BrowserContext internally creates a separate jar that
actually receives Set-Cookie headers from HTTP responses.

This caused browser.cookies().get_all() to always return
empty after navigation — the populated jar was unreachable.

Fix: use context.cookie_jar instead of creating a separate
one. Also removes redundant cookie-loading logic in
Browser::build() since BrowserContext::_new_inner already
handles loading from storage_dir.

Closes #241

* fix(browser): add <base href> support for script URL resolution (closes #242)

Previously resolve_base_url() returned the document URL directly,
causing relative <script src> URLs to resolve against the page URL
rather than the <base href> target. On SPAs served from sub-paths
(Angular/Vite/Next.js), this caused all bundle scripts to 404.

Add resolve_base_url() that queries DOM for <base href> and uses it
as the base for relative URL resolution per HTML spec. Applied to
both regular scripts and ES modules in execute_scripts().

* fix(js): implement Node.baseURI getter per WHATWG DOM spec (closes #244)

Node.baseURI was missing from the bootstrap.js Node class, causing
document.baseURI to return undefined. This broke Angular's
PlatformLocation.getBaseHref which calls new URL(href, document.baseURI)
-- with baseURI empty, the URL constructor throws Invalid URL.

Per https://dom.spec.whatwg.org/#dom-node-baseuri, Node.baseURI must
return the href of the first <base href> resolved against the document
URL, falling back to the document URL when no <base> is present.

* fix(js): add XMLHttpRequestEventTarget + XHR dispatchEvent (closes #245)

XHR shim lacked dispatchEvent and XMLHttpRequestEventTarget, causing
zone.js (Angular/zone.js) to crash with 'Cannot read properties of
undefined (reading call)' when Angular's HttpClient makes XHR calls.

Add spec-compliant XMLHttpRequestEventTarget base class with
addEventListener/removeEventListener/dispatchEvent. Make XHR extend
it so zone.js can walk the proto chain correctly.

* fix(js): fix XHR super() call order in ES6 class extends

* fix(js): use base href for dynamic script/module URL resolution

Angular lazy-loaded chunks were resolving against document.location.href
instead of the <base href>. This caused all dynamic import() chunks on
the Deutsche Boerse SPA to 404 (/bond/7591.xxx.js instead of /7591.xxx.js).

Fix: resolve dynamic script src against <base href> when present, falling
back to location.href per HTML spec.

* Revert "fix(js): use base href for dynamic script/module URL resolution"

This reverts commit 88af239be38f2aa11c3c978a4db2a7482f2ed6c0.

* feat(api): expose Page::settle() to pump JS event loop (closes #247)

obscura_browser::Page already had settle(max_ms) but it was unreachable
from the public API. Exposing it allows callers to resolve Promises,
fetch callbacks, and microtasks before reading results with evaluate().

---------

Co-authored-by: zhangyang-crazy-one <[email protected]>
2026-06-05 20:52:39 +02:00
SGavrl a703b7ac30 WPT conformance + perf: O(1) querySelector('#id'), HTML attribute folding, compareDocumentPosition, URLSearchParams, DOMParser (#238)
* 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.

* conformance: HTML attribute folding, compareDocumentPosition, URLSearchParams, DOMParser, fetch UA

Next WPT Core batch, all verified live and benchmarked for no hot-path regression.

DOM:
- compareDocumentPosition rewritten to spec: real PRECEDING/FOLLOWING via the
  compare_order op, CONTAINS/CONTAINED_BY, and DISCONNECTED|IMPLEMENTATION_SPECIFIC
  for different roots. Adds the DOCUMENT_POSITION_* and missing nodeType constants
  on Node and its prototype. WPT getPosition() depends on this, so it cascades into
  the Range comparePoint/compareBoundaryPoints/isPointInRange expectations.
- HTML elements now ASCII-lowercase attribute names in getAttribute/setAttribute/
  removeAttribute/hasAttribute, so el.setAttribute('accessKey') reflects via
  getAttribute('accesskey'). The fold is gated behind a cheap uppercase scan and a
  namespaceURI HTML guard (SVG/foreign attributes keep their case), and getAttribute
  only folds on a miss, so the all-lowercase hot path is unchanged (benchmarked flat).
- Range comparePoint/isPointInRange ToUint32-coerce the offset (negative throws
  IndexSizeError); createTreeWalker/createNodeIterator keep an explicit whatToShow of 0.

URL:
- URLSearchParams uses a proper form-urlencoded byte decoder (partial/invalid escapes
  stay literal instead of decodeURIComponent throwing on the whole string), 2-arg
  has(name,value)/delete(name,value), and a sequence-pair TypeError. Adds URL.parse
  and URL.canParse statics.

Other:
- fetch()/XHR send a default Chrome User-Agent (the navigation path set one but
  scripted requests went out with none, so UA-gated servers rejected them); honors an
  explicit override.
- DOMParser documents expose URL/compatMode/characterSet/readyState/styleSheets/
  defaultView and createTreeWalker/createNodeIterator; XMLDocument is defined globally.

* perf(dom): O(1) querySelector('#id') via the id index

querySelector('#id') went through the full selector engine, scanning descendants
in tree order until the first match (O(n), ~68us per call over a 2000-element
document in the micro-bench). A bare ASCII id selector now resolves through the
existing id index in O(1): ~68us -> ~1.25us per call, about 55x faster, with no
change to any other path (create/getAttribute/setAttribute/querySelectorAll/href
all flat). querySelector by id is one of the hottest scraping operations.

The fast path is conservative: only a bare `#name` selector with an ASCII
letter/underscore-led identifier takes it; escapes, combinators, commas,
whitespace, compounds, and non-ASCII fall through to the full engine unchanged.
For a scoped element.querySelector it returns the indexed element only when it is
a strict descendant of the scope, and falls back to the scan otherwise, so
duplicate-id scoped queries stay correct.

Also makes the id index keep the FIRST element in tree order per id (or_insert at
parse time) instead of the last, so getElementById and querySelector('#id') return
the first-in-tree-order element on duplicate ids, per spec (previously last).
2026-06-04 17:28:42 +00:00
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
SGavrl 990f2a63c8 feat(js): popover, legacy events, CDATA/PI, DOMTokenList, namedItem, attachShadow (#231)
Next batch of WPT dom/shadow-dom conformance fixes in bootstrap.js:
- Popover API: popover reflection, showPopover/hidePopover/togglePopover, ToggleEvent, :popover-open
- Legacy event init: initEvent/initUIEvent/initMouseEvent/initKeyboardEvent/initCompositionEvent, CompositionEvent
- Document.createCDATASection / createProcessingInstruction (XML-gated)
- classList as a real DOMTokenList (+ relList/sandbox/sizes/htmlFor), spec-correct namespaceURI
- getElementsByClassName ASCII-whitespace tokenization
- HTMLCollection.namedItem and named access; createRange on document/iframe/DOMParser
- attachShadow mode validation and a real ShadowRoot (cloneNode throws, activeElement, styleSheets)

querySelectorAll returns a NodeList; named access on live HTMLCollections is
resolved lazily via a Proxy so query hot paths stay at native speed.
2026-06-03 10:36:02 +00:00
SGavrl dc2a2e3b52 feat(js): DOM, namespace, validity, and parsing APIs surfaced by WPT conformance work (#230)
* feat(js): DOMException, ParentNode methods, composedPath, HTMLCollection/NodeList

Add the DOMException global (name, message, code, and the legacy code
constants on both the constructor and instances). It was referenced by
AbortSignal and others but never defined, so any test touching it failed
with 'DOMException is not defined'.

ParentNode append/prepend/replaceChildren now live on Element, Document,
and DocumentFragment and use the spec node conversion: a non-Node
argument (including null and undefined) becomes a Text node instead of
being dropped or throwing. replaceChildren was missing entirely; append
and prepend were Element-only and skipped null/undefined.

Add Event.composedPath() and the HTMLCollection and NodeList globals.

Verified with fetch --eval; integration suite still 28/28.

* feat(js): setHTMLUnsafe/getHTML, namespace lookups, getElementsByTagNameNS, Attr, form validity, Response.blob

Element and ShadowRoot setHTMLUnsafe / getHTML as shims over innerHTML.

Node lookupNamespaceURI / lookupPrefix / isDefaultNamespace walking the
element ancestry and xmlns declarations, with the built-in xml and xmlns
namespaces.

Element and Document getElementsByTagNameNS with namespace and localName
wildcard support.

Attr nodes plus Document createAttribute / createAttributeNS and Element
getAttributeNode(NS) / setAttributeNode(NS) / removeAttributeNode, bridged
to get and set attribute.

Form control constraint validation (ValidityState, validity,
willValidate, checkValidity, reportValidity, setCustomValidity,
validationMessage) and text selection (selectionStart / End / Direction,
setSelectionRange, setRangeText, select) as Element.prototype shims.

Patch blob / text / json onto the engine Response prototype so
fetch(...).blob() works; the real Response prototype lacked blob.

Verified with fetch --eval; integration suite still 28/28.
2026-06-03 09:14:11 +00:00