mirror of
https://github.com/lexmount/moli.git
synced 2026-09-28 00:01:34 +00:00
test(renderer): split DOM XHR test modules
This commit is contained in:
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 & 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<&">	 ',
|
||||
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
Reference in New Issue
Block a user