Skip to content

Commit 2f34df6

Browse files
feat: wait for resources to be settled before making screenshot
1 parent 8bfcb6e commit 2f34df6

File tree

8 files changed

+564
-0
lines changed

8 files changed

+564
-0
lines changed

src/browser/commands/assert-view/index.js

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,14 @@ module.exports.default = browser => {
5757
return Promise.reject(new Error(`duplicate name for "${state}" state`));
5858
}
5959

60+
if (opts.waitForStaticToLoadTimeout) {
61+
// Interval between checks is "waitPageReadyTimeout / 10" ms, but at least 50ms and not more than 500ms
62+
await session.waitForStaticToLoad({
63+
timeout: opts.waitForStaticToLoadTimeout,
64+
interval: Math.min(Math.max(50, opts.waitForStaticToLoadTimeout / 10), 500),
65+
});
66+
}
67+
6068
const handleCaptureProcessorError = e =>
6169
e instanceof BaseStateError ? testplaneCtx.assertViewResults.add(e) : Promise.reject(e);
6270

src/browser/commands/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,4 +9,5 @@ export const customCommandFileNames = [
99
"switchToRepl",
1010
"moveCursorTo",
1111
"captureDomSnapshot",
12+
"waitForStaticToLoad",
1213
];
Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
import type { Browser } from "../types";
2+
import * as logger from "../../utils/logger";
3+
4+
/* eslint-disable no-var */
5+
function browserIsPageReady(): { ready: boolean; reason?: string; pendingResources?: string[] } {
6+
if (document.readyState === "loading") {
7+
return { ready: false, reason: "Document is loading" };
8+
}
9+
10+
if (document.currentScript) {
11+
return { ready: false, reason: "JavaScript is running" };
12+
}
13+
14+
if (document.fonts && document.fonts.status === "loading") {
15+
return { ready: false, reason: "Fonts are loading" };
16+
}
17+
18+
var imagesCount = (document.images && document.images.length) || 0;
19+
20+
for (var i = 0; i < imagesCount; i++) {
21+
var image = document.images.item(i);
22+
23+
if (image && !image.complete) {
24+
return { ready: false, reason: `Image from ${image.src} is loading` };
25+
}
26+
}
27+
28+
var externalStyles = document.querySelectorAll<HTMLLinkElement>('link[rel="stylesheet"]');
29+
var externalStylesCount = (externalStyles && externalStyles.length) || 0;
30+
31+
for (var i = 0; i < externalStylesCount; i++) {
32+
var style = externalStyles.item(i);
33+
34+
if (!style.sheet) {
35+
return { ready: false, reason: `Styles from ${style.href} are loading` };
36+
}
37+
}
38+
39+
var waitingForResourceUrls = new Set<string>();
40+
41+
var nodesWithInlineStylesWithUrl = document.querySelectorAll<HTMLElement>('[style*="url("]');
42+
var styleWithUrlRegExp = /^url\("(.*)"\)$/;
43+
44+
for (var node of nodesWithInlineStylesWithUrl) {
45+
if (!node.clientHeight || !node.clientWidth) {
46+
continue;
47+
}
48+
49+
var inlineRulesCount = node.style ? node.style.length : 0;
50+
51+
for (var i = 0; i < inlineRulesCount; i++) {
52+
var inlineRuleName = node.style[i];
53+
var inlineRuleValue = node.style[inlineRuleName as keyof CSSStyleDeclaration] as string;
54+
55+
if (!inlineRuleValue || (!inlineRuleValue.startsWith('url("') && !inlineRuleValue.startsWith("url('"))) {
56+
continue;
57+
}
58+
59+
var computedStyleValue = getComputedStyle(node).getPropertyValue(inlineRuleName);
60+
var match = styleWithUrlRegExp.exec(computedStyleValue);
61+
62+
if (match && match[1] && !match[1].startsWith("data:")) {
63+
waitingForResourceUrls.add(match[1]);
64+
}
65+
}
66+
}
67+
68+
for (var styleSheet of document.styleSheets) {
69+
for (var cssRules of styleSheet.cssRules) {
70+
var cssStyleRule = cssRules as CSSStyleRule;
71+
var cssStyleSelector = cssStyleRule.selectorText;
72+
var cssStyleRulesCount = cssStyleRule.style ? cssStyleRule.style.length : 0;
73+
74+
var displayedNodeElementsStyles: CSSStyleDeclaration[] | null = null;
75+
76+
for (var i = 0; i < cssStyleRulesCount; i++) {
77+
var cssRuleName = cssStyleRule.style[i];
78+
var cssRuleValue = cssStyleRule.style[cssRuleName as keyof CSSStyleDeclaration] as string;
79+
80+
if (!cssRuleValue || (!cssRuleValue.startsWith('url("') && !cssRuleValue.startsWith("url('"))) {
81+
continue;
82+
}
83+
84+
if (!displayedNodeElementsStyles) {
85+
displayedNodeElementsStyles = [] as CSSStyleDeclaration[];
86+
87+
document.querySelectorAll<HTMLElement>(cssStyleSelector).forEach(function (node) {
88+
if (!node.clientHeight || !node.clientWidth) {
89+
return;
90+
}
91+
92+
(displayedNodeElementsStyles as CSSStyleDeclaration[]).push(getComputedStyle(node));
93+
});
94+
}
95+
96+
for (var nodeStyles of displayedNodeElementsStyles) {
97+
var computedStyleValue = nodeStyles.getPropertyValue(cssRuleName);
98+
var match = styleWithUrlRegExp.exec(computedStyleValue);
99+
100+
if (match && match[1] && !match[1].startsWith("data:")) {
101+
waitingForResourceUrls.add(match[1]);
102+
}
103+
}
104+
}
105+
}
106+
}
107+
108+
var performanceResourceEntries = performance.getEntriesByType("resource") as PerformanceResourceTiming[];
109+
110+
performanceResourceEntries.forEach(function (performanceResourceEntry) {
111+
waitingForResourceUrls.delete(performanceResourceEntry.name);
112+
});
113+
114+
if (!waitingForResourceUrls.size) {
115+
return { ready: true };
116+
}
117+
118+
var pendingResources = Array.from(waitingForResourceUrls);
119+
120+
return { ready: false, reason: "Resources are not loaded", pendingResources };
121+
}
122+
123+
function browserAreResourcesLoaded(pendingResources: string[]): string[] {
124+
var pendingResourcesSet = new Set(pendingResources);
125+
var performanceResourceEntries = performance.getEntriesByType("resource") as PerformanceResourceTiming[];
126+
127+
performanceResourceEntries.forEach(function (performanceResourceEntry) {
128+
pendingResourcesSet.delete(performanceResourceEntry.name);
129+
});
130+
131+
return Array.from(pendingResourcesSet);
132+
}
133+
/* eslint-enable no-var */
134+
135+
export type WaitForStaticToLoadResult =
136+
| { ready: true }
137+
| { ready: false; reason: string }
138+
| { ready: false; reason: "Resources are not loaded"; pendingResources: string[] };
139+
140+
export default (browser: Browser): void => {
141+
const { publicAPI: session } = browser;
142+
143+
session.addCommand(
144+
"waitForStaticToLoad",
145+
async function ({
146+
timeout = browser.config.waitTimeout,
147+
interval = browser.config.waitInterval,
148+
} = {}): Promise<WaitForStaticToLoadResult> {
149+
let isTimedOut = false;
150+
151+
const loadTimeout = setTimeout(() => {
152+
isTimedOut = true;
153+
}, timeout).unref();
154+
const warnTimedOut = (result: ReturnType<typeof browserIsPageReady>): void => {
155+
const timedOutMsg = `Timed out waiting for page to load in ${timeout}ms.`;
156+
157+
if (result && result.pendingResources) {
158+
logger.warn(
159+
[
160+
`${timedOutMsg} Several resources are still not loaded:`,
161+
...result.pendingResources.map(resouce => `- ${resouce}`),
162+
].join("\n"),
163+
);
164+
} else {
165+
logger.warn(`${timedOutMsg} ${result.reason}`);
166+
}
167+
};
168+
169+
let result = await session.execute(browserIsPageReady);
170+
171+
while (!isTimedOut && !result.ready) {
172+
await new Promise(resolve => setTimeout(resolve, interval));
173+
174+
if (result.pendingResources) {
175+
result.pendingResources = await session.execute(browserAreResourcesLoaded, result.pendingResources);
176+
result.ready = result.pendingResources.length === 0;
177+
} else {
178+
result = await session.execute(browserIsPageReady);
179+
}
180+
}
181+
182+
clearTimeout(loadTimeout);
183+
184+
if (isTimedOut && !result.ready) {
185+
warnTimedOut(result);
186+
}
187+
188+
if (result.ready) {
189+
return { ready: true };
190+
}
191+
192+
if (result.reason === "Resources are not loaded") {
193+
return { ready: false, reason: "Resources are not loaded", pendingResources: result.pendingResources };
194+
}
195+
196+
return { ready: false, reason: result.reason as string };
197+
},
198+
);
199+
};

