Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

core(full-page-screenshot): resolve node rects during emulation #11536

Merged
merged 41 commits into from
Nov 30, 2020
Merged
Show file tree
Hide file tree
Changes from 11 commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
fa68fbf
initial
connorjclark Sep 9, 2020
d9f4d2e
test
connorjclark Sep 9, 2020
97fbdae
test
connorjclark Oct 7, 2020
a2391d2
Merge remote-tracking branch 'origin/master' into resolve-nodes
connorjclark Oct 7, 2020
1b6ba16
update
connorjclark Oct 7, 2020
93960c5
Merge branch 'full-page-dpr-fix' into resolve-nodes
connorjclark Oct 7, 2020
90190c6
minor
connorjclark Oct 7, 2020
fe7b0f8
report: do not show element screenshot if out of bounds
connorjclark Oct 8, 2020
987c676
test
connorjclark Oct 8, 2020
827a19f
Revert "Merge branch 'full-page-dpr-fix' into resolve-nodes"
connorjclark Oct 8, 2020
437e3af
Merge branch 'screenshots-oob' into resolve-nodes
connorjclark Oct 8, 2020
553eb21
Merge remote-tracking branch 'origin/master' into resolve-nodes
connorjclark Nov 10, 2020
551c53b
refactor
connorjclark Nov 11, 2020
df7a9f2
read from all contexts
connorjclark Nov 11, 2020
c592163
tests
connorjclark Nov 11, 2020
f54e01d
expand smoke test
connorjclark Nov 12, 2020
315150c
lhId
connorjclark Nov 12, 2020
77b72d9
details renderer test
connorjclark Nov 12, 2020
5fb4dd6
axe
connorjclark Nov 12, 2020
69e71bb
mock fps gatherer
connorjclark Nov 12, 2020
8f6c886
fix mocks
connorjclark Nov 12, 2020
3b62754
expectations
connorjclark Nov 12, 2020
445b614
mockkkkkkssssss
connorjclark Nov 13, 2020
da33697
Merge remote-tracking branch 'origin/master' into resolve-nodes
connorjclark Nov 13, 2020
8566593
update
connorjclark Nov 16, 2020
6f16838
updatesmokes
connorjclark Nov 16, 2020
7cee565
Merge remote-tracking branch 'origin/master' into resolve-nodes
connorjclark Nov 17, 2020
a18ead2
timings
connorjclark Nov 17, 2020
f043e2d
xvfb the thing
connorjclark Nov 17, 2020
cf3359b
nah
connorjclark Nov 17, 2020
32b0dea
Merge remote-tracking branch 'origin/master' into resolve-nodes
connorjclark Nov 17, 2020
7783001
hmm
connorjclark Nov 17, 2020
9ff001c
nah
connorjclark Nov 17, 2020
9cadb0b
Merge remote-tracking branch 'origin/master' into resolve-nodes
connorjclark Nov 21, 2020
4f82ee3
move to base artifact
connorjclark Nov 21, 2020
6cfe947
Merge remote-tracking branch 'origin/master' into resolve-nodes
connorjclark Nov 24, 2020
b4f129f
ts
connorjclark Nov 24, 2020
76835b1
Merge remote-tracking branch 'origin/master' into resolve-nodes
connorjclark Nov 25, 2020
0484a53
oop
connorjclark Nov 25, 2020
20ce34a
html element shadow
connorjclark Nov 25, 2020
df63018
simple
connorjclark Nov 26, 2020
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion lighthouse-cli/test/fixtures/perf/delayed-element.js
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,14 @@ setTimeout(() => {
sectionEl.append(imgEl, textEl);
shadowRoot.append(sectionEl);

textEl.style.color = 'gray';
textEl.style.backgroundColor = 'lightgrey';
let padding = 1;
setInterval(() => {
textEl.style.paddingTop = padding + 'px';
padding += 1;
}, 50);

// layout-shift-elements: ensure we can handle missing shift elements
if (window.location.href.includes('?missing')) {
stall(100); // force a long task to ensure we reach the rerendering stage
Expand All @@ -47,7 +55,7 @@ setTimeout(() => {
document.body.textContent = 'Now it is all gone!';
}, 50);
}
}, 1000);
}, 1);

// long-tasks: add a very long task at least 500ms
stall(800);
61 changes: 60 additions & 1 deletion lighthouse-core/gather/gather-runner.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ const NetworkRecorder = require('../lib/network-recorder.js');
const constants = require('../config/constants.js');
const i18n = require('../lib/i18n/i18n.js');
const URL = require('../lib/url-shim.js');
const pageFunctions = require('../lib/page-functions.js');

