Skip to content

Commit 69e50c3

Browse files
danilsomsikovDevtools-frontend LUCI CQ
authored and
Devtools-frontend LUCI CQ
committed
[test] Implement Node.prototype.deepInnerText
This replaces `deepTextContent` which could be problematic when used to extract text from DOM elements. `deepTextContent` would recursively concatenate all `textContent` from child nodes, which contains insignificant whitespace but doesn't have whitespace implied by the layout context, e.g. for `<div> <div>a</div><div>b</div> </div>` textContent is ` ab ` while innerText is `a\nb` `deepInnerText` tries to replicate this behavior as close as possible but also support shadow DOM, slots and non-Element nodes. The implementation detects the largest subtree where `innerText` would return expected result, i.e. those without shadow DOM and slots. It than joins innerText (or trimmed textContent for non-Elements) with a newline. This is not entirely correct, as it would result in `a\nb` for `<devtools-checkbox>a</devtools-checkbox> <devtools-checkbox>b</devtools-checkbox>` while it would render as `[ ]a [ ]b`, but this is something we can live with. The new function is then applied in some e2e tests where previously there were problems. Bug: 407751668 Change-Id: Ifce3e28b80c3493f25c806ae9e0ba9ea28aba091 Reviewed-on: https://chromium-review.googlesource.com/c/devtools/devtools-frontend/+/6563377 Auto-Submit: Danil Somsikov <dsv@chromium.org> Commit-Queue: Danil Somsikov <dsv@chromium.org> Reviewed-by: Philip Pfaffe <pfaffe@chromium.org>
1 parent 79db863 commit 69e50c3

File tree

5 files changed

+161
-24
lines changed

5 files changed

+161
-24
lines changed

front_end/core/dom_extension/DOMExtension.test.ts

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44

55
import './dom_extension.js';
66

