Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
8 changes: 8 additions & 0 deletions src/browser/commands/assert-view/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,14 @@ module.exports.default = browser => {
return Promise.reject(new Error(`duplicate name for "${state}" state`));
}

if (opts.waitForStaticToLoadTimeout) {
// Interval between checks is "waitPageReadyTimeout / 10" ms, but at least 50ms and not more than 500ms
await session.waitForStaticToLoad({
timeout: opts.waitForStaticToLoadTimeout,
interval: Math.min(Math.max(50, opts.waitForStaticToLoadTimeout / 10), 500),
});
}

const handleCaptureProcessorError = e =>
e instanceof BaseStateError ? testplaneCtx.assertViewResults.add(e) : Promise.reject(e);

Expand Down
1 change: 1 addition & 0 deletions src/browser/commands/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,5 @@ export const customCommandFileNames = [
"switchToRepl",
"moveCursorTo",
"captureDomSnapshot",
"waitForStaticToLoad",
];
199 changes: 199 additions & 0 deletions src/browser/commands/waitForStaticToLoad.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
import type { Browser } from "../types";
import * as logger from "../../utils/logger";

/* eslint-disable no-var */
function browserIsPageReady(): { ready: boolean; reason?: string; pendingResources?: string[] } {
if (document.readyState === "loading") {
return { ready: false, reason: "Document is loading" };
}

if (document.currentScript) {
return { ready: false, reason: "JavaScript is running" };
}

if (document.fonts && document.fonts.status === "loading") {
return { ready: false, reason: "Fonts are loading" };
}

var imagesCount = (document.images && document.images.length) || 0;

for (var i = 0; i < imagesCount; i++) {
var image = document.images.item(i);

if (image && !image.complete) {
return { ready: false, reason: `Image from ${image.src} is loading` };
}
}

var externalStyles = document.querySelectorAll<HTMLLinkElement>('link[rel="stylesheet"]');
var externalStylesCount = (externalStyles && externalStyles.length) || 0;

for (var i = 0; i < externalStylesCount; i++) {
var style = externalStyles.item(i);

if (!style.sheet) {
return { ready: false, reason: `Styles from ${style.href} are loading` };
}
}

var waitingForResourceUrls = new Set<string>();

var nodesWithInlineStylesWithUrl = document.querySelectorAll<HTMLElement>('[style*="url("]');
var styleWithUrlRegExp = /^url\("(.*)"\)$/;

for (var node of nodesWithInlineStylesWithUrl) {
if (!node.clientHeight || !node.clientWidth) {
continue;
}

var inlineRulesCount = node.style ? node.style.length : 0;

for (var i = 0; i < inlineRulesCount; i++) {
var inlineRuleName = node.style[i];
var inlineRuleValue = node.style[inlineRuleName as keyof CSSStyleDeclaration] as string;

if (!inlineRuleValue || (!inlineRuleValue.startsWith('url("') && !inlineRuleValue.startsWith("url('"))) {
continue;
}

var computedStyleValue = getComputedStyle(node).getPropertyValue(inlineRuleName);
var match = styleWithUrlRegExp.exec(computedStyleValue);

if (match && match[1] && !match[1].startsWith("data:")) {
waitingForResourceUrls.add(match[1]);
}
}
}

for (var styleSheet of document.styleSheets) {
for (var cssRules of styleSheet.cssRules) {
var cssStyleRule = cssRules as CSSStyleRule;
var cssStyleSelector = cssStyleRule.selectorText;
var cssStyleRulesCount = cssStyleRule.style ? cssStyleRule.style.length : 0;

var displayedNodeElementsStyles: CSSStyleDeclaration[] | null = null;

for (var i = 0; i < cssStyleRulesCount; i++) {
var cssRuleName = cssStyleRule.style[i];
var cssRuleValue = cssStyleRule.style[cssRuleName as keyof CSSStyleDeclaration] as string;

if (!cssRuleValue || (!cssRuleValue.startsWith('url("') && !cssRuleValue.startsWith("url('"))) {
continue;
}

if (!displayedNodeElementsStyles) {
displayedNodeElementsStyles = [] as CSSStyleDeclaration[];

document.querySelectorAll<HTMLElement>(cssStyleSelector).forEach(function (node) {
if (!node.clientHeight || !node.clientWidth) {
return;
}

(displayedNodeElementsStyles as CSSStyleDeclaration[]).push(getComputedStyle(node));
});
}

for (var nodeStyles of displayedNodeElementsStyles) {
var computedStyleValue = nodeStyles.getPropertyValue(cssRuleName);
var match = styleWithUrlRegExp.exec(computedStyleValue);

if (match && match[1] && !match[1].startsWith("data:")) {
waitingForResourceUrls.add(match[1]);
}
}
}
}
}

var performanceResourceEntries = performance.getEntriesByType("resource") as PerformanceResourceTiming[];

performanceResourceEntries.forEach(function (performanceResourceEntry) {
waitingForResourceUrls.delete(performanceResourceEntry.name);
});

if (!waitingForResourceUrls.size) {
return { ready: true };
}

var pendingResources = Array.from(waitingForResourceUrls);

return { ready: false, reason: "Resources are not loaded", pendingResources };
}

