diff --git a/src/browser/commands/assert-view/index.js b/src/browser/commands/assert-view/index.js index c9b13f93e..97e2da070 100644 --- a/src/browser/commands/assert-view/index.js +++ b/src/browser/commands/assert-view/index.js @@ -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); diff --git a/src/browser/commands/index.ts b/src/browser/commands/index.ts index 3586f6c71..0bb42969f 100644 --- a/src/browser/commands/index.ts +++ b/src/browser/commands/index.ts @@ -9,4 +9,5 @@ export const customCommandFileNames = [ "switchToRepl", "moveCursorTo", "captureDomSnapshot", + "waitForStaticToLoad", ]; diff --git a/src/browser/commands/waitForStaticToLoad.ts b/src/browser/commands/waitForStaticToLoad.ts new file mode 100644 index 000000000..4fc2903f4 --- /dev/null +++ b/src/browser/commands/waitForStaticToLoad.ts @@ -0,0 +1,201 @@ +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('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(); + + var nodesWithInlineStylesWithUrl = document.querySelectorAll('[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) { + try { + 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(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]); + } + } + } + } + } catch (err) {} // eslint-disable-line no-empty + } + + 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 { + let isTimedOut = false; + + const loadTimeout = setTimeout(() => { + isTimedOut = true; + }, timeout).unref(); + const warnTimedOut = (result: ReturnType): 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 }; + }, + ); +}; diff --git a/src/browser/types.ts b/src/browser/types.ts index a3283ac15..00a16948c 100644 --- a/src/browser/types.ts +++ b/src/browser/types.ts @@ -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, @@ -161,6 +162,11 @@ declare global { clearSession: (this: WebdriverIO.Browser) => Promise; + waitForStaticToLoad: ( + this: WebdriverIO.Browser, + opts?: { timeout?: number; interval?: number }, + ) => Promise; + unstable_captureDomSnapshot( this: WebdriverIO.Browser, options?: Partial, diff --git a/src/config/defaults.js b/src/config/defaults.js index 1af25c836..4269a6879 100644 --- a/src/config/defaults.js +++ b/src/config/defaults.js @@ -29,6 +29,7 @@ module.exports = { captureElementFromTop: true, allowViewportOverflow: false, ignoreDiffPixelCount: 0, + waitForStaticToLoadTimeout: 5000, }, openAndWaitOpts: { waitNetworkIdle: true, diff --git a/src/config/types.ts b/src/config/types.ts index 3bfc237ca..99911b616 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -122,6 +122,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 { diff --git a/test/src/browser/commands/assert-view/index.js b/test/src/browser/commands/assert-view/index.js index c310a983e..bf8d37141 100644 --- a/test/src/browser/commands/assert-view/index.js +++ b/test/src/browser/commands/assert-view/index.js @@ -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 }, diff --git a/test/src/browser/commands/waitForStaticToLoad.ts b/test/src/browser/commands/waitForStaticToLoad.ts new file mode 100644 index 000000000..08703d7b0 --- /dev/null +++ b/test/src/browser/commands/waitForStaticToLoad.ts @@ -0,0 +1,315 @@ +import sinon, { SinonStub } from "sinon"; +import FakeTimers from "@sinonjs/fake-timers"; + +import { mkExistingBrowser_ as mkBrowser_, mkSessionStub_ as mkSessionStubOrigin_ } from "../utils"; + +import type { ExistingBrowser as ExistingBrowserOriginal } from "src/browser/existing-browser"; +import { Calibrator } from "src/browser/calibrator"; +import proxyquire from "proxyquire"; +import type { WaitForStaticToLoadResult } from "src/browser/commands/waitForStaticToLoad"; + +type SessionOrigin = ReturnType; +type Session = SessionOrigin & { + waitForStaticToLoad(opts?: { timeout?: number; interval?: number }): Promise; +}; + +const mkSessionStub_ = (): Session => { + return mkSessionStubOrigin_() as Session; +}; + +describe('"waitForStaticToLoad" command', () => { + const sandbox = sinon.createSandbox(); + let clock: FakeTimers.InstalledClock; + let ExistingBrowser: typeof ExistingBrowserOriginal; + let webdriverioAttachStub: SinonStub; + let loggerWarnStub: SinonStub; + + const initBrowser_ = ({ + browser = mkBrowser_(undefined, undefined, ExistingBrowser), + session = mkSessionStub_(), + } = {}): Promise => { + webdriverioAttachStub.resolves(session); + + return browser.init({ sessionId: session.sessionId, sessionCaps: session.capabilities }, {} as Calibrator); + }; + + beforeEach(() => { + clock = FakeTimers.install(); + webdriverioAttachStub = sandbox.stub(); + loggerWarnStub = sandbox.stub(); + ExistingBrowser = proxyquire("src/browser/existing-browser", { + "@testplane/webdriverio": { + attach: webdriverioAttachStub, + }, + "./client-bridge": { + build: sandbox.stub().resolves(), + }, + "../utils/logger": { + warn: loggerWarnStub, + }, + "./commands/waitForStaticToLoad": proxyquire("src/browser/commands/waitForStaticToLoad", { + "../../utils/logger": { + warn: loggerWarnStub, + }, + }), + }).ExistingBrowser; + }); + + afterEach(async () => { + await clock.runAllAsync(); + clock.uninstall(); + global.window = undefined as unknown as Window & typeof globalThis; + sandbox.restore(); + }); + + it("should add command", async () => { + const session = mkSessionStub_(); + + await initBrowser_({ session }); + + assert.calledWith(session.addCommand, "waitForStaticToLoad", sinon.match.func); + }); + + it("should return ready true when page is ready immediately", async () => { + const session = mkSessionStub_(); + session.execute.resolves({ ready: true }); + + await initBrowser_({ session }); + + const result = await session.waitForStaticToLoad(); + + assert.deepEqual(result, { ready: true }); + assert.calledOnce(session.execute); + }); + + it("should use default timeout and interval from browser config", async () => { + const browser = mkBrowser_({ waitTimeout: 5000, waitInterval: 100 }, undefined, ExistingBrowser); + const session = mkSessionStub_(); + session.execute.resolves({ ready: true }); + + await initBrowser_({ browser, session }); + + await session.waitForStaticToLoad(); + + assert.calledOnce(session.execute); + }); + + it("should use custom timeout and interval when provided", async () => { + const session = mkSessionStub_(); + session.execute.resolves({ ready: true }); + + await initBrowser_({ session }); + + await session.waitForStaticToLoad({ timeout: 3000, interval: 50 }); + + assert.calledOnce(session.execute); + }); + + it("should poll until page is ready", async () => { + const session = mkSessionStub_(); + session.execute + .onFirstCall() + .resolves({ ready: false, reason: "Document is loading" }) + .onSecondCall() + .resolves({ ready: false, reason: "Images loading" }) + .onThirdCall() + .resolves({ ready: true }); + + await initBrowser_({ session }); + + const promise = session.waitForStaticToLoad({ timeout: 1000, interval: 100 }); + + await clock.tickAsync(250); + + const result = await promise; + + assert.deepEqual(result, { ready: true }); + assert.calledThrice(session.execute); + }); + + it("should handle pending resources polling", async () => { + const session = mkSessionStub_(); + session.execute + .onFirstCall() + .resolves({ + ready: false, + reason: "Resources are not loaded", + pendingResources: ["image1.jpg", "style.css"], + }) + .onSecondCall() + .resolves(["image1.jpg"]) // browserAreResourcesLoaded result + .onThirdCall() + .resolves([]); // browserAreResourcesLoaded result - all loaded + + await initBrowser_({ session }); + + const promise = session.waitForStaticToLoad({ timeout: 1000, interval: 100 }); + + await clock.tickAsync(250); + + const result = await promise; + + assert.deepEqual(result, { ready: true }); + assert.calledThrice(session.execute); + }); + + it("should return pending resources when not ready after timeout", async () => { + const session = mkSessionStub_(); + session.execute + .onFirstCall() + .resolves({ + ready: false, + reason: "Resources are not loaded", + pendingResources: ["image1.jpg", "style.css"], + }) + .resolves(["image1.jpg", "style.css"]); + + await initBrowser_({ session }); + + const promise = session.waitForStaticToLoad({ timeout: 200, interval: 50 }); + + await clock.tickAsync(250); + + const result = await promise; + + assert.deepEqual(result, { + ready: false, + reason: "Resources are not loaded", + pendingResources: ["image1.jpg", "style.css"], + }); + }); + + it("should return reason when not ready after timeout", async () => { + const session = mkSessionStub_(); + session.execute.resolves({ ready: false, reason: "Document is loading" }); + + await initBrowser_({ session }); + + const promise = session.waitForStaticToLoad({ timeout: 200, interval: 50 }); + + await clock.tickAsync(250); + + const result = await promise; + + assert.deepEqual(result, { ready: false, reason: "Document is loading" }); + }); + + it("should warn when timeout occurs with pending resources", async () => { + const session = mkSessionStub_(); + session.execute + .onFirstCall() + .resolves({ + ready: false, + reason: "Resources are not loaded", + pendingResources: ["image1.jpg", "style.css"], + }) + .resolves(["image1.jpg", "style.css"]); + + await initBrowser_({ session }); + + const promise = session.waitForStaticToLoad({ timeout: 200, interval: 50 }); + + await clock.tickAsync(250); + + await promise; + + assert.calledOnceWith( + loggerWarnStub, + "Timed out waiting for page to load in 200ms. Several resources are still not loaded:\n- image1.jpg\n- style.css", + ); + }); + + it("should warn when timeout occurs with reason", async () => { + const session = mkSessionStub_(); + session.execute.resolves({ ready: false, reason: "Document is loading" }); + + await initBrowser_({ session }); + + const promise = session.waitForStaticToLoad({ timeout: 200, interval: 50 }); + + await clock.tickAsync(250); + + await promise; + + assert.calledOnceWith(loggerWarnStub, "Timed out waiting for page to load in 200ms. Document is loading"); + }); + + it("should not warn when page becomes ready before timeout", async () => { + const session = mkSessionStub_(); + session.execute + .onFirstCall() + .resolves({ ready: false, reason: "Document is loading" }) + .onSecondCall() + .resolves({ ready: true }); + + await initBrowser_({ session }); + + const promise = session.waitForStaticToLoad({ timeout: 1000, interval: 100 }); + + await clock.tickAsync(150); + + await promise; + + assert.notCalled(loggerWarnStub); + }); + + it("should handle mixed polling scenarios", async () => { + const session = mkSessionStub_(); + session.execute + .onCall(0) + .resolves({ ready: false, reason: "Document is loading" }) + .onCall(1) + .resolves({ + ready: false, + reason: "Resources are not loaded", + pendingResources: ["image1.jpg", "style.css", "font.woff"], + }) + .onCall(2) + .resolves(["image1.jpg", "font.woff"]) // some resources loaded + .onCall(3) + .resolves(["font.woff"]) // more resources loaded + .onCall(4) + .resolves([]); // all resources loaded + + await initBrowser_({ session }); + + const promise = session.waitForStaticToLoad({ timeout: 1000, interval: 50 }); + + await clock.tickAsync(300); + + const result = await promise; + + assert.deepEqual(result, { ready: true }); + assert.callCount(session.execute, 5); + }); + + it("should handle timeout during resource polling", async () => { + const session = mkSessionStub_(); + session.execute + .onCall(0) + .resolves({ + ready: false, + reason: "Resources are not loaded", + pendingResources: ["image1.jpg", "style.css"], + }) + .resolves(["image1.jpg"]); // partially loaded + + await initBrowser_({ session }); + + const promise = session.waitForStaticToLoad({ timeout: 150, interval: 50 }); + + await clock.tickAsync(200); + + const result = await promise; + + assert.deepEqual(result, { + ready: false, + reason: "Resources are not loaded", + pendingResources: ["image1.jpg"], + }); + assert.calledWith( + loggerWarnStub, + "Timed out waiting for page to load in 150ms. Several resources are still not loaded:\n- image1.jpg", + ); + }); +});