7+
import {renderElementIntoDOM} from '../../testing/DOMHelpers.js';
8+
79
function createSlot(parent: HTMLElement, name?: string) {
810
const slot = parent.createChild('slot');
911
if (name) {
@@ -233,3 +235,109 @@ describe('DataGrid', () => {
233235
assert.strictEqual(node.nodeName, '#text');
234236
});
235237
});
238+
239+
describe('Node.prototype.deepInnerText', () => {
240+
it('gets text from a simple element', () => {
241+
const element = document.createElement('div');
242+
element.textContent = 'Simple text';
243+
renderElementIntoDOM(element);
244+
assert.strictEqual(element.deepInnerText(), 'Simple text');
245+
});
246+
247+
it('gets text from an element with multiple children', () => {
248+
const element = document.createElement('div');
249+
element.createChild('p').textContent = 'First child';
250+
element.createChild('span').textContent = 'Second child';
251+
renderElementIntoDOM(element);
252+
assert.strictEqual(element.deepInnerText(), element.innerText);
253+
assert.strictEqual(element.deepInnerText(), 'First child\n\nSecond child');
254+
});
255+
256+
it('gets text from an element with nested children', () => {
257+
const element = document.createElement('div');
258+
element.appendChild(document.createTextNode(' Outer text. '));
259+
const childDiv = element.createChild('div');
260+
childDiv.textContent = ' Child text. '; // innerText of childDiv would be "Child text. Grandchild text."
261+
const grandchildSpan = childDiv.createChild('span');
262+
grandchildSpan.textContent = 'Grandchild text.';
263+
renderElementIntoDOM(element);
264+
assert.strictEqual(element.deepInnerText(), element.innerText);
265+
assert.strictEqual(element.deepInnerText(), 'Outer text.\nChild text. Grandchild text.');
266+
});
267+
268+
it('gets text from an element with a shadow DOM', () => {
269+
const element = document.createElement('div');
270+
const shadow = element.attachShadow({mode: 'open'});
271+
shadow.createChild('p').textContent = 'Shadow text';
272+
renderElementIntoDOM(element);
273+
assert.strictEqual(element.deepInnerText(), 'Shadow text');
274+
});
275+
276+
it('gets text from an element with a shadow DOM and slotted content', () => {
277+
const element = document.createElement('div');
278+
element.createChild('span').textContent = 'Slotted content';
279+
280+
const shadow = element.attachShadow({mode: 'open'});
281+
shadow.appendChild(document.createTextNode('Shadow text before slot. '));
282+
shadow.createChild('slot');
283+
shadow.createChild('p').textContent = 'Shadow text after slot.';
284+
285+
renderElementIntoDOM(element);
286+
const expectedText = 'Shadow text before slot.\nSlotted content\nShadow text after slot.';
287+
assert.strictEqual(element.deepInnerText(), expectedText);
288+
});
289+
290+
it('gets text from an element with multiple shadow DOMs and regular siblings', () => {
291+
const element = document.createElement('div');
292+
element.appendChild(document.createTextNode('Light DOM text before shadow 1. '));
293+
const shadow1 = element.createChild('div').attachShadow({mode: 'open'});
294+
const shadow1Paragraph1 = shadow1.createChild('p');
295+
shadow1Paragraph1.createChild('span').textContent = 'Shadow 1';
296+
shadow1Paragraph1.createChild('span').textContent = '(1)';
297+
const shadow1Paragraph2 = shadow1.createChild('p');
298+
shadow1Paragraph2.createChild('span').textContent = 'Shadow 1';
299+
shadow1Paragraph2.createChild('span').textContent = '(2)';
300+
element.appendChild(document.createTextNode(' Light DOM text between shadows. '));
301+
const shadow2 = element.createChild('div').attachShadow({mode: 'open'});
302+
shadow2.createChild('span').textContent = 'Shadow 2 text.';
303+
element.appendChild(document.createTextNode(' Light DOM text after shadow 2.'));
304+
305+
renderElementIntoDOM(element);
306+
const expectedText =
307+
'Light DOM text before shadow 1.\nShadow 1(1)\nShadow 1(2)\nLight DOM text between shadows.\nShadow 2 text.\nLight DOM text after shadow 2.';
308+
assert.strictEqual(element.deepInnerText(), expectedText);
309+
});
310+
311+
it('returns empty string for an element with no text content', () => {
312+
const element = document.createElement('div');
313+
renderElementIntoDOM(element);
314+
assert.strictEqual(element.deepInnerText(), '');
315+
});
316+
317+
it('ignores text content within SCRIPT tags', () => {
318+
const element = document.createElement('div');
319+
element.innerHTML = 'Visible text<script>console.log("script text")</script>';
320+
renderElementIntoDOM(element);
321+
assert.strictEqual(element.deepInnerText(), 'Visible text');
322+
});
323+
324+
it('ignores text content within STYLE tags', () => {
325+
const element = document.createElement('div');
326+
element.innerHTML = 'Visible text<style>body { color: red; }</style>';
327+
renderElementIntoDOM(element);
328+
assert.strictEqual(element.deepInnerText(), 'Visible text');
329+
});
330+
331+
it('gets text when called directly on a TextNode', () => {
332+
const textNode = document.createTextNode('Direct text node content');
333+
assert.strictEqual(textNode.deepInnerText(), 'Direct text node content');
334+
});
335+
336+
it('handles elements that only contain other elements which produce text', () => {
337+
const element = document.createElement('div');
338+
const child = element.createChild('p');
339+
child.textContent = 'Paragraph text';
340+
renderElementIntoDOM(element);
341+
assert.strictEqual(element.deepInnerText(), 'Paragraph text');
342+
});
343+
});

front_end/core/dom_extension/DOMExtension.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -261,6 +261,41 @@ Node.prototype.childTextNodes = function(): Node[] {
261261
return result;
262262
};
263263