src/browser/types.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import type { Callstack } from "./history/callstack";
99
import type { Test, Hook } from "../test-reader/test-object";
1010
import type { CaptureSnapshotOptions, CaptureSnapshotResult } from "./commands/captureDomSnapshot";
1111
import type { Options } from "@testplane/wdio-types";
12+
import type { WaitForStaticToLoadResult } from "./commands/waitForStaticToLoad";
1213

1314
export const BrowserName = {
1415
CHROME: "chrome" as PuppeteerBrowser.CHROME,
@@ -161,6 +162,11 @@ declare global {
161162

162163
clearSession: (this: WebdriverIO.Browser) => Promise<void>;
163164

165+
waitForStaticToLoad: (
166+
this: WebdriverIO.Browser,
167+
opts?: { timeout?: number; interval?: number },
168+
) => Promise<WaitForStaticToLoadResult>;
169+
164170
unstable_captureDomSnapshot(
165171
this: WebdriverIO.Browser,
166172
options?: Partial<CaptureSnapshotOptions>,

src/config/defaults.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ module.exports = {
2929
captureElementFromTop: true,
3030
allowViewportOverflow: false,
3131
ignoreDiffPixelCount: 0,
32+
waitForStaticToLoadTimeout: 5000,
3233
},
3334
openAndWaitOpts: {
3435
waitNetworkIdle: true,

src/config/types.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,22 @@ export interface AssertViewOpts {
121121
* @defaultValue `0`
122122
*/
123123
ignoreDiffPixelCount?: `${number}%` | number;
124+
/**
125+
* Ability to wait for page to be fully ready before making screenshot.
126+
* This ensures (in following order):
127+
* - no script is running at the moment;
128+
* - fonts are no longer loading
129+
* - images are no longer loading
130+
* - external styles are loaded
131+
* - external scripts are no longer loading
132+
*
133+
* @remarks
134+
* If page is still not ready after non-zero timeout, there would only be a warning about it, no error is thrown.
135+
*
136+
* @note
137+
* Setting it to zero disables waiting for page to be ready.
138+
*/
139+
waitForStaticToLoadTimeout?: number;
124140
}
125141

126142
export interface ExpectOptsConfig {

test/src/browser/commands/assert-view/index.js

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,24 @@ describe("assertView command", () => {
194194
);
195195
});
196196

197+
it("should not call 'waitForStaticToLoad'", async () => {
198+
const browser = await initBrowser_();
199+
browser.publicAPI.waitForStaticToLoad = sandbox.stub().resolves();
200+
201+
await browser.publicAPI.assertView("plain", { waitForStaticToLoadTimeout: 0 });
202+
203+
assert.notCalled(browser.publicAPI.waitForStaticToLoad);
204+
});
205+
206+
it("should call 'waitForStaticToLoad'", async () => {
207+
const browser = await initBrowser_();
208+
browser.publicAPI.waitForStaticToLoad = sandbox.stub().resolves();
209+
210+
await browser.publicAPI.assertView("plain", { waitForStaticToLoadTimeout: 3000 });
211+
212+
assert.calledOnceWith(browser.publicAPI.waitForStaticToLoad, { timeout: 3000, interval: 300 });
213+
});
214+
197215
[
198216
{ scope: "browser", fn: assertViewBrowser },
199217
{ scope: "element", fn: assertViewElement },

0 commit comments

Comments
 (0)