blob: 6e4434aaa42dd41089ea7f6b2bb372e0d3f1ba50 [file]
// Copyright 2026 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import {assert} from 'chai';
import {beforeEach, it} from 'mocha';
import sinon from 'sinon';
import * as SDK from '../../core/sdk/sdk.js';
import type * as Protocol from '../../generated/protocol.js';
import {createTarget, describeWithEnvironment} from '../../testing/EnvironmentHelpers.js';
import * as Elements from './elements.js';
describeWithEnvironment('DOMPath', () => {
let target: SDK.Target.Target;
let domModel: SDK.DOMModel.DOMModel;
beforeEach(() => {
target = createTarget();
domModel = target.model(SDK.DOMModel.DOMModel) as SDK.DOMModel.DOMModel;
});
interface NodeSpec {
nodeType: number;
nodeName: string;
localName?: string;
nodeValue?: string;
attributes?: Record<string, string>;
children?: NodeSpec[];
}
let nextNodeId = 1;
function buildPayload(spec: NodeSpec): Protocol.DOM.Node {
const id = nextNodeId++;
const attributes: string[] = [];
if (spec.attributes) {
for (const [key, value] of Object.entries(spec.attributes)) {
attributes.push(key, value);
}
}
return {
nodeId: id as Protocol.DOM.NodeId,
backendNodeId: id as Protocol.DOM.BackendNodeId,
nodeType: spec.nodeType,
nodeName: spec.nodeName,
localName: spec.localName ?? spec.nodeName.toLowerCase(),
nodeValue: spec.nodeValue ?? '',
attributes,
children: spec.children ? spec.children.map(buildPayload) : undefined,
childNodeCount: spec.children ? spec.children.length : 0,
};
}
function createDOMNode(spec: NodeSpec): SDK.DOMModel.DOMNode {
const payload = buildPayload(spec);
return SDK.DOMModel.DOMNode.create(domModel, null, false, payload);
}
const htmlSpec: NodeSpec = {
nodeType: Node.DOCUMENT_NODE,
nodeName: '#document',
children: [{
nodeType: Node.ELEMENT_NODE,
nodeName: 'HTML',
children: [
{nodeType: Node.ELEMENT_NODE, nodeName: 'HEAD', children: [{nodeType: Node.ELEMENT_NODE, nodeName: 'BASE'}]},
{
nodeType: Node.ELEMENT_NODE,
nodeName: 'BODY',
children: [
{nodeType: Node.ELEMENT_NODE, nodeName: 'ARTICLE'},
{nodeType: Node.ELEMENT_NODE, nodeName: 'ARTICLE'},
{nodeType: Node.ELEMENT_NODE, nodeName: 'INPUT', attributes: {type: 'number'}},
{
nodeType: Node.ELEMENT_NODE,
nodeName: 'DIV',
attributes: {id: 'ids'},
children: [
{nodeType: Node.ELEMENT_NODE, nodeName: 'DIV'},
{nodeType: Node.ELEMENT_NODE, nodeName: 'DIV', attributes: {id: 'inner-id'}},
{nodeType: Node.ELEMENT_NODE, nodeName: 'DIV', attributes: {id: '__proto__'}},
{nodeType: Node.ELEMENT_NODE, nodeName: 'DIV', attributes: {id: '#"ridiculous".id'}},
{nodeType: Node.ELEMENT_NODE, nodeName: 'DIV', attributes: {id: '\'quoted.value\''}},
{nodeType: Node.ELEMENT_NODE, nodeName: 'DIV', attributes: {id: '.foo.bar'}},
{nodeType: Node.ELEMENT_NODE, nodeName: 'DIV', attributes: {id: '-'}},
{nodeType: Node.ELEMENT_NODE, nodeName: 'DIV', attributes: {id: '-a'}},
{nodeType: Node.ELEMENT_NODE, nodeName: 'DIV', attributes: {id: '-0'}},
{nodeType: Node.ELEMENT_NODE, nodeName: 'DIV', attributes: {id: '7'}},
{nodeType: Node.ELEMENT_NODE, nodeName: 'DIV', attributes: {id: 'ид'}},
{nodeType: Node.ELEMENT_NODE, nodeName: 'DIV', attributes: {id: '#'}},
{nodeType: Node.ELEMENT_NODE, nodeName: 'DIV', attributes: {id: '#foo'}},
{nodeType: Node.ELEMENT_NODE, nodeName: 'DIV', attributes: {id: '##'}},
{nodeType: Node.ELEMENT_NODE, nodeName: 'DIV', attributes: {id: '#.#.#'}},
{nodeType: Node.ELEMENT_NODE, nodeName: 'DIV', attributes: {id: '_'}},
{nodeType: Node.ELEMENT_NODE, nodeName: 'DIV', attributes: {id: '{}'}},
{nodeType: Node.ELEMENT_NODE, nodeName: 'DIV', attributes: {id: '.fake-class'}},
{nodeType: Node.ELEMENT_NODE, nodeName: 'DIV', attributes: {id: 'foo.bar'}},
{nodeType: Node.ELEMENT_NODE, nodeName: 'DIV', attributes: {id: ':hover'}},
{nodeType: Node.ELEMENT_NODE, nodeName: 'DIV', attributes: {id: ':hover:focus:active'}},
{nodeType: Node.ELEMENT_NODE, nodeName: 'DIV', attributes: {id: '[attr=value]'}},
{nodeType: Node.ELEMENT_NODE, nodeName: 'DIV', attributes: {id: 'f/o/o'}},
{nodeType: Node.ELEMENT_NODE, nodeName: 'DIV', attributes: {id: 'foo'}}, // f\o\o -> foo
{nodeType: Node.ELEMENT_NODE, nodeName: 'DIV', attributes: {id: 'f*o*o'}},
{nodeType: Node.ELEMENT_NODE, nodeName: 'DIV', attributes: {id: 'f!o!o'}},
{nodeType: Node.ELEMENT_NODE, nodeName: 'DIV', attributes: {id: 'f\'o\'o'}},
{nodeType: Node.ELEMENT_NODE, nodeName: 'DIV', attributes: {id: 'f~o~o'}},
{nodeType: Node.ELEMENT_NODE, nodeName: 'DIV', attributes: {id: 'f+o+o'}},
{nodeType: Node.ELEMENT_NODE, nodeName: 'INPUT', attributes: {type: 'text', id: 'input-id'}},
{nodeType: Node.ELEMENT_NODE, nodeName: 'INPUT', attributes: {type: 'text'}},
{
nodeType: Node.ELEMENT_NODE,
nodeName: 'INPUT',
attributes: {type: 'something-invalid-\'-"-and-weird'},
},
{nodeType: Node.ELEMENT_NODE, nodeName: 'P'},
],
},
{
nodeType: Node.ELEMENT_NODE,
nodeName: 'DIV',
attributes: {id: 'classes'},
children: [
{nodeType: Node.ELEMENT_NODE, nodeName: 'DIV', attributes: {class: 'foo bar'}},
{nodeType: Node.ELEMENT_NODE, nodeName: 'DIV', attributes: {class: ' foo foo '}},
{nodeType: Node.ELEMENT_NODE, nodeName: 'DIV', attributes: {class: '.foo'}},
{nodeType: Node.ELEMENT_NODE, nodeName: 'DIV', attributes: {class: '.foo.bar'}},
{nodeType: Node.ELEMENT_NODE, nodeName: 'DIV', attributes: {class: '-'}},
{nodeType: Node.ELEMENT_NODE, nodeName: 'DIV', attributes: {class: '-a'}},
{nodeType: Node.ELEMENT_NODE, nodeName: 'DIV', attributes: {class: '-0'}},
{nodeType: Node.ELEMENT_NODE, nodeName: 'DIV', attributes: {class: '--a'}},
{nodeType: Node.ELEMENT_NODE, nodeName: 'DIV', attributes: {class: '---a'}},
{nodeType: Node.ELEMENT_NODE, nodeName: 'DIV', attributes: {class: '7'}},
{nodeType: Node.ELEMENT_NODE, nodeName: 'DIV', attributes: {class: 'класс'}},
{nodeType: Node.ELEMENT_NODE, nodeName: 'DIV', attributes: {class: '__proto__'}},
{nodeType: Node.ELEMENT_NODE, nodeName: 'DIV', attributes: {class: '__proto__ foo'}},
{nodeType: Node.ELEMENT_NODE, nodeName: 'DIV', attributes: {class: '#'}},
{nodeType: Node.ELEMENT_NODE, nodeName: 'DIV', attributes: {class: '#foo'}},
{nodeType: Node.ELEMENT_NODE, nodeName: 'DIV', attributes: {class: '##'}},
{nodeType: Node.ELEMENT_NODE, nodeName: 'DIV', attributes: {class: '#.#.#'}},
{nodeType: Node.ELEMENT_NODE, nodeName: 'DIV', attributes: {class: '_'}},
{nodeType: Node.ELEMENT_NODE, nodeName: 'DIV', attributes: {class: '{}'}},
{nodeType: Node.ELEMENT_NODE, nodeName: 'DIV', attributes: {class: ':hover'}},
{nodeType: Node.ELEMENT_NODE, nodeName: 'DIV', attributes: {class: ':hover:focus:active'}},
{nodeType: Node.ELEMENT_NODE, nodeName: 'DIV', attributes: {class: '[attr=value]'}},
{nodeType: Node.ELEMENT_NODE, nodeName: 'DIV', attributes: {class: 'f/o/o'}},
{nodeType: Node.ELEMENT_NODE, nodeName: 'DIV', attributes: {class: 'foo'}}, // f\o\o -> foo
{nodeType: Node.ELEMENT_NODE, nodeName: 'DIV', attributes: {class: 'f*o*o'}},
{nodeType: Node.ELEMENT_NODE, nodeName: 'DIV', attributes: {class: 'f!o!o'}},
{nodeType: Node.ELEMENT_NODE, nodeName: 'DIV', attributes: {class: 'f\'o\'o'}},
{nodeType: Node.ELEMENT_NODE, nodeName: 'DIV', attributes: {class: 'f~o~o'}},
{nodeType: Node.ELEMENT_NODE, nodeName: 'DIV', attributes: {class: 'f+o+o'}},
{nodeType: Node.ELEMENT_NODE, nodeName: 'SPAN', attributes: {class: 'bar'}},
{nodeType: Node.ELEMENT_NODE, nodeName: 'DIV', attributes: {id: 'id-with-class', class: 'moo'}},
{nodeType: Node.ELEMENT_NODE, nodeName: 'INPUT', attributes: {type: 'text', class: 'input-class-one'}},
{nodeType: Node.ELEMENT_NODE, nodeName: 'INPUT', attributes: {type: 'text', class: 'input-class-two'}},
],
},
{
nodeType: Node.ELEMENT_NODE,
nodeName: 'DIV',
attributes: {id: 'non-unique-classes'},
children: [
{nodeType: Node.ELEMENT_NODE, nodeName: 'SPAN', attributes: {class: 'c1'}},
{nodeType: Node.ELEMENT_NODE, nodeName: 'SPAN', attributes: {class: 'c1'}},
{nodeType: Node.ELEMENT_NODE, nodeName: 'SPAN', attributes: {class: 'c1 c2'}},
{nodeType: Node.ELEMENT_NODE, nodeName: 'SPAN', attributes: {class: 'c1 c2 c3'}},
{nodeType: Node.ELEMENT_NODE, nodeName: 'SPAN'},
{nodeType: Node.ELEMENT_NODE, nodeName: 'DIV', attributes: {class: 'c1'}},
{nodeType: Node.ELEMENT_NODE, nodeName: 'DIV', attributes: {class: 'c1 c2'}},
{nodeType: Node.ELEMENT_NODE, nodeName: 'DIV', attributes: {class: 'c3 c2'}},
{nodeType: Node.ELEMENT_NODE, nodeName: 'DIV', attributes: {class: 'c3 c4'}},
{nodeType: Node.ELEMENT_NODE, nodeName: 'DIV', attributes: {class: 'c1 c4'}},
{nodeType: Node.ELEMENT_NODE, nodeName: 'INPUT', attributes: {type: 'text', class: 'input-class'}},
{nodeType: Node.ELEMENT_NODE, nodeName: 'INPUT', attributes: {type: 'text', class: 'input-class'}},
{nodeType: Node.ELEMENT_NODE, nodeName: 'DIV'},
],
},
],
},
],
}],
};
const expectedPaths = [
'html',
'head',
'head > base',
'body',
'body > article:nth-child(1)',
'body > article:nth-child(2)',
'body > input[type=number]',
'#ids',
'#ids > div:nth-child(1)',
'#inner-id',
'#__proto__',
'#\\#\\"ridiculous\\"\\.id',
'#\\\'quoted\\.value\\\'',
'#\\.foo\\.bar',
'#\\-',
'#-a',
'#-\\30 ',
'#\\37 ',
'#ид',
'#\\#',
'#\\#foo',
'#\\#\\#',
'#\\#\\.\\#\\.\\#',
'#_',
'#\\{\\}',
'#\\.fake-class',
'#foo\\.bar',
'#\\:hover',
'#\\:hover\\:focus\\:active',
'#\\[attr\\=value\\]',
'#f\\/o\\/o',
'#foo',
'#f\\*o\\*o',
'#f\\!o\\!o',
'#f\\\'o\\\'o',
'#f\\~o\\~o',
'#f\\+o\\+o',
'#input-id',
'#ids > input[type=text]:nth-child(31)',
'#ids > input[type=something-invalid-\\\'-\\"-and-weird]:nth-child(32)',
'#ids > p',
'#classes',
'#classes > div.foo.bar',
'#classes > div:nth-child(2)',
'#classes > div.\\.foo',
'#classes > div.\\.foo\\.bar',
'#classes > div.\\-',
'#classes > div.-a',
'#classes > div.-\\30 ',
'#classes > div.--a',
'#classes > div.---a',
'#classes > div.\\37 ',
'#classes > div.класс',
'#classes > div:nth-child(12)',
'#classes > div.__proto__.foo',
'#classes > div.\\#',
'#classes > div.\\#foo',
'#classes > div.\\#\\#',
'#classes > div.\\#\\.\\#\\.\\#',
'#classes > div._',
'#classes > div.\\{\\}',
'#classes > div.\\:hover',
'#classes > div.\\:hover\\:focus\\:active',
'#classes > div.\\[attr\\=value\\]',
'#classes > div.f\\/o\\/o',
'#classes > div:nth-child(24)',
'#classes > div.f\\*o\\*o',
'#classes > div.f\\!o\\!o',
'#classes > div.f\\\'o\\\'o',
'#classes > div.f\\~o\\~o',
'#classes > div.f\\+o\\+o',
'#classes > span',
'#id-with-class',
'#classes > input.input-class-one',
'#classes > input.input-class-two',
'#non-unique-classes',
'#non-unique-classes > span:nth-child(1)',
'#non-unique-classes > span:nth-child(2)',
'#non-unique-classes > span:nth-child(3)',
'#non-unique-classes > span.c1.c2.c3',
'#non-unique-classes > span:nth-child(5)',
'#non-unique-classes > div:nth-child(6)',
'#non-unique-classes > div.c1.c2',
'#non-unique-classes > div.c3.c2',
'#non-unique-classes > div.c3.c4',
'#non-unique-classes > div.c1.c4',
'#non-unique-classes > input:nth-child(11)',
'#non-unique-classes > input:nth-child(12)',
'#non-unique-classes > div:nth-child(13)',
];
it('DOMNode.cssPath() matches expectations', () => {
const rootNode = createDOMNode(htmlSpec);
const paths: string[] = [];
function collectPaths(node: SDK.DOMModel.DOMNode) {
if (node.nodeType() === Node.ELEMENT_NODE) {
paths.push(Elements.DOMPath.cssPath(node, true));
}
const children = node.children();
if (children) {
for (const child of children) {
collectPaths(child);
}
}
}
const children = rootNode.children();
assert.exists(children);
for (const child of children!) {
collectPaths(child);
}
assert.deepEqual(paths, expectedPaths);
});
it('computes jsPath escaping quotes, special IDs, and class selectors', () => {
const rootNode = createDOMNode({
nodeType: Node.DOCUMENT_NODE,
nodeName: '#document',
children: [{
nodeType: Node.ELEMENT_NODE,
nodeName: 'HTML',
children: [{
nodeType: Node.ELEMENT_NODE,
nodeName: 'BODY',
children: [
{nodeType: Node.ELEMENT_NODE, nodeName: 'ARTICLE'},
{nodeType: Node.ELEMENT_NODE, nodeName: 'ARTICLE'},
{nodeType: Node.ELEMENT_NODE, nodeName: 'INPUT', attributes: {type: 'number'}},
{nodeType: Node.ELEMENT_NODE, nodeName: 'DIV', attributes: {id: 'inner-id'}},
{nodeType: Node.ELEMENT_NODE, nodeName: 'DIV', attributes: {id: '__proto__'}},
{nodeType: Node.ELEMENT_NODE, nodeName: 'DIV', attributes: {id: '#"ridiculous".id'}},
{nodeType: Node.ELEMENT_NODE, nodeName: 'DIV', attributes: {id: '\'quoted.value\''}},
{nodeType: Node.ELEMENT_NODE, nodeName: 'DIV', attributes: {id: ':hover'}},
{
nodeType: Node.ELEMENT_NODE,
nodeName: 'DIV',
attributes: {id: 'classes'},
children: [
{nodeType: Node.ELEMENT_NODE, nodeName: 'DIV', attributes: {class: 'foo bar'}},
{nodeType: Node.ELEMENT_NODE, nodeName: 'DIV', attributes: {class: 'baz'}},
],
},
],
}],
}],
});
const jsPaths: string[] = [];
const collect = (node: SDK.DOMModel.DOMNode) => {
if (node.nodeType() === Node.ELEMENT_NODE) {
jsPaths.push(Elements.DOMPath.jsPath(node, true));
}
node.children()?.forEach(collect);
};
collect(rootNode);
assert.deepEqual(jsPaths, [
'document.querySelector("html")',
'document.querySelector("body")',
'document.querySelector("body > article:nth-child(1)")',
'document.querySelector("body > article:nth-child(2)")',
'document.querySelector("body > input[type=number]")',
'document.querySelector("#inner-id")',
'document.querySelector("#__proto__")',
'document.querySelector("#\\\\#\\\\\\"ridiculous\\\\\\"\\\\.id")',
'document.querySelector("#\\\\\'quoted\\\\.value\\\\\'")',
'document.querySelector("#\\\\:hover")',
'document.querySelector("#classes")',
'document.querySelector("#classes > div.foo.bar")',
'document.querySelector("#classes > div.baz")',
]);
});
it('computes xPath with and without optimization for nested elements, sibling indices, and IDs', async () => {
const docNode = createDOMNode({
nodeType: Node.DOCUMENT_NODE,
nodeName: '#document',
children: [{
nodeType: Node.ELEMENT_NODE,
nodeName: 'HTML',
children: [{
nodeType: Node.ELEMENT_NODE,
nodeName: 'BODY',
children: [
{nodeType: Node.ELEMENT_NODE, nodeName: 'DIV'},
{
nodeType: Node.ELEMENT_NODE,
nodeName: 'DIV',
attributes: {id: 'anchor-id'},
children: [
{nodeType: Node.ELEMENT_NODE, nodeName: 'SPAN'},
{
nodeType: Node.ELEMENT_NODE,
nodeName: 'SPAN',
children: [{nodeType: Node.TEXT_NODE, nodeName: '#text', nodeValue: 'hello'}],
},
{nodeType: Node.COMMENT_NODE, nodeName: '#comment', nodeValue: 'note'},
],
},
],
}],
}],
});
const bodyNode = docNode.children()![0].children()![0];
const [firstDiv, secondDiv] = bodyNode.children()!;
const [firstSpan, secondSpan, commentNode] = secondDiv.children()!;
const textNode = secondSpan.children()![0];
assert.strictEqual(await Elements.DOMPath.xPath(docNode, true), '/');
assert.strictEqual(await Elements.DOMPath.xPath(firstDiv, false), '/html/body/div[1]');
assert.strictEqual(await Elements.DOMPath.xPath(firstDiv, true), '/html/body/div[1]');
assert.strictEqual(await Elements.DOMPath.xPath(secondDiv, false), '/html/body/div[2]');
assert.strictEqual(await Elements.DOMPath.xPath(secondDiv, true), '//*[@id="anchor-id"]');
assert.strictEqual(await Elements.DOMPath.xPath(firstSpan, false), '/html/body/div[2]/span[1]');
assert.strictEqual(await Elements.DOMPath.xPath(firstSpan, true), '//*[@id="anchor-id"]/span[1]');
assert.strictEqual(await Elements.DOMPath.xPath(secondSpan, false), '/html/body/div[2]/span[2]');
assert.strictEqual(await Elements.DOMPath.xPath(secondSpan, true), '//*[@id="anchor-id"]/span[2]');
assert.strictEqual(await Elements.DOMPath.xPath(textNode, false), '/html/body/div[2]/span[2]/text()');
assert.strictEqual(await Elements.DOMPath.xPath(textNode, true), '//*[@id="anchor-id"]/span[2]/text()');
assert.strictEqual(await Elements.DOMPath.xPath(commentNode, true), '//*[@id="anchor-id"]/comment()');
});
it('computes xPath text()[n] accounting for whitespace-only text siblings in the live DOM', async () => {
// In DOMModel (with default includeWhitespace: "none"), the leading "\n" text node is omitted,
// so the DIV only has STRONG and the " Bar" text node as children.
const docNode = createDOMNode({
nodeType: Node.DOCUMENT_NODE,
nodeName: '#document',
children: [{
nodeType: Node.ELEMENT_NODE,
nodeName: 'HTML',
children: [{
nodeType: Node.ELEMENT_NODE,
nodeName: 'BODY',
children: [{
nodeType: Node.ELEMENT_NODE,
nodeName: 'DIV',
attributes: {id: 'info'},
children: [
{
nodeType: Node.ELEMENT_NODE,
nodeName: 'STRONG',
children: [{nodeType: Node.TEXT_NODE, nodeName: '#text', nodeValue: 'Foo:'}],
},
{nodeType: Node.TEXT_NODE, nodeName: '#text', nodeValue: ' Bar'},
],
}],
}],
}],
});
const divNode = docNode.children()![0].children()![0].children()![0];
const [strongNode, barTextNode] = divNode.children()!;
const fooTextNode = strongNode.children()![0];
// Build the corresponding real DOM structure containing the leading whitespace-only text node
// (`<div>\n<strong>Foo:</strong> Bar</div>`) and execute the page function passed to `callFunction`
// against the live DOM nodes.
const liveDiv = document.createElement('div');
liveDiv.innerHTML = '\n<strong>Foo:</strong> Bar';
const liveBarText = liveDiv.childNodes[2];
const liveFooText = liveDiv.querySelector('strong')!.firstChild!;
sinon.stub(barTextNode, 'callFunction').callsFake(async fn => {
return {value: (fn as (this: Node) => unknown).call(liveBarText) as never};
});
sinon.stub(fooTextNode, 'callFunction').callsFake(async fn => {
return {value: (fn as (this: Node) => unknown).call(liveFooText) as never};
});
assert.strictEqual(await Elements.DOMPath.xPath(barTextNode, false), '/html/body/div/text()[2]');
assert.strictEqual(await Elements.DOMPath.xPath(barTextNode, true), '//*[@id="info"]/text()[2]');
assert.strictEqual(await Elements.DOMPath.xPath(fooTextNode, false), '/html/body/div/strong/text()');
assert.strictEqual(await Elements.DOMPath.xPath(fooTextNode, true), '//*[@id="info"]/strong/text()');
});
it('computes jsPath and xPath for nodes inside nested shadow roots', async () => {
const innerShadow = Object.assign(buildPayload({
nodeType: Node.DOCUMENT_FRAGMENT_NODE,
nodeName: '#document-fragment',
localName: '',
children: [{
nodeType: Node.ELEMENT_NODE,
nodeName: 'SPAN',
attributes: {id: 'deep-shadow-target'},
children: [{nodeType: Node.ELEMENT_NODE, nodeName: 'B'}],
}],
}),
{shadowRootType: 'open' as Protocol.DOM.ShadowRootType});
const innerHost =
Object.assign(buildPayload({nodeType: Node.ELEMENT_NODE, nodeName: 'DIV', attributes: {id: 'inner-host'}}),
{shadowRoots: [innerShadow]});
const outerShadow = Object.assign(
buildPayload({nodeType: Node.DOCUMENT_FRAGMENT_NODE, nodeName: '#document-fragment', localName: ''}),
{shadowRootType: 'open' as Protocol.DOM.ShadowRootType, children: [innerHost]});
const outerHost =
Object.assign(buildPayload({nodeType: Node.ELEMENT_NODE, nodeName: 'DIV', attributes: {id: 'outer-host'}}),
{shadowRoots: [outerShadow]});
const docPayload = buildPayload({
nodeType: Node.DOCUMENT_NODE,
nodeName: '#document',
children: [
{nodeType: Node.ELEMENT_NODE, nodeName: 'HTML', children: [{nodeType: Node.ELEMENT_NODE, nodeName: 'BODY'}]},
],
});
docPayload.children![0].children![0].children = [outerHost];
const doc = SDK.DOMModel.DOMNode.create(domModel, null, false, docPayload);
const deepTarget = doc.children()![0]
.children()![0]
.children()![0]
.shadowRoots()[0]
.children()![0]
.shadowRoots()[0]
.children()![0];
const deepChild = deepTarget.children()![0];
assert.isTrue(Elements.DOMPath.canGetJSPath(deepTarget));
assert.strictEqual(
Elements.DOMPath.jsPath(deepTarget, true),
'document.querySelector("#outer-host").shadowRoot.querySelector("#inner-host").shadowRoot.querySelector("#deep-shadow-target")');
assert.strictEqual(
Elements.DOMPath.jsPath(deepChild, true),
'document.querySelector("#outer-host").shadowRoot.querySelector("#inner-host").shadowRoot.querySelector("#deep-shadow-target > b")');
assert.strictEqual(await Elements.DOMPath.xPath(deepTarget, true), '//*[@id="deep-shadow-target"]');
assert.strictEqual(await Elements.DOMPath.xPath(deepChild, true), '//*[@id="deep-shadow-target"]/b');
});
it('computes xPath text()[n] across mixed text/CDATA siblings and document-level comment()[n]', async () => {
// Mirrors legacy elements/node-xpath (resources/node-xpath.xhtml).
const doc = createDOMNode({
nodeType: Node.DOCUMENT_NODE,
nodeName: '#document',
children: [
{nodeType: Node.COMMENT_NODE, nodeName: '#comment', nodeValue: ' Pre-comment '},
{
nodeType: Node.ELEMENT_NODE,
nodeName: 'html',
children: [
{
nodeType: Node.ELEMENT_NODE,
nodeName: 'head',
children: [{
nodeType: Node.ELEMENT_NODE,
nodeName: 'script',
children: [
{nodeType: Node.TEXT_NODE, nodeName: '#text', nodeValue: '\n// Comment\n//'},
{nodeType: Node.CDATA_SECTION_NODE, nodeName: '#cdata-section', nodeValue: '\nfunction f() {}\n//'},
],
}],
},
{
nodeType: Node.ELEMENT_NODE,
nodeName: 'body',
children: [
{nodeType: Node.ELEMENT_NODE, nodeName: 'div', attributes: {id: 'id1'}},
{nodeType: Node.ELEMENT_NODE, nodeName: 'div', attributes: {id: 'id2'}},
{
nodeType: Node.ELEMENT_NODE,
nodeName: 'div',
attributes: {id: 'container'},
children: [
{
nodeType: Node.ELEMENT_NODE,
nodeName: 'div',
attributes: {id: 'id3'},
children: [
{nodeType: Node.TEXT_NODE, nodeName: '#text', nodeValue: '3 Prefix '},
{
nodeType: Node.CDATA_SECTION_NODE,
nodeName: '#cdata-section',
nodeValue: '<greeting>Hello, world!</greeting>',
},
{nodeType: Node.TEXT_NODE, nodeName: '#text', nodeValue: ' Suffix'},
],
},
{
nodeType: Node.ELEMENT_NODE,
nodeName: 'div',
attributes: {id: 'id4'},
children: [{nodeType: Node.TEXT_NODE, nodeName: '#text', nodeValue: '4'}],
},
],
},
],
},
],
},
{nodeType: Node.COMMENT_NODE, nodeName: '#comment', nodeValue: ' Post-comment '},
],
});
const lines: string[] = [];
const dump = async (node: SDK.DOMModel.DOMNode) => {
lines.push(`${node.nodeName()} - ${await Elements.DOMPath.xPath(node, true)} - ${
await Elements.DOMPath.xPath(node, false)}`);
for (const child of node.children() ?? []) {
await dump(child);
}
};
await dump(doc);
assert.deepEqual(lines, [
'#document - / - /',
'#comment - /comment()[1] - /comment()[1]',
'html - /html - /html',
'head - /html/head - /html/head',
'script - /html/head/script - /html/head/script',
'#text - /html/head/script/text()[1] - /html/head/script/text()[1]',
'#cdata-section - /html/head/script/text()[2] - /html/head/script/text()[2]',
'body - /html/body - /html/body',
'div - //*[@id="id1"] - /html/body/div[1]',
'div - //*[@id="id2"] - /html/body/div[2]',
'div - //*[@id="container"] - /html/body/div[3]',
'div - //*[@id="id3"] - /html/body/div[3]/div[1]',
'#text - //*[@id="id3"]/text()[1] - /html/body/div[3]/div[1]/text()[1]',
'#cdata-section - //*[@id="id3"]/text()[2] - /html/body/div[3]/div[1]/text()[2]',
'#text - //*[@id="id3"]/text()[3] - /html/body/div[3]/div[1]/text()[3]',
'div - //*[@id="id4"] - /html/body/div[3]/div[2]',
'#text - //*[@id="id4"]/text() - /html/body/div[3]/div[2]/text()',
'#comment - /comment()[2] - /comment()[2]',
]);
});
it('computes non-optimized xPath and jsPath across shadow boundaries', async () => {
// Mirrors legacy elements/shadow/inspect-deep-shadow-element.
function shadowTree(targetTag: string, targetId: string): Protocol.DOM.Node {
return Object.assign(buildPayload({
nodeType: Node.DOCUMENT_FRAGMENT_NODE,
nodeName: '#document-fragment',
localName: '',
children: [{
nodeType: Node.ELEMENT_NODE,
nodeName: 'DIV',
children: [{
nodeType: Node.ELEMENT_NODE,
nodeName: 'DIV',
children: [{
nodeType: Node.ELEMENT_NODE,
nodeName: targetTag,
attributes: {id: targetId},
children: [{nodeType: Node.TEXT_NODE, nodeName: '#text', nodeValue: 'Shadow'}],
}],
}],
}],
}),
{shadowRootType: 'open' as Protocol.DOM.ShadowRootType});
}
const host = Object.assign(buildPayload({nodeType: Node.ELEMENT_NODE, nodeName: 'DIV', attributes: {id: 'host'}}),
{shadowRoots: [shadowTree('SPAN', 'shadow')]});
const hostOpen =
Object.assign(buildPayload({nodeType: Node.ELEMENT_NODE, nodeName: 'SPAN', attributes: {id: 'hostOpen'}}),
{shadowRoots: [shadowTree('SPAN', 'shadow-open')]});
const docPayload = buildPayload({
nodeType: Node.DOCUMENT_NODE,
nodeName: '#document',
children: [{
nodeType: Node.ELEMENT_NODE,
nodeName: 'HTML',
children: [{
nodeType: Node.ELEMENT_NODE,
nodeName: 'BODY',
children: [{
nodeType: Node.ELEMENT_NODE,
nodeName: 'DIV',
children: [{nodeType: Node.ELEMENT_NODE, nodeName: 'DIV'}],
}],
}],
}],
});
const innerDivPayload = docPayload.children![0].children![0].children![0].children![0];
innerDivPayload.children = [host, hostOpen];
innerDivPayload.childNodeCount = 2;
const doc = SDK.DOMModel.DOMNode.create(domModel, null, false, docPayload);
const innerDiv = doc.children()![0].children()![0].children()![0].children()![0];
const [hostNode, hostOpenNode] = innerDiv.children()!;
const target = hostNode.shadowRoots()[0].children()![0].children()![0].children()![0];
const targetOpen = hostOpenNode.shadowRoots()[0].children()![0].children()![0].children()![0];
assert.strictEqual(target.getAttribute('id'), 'shadow');
assert.strictEqual(targetOpen.getAttribute('id'), 'shadow-open');
assert.strictEqual(await Elements.DOMPath.xPath(target, false), '/html/body/div/div/div//div/div/span');
assert.strictEqual(Elements.DOMPath.jsPath(target, false),
'document.querySelector("div#host").shadowRoot.querySelector("span#shadow")');
assert.strictEqual(await Elements.DOMPath.xPath(targetOpen, false), '/html/body/div/div/span//div/div/span');
assert.strictEqual(Elements.DOMPath.jsPath(targetOpen, false),
'document.querySelector("span#hostOpen").shadowRoot.querySelector("span#shadow-open")');
});
it('computes simpleSelector for tags, IDs, classes, and input types', () => {
const container = createDOMNode({
nodeType: Node.ELEMENT_NODE,
nodeName: 'SECTION',
children: [
{nodeType: Node.ELEMENT_NODE, nodeName: 'SPAN'},
{nodeType: Node.ELEMENT_NODE, nodeName: 'DIV'},
{nodeType: Node.ELEMENT_NODE, nodeName: 'DIV', attributes: {id: 'header'}},
{nodeType: Node.ELEMENT_NODE, nodeName: 'DIV', attributes: {class: 'class1 class2'}},
{nodeType: Node.ELEMENT_NODE, nodeName: 'SPAN', attributes: {class: 'class1 class2'}},
{nodeType: Node.ELEMENT_NODE, nodeName: 'INPUT', attributes: {type: 'text'}},
{nodeType: Node.ELEMENT_NODE, nodeName: 'INPUT', attributes: {type: 'checkbox', id: 'agree'}},
{nodeType: Node.ELEMENT_NODE, nodeName: 'INPUT', attributes: {type: 'submit', class: 'primary'}},
],
});
assert.deepEqual(container.children()!.map(node => node.simpleSelector()), [
'span',
'div',
'div#header',
'.class1.class2',
'span.class1.class2',
'input[type="text"]',
'input#agree',
'input.primary',
]);
});
it('computes valid CSS selectors for pseudo-elements', () => {
const divNode = SDK.DOMModel.DOMNode.create(domModel, null, false, {
nodeId: 100 as Protocol.DOM.NodeId,
backendNodeId: 100 as Protocol.DOM.BackendNodeId,
nodeType: Node.ELEMENT_NODE,
nodeName: 'DIV',
localName: 'div',
nodeValue: '',
attributes: ['id', 'target'],
pseudoElements: [
{
nodeId: 101 as Protocol.DOM.NodeId,
backendNodeId: 101 as Protocol.DOM.BackendNodeId,
nodeType: Node.ELEMENT_NODE,
nodeName: '::before',
localName: '::before',
nodeValue: '',
pseudoType: 'before' as Protocol.DOM.PseudoType,
},
{
nodeId: 102 as Protocol.DOM.NodeId,
backendNodeId: 102 as Protocol.DOM.BackendNodeId,
nodeType: Node.ELEMENT_NODE,
nodeName: '::after',
localName: '::after',
nodeValue: '',
pseudoType: 'after' as Protocol.DOM.PseudoType,
},
],
});
const beforeNode = divNode.beforePseudoElement();
assert.exists(beforeNode);
assert.strictEqual(Elements.DOMPath.cssPath(beforeNode, true), '#target::before');
const afterNode = divNode.afterPseudoElement();
assert.exists(afterNode);
assert.strictEqual(Elements.DOMPath.cssPath(afterNode, true), '#target::after');
});
it('escapes pseudo-element identifiers in CSS selectors', () => {
const divNode = SDK.DOMModel.DOMNode.create(domModel, null, false, {
nodeId: 100 as Protocol.DOM.NodeId,
backendNodeId: 100 as Protocol.DOM.BackendNodeId,
nodeType: Node.ELEMENT_NODE,
nodeName: 'DIV',
localName: 'div',
nodeValue: '',
attributes: ['id', 'target'],
pseudoElements: [
{
nodeId: 101 as Protocol.DOM.NodeId,
backendNodeId: 101 as Protocol.DOM.BackendNodeId,
nodeType: Node.ELEMENT_NODE,
nodeName: '::view-transition-group',
localName: '::view-transition-group',
nodeValue: '',
pseudoType: 'view-transition-group' as Protocol.DOM.PseudoType,
pseudoIdentifier: 'page transition',
},
{
nodeId: 102 as Protocol.DOM.NodeId,
backendNodeId: 102 as Protocol.DOM.BackendNodeId,
nodeType: Node.ELEMENT_NODE,
nodeName: '::view-transition-group',
localName: '::view-transition-group',
nodeValue: '',
pseudoType: 'view-transition-group' as Protocol.DOM.PseudoType,
pseudoIdentifier: '123.foo',
},
],
});
const vtGroupNodes = divNode.pseudoElements().get('view-transition-group');
assert.exists(vtGroupNodes);
assert.lengthOf(vtGroupNodes, 2);
assert.strictEqual(Elements.DOMPath.cssPath(vtGroupNodes[0], true),
'#target::view-transition-group(page\\ transition)');
assert.strictEqual(Elements.DOMPath.cssPath(vtGroupNodes[1], true),
'#target::view-transition-group(\\31 23\\.foo)');
});
it('computes valid CSS selectors for nested view-transition pseudo-elements', () => {
const htmlNode = SDK.DOMModel.DOMNode.create(domModel, null, false, {
nodeId: 200 as Protocol.DOM.NodeId,
backendNodeId: 200 as Protocol.DOM.BackendNodeId,
nodeType: Node.ELEMENT_NODE,
nodeName: 'HTML',
localName: 'html',
nodeValue: '',
pseudoElements: [
{
nodeId: 201 as Protocol.DOM.NodeId,
backendNodeId: 201 as Protocol.DOM.BackendNodeId,
nodeType: Node.ELEMENT_NODE,
nodeName: '::view-transition',
localName: '::view-transition',
nodeValue: '',
pseudoType: 'view-transition' as Protocol.DOM.PseudoType,
pseudoElements: [
{
nodeId: 202 as Protocol.DOM.NodeId,
backendNodeId: 202 as Protocol.DOM.BackendNodeId,
nodeType: Node.ELEMENT_NODE,
nodeName: '::view-transition-group',
localName: '::view-transition-group',
nodeValue: '',
pseudoType: 'view-transition-group' as Protocol.DOM.PseudoType,
pseudoIdentifier: 'root',
pseudoElements: [
{
nodeId: 203 as Protocol.DOM.NodeId,
backendNodeId: 203 as Protocol.DOM.BackendNodeId,
nodeType: Node.ELEMENT_NODE,
nodeName: '::view-transition-image-pair',
localName: '::view-transition-image-pair',
nodeValue: '',
pseudoType: 'view-transition-image-pair' as Protocol.DOM.PseudoType,
pseudoIdentifier: 'root',
pseudoElements: [
{
nodeId: 204 as Protocol.DOM.NodeId,
backendNodeId: 204 as Protocol.DOM.BackendNodeId,
nodeType: Node.ELEMENT_NODE,
nodeName: '::view-transition-old',
localName: '::view-transition-old',
nodeValue: '',
pseudoType: 'view-transition-old' as Protocol.DOM.PseudoType,
pseudoIdentifier: 'root',
},
],
},
],
},
],
},
],
});
const transitionNode = htmlNode.viewTransitionPseudoElements().find(n => n.pseudoType() === 'view-transition');
assert.exists(transitionNode);
const groupNode =
transitionNode.viewTransitionPseudoElements().find(n => n.pseudoType() === 'view-transition-group');
assert.exists(groupNode);
const imagePairNode =
groupNode.viewTransitionPseudoElements().find(n => n.pseudoType() === 'view-transition-image-pair');
assert.exists(imagePairNode);
const oldNode = imagePairNode.viewTransitionPseudoElements().find(n => n.pseudoType() === 'view-transition-old');
assert.exists(oldNode);
assert.strictEqual(Elements.DOMPath.cssPath(transitionNode, true), 'html::view-transition');
assert.strictEqual(Elements.DOMPath.cssPath(groupNode, true), 'html::view-transition-group(root)');
assert.strictEqual(Elements.DOMPath.cssPath(imagePairNode, true), 'html::view-transition-image-pair(root)');
assert.strictEqual(Elements.DOMPath.cssPath(oldNode, true), 'html::view-transition-old(root)');
});
});