function browserAreResourcesLoaded(pendingResources: string[]): string[] {
var pendingResourcesSet = new Set(pendingResources);
var performanceResourceEntries = performance.getEntriesByType("resource") as PerformanceResourceTiming[];

performanceResourceEntries.forEach(function (performanceResourceEntry) {
pendingResourcesSet.delete(performanceResourceEntry.name);
});

return Array.from(pendingResourcesSet);
}
/* eslint-enable no-var */

export type WaitForStaticToLoadResult =
| { ready: true }
| { ready: false; reason: string }
| { ready: false; reason: "Resources are not loaded"; pendingResources: string[] };

export default (browser: Browser): void => {
const { publicAPI: session } = browser;

session.addCommand(
"waitForStaticToLoad",
async function ({
timeout = browser.config.waitTimeout,
interval = browser.config.waitInterval,
} = {}): Promise<WaitForStaticToLoadResult> {
let isTimedOut = false;

const loadTimeout = setTimeout(() => {
isTimedOut = true;
}, timeout).unref();
const warnTimedOut = (result: ReturnType<typeof browserIsPageReady>): void => {
const timedOutMsg = `Timed out waiting for page to load in ${timeout}ms.`;

if (result && result.pendingResources) {
logger.warn(
[
`${timedOutMsg} Several resources are still not loaded:`,
...result.pendingResources.map(resouce => `- ${resouce}`),
].join("\n"),
);
} else {
logger.warn(`${timedOutMsg} ${result.reason}`);
}
};

let result = await session.execute(browserIsPageReady);

while (!isTimedOut && !result.ready) {
await new Promise(resolve => setTimeout(resolve, interval));

if (result.pendingResources) {
result.pendingResources = await session.execute(browserAreResourcesLoaded, result.pendingResources);
result.ready = result.pendingResources.length === 0;
} else {
result = await session.execute(browserIsPageReady);
}
}

clearTimeout(loadTimeout);

if (isTimedOut && !result.ready) {
warnTimedOut(result);
}

if (result.ready) {
return { ready: true };
}

if (result.reason === "Resources are not loaded") {
return { ready: false, reason: "Resources are not loaded", pendingResources: result.pendingResources };
}

return { ready: false, reason: result.reason as string };
},
);
};
6 changes: 6 additions & 0 deletions src/browser/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import type { Callstack } from "./history/callstack";
import type { Test, Hook } from "../test-reader/test-object";
import type { CaptureSnapshotOptions, CaptureSnapshotResult } from "./commands/captureDomSnapshot";
import type { Options } from "@testplane/wdio-types";
import type { WaitForStaticToLoadResult } from "./commands/waitForStaticToLoad";

export const BrowserName = {
CHROME: "chrome" as PuppeteerBrowser.CHROME,
Expand Down Expand Up @@ -161,6 +162,11 @@ declare global {

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

waitForStaticToLoad: (
this: WebdriverIO.Browser,
opts?: { timeout?: number; interval?: number },
) => Promise<WaitForStaticToLoadResult>;

unstable_captureDomSnapshot(
this: WebdriverIO.Browser,
options?: Partial<CaptureSnapshotOptions>,
Expand Down
1 change: 1 addition & 0 deletions src/config/defaults.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ module.exports = {
captureElementFromTop: true,
allowViewportOverflow: false,
ignoreDiffPixelCount: 0,
waitForStaticToLoadTimeout: 5000,
},
openAndWaitOpts: {
waitNetworkIdle: true,
Expand Down
16 changes: 16 additions & 0 deletions src/config/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,22 @@ export interface AssertViewOpts {
* @defaultValue `0`
*/
ignoreDiffPixelCount?: `${number}%` | number;
/**
* Ability to wait for page to be fully ready before making screenshot.
* This ensures (in following order):
* - no script is running at the moment;
* - fonts are no longer loading
* - images are no longer loading
* - external styles are loaded
* - external scripts are no longer loading
*
* @remarks
* If page is still not ready after non-zero timeout, there would only be a warning about it, no error is thrown.
*
* @note
* Setting it to zero disables waiting for page to be ready.
*/
waitForStaticToLoadTimeout?: number;
}

export interface ExpectOptsConfig {
Expand Down
18 changes: 18 additions & 0 deletions test/src/browser/commands/assert-view/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,24 @@ describe("assertView command", () => {
);
});

it("should not call 'waitForStaticToLoad'", async () => {
const browser = await initBrowser_();
browser.publicAPI.waitForStaticToLoad = sandbox.stub().resolves();

await browser.publicAPI.assertView("plain", { waitForStaticToLoadTimeout: 0 });

assert.notCalled(browser.publicAPI.waitForStaticToLoad);
});

it("should call 'waitForStaticToLoad'", async () => {
const browser = await initBrowser_();
browser.publicAPI.waitForStaticToLoad = sandbox.stub().resolves();

await browser.publicAPI.assertView("plain", { waitForStaticToLoadTimeout: 3000 });

assert.calledOnceWith(browser.publicAPI.waitForStaticToLoad, { timeout: 3000, interval: 300 });
});

[
{ scope: "browser", fn: assertViewBrowser },
{ scope: "element", fn: assertViewElement },
Expand Down
Loading
Loading