test(renderer): split DOM XHR test modules

This commit is contained in:
ldm0
2026-09-27 18:39:00 +08:00
parent a41a8b670d
commit e02a0b4e49
13 changed files with 9921 additions and 9816 deletions
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,231 @@
use super::*;
#[test]
fn dom_parser_append_is_atomic_when_later_argument_is_invalid() {
let mut vm = new_storage_test_vm("https://dom-parser-append-atomic.test/");
let result = vm
.eval(
r#"
(() => {
const doc = new DOMParser().parseFromString('<!doctype html><html><body></body></html>', 'text/html');
try {
doc.body.append('before', document);
} catch (e) {
}
return [
doc.body.childNodes.length,
doc.body.textContent
].join('|');
})()
"#,
)
.expect("DOMParser append should not partially mutate on later invalid args");
assert_eq!(result, "0|");
}
#[test]
fn detached_document_head_body_only_match_document_element_children() {
let mut vm = new_storage_test_vm("https://detached-head-body-direct-children.test/");
let result = vm
.eval(
r#"
(() => {
const doc = new DOMParser().parseFromString('<!doctype html><html><head></head><body></body></html>', 'text/html');
doc.removeChild(doc.documentElement);
const html = doc.createElement('html');
const body = doc.createElement('body');
const section = doc.createElement('section');
const nestedHead = doc.createElement('head');
section.appendChild(nestedHead);
body.appendChild(section);
html.appendChild(body);
doc.appendChild(html);
return [
doc.head === null,
nestedHead.localName,
doc.body.localName
].join('|');
})()
"#,
)
.expect("detached document head/body should only use direct html children");
assert_eq!(result, "true|head|body");
}
#[test]
fn child_content_document_exposes_document_node_mutation_methods() {
let mut vm = new_storage_test_vm("https://child-content-document-node-mutation.test/");
let result = vm
.eval(
r#"
(() => {
const iframe = document.createElement('iframe');
(document.body || document.documentElement || document).appendChild(iframe);
const doc = iframe.contentDocument;
const root = doc.documentElement;
const before = [
typeof doc.removeChild,
typeof doc.appendChild,
typeof doc.insertBefore,
typeof doc.replaceChild,
!!root
].join(',');
const removed = doc.removeChild(root);
const afterRemove = [
removed === root,
doc.documentElement === null,
doc.body === null,
root.parentNode === null,
root.isConnected === false
].join(',');
const replacement = root.cloneNode(true);
doc.appendChild(replacement);
const afterAppend = [
doc.documentElement === replacement,
doc.body === replacement.querySelector('body'),
doc.lastChild === replacement,
replacement.parentNode === doc,
replacement.isConnected === true
].join(',');
return `${before}|${afterRemove}|${afterAppend}`;
})()
"#,
)
.expect("child contentDocument should expose document node mutation methods");
assert_eq!(
result,
"function,function,function,function,true|true,true,true,true,true|true,true,true,true,true"
);
}
#[test]
fn child_window_exports_script_globals_even_when_script_throws() {
let mut vm = new_storage_test_vm("https://child-script-throw-export.test/");
let result = vm
.eval(
r#"
(() => {
const iframe = document.createElement('iframe');
(document.body || document.documentElement || document).appendChild(iframe);
const win = iframe.contentWindow;
const doc = iframe.contentDocument;
doc.open();
doc.write('<script>function keptFunction() { return this === window ? 7 : 0; } var keptVar = 3; throw new Error("boom");</script>');
doc.close();
return [
typeof win.keptFunction,
win.keptFunction(),
win.keptVar
].join('|');
})()
"#,
)
.expect("child script globals should be exported before a runtime throw escapes");
assert_eq!(result, "function|7|3");
}
#[test]
fn detached_dom_parser_nodes_keep_relationship_state_after_node_prototype_migration() {
let mut vm = new_storage_test_vm("https://detached-dom-node-prototype-shadowing.test/");
let result = vm
.eval(
r#"
(() => {
const doc = new DOMParser().parseFromString(
'<html><body><p id="a"></p><span id="b"></span></body></html>',
'text/html'
);
const body = doc.body;
const a = doc.getElementById('a');
const b = doc.getElementById('b');
const div = doc.createElement('div');
const before = [
Object.prototype.hasOwnProperty.call(body, 'childNodes'),
Object.prototype.hasOwnProperty.call(body, 'firstChild'),
Object.prototype.hasOwnProperty.call(body, 'lastChild'),
Object.prototype.hasOwnProperty.call(a, 'parentNode'),
Object.prototype.hasOwnProperty.call(a, 'nextSibling'),
Object.prototype.hasOwnProperty.call(b, 'previousSibling'),
Object.prototype.toString.call(body.childNodes),
Array.from(body.childNodes).map(node => node.id).join(','),
Object.prototype.toString.call(div.childNodes),
Array.from(div.childNodes).length,
body.firstChild === a,
body.lastChild === b,
a.parentNode === body,
a.nextSibling === b,
b.previousSibling === a
].join('|');
body.insertBefore(div, b);
const text = doc.createTextNode('x');
div.appendChild(text);
const after = [
Array.from(body.childNodes).map(node => node.id || node.nodeName).join(','),
body.firstChild === a,
body.lastChild === b,
div.parentNode === body,
Object.prototype.toString.call(div.childNodes),
Array.from(div.childNodes).length,
div.firstChild === text,
div.lastChild === text,
text.parentNode === div
].join('|');
return `${before}||${after}`;
})()
"#,
)
.expect("detached DOMParser nodes should retain own relationship state");
assert_eq!(
result,
"false|false|false|false|false|false|[object NodeList]|a,b|[object NodeList]|0|true|true|true|true|true||a,DIV,b|true|true|true|[object NodeList]|1|true|true|true"
);
}
#[test]
fn detached_document_rejects_overdeep_live_subtree_adoption() {
run_large_stack_dom_test("detached-adopt-depth", || {
let mut vm = new_storage_test_vm("https://detached-adopt-depth.test/");
let result = vm
.eval(
r#"
(() => {
const detached = document.implementation.createDocument(null, 'container', null);
const root = document.createElement('div');
let cursor = root;
for (let i = 0; i < 514; i++) {
const child = document.createElement('div');
cursor.appendChild(child);
cursor = child;
}
try {
detached.documentElement.appendChild(root);
return `inserted:${detached.documentElement.childNodes.length}`;
} catch (error) {
return `${error.name}:${detached.documentElement.childNodes.length}`;
}
})()
"#,
)
.expect("overdeep detached live subtree adoption should return a bounded result");
assert_eq!(result, "HierarchyRequestError:0");
});
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,317 @@
use super::*;
#[test]
fn dom_parser_parse_from_string_parses_webidl_arguments() {
let mut vm = new_storage_test_vm("https://dom-parser-webidl-args.test/");
let result = vm
.eval(
r#"
(() => {
function probe(callback) {
try {
const value = callback();
return value && value.documentElement
? value.documentElement.tagName
: String(value);
} catch (error) {
return 'throw:' + error.name;
}
}
const parser = new DOMParser();
const sourceObject = {
toString() {
return '<html><body><section id="from-object"></section></body></html>';
}
};
return JSON.stringify({
html: parser.parseFromString('<html><body><p></p></body></html>', 'text/html').documentElement.tagName,
xml: parser.parseFromString('<root></root>', 'application/xml').documentElement.tagName,
objectSource: parser.parseFromString(sourceObject, 'text/html').getElementById('from-object').tagName,
nullSource: parser.parseFromString(null, 'text/html').body.textContent,
missingSource: probe(() => parser.parseFromString()),
missingType: probe(() => parser.parseFromString('<root></root>')),
invalidType: probe(() => parser.parseFromString('<root></root>', 'TEXT/html')),
symbolSource: probe(() => parser.parseFromString(Symbol(), 'text/html')),
symbolType: probe(() => parser.parseFromString('<root></root>', Symbol())),
throwingSource: probe(() => parser.parseFromString({
toString() { throw new Error('source failed'); }
}, 'text/html'))
});
})()
"#,
)
.expect("DOMParser.parseFromString WebIDL argument probe should run");
assert_eq!(
result,
r#"{"html":"HTML","xml":"root","objectSource":"SECTION","nullSource":"null","missingSource":"throw:TypeError","missingType":"throw:TypeError","invalidType":"throw:TypeError","symbolSource":"throw:TypeError","symbolType":"throw:TypeError","throwingSource":"throw:Error"}"#
);
}
#[test]
fn dom_parser_prototype_parse_from_string_is_declared_operation() {
let mut vm = new_storage_test_vm("https://dom-parser-prototype-methods.test/");
let result = vm
.eval(
r#"
(() => {
const parser = new DOMParser();
const descriptor = Object.getOwnPropertyDescriptor(DOMParser.prototype, 'parseFromString');
const html = parser.parseFromString(
'<html><body><main id="root"></main></body></html>',
'text/html'
);
const xml = DOMParser.prototype.parseFromString.call(
parser,
'<root><child /></root>',
'application/xml'
);
return JSON.stringify({
descriptor: [
typeof descriptor?.value,
descriptor?.value?.name,
descriptor?.value?.length,
descriptor?.enumerable,
descriptor?.writable,
descriptor?.configurable
].join(':'),
own: Object.hasOwn(parser, 'parseFromString'),
enumerable: Object.keys(DOMParser.prototype).join(','),
behavior: [
html.getElementById('root').tagName,
xml.documentElement.tagName,
parser instanceof DOMParser
].join(':')
});
})()
"#,
)
.expect("DOMParser prototype method descriptor probe should run");
assert_eq!(
result,
r#"{"descriptor":"function:parseFromString:2:true:true:true","own":false,"enumerable":"parseFromString","behavior":"MAIN:root:true"}"#
);
}
#[test]
fn dom_parser_xml_text_content_excludes_processing_instruction_descendants() {
let mut vm = new_storage_test_vm("https://dom-parser-xml-text-content.test/");
let result = vm
.eval(
r#"
(() => {
const doc = new DOMParser().parseFromString(
"<root><?start name=p?>Unchanged<!--ignored--><![CDATA[ kept ]]></root>",
"application/xml"
);
const root = doc.documentElement;
return JSON.stringify({
rootText: root.textContent,
piText: root.firstChild.textContent,
commentText: root.childNodes[2].textContent
});
})()
"#,
)
.expect("DOMParser XML textContent processing instruction probe should run");
assert_eq!(
result,
r#"{"rootText":"Unchanged kept ","piText":"name=p","commentText":"ignored"}"#
);
}
#[test]
fn dom_parser_inner_html_supports_html_sanitizer_roundtrip_path() {
let mut vm = new_storage_test_vm("https://dom-parser-sanitizer-path.test/");
let result = vm
.eval(
r#"
(() => {
function sanitizeLikePage(html) {
const doc = new DOMParser().parseFromString(html, 'text/html');
return doc.body.innerHTML;
}
return [
sanitizeLikePage('Plain title'),
sanitizeLikePage('<p>A</p><p><strong>B</strong></p>'),
sanitizeLikePage('<img src="x.png" img_width="10"><p>caption</p>'),
].join('|');
})()
"#,
)
.expect("dom parser body.innerHTML should support sanitizer roundtrips");
assert_eq!(
result,
"Plain title|<p>A</p><p><strong>B</strong></p>|<img src=\"x.png\" img_width=\"10\"><p>caption</p>"
);
}
#[test]
fn dom_parser_body_and_elements_expose_class_list_for_sanitizer_walks() {
let mut vm = new_storage_test_vm("https://dom-parser-class-list.test/");
let result = vm
.eval(
r#"
(() => {
const doc = new DOMParser().parseFromString(
'<html><body class="article-body"><pre class="highlight language-js">const x = 1;</pre></body></html>',
'text/html'
);
const body = doc.body;
const pre = body.querySelector('pre');
return JSON.stringify({
bodyTag: Object.prototype.toString.call(body.classList),
bodyContains: body.classList.contains('article-body'),
preContainsHighlight: pre.classList.contains('highlight'),
preContainsLanguage: pre.classList.contains('language-js'),
stable: body.classList === body.classList,
preStable: pre.classList === pre.classList,
item0: pre.classList.item(0),
item1: pre.classList.item(1),
length: pre.classList.length
});
})()
"#,
)
.expect("DOMParser detached body.classList should stay available for sanitizer walks");
assert_eq!(
result,
r#"{"bodyTag":"[object DOMTokenList]","bodyContains":true,"preContainsHighlight":true,"preContainsLanguage":true,"stable":true,"preStable":true,"item0":"highlight","item1":"language-js","length":2}"#
);
}
#[test]
fn dom_parser_elements_expose_dataset_has_attribute_and_element_traversal() {
let mut vm = new_storage_test_vm("https://dom-parser-dataset.test/");
let result = vm
.eval(
r#"
(() => {
const doc = new DOMParser().parseFromString(
'<html><body data-root="article">' +
'text<!--gap-->' +
'<pre id="code" data-eeimg="1" data-tex="inline"></pre>' +
'<figure id="target"></figure>' +
'<span id="tail"></span>' +
'</body></html>',
'text/html'
);
const body = doc.body;
const pre = doc.getElementById('code');
const figure = doc.getElementById('target');
const dataset = pre.dataset;
dataset.imageSrc = 'https://img.test/a.png';
dataset.imageStatus = 'ready';
return JSON.stringify({
bodyHasRoot: body.hasAttribute('data-root'),
bodyMissing: body.hasAttribute('data-missing'),
datasetTag: Object.prototype.toString.call(dataset),
stable: dataset === pre.dataset,
eeimg: dataset.eeimg,
tex: dataset.tex,
imageSrc: dataset.imageSrc,
attrImageSrc: pre.getAttribute('data-image-src'),
keys: Object.keys(dataset).sort(),
firstElementChild: body.firstElementChild && body.firstElementChild.id,
lastElementChild: body.lastElementChild && body.lastElementChild.id,
childElementCount: body.childElementCount,
preNext: pre.nextElementSibling && pre.nextElementSibling.id,
figurePrev: figure.previousElementSibling && figure.previousElementSibling.id,
tailPrev: doc.getElementById('tail').previousElementSibling && doc.getElementById('tail').previousElementSibling.id
});
})()
"#,
)
.expect("DOMParser detached elements should expose dataset and element traversal");
assert_eq!(
result,
r#"{"bodyHasRoot":true,"bodyMissing":false,"datasetTag":"[object DOMStringMap]","stable":true,"eeimg":"1","tex":"inline","imageSrc":"https://img.test/a.png","attrImageSrc":"https://img.test/a.png","keys":["eeimg","imageSrc","imageStatus","tex"],"firstElementChild":"code","lastElementChild":"tail","childElementCount":3,"preNext":"target","figurePrev":"code","tailPrev":"target"}"#
);
}
#[test]
fn child_shadow_root_legacy_wpt_attributes_and_methods() {
let mut vm = new_storage_test_vm("https://child-shadow-root-legacy-wpt.test/");
let result = eval_with_layout_publications(
&mut vm,
r#"
(function* () {
const frame = document.createElement('iframe');
(document.body || document.documentElement || document).appendChild(frame);
const doc = frame.contentWindow.document;
const host = doc.createElement('div');
doc.body.appendChild(host);
const shadow = host.attachShadow({ mode: 'open' });
const input = doc.createElement('input');
shadow.appendChild(input);
input.focus();
const activeTag = shadow.activeElement && shadow.activeElement.tagName;
const span = doc.createElement('span');
span.innerHTML = 'Some text';
shadow.appendChild(span);
const innerBefore = shadow.innerHTML.toLowerCase();
shadow.innerHTML = '<input type="text" id="inputId"><div id="divId">new text</div>';
const innerAfter = shadow.innerHTML.toLowerCase();
const styleHost = doc.createElement('div');
doc.body.appendChild(styleHost);
const styleRoot = styleHost.attachShadow({ mode: 'open' });
const emptyStyleLength = styleRoot.styleSheets.length;
styleRoot.appendChild(doc.createElement('style'));
const styleLength = styleRoot.styleSheets.length;
const selectionHost = doc.createElement('div');
doc.body.appendChild(selectionHost);
const selectionRoot = selectionHost.attachShadow({ mode: 'open' });
const selected = doc.createElement('span');
selected.innerHTML = 'Some text';
selectionRoot.appendChild(selected);
const range = doc.createRange();
range.setStart(selected.firstChild, 0);
range.setEnd(selected.firstChild, 3);
const selection = selectionRoot.getSelection();
selection.removeAllRanges();
selection.addRange(range);
yield; // Publish this scene before reading its geometry.
const selectedText = selectionRoot.getSelection().toString();
let cloneError = '';
try {
selectionRoot.cloneNode();
} catch (error) {
cloneError = error.name + ':' + error.code;
}
return [
activeTag,
innerBefore,
innerAfter,
shadow.querySelector('#inputId') && shadow.querySelector('#inputId').id,
emptyStyleLength,
styleLength,
selectedText,
cloneError
].join('|');
})()
"#,
)
.expect("child ShadowRoot legacy WPT surface should evaluate");
assert_eq!(
result,
"INPUT|<input><span>some text</span>|<input type=\"text\" id=\"inputid\"><div id=\"divid\">new text</div>|inputId|0|1|Som|NotSupportedError:9"
);
}
@@ -0,0 +1,459 @@
use super::*;
#[test]
fn detached_html_elements_use_specific_prototypes_for_common_tags() {
let mut vm = new_storage_test_vm("https://detached-html-element-prototypes.test/");
let result = vm
.eval(
r#"
(() => {
const expected = new Map([
['a', 'HTMLAnchorElement'],
['img', 'HTMLImageElement'],
['form', 'HTMLFormElement'],
['input', 'HTMLInputElement'],
['button', 'HTMLButtonElement'],
['script', 'HTMLScriptElement'],
['iframe', 'HTMLIFrameElement'],
['canvas', 'HTMLCanvasElement'],
['textarea', 'HTMLTextAreaElement'],
['select', 'HTMLSelectElement'],
['option', 'HTMLOptionElement'],
['section', 'HTMLElement']
]);
const doc = new DOMParser().parseFromString(
'<html><body><img id="parsed"><form id="form"><input id="input"></form></body></html>',
'text/html'
);
const created = [];
for (const [tag, ctorName] of expected) {
const element = doc.createElement(tag);
const ctor = globalThis[ctorName];
created.push([
tag,
Object.prototype.toString.call(element),
ctor && element instanceof ctor,
element instanceof HTMLElement,
element.constructor && element.constructor.name
].join(','));
}
const parsedImg = doc.getElementById('parsed');
const parsedInput = doc.getElementById('input');
const probeDiv = doc.createElement('div');
probeDiv.innerHTML = '<span class="x"></span>';
const probeProto = Object.getPrototypeOf(probeDiv);
const probeProtoParent = Object.getPrototypeOf(probeProto);
const hasOwn = (object, name) => Object.prototype.hasOwnProperty.call(object, name);
const methodShape = [
hasOwn(probeDiv, 'appendChild'),
hasOwn(probeDiv, 'querySelector'),
hasOwn(probeDiv, 'getAttribute'),
hasOwn(probeDiv, 'matches'),
probeProto === HTMLDivElement.prototype,
probeProtoParent === HTMLElement.prototype,
typeof probeDiv.appendChild,
probeDiv.querySelector('.x').tagName
].join(',');
const xmlDoc = new DOMParser().parseFromString('<input/>', 'application/xml');
return [
created.join(';'),
Object.prototype.toString.call(parsedImg),
parsedImg instanceof HTMLImageElement,
Object.prototype.toString.call(parsedInput),
parsedInput instanceof HTMLInputElement,
Object.prototype.toString.call(xmlDoc.documentElement),
xmlDoc.documentElement instanceof Element,
xmlDoc.documentElement instanceof HTMLInputElement,
methodShape
].join('|');
})()
"#,
)
.expect("detached HTML elements should use common specialized prototypes");
assert_eq!(
result,
"a,[object HTMLAnchorElement],true,true,HTMLAnchorElement;img,[object HTMLImageElement],true,true,HTMLImageElement;form,[object HTMLFormElement],true,true,HTMLFormElement;input,[object HTMLInputElement],true,true,HTMLInputElement;button,[object HTMLButtonElement],true,true,HTMLButtonElement;script,[object HTMLScriptElement],true,true,HTMLScriptElement;iframe,[object HTMLIFrameElement],true,true,HTMLIFrameElement;canvas,[object HTMLCanvasElement],true,true,HTMLCanvasElement;textarea,[object HTMLTextAreaElement],true,true,HTMLTextAreaElement;select,[object HTMLSelectElement],true,true,HTMLSelectElement;option,[object HTMLOptionElement],true,true,HTMLOptionElement;section,[object HTMLElement],true,true,HTMLElement|[object HTMLImageElement]|true|[object HTMLInputElement]|true|[object Element]|true|false|false,false,false,false,true,true,function,SPAN"
);
}
#[test]
fn live_html_create_element_matches_replay_brands_for_standard_tags() {
let mut vm = new_storage_test_vm("https://live-html-element-replay-brands.test/");
let result = vm
.eval(
r#"
(() => {
const expected = new Map([
['html', 'HTMLHtmlElement'],
['head', 'HTMLHeadElement'],
['body', 'HTMLBodyElement'],
['input', 'HTMLInputElement'],
['select', 'HTMLSelectElement'],
['option', 'HTMLOptionElement'],
['fieldset', 'HTMLFieldSetElement'],
['meta', 'HTMLMetaElement'],
['title', 'HTMLTitleElement'],
['span', 'HTMLSpanElement'],
['p', 'HTMLParagraphElement'],
['area', 'HTMLAreaElement'],
['base', 'HTMLBaseElement'],
['br', 'HTMLBRElement'],
['data', 'HTMLDataElement'],
['datalist', 'HTMLDataListElement'],
['map', 'HTMLMapElement'],
['object', 'HTMLObjectElement'],
['output', 'HTMLOutputElement'],
['progress', 'HTMLProgressElement'],
['table', 'HTMLTableElement'],
['caption', 'HTMLTableCaptionElement'],
['col', 'HTMLTableColElement'],
['tbody', 'HTMLTableSectionElement'],
['tr', 'HTMLTableRowElement'],
['slot', 'HTMLSlotElement'],
['source', 'HTMLSourceElement'],
['del', 'HTMLModElement'],
['pre', 'HTMLPreElement'],
['frame', 'HTMLFrameElement'],
['frameset', 'HTMLFrameSetElement'],
['font', 'HTMLFontElement'],
['marquee', 'HTMLMarqueeElement'],
['meter', 'HTMLMeterElement'],
['ul', 'HTMLUListElement'],
['section', 'HTMLElement']
]);
const out = [];
for (const [tag, ctorName] of expected) {
const element = document.createElement(tag);
const ctor = globalThis[ctorName];
out.push([
tag,
element.constructor && element.constructor.name,
Object.prototype.toString.call(element),
!!ctor && element instanceof ctor,
element instanceof HTMLElement
].join(':'));
}
return out.join('|');
})()
"#,
)
.expect("live createElement should expose Chromium-like brands");
assert_eq!(
result,
"html:HTMLHtmlElement:[object HTMLHtmlElement]:true:true|head:HTMLHeadElement:[object HTMLHeadElement]:true:true|body:HTMLBodyElement:[object HTMLBodyElement]:true:true|input:HTMLInputElement:[object HTMLInputElement]:true:true|select:HTMLSelectElement:[object HTMLSelectElement]:true:true|option:HTMLOptionElement:[object HTMLOptionElement]:true:true|fieldset:HTMLFieldSetElement:[object HTMLFieldSetElement]:true:true|meta:HTMLMetaElement:[object HTMLMetaElement]:true:true|title:HTMLTitleElement:[object HTMLTitleElement]:true:true|span:HTMLSpanElement:[object HTMLSpanElement]:true:true|p:HTMLParagraphElement:[object HTMLParagraphElement]:true:true|area:HTMLAreaElement:[object HTMLAreaElement]:true:true|base:HTMLBaseElement:[object HTMLBaseElement]:true:true|br:HTMLBRElement:[object HTMLBRElement]:true:true|data:HTMLDataElement:[object HTMLDataElement]:true:true|datalist:HTMLDataListElement:[object HTMLDataListElement]:true:true|map:HTMLMapElement:[object HTMLMapElement]:true:true|object:HTMLObjectElement:[object HTMLObjectElement]:true:true|output:HTMLOutputElement:[object HTMLOutputElement]:true:true|progress:HTMLProgressElement:[object HTMLProgressElement]:true:true|table:HTMLTableElement:[object HTMLTableElement]:true:true|caption:HTMLTableCaptionElement:[object HTMLTableCaptionElement]:true:true|col:HTMLTableColElement:[object HTMLTableColElement]:true:true|tbody:HTMLTableSectionElement:[object HTMLTableSectionElement]:true:true|tr:HTMLTableRowElement:[object HTMLTableRowElement]:true:true|slot:HTMLSlotElement:[object HTMLSlotElement]:true:true|source:HTMLSourceElement:[object HTMLSourceElement]:true:true|del:HTMLModElement:[object HTMLModElement]:true:true|pre:HTMLPreElement:[object HTMLPreElement]:true:true|frame:HTMLFrameElement:[object HTMLFrameElement]:true:true|frameset:HTMLFrameSetElement:[object HTMLFrameSetElement]:true:true|font:HTMLFontElement:[object HTMLFontElement]:true:true|marquee:HTMLMarqueeElement:[object HTMLMarqueeElement]:true:true|meter:HTMLMeterElement:[object HTMLMeterElement]:true:true|ul:HTMLUListElement:[object HTMLUListElement]:true:true|section:HTMLElement:[object HTMLElement]:true:true"
);
}
#[test]
fn parsed_html_template_content_is_exposed_through_live_wrapper() {
let mut vm = new_parsed_test_vm(
"https://live-template-content.test/",
"<!doctype html><html><body><template id=t>Hello<span id=inner>world</span></template></body></html>",
);
let result = vm
.eval(
r#"
(() => {
const template = document.getElementById('t');
const content = template.content;
const childNodes = Array.prototype.map.call(content.childNodes, (node) => {
return [node.nodeType, node.nodeName, node.nodeValue || node.localName].join(':');
});
const descriptor = Object.getOwnPropertyDescriptor(HTMLTemplateElement.prototype, 'content');
return [
template.constructor && template.constructor.name,
Object.prototype.toString.call(template),
template instanceof HTMLTemplateElement,
content instanceof DocumentFragment,
Object.prototype.toString.call(content),
content.childNodes.length,
childNodes.join(','),
content.querySelector('#inner').textContent,
typeof descriptor.get,
descriptor.enumerable,
descriptor.configurable
].join('|');
})()
"#,
)
.expect("parsed template content should be visible through live wrapper");
assert_eq!(
result,
"HTMLTemplateElement|[object HTMLTemplateElement]|true|true|[object DocumentFragment]|2|3:#text:Hello,1:SPAN:span|world|function|true|true"
);
}
#[test]
fn parsed_xml_xhtml_template_preserves_interface_and_content() {
let mut vm = new_storage_test_vm("https://xml-template-content.test/");
let result = vm
.eval(
r#"
(() => {
const xml = new DOMParser().parseFromString(
"<template xmlns='http://www.w3.org/1999/xhtml'><test/></template>",
"text/xml"
);
const template = xml.documentElement;
return [
template.constructor && template.constructor.name,
Object.prototype.toString.call(template),
template instanceof HTMLTemplateElement,
template.childElementCount,
template.content instanceof DocumentFragment,
template.content.firstChild.localName,
].join('|');
})()
"#,
)
.expect("XML XHTML template content should be exposed");
assert_eq!(
result,
"HTMLTemplateElement|[object HTMLTemplateElement]|true|0|true|test"
);
}
#[test]
fn xml_serializer_synthesizes_required_namespace_declarations() {
let mut vm = new_storage_test_vm("https://xml-serializer-namespaces.test/");
let result = vm
.eval(
r#"
(() => {
const xml = document.implementation.createDocument('urn:catalog', 'c:catalog', null);
const root = xml.documentElement;
root.setAttributeNS('http://www.w3.org/2000/xmlns/', 'xmlns:m', 'urn:meta');
const item = xml.createElementNS('urn:catalog', 'c:item');
item.setAttributeNS('urn:meta', 'm:code', 'code-7');
item.append(xml.createTextNode('alpha & beta'));
root.append(item);
const serialized = new XMLSerializer().serializeToString(xml);
const reparsed = new DOMParser().parseFromString(serialized, 'application/xml');
return [
serialized,
reparsed.documentElement.namespaceURI,
reparsed.documentElement.getAttributeNS(
'http://www.w3.org/2000/xmlns/',
'c'
),
reparsed.getElementsByTagNameNS('urn:catalog', 'item').length,
reparsed.getElementsByTagNameNS('urn:catalog', 'item')[0]
.getAttributeNS('urn:meta', 'code')
].join('|');
})()
"#,
)
.expect("XMLSerializer namespace projection should evaluate");
assert_eq!(
result,
concat!(
"<c:catalog xmlns:c=\"urn:catalog\" xmlns:m=\"urn:meta\">",
"<c:item m:code=\"code-7\">alpha &amp; beta</c:item>",
"</c:catalog>|urn:catalog|urn:catalog|1|code-7"
)
);
}
#[test]
fn xml_serializer_matches_chromium_for_empty_elements_attrs_and_parser_errors() {
let mut vm = new_storage_test_vm("https://xml-serializer-node-kinds.test/");
let result = vm
.eval(
r#"
(() => {
const serializer = new XMLSerializer();
const htmlVoid = document.createElement('br');
const htmlVoidWithChild = document.createElement('br');
htmlVoidWithChild.append('child');
const xml = document.implementation.createDocument(null, 'root');
const owner = document.createElement('div');
owner.setAttribute('data-value', 'a<&">\t\n\r');
const attribute = owner.getAttributeNode('data-value');
const emptyXml = new DOMParser().parseFromString('', 'text/xml');
const emptyError = emptyXml.getElementsByTagName('parsererror')[0];
const emptySerialized = serializer.serializeToString(emptyXml);
const partialXml = new DOMParser().parseFromString(
'<catalog><item></catalog>',
'application/xml'
);
const partialError = partialXml.getElementsByTagName('parsererror')[0];
const partialSerialized = serializer.serializeToString(partialXml);
return [
serializer.serializeToString(htmlVoid) ===
'<br xmlns="http://www.w3.org/1999/xhtml" />',
serializer.serializeToString(htmlVoidWithChild) ===
'<br xmlns="http://www.w3.org/1999/xhtml">child</br>',
serializer.serializeToString(xml.documentElement) === '<root/>',
serializer.serializeToString(attribute) ===
'a&lt;&amp;&quot;&gt;&#9;&#10;&#13;',
emptyXml.documentElement.localName === 'html',
emptyXml.documentElement.namespaceURI === 'http://www.w3.org/1999/xhtml',
emptyXml.documentElement.getAttribute('xmlns') === null,
emptyError.getAttributeNames().join(',') === 'style',
emptySerialized.startsWith(
'<html xmlns="http://www.w3.org/1999/xhtml"><body><parsererror style='
),
!emptySerialized.includes('<parsererror xmlns='),
partialError.getAttribute('xmlns') === null,
partialSerialized.startsWith(
'<catalog><parsererror xmlns="http://www.w3.org/1999/xhtml" style='
)
].join('|');
})()
"#,
)
.expect("XMLSerializer node-kind projection should evaluate");
assert_eq!(
result,
"true|true|true|true|true|true|true|true|true|true|true|true"
);
}
#[test]
fn dom_parser_xml_errors_preserve_the_partial_document_root() {
let mut vm = new_storage_test_vm("https://dom-parser-partial-xml-error.test/");
let result = vm
.eval(
r#"
(() => {
const parsed = new DOMParser().parseFromString(
'<catalog><item></catalog>',
'application/xml'
);
const errors = parsed.getElementsByTagName('parsererror');
return [
parsed.documentElement.localName,
errors.length,
errors[0].parentNode === parsed.documentElement,
errors[0].namespaceURI,
errors[0].nextElementSibling.localName,
errors[0].querySelectorAll('h3').length
].join('|');
})()
"#,
)
.expect("DOMParser partial XML error tree should evaluate");
assert_eq!(result, "catalog|1|true|http://www.w3.org/1999/xhtml|item|2");
}
#[test]
fn xhtml_element_interface_survives_move_through_xml_document() {
let mut vm = new_storage_test_vm("https://xhtml-xml-document-move.test/");
let result = vm
.eval(
r#"
(() => {
const html = document.documentElement || document.appendChild(document.createElement("html"));
const body = document.body || html.appendChild(document.createElement("body"));
const xml = document.implementation.createDocument(
"http://www.w3.org/1999/xhtml",
"html"
);
const style = document.createElement("style");
style.setAttribute("nonce", "allowme");
const initialInterface = Object.getPrototypeOf(style) === HTMLStyleElement.prototype;
xml.documentElement.appendChild(style);
const xmlInterface = Object.getPrototypeOf(style) === HTMLStyleElement.prototype;
const xmlNonce = style.nonce;
body.appendChild(style);
return [
initialInterface,
xmlInterface,
xmlNonce,
Object.getPrototypeOf(style) === HTMLStyleElement.prototype,
style.nonce,
style.getAttribute("nonce")
].join("|");
})()
"#,
)
.expect("XHTML element interface should survive XML document adoption");
assert_eq!(result, "true|true|allowme|true|allowme|");
}
#[test]
fn child_content_document_template_uses_child_realm_template_surface() {
let mut vm = new_storage_test_vm("https://child-template-content.test/");
let result = vm
.eval(
r#"
(() => {
const frame = document.createElement('iframe');
(document.body || document.documentElement || document).appendChild(frame);
const doc = frame.contentDocument;
doc.open();
doc.write('<!doctype html><html><body><template id=t>Hello<span id=inner>world</span></template></body></html>');
doc.close();
const template = doc.getElementById('t');
const content = template.content;
return [
template.constructor === frame.contentWindow.HTMLTemplateElement,
template instanceof frame.contentWindow.HTMLTemplateElement,
Object.prototype.toString.call(template),
content instanceof frame.contentWindow.DocumentFragment,
Object.prototype.toString.call(content),
content.childNodes.length,
content.querySelector('#inner').textContent,
typeof Object.getOwnPropertyDescriptor(frame.contentWindow.HTMLTemplateElement.prototype, 'content').get
].join('|');
})()
"#,
)
.expect("child template content should be visible through child realm wrapper");
assert_eq!(
result,
"true|true|[object HTMLTemplateElement]|true|[object DocumentFragment]|2|world|function"
);
}
#[test]
fn live_html_create_element_uses_html_unknown_element_for_observed_unknown_tags() {
let mut vm = new_storage_test_vm("https://live-html-unknown-element-brands.test/");
let result = vm
.eval(
r#"
(() => {
const tags = ['applet', 'bgsound', 'blink', 'content', 'decorator', 'element', 'image', 'isindex', 'menuitem', 'shadow', 'spacer'];
return tags.map((tag) => {
const element = document.createElement(tag);
return [
tag,
element.constructor && element.constructor.name,
Object.prototype.toString.call(element),
element instanceof HTMLUnknownElement,
element instanceof HTMLElement
].join(':');
}).join('|');
})()
"#,
)
.expect("unknown replay tags should brand as HTMLUnknownElement");
assert_eq!(
result,
"applet:HTMLUnknownElement:[object HTMLUnknownElement]:true:true|bgsound:HTMLUnknownElement:[object HTMLUnknownElement]:true:true|blink:HTMLUnknownElement:[object HTMLUnknownElement]:true:true|content:HTMLUnknownElement:[object HTMLUnknownElement]:true:true|decorator:HTMLUnknownElement:[object HTMLUnknownElement]:true:true|element:HTMLUnknownElement:[object HTMLUnknownElement]:true:true|image:HTMLUnknownElement:[object HTMLUnknownElement]:true:true|isindex:HTMLUnknownElement:[object HTMLUnknownElement]:true:true|menuitem:HTMLUnknownElement:[object HTMLUnknownElement]:true:true|shadow:HTMLUnknownElement:[object HTMLUnknownElement]:true:true|spacer:HTMLUnknownElement:[object HTMLUnknownElement]:true:true"
);
}
@@ -0,0 +1,431 @@
use super::*;
#[test]
fn detached_html_elements_reflect_common_attributes() {
let mut vm = new_storage_test_vm("https://detached-reflected-attrs.test/path/page.html");
let result = vm
.eval(
r#"
(() => {
const doc = new DOMParser().parseFromString(
'<html><body>' +
'<input id="input" name="token" value="seed" checked disabled>' +
'<img id="img" name="hero" src="img/a.png">' +
'<script id="script" src="/app.js"></script>' +
'<iframe id="frame" name="child" src="child.html"></iframe>' +
'<button id="button" name="go" value="yes" disabled></button>' +
'<textarea id="textarea" name="bio"></textarea>' +
'<select id="select" name="choice"><option id="option" value="a" disabled>A</option></select>' +
'<section id="section"></section>' +
'</body></html>',
'text/html'
);
const input = doc.getElementById('input');
const img = doc.getElementById('img');
const script = doc.getElementById('script');
const frame = doc.getElementById('frame');
const button = doc.getElementById('button');
const textarea = doc.getElementById('textarea');
const select = doc.getElementById('select');
const option = doc.getElementById('option');
const section = doc.getElementById('section');
const created = doc.createElement('input');
created.name = 'created-name';
created.value = 42;
created.checked = true;
created.disabled = true;
input.checked = false;
input.disabled = false;
img.src = '/asset.png';
button.disabled = false;
textarea.value = 'typed';
select.value = 'b';
option.disabled = false;
return [
input.name,
input.value,
input.checked,
input.disabled,
input.getAttribute('checked') === null,
input.getAttribute('disabled') === null,
created.name,
created.value,
created.checked,
created.disabled,
created.getAttribute('checked'),
created.getAttribute('disabled'),
img.name,
img.getAttribute('src'),
img.src,
script.getAttribute('src'),
script.src,
frame.name,
frame.getAttribute('src'),
frame.src,
button.name,
button.value,
button.disabled,
button.getAttribute('disabled') === null,
textarea.name,
textarea.value,
textarea.getAttribute('value'),
select.name,
select.value,
select.getAttribute('value'),
option.value,
option.disabled,
option.getAttribute('disabled') === null,
'name' in section,
typeof section.value,
typeof section.checked,
typeof section.src
].join('|');
})()
"#,
)
.expect("detached HTML elements should reflect common attributes");
assert_eq!(
result,
"token|seed|false|false|false|true|created-name|42|true|true|||hero|/asset.png|https://detached-reflected-attrs.test/asset.png|/app.js|https://detached-reflected-attrs.test/app.js|child|child.html|https://detached-reflected-attrs.test/path/child.html|go|yes|false|true|bio|typed||choice|||a|false|true|false|undefined|undefined|undefined"
);
}
#[test]
fn live_element_name_accessor_is_writable() {
let mut vm = new_storage_test_vm("https://live-name-attr.test/");
let result = vm
.eval(
r#"
(() => {
const input = document.createElement('input');
input.name = 'live-token';
return [input.name, input.getAttribute('name')].join('|');
})()
"#,
)
.expect("live element name accessor should evaluate");
assert_eq!(result, "live-token|live-token");
}
#[test]
fn option_name_assignment_is_an_expando_and_does_not_reflect_the_content_attribute() {
let mut vm = new_storage_test_vm("https://option-name.test/");
let result = vm
.eval(
r#"
(() => {
const option = document.createElement('option');
option.setAttribute('name', 'content-name');
const before = option.name;
option.name = 'expando-name';
const descriptor = Object.getOwnPropertyDescriptor(option, 'name');
return JSON.stringify({
prototypeHasName: Object.prototype.hasOwnProperty.call(HTMLOptionElement.prototype, 'name'),
before: before === undefined ? 'undefined' : before,
expando: option.name,
attribute: option.getAttribute('name'),
descriptor: [
descriptor.value,
descriptor.enumerable,
descriptor.writable,
descriptor.configurable
]
});
})()
"#,
)
.expect("option name expando probe should evaluate");
assert_eq!(
result,
r#"{"prototypeHasName":false,"before":"undefined","expando":"expando-name","attribute":"content-name","descriptor":["expando-name",true,true,true]}"#
);
}
#[test]
fn detached_child_document_elements_expose_focus_and_blur() {
let mut vm = new_storage_test_vm("https://child-window-detached-focus.test/");
let result = vm
.eval(
r#"
(() => {
const frame = document.createElement('iframe');
frame.srcdoc = '<body></body>';
(document.body || document.documentElement || document).appendChild(frame);
const input = frame.contentDocument.createElement('input');
let status = 'ok';
try {
input.focus();
input.blur();
} catch (error) {
status = error && error.message;
}
return [
input instanceof HTMLElement,
input instanceof frame.contentWindow.HTMLElement,
input instanceof frame.contentWindow.HTMLInputElement,
Object.getPrototypeOf(input) === frame.contentWindow.HTMLInputElement.prototype,
typeof input.focus,
typeof input.blur,
status
].join('|');
})()
"#,
)
.expect("detached child document elements should expose focus/blur");
assert_eq!(result, "false|true|true|true|function|function|ok");
}
#[test]
fn dom_parser_detached_elements_dispatch_local_events() {
let mut vm = new_storage_test_vm("https://dom-parser-detached-events.test/");
let result = vm
.eval(
r#"
(() => {
const doc = new DOMParser().parseFromString(
'<html><body><div id="parent"><button id="button"></button></div></body></html>',
'text/html'
);
const parent = doc.getElementById('parent');
const button = doc.getElementById('button');
const order = [];
parent.addEventListener('click', (event) => {
order.push('capture:' + (event.target === button) + ':' + (event.currentTarget === parent));
}, true);
button.addEventListener('click', (event) => {
order.push('target:' + (event.target === button) + ':' + (event.currentTarget === button) + ':' + (event.composedPath()[0] === button));
});
parent.addEventListener('click', () => order.push('bubble'));
button.click();
return order.join('|');
})()
"#,
)
.expect("DOMParser detached elements should dispatch local click events");
assert_eq!(result, "capture:true:true|target:true:true:true|bubble");
}
#[test]
fn dom_parser_detached_iframe_load_ignores_tampered_non_elements() {
let mut vm = new_storage_test_vm("https://dom-parser-detached-iframe-load-guard.test/");
let result = vm
.eval(
r#"
(() => {
const doc = new DOMParser().parseFromString('<html><body></body></html>', 'text/html');
const text = doc.createTextNode('not an iframe');
Object.defineProperty(text, 'localName', { value: 'iframe' });
let textLoads = 0;
text.addEventListener('load', () => ++textLoads);
doc.body.appendChild(text);
const iframe = doc.createElement('iframe');
let iframeLoads = 0;
iframe.addEventListener('load', () => ++iframeLoads);
doc.body.appendChild(iframe);
return `${textLoads}|${iframeLoads}`;
})()
"#,
)
.expect("DOMParser detached iframe load should guard node type");
assert_eq!(result, "0|1");
}
#[test]
fn dom_parser_detached_iframe_window_declares_own_methods() {
let mut vm = new_storage_test_vm("https://dom-parser-detached-window-methods.test/");
let result = vm
.eval(
r#"
(() => {
const doc = new DOMParser().parseFromString('<html><body></body></html>', 'text/html');
const iframe = doc.createElement('iframe');
doc.body.appendChild(iframe);
const win = iframe.contentWindow;
const shape = name => {
const descriptor = Object.getOwnPropertyDescriptor(win, name);
const value = descriptor && descriptor.value;
return [
typeof value,
value && value.name,
value && value.length,
descriptor && descriptor.enumerable,
descriptor && descriptor.configurable,
descriptor && descriptor.writable,
/\[native code\]/.test(String(value))
].join(':');
};
return [
Object.prototype.toString.call(win),
shape('postMessage'),
shape('open'),
shape('blur'),
shape('find'),
shape('stop'),
shape('print'),
win.find('needle'),
String(win.blur()),
String(win.print())
].join('|');
})()
"#,
)
.expect("DOMParser detached iframe window methods should evaluate");
assert_eq!(
result,
"[object Window]|function:postMessage:1:false:true:true:true|function:open:0:false:true:true:true|function:blur:0:false:true:true:true|function:find:0:false:true:true:true|function:stop:0:false:true:true:true|function:print:0:false:true:true:true|false|undefined|undefined"
);
}
#[test]
fn dom_parser_detached_iframe_adopted_node_loads_after_insert() {
let mut vm = new_storage_test_vm("https://dom-parser-detached-iframe-import-sync.test/");
let result = vm
.eval(
r#"
(() => {
const sourceDoc = new DOMParser().parseFromString(
'<html><body><iframe></iframe></body></html>',
'text/html'
);
const targetDoc = new DOMParser().parseFromString('<html><body></body></html>', 'text/html');
const source = sourceDoc.querySelector('iframe');
let sourceListenerLoads = 0;
const handlerTargets = [];
source.onload = function() {
handlerTargets.push(this.ownerDocument === targetDoc ? 'imported' : 'source');
};
source.addEventListener('load', () => ++sourceListenerLoads);
targetDoc.body.appendChild(source);
const imported = targetDoc.querySelector('iframe');
return [
!!imported,
sourceListenerLoads,
handlerTargets.join(','),
source.contentDocument === imported.contentDocument,
source.contentWindow === imported.contentWindow
].join('|');
})()
"#,
)
.expect("DOMParser detached iframe import source sync should evaluate");
assert_eq!(result, "true|1|imported|true|true");
}
#[test]
fn dom_parser_detached_event_target_handles_remove_once_prevent_default_and_exceptions() {
let mut vm = new_storage_test_vm("https://dom-parser-detached-events-edge.test/");
let result = vm
.eval(
r#"
(() => {
const doc = new DOMParser().parseFromString('<html><body><div id="target"></div></body></html>', 'text/html');
const target = doc.getElementById('target');
const out = [];
function removed() { out.push('removed'); }
target.addEventListener('custom', removed);
target.removeEventListener('custom', removed);
target.addEventListener('custom', { handleEvent() { out.push('object-once'); } }, { once: true });
target.dispatchEvent(new Event('custom'));
target.dispatchEvent(new Event('custom'));
target.addEventListener('cancelable', (event) => event.preventDefault());
const allowed = target.dispatchEvent(new Event('cancelable', { cancelable: true }));
target.addEventListener('boom', () => { throw new Error('detached listener boom'); });
target.addEventListener('boom', () => out.push('after-throw'));
const boomAllowed = target.dispatchEvent(new Event('boom'));
return [out.join(','), allowed, boomAllowed].join('|');
})()
"#,
)
.expect("DOMParser detached event dispatch should match EventTarget edge behavior");
assert_eq!(result, "object-once,after-throw|false|true");
}
#[test]
fn constructed_event_target_exposes_composed_path_during_dispatch() {
let mut vm = new_storage_test_vm("https://event-target-constructible.test/");
let result = vm
.eval(
r#"
(() => {
const target = new EventTarget();
const event = new Event('custom');
const observed = [];
target.addEventListener('custom', (event) => {
observed.push(event.target === target);
observed.push(event.currentTarget === target);
observed.push(event.composedPath().length);
observed.push(event.composedPath()[0] === target);
event.initEvent('mutated', true, true);
observed.push(event.type);
observed.push(event.bubbles);
observed.push(event.cancelable);
}, { once: true });
const allowed = target.dispatchEvent(event);
observed.push(allowed);
observed.push(event.currentTarget === null);
observed.push(event.composedPath().length);
target.dispatchEvent(event);
return observed.join('|');
})()
"#,
)
.expect("constructed EventTarget should expose composedPath while dispatching");
assert_eq!(result, "true|true|1|true|custom|false|false|true|true|0");
}
#[test]
fn dom_parser_detached_event_handler_properties_dispatch_through_local_events() {
let mut vm = new_storage_test_vm("https://dom-parser-detached-handler-properties.test/");
let result = vm
.eval(
r#"
(() => {
const doc = new DOMParser().parseFromString('<html><body><button id="target"></button></body></html>', 'text/html');
const target = doc.getElementById('target');
const out = [];
out.push(target.onclick === null);
target.onclick = function(event) {
out.push('first:' + (this === target) + ':' + event.type + ':' + (event.currentTarget === target));
};
out.push(typeof target.onclick);
target.click();
target.onclick = function() { out.push('second'); };
target.addEventListener('click', () => out.push('listener'));
target.click();
target.onclick = null;
out.push(target.onclick === null);
target.click();
return out.join('|');
})()
"#,
)
.expect("DOMParser detached event handler properties should dispatch local events");
assert_eq!(
result,
"true|function|first:true:click:true|second|listener|true|listener"
);
}
@@ -0,0 +1,966 @@
use super::*;
#[test]
fn file_constructor_preserves_declared_metadata_slots() {
let mut vm = new_storage_test_vm("https://file-metadata-declaration.test/");
let result = vm
.eval(
r#"
(() => {
const descriptorReport = (prototype, name) => {
const descriptor = Object.getOwnPropertyDescriptor(prototype, name);
return [
name,
typeof descriptor?.get,
descriptor?.get?.name,
descriptor?.get?.length,
typeof descriptor?.set,
descriptor?.enumerable,
descriptor?.configurable
].join(":");
};
const defaultFile = new File(["a"], "default.txt");
const explicitFile = new File([new Uint8Array([104, 105])], "note.txt", {
type: "text/plain",
lastModified: 7
});
const weirdFile = new File(["x"], "weird.txt", { lastModified: Infinity });
const dt = new DataTransfer();
const item = dt.items.add(explicitFile);
const roundTrip = item && item.getAsFile && item.getAsFile();
const nameDescriptor = Object.getOwnPropertyDescriptor(File.prototype, "name");
const lastModifiedDescriptor = Object.getOwnPropertyDescriptor(File.prototype, "lastModified");
const lengthDescriptor = Object.getOwnPropertyDescriptor(FileList.prototype, "length");
const fileInternalNames = Object.getOwnPropertyNames(explicitFile)
.filter(name => name.startsWith("__lmFile"))
.sort();
const fileListInternalNames = Object.getOwnPropertyNames(dt.files)
.filter(name => name.startsWith("__lmFile"))
.sort();
const fakeFile = {
__lmFileName: "spoofed.txt",
__lmFileLastModified: 99
};
const fakeFileList = { __lmFileListLength: 99 };
return JSON.stringify({
fileDescriptors: [
descriptorReport(File.prototype, "name"),
descriptorReport(File.prototype, "lastModified")
],
fileListDescriptors: [
descriptorReport(FileList.prototype, "length")
],
fileInternalNames,
fileListInternalNames,
defaultName: defaultFile.name,
defaultLastModifiedFinite: Number.isFinite(defaultFile.lastModified),
explicitName: explicitFile.name,
explicitLastModified: explicitFile.lastModified,
explicitType: explicitFile.type,
roundTripName: roundTrip && roundTrip.name,
roundTripLastModified: roundTrip && roundTrip.lastModified,
roundTripType: roundTrip && roundTrip.type,
tag: Object.prototype.toString.call(explicitFile),
ctor: explicitFile instanceof File,
weirdLastModifiedFinite: Number.isFinite(weirdFile.lastModified),
fakeName: nameDescriptor.get.call(fakeFile),
fakeLastModifiedFinite: Number.isFinite(lastModifiedDescriptor.get.call(fakeFile)),
fakeLength: lengthDescriptor.get.call(fakeFileList)
});
})()
"#,
)
.expect("File metadata declaration should preserve script-visible slots");
assert_eq!(
result,
r#"{"fileDescriptors":["name:function:get name:0:undefined:true:true","lastModified:function:get lastModified:0:undefined:true:true"],"fileListDescriptors":["length:function:get length:0:undefined:true:true"],"fileInternalNames":[],"fileListInternalNames":[],"defaultName":"default.txt","defaultLastModifiedFinite":true,"explicitName":"note.txt","explicitLastModified":7,"explicitType":"text/plain","roundTripName":"note.txt","roundTripLastModified":7,"roundTripType":"text/plain","tag":"[object File]","ctor":true,"weirdLastModifiedFinite":true,"fakeName":"","fakeLastModifiedFinite":true,"fakeLength":0}"#
);
}
#[test]
fn data_transfer_and_input_files_support_playwright_style_upload_assignment() {
let mut vm = new_storage_test_vm("https://input-files-upload.test/");
let result = vm
.eval(
r#"
(() => {
const host = document.body || document.documentElement || document;
const input = document.createElement('input');
input.type = 'file';
host.appendChild(input);
const seen = [];
input.addEventListener('input', () => {
seen.push(`input:${input.files.length}:${input.files[0].name}:${input.value}`);
});
input.addEventListener('change', () => {
seen.push(`change:${input.files[0].type}:${input.files[0].lastModified}`);
});
const dt = new DataTransfer();
const file = new File([new Uint8Array([104, 105])], 'note.txt', {
type: 'text/plain',
lastModified: 7
});
dt.items.add(file);
input.files = dt.files;
input.dispatchEvent(new Event('input', { bubbles: true, composed: true }));
input.dispatchEvent(new Event('change', { bubbles: true }));
return [
typeof DataTransfer,
dt.items.length,
Object.prototype.toString.call(dt.files),
input.files.length,
input.files[0].name,
input.files[0].type,
input.files[0].lastModified,
input.value,
typeof input.files[Symbol.iterator],
Array.from(input.files).map(file => file.name).join(','),
seen.join(',')
].join('|');
})()
"#,
)
.expect("DataTransfer-backed file assignment should succeed");
assert_eq!(
result,
"function|1|[object FileList]|1|note.txt|text/plain|7|C:\\fakepath\\note.txt|function|note.txt|input:1:note.txt:C:\\fakepath\\note.txt,change:text/plain:7"
);
}
#[test]
fn input_files_nullable_setter_preserves_current_file_list() {
let mut vm = new_storage_test_vm("https://input-files-nullable-setter.test/");
let result = vm
.eval(
r#"
(() => {
const input = document.createElement('input');
input.type = 'file';
const transfer = new DataTransfer();
transfer.items.add(new File(['data'], 'note.txt'));
input.files = transfer.files;
const files = input.files;
input.files = null;
const afterNull = input.files === files && input.files[0].name;
input.files = undefined;
const afterUndefined = input.files === files && input.files[0].name;
const assignmentError = value => {
try {
input.files = value;
return 'none';
} catch (error) {
return error.name;
}
};
return [
afterNull,
afterUndefined,
assignmentError([]),
assignmentError([new File([], 'other.txt')])
].join('|');
})()
"#,
)
.expect("nullable input files setter probe should evaluate");
assert_eq!(result, "note.txt|note.txt|TypeError|TypeError");
}
#[test]
fn input_file_list_cache_refreshes_after_external_file_selection_replacement() {
let mut vm = new_storage_test_vm("https://input-files-external-replace.test/");
let initial = vm
.eval(
r#"
(() => {
const host = document.body || document.documentElement || document;
const input = document.createElement('input');
input.id = 'upload';
input.type = 'file';
host.appendChild(input);
globalThis.__emptyFiles = input.files;
return [input.files.length, input.files === globalThis.__emptyFiles].join('|');
})()
"#,
)
.expect("initial file input cache probe should run");
assert_eq!(initial, "0|true");
let upload = vm
.document_runtime
.get_element_by_id("upload")
.expect("upload input should exist");
assert!(
vm.set_file_input_files(
upload,
vec![crate::dom::native::SelectedFile {
bytes: b"alpha".to_vec(),
mime_type: "text/plain".to_owned(),
name: "first.txt".to_owned(),
last_modified: 1.0,
}],
false,
)
.expect("first external file selection should run")
);
let first = vm
.eval(
r#"
(() => {
const input = document.getElementById('upload');
globalThis.__firstFiles = input.files;
return [
input.files.length,
input.files[0].name,
input.files === globalThis.__emptyFiles,
input.files === globalThis.__firstFiles
].join('|');
})()
"#,
)
.expect("first file input cache probe should run");
assert_eq!(first, "1|first.txt|false|true");
assert!(
vm.set_file_input_files(
upload,
vec![crate::dom::native::SelectedFile {
bytes: b"bravo".to_vec(),
mime_type: "text/plain".to_owned(),
name: "second.txt".to_owned(),
last_modified: 2.0,
}],
false,
)
.expect("second external file selection should run")
);
let second = vm
.eval(
r#"
(() => {
const input = document.getElementById('upload');
const files = input.files;
return [
globalThis.__firstFiles[0].name,
files.length,
files[0].name,
files === globalThis.__firstFiles,
input.files === files
].join('|');
})()
"#,
)
.expect("second file input cache probe should run");
assert_eq!(second, "first.txt|1|second.txt|false|true");
}
#[test]
fn data_transfer_item_list_add_returns_item_with_get_as_file() {
let mut vm = new_storage_test_vm("https://data-transfer-item-add.test/");
let result = vm
.eval(
r#"
(() => {
const dt = new DataTransfer();
const file = new File(['hi'], 'note.txt', { type: 'text/plain', lastModified: 7 });
const item = dt.items.add(file);
const roundTrip = item && item.getAsFile && item.getAsFile();
return [
item !== null,
item && item.kind,
item && item.type,
roundTrip && roundTrip.name,
roundTrip && roundTrip.type,
roundTrip && roundTrip.lastModified
].join('|');
})()
"#,
)
.expect("DataTransferItemList.add return value should evaluate");
assert_eq!(result, "true|file|text/plain|note.txt|text/plain|7");
}
#[test]
fn constructed_data_transfer_defaults_to_none_effects() {
let mut vm = new_storage_test_vm("https://data-transfer-constructor-defaults.test/");
let result = vm
.eval(
r#"
(() => {
const transfer = new DataTransfer();
return [transfer.dropEffect, transfer.effectAllowed].join('|');
})()
"#,
)
.expect("constructed DataTransfer defaults should evaluate");
assert_eq!(result, "none|none");
}
#[test]
fn data_transfer_item_removals_disable_existing_wrappers() {
let mut vm = new_storage_test_vm("https://data-transfer-item-disabled-mode.test/");
let result = vm
.eval(
r#"
(() => {
const removedTransfer = new DataTransfer();
const removedItem = removedTransfer.items.add(
new File(['file'], 'removed.txt', { type: 'text/plain' })
);
removedTransfer.items.remove(0);
const clearedTransfer = new DataTransfer();
const clearedItem = clearedTransfer.items.add('cleared', 'text/plain');
clearedTransfer.items.clear();
let clearedCallbackCalled = false;
clearedItem.getAsString(() => { clearedCallbackCalled = true; });
const clearDataTransfer = new DataTransfer();
const clearDataItem = clearDataTransfer.items.add('clear-data', 'text/plain');
clearDataTransfer.clearData('text/plain');
let clearDataCallbackCalled = false;
clearDataItem.getAsString(() => { clearDataCallbackCalled = true; });
return JSON.stringify({
removed: [
removedItem.kind,
removedItem.type,
removedItem.getAsFile() === null,
removedItem.webkitGetAsEntry() === null,
removedTransfer.items.length
],
cleared: [
clearedItem.kind,
clearedItem.type,
clearedCallbackCalled,
clearedTransfer.items.length
],
clearData: [
clearDataItem.kind,
clearDataItem.type,
clearDataCallbackCalled,
clearDataTransfer.items.length
]
});
})()
"#,
)
.expect("removed DataTransferItem wrappers should enter disabled mode");
assert_eq!(
result,
r#"{"removed":["","",true,true,0],"cleared":["","",false,0],"clearData":["","",false,0]}"#
);
}
#[test]
fn data_transfer_item_and_list_use_stable_interface_wrappers() {
let mut vm = new_storage_test_vm("https://data-transfer-item-wrapper.test/");
let result = vm
.eval(
r#"
(() => {
const dt = new DataTransfer();
const file = new File(['hi'], 'note.txt', { type: 'text/plain', lastModified: 7 });
const item = dt.items.add(file);
const indexed = dt.items[0];
const fromItem = dt.items.item(0);
return [
item instanceof DataTransferItem,
dt.items instanceof DataTransferItemList,
indexed === item,
fromItem === item,
indexed && indexed.getAsFile && indexed.getAsFile().name,
dt.items.length
].join('|');
})()
"#,
)
.expect("DataTransferItem wrappers should evaluate");
assert_eq!(result, "true|true|true|true|note.txt|1");
}
#[test]
fn data_transfer_declared_slots_ignore_prototype_spoofing() {
let mut vm = new_storage_test_vm("https://data-transfer-declared-slots.test/");
let result = vm
.eval(
r#"
(() => {
const accessorDescriptor = (prototype, name) => {
const descriptor = Object.getOwnPropertyDescriptor(prototype, name);
const setter = descriptor && descriptor.set;
return [
name,
typeof descriptor?.get,
descriptor?.get?.name,
descriptor?.get?.length,
typeof setter,
setter ? setter.name : 'none',
setter ? setter.length : 'none',
descriptor?.enumerable,
descriptor?.configurable
].join(':');
};
const dt = new DataTransfer();
dt.setData('text/plain', 'alpha');
const file = new File(['hi'], 'note.txt', { type: 'text/plain', lastModified: 7 });
const item = dt.items.add(file);
const entry = item.webkitGetAsEntry();
const internalNames = object => Object.getOwnPropertyNames(object)
.filter(name => name.startsWith('__lmDataTransfer') || name.startsWith('__lmFileSystem'))
.sort();
const ownNamesBefore = {
dataTransfer: internalNames(dt),
itemList: internalNames(dt.items),
item: internalNames(item),
entry: internalNames(entry)
};
DataTransfer.prototype.__lmDataTransferItems = dt.items;
DataTransfer.prototype.__lmDataTransferTypes = ['prototype/type'];
DataTransfer.prototype.__lmDataTransferDropEffect = 'copy';
DataTransfer.prototype.__lmDataTransferEffectAllowed = 'all';
DataTransferItemList.prototype.__lmDataTransferItemArray = [item, item];
DataTransferItem.prototype.__lmDataTransferItemKind = 'string';
DataTransferItem.prototype.__lmDataTransferItemType = 'prototype/type';
DataTransferItem.prototype.__lmDataTransferItemFile = new File(['bad'], 'bad.txt');
FileSystemEntry.prototype.__lmFileSystemEntryName = 'prototype.txt';
FileSystemEntry.prototype.__lmFileSystemEntryFullPath = '/prototype.txt';
FileSystemEntry.prototype.__lmFileSystemEntryIsFile = false;
FileSystemEntry.prototype.__lmFileSystemEntryIsDirectory = true;
const fakeDataTransfer = Object.create(DataTransfer.prototype);
const fakeList = Object.create(DataTransferItemList.prototype);
const fakeItem = Object.create(DataTransferItem.prototype);
const fakeEntry = Object.create(FileSystemEntry.prototype);
const entryNameGetter = Object.getOwnPropertyDescriptor(FileSystemEntry.prototype, 'name').get;
const entryPathGetter = Object.getOwnPropertyDescriptor(FileSystemEntry.prototype, 'fullPath').get;
const descriptors = {
dataTransfer: [
accessorDescriptor(DataTransfer.prototype, 'files'),
accessorDescriptor(DataTransfer.prototype, 'items'),
accessorDescriptor(DataTransfer.prototype, 'types'),
accessorDescriptor(DataTransfer.prototype, 'dropEffect'),
accessorDescriptor(DataTransfer.prototype, 'effectAllowed')
],
itemList: [
accessorDescriptor(DataTransferItemList.prototype, 'length')
],
item: [
accessorDescriptor(DataTransferItem.prototype, 'kind'),
accessorDescriptor(DataTransferItem.prototype, 'type')
],
entry: [
accessorDescriptor(FileSystemEntry.prototype, 'filesystem'),
accessorDescriptor(FileSystemEntry.prototype, 'fullPath'),
accessorDescriptor(FileSystemEntry.prototype, 'isDirectory'),
accessorDescriptor(FileSystemEntry.prototype, 'isFile'),
accessorDescriptor(FileSystemEntry.prototype, 'name')
]
};
dt.__lmDataTransferItems = fakeList;
dt.__lmDataTransferTypes = ['own/type'];
dt.__lmDataTransferDropEffect = 'move';
dt.__lmDataTransferEffectAllowed = 'copyMove';
dt.items.__lmDataTransferItemArray = [item, item, item];
dt.items.__lmDataTransferItemListIndexedLength = 9;
item.__lmDataTransferItemKind = 'string';
item.__lmDataTransferItemType = 'own/type';
item.__lmDataTransferItemFile = new File(['bad'], 'bad.txt');
entry.__lmFileSystemEntryName = 'own.txt';
entry.__lmFileSystemEntryFullPath = '/own.txt';
entry.__lmFileSystemEntryIsFile = false;
entry.__lmFileSystemEntryIsDirectory = true;
return JSON.stringify({
ownNamesBefore,
descriptors,
real: [
dt.getData('text/plain'),
dt.types.join(','),
dt.dropEffect,
dt.effectAllowed,
dt.items.length,
item.kind,
item.type,
item.getAsFile().name,
entry.name,
entry.fullPath,
entry.isFile,
entry.isDirectory
].join('|'),
fake: [
DataTransfer.prototype.getData.call(fakeDataTransfer, 'text/plain'),
fakeDataTransfer.types === undefined ? 'undefined' : fakeDataTransfer.types.join(','),
fakeDataTransfer.dropEffect,
fakeDataTransfer.effectAllowed,
fakeList.length,
fakeItem.kind,
fakeItem.type,
fakeItem.getAsFile(),
entryNameGetter.call(fakeEntry),
entryPathGetter.call(fakeEntry)
].map(value => value === null ? 'null' : String(value)).join('|')
});
})()
"#,
)
.expect("DataTransfer declared slots should ignore prototype spoofing");
assert_eq!(
result,
r#"{"ownNamesBefore":{"dataTransfer":[],"itemList":[],"item":[],"entry":[]},"descriptors":{"dataTransfer":["files:function:get files:0:undefined:none:none:true:true","items:function:get items:0:undefined:none:none:true:true","types:function:get types:0:undefined:none:none:true:true","dropEffect:function:get dropEffect:0:function:set dropEffect:1:true:true","effectAllowed:function:get effectAllowed:0:function:set effectAllowed:1:true:true"],"itemList":["length:function:get length:0:undefined:none:none:true:true"],"item":["kind:function:get kind:0:undefined:none:none:true:true","type:function:get type:0:undefined:none:none:true:true"],"entry":["filesystem:function:get filesystem:0:undefined:none:none:true:true","fullPath:function:get fullPath:0:undefined:none:none:true:true","isDirectory:function:get isDirectory:0:undefined:none:none:true:true","isFile:function:get isFile:0:undefined:none:none:true:true","name:function:get name:0:undefined:none:none:true:true"]},"real":"alpha|text/plain,Files|none|none|2|file|text/plain|note.txt|note.txt|/note.txt|true|false","fake":"|undefined|none|uninitialized|0|||null||"}"#
);
}
#[test]
fn data_transfer_directory_entries_use_private_slots_for_reflection_and_spoofing() {
use crate::runtime::{RendererDragData, RendererDraggedDirectory, RendererDraggedFile};
let mut vm = new_rendered_test_vm(
"https://data-transfer-directory-slots.test/",
r#"<html><body><div id="drop" style="width: 100px; height: 100px">drop</div></body></html>"#,
);
vm.eval(
r#"
(() => {
const target = document.getElementById('drop');
const internalNames = object => Object.getOwnPropertyNames(object)
.filter(name => name.startsWith('__lmDataTransfer') || name.startsWith('__lmFileSystem'))
.sort();
window.__directoryDropReport = 'missing';
target.addEventListener('drop', event => {
const item = event.dataTransfer.items[0];
const types = event.dataTransfer.types;
const entry = item.webkitGetAsEntry();
const reader = entry.createReader();
const ownNamesBefore = {
dataTransfer: internalNames(event.dataTransfer),
itemList: internalNames(event.dataTransfer.items),
item: internalNames(item),
entry: internalNames(entry),
reader: internalNames(reader)
};
event.dataTransfer.__lmDataTransferItems = null;
event.dataTransfer.__lmDataTransferTypes = ['own/type'];
event.dataTransfer.items.__lmDataTransferItemArray = [];
item.__lmDataTransferItemKind = 'string';
item.__lmDataTransferItemType = 'own/type';
entry.__lmFileSystemEntryName = 'own';
entry.__lmFileSystemEntryFullPath = '/own';
entry.__lmFileSystemEntryIsDirectory = false;
entry.__lmFileSystemEntryIsFile = true;
entry.__lmFileSystemDirectoryEntryEntries = [];
reader.__lmFileSystemDirectoryReaderEntries = [];
reader.__lmFileSystemDirectoryReaderOffset = 0;
const reader2 = entry.createReader();
window.__directoryDropReport = JSON.stringify({
ownNamesBefore,
transfer: [
types.join(','),
types === event.dataTransfer.types,
Object.isFrozen(types),
event.dataTransfer.items.length,
item.kind,
item.type,
entry.name,
entry.fullPath,
entry.isDirectory,
entry.isFile
].join('|'),
freshReaderOwnNames: internalNames(reader2)
});
});
})()
"#,
)
.expect("directory drop listener setup should evaluate");
let drag_data = RendererDragData {
items: Vec::new(),
files: Vec::new(),
directories: vec![RendererDraggedDirectory {
name: "docs".to_owned(),
files: vec![RendererDraggedFile {
bytes: b"hello".to_vec(),
mime_type: "text/plain".to_owned(),
name: "child.txt".to_owned(),
last_modified: 7.0,
}],
directories: vec![RendererDraggedDirectory {
name: "nested".to_owned(),
files: Vec::new(),
directories: Vec::new(),
}],
}],
drag_operations_mask: 1,
};
vm.dispatch_drag_event_at_point(10.0, 10.0, "drop", drag_data, 0)
.expect("directory drop should dispatch");
let result = vm
.eval("window.__directoryDropReport")
.expect("directory drop report should be readable");
assert_eq!(
result,
r#"{"ownNamesBefore":{"dataTransfer":[],"itemList":[],"item":[],"entry":[],"reader":[]},"transfer":"Files|true|true|1|file||docs|/docs|true|false","freshReaderOwnNames":[]}"#
);
}
#[test]
fn data_transfer_string_surface_and_drag_event_constructor_work() {
let mut vm = new_storage_test_vm("https://drag-event-data-transfer.test/");
let result = vm
.eval(
r#"
(() => {
const dt = new DataTransfer();
dt.setData('text', 'alpha');
const htmlItem = dt.items.add('<b>beta</b>', 'text/html');
let stringPayload = '';
htmlItem.getAsString(value => {
stringPayload = value;
});
const event = new DragEvent('drop', {
dataTransfer: dt,
clientX: 12,
clientY: 34
});
const typesBeforeClear = dt.types.join(',');
dt.clearData('text/html');
return [
typeof DragEvent,
event instanceof DragEvent,
event instanceof MouseEvent,
event.dataTransfer === dt,
dt.getData('text/plain'),
typesBeforeClear,
dt.types.join(','),
htmlItem.kind,
htmlItem.type,
stringPayload,
dt.dropEffect,
dt.effectAllowed,
event.clientX,
event.clientY,
dt.items.length
].join('|');
})()
"#,
)
.expect("DragEvent/DataTransfer string surface should evaluate");
assert_eq!(
result,
"function|true|true|true|alpha|text/plain,text/html|text/plain||||none|none|12|34|1"
);
}
#[test]
fn drag_event_data_transfer_init_enforces_nullable_interface_conversion() {
let mut vm = new_storage_test_vm("https://drag-event-data-transfer-conversion.test/");
let result = vm
.eval(
r#"
(() => {
const outcome = dataTransfer => {
try {
new DragEvent('drop', { dataTransfer });
return 'accepted';
} catch (error) {
return error.name;
}
};
const transfer = new DataTransfer();
const fakeTransfer = Object.create(DataTransfer.prototype);
let getterError = 'missing';
try {
new DragEvent('drop', {
get dataTransfer() {
throw new RangeError('sentinel');
}
});
} catch (error) {
getterError = `${error.name}:${error.message}`;
}
return [
new DragEvent('drop').dataTransfer === null,
new DragEvent('drop', { dataTransfer: null }).dataTransfer === null,
new DragEvent('drop', { dataTransfer: undefined }).dataTransfer === null,
new DragEvent('drop', { dataTransfer: transfer }).dataTransfer === transfer,
outcome({}),
outcome(fakeTransfer),
outcome(1),
getterError
].join('|');
})()
"#,
)
.expect("DragEvent dataTransfer conversion should evaluate");
assert_eq!(
result,
"true|true|true|true|TypeError|TypeError|TypeError|RangeError:sentinel"
);
}
#[test]
fn data_transfer_types_is_a_cached_frozen_array_per_item_list_mutation() {
let mut vm = new_storage_test_vm("https://data-transfer-types-cache.test/");
let result = vm
.eval(
r#"
(() => {
const transfer = new DataTransfer();
const otherTransfer = new DataTransfer();
const initial = transfer.types;
const otherInitial = otherTransfer.types;
transfer.types = ['replacement'];
const afterReadonlyAssignment = transfer.types;
transfer.clearData();
transfer.items.clear();
transfer.items.remove(0);
const afterEmptyNoOps = transfer.types;
transfer.setData('text/plain', 'alpha');
const afterAdd = transfer.types;
transfer.clearData('text/html');
const afterMissingClear = transfer.types;
transfer.setData('text/plain', 'beta');
const afterReplace = transfer.types;
transfer.items.clear();
const afterClear = transfer.types;
transfer.items.clear();
return JSON.stringify({
arrays: [Array.isArray(initial), Object.isFrozen(initial)],
initialIdentity: [transfer !== otherTransfer, initial !== otherInitial],
readonlyIdentity: initial === afterReadonlyAssignment,
emptyNoOpsPreserveIdentity: initial === afterEmptyNoOps,
addReplacesIdentity: initial !== afterAdd,
missingClearPreservesIdentity: afterAdd === afterMissingClear,
replacementReplacesIdentity: afterMissingClear !== afterReplace,
clearReplacesIdentity: afterReplace !== afterClear,
finalNoOpPreservesIdentity: afterClear === transfer.types,
snapshots: [initial.join(','), afterAdd.join(','), afterReplace.join(','), afterClear.join(',')],
frozenAfterMutations: [afterAdd, afterReplace, afterClear].every(Object.isFrozen)
});
})()
"#,
)
.expect("DataTransfer types FrozenArray semantics should evaluate");
assert_eq!(
result,
r#"{"arrays":[true,true],"initialIdentity":[true,true],"readonlyIdentity":true,"emptyNoOpsPreserveIdentity":true,"addReplacesIdentity":true,"missingClearPreservesIdentity":true,"replacementReplacesIdentity":true,"clearReplacesIdentity":true,"finalNoOpPreservesIdentity":true,"snapshots":["","text/plain","text/plain",""],"frozenAfterMutations":true}"#
);
}
#[test]
fn data_transfer_types_lazily_publishes_first_snapshot_after_mutation() {
let mut vm = new_storage_test_vm("https://data-transfer-types-lazy-cache.test/");
let result = vm
.eval(
r#"
(() => {
const transfer = new DataTransfer();
transfer.setData('text/plain', 'alpha');
const first = transfer.types;
return [first.join(','), Object.isFrozen(first), first === transfer.types].join('|');
})()
"#,
)
.expect("DataTransfer types should publish its first snapshot lazily");
assert_eq!(result, "text/plain|true|true");
}
#[test]
fn data_transfer_types_snapshot_tracks_file_and_failed_item_mutations() {
let mut vm = new_storage_test_vm("https://data-transfer-file-types-cache.test/");
let result = vm
.eval(
r#"
(() => {
const transfer = new DataTransfer();
const files = transfer.files;
const initial = transfer.types;
const firstFile = new File(['first'], 'first.txt');
const secondFile = new File(['second'], 'second.txt');
transfer.items.add(firstFile);
const afterFirstFile = transfer.types;
transfer.setData('text/plain', 'alpha');
const afterText = transfer.types;
let duplicateError = '';
try {
transfer.items.add('duplicate', 'text/plain');
} catch (error) {
duplicateError = error.name;
}
const afterDuplicate = transfer.types;
const invalidAdd = transfer.items.add({});
const afterInvalidAdd = transfer.types;
transfer.items.remove(99);
const afterInvalidRemoval = transfer.types;
transfer.items.add(secondFile);
const afterSecondFile = transfer.types;
transfer.items.remove(0);
const afterFirstRemoval = transfer.types;
transfer.items.remove(1);
const afterLastFileRemoval = transfer.types;
return JSON.stringify({
values: [
initial.join(','),
afterFirstFile.join(','),
afterText.join(','),
afterSecondFile.join(','),
afterFirstRemoval.join(','),
afterLastFileRemoval.join(',')
],
identities: [
initial !== afterFirstFile,
afterFirstFile !== afterText,
afterText === afterDuplicate,
afterDuplicate === afterInvalidAdd,
afterInvalidAdd === afterInvalidRemoval,
afterInvalidRemoval !== afterSecondFile,
afterSecondFile !== afterFirstRemoval,
afterFirstRemoval !== afterLastFileRemoval
],
frozen: [
initial,
afterFirstFile,
afterText,
afterSecondFile,
afterFirstRemoval,
afterLastFileRemoval
].every(Object.isFrozen),
failures: [duplicateError, invalidAdd === null],
liveFiles: [transfer.files === files, files.length]
});
})()
"#,
)
.expect("DataTransfer file types FrozenArray semantics should evaluate");
assert_eq!(
result,
r#"{"values":["","Files","text/plain,Files","text/plain,Files","text/plain,Files","text/plain"],"identities":[true,true,true,true,true,true,true,true],"frozen":true,"failures":["NotSupportedError",true],"liveFiles":[true,0]}"#
);
}
#[test]
fn data_transfer_file_list_reference_tracks_item_mutations() {
let mut vm = new_storage_test_vm("https://data-transfer-live-files.test/");
let result = vm
.eval(
r#"
(() => {
const transfer = new DataTransfer();
const files = transfer.files;
const first = new File(["first"], "first.txt");
const second = new File(["second"], "second.txt");
transfer.items.add(first);
transfer.items.add(second);
const afterAdd = [
transfer.files === files,
files.length,
files[0] === first,
files.item(1) === second
].join(":");
transfer.items.remove(0);
const afterRemove = [
transfer.files === files,
files.length,
files[0] === second,
files[1] === undefined,
files.item(1) === null
].join(":");
return `${afterAdd}|${afterRemove}`;
})()
"#,
)
.expect("live DataTransfer FileList probe should evaluate");
assert_eq!(result, "true:2:true:true|true:1:true:true:true");
}
#[test]
fn mouse_dragstart_bubbles_to_window_once() {
let mut vm = new_rendered_test_vm(
"https://dragstart-bubbles-once.test/",
r#"<html><body><div id="drag" draggable="true">drag</div></body></html>"#,
);
vm.eval(
r#"
(() => {
window.__dragStarts = 0;
window.__dragStartLog = [];
window.addEventListener('dragstart', event => {
window.__dragStarts += 1;
window.__dragStartLog.push([
event.type,
event.currentTarget === window,
!!event.dataTransfer
].join(':'));
});
})()
"#,
)
.expect("dragstart listener setup should evaluate");
vm.publish_layout_for_test()
.expect("publish geometry before coordinate input");
vm.dispatch_mouse_event_at_point(20.0, 20.0, "mousedown", 0, None, 0.0, 0.0)
.expect("mousedown should dispatch");
vm.dispatch_mouse_event_at_point(20.0, 20.0, "mousemove", 0, Some(1), 0.0, 0.0)
.expect("mousemove should start drag");
let result = vm
.eval(
r#"
(() => [window.__dragStarts, window.__dragStartLog.join('|')].join('|'))()
"#,
)
.expect("dragstart log should evaluate");
assert_eq!(result, "1|dragstart:true:true");
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,642 @@
use super::*;
#[test]
fn range_insert_node_updates_collapsed_end_boundary_after_native_insert() {
let mut vm = new_storage_test_vm("https://range-insert-node-collapsed-end.test/");
let result = vm
.eval(
r#"
(() => {
const host = document.createElement('div');
host.append('ab');
(document.body || document.documentElement || document).appendChild(host);
const text = host.firstChild;
const range = document.createRange();
range.setStart(text, 1);
range.setEnd(text, 1);
const marker = document.createElement('span');
range.insertNode(marker);
return [
host.childNodes.length,
host.childNodes[0].data,
host.childNodes[1] === marker,
host.childNodes[2].data,
range.startContainer === text,
range.startOffset,
range.endContainer === host,
range.endOffset
].join('|');
})()
"#,
)
.expect("Range.insertNode collapsed text insertion probe should evaluate");
assert_eq!(result, "3|a|true|b|true|1|true|2");
}
#[test]
fn range_insert_node_counts_document_fragment_children_for_collapsed_end() {
let mut vm = new_storage_test_vm("https://range-insert-node-fragment-offset.test/");
let result = vm
.eval(
r#"
(() => {
const host = document.createElement('div');
host.append(document.createElement('a'), document.createElement('d'));
(document.body || document.documentElement || document).appendChild(host);
const fragment = document.createDocumentFragment();
fragment.append(document.createElement('b'), document.createElement('c'));
const range = document.createRange();
range.setStart(host, 1);
range.setEnd(host, 1);
range.insertNode(fragment);
return [
Array.from(host.childNodes, node => node.localName).join(''),
fragment.childNodes.length,
range.startContainer === host,
range.startOffset,
range.endContainer === host,
range.endOffset
].join('|');
})()
"#,
)
.expect("Range.insertNode document fragment offset probe should evaluate");
assert_eq!(result, "abcd|0|true|1|true|3");
}
#[test]
fn range_insert_node_validates_before_splitting_text_for_document_rules() {
let mut vm = new_storage_test_vm("https://range-insert-node-validation-order.test/");
let result = vm
.eval(
r#"
(() => {
const host = document.createElement('div');
host.append('abc');
(document.body || document.documentElement || document).appendChild(host);
const text = host.firstChild;
const range = document.createRange();
range.setStart(text, 1);
range.setEnd(text, 1);
const doctype = document.implementation.createDocumentType('html', '', '');
let thrown = null;
try {
range.insertNode(doctype);
} catch (error) {
thrown = error;
}
return [
thrown && thrown.name,
text.data,
text.parentNode === host,
host.childNodes.length,
range.startContainer === text,
range.startOffset,
range.endContainer === text,
range.endOffset
].join('|');
})()
"#,
)
.expect("Range.insertNode document validation order probe should evaluate");
assert_eq!(result, "HierarchyRequestError|abc|true|1|true|1|true|1");
}
#[test]
fn range_insert_node_splits_cdata_start_container() {
let mut vm = new_storage_test_vm("https://range-insert-node-cdata.test/");
let result = vm
.eval(
r#"
(() => {
const xml = document.implementation.createDocument(null, 'root');
const cdata = xml.createCDATASection('abcd');
xml.documentElement.appendChild(cdata);
const range = xml.createRange();
range.setStart(cdata, 2);
range.setEnd(cdata, 2);
const marker = xml.createElement('marker');
range.insertNode(marker);
return [
xml.documentElement.childNodes.length,
xml.documentElement.childNodes[0].data,
xml.documentElement.childNodes[1] === marker,
xml.documentElement.childNodes[2].data,
range.startContainer === cdata,
range.startOffset,
range.endContainer === xml.documentElement,
range.endOffset
].join('|');
})()
"#,
)
.expect("Range.insertNode CDATA insertion probe should evaluate");
assert_eq!(result, "3|ab|true|cd|true|2|true|2");
}
#[test]
fn range_insert_node_move_keeps_non_collapsed_boundary_after_moved_child() {
let mut vm = new_storage_test_vm("https://range-insert-node-move-boundary.test/");
let result = vm
.eval(
r#"
(() => {
const host = document.createElement('p');
const text = document.createTextNode('abc');
host.appendChild(text);
(document.body || document.documentElement || document).appendChild(host);
const range = document.createRange();
range.setStart(host, 0);
range.setEnd(host, 1);
range.insertNode(text);
return [
host.childNodes.length,
host.firstChild === text,
range.startContainer === host,
range.startOffset,
range.endContainer === host,
range.endOffset
].join('|');
})()
"#,
)
.expect("Range.insertNode moving covered child should preserve the end boundary");
assert_eq!(result, "1|true|true|0|true|1");
}
#[test]
fn range_insert_node_sets_end_after_move_collapses_current_range() {
let mut vm = new_storage_test_vm("https://range-insert-node-current-collapse.test/");
let result = vm
.eval(
r#"
(() => {
const host = document.createElement('p');
const text = document.createTextNode('abcdefg');
host.appendChild(text);
(document.body || document.documentElement || document).appendChild(host);
const range = document.createRange();
range.setStart(host, 0);
range.setEnd(text, 7);
range.insertNode(text);
return [
host.childNodes.length,
host.firstChild === text,
range.startContainer === host,
range.startOffset,
range.endContainer === host,
range.endOffset
].join('|');
})()
"#,
)
.expect("Range.insertNode should repair the end after moving the end container");
assert_eq!(result, "1|true|true|0|true|1");
}
#[test]
fn range_insert_node_move_comment_keeps_boundary_after_original_position() {
let mut vm = new_storage_test_vm("https://range-insert-node-comment-boundary.test/");
let result = vm
.eval(
r#"
(() => {
const host = document.createElement('div');
for (let index = 0; index < 6; index++) {
host.appendChild(document.createElement('p'));
}
const comment = document.createComment('Alphabet soup?');
host.appendChild(comment);
(document.body || document.documentElement || document).appendChild(host);
const range = document.createRange();
range.setStart(host, 0);
range.setEnd(comment, 5);
range.insertNode(comment);
return [
host.childNodes.length,
host.firstChild === comment,
range.startContainer === host,
range.startOffset,
range.endContainer === host,
range.endOffset
].join('|');
})()
"#,
)
.expect("Range.insertNode should keep moved comment boundary after original position");
assert_eq!(result, "7|true|true|0|true|7");
}
#[test]
fn range_insert_node_move_foreign_text_keeps_boundary_after_original_position() {
let mut vm = new_storage_test_vm("https://range-insert-node-foreign-text-boundary.test/");
let result = vm
.eval(
r#"
(() => {
const foreignDoc = document.implementation.createHTMLDocument('');
const first = foreignDoc.createElement('p');
const second = foreignDoc.createElement('p');
const text = foreignDoc.createTextNode('I admit that I harbor doubts about whether we really need so many things to test, but it is too late to stop now.');
foreignDoc.body.appendChild(first);
foreignDoc.body.appendChild(second);
foreignDoc.body.appendChild(text);
const range = foreignDoc.createRange();
range.setStart(foreignDoc.body, 0);
range.setEnd(text, 36);
range.insertNode(text);
return [
foreignDoc.body.childNodes.length,
foreignDoc.body.firstChild === text,
range.startContainer === foreignDoc.body,
range.startOffset,
range.endContainer === foreignDoc.body,
range.endOffset
].join('|');
})()
"#,
)
.expect("Range.insertNode should keep moved foreign text boundary after original position");
assert_eq!(result, "3|true|true|0|true|3");
}
#[test]
fn range_insert_node_rejects_document_doctype_self_move_before_reference_adjustment() {
let mut vm = new_storage_test_vm("https://range-insert-node-doctype-self-move.test/");
let result = vm
.eval(
r#"
(() => {
const doc = document.implementation.createHTMLDocument('');
const doctype = doc.doctype;
const range = doc.createRange();
range.setStart(doc, 0);
range.setEnd(doc, 1);
let thrown = null;
try {
range.insertNode(doctype);
} catch (error) {
thrown = error;
}
return [
!!doctype,
thrown && thrown.name,
thrown && thrown.code,
doc.childNodes.length,
range.endContainer === doc,
range.endOffset
].join('|');
})()
"#,
)
.expect("Range.insertNode should reject moving a document doctype before itself");
assert_eq!(result, "true|HierarchyRequestError|3|2|true|1");
}
#[test]
fn range_insert_node_allows_comment_before_foreign_document_element() {
let mut vm = new_storage_test_vm("https://range-insert-node-foreign-document-comment.test/");
let result = vm
.eval(
r#"
(() => {
const probeDoc = document.implementation.createHTMLDocument('');
const plainComment = document.createComment('plain');
let plainThrown = null;
try {
probeDoc.insertBefore(plainComment, probeDoc.documentElement);
} catch (error) {
plainThrown = error.name;
}
const foreignDoc = document.implementation.createHTMLDocument('');
const comment = document.createComment('Alphabet soup?');
(document.body || document.documentElement || document).appendChild(comment);
const range = foreignDoc.createRange();
range.setStart(foreignDoc, 1);
range.setEnd(foreignDoc, 1);
let rangeThrown = null;
try {
range.insertNode(comment);
} catch (error) {
rangeThrown = error.name;
}
const xmlDoc = document.implementation.createDocument(null, null,
document.implementation.createDocumentType('qorflesnorf', 'abcde', 'x'));
const xmlElement = xmlDoc.createElement('root');
xmlDoc.appendChild(xmlElement);
const xmlComment = document.createComment('xml');
const xmlRange = xmlDoc.createRange();
xmlRange.setStart(xmlDoc, 1);
xmlRange.setEnd(xmlDoc, 1);
let xmlThrown = null;
try {
xmlRange.insertNode(xmlComment);
} catch (error) {
xmlThrown = error.name;
}
return [
plainThrown,
probeDoc.childNodes[1] === plainComment,
rangeThrown,
foreignDoc.childNodes[1] === comment,
comment.ownerDocument === foreignDoc,
range.startContainer === foreignDoc,
range.startOffset,
range.endContainer === foreignDoc,
range.endOffset,
xmlThrown,
xmlDoc.childNodes[1] === xmlComment,
xmlComment.ownerDocument === xmlDoc,
xmlRange.endOffset
].join('|');
})()
"#,
)
.expect("Range.insertNode should allow comments before a document element");
assert_eq!(result, "|true||true|true|true|1|true|2||true|true|2");
}
#[test]
fn range_surround_contents_rejects_partially_selected_element_before_mutation() {
let mut vm = new_storage_test_vm("https://range-surround-partial-invalid.test/");
let result = vm
.eval(
r#"
(() => {
const host = document.createElement('div');
const first = document.createElement('p');
first.textContent = 'abc';
const second = document.createElement('p');
second.textContent = 'def';
host.append(first, second);
(document.body || document.documentElement || document).appendChild(host);
const wrapper = document.createElement('section');
wrapper.appendChild(document.createElement('old'));
const range = document.createRange();
range.setStart(first.firstChild, 1);
range.setEnd(host, 2);
let thrown = null;
try {
range.surroundContents(wrapper);
} catch (error) {
thrown = error;
}
return [
thrown && thrown.name,
thrown && thrown.code,
host.childNodes.length,
host.firstChild === first,
first.firstChild.data,
wrapper.firstChild && wrapper.firstChild.localName,
range.startContainer === first.firstChild,
range.startOffset,
range.endContainer === host,
range.endOffset
].join('|');
})()
"#,
)
.expect("Range.surroundContents should validate partial non-Text selection first");
assert_eq!(result, "InvalidStateError|11|2|true|abc|old|true|1|true|2");
}
#[test]
fn range_surround_contents_replaces_new_parent_children_and_selects_wrapper() {
let mut vm = new_storage_test_vm("https://range-surround-success.test/");
let result = vm
.eval(
r#"
(() => {
const host = document.createElement('div');
const first = document.createElement('p');
first.id = 'a';
first.textContent = 'ab';
const second = document.createElement('p');
second.id = 'b';
second.textContent = 'cd';
host.append(first, second);
(document.body || document.documentElement || document).appendChild(host);
const old = document.createElement('old');
const wrapper = document.createElement('section');
wrapper.appendChild(old);
const range = document.createRange();
range.setStart(host, 0);
range.setEnd(host, 2);
range.surroundContents(wrapper);
return [
host.childNodes.length,
host.firstChild === wrapper,
Array.from(wrapper.childNodes, node => node.id).join(','),
old.parentNode === null,
range.startContainer === host,
range.startOffset,
range.endContainer === host,
range.endOffset
].join('|');
})()
"#,
)
.expect("Range.surroundContents should wrap extracted contents and select new parent");
assert_eq!(result, "1|true|a,b|true|true|0|true|1");
}
#[test]
fn range_surround_contents_text_new_parent_fails_after_insert_step() {
let mut vm = new_storage_test_vm("https://range-surround-text-parent-failure.test/");
let result = vm
.eval(
r#"
(() => {
const host = document.createElement('div');
const child = document.createElement('p');
child.textContent = 'abc';
host.appendChild(child);
(document.body || document.documentElement || document).appendChild(host);
const wrapper = document.createTextNode('wrapper');
const range = document.createRange();
range.setStart(host, 0);
range.setEnd(host, 1);
let thrown = null;
try {
range.surroundContents(wrapper);
} catch (error) {
thrown = error;
}
return [
thrown && thrown.name,
thrown && thrown.code,
host.childNodes.length,
host.firstChild === wrapper,
child.parentNode && child.parentNode.nodeType,
range.startContainer === host,
range.startOffset,
range.endContainer === host,
range.endOffset
].join('|');
})()
"#,
)
.expect("Range.surroundContents should preserve spec mutation order when append fails");
assert_eq!(result, "HierarchyRequestError|3|1|true|11|true|0|true|1");
}
#[test]
fn range_surround_contents_uses_boundary_after_clearing_new_parent_children() {
let mut vm = new_storage_test_vm("https://range-surround-ancestor-parent.test/");
let result = vm
.eval(
r#"
(() => {
const wrapper = document.createElement('div');
const child = document.createElement('p');
child.textContent = 'abc';
wrapper.appendChild(child);
const range = document.createRange();
range.setStart(child.firstChild, 0);
range.setEnd(child.firstChild, 0);
let thrown = null;
try {
range.surroundContents(wrapper);
} catch (error) {
thrown = error;
}
return [
thrown && thrown.name,
thrown && thrown.code,
wrapper.childNodes.length,
child.parentNode === null,
range.startContainer === wrapper,
range.startOffset,
range.endContainer === wrapper,
range.endOffset
].join('|');
})()
"#,
)
.expect("Range.surroundContents should use the live boundary after clearing newParent");
assert_eq!(result, "HierarchyRequestError|3|0|true|true|0|true|0");
}
#[test]
fn replace_child_self_updates_live_range_boundaries_like_remove_then_insert() {
let mut vm = new_storage_test_vm("https://replace-child-self-range.test/");
let result = vm
.eval(
r#"
(() => {
const parent = document.createElement('div');
parent.append(document.createElement('a'), document.createElement('b'), document.createElement('c'));
(document.body || document.documentElement || document).appendChild(parent);
const oldChild = parent.childNodes[1];
oldChild.textContent = 'bc';
const parentRange = document.createRange();
parentRange.setStart(parent, 1);
parentRange.setEnd(parent, 2);
const childRange = document.createRange();
childRange.setStart(oldChild.firstChild, 0);
childRange.setEnd(oldChild.firstChild, 1);
const returned = parent.replaceChild(oldChild, oldChild);
return [
returned === oldChild,
parent.childNodes.length,
parent.childNodes[1] === oldChild,
parentRange.startContainer === parent,
parentRange.startOffset,
parentRange.endContainer === parent,
parentRange.endOffset,
childRange.startContainer === parent,
childRange.startOffset,
childRange.endContainer === parent,
childRange.endOffset
].join('|');
})()
"#,
)
.expect("replaceChild(oldChild, oldChild) should update ranges like remove then insert");
assert_eq!(result, "true|3|true|true|1|true|1|true|1|true|1");
}
#[test]
fn child_document_stream_slots_ignore_page_tampering() {
let mut vm = new_storage_test_vm("https://child-stream-slot-tamper.test/");
let result = vm
.eval(
r#"
(() => {
const iframe = document.createElement('iframe');
(document.body || document.documentElement || document).appendChild(iframe);
const doc = iframe.contentDocument;
doc.open();
doc.write('<script>window.__count = (window.__count || 0) + 1;</script>');
doc.__lmChildDocumentExecutedScriptOffset = 0;
doc.__lmChildDocumentPendingWrite = '<p id="evil"></p>';
doc.write('<p id="safe"></p>');
return [
iframe.contentWindow.__count,
!!doc.getElementById('safe'),
!!doc.getElementById('evil'),
Object.prototype.propertyIsEnumerable.call(doc, '__lmChildDocumentPendingWrite'),
Object.prototype.propertyIsEnumerable.call(doc, '__lmChildDocumentExecutedScriptOffset')
].join('|');
})()
"#,
)
.expect("child document stream internals should ignore page-owned slots");
assert_eq!(result, "1|true|false|true|true");
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,785 @@
use super::*;
#[test]
fn document_point_queries_use_real_paint_order_geometry() {
let mut vm = new_rendered_test_vm(
"https://document-point-query.test/path/index.html",
r#"<html><body>
<div id="target" style="width:100px;height:50px">target</div>
</body></html>"#,
);
let result = vm
.eval(
r#"
(() => JSON.stringify({
element: document.elementFromPoint(10, 10)?.localName ?? null,
elements: document.elementsFromPoint(10, 10).map(element => element.localName)
}))()
"#,
)
.expect("document point query probe should evaluate");
assert_eq!(
result,
r#"{"element":"div","elements":["div","body","html"]}"#
);
}
#[test]
fn style_source_sync_consumes_prepared_sources_and_preserves_cssom() {
let mut vm = new_parsed_test_vm(
"https://style-source-lifecycle.test/",
"<!doctype html><body></body>",
);
vm.eval(
r#"
document.open();
document.write('<!doctype html><head><style id=sheet>body { color: red; }');
globalThis.pendingStyle = document.getElementById('sheet');
"#,
)
.unwrap();
vm.sync_live_document_style_sources();
assert_eq!(
vm.eval("JSON.stringify([pendingStyle.sheet === null, document.styleSheets.length])")
.unwrap(),
"[true,0]",
"rendering synchronization must not create a source for an unfinished style",
);
vm.eval(
r#"
document.write('</style></head><body>');
globalThis.preparedSheet = pendingStyle.sheet;
preparedSheet.insertRule('.added { color: blue; }', preparedSheet.cssRules.length);
"#,
)
.unwrap();
vm.sync_live_document_style_sources();
assert_eq!(
vm.eval(
"JSON.stringify([pendingStyle.sheet === preparedSheet, preparedSheet.cssRules.length])"
)
.unwrap(),
"[true,2]",
"synchronization must preserve the prepared stylesheet and CSSOM edits",
);
assert_eq!(
vm.eval(
r#"
pendingStyle.textContent = 'body { color: green; }';
document.close();
JSON.stringify([pendingStyle.sheet !== preparedSheet,
pendingStyle.sheet.cssRules.length, getComputedStyle(document.body).color]);
"#,
)
.unwrap(),
r#"[true,1,"rgb(0, 128, 0)"]"#,
"a real DOM content change must still replace the stylesheet",
);
}
#[test]
fn parser_style_waits_for_complete_source_in_main_and_child_documents() {
let mut vm = new_parsed_test_vm(
"https://parser-style-completion.test/",
"<!doctype html><body></body>",
);
let result = vm
.eval(
r#"
(() => {
function exercise(w) {
const d = w.document;
d.open();
d.write('<!doctype html><head><style id=sheet>\n');
const style = d.getElementById('sheet');
const pending = () => [style.sheet === null, d.styleSheets.length];
const before = [pending()];
d.write('body { color: rgb(1, 2, 3); }\n');
before.push(pending());
d.write('.second { color: green; }\n');
style.type = 'text/css';
before.push(pending());
const clone = d.head.appendChild(style.cloneNode(true));
const cloneRules = clone.sheet.cssRules.length;
clone.remove();
d.write('</style></head><body>');
const sheet = style.sheet;
const complete = [sheet.cssRules.length, d.styleSheets.length,
w.getComputedStyle(d.body).color];
sheet.insertRule('.kept { color: blue; }', sheet.cssRules.length);
d.write('<p>unrelated parser work</p>');
const preserved = sheet === style.sheet && sheet.cssRules.length === 3;
style.firstChild.appendData('.third { color: black; }');
const changed = Array.from(style.sheet.cssRules, r => r.selectorText);
style.textContent = 'body { color: rgb(4, 5, 6); }';
const dynamicColor = w.getComputedStyle(d.body).color;
d.close();
return {before, cloneRules, complete, preserved, changed, dynamicColor};
}
const main = exercise(window);
const frame = document.body.appendChild(document.createElement('iframe'));
const child = exercise(frame.contentWindow);
return JSON.stringify([main, child]);
})()
"#,
)
.expect("parser style completion should preserve CSSOM and dynamic text updates");
let expected = serde_json::json!({
"before": [[true, 0], [true, 0], [true, 0]],
"cloneRules": 2,
"complete": [2, 1, "rgb(1, 2, 3)"],
"preserved": true,
"changed": ["body", ".second", ".third"],
"dynamicColor": "rgb(4, 5, 6)"
});
assert_eq!(
serde_json::from_str::<serde_json::Value>(&result).unwrap(),
serde_json::json!([expected, expected]),
);
}
#[test]
fn parser_style_completion_follows_the_creating_parser_and_preserves_abort() {
let mut vm = new_parsed_test_vm(
"https://parser-style-ownership.test/",
"<!doctype html><body><div id=probe>probe</div></body>",
);
let result = vm.eval(r#"
(() => {
const frame = document.body.appendChild(document.createElement('iframe'));
const d = frame.contentDocument;
d.open();
d.write('<!doctype html><head><style>#probe { color: rgb(1, 2, 3) }');
const moved = d.querySelector('style');
document.head.appendChild(moved);
const pending = moved.sheet === null;
d.write('</style>');
d.close();
const complete = [!!moved.sheet, getComputedStyle(document.getElementById('probe')).color];
moved.remove();
d.open();
d.write('<!doctype html><head><style>#probe { color: red }');
const aborted = d.querySelector('style');
d.open();
d.write('<!doctype html><body>replacement</body>');
d.close();
document.head.appendChild(aborted);
aborted.textContent = '#probe { color: blue }';
const afterAbort = [aborted.sheet === null, getComputedStyle(document.getElementById('probe')).color];
aborted.remove();
frame.remove();
return JSON.stringify({pending, complete, afterAbort});
})()
"#).expect("moving a style preserves parser ownership, while cancellation cannot complete it");
assert_eq!(
serde_json::from_str::<serde_json::Value>(&result).unwrap(),
serde_json::json!({
"pending": true,
"complete": [true, "rgb(1, 2, 3)"],
"afterAbort": [true, "rgb(0, 0, 0)"]
}),
);
}
#[test]
fn parser_style_finishes_at_eof_in_main_and_child_documents() {
let mut vm = new_parsed_test_vm(
"https://parser-style-eof.test/",
"<!doctype html><body></body>",
);
let result = vm
.eval(
r#"
(() => {
function exercise(w) {
const d = w.document;
d.open();
d.write('<!doctype html><body><style id=sheet>\nbody { color: rgb(7, 8, 9); }\n');
const style = d.getElementById('sheet');
const pending = style.sheet === null && d.styleSheets.length === 0;
d.close();
return [pending, style.sheet.cssRules.length, d.styleSheets.length,
w.getComputedStyle(d.body).color];
}
const main = exercise(window);
const frame = document.body.appendChild(document.createElement('iframe'));
const child = exercise(frame.contentWindow);
return JSON.stringify([main, child]);
})()
"#,
)
.expect("EOF should publish the complete unterminated style source");
let expected = serde_json::json!([true, 1, 1, "rgb(7, 8, 9)"]);
assert_eq!(
serde_json::from_str::<serde_json::Value>(&result).unwrap(),
serde_json::json!([expected, expected]),
);
}
#[test]
fn parser_style_finishes_in_svg_and_detached_fragments() {
let mut vm = new_parsed_test_vm(
"https://parser-svg-style-completion.test/",
"<!doctype html><body></body>",
);
let result = vm.eval(r#"
(() => {
document.open();
document.write('<!doctype html><body><svg><style id=sheet>\n');
const style = document.getElementById('sheet');
document.write('body { color: rgb(1, 2, 3); }\n');
const pending = style.sheet === null && document.styleSheets.length === 0;
document.write('</style><style id=empty /></svg>');
const complete = [style.sheet.cssRules.length,
document.getElementById('empty').sheet.cssRules.length];
document.close();
const fragment = document.createElement('div');
fragment.innerHTML = '<style>body { color: rgb(4, 5, 6); }\n</style>';
document.body.appendChild(fragment);
const fragmentRules = fragment.firstChild.sheet.cssRules.length;
const parsed = new DOMParser().parseFromString(
'<html xmlns="http://www.w3.org/1999/xhtml"><head><style>body {color:red}</style></head></html>',
'application/xhtml+xml');
return JSON.stringify({pending, complete, fragmentRules,
color:getComputedStyle(document.body).color,
xmlRules:parsed.querySelector('style').sheet.cssRules.length});
})()
"#).expect("SVG, fragments and XML should all finish parser-created styles");
assert_eq!(
serde_json::from_str::<serde_json::Value>(&result).unwrap(),
serde_json::json!({
"pending": true, "complete": [1, 0], "fragmentRules": 1,
"color": "rgb(4, 5, 6)", "xmlRules": 1
}),
);
}
#[test]
fn parser_coalesced_style_text_updates_main_and_child_hit_tests() {
let mut vm = new_rendered_test_vm(
"https://parser-style-hit-test.test/",
"<!doctype html><body></body>",
);
let result = eval_with_layout_publications(
&mut vm,
r#"
(function* () {
function* exercise(w) {
const d = w.document;
d.open();
d.write('<!doctype html><style>\n');
d.write('html, body {margin:0;padding:0} #target {width:100px;height:100px}');
d.write('</style><body><div id=target></div>');
yield; // Publish this scene before reading its geometry.
const rect = d.getElementById('target').getBoundingClientRect();
const result = {rect:[rect.x,rect.y,rect.width,rect.height],
hits:d.elementsFromPoint(1,1).map(e => e.id || e.localName)};
d.close();
return result;
}
const main = yield* exercise(window);
const frame = document.body.appendChild(document.createElement('iframe'));
const child = yield* exercise(frame.contentWindow);
return JSON.stringify([main,child]);
})()
"#,
)
.expect("coalesced parser text must update its stylesheet before layout queries");
let expected = serde_json::json!({"rect":[0,0,100,100], "hits":["target","body","html"]});
assert_eq!(
serde_json::from_str::<serde_json::Value>(&result).unwrap(),
serde_json::json!([expected, expected]),
);
}
#[test]
fn parser_coalesced_text_notifies_main_and_child_mutation_observers() {
let mut vm = new_parsed_test_vm(
"https://parser-text-observer.test/",
"<!doctype html><body></body>",
);
let result = vm.eval(r#"
(() => {
function exercise(w) {
const d = w.document;
d.open(); d.write('<!doctype html><body><p>a');
const p = d.querySelector('p'), text = p.firstChild;
const observer = new w.MutationObserver(() => {});
observer.observe(p, {subtree:true,childList:true,characterData:true,characterDataOldValue:true});
d.write('bc');
const result = {sameText:p.firstChild === text, children:p.childNodes.length,
records:observer.takeRecords().map(r => [r.type,r.target === text,r.oldValue,r.target.data])};
observer.disconnect();
d.write('</p>'); d.close();
return result;
}
const main = exercise(window);
const frame = document.body.appendChild(document.createElement('iframe'));
return JSON.stringify([main,exercise(frame.contentWindow)]);
})()
"#).expect("parser text coalescing must report characterData without replacing the Text node");
let expected = serde_json::json!({"sameText":true,"children":1,
"records":[["characterData",true,"a","abc"]]});
assert_eq!(
serde_json::from_str::<serde_json::Value>(&result).unwrap(),
serde_json::json!([expected, expected]),
);
}
#[test]
fn point_queries_only_follow_explicit_viewport_publication() {
use moli_layout::{
GeometryProvider, LayoutPoint, LayoutQuery, LayoutQueryAnswer, LayoutQueryBatch,
LayoutViewport,
};
let mut vm = new_parsed_test_vm(
"https://point-query-viewport.test/",
"<html><body style='margin:0'><div style='width:300px;height:100px'></div></body></html>",
);
let before = vm.layout_pass_observability_for_test().1;
let batch = LayoutQueryBatch::new(vec![
LayoutQuery::HitTest {
point: LayoutPoint::new(-1.0, -1.0),
ignore_pointer_events_none: false,
},
LayoutQuery::HitTestAll {
point: LayoutPoint::new(-1.0, -1.0),
ignore_pointer_events_none: false,
},
LayoutQuery::HitTest {
point: LayoutPoint::new(150.0, 20.0),
ignore_pointer_events_none: false,
},
LayoutQuery::HitTestAll {
point: LayoutPoint::new(150.0, 20.0),
ignore_pointer_events_none: false,
},
LayoutQuery::CaretPosition {
point: LayoutPoint::new(-1.0, -1.0),
},
LayoutQuery::CaretPosition {
point: LayoutPoint::new(150.0, 20.0),
},
]);
for (width, expect_hit, expected_passes) in [
(320, true, 1),
(320, true, 1),
(100, false, 2),
(100, false, 2),
(320, true, 3),
] {
if vm.layout_pass_observability_for_test().1 < before + expected_passes {
vm.screenshot_layout_snapshot(LayoutViewport::new(width, 200, 1.0))
.unwrap()
.unwrap();
}
let answers = GeometryProvider::answer(&mut *vm, &batch).expect("point query batch");
assert_eq!(answers.answers[0], LayoutQueryAnswer::HitTest(None));
assert_eq!(
answers.answers[1],
LayoutQueryAnswer::HitTestAll(Vec::new())
);
assert_eq!(
matches!(answers.answers[2], LayoutQueryAnswer::HitTest(Some(_))),
expect_hit
);
let LayoutQueryAnswer::HitTestAll(hits) = &answers.answers[3] else {
panic!("expected hit list")
};
assert_eq!(!hits.is_empty(), expect_hit);
assert_eq!(answers.answers[4], LayoutQueryAnswer::CaretPosition(None));
let LayoutQueryAnswer::CaretPosition(position) = &answers.answers[5] else {
panic!("expected caret position")
};
assert_eq!(position.is_some(), expect_hit);
assert_eq!(
vm.layout_pass_observability_for_test().1,
before + expected_passes,
"only explicit output publishes the changed viewport"
);
}
}
#[test]
fn point_queries_follow_explicit_output_when_viewport_expands() {
use moli_layout::{
GeometryProvider, LayoutPoint, LayoutQuery, LayoutQueryAnswer, LayoutQueryBatch,
LayoutViewport,
};
for (initial_size, expanded_size, point) in [
((100, 200), (320, 200), LayoutPoint::new(150.0, 20.0)),
((320, 100), (320, 200), LayoutPoint::new(20.0, 150.0)),
((320, 100), (100, 200), LayoutPoint::new(20.0, 150.0)),
] {
let mut vm = new_parsed_test_vm(
"https://point-query-viewport-expansion.test/",
"<html><body style='margin:0'><div style='width:300px;height:300px'></div></body></html>",
);
let before = vm.layout_pass_observability_for_test().1;
let batch = LayoutQueryBatch::new(vec![
LayoutQuery::HitTest {
point,
ignore_pointer_events_none: false,
},
LayoutQuery::HitTestAll {
point,
ignore_pointer_events_none: false,
},
LayoutQuery::CaretPosition { point },
]);
for ((width, height), expect_hit, expected_passes) in [
(initial_size, false, 1),
(expanded_size, true, 2),
(expanded_size, true, 2),
] {
if vm.layout_pass_observability_for_test().1 < before + expected_passes {
vm.screenshot_layout_snapshot(LayoutViewport::new(width, height, 1.0))
.unwrap()
.unwrap();
}
let answers = GeometryProvider::answer(&mut *vm, &batch)
.expect("point query batch after viewport expansion");
assert_eq!(
matches!(answers.answers[0], LayoutQueryAnswer::HitTest(Some(_))),
expect_hit,
"point {point:?} in {width}x{height} after {initial_size:?}"
);
let LayoutQueryAnswer::HitTestAll(hits) = &answers.answers[1] else {
panic!("expected hit list")
};
assert_eq!(!hits.is_empty(), expect_hit);
let LayoutQueryAnswer::CaretPosition(position) = &answers.answers[2] else {
panic!("expected caret position")
};
assert_eq!(position.is_some(), expect_hit);
assert_eq!(
vm.layout_pass_observability_for_test().1,
before + expected_passes,
"the explicit output builds once; repeated queries consume its tree"
);
}
}
}
#[test]
fn document_point_queries_retain_published_geometry_after_viewport_resize() {
let mut vm = new_parsed_test_vm(
"https://document-point-query-viewport-expansion.test/",
"<html><body style='margin:0'><div id='target' style='width:50vw;height:100px'></div></body></html>",
);
let before = vm.layout_pass_observability_for_test().1;
let mut completed_passes = before;
let mut published = "[false,false,0]".to_owned();
let query = r#"JSON.stringify([
document.elementFromPoint(75, 20)?.id === 'target',
document.elementsFromPoint(75, 20).some(element => element.id === 'target'),
document.getElementById('target').getBoundingClientRect().width
])"#;
for (width, expected, expected_passes) in [
(100, "[false,false,50]", 1),
(320, "[true,true,160]", 2),
(100, "[false,false,50]", 3),
(320, "[true,true,160]", 4),
(320, "[true,true,160]", 4),
] {
vm.set_viewport_surface(Some(crate::protocol_types::ViewportSurface {
inner_width: width,
inner_height: 200,
device_pixel_ratio: 1.0,
..Default::default()
}))
.expect("point query viewport should update");
assert_eq!(
vm.layout_pass_observability_for_test().1,
completed_passes,
"changing the viewport alone must not trigger layout"
);
if completed_passes == before {
assert_eq!(vm.eval(query).unwrap(), expected);
completed_passes += 1;
} else {
assert_eq!(vm.eval(query).unwrap(), published);
}
assert_eq!(vm.layout_pass_observability_for_test().1, completed_passes);
if completed_passes < before + expected_passes {
vm.screenshot_layout_snapshot(moli_layout::LayoutViewport::new(width, 200, 1.0))
.unwrap()
.unwrap();
}
let result = vm
.eval(query)
.expect("document point queries after viewport resize");
assert_eq!(result, expected, "viewport width {width}");
assert_eq!(
vm.layout_pass_observability_for_test().1,
before + expected_passes,
"only explicit output refreshes the viewport-dependent geometry"
);
completed_passes = before + expected_passes;
published = result;
}
}
#[test]
fn geometry_queries_retain_published_screen_and_resolution_environment() {
// Exercise each entry point without first warming the other layout mode.
for hit_test in [false, true] {
let mut vm = new_parsed_test_vm(
"https://geometry-screen-environment.test/",
r#"<html><head><style>
body { margin: 0 }
#target { width: 100px; height: 100px }
@media (device-width: 1280px), (device-height: 720px), (resolution: 2dppx) {
#target { width: 200px }
}
</style></head><body><div id=target></div></body></html>"#,
);
let mut surface = crate::protocol_types::ViewportSurface {
inner_width: 800,
inner_height: 600,
device_pixel_ratio: 1.0,
..Default::default()
};
let before = vm.layout_pass_observability_for_test().1;
let mut published_width = "0".to_owned();
for (screen_width, screen_height, dpr, expected_width, expected_passes) in [
(1920, 1080, 1.0, 100, 1),
(1280, 1080, 1.0, 200, 2),
(1280, 1080, 1.0, 200, 2),
(1920, 1080, 1.0, 100, 3),
(1920, 720, 1.0, 200, 4),
(1920, 720, 1.0, 200, 4),
(1920, 1080, 1.0, 100, 5),
(1920, 1080, 2.0, 200, 6),
(1920, 1080, 2.0, 200, 6),
(1920, 1080, 1.0, 100, 7),
] {
surface.screen_width = screen_width;
surface.screen_height = screen_height;
surface.device_pixel_ratio = dpr;
// Moving the window changes the surface but not the style environment.
surface.window_x += 1;
let passes = vm.layout_pass_observability_for_test().1;
let cache = vm.layout_snapshot_cache_observability_for_test();
vm.set_viewport_surface(Some(surface))
.expect("screen environment should update");
assert_eq!(vm.layout_snapshot_cache_observability_for_test(), cache);
assert_eq!(
vm.layout_pass_observability_for_test().1,
passes,
"an environment update alone must not trigger layout"
);
if passes == before {
published_width = expected_width.to_string();
}
assert_eq!(
vm.eval("String(target.getBoundingClientRect().width)")
.unwrap(),
published_width
);
let after_read = passes + u64::from(passes == before);
assert_eq!(vm.layout_pass_observability_for_test().1, after_read);
if after_read < before + expected_passes {
vm.screenshot_layout_snapshot(moli_layout::LayoutViewport::new(
800, 600, dpr as f32,
))
.unwrap()
.unwrap();
}
let result = vm
.eval(if hit_test {
r#"JSON.stringify([
document.elementFromPoint(150, 20)?.id === 'target',
document.elementsFromPoint(150, 20).some(element => element.id === 'target'),
document.getElementById('target').getBoundingClientRect().width
])"#
} else {
"String(document.getElementById('target').getBoundingClientRect().width)"
})
.expect("geometry query after screen environment update");
published_width = expected_width.to_string();
let hit = expected_width == 200;
let expected = if hit_test {
format!("[{hit},{hit},{expected_width}]")
} else {
expected_width.to_string()
};
assert_eq!(
result, expected,
"screen {screen_width}x{screen_height}, DPR {dpr}"
);
assert_eq!(
vm.layout_pass_observability_for_test().1,
before + expected_passes,
"screen/resolution changes become visible only after explicit output"
);
}
}
}
#[test]
fn geometry_queries_retain_published_media_environment() {
use crate::protocol_types::EmulatedMediaOverrides;
for overrides in [
EmulatedMediaOverrides {
media: Some("print".to_owned()),
..Default::default()
},
EmulatedMediaOverrides {
color_scheme: Some("dark".to_owned()),
..Default::default()
},
EmulatedMediaOverrides {
reduced_motion: Some("reduce".to_owned()),
..Default::default()
},
] {
let mut vm = new_parsed_test_vm(
"https://geometry-media-environment.test/",
r#"<html><head><style>
body { margin: 0 }
#target { width: 100px; height: 100px }
@media print, (prefers-color-scheme: dark), (prefers-reduced-motion: reduce) {
#target { width: 200px }
}
</style></head><body><div id=target></div></body></html>"#,
);
let defaults = EmulatedMediaOverrides::default();
let before = vm.layout_pass_observability_for_test().1;
let mut published_width = "0".to_owned();
for (environment, expected_width, expected_passes) in [
(&defaults, 100, 1),
(&overrides, 200, 2),
(&overrides, 200, 2),
(&defaults, 100, 3),
] {
let passes = vm.layout_pass_observability_for_test().1;
vm.set_emulated_media(environment);
assert_eq!(vm.layout_pass_observability_for_test().1, passes);
if passes == before {
published_width = expected_width.to_string();
}
assert_eq!(
vm.eval("String(target.getBoundingClientRect().width)")
.unwrap(),
published_width
);
let after_read = passes + u64::from(passes == before);
assert_eq!(vm.layout_pass_observability_for_test().1, after_read);
if after_read < before + expected_passes {
vm.screenshot_layout_snapshot(moli_layout::LayoutViewport::new(800, 600, 1.0))
.unwrap()
.unwrap();
}
let result = vm
.eval(
r#"JSON.stringify([
document.elementFromPoint(150, 20)?.id === 'target',
document.getElementById('target').getBoundingClientRect().width
])"#,
)
.expect("geometry query after media environment update");
published_width = expected_width.to_string();
assert_eq!(
result,
format!("[{},{}]", expected_width == 200, expected_width)
);
assert_eq!(
vm.layout_pass_observability_for_test().1,
before + expected_passes,
"media changes become visible only after explicit output"
);
}
}
}
#[test]
fn document_point_queries_parse_webidl_coordinates() {
let mut vm = new_rendered_test_vm(
"https://document-point-query-webidl.test/path/index.html",
r#"<html><body>
<div id="target" style="width:100px;height:50px">target</div>
</body></html>"#,
);
let result = vm
.eval(
r#"
(() => {
const probe = callback => {
try {
const value = callback();
return value && value.localName ? value.localName : String(value);
} catch (error) {
return `throw:${error && error.name}`;
}
};
const arrayProbe = callback => {
try {
return callback().map((element) => element.localName).join(",");
} catch (error) {
return `throw:${error && error.name}`;
}
};
return JSON.stringify({
missingElement: probe(() => document.elementFromPoint()),
missingElements: arrayProbe(() => document.elementsFromPoint(1)),
symbolX: probe(() => document.elementFromPoint(Symbol(), 1)),
symbolY: arrayProbe(() => document.elementsFromPoint(1, Symbol())),
infinity: probe(() => document.elementFromPoint(Infinity, 1)),
stringCoordinates: probe(() => document.elementFromPoint("10", "10")),
objectCoordinates: arrayProbe(() => document.elementsFromPoint(
{ valueOf() { return 10; } },
{ valueOf() { return 10; } }
))
});
})()
"#,
)
.expect("document point query WebIDL probe should evaluate");
assert_eq!(
result,
r#"{"missingElement":"throw:TypeError","missingElements":"throw:TypeError","symbolX":"throw:TypeError","symbolY":"throw:TypeError","infinity":"throw:TypeError","stringCoordinates":"div","objectCoordinates":"div,body,html"}"#
);
}
#[test]
fn shadow_root_point_queries_retarget_real_layout_hits_to_the_tree_scope() {
let mut vm = new_rendered_test_vm(
"https://shadow-root-point-query.test/path/index.html",
r#"<html><head><style>
html, body { margin: 0; }
#host, #inside { display: block; width: 100px; height: 50px; }
</style></head><body><div id="host"></div></body></html>"#,
);
let result = eval_with_layout_publications(
&mut vm,
r#"
(function* () {
const host = document.getElementById('host');
const shadow = host.attachShadow({ mode: 'closed' });
shadow.innerHTML = '<span id="inside">text</span>';
yield; // Publish this scene before reading its geometry.
return [
document.elementFromPoint(1, 1)?.id,
document.elementsFromPoint(1, 1).map(element => element.id || element.localName).join(','),
shadow.elementFromPoint(1, 1)?.id,
shadow.elementsFromPoint(1, 1).map(element => element.id || element.localName).join(',')
].join('|');
})()
"#,
)
.expect("shadow root point queries should evaluate");
assert_eq!(result, "host|host,body,html|inside|inside,host,body,html");
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff