Skip to content

Commit 7d1cb5a

Browse files
authored
core(dom): introduce safelySetHref (#12796)
1 parent 31bfccd commit 7d1cb5a

12 files changed

+230
-143
lines changed

lighthouse-core/test/lib/page-functions-test.js

Lines changed: 72 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -15,33 +15,32 @@ let assert;
1515
let jsdom;
1616
/** @type {import('../../lib/page-functions.js')} */
1717
let pageFunctions;
18-
/** @type {import('../../../report/renderer/dom.js').DOM} */
19-
let DOM;
2018

2119
/* eslint-env jest */
2220

21+
/* global document */
22+
2323
describe('Page Functions', () => {
2424
const url = 'http://www.example.com';
25-
let dom;
2625

2726
beforeAll(async () => {
2827
assert = (await import('assert')).strict;
2928
jsdom = await import('jsdom');
3029
pageFunctions = (await import('../../lib/page-functions.js')).default;
31-
DOM = (await import('../../../report/renderer/dom.js')).DOM;
3230

3331
const {document, ShadowRoot, Node, HTMLElement} = new jsdom.JSDOM('', {url}).window;
3432
global.ShadowRoot = ShadowRoot;
3533
global.Node = Node;
3634
global.HTMLElement = HTMLElement;
35+
global.document = document;
3736
global.window = {};
38-
dom = new DOM(document);
3937
});
4038

4139
afterAll(() => {
4240
global.ShadowRoot = undefined;
4341
global.Node = undefined;
4442
global.window = undefined;
43+
global.document = undefined;
4544
});
4645

4746
describe('wrapRuntimeEvalErrorInBrowser()', () => {
@@ -85,124 +84,146 @@ describe('Page Functions', () => {
8584

8685
describe('get outer HTML snippets', () => {
8786
it('gets full HTML snippet', () => {
88-
assert.equal(pageFunctions.getOuterHTMLSnippet(
89-
dom.createElement('div', '', {id: '1', style: 'style'})), '<div id="1" style="style">');
87+
const elem = document.createElement('div');
88+
elem.id = '1';
89+
elem.style = 'width: 1px;';
90+
assert.equal(pageFunctions.getOuterHTMLSnippet(elem), '<div id="1" style="width: 1px;">');
9091
});
9192

9293
it('replaces img.src with img.currentSrc', () => {
93-
const el = dom.createElement('img', '', {id: '1', src: 'no'});
94+
const el = document.createElement('img');
95+
el.id = '1';
96+
el.src = 'no';
9497
Object.defineProperty(el, 'currentSrc', {value: 'yes'});
9598
assert.equal(pageFunctions.getOuterHTMLSnippet(el), '<img id="1" src="yes">');
9699
});
97100

98101
it('does not replace img.src with img.currentSrc if resolve to same URL', () => {
99-
const el = dom.createElement('img', '', {id: '1', src: './a.png'});
102+
const el = document.createElement('img');
103+
el.id = '1';
104+
el.src = './a.png';
100105
Object.defineProperty(el, 'currentSrc', {value: `${url}/a.png`});
101106
assert.equal(pageFunctions.getOuterHTMLSnippet(el), '<img id="1" src="./a.png">');
102107
});
103108

104109
it('removes a specific attribute', () => {
105-
assert.equal(pageFunctions.getOuterHTMLSnippet(
106-
dom.createElement('div', '', {id: '1', style: 'style'}), ['style']), '<div id="1">');
110+
const elem = document.createElement('div');
111+
elem.id = '1';
112+
elem.style = 'width: 1px;';
113+
assert.equal(pageFunctions.getOuterHTMLSnippet(elem, ['style']), '<div id="1">');
107114
});
108115

109116
it('removes multiple attributes', () => {
110-
assert.equal(pageFunctions.getOuterHTMLSnippet(
111-
dom.createElement('div', '', {'id': '1', 'style': 'style', 'aria-label': 'label'}),
112-
['style', 'aria-label']
113-
), '<div id="1">');
117+
const elem = document.createElement('div');
118+
elem.id = '1';
119+
elem.style = 'width: 1px;';
120+
elem.setAttribute('aria-label', 'label');
121+
assert.equal(
122+
pageFunctions.getOuterHTMLSnippet(elem, ['style', 'aria-label']),
123+
'<div id="1">');
114124
});
115125

116126
it('should handle dom nodes that cannot be cloned', () => {
117-
const element = dom.createElement('div');
127+
const element = document.createElement('div');
118128
element.cloneNode = () => {
119129
throw new Error('oops!');
120130
};
121131
assert.equal(pageFunctions.getOuterHTMLSnippet(element), '<div>');
122132
});
123133
it('ignores when attribute not found', () => {
134+
const elem = document.createElement('div');
135+
elem.id = '1';
136+
elem.style = 'width: 1px;';
137+
elem.setAttribute('aria-label', 'label');
124138
assert.equal(pageFunctions.getOuterHTMLSnippet(
125-
dom.createElement('div', '', {'id': '1', 'style': 'style', 'aria-label': 'label'}),
139+
elem,
126140
['style-missing', 'aria-label-missing']
127-
), '<div id="1" style="style" aria-label="label">');
141+
), '<div id="1" style="width: 1px;" aria-label="label">');
128142
});
129143

130144
it('works if attribute values contain line breaks', () => {
131-
assert.equal(pageFunctions.getOuterHTMLSnippet(
132-
dom.createElement('div', '', {style: 'style1\nstyle2'})), '<div style="style1\nstyle2">');
145+
const elem = document.createElement('div');
146+
elem.style = 'width: 1px;\nheight: 2px;';
147+
assert.equal(pageFunctions.getOuterHTMLSnippet(elem),
148+
'<div style="width: 1px; height: 2px;">');
133149
});
134150

135151
it('truncates attribute values that are too long', () => {
136-
const longClass = 'a'.repeat(200);
152+
const elem = document.createElement('div');
153+
elem.className = 'a'.repeat(200);
137154
const truncatedExpectation = 'a'.repeat(74) + '…';
138-
assert.equal(pageFunctions.getOuterHTMLSnippet(
139-
dom.createElement('div', '', {class: longClass})), `<div class="${truncatedExpectation}">`
140-
);
155+
assert.equal(
156+
pageFunctions.getOuterHTMLSnippet(elem),
157+
`<div class="${truncatedExpectation}">`);
141158
});
142159

143160
it('removes attributes if the length of the attribute name + value is too long', () => {
144161
const longValue = 'a'.repeat(200);
145162
const truncatedValue = 'a'.repeat(74) + '…';
146-
const element = dom.createElement('div', '', {
147-
class: longValue,
148-
id: longValue,
149-
att1: 'shouldn\'t see this',
150-
att2: 'shouldn\'t see this either',
151-
});
152-
const snippet = pageFunctions.getOuterHTMLSnippet(element, [], 150);
163+
const elem = document.createElement('div');
164+
elem.className = longValue;
165+
elem.id = longValue;
166+
elem.setAttribute('att1', 'shouldn\'t see this');
167+
elem.setAttribute('att2', 'shouldn\'t see this either');
168+
169+
const snippet = pageFunctions.getOuterHTMLSnippet(elem, [], 150);
153170
assert.equal(snippet, `<div class="${truncatedValue}" id="${truncatedValue}" …>`
154171
);
155172
});
156173
});
157174

158175
describe('getNodeSelector', () => {
159176
it('Uses IDs where available and otherwise falls back to classes', () => {
160-
const parentEl = dom.createElement('div', '', {id: 'wrapper', class: 'dont-use-this'});
161-
const childEl = dom.createElement('div', '', {class: 'child'});
177+
const parentEl = document.createElement('div');
178+
parentEl.id = 'wrapper';
179+
parentEl.className = 'dont-use-this';
180+
const childEl = document.createElement('div');
181+
childEl.className = 'child';
162182
parentEl.appendChild(childEl);
163183
assert.equal(pageFunctions.getNodeSelector(childEl), 'div#wrapper > div.child');
164184
});
165185
});
166186

167187
describe('getNodeLabel', () => {
168188
it('Returns innerText if element has visible text', () => {
169-
const el = dom.createElement('div');
189+
const el = document.createElement('div');
170190
el.innerText = 'Hello';
171191
assert.equal(pageFunctions.getNodeLabel(el), 'Hello');
172192
});
173193

174194
it('Falls back to children and alt/aria-label if a title can\'t be determined', () => {
175-
const el = dom.createElement('div');
176-
const childEl = dom.createElement('div', '', {'aria-label': 'Something'});
195+
const el = document.createElement('div');
196+
const childEl = document.createElement('div');
197+
childEl.setAttribute('aria-label', 'Something');
177198
el.appendChild(childEl);
178199
assert.equal(pageFunctions.getNodeLabel(el), 'Something');
179200
});
180201

181202
it('Truncates long text', () => {
182-
const el = dom.createElement('div');
203+
const el = document.createElement('div');
183204
el.setAttribute('alt', Array(100).fill('a').join(''));
184205
assert.equal(pageFunctions.getNodeLabel(el).length, 80);
185206
});
186207

187208
it('Truncates long text containing unicode surrogate pairs', () => {
188-
const el = dom.createElement('div');
209+
const el = document.createElement('div');
189210
// `getNodeLabel` truncates to 80 characters internally.
190211
// We want to test a unicode character on the boundary.
191212
el.innerText = Array(78).fill('a').join('') + '💡💡💡';
192213
assert.equal(pageFunctions.getNodeLabel(el), Array(78).fill('a').join('') + '💡…');
193214
});
194215

195216
it('Returns null if there is no better label', () => {
196-
const el = dom.createElement('div');
197-
const childEl = dom.createElement('span');
217+
const el = document.createElement('div');
218+
const childEl = document.createElement('span');
198219
el.appendChild(childEl);
199220
assert.equal(pageFunctions.getNodeLabel(el), null);
200221
});
201222
});
202223

203224
describe('getNodePath', () => {
204225
it('returns basic node path', () => {
205-
const el = dom.createElement('div');
226+
const el = document.createElement('div');
206227
el.innerHTML = `
207228
<section>
208229
<span>Sup</span>
@@ -215,11 +236,11 @@ describe('Page Functions', () => {
215236
});
216237

217238
it('returns node path through shadow root', () => {
218-
const el = dom.createElement('div');
219-
const main = el.appendChild(dom.createElement('main'));
239+
const el = document.createElement('div');
240+
const main = el.appendChild(document.createElement('main'));
220241
const shadowRoot = main.attachShadow({mode: 'open'});
221-
const sectionEl = dom.createElement('section');
222-
const img = dom.createElement('img');
242+
const sectionEl = document.createElement('section');
243+
const img = document.createElement('img');
223244
img.src = '#';
224245
sectionEl.append(img);
225246
shadowRoot.append(sectionEl);
@@ -230,8 +251,12 @@ describe('Page Functions', () => {
230251

231252
describe('getNodeDetails', () => {
232253
it('Returns selector as fallback if nodeLabel equals html tag name', () => {
233-
const el = dom.createElement('div', '', {id: 'parent', class: 'parent-el'});
234-
const childEl = dom.createElement('p', '', {id: 'child', class: 'child-el'});
254+
const el = document.createElement('div');
255+
el.id = 'parent';
256+
el.className = 'parent-el';
257+
const childEl = document.createElement('p');
258+
childEl.id = 'child';
259+
childEl.className = 'child-el';
235260
el.appendChild(childEl);
236261
const {nodeLabel} = pageFunctions.getNodeDetails(el);
237262
assert.equal(nodeLabel, 'div#parent');

lighthouse-treemap/app/src/main.js

Lines changed: 8 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -646,16 +646,14 @@ function renderViewModeButtons(viewModes) {
646646
if (!viewMode.enabled) viewModeEl.classList.add('view-mode--disabled');
647647
viewModeEl.id = `view-mode--${viewMode.id}`;
648648

649-
const inputEl = TreemapUtil.createChildOf(viewModeEl, 'input', 'view-mode__button', {
650-
id: `view-mode--${viewMode.id}__label`,
651-
type: 'radio',
652-
name: 'view-mode',
653-
disabled: viewMode.enabled ? undefined : '',
654-
});
655-
656-
const labelEl = TreemapUtil.createChildOf(viewModeEl, 'label', undefined, {
657-
for: inputEl.id,
658-
});
649+
const inputEl = TreemapUtil.createChildOf(viewModeEl, 'input', 'view-mode__button');
650+
inputEl.id = `view-mode--${viewMode.id}__label`;
651+
inputEl.type = 'radio';
652+
inputEl.name = 'view-mode';
653+
inputEl.disabled = !viewMode.enabled;
654+
655+
const labelEl = TreemapUtil.createChildOf(viewModeEl, 'label');
656+
labelEl.htmlFor = inputEl.id;
659657
TreemapUtil.createChildOf(labelEl, 'span', 'view-mode__label').textContent = viewMode.label;
660658
TreemapUtil.createChildOf(labelEl, 'span', 'view-mode__sublabel lh-text-dim').textContent =
661659
` (${viewMode.subLabel})`;

lighthouse-treemap/app/src/util.js

Lines changed: 3 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -75,22 +75,13 @@ class TreemapUtil {
7575
* @template {string} T
7676
* @param {T} name
7777
* @param {string=} className
78-
* @param {Object<string, (string|undefined)>=} attrs Attribute key/val pairs.
79-
* Note: if an attribute key has an undefined value, this method does not
80-
* set the attribute on the node.
8178
* @return {HTMLElementByTagName[T]}
8279
*/
83-
static createElement(name, className, attrs = {}) {
80+
static createElement(name, className) {
8481
const element = document.createElement(name);
8582
if (className) {
8683
element.className = className;
8784
}
88-
Object.keys(attrs).forEach(key => {
89-
const value = attrs[key];
90-
if (typeof value !== 'undefined') {
91-
element.setAttribute(key, value);
92-
}
93-
});
9485
return element;
9586
}
9687

@@ -99,13 +90,10 @@ class TreemapUtil {
9990
* @param {Element} parentElem
10091
* @param {T} elementName
10192
* @param {string=} className
102-
* @param {Object<string, (string|undefined)>=} attrs Attribute key/val pairs.
103-
* Note: if an attribute key has an undefined value, this method does not
104-
* set the attribute on the node.
10593
* @return {HTMLElementByTagName[T]}
10694
*/
107-
static createChildOf(parentElem, elementName, className, attrs) {
108-
const element = this.createElement(elementName, className, attrs);
95+
static createChildOf(parentElem, elementName, className) {
96+
const element = this.createElement(elementName, className);
10997
parentElem.appendChild(element);
11098
return element;
11199
}

report/renderer/category-renderer.js

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -82,9 +82,8 @@ export class CategoryRenderer {
8282
descEl.appendChild(this.dom.convertMarkdownLinkSnippets(audit.result.description));
8383

8484
for (const relevantMetric of audit.relevantMetrics || []) {
85-
const adornEl = this.dom.createChildOf(descEl, 'span', 'lh-audit__adorn', {
86-
title: `Relevant to ${relevantMetric.result.title}`,
87-
});
85+
const adornEl = this.dom.createChildOf(descEl, 'span', 'lh-audit__adorn');
86+
adornEl.title = `Relevant to ${relevantMetric.result.title}`;
8887
adornEl.textContent = relevantMetric.acronym || relevantMetric.id;
8988
}
9089

@@ -332,7 +331,7 @@ export class CategoryRenderer {
332331
renderScoreGauge(category, groupDefinitions) { // eslint-disable-line no-unused-vars
333332
const tmpl = this.dom.cloneTemplate('#tmpl-lh-gauge', this.templateContext);
334333
const wrapper = this.dom.find('a.lh-gauge__wrapper', tmpl);
335-
wrapper.href = `#${category.id}`;
334+
this.dom.safelySetHref(wrapper, `#${category.id}`);
336335

337336
if (Util.isPluginCategory(category.id)) {
338337
wrapper.classList.add('lh-gauge__wrapper--plugin');

report/renderer/details-renderer.js

Lines changed: 6 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -151,24 +151,19 @@ export class DetailsRenderer {
151151
* @return {HTMLElement}
152152
*/
153153
_renderLink(details) {
154-
const allowedProtocols = ['https:', 'http:'];
155-
let url;
156-
try {
157-
url = new URL(details.url);
158-
} catch (_) {}
154+
const a = this._dom.createElement('a');
155+
this._dom.safelySetHref(a, details.url);
159156

160-
if (!url || !allowedProtocols.includes(url.protocol)) {
157+
if (!a.href) {
161158
// Fall back to just the link text if invalid or protocol not allowed.
162159
const element = this._renderText(details.text);
163160
element.classList.add('lh-link');
164161
return element;
165162
}
166163

167-
const a = this._dom.createElement('a');
168164
a.rel = 'noopener';
169165
a.target = '_blank';
170166
a.textContent = details.text;
171-
a.href = url.href;
172167
a.classList.add('lh-link');
173168
return a;
174169
}
@@ -599,10 +594,9 @@ export class DetailsRenderer {
599594

600595
for (const thumbnail of details.items) {
601596
const frameEl = this._dom.createChildOf(filmstripEl, 'div', 'lh-filmstrip__frame');
602-
this._dom.createChildOf(frameEl, 'img', 'lh-filmstrip__thumbnail', {
603-
src: thumbnail.data,
604-
alt: `Screenshot`,
605-
});
597+
const imgEl = this._dom.createChildOf(frameEl, 'img', 'lh-filmstrip__thumbnail');
598+
imgEl.src = thumbnail.data;
599+
imgEl.alt = `Screenshot`;
606600
}
607601
return filmstripEl;
608602
}

0 commit comments

Comments
 (0)