diff --git a/apps/chuzz/src/document_loader.rs b/apps/chuzz/src/document_loader.rs index 839384d..bef206d 100644 --- a/apps/chuzz/src/document_loader.rs +++ b/apps/chuzz/src/document_loader.rs @@ -207,6 +207,32 @@ pub(crate) const WEB_API_SHIM: &str = r#" orientation: { type: 'landscape-primary', angle: 0 } }; } + if (typeof globalThis.top === 'undefined') { + // Real, and the answer a browser gives: there are no frames here, so a + // document is its own top, parent and self. Frame-busting code compares + // `window.top !== window.self` and gets `false`, which is correct rather + // than convenient. + globalThis.top = globalThis; + globalThis.parent = globalThis; + globalThis.self = globalThis; + globalThis.frames = globalThis; + globalThis.frameElement = null; + } + if (typeof globalThis.scrollX === 'undefined') { + // The document's scroll offset is the engine's and does not reach here, so + // these report the position a page loads at and never move. That is right + // at load, which is when the scripts that read them run, and it is the same + // choice `IntersectionObserver` above makes: a lazy loader reading `scrollY` + // concludes it is at the top of the page and shows what is above the fold. + // A page that binds a scroll handler and recomputes from these will not see + // the view move. Making them true is engine work. + globalThis.scrollX = 0; + globalThis.scrollY = 0; + globalThis.pageXOffset = 0; + globalThis.pageYOffset = 0; + globalThis.scrollTo = function () {}; + globalThis.scrollBy = function () {}; + } if (typeof globalThis.requestIdleCallback === 'undefined') { globalThis.requestIdleCallback = function (callback) { return setTimeout(function () { @@ -325,6 +351,499 @@ pub(crate) const WEB_API_SHIM: &str = r#" }; }; } + // `String.prototype.substr`. Annex B, and the engine does not have it. + // + // This one is not on the corpus's missing-globals list and cannot be: the + // report counts names a page looked up and did not find, and a missing method + // on an existing prototype is a `TypeError: not a callable function` instead, + // which is a different error class counted nowhere. It was found by writing + // `unescape` in terms of it. Real, not a stub; the negative `start` and + // omitted `length` cases are the ones old code actually uses. + // + // Defined rather than assigned, because a plain assignment is enumerable and + // this is a prototype: `for (var key in 'abc')` would start yielding 'substr' + // alongside the indices, on every string in the page. + if (typeof String.prototype.substr !== 'function') { + Object.defineProperty(String.prototype, 'substr', { + configurable: true, + writable: true, + enumerable: false, + value: function (start, length) { + var text = String(this); + var from = start === undefined ? 0 : Math.trunc(Number(start)) || 0; + if (from < 0) { from = Math.max(text.length + from, 0); } + if (length === undefined) { return text.slice(from); } + var count = Math.trunc(Number(length)) || 0; + if (count <= 0) { return ''; } + return text.slice(from, from + count); + } + }); + } + // Annex B string escaping. Real implementations, not stubs: both are pure + // string transforms with a specification, so there is nothing to fake. + if (typeof globalThis.unescape === 'undefined') { + globalThis.unescape = function (input) { + var text = String(input); + var out = ''; + var index = 0; + while (index < text.length) { + var character = text.charAt(index); + if (character === '%') { + var wide = text.slice(index + 2, index + 6); + if (text.charAt(index + 1) === 'u' && /^[0-9a-fA-F]{4}$/.test(wide)) { + out += String.fromCharCode(parseInt(wide, 16)); + index += 6; + continue; + } + var narrow = text.slice(index + 1, index + 3); + if (/^[0-9a-fA-F]{2}$/.test(narrow)) { + out += String.fromCharCode(parseInt(narrow, 16)); + index += 3; + continue; + } + } + out += character; + index += 1; + } + return out; + }; + } + if (typeof globalThis.escape === 'undefined') { + globalThis.escape = function (input) { + var text = String(input); + // The unreserved set Annex B names, verbatim. + var keep = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789@*_+-./'; + var out = ''; + for (var index = 0; index < text.length; index++) { + var character = text.charAt(index); + if (keep.indexOf(character) >= 0) { + out += character; + continue; + } + var code = text.charCodeAt(index); + if (code < 256) { + out += '%' + (code < 16 ? '0' : '') + code.toString(16).toUpperCase(); + } else { + var hex = code.toString(16).toUpperCase(); + while (hex.length < 4) { hex = '0' + hex; } + out += '%u' + hex; + } + } + return out; + }; + } + if (typeof globalThis.atob === 'undefined') { + // Real base64, both ways, and the largest gap the corpus had not yet + // reported: once `String.prototype.substr` above let those scripts run past + // their first TypeError, `atob` became the next wall on 4 of the 12 pages + // re-captured. A missing global only gets counted once something reaches + // it, which is why the fix for one defect is what surfaces the next. + var BASE64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'; + globalThis.atob = function (input) { + // Whitespace is allowed anywhere in the input and padding is optional, + // which is what a page decoding a header or a data URL relies on. + var text = String(input).replace(/[ \t\n\f\r]/g, '').replace(/=+$/, ''); + if (text.length % 4 === 1) { + throw new globalThis.DOMException('invalid base64', 'InvalidCharacterError'); + } + var out = ''; + var buffer = 0; + var bits = 0; + for (var index = 0; index < text.length; index++) { + var digit = BASE64.indexOf(text.charAt(index)); + if (digit < 0) { + throw new globalThis.DOMException('invalid base64', 'InvalidCharacterError'); + } + buffer = (buffer << 6) | digit; + bits += 6; + if (bits >= 8) { + bits -= 8; + out += String.fromCharCode((buffer >> bits) & 0xff); + // Masked back down, or the accumulator keeps every group it has seen + // and overflows the 32 bits the shift operators work in. + buffer &= (1 << bits) - 1; + } + } + return out; + }; + globalThis.btoa = function (input) { + var text = String(input); + var out = ''; + for (var index = 0; index < text.length; index += 3) { + var first = text.charCodeAt(index); + var second = text.charCodeAt(index + 1); + var third = text.charCodeAt(index + 2); + // btoa is defined over a byte string; anything above 255 is the caller + // passing text it should have encoded first, and throwing says so. + if (first > 0xff || (second > 0xff) || (third > 0xff)) { + throw new globalThis.DOMException('not a byte string', 'InvalidCharacterError'); + } + var chunk = (first << 16) | ((second || 0) << 8) | (third || 0); + out += BASE64.charAt((chunk >> 18) & 0x3f) + BASE64.charAt((chunk >> 12) & 0x3f); + out += isNaN(second) ? '=' : BASE64.charAt((chunk >> 6) & 0x3f); + out += isNaN(third) ? '=' : BASE64.charAt(chunk & 0x3f); + } + return out; + }; + } + if (typeof globalThis.DOMException === 'undefined') { + // Real. A DOMException is a name, a message and a legacy code, and the + // reason pages reach for it is `error.name === 'AbortError'` rather than + // anything the platform has to provide. Building it here also gives the + // abort machinery below the type a browser would actually throw. + var LEGACY_CODES = { + IndexSizeError: 1, HierarchyRequestError: 3, WrongDocumentError: 4, + InvalidCharacterError: 5, NoModificationAllowedError: 7, NotFoundError: 8, + NotSupportedError: 9, InUseAttributeError: 10, InvalidStateError: 11, + SyntaxError: 12, InvalidModificationError: 13, NamespaceError: 14, + InvalidAccessError: 15, TypeMismatchError: 17, SecurityError: 18, + NetworkError: 19, AbortError: 20, URLMismatchError: 21, + QuotaExceededError: 22, TimeoutError: 23, InvalidNodeTypeError: 24, + DataCloneError: 25 + }; + globalThis.DOMException = function (message, name) { + this.message = message === undefined ? '' : String(message); + this.name = name === undefined ? 'Error' : String(name); + this.code = LEGACY_CODES[this.name] || 0; + // Not inherited from Error, because Boa's Error does not take to being + // subclassed from a plain constructor. A stack is attached instead, since + // that is the one property a reporter reads off a caught exception. + this.stack = this.name + ': ' + this.message; + }; + globalThis.DOMException.prototype.toString = function () { + return this.name + ': ' + this.message; + }; + } + if (typeof globalThis.TextEncoder === 'undefined') { + // Real UTF-8, including surrogate pairs, because the callers that reach for + // this are hashing, signing or framing bytes. An encoder that got the + // multi-byte cases wrong would hand them a plausible array of the wrong + // length, and they would fail somewhere else entirely. + globalThis.TextEncoder = function () {}; + Object.defineProperty(globalThis.TextEncoder.prototype, 'encoding', { + configurable: true, + get: function () { return 'utf-8'; } + }); + globalThis.TextEncoder.prototype.encode = function (input) { + var text = input === undefined ? '' : String(input); + var bytes = []; + for (var index = 0; index < text.length; index++) { + var code = text.charCodeAt(index); + if (code >= 0xd800 && code <= 0xdbff) { + // A high surrogate followed by its low half is one code point; a lone + // one is not representable, and the spec says to emit U+FFFD. + var low = index + 1 < text.length ? text.charCodeAt(index + 1) : 0; + if (low >= 0xdc00 && low <= 0xdfff) { + code = 0x10000 + ((code - 0xd800) * 0x400) + (low - 0xdc00); + index += 1; + } else { + code = 0xfffd; + } + } else if (code >= 0xdc00 && code <= 0xdfff) { + code = 0xfffd; + } + if (code < 0x80) { + bytes.push(code); + } else if (code < 0x800) { + bytes.push(0xc0 | (code >> 6), 0x80 | (code & 0x3f)); + } else if (code < 0x10000) { + bytes.push(0xe0 | (code >> 12), 0x80 | ((code >> 6) & 0x3f), 0x80 | (code & 0x3f)); + } else { + bytes.push( + 0xf0 | (code >> 18), + 0x80 | ((code >> 12) & 0x3f), + 0x80 | ((code >> 6) & 0x3f), + 0x80 | (code & 0x3f) + ); + } + } + return typeof Uint8Array === 'function' ? new Uint8Array(bytes) : bytes; + }; + globalThis.TextEncoder.prototype.encodeInto = function (input, destination) { + var text = input === undefined ? '' : String(input); + var encoded = this.encode(text); + var written = Math.min(encoded.length, destination ? destination.length : 0); + for (var index = 0; index < written; index++) { destination[index] = encoded[index]; } + // `read` counts the UTF-16 units consumed, and is only exact when the + // whole string fitted: stopping part way would need the encoder to encode + // incrementally, which this one does not. + return { read: written === encoded.length ? text.length : 0, written: written }; + }; + } + if (typeof globalThis.TextDecoder === 'undefined') { + globalThis.TextDecoder = function (label) { + this._encoding = label === undefined ? 'utf-8' : String(label).toLowerCase(); + }; + Object.defineProperty(globalThis.TextDecoder.prototype, 'encoding', { + configurable: true, + get: function () { return this._encoding || 'utf-8'; } + }); + globalThis.TextDecoder.prototype.decode = function (input) { + if (input === undefined || input === null) { return ''; } + var bytes = input; + // Accept an ArrayBuffer or any view over one, which is what a caller + // holding the result of a slice or a DataView actually has. + if (typeof ArrayBuffer === 'function' && input instanceof ArrayBuffer) { + bytes = new Uint8Array(input); + } else if (typeof Uint8Array === 'function' && !(input instanceof Uint8Array) + && input.buffer && typeof input.byteOffset === 'number') { + bytes = new Uint8Array(input.buffer, input.byteOffset, input.byteLength); + } + var out = ''; + var index = 0; + var length = bytes.length; + while (index < length) { + var lead = bytes[index++] & 0xff; + var code; + var trailing; + if (lead < 0x80) { out += String.fromCharCode(lead); continue; } + // 0xc0 and 0xc1 are excluded here rather than checked for afterwards: + // they can only ever start an overlong two-byte sequence. + else if (lead >= 0xc2 && lead <= 0xdf) { code = lead & 0x1f; trailing = 1; } + else if (lead >= 0xe0 && lead <= 0xef) { code = lead & 0x0f; trailing = 2; } + else if (lead >= 0xf0 && lead <= 0xf4) { code = lead & 0x07; trailing = 3; } + else { out += '\uFFFD'; continue; } + var complete = true; + for (var step = 0; step < trailing; step++) { + var next = index < length ? bytes[index] & 0xff : -1; + if (next < 0x80 || next > 0xbf) { complete = false; break; } + code = (code * 64) + (next & 0x3f); + index += 1; + } + if (!complete + || code > 0x10ffff + || (code >= 0xd800 && code <= 0xdfff) + || (trailing === 2 && code < 0x800) + || (trailing === 3 && code < 0x10000)) { + out += '\uFFFD'; + continue; + } + if (code <= 0xffff) { + out += String.fromCharCode(code); + } else { + code -= 0x10000; + out += String.fromCharCode(0xd800 + (code >> 10), 0xdc00 + (code & 0x3ff)); + } + } + return out; + }; + } + if (typeof globalThis.AbortController === 'undefined') { + // Real, not a stub: the whole of AbortController is bookkeeping over a flag + // and a listener list, and there is no engine support to wait for. The half + // that is missing is on the consumer side, where a request already in + // flight cannot be cancelled at the socket. The page's own `signal.aborted` + // checks, `throwIfAborted`, and its abort handlers all behave. + var abortReason = function (reason) { + if (reason !== undefined) { return reason; } + return new globalThis.DOMException('signal is aborted without reason', 'AbortError'); + }; + var AbortSignal = function () { + this.aborted = false; + this.reason = undefined; + this.onabort = null; + this._handlers = []; + }; + AbortSignal.prototype.addEventListener = function (type, handler) { + if (type === 'abort' && typeof handler === 'function') { this._handlers.push(handler); } + }; + AbortSignal.prototype.removeEventListener = function (type, handler) { + if (type !== 'abort') { return; } + var at = this._handlers.indexOf(handler); + if (at >= 0) { this._handlers.splice(at, 1); } + }; + AbortSignal.prototype.dispatchEvent = function () { return false; }; + AbortSignal.prototype.throwIfAborted = function () { + if (this.aborted) { throw this.reason; } + }; + var fireAbort = function (signal, reason) { + if (signal.aborted) { return; } + signal.aborted = true; + signal.reason = abortReason(reason); + var event = { type: 'abort', target: signal }; + if (typeof signal.onabort === 'function') { + try { signal.onabort(event); } catch (error) { /* the page's handler threw */ } + } + var handlers = signal._handlers.slice(); + signal._handlers.length = 0; + for (var index = 0; index < handlers.length; index++) { + try { handlers[index](event); } catch (error) { /* likewise */ } + } + }; + AbortSignal.abort = function (reason) { + var signal = new AbortSignal(); + signal.aborted = true; + signal.reason = abortReason(reason); + return signal; + }; + AbortSignal.timeout = function (milliseconds) { + var signal = new AbortSignal(); + setTimeout(function () { + fireAbort(signal, new globalThis.DOMException('signal timed out', 'TimeoutError')); + }, milliseconds); + return signal; + }; + AbortSignal.any = function (signals) { + var combined = new AbortSignal(); + var list = signals || []; + for (var index = 0; index < list.length; index++) { + if (list[index] && list[index].aborted) { + combined.aborted = true; + combined.reason = list[index].reason; + return combined; + } + } + for (var each = 0; each < list.length; each++) { + if (!list[each] || typeof list[each].addEventListener !== 'function') { continue; } + (function (source) { + source.addEventListener('abort', function () { fireAbort(combined, source.reason); }); + })(list[each]); + } + return combined; + }; + globalThis.AbortSignal = AbortSignal; + globalThis.AbortController = function () { this.signal = new AbortSignal(); }; + globalThis.AbortController.prototype.abort = function (reason) { + fireAbort(this.signal, reason); + }; + } + if (typeof globalThis.ResizeObserver === 'undefined') { + // A stub, and deliberately silent rather than firing once the way + // `IntersectionObserver` above does. The difference is what an invented + // entry would have to say: visibility has an answer that is right for most + // of a page ("yes"), and a size does not. Nothing here can measure a box + // from JavaScript, so the only entry this could deliver carries a zero + // `contentRect`, and a grid or carousel that divides by that width computes + // zero columns and renders nothing. Never firing leaves such a component on + // whatever it renders before it has measured, which is the better of the + // two wrong answers. Backing this with real box data is engine work. + globalThis.ResizeObserver = function (callback) { + this.callback = callback; + this.observe = function () {}; + this.unobserve = function () {}; + this.disconnect = function () {}; + }; + } + if (typeof globalThis.Image === 'undefined') { + // A stub. It reports every image as loaded without fetching anything, so a + // preloader, which is what most constructed `Image`s are, runs its + // callback and the page proceeds. Code that waits for the load and then + // reads pixels or natural dimensions gets nothing useful, and the zero + // dimensions below are left honest rather than invented for that reason. + // Images the *document* references are fetched and painted by the engine; + // this is only the JavaScript constructor. + globalThis.Image = function (width, height) { + var image = this; + var handlers = []; + var source = ''; + this.width = width === undefined ? 0 : width; + this.height = height === undefined ? 0 : height; + this.naturalWidth = 0; + this.naturalHeight = 0; + this.complete = false; + this.onload = null; + this.onerror = null; + this.crossOrigin = null; + this.decoding = 'auto'; + this.loading = 'eager'; + this.addEventListener = function (type, handler) { + if (typeof handler === 'function') { handlers.push([String(type), handler]); } + }; + this.removeEventListener = function (type, handler) { + for (var index = handlers.length - 1; index >= 0; index--) { + if (handlers[index][0] === String(type) && handlers[index][1] === handler) { + handlers.splice(index, 1); + } + } + }; + this.dispatchEvent = function () { return false; }; + this.decode = function () { return Promise.resolve(); }; + Object.defineProperty(this, 'src', { + configurable: true, + get: function () { return source; }, + set: function (value) { + source = String(value); + // Asynchronously, the way a real load completes. Firing during the + // assignment would reach a handler the caller attaches on the next + // line, which is the ordinary way this is written. + setTimeout(function () { + image.complete = true; + var event = { type: 'load', target: image }; + if (typeof image.onload === 'function') { + try { image.onload(event); } catch (error) { /* the page's handler threw */ } + } + var listeners = handlers.slice(); + for (var index = 0; index < listeners.length; index++) { + if (listeners[index][0] !== 'load') { continue; } + try { listeners[index][1](event); } catch (error) { /* likewise */ } + } + }, 0); + } + }); + }; + } + if (typeof globalThis.Path2D === 'undefined') { + // The path is really accumulated; what is missing is anything that reads + // it. A page constructing a Path2D is about to hand it to a canvas context, + // and that is the part the engine does not have, so this keeps the + // construction from throwing and no more. + globalThis.Path2D = function (path) { + this.commands = path && path.commands ? path.commands.slice() : []; + var record = function (name) { + return function () { + this.commands.push([name].concat(Array.prototype.slice.call(arguments))); + }; + }; + this.addPath = function (other) { + if (other && other.commands) { this.commands = this.commands.concat(other.commands); } + }; + this.closePath = record('closePath'); + this.moveTo = record('moveTo'); + this.lineTo = record('lineTo'); + this.bezierCurveTo = record('bezierCurveTo'); + this.quadraticCurveTo = record('quadraticCurveTo'); + this.arc = record('arc'); + this.arcTo = record('arcTo'); + this.ellipse = record('ellipse'); + this.rect = record('rect'); + this.roundRect = record('roundRect'); + }; + } + if (typeof globalThis.ShadowRoot === 'undefined') { + // Declared so `node instanceof ShadowRoot` and `x.constructor === ShadowRoot` + // are answerable, and nothing is an instance of it. That is the truthful + // answer here: this engine builds no shadow trees, so every node really is + // in the light DOM, and a test that asks gets "no" instead of a ReferenceError. + globalThis.ShadowRoot = function () {}; + } + // Deliberately absent, so nobody adds them from the corpus report alone: + // + // - `getComputedStyle`. A stub answering '' for every property is worse than + // the ReferenceError it replaces. Today the script throws and stops, which + // is visible; with a lying stub it continues, measures nothing, and lays the + // page out wrongly, which looks like an engine bug. It needs real style data + // from the document, which is engine work rather than a shim. + // - `ReadableStream`. A page reaching for it wants incremental delivery, and a + // stub can only hand over the whole body at once or nothing. Both read as a + // working stream to the code and neither is one. + // - The DOM interface constructors the corpus also reported missing: + // `NodeList`, `DocumentFragment`, `CharacterData`, `KeyboardEvent`, + // `HTMLVideoElement`. `ShadowRoot` above is declared precisely because + // nothing in this engine is one, so answering `false` to `instanceof` is + // true. These are the opposite case: the document really does contain node + // lists and fragments, so an empty constructor would answer `false` about + // objects that genuinely are instances, and a branch that meant to take the + // DOM path would silently take the other one. They belong with the engine's + // DOM bindings, next to the prototypes they have to be related to. + // - `Intl`. Faking `NumberFormat` and `DateTimeFormat` as `String(value)` + // would keep a script alive at the cost of rendering unformatted numbers + // and raw date strings as though they were the page's own output, and the + // locale data behind a real one is not a shim. + // - `ActiveXObject`, reported by one site. No browser has it, and a page that + // reaches for it without a `typeof` guard throws in Chrome too. Absent is + // the correct answer and the report is not a defect of ours. + // - `WebAssembly`, `define` and `require` are module and engine support, + // which is not something JavaScript in this string can supply. })(); "#; /// Load a page outside the browser, for headless capture. @@ -480,3 +999,320 @@ impl CapturedDocument { } } } + +/// The shim is a three-hundred-line JavaScript string in a Rust file, and +/// nothing else in the build parses it. A syntax error in it is not a compile +/// error: it is a page that renders as if the shim were absent, on every site. +/// These evaluate it the way a page does and read the answers back. +#[cfg(all(test, feature = "javascript"))] +mod tests { + use super::WEB_API_SHIM; + + fn shimmed() -> blitz_script::ScriptDocument { + let mut document = blitz_script::ScriptDocument::from_html( + "", + blitz_dom::DocumentConfig::default(), + ); + document.eval(WEB_API_SHIM); + document + } + + fn value(document: &mut blitz_script::ScriptDocument, script: &str) -> serde_json::Value { + document + .eval_json(script) + .unwrap_or_else(|error| panic!("evaluating `{script}` failed: {error:?}")) + } + + /// Run timers until a probe answers, or give up. + /// + /// `setTimeout` fires from the document's own polling, so a shim that + /// defers its callback has nothing to fire it in a test that only evals. + fn pump_for(document: &mut blitz_script::ScriptDocument, probe: &str) -> serde_json::Value { + use blitz_dom::Document as _; + for _ in 0..100 { + document.poll(None); + let seen = value(document, probe); + if !seen.is_null() { + return seen; + } + std::thread::sleep(std::time::Duration::from_millis(5)); + } + serde_json::Value::Null + } + + /// The whole string parses and every global it promises is installed. + /// + /// One bad token anywhere silently costs all of them, so this asserts the + /// list rather than each name where it is tested. + #[test] + fn the_shim_installs_every_global_it_claims() { + let mut document = shimmed(); + for name in [ + "localStorage", + "sessionStorage", + "URLSearchParams", + "MutationObserver", + "IntersectionObserver", + "requestIdleCallback", + "matchMedia", + "unescape", + "escape", + "TextEncoder", + "TextDecoder", + "AbortController", + "AbortSignal", + "ResizeObserver", + "Image", + "Path2D", + "ShadowRoot", + "DOMException", + "atob", + "btoa", + "top", + "scrollX", + ] { + assert_ne!( + value(&mut document, &format!("typeof globalThis.{name}")), + serde_json::json!("undefined"), + "the shim should define {name}" + ); + } + } + + /// ASCII, two-byte, three-byte and a surrogate pair, both ways. + /// + /// The callers that reach for `TextEncoder` are hashing or framing bytes, so + /// a wrong length is worse than a missing constructor: it fails somewhere + /// else, later, as a bad digest. + #[test] + fn text_encoding_is_real_utf8() { + let mut document = shimmed(); + assert_eq!( + value( + &mut document, + "Array.from(new TextEncoder().encode('A\u{00e9}\u{20ac}\u{1f600}'))" + ), + serde_json::json!([0x41, 0xc3, 0xa9, 0xe2, 0x82, 0xac, 0xf0, 0x9f, 0x98, 0x80]), + "one ASCII, one two-byte, one three-byte and one four-byte code point" + ); + assert_eq!( + value( + &mut document, + "new TextDecoder().decode(new TextEncoder().encode('A\u{00e9}\u{20ac}\u{1f600}'))" + ), + serde_json::json!("A\u{00e9}\u{20ac}\u{1f600}"), + "decoding what the encoder produced returns the original string" + ); + assert_eq!( + value( + &mut document, + "new TextDecoder().decode(new Uint8Array([0xc0, 0x80, 0x41]))" + ), + serde_json::json!("\u{fffd}\u{fffd}A"), + "an overlong sequence is replaced rather than decoded" + ); + } + + /// Annex B escaping, which is a pure string transform with a specification. + #[test] + fn escape_and_unescape_round_trip() { + let mut document = shimmed(); + assert_eq!( + value(&mut document, "escape('a b/\u{00e9}\u{20ac}')"), + serde_json::json!("a%20b/%E9%u20AC"), + "space and Latin-1 as %XX, above 255 as %uXXXX, and `/` left alone" + ); + assert_eq!( + value(&mut document, "unescape(escape('a b/\u{00e9}\u{20ac}'))"), + serde_json::json!("a b/\u{00e9}\u{20ac}") + ); + } + + /// `substr` is Annex B, the engine lacks it, and old code still calls it. + /// + /// A missing prototype method reads as `TypeError: not a callable function` + /// rather than a missing global, which is why no corpus count found it. + #[test] + fn substr_handles_the_cases_old_code_uses() { + let mut document = shimmed(); + assert_eq!( + value( + &mut document, + "['abcdef'.substr(2), 'abcdef'.substr(1, 3), 'abcdef'.substr(-2), 'abcdef'.substr(1, 0)]" + ), + serde_json::json!(["cdef", "bcd", "ef", ""]) + ); + assert_eq!( + value( + &mut document, + "(function () { var keys = []; for (var key in 'ab') { keys.push(key); } return keys; })()" + ), + serde_json::json!(["0", "1"]), + "a prototype addition must not become enumerable on every string" + ); + } + + /// A controller aborts its signal, and the abort is observable three ways. + #[test] + fn aborting_a_controller_notifies_its_signal() { + let mut document = shimmed(); + assert_eq!( + value( + &mut document, + "(function () { + var controller = new AbortController(); + var seen = 0; + controller.signal.addEventListener('abort', function () { seen++; }); + controller.signal.onabort = function () { seen++; }; + var before = controller.signal.aborted; + controller.abort(); + var threw = false; + try { controller.signal.throwIfAborted(); } catch (error) { threw = error.name; } + return [before, controller.signal.aborted, seen, threw]; + })()" + ), + serde_json::json!([false, true, 2, "AbortError"]) + ); + assert_eq!( + value(&mut document, "AbortSignal.abort('gone').reason"), + serde_json::json!("gone"), + "an explicit reason is kept rather than replaced with an AbortError" + ); + } + + /// Setting `src` reports a load, asynchronously, to both handler styles. + /// + /// Asynchronously matters: a preloader attaches `onload` on the line after + /// the assignment, and a callback fired during the setter would miss it. + #[test] + fn an_image_reports_a_load_after_its_src_is_set() { + let mut document = shimmed(); + document.eval( + "globalThis.__loaded = null; + var image = new Image(); + var seen = []; + image.addEventListener('load', function () { seen.push('listener'); }); + image.onload = function () { seen.push('onload'); globalThis.__loaded = seen; }; + globalThis.__during = image.complete; + image.src = 'https://example.invalid/pixel.png';", + ); + assert_eq!( + value(&mut document, "globalThis.__during"), + serde_json::json!(false), + "the load must not be reported from inside the setter" + ); + assert_eq!( + pump_for(&mut document, "globalThis.__loaded"), + serde_json::json!(["onload", "listener"]), + "both handler styles run once the timer fires" + ); + } + + /// Base64 both ways, including the unpadded and whitespaced inputs pages send. + /// + /// This is the one addition here the corpus did not ask for and measurement + /// did: `substr` let four of the twelve re-captured pages run past their + /// first TypeError, and `atob` was the wall they hit next. + #[test] + fn base64_round_trips() { + let mut document = shimmed(); + assert_eq!( + value(&mut document, "btoa('any carnal pleasure.')"), + serde_json::json!("YW55IGNhcm5hbCBwbGVhc3VyZS4=") + ); + assert_eq!( + value(&mut document, "atob('YW55IGNhcm5hbCBwbGVhc3VyZS4=')"), + serde_json::json!("any carnal pleasure.") + ); + assert_eq!( + value( + &mut document, + "[btoa('a'), btoa('ab'), btoa('abc'), atob('YQ'), atob('YWJj')]" + ), + serde_json::json!(["YQ==", "YWI=", "YWJj", "a", "abc"]), + "every padding length, and an unpadded input decoding anyway" + ); + assert_eq!( + value(&mut document, "atob(' YW Jj\\n')"), + serde_json::json!("abc"), + "whitespace anywhere is stripped rather than rejected" + ); + assert_eq!( + value( + &mut document, + "(function () { try { atob('!'); } catch (error) { return error.name; } return 'no throw'; })()" + ), + serde_json::json!("InvalidCharacterError") + ); + } + + /// A DOMException carries the name a page branches on, and a legacy code. + #[test] + fn dom_exception_is_the_type_a_browser_throws() { + let mut document = shimmed(); + assert_eq!( + value( + &mut document, + "(function () { + var error = new DOMException('nope', 'AbortError'); + return [error.name, error.message, error.code, String(error)]; + })()" + ), + serde_json::json!(["AbortError", "nope", 20, "AbortError: nope"]) + ); + assert_eq!( + value( + &mut document, + "(function () { + var controller = new AbortController(); + controller.abort(); + return controller.signal.reason instanceof DOMException; + })()" + ), + serde_json::json!(true), + "an abort with no reason throws what a browser throws" + ); + } + + /// A document with no frames is its own top, which is the true answer. + #[test] + fn the_window_is_its_own_top() { + let mut document = shimmed(); + assert_eq!( + value( + &mut document, + "[globalThis.top === globalThis.self, globalThis.parent === globalThis, globalThis.frameElement]" + ), + serde_json::json!([true, true, serde_json::Value::Null]), + "frame-busting code must not conclude it is framed" + ); + } + + /// The omissions are deliberate, and this is the record of that. + /// + /// Both are on the corpus's missing-globals list, and both are cheap to + /// stub and wrong to stub: `getComputedStyle` returning `''` for every + /// property turns a visible error into a silently mislaid-out page, and a + /// `ReadableStream` that cannot stream reads as one to the code using it. + /// A future change that adds either should be a change that backs it with + /// real data, and should delete this test rather than edit it. + #[test] + fn the_lying_stubs_are_left_out() { + let mut document = shimmed(); + for name in [ + "getComputedStyle", + "ReadableStream", + "NodeList", + "DocumentFragment", + "CharacterData", + "Intl", + "ActiveXObject", + ] { + assert_eq!( + value(&mut document, &format!("typeof globalThis.{name}")), + serde_json::json!("undefined"), + "{name} is deliberately not shimmed" + ); + } + } +} diff --git a/apps/chuzz/src/net_bridge.rs b/apps/chuzz/src/net_bridge.rs index 1b8f1ac..34c8a09 100644 --- a/apps/chuzz/src/net_bridge.rs +++ b/apps/chuzz/src/net_bridge.rs @@ -188,8 +188,17 @@ const NETWORK_API_SHIM: &str = r#" delete pending[id]; onResult({ ok: false, error: String(error) }); } + return id; } + // Stop listening for a request's answer. + // + // The request itself keeps running: the host has already spawned it and there + // is no cancellation channel back. What this buys is the observable half of + // aborting: the caller's promise settles now, and its handler does not run + // later against a component that has been torn down. + function forget(id) { delete pending[id]; } + function Response(result) { var body = result.body == null ? "" : String(result.body); this.ok = !!result.ok; @@ -209,14 +218,32 @@ const NETWORK_API_SHIM: &str = r#" this.clone = function () { return new Response(result); }; } + function abortError(signal) { + var reason = signal && signal.reason; + if (reason !== undefined && reason !== null) { return reason; } + var error = new Error("The operation was aborted"); + error.name = "AbortError"; + return error; + } + if (typeof globalThis.fetch === "undefined") { globalThis.fetch = function (input, init) { var url = input && input.url ? input.url : input; + var signal = init && init.signal; return new Promise(function (resolve, reject) { - send(url, init, function (result) { + // An already-aborted signal rejects without touching the network, which + // is what a caller reusing a controller across renders depends on. + if (signal && signal.aborted) { reject(abortError(signal)); return; } + var id = send(url, init, function (result) { if (result.ok) { resolve(new Response(result)); } else { reject(new TypeError("fetch failed: " + (result.error || "unknown"))); } }); + if (signal && typeof signal.addEventListener === "function") { + signal.addEventListener("abort", function () { + forget(id); + reject(abortError(signal)); + }); + } }); }; } @@ -231,8 +258,11 @@ const NETWORK_API_SHIM: &str = r#" this.onreadystatechange = null; this.onload = null; this.onerror = null; + this.onabort = null; this._method = "GET"; this._url = ""; + // The id of the request in flight, so `abort` has something to drop. + this._id = 0; }; XHR.prototype.open = function (method, url) { this._method = method || "GET"; @@ -245,10 +275,18 @@ const NETWORK_API_SHIM: &str = r#" XHR.prototype.setRequestHeader = function () {}; XHR.prototype.getAllResponseHeaders = function () { return ""; }; XHR.prototype.getResponseHeader = function () { return null; }; - XHR.prototype.abort = function () {}; + XHR.prototype.abort = function () { + if (this._id) { forget(this._id); this._id = 0; } + this.readyState = 0; + this.status = 0; + this.statusText = ""; + if (this.onabort) { this.onabort(); } + if (this.onreadystatechange) { this.onreadystatechange(); } + }; XHR.prototype.send = function (body) { var self = this; - send(this._url, { method: this._method, body: body }, function (result) { + this._id = send(this._url, { method: this._method, body: body }, function (result) { + self._id = 0; self.readyState = 4; if (result.ok) { self.status = result.status || 200; @@ -342,10 +380,15 @@ mod tests { } fn page() -> blitz_script::ScriptDocument { - blitz_script::ScriptDocument::from_html( + let mut document = blitz_script::ScriptDocument::from_html( "
", blitz_dom::DocumentConfig::default(), - ) + ); + // The order `browser.rs` and `load_for_capture` both use: the web-API + // shim first, then this one over it. `AbortController` comes from + // there, and `fetch` here only honours a signal because it does. + document.eval(crate::document_loader::WEB_API_SHIM); + document } /// A page that fetches gets its body, through the promise it parked. @@ -409,4 +452,80 @@ mod tests { ); assert_eq!(result, serde_json::json!("hello from the server")); } + + /// A signal that is already aborted rejects without reaching the network. + /// + /// The URL below is deliberately one nothing can answer: if this ever + /// reaches the host the test still fails, because the rejection would carry + /// a fetch error rather than the abort reason. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn a_fetch_with_an_aborted_signal_never_leaves() { + let mut document = page(); + install( + &mut document, + Arc::new(NetProvider::new(None)), + Duration::from_secs(10), + ); + + document.eval( + "globalThis.__aborted = null; + var controller = new AbortController(); + controller.abort(); + fetch('http://127.0.0.1:1/never', { signal: controller.signal }) + .then(function () { globalThis.__aborted = 'resolved'; }) + .catch(function (error) { globalThis.__aborted = error.name; });", + ); + + assert_eq!( + pump_for(&mut document, "globalThis.__aborted"), + serde_json::json!("AbortError") + ); + } + + /// Aborting a request in flight settles the promise and drops the answer. + /// + /// The server here does reply, and the host request does finish: what abort + /// buys is that the page's `then` never runs against it. That is the half + /// of cancellation this can honour, and the assertion is written to fail if + /// a later change quietly lets the late delivery through. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn aborting_in_flight_drops_the_answer() { + let (port, server) = serve_once("late"); + let mut document = page(); + install( + &mut document, + Arc::new(NetProvider::new(None)), + Duration::from_secs(10), + ); + + document.eval(&format!( + "globalThis.__settled = null; + var controller = new AbortController(); + fetch('http://127.0.0.1:{port}/slow', {{ signal: controller.signal }}) + .then(function () {{ globalThis.__settled = 'resolved'; }}) + .catch(function (error) {{ globalThis.__settled = error.name; }}); + controller.abort();" + )); + + assert_eq!( + pump_for(&mut document, "globalThis.__settled"), + serde_json::json!("AbortError") + ); + assert!( + server.join().expect("the server thread finishes"), + "the request was already on its way; abort does not unsend it" + ); + // Pump past the point the reply comes back, and the page must not see it. + for _ in 0..50 { + document.poll(None); + std::thread::sleep(Duration::from_millis(10)); + } + assert_eq!( + document + .eval_json("globalThis.__settled") + .expect("the probe evaluates"), + serde_json::json!("AbortError"), + "the dropped delivery must not resolve the aborted promise" + ); + } }