264+
function innerTextDescendants(node: Node): Node[] {
265+
if (![Node.ELEMENT_NODE, Node.TEXT_NODE].includes(node.nodeType) || ['SCRIPT', 'STYLE'].includes(node.nodeName)) {
266+
return [];
267+
}
268+
if (!(node instanceof HTMLElement)) {
269+
return [node];
270+
}
271+
if (node instanceof HTMLSlotElement) {
272+
return [...node.assignedNodes()].flatMap(innerTextDescendants);
273+
}
274+
if (node.shadowRoot) {
275+
return [...node.shadowRoot.childNodes].flatMap(innerTextDescendants);
276+
}
277+
const result: Node[] = [];
278+
let expanded = false;
279+
for (const child of node.childNodes) {
280+
const childResult = innerTextDescendants(child);
281+
if (childResult.length > 1 || childResult.length === 1 && childResult[0] !== child) {
282+
expanded = true;
283+
}
284+
result.push(...childResult);
285+
}
286+
if (!expanded) {
287+
return [node];
288+
}
289+
return result;
290+
}
291+
292+
Node.prototype.deepInnerText = function(): string {
293+
return innerTextDescendants(this)
294+
.map(n => n instanceof HTMLElement ? n.innerText : n.textContent.trim())
295+
.filter(Boolean)
296+
.join('\n');
297+
};
298+
264299
Node.prototype.isAncestor = function(node: Node|null): boolean {
265300
if (!node) {
266301
return false;

front_end/legacy/legacy-defs.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,7 @@ interface Node {
9090
traverseNextNode(stayWithin?: Node): Node|null;
9191
traversePreviousNode(stayWithin?: Node): Node|null;
9292
deepTextContent(normalizeWhitespace?: boolean): string;
93+
deepInnerText(): string;
9394
window(): Window;
9495
childTextNodes(): Node[];
9596
}

test/e2e/elements/at-property-sections_test.ts

Lines changed: 9 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -45,9 +45,9 @@ describe('The styles pane', () => {
4545

4646
const popover = await waitFor(':popover-open devtools-css-variable-parser-error');
4747
const firstSection = await waitFor('.variable-value-popup-wrapper', popover);
48-
const popoverContents = (await firstSection.evaluate(e => e.textContent))?.trim()?.replaceAll(/\s\s+/g, ', ');
48+
const popoverContents = await firstSection.evaluate(e => e.deepInnerText());
4949

50-
assert.deepEqual(popoverContents, 'Invalid property value, expected type "<color>", View registered property');
50+
assert.deepEqual(popoverContents, 'Invalid property value, expected type "<color>"\nView registered property');
5151
});
5252

5353
it('shows registered properties', async () => {
@@ -126,16 +126,9 @@ describe('The styles pane', () => {
126126
}
127127

128128
const popover = await waitFor(':popover-open devtools-css-variable-value-view');
129-
const firstSection = await waitFor('.variable-value-popup-wrapper', popover);
130-
const textContent = await firstSection.evaluate((e: Element|null) => {
131-
const results = [];
132-
while (e) {
133-
results.push(e.textContent);
134-
e = e.nextElementSibling;
135-
}
136-
return results;
129+
const popoverContents = await popover.evaluate((e: Element|null) => {
130+
return e?.deepInnerText();
137131
});
138-
const popoverContents = textContent.join(' ').trim().replaceAll(/\s\s+/g, ', ');
139132

140133
await hover(ELEMENTS_PANEL_SELECTOR);
141134
await waitForNone(':popover-open devtools-css-variable-value-view');
@@ -146,21 +139,21 @@ describe('The styles pane', () => {
146139

147140
assert.strictEqual(
148141
await hoverVariable('var(--my-cssom-color)'),
149-
'orange, syntax: "<color>", inherits: false, initial-value: orange, View registered property');
142+
'orange\nsyntax: "<color>"\ninherits: false\ninitial-value: orange\nView registered property');
150143

151144
assert.strictEqual(
152145
await hoverVariable('--my-color'),
153-
'red, syntax: "<color>", inherits: false, initial-value: red, View registered property');
146+
'red\nsyntax: "<color>"\ninherits: false\ninitial-value: red\nView registered property');
154147
assert.strictEqual(
155148
await hoverVariable('var(--my-color)'),
156-
'red, syntax: "<color>", inherits: false, initial-value: red, View registered property');
149+
'red\nsyntax: "<color>"\ninherits: false\ninitial-value: red\nView registered property');
157150

158151
assert.strictEqual(
159152
await hoverVariable('--my-color2'),
160-
'gray, syntax: "<color>", inherits: false, initial-value: #c0ffee, View registered property');
153+
'gray\nsyntax: "<color>"\ninherits: false\ninitial-value: #c0ffee\nView registered property');
161154
assert.strictEqual(
162155
await hoverVariable('var(--my-color2)'),
163-
'gray, syntax: "<color>", inherits: false, initial-value: #c0ffee, View registered property');
156+
'gray\nsyntax: "<color>"\ninherits: false\ninitial-value: #c0ffee\nView registered property');
164157

165158
assert.strictEqual(await hoverVariable('--my-other-color'), 'green');
166159
assert.strictEqual(await hoverVariable('var(--my-other-color)'), 'green');

test/e2e/elements/style-pane-properties_test.ts

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -179,10 +179,10 @@ describe('The Styles pane', () => {
179179
await hover('.hint-wrapper');
180180

181181
const infobox = await waitFor(':popover-open');
182-
const textContent: string = await infobox.evaluate(e => e.deepTextContent());
182+
const textContent: string = await infobox.evaluate(e => e.deepInnerText());
183183
assert.strictEqual(
184-
textContent.replaceAll(/\s+/g, ' ').trim(),
185-
'The display: block property prevents grid-column-end from having an effect. Try setting display to something other than block.');
184+
textContent,
185+
'The display: block property prevents grid-column-end from having an effect.\nTry setting display to something other than block.');
186186
await expectVeEvents([veImpressionsUnder(
187187
'Panel: elements > Pane: styles > Section: style-properties > Tree > TreeItem: grid-column-end',
188188
[veImpression('Popover', 'elements.css-hint')])]);
@@ -205,7 +205,7 @@ describe('The Styles pane', () => {
205205
await hover('.exclamation-mark');
206206

207207
const infobox = await waitFor(':popover-open');
208-
const textContent: string = await infobox.evaluate(e => e.deepTextContent());
208+
const textContent: string = await infobox.evaluate(e => e.deepInnerText());
209209
assert.strictEqual(
210210
textContent.replaceAll(/\s+/g, ' ').trim(),
211211
'Invalid property value, expected type "<color>" View registered property');
@@ -225,7 +225,7 @@ describe('The Styles pane', () => {
225225
await hover('.link-swatch-link', {root: testElementRule});
226226

227227
const infobox = await waitFor('[aria-label="CSS property value: var(--title-color)"] :popover-open');
228-
const textContent = await infobox.evaluate(e => e.deepTextContent());
228+
const textContent = await infobox.evaluate(e => e.deepInnerText());
229229
assert.strictEqual(textContent.trim(), 'black');
230230
await expectVeEvents([veImpressionsUnder(
231231
'Panel: elements > Pane: styles > Section: style-properties > Tree > TreeItem: color > Value > Link: css-variable',
@@ -244,7 +244,7 @@ describe('The Styles pane', () => {
244244
await hover('aria/CSS property name: --color');
245245

246246
const infobox = await waitFor('.tree-outline :popover-open');
247-
const textContent = await infobox.evaluate(e => e.deepTextContent());
247+
const textContent = await infobox.evaluate(e => e.deepInnerText());
248248
assert.strictEqual(textContent.trim(), 'red');
249249
await expectVeEvents([veImpressionsUnder(
250250
'Panel: elements > Pane: styles > Section: style-properties > Tree > TreeItem: custom-property > Key',
@@ -263,7 +263,7 @@ describe('The Styles pane', () => {
263263
await hover('devtools-color-mix-swatch');
264264

265265
const infobox = await waitFor('[aria-label="CSS property value: color-mix(in srgb, red, blue)"] :popover-open');
266-
const textContent = await infobox.evaluate(e => e.deepTextContent());
266+
const textContent = await infobox.evaluate(e => e.deepInnerText());
267267
assert.strictEqual(textContent.trim(), '#800080');
268268
await expectVeEvents([veImpressionsUnder(
269269
'Panel: elements > Pane: styles > Section: style-properties > Tree > TreeItem: color > Value',
@@ -282,7 +282,7 @@ describe('The Styles pane', () => {
282282
await hover('text/1em', {root: await waitForAria('CSS property value: 1em')});
283283

284284
const infobox = await waitFor('[aria-label="CSS property value: 1em"] :popover-open');
285-
const textContent = await infobox.evaluate(e => e.deepTextContent());
285+
const textContent = await infobox.evaluate(e => e.deepInnerText());
286286
assert.strictEqual(textContent.trim(), '16px');
287287
await expectVeEvents([veImpressionsUnder(
288288
'Panel: elements > Pane: styles > Section: style-properties > Tree > TreeItem: width > Value',

0 commit comments

Comments
 (0)