const UIStrings = {
/**
Expand Down Expand Up @@ -501,6 +502,60 @@ class GatherRunner {
};
}

/**
* Gatherers can collect details about DOM nodes, including their position on the page.
* Layout shifts occuring after a gatherer runs can cause these positions to be incorrect,
* resulting in a poor experience for element screenshots.
* `getNodeDetails` maintains a collection of DOM objects in the page, which we can iterate
* to re-collect the bounding client rectangle. We also update the devtools node path.
* The old devtools node path is used as a lookup key. We walk the entire artifacts object
* to update all objects that reference an old devtools node path.
* @param {Driver} driver
* @param {Partial<LH.Artifacts>} artifacts
*/
static async resolveNodes(driver, artifacts) {
function resolveNodes() {
return (window.__nodes || []).map(({key, node}) => {
return {
key,
newBoundingRect: getBoundingClientRect(node),
newDevtoolsNodePath: getNodePath(node),
};
});
}
const expression = `(function () {
${pageFunctions.getBoundingClientRectString};
${pageFunctions.getNodePathString};
return (${resolveNodes.toString()}());
})()`;

/** @type {Array<{key: string, newBoundingRect: any, newDevtoolsNodePath: string}>} */
const resolved = await driver.evaluateAsync(expression, {useIsolation: true});

console.log(' resolved ====');
console.log(resolved);

const walk = require('../lib/sd-validation/helpers/walk-object.js');
walk(artifacts, (name, value) => {
if (!value || typeof value !== 'object') return;
if (!value.path && !value.devtoolsNodePath) return;

const resolvedNode = resolved.find(r => r.key === value.devtoolsNodePath);
if (!resolvedNode) return;

console.log(resolvedNode.key, 'set to rect', resolvedNode.newBoundingRect);

// Rects are stored on properties named either `boundingRect` or `clientRect`.
if (value.boundingRect) {
value.boundingRect = resolvedNode.newBoundingRect;
} else if (value.clientRect) {
value.clientRect = resolvedNode.newBoundingRect;
Copy link
Member

@paulirish paulirish Oct 10, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

just leaving this for posterity...

the _taptarget artifact_ has a `clientRects` prop (note the plural).. this can have multiple client rects per node even tho it has just a single bounding rect.

image

the iframe and image element artifacts have a clientRect prop. and yes those are identical in definition to boundingRect.

}

value.devtoolsNodePath = resolvedNode.newDevtoolsNodePath;
Copy link
Collaborator Author

@connorjclark connorjclark Oct 7, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I suppose this could break things if updating the path (which is the lookup key...) as we go. Perhaps should create a key -> artifact object mapping before doing the updating.

});
}

/**
* Return an initialized but mostly empty set of base artifacts, to be
* populated as the run continues.
Expand Down Expand Up @@ -783,7 +838,11 @@ class GatherRunner {

// Run `afterPass()` on gatherers and return collected artifacts.
await GatherRunner.afterPass(passContext, loadData, gathererResults);
const artifacts = GatherRunner.collectArtifacts(gathererResults);
const artifacts = await GatherRunner.collectArtifacts(gathererResults);

if (process.env.RESOLVE_NODES) {
await this.resolveNodes(driver, artifacts.artifacts);
}

log.timeEnd(status);
return artifacts;
Expand Down
27 changes: 19 additions & 8 deletions lighthouse-core/lib/page-functions.js
Original file line number Diff line number Diff line change
Expand Up @@ -447,19 +447,30 @@ function wrapRequestIdleCallback(cpuSlowdownMultiplier) {
};
}

const getNodeDetailsString = `function getNodeDetails(elem) {
/**
* @param {HTMLElement} element
*/
function getNodeDetails(element) {
const details = {
devtoolsNodePath: getNodePath(element),
selector: getNodeSelector(element),
boundingRect: getBoundingClientRect(element),
snippet: getOuterHTMLSnippet(element),
nodeLabel: getNodeLabel(element),
};
window.__nodes = window.__nodes || [];
window.__nodes.push({key: details.devtoolsNodePath, node: element});
return details;
}

const getNodeDetailsString = `function getNodeDetails(element) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we just put getNodeDetails.toString() in the exports and remove this?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah i considered that as well, though this fn is a little different from the rest in that it depends on all the others. so we need those stringified functions inlined here within this one.

when i worked on this with @adrianaixba it seems the most practical to just define this fn as a string and skip the conversion in order to make sure all the deps were available. (otherwise i figured whereever we used getNodeDetails in gatherers, we'd also need to inject each of its dependency fns as well.

i still feel pretty decent this was the right compromise for DX, but if there's another good solution we can def explore it.

Copy link
Collaborator Author

@connorjclark connorjclark Nov 12, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I had to add non trivial code to this function so I split it out, but am keeping the stringified dep export.

${getNodePath.toString()};
${getNodeSelector.toString()};
${getBoundingClientRect.toString()};
${getOuterHTMLSnippet.toString()};
${getNodeLabel.toString()};
return {
devtoolsNodePath: getNodePath(elem),
selector: getNodeSelector(elem),
boundingRect: getBoundingClientRect(elem),
snippet: getOuterHTMLSnippet(elem),
nodeLabel: getNodeLabel(elem),
};
${getNodeDetails.toString()};
return getNodeDetails(element);
}`;

module.exports = {
Expand Down
2 changes: 1 addition & 1 deletion lighthouse-core/report/html/renderer/details-renderer.js
Original file line number Diff line number Diff line change
Expand Up @@ -533,7 +533,7 @@ class DetailsRenderer {
item.boundingRect,
maxThumbnailSize
);
element.prepend(elementScreenshot);
elementScreenshot && element.prepend(elementScreenshot);

return element;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,22 @@
/** @typedef {LH.Artifacts.Rect} Rect */
/** @typedef {{width: number, height: number}} Size */

/**
* Returns whether rect2 is contained entirely within rect1;
* @param {LH.Artifacts.Rect} rect1
* @param {LH.Artifacts.Rect} rect2
* @return {boolean}
*/
// We sometimes run this as a part of a gatherer script injected into the page, so prevent
// renaming the function for code coverage.
/* istanbul ignore next */
function rectContains(rect1, rect2) {
return rect2.top >= rect1.top &&
rect2.right <= rect1.right &&
rect2.bottom <= rect1.bottom &&
rect2.left >= rect1.left;
}

/**
* @param {number} value
* @param {number} min
Expand Down Expand Up @@ -153,13 +169,16 @@ class ElementScreenshotRenderer {
top: Number(el.dataset['rectTop']),
bottom: Number(el.dataset['rectTop']) + Number(el.dataset['rectHeight']),
};
overlay.appendChild(ElementScreenshotRenderer.render(
const screenshotElement = ElementScreenshotRenderer.render(
dom,
templateContext,
fullPageScreenshot,
elementRectSC,
maxLightboxSize
));
);
if (!screenshotElement) return;

overlay.appendChild(screenshotElement);
overlay.addEventListener('click', () => {
overlay.remove();
});
Expand Down Expand Up @@ -188,14 +207,28 @@ class ElementScreenshotRenderer {
/**
* Renders an element with surrounding context from the full page screenshot.
* Used to render both the thumbnail preview in details tables and the full-page screenshot in the lightbox.
* Returns null if element rect is outside screenshot bounds.
* @param {DOM} dom
* @param {ParentNode} templateContext
* @param {LH.Audit.Details.FullPageScreenshot} fullPageScreenshot
* @param {LH.Artifacts.Rect} elementRectSC Region of screenshot to highlight.
* @param {Size} maxRenderSizeDC e.g. maxThumbnailSize or maxLightboxSize.
* @return {Element}
* @return {Element|null}
*/
static render(dom, templateContext, fullPageScreenshot, elementRectSC, maxRenderSizeDC) {
const fullPageScreenshotRect = {
left: 0,
top: 0,
right: fullPageScreenshot.width,
bottom: fullPageScreenshot.height,
width: fullPageScreenshot.width,
height: fullPageScreenshot.height,
};
if (!rectContains(fullPageScreenshotRect, elementRectSC)) {
// Element is out of bounds of screenshot.
return null;
}

const tmpl = dom.cloneTemplate('#tmpl-lh-element-screenshot', templateContext);
const containerEl = dom.find('.lh-element-screenshot', tmpl);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,18 @@ const TEMPLATE_FILE = fs.readFileSync(
'utf8'
);

/**
* @param {{left: number, top: number, width: number, height:number}} opts
* @returns {LH.Artifacts.Rect}
*/
function makeRect(opts) {
return {
...opts,
right: opts.left + opts.width,
bottom: opts.top + opts.height,
};
}

describe('ElementScreenshotRenderer', () => {
let dom;

Expand All @@ -38,17 +50,17 @@ describe('ElementScreenshotRenderer', () => {
global.Util = undefined;
});

it('renders screenshot', () => {
it.only('renders screenshot', () => {
const fullPageScreenshot = {
width: 1000,
height: 1000,
};
const elementRectSC = {
const elementRectSC = makeRect({
left: 50,
top: 50,
width: 200,
height: 300,
};
});
const renderContainerSizeDC = {
width: 500,
height: 500,
Expand Down Expand Up @@ -81,6 +93,30 @@ describe('ElementScreenshotRenderer', () => {
/* eslint-enable max-len */
});

it('returns null if element is out of bounds', () => {
const fullPageScreenshot = {
width: 1000,
height: 1000,
};
const elementRectSC = makeRect({
left: 50,
top: 5000,
width: 200,
height: 300,
});
const renderContainerSizeDC = {
width: 500,
height: 500,
};
expect(ElementScreenshotRenderer.render(
dom,
dom.document(),
fullPageScreenshot,
elementRectSC,
renderContainerSizeDC
)).toBe(null);
});

describe('getScreenshotPositions', () => {
it('centers the screenshot on the highlighted area', () => {
expect(
Expand Down