UNPKG

creevey

Version:

Cross-browser screenshot testing tool for Storybook with fancy UI Runner

847 lines (747 loc) 30.7 kB
import type { Args } from 'storybook/internal/types'; import chalk from 'chalk'; import http from 'http'; import https from 'https'; import Logger from 'loglevel'; import prefix from 'loglevel-plugin-prefix'; import { PNG } from 'pngjs'; import { Builder, By, Capabilities, Origin, WebDriver, WebElement, logging } from 'selenium-webdriver'; // import { Options as IeOptions } from 'selenium-webdriver/ie'; // import { Options as EdgeOptions } from 'selenium-webdriver/edge'; // import { Options as ChromeOptions } from 'selenium-webdriver/chrome'; // import { Options as SafariOptions } from 'selenium-webdriver/safari'; // import { Options as FirefoxOptions } from 'selenium-webdriver/firefox'; import { PageLoadStrategy } from 'selenium-webdriver/lib/capabilities.js'; import { BrowserConfigObject, Config, noop, StorybookGlobals, StoryInput, StoriesRaw, StorybookEvents, } from '../../types.js'; import { colors, logger } from '../logger.js'; import { emitWorkerMessage, subscribeOn } from '../messages.js'; import { isShuttingDown, runSequence } from '../utils.js'; import { appendIframePath, LOCALHOST_REGEXP, resolveStorybookUrl, storybookRootID } from '../webdriver.js'; import { getStories, insertIgnoreStyles, removeIgnoreStyles, selectStory } from '../storybook-helpers.js'; interface ElementRect { top: number; left: number; width: number; height: number; } // type UnPromise<P> = P extends Promise<infer T> ? T : never; // let context: UnPromise<ReturnType<typeof BrowsingContext>> | null = null; function getSessionData(grid: string, sessionId = ''): Promise<Record<string, unknown>> { const gridUrl = new URL(grid); gridUrl.pathname = `/host/${sessionId}`; return new Promise((resolve, reject) => (gridUrl.protocol == 'https:' ? https : http).get(gridUrl.toString(), (res) => { if (res.statusCode !== 200) { reject(new Error(`Couldn't get session data for ${sessionId}. Status code: ${res.statusCode ?? 'Unknown'}`)); return; } let data = ''; res.setEncoding('utf-8'); res.on('data', (chunk: string) => (data += chunk)); res.on('end', () => { try { resolve(JSON.parse(data) as Record<string, unknown>); } catch (error) { reject( new Error( `Couldn't get session data for ${sessionId}. ${ error instanceof Error ? (error.stack ?? error.message) : (error as string) }`, ), ); } }); }), ); } async function openUrlAndWaitForPageSource( browser: WebDriver, url: string, predicate: (source: string) => boolean, ): Promise<string> { let source = ''; await browser.get(url); do { try { source = await browser.getPageSource(); } catch { // NOTE: Firefox can raise exception "curContainer.frame.document.documentElement is null" } } while (predicate(source)); return source; } async function buildWebdriver( browser: string, gridUrl: string, config: Config, debug: boolean, ): Promise<WebDriver | null> { const browserConfig = config.browsers[browser] as BrowserConfigObject; const { /*customizeBuilder,*/ seleniumCapabilities, browserName, connectionTimeout } = browserConfig; // Use browser-specific or global or default timeout (60 seconds) const timeout = connectionTimeout ?? config.connectionTimeout ?? 60_000; const url = new URL(gridUrl); url.username = url.username ? '********' : ''; url.password = url.password ? '********' : ''; logger().debug(`Connecting to Selenium ${chalk.magenta(url.toString())}`); // TODO Define some capabilities explicitly and define typings const capabilities = new Capabilities({ browserName, ...seleniumCapabilities, pageLoadStrategy: PageLoadStrategy.EAGER, }); const prefs = new logging.Preferences(); if (debug) { for (const type of Object.values(logging.Type)) { prefs.setLevel(type as string, logging.Level.ALL); } } // TODO Fetch selenium grid capabilities // TODO Validate browsers, versions, and platform // TODO Use `customizeBuilder` let webdriver: WebDriver | null; try { const maxRetries = 5; let retries = 0; do { webdriver = await Promise.race([ new Promise<null>((resolve) => { setTimeout(() => { retries += 1; resolve(null); }, timeout); }), (async () => { if (retries > 0) { logger().debug(`Trying to initialize session to Selenium Grid: retried ${retries} of ${maxRetries}`); } const retry = retries; // const ie = new IeOptions(); // const edge = new EdgeOptions(); // const chrome = new ChromeOptions(); // const safari = new SafariOptions(); // const firefox = new FirefoxOptions(); // edge.enableBidi(); // chrome.enableBidi(); // firefox.enableBidi(); const driver = await new Builder() // .setIeOptions(ie) // .setEdgeOptions(edge) // .setChromeOptions(chrome) // .setSafariOptions(safari) // .setFirefoxOptions(firefox) .usingServer(gridUrl) .withCapabilities(capabilities) .setLoggingPrefs(prefs) // NOTE: Should go last .build(); // const id = await driver.getWindowHandle(); // context = await BrowsingContext(driver, { browsingContextId: id }); if (retry != retries) { void driver.quit().catch(() => { /* noop */ }); return null; } return driver; })(), ]); if (webdriver) break; } while (retries < maxRetries); if (!webdriver) throw new Error('Failed to initialize session to Selenium Grid due to many retries'); } catch (error) { logger().error(`Failed to start browser:`, error); return null; } return webdriver; } export class InternalBrowser { #isShuttingDown = false; #browser: WebDriver; #storybookGlobals?: StorybookGlobals; #unsubscribe: () => void = noop; #keepAliveInterval: NodeJS.Timeout | null = null; #sessionId = ''; constructor(browser: WebDriver, storybookGlobals?: StorybookGlobals) { this.#browser = browser; this.#storybookGlobals = storybookGlobals; this.#unsubscribe = subscribeOn('shutdown', () => { void this.closeBrowser(); }); } get browser() { return this.#browser; } async closeBrowser(): Promise<void> { if (this.#isShuttingDown) return; this.#isShuttingDown = true; this.#unsubscribe(); if (this.#keepAliveInterval !== null) clearInterval(this.#keepAliveInterval); try { await this.#browser.quit(); } catch { /* noop */ } } async takeScreenshot(captureElement?: string | null, ignoreElements?: string | string[] | null): Promise<Buffer> { let screenshot: string | Buffer; const ignoreStyles = await this.insertIgnoreStyles(ignoreElements); if (logger().getLevel() <= Logger.levels.DEBUG) { const { innerWidth, innerHeight } = await this.#browser.executeScript<{ innerWidth: number; innerHeight: number; }>(function () { return { innerWidth: window.innerWidth, innerHeight: window.innerHeight, }; }); logger().debug(`Viewport size is: ${innerWidth}x${innerHeight}`); } try { if (!captureElement) { logger().debug('Capturing viewport screenshot'); screenshot = await this.#browser.takeScreenshot(); logger().debug('Viewport screenshot is captured'); } else { logger().debug(`Checking is element ${chalk.cyan(captureElement)} fit into viewport`); const rects = await this.#browser.executeScript< { elementRect: ElementRect; windowRect: ElementRect } | undefined >(function (selector: string): { elementRect: ElementRect; windowRect: ElementRect } | undefined { window.scrollTo(0, 0); // eslint-disable-next-line no-var var element = document.querySelector(selector); if (!element) return; // eslint-disable-next-line no-var var elementRect = element.getBoundingClientRect(); return { elementRect: { top: elementRect.top, left: elementRect.left, width: elementRect.width, height: elementRect.height, }, // NOTE page_Offset is used only for IE9-11 windowRect: { top: Math.round(window.scrollY || window.pageYOffset), left: Math.round(window.scrollX || window.pageXOffset), width: window.innerWidth, height: window.innerHeight, }, }; }, captureElement); const { elementRect, windowRect } = rects ?? {}; if (!elementRect || !windowRect) throw new Error(`Couldn't find element with selector: '${captureElement}'`); const isFitIntoViewport = elementRect.width + elementRect.left <= windowRect.width && elementRect.height + elementRect.top <= windowRect.height; if (isFitIntoViewport) { logger().debug( `Capturing ${chalk.cyan(captureElement)} with size: ${elementRect.width}x${elementRect.height}`, ); } else logger().debug( `Capturing composite screenshot image of ${chalk.cyan(captureElement)} with size: ${elementRect.width}x${elementRect.height}`, ); // const element = await browser.findElement(By.css(captureElement)); // screenshot = isFitIntoViewport // ? context // ? await context.captureElementScreenshot(await element.getId()) // : await browser.findElement(By.css(captureElement)).takeScreenshot() // : await takeCompositeScreenshot(browser, windowRect, elementRect); screenshot = isFitIntoViewport ? await this.#browser.findElement(By.css(captureElement)).takeScreenshot() : await this.takeCompositeScreenshot(windowRect, elementRect); logger().debug(`${chalk.cyan(captureElement)} is captured`); } } finally { await this.removeIgnoreStyles(ignoreStyles); } return typeof screenshot === 'string' ? Buffer.from(screenshot, 'base64') : screenshot; } async selectStory(id: string): Promise<void> { const sessionId = await this.#browser.executeScript(() => window.__CREEVEY_SESSION_ID__); if (sessionId !== this.#sessionId) { const done = await this.initStorybook(); if (!done) return; } await this.#browser.executeScript(() => delete window.__CREEVEY_SELECT_STORY_RESULT__); await this.resetMousePosition(); logger().debug(`Triggering 'SetCurrentStory' event with storyId ${chalk.magenta(id)}`); void this.#browser.executeScript<string | null>(selectStory, id).catch(() => { /* noop */ }); let isWaitingForStory = true; const result = await Promise.race([ new Promise<{ type: 'timeout' }>((resolve) => { setTimeout(() => { isWaitingForStory = false; resolve({ type: 'timeout' }); }, 60000); }), (async () => { // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition while (isWaitingForStory) { const [selectResult, sessionId] = await this.#browser.executeScript< [typeof window.__CREEVEY_SELECT_STORY_RESULT__, typeof window.__CREEVEY_SESSION_ID__] >(() => [window.__CREEVEY_SELECT_STORY_RESULT__, window.__CREEVEY_SESSION_ID__]); if (selectResult) return { type: 'select', ...selectResult } as const; if (sessionId !== this.#sessionId) return { type: 'reload' } as const; await new Promise((resolve) => setTimeout(resolve, 100)); } return { type: 'timeout' } as const; })(), ]); if (result.type === 'timeout') throw new Error('Story selection timed out'); if (result.type === 'reload') { logger().debug('Storybook page has been reloaded during story selection'); const done = await this.initStorybook(); if (!done) return; } if (result.type === 'select' && result.status === 'error') { throw new Error(`Failed to select story: ${result.message}`); } } async updateStoryArgs(story: StoryInput, updatedArgs: Args): Promise<void> { await this.#browser.executeAsyncScript<undefined>( function ( storyId: string, updatedArgs: Args, UPDATE_STORY_ARGS: string, STORY_RENDERED: string, callback: () => void, ) { window.__STORYBOOK_ADDONS_CHANNEL__.once(STORY_RENDERED, callback); window.__STORYBOOK_ADDONS_CHANNEL__.emit(UPDATE_STORY_ARGS, { storyId, updatedArgs, }); }, story.id, updatedArgs, StorybookEvents.UPDATE_STORY_ARGS, StorybookEvents.STORY_RENDERED, ); } async loadStoriesFromBrowser(): Promise<StoriesRaw> { return await this.#browser.executeAsyncScript(getStories); } async afterTest(): Promise<void> { if (logger().getLevel() <= Logger.levels.DEBUG) { const logs = await this.#browser.manage().logs().get('browser'); for (const log of logs) { logger().debug(`Console message: ${new Date(log.timestamp).toISOString()} - ${log.message}`); } } } static async getBrowser( browserName: string, gridUrl: string, config: Config, debug: boolean, ): Promise<InternalBrowser | null> { const browserConfig = config.browsers[browserName] as BrowserConfigObject; const { storybookUrl: address = config.storybookUrl, limit, viewport, // eslint-disable-next-line @typescript-eslint/no-deprecated _storybookGlobals, storybookGlobals = _storybookGlobals, } = browserConfig; void limit; const browser = await buildWebdriver(browserName, gridUrl, config, debug); if (!browser) return null; const internalBrowser = new InternalBrowser(browser, storybookGlobals); try { if (isShuttingDown.current) return null; const done = await internalBrowser.init({ browserName, gridUrl, viewport, storybookUrl: address, }); return done ? internalBrowser : null; } catch (originalError) { void internalBrowser.closeBrowser(); const message = originalError instanceof Error ? originalError.message : ((originalError ?? 'Unknown error') as string); const error = new Error(`Can't load storybook root page: ${message}`); if (originalError instanceof Error) error.stack = originalError.stack; logger().error(error); return null; } } private async init({ browserName, gridUrl, viewport, storybookUrl, }: { browserName: string; gridUrl: string; viewport?: { width: number; height: number }; storybookUrl: string; }): Promise<boolean> { const sessionId = (await this.#browser.getSession()).getId(); let browserHost = ''; try { const { Name } = await getSessionData(gridUrl, sessionId); if (typeof Name == 'string') browserHost = Name; } catch { /* noop */ } prefix.apply(logger(), { format(level) { const levelColor = colors[level.toUpperCase() as keyof typeof colors]; return `[${browserName}:${chalk.gray(process.pid)}] ${levelColor(level)} => ${chalk.gray(sessionId)}`; }, }); logger().debug(`Connected successful with ${chalk.green(browserHost)}`); this.#sessionId = sessionId; return await runSequence( [ () => this.#browser.manage().setTimeouts({ pageLoad: 60000, script: 60000 }), () => this.openStorybookPage(storybookUrl), () => this.initStorybook(), // NOTE: Selenium draws automation toolbar with some delay after webdriver initialization // NOTE: So if we resize window right after getting webdriver instance we might get situation // NOTE: When the toolbar appears after resize and final viewport size become smaller than we set () => this.resizeViewport(viewport), () => { this.keepAlive(); }, ], () => !this.#isShuttingDown, ); } private async initStorybook() { await this.#browser.executeScript((id: string) => (window.__CREEVEY_SESSION_ID__ = id), this.#sessionId); return await runSequence( [() => this.waitForStorybook(), () => this.loadStorybookStories(), () => this.defineGlobals()], () => !this.#isShuttingDown, ); } private async openStorybookPage(storybookUrl: string): Promise<void> { if (!LOCALHOST_REGEXP.test(storybookUrl)) { return this.#browser.get(appendIframePath(storybookUrl)); } try { // NOTE: getUrlChecker already calls `browser.get` so we don't need another one await resolveStorybookUrl(appendIframePath(storybookUrl), (url) => this.checkUrl(url)); } catch (error) { logger().error('Failed to resolve storybook URL', error instanceof Error ? error.message : ''); throw error; } } private async checkUrl(url: string): Promise<boolean> { try { // NOTE: Before trying a new url, reset the current one logger().debug(`Opening ${chalk.magenta('about:blank')} page`); await openUrlAndWaitForPageSource( this.#browser, 'about:blank', (source: string) => !source.includes('<body></body>'), ); logger().debug(`Opening ${chalk.magenta(url)} and checking the page source`); const source = await openUrlAndWaitForPageSource( this.#browser, url, // NOTE: IE11 can return only `head` without body (source: string) => source.length == 0 || !/<body([^>]*>).+<\/body>/s.test(source), ); // NOTE: This is the most optimal way to check if we in storybook or not // We don't use any page load strategies except `NONE` // because other add significant delay and some of them don't work in earlier chrome versions // Browsers always load page successful even it's failed // So we just check `root` element logger().debug(`Checking ${chalk.cyan(`#${storybookRootID}`)} existence on ${chalk.magenta(url)}`); return source.includes(`id="${storybookRootID}"`); } catch { return false; } } private async waitForStorybook(): Promise<void> { logger().debug('Waiting for Storybook to initiate'); void this.#browser .executeScript(function () { requestAnimationFrame(check); function check() { if ( document.readyState !== 'complete' || typeof window.__STORYBOOK_PREVIEW__ === 'undefined' || typeof window.__STORYBOOK_ADDONS_CHANNEL__ === 'undefined' || (!('ready' in window.__STORYBOOK_PREVIEW__) && window.__STORYBOOK_ADDONS_CHANNEL__.last('setGlobals') === undefined) ) { requestAnimationFrame(check); return; } if ('ready' in window.__STORYBOOK_PREVIEW__) { // NOTE: Storybook <= 7.x doesn't have ready() method void window.__STORYBOOK_PREVIEW__.ready().then(() => (window.__CREEVYE_STORYBOOK_READY__ = true)); } else { window.__CREEVYE_STORYBOOK_READY__ = true; } } }) .catch(() => { /* noop */ }); let isWaitingForStorybook = true; const isTimeout = await Promise.race([ new Promise<boolean>((resolve) => { setTimeout(() => { isWaitingForStorybook = false; resolve(true); }, 60000); }), (async () => { // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition while (isWaitingForStorybook) { if (await this.#browser.executeScript(() => window.__CREEVYE_STORYBOOK_READY__)) return false; else await new Promise((resolve) => setTimeout(resolve, 100)); } return true; })(), ]); if (isTimeout) throw new Error('Failed to wait Storybook init'); } private async loadStorybookStories() { logger().debug('Loading Storybook stories'); void this.#browser .executeScript(() => { void window.__STORYBOOK_PREVIEW__.extract().then((stories) => { window.__CREEVEY_STORYBOOK_STORIES__ = stories; }); }) .catch(() => { /* noop */ }); let isWaitingForStories = true; const result = await Promise.race([ new Promise<{ type: 'timeout' }>((resolve) => { setTimeout(() => { isWaitingForStories = false; resolve({ type: 'timeout' }); }, 60000); }), (async () => { // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition while (isWaitingForStories) { const [hasStories, sessionId] = await this.#browser.executeScript<[boolean, string]>(() => [ Boolean(window.__CREEVEY_STORYBOOK_STORIES__), window.__CREEVEY_SESSION_ID__, ]); if (hasStories) return { type: 'stories' } as const; if (sessionId !== this.#sessionId) return { type: 'reload' } as const; await new Promise((resolve) => setTimeout(resolve, 100)); } return { type: 'timeout' } as const; })(), ]); if (result.type === 'timeout') throw new Error('Failed to load Storybook stories'); if (result.type === 'reload') await this.waitForStorybook(); } private async defineGlobals(): Promise<void> { logger().debug('Defining Storybook globals'); await this.#browser.executeAsyncScript(function (userGlobals: StorybookGlobals | undefined, callback: () => void) { // @ts-expect-error https://github.com/evanw/esbuild/issues/2605#issuecomment-2050808084 window.__name = (func: unknown) => func; if (userGlobals) { window.__STORYBOOK_ADDONS_CHANNEL__.once('globalsUpdated', () => { callback(); }); window.__STORYBOOK_ADDONS_CHANNEL__.emit('updateGlobals', { globals: userGlobals }); } else { callback(); } }, this.#storybookGlobals); } private async resizeViewport(viewport?: { width: number; height: number }): Promise<void> { if (!viewport) return; const windowRect = await this.#browser.manage().window().getRect(); const { innerWidth, innerHeight } = await this.#browser.executeScript<{ innerWidth: number; innerHeight: number }>( function () { return { innerWidth: window.innerWidth, innerHeight: window.innerHeight, }; }, ); logger().debug(`Resizing viewport from ${innerWidth}x${innerHeight} to ${viewport.width}x${viewport.height}`); const dWidth = windowRect.width - innerWidth; const dHeight = windowRect.height - innerHeight; await this.#browser .manage() .window() .setRect({ width: viewport.width + dWidth, height: viewport.height + dHeight, }); } private async resetMousePosition(): Promise<void> { logger().debug('Resetting mouse position to the top-left corner'); const browserName = (await this.#browser.getCapabilities()).getBrowserName(); const [browserVersion] = (await this.#browser.getCapabilities()).getBrowserVersion()?.split('.') ?? ((await this.#browser.getCapabilities()).get('version') as string | undefined)?.split('.') ?? []; // NOTE Reset mouse position to support keweb selenium grid browser versions if (browserName == 'chrome' && browserVersion == '70') { const { top, left, width, height } = await this.#browser.executeScript<ElementRect>(function (): ElementRect { const bodyRect = document.body.getBoundingClientRect(); return { top: bodyRect.top, left: bodyRect.left, width: bodyRect.width, height: bodyRect.height, }; }); // NOTE Bridge mode doesn't support `Origin.VIEWPORT`, move mouse relative await this.#browser .actions({ bridge: true }) .move({ origin: this.#browser.findElement(By.css('body')), x: Math.ceil((-1 * width) / 2) - left, y: Math.ceil((-1 * height) / 2) - top, }) .perform(); } else if (browserName == 'firefox') { // NOTE Firefox for some reason moving by 0 x 0 move cursor in bottom left corner :sad: // NOTE In recent versions (eg 128.0) moving by 0 x 0 doesn't work at all await this.#browser.actions().move({ origin: Origin.VIEWPORT, x: 0, y: 1 }).perform(); } else { // NOTE IE don't emit move events until force window focus or connect by RDP on virtual machine await this.#browser.actions().move({ origin: Origin.VIEWPORT, x: 0, y: 0 }).perform(); } } private async insertIgnoreStyles(ignoreElements?: string | string[] | null): Promise<WebElement | null> { const ignoreSelectors = Array.prototype.concat(ignoreElements).filter(Boolean); if (!ignoreSelectors.length) return null; logger().debug('Hiding ignored elements before capturing'); return await this.#browser.executeScript(insertIgnoreStyles, ignoreSelectors); } private async takeCompositeScreenshot(windowRect: ElementRect, elementRect: ElementRect): Promise<Buffer> { const screens = []; const isScreenshotWithoutScrollBar = !(await this.hasScrollBar()); const scrollBarWidth = await this.getScrollBarWidth(); // NOTE Sometimes viewport has been scrolled somewhere const normalizedElementRect = { left: elementRect.left - windowRect.left, right: elementRect.left + elementRect.width - windowRect.left, top: elementRect.top - windowRect.top, bottom: elementRect.top + elementRect.height - windowRect.top, }; const isFitHorizontally = windowRect.width >= elementRect.width + normalizedElementRect.left; const isFitVertically = windowRect.height >= elementRect.height + normalizedElementRect.top; const viewportWidth = windowRect.width - (isFitVertically ? 0 : scrollBarWidth); const viewportHeight = windowRect.height - (isFitHorizontally ? 0 : scrollBarWidth); const cols = Math.ceil(elementRect.width / viewportWidth); const rows = Math.ceil(elementRect.height / viewportHeight); const xOffset = Math.round( isFitHorizontally ? normalizedElementRect.left : Math.max(0, cols * viewportWidth - elementRect.width), ); const yOffset = Math.round( isFitVertically ? normalizedElementRect.top : Math.max(0, rows * viewportHeight - elementRect.height), ); for (let row = 0; row < rows; row += 1) { for (let col = 0; col < cols; col += 1) { const dx = Math.min( viewportWidth * col + normalizedElementRect.left, Math.max(0, normalizedElementRect.right - viewportWidth), ); const dy = Math.min( viewportHeight * row + normalizedElementRect.top, Math.max(0, normalizedElementRect.bottom - viewportHeight), ); await this.#browser.executeScript( function (x: number, y: number) { window.scrollTo(x, y); }, dx, dy, ); screens.push(await this.#browser.takeScreenshot()); } } const images = screens.map((s) => Buffer.from(s, 'base64')).map((b) => PNG.sync.read(b)); const compositeImage = new PNG({ width: Math.round(elementRect.width), height: Math.round(elementRect.height) }); for (let y = 0; y < compositeImage.height; y += 1) { for (let x = 0; x < compositeImage.width; x += 1) { const col = Math.floor(x / viewportWidth); const row = Math.floor(y / viewportHeight); const isLastCol = cols - col == 1; const isLastRow = rows - row == 1; const scrollOffset = isFitVertically || isScreenshotWithoutScrollBar ? 0 : scrollBarWidth; const i = (y * compositeImage.width + x) * 4; const j = // NOTE compositeImage(x, y) => image(x, y) ((y % viewportHeight) * (viewportWidth + scrollOffset) + (x % viewportWidth)) * 4 + // NOTE Offset for last row/col image (isLastRow ? yOffset * (viewportWidth + scrollOffset) * 4 : 0) + (isLastCol ? xOffset * 4 : 0); const image = images[row * cols + col]; compositeImage.data[i + 0] = image.data[j + 0]; compositeImage.data[i + 1] = image.data[j + 1]; compositeImage.data[i + 2] = image.data[j + 2]; compositeImage.data[i + 3] = image.data[j + 3]; } } return PNG.sync.write(compositeImage); } private async removeIgnoreStyles(ignoreStyles: WebElement | null): Promise<void> { if (ignoreStyles) { logger().debug('Revert hiding ignored elements'); await this.#browser.executeScript(removeIgnoreStyles, ignoreStyles); } } // NOTE Firefox and Safari take viewport screenshot without scrollbars private async hasScrollBar(): Promise<boolean> { const browserName = (await this.#browser.getCapabilities()).getBrowserName(); const [browserVersion] = (await this.#browser.getCapabilities()).getBrowserVersion()?.split('.') ?? []; return ( browserName != 'Safari' && // NOTE This need to work with keweb selenium grid !(browserName == 'firefox' && browserVersion == '61') ); } private async getScrollBarWidth(): Promise<number> { const scrollBarWidth = await this.#browser.executeScript<number>(function () { // eslint-disable-next-line no-var var div = document.createElement('div'); div.innerHTML = 'a'; // NOTE: In IE clientWidth is 0 if this div is empty. div.style.overflowY = 'scroll'; document.body.appendChild(div); // eslint-disable-next-line no-var var widthDiff = div.offsetWidth - div.clientWidth; document.body.removeChild(div); return widthDiff; }); return scrollBarWidth; } private keepAlive(): void { this.#keepAliveInterval = setInterval(() => { // NOTE Simple way to keep session alive void this.#browser .getCurrentUrl() .then((url) => { logger().debug('current url', chalk.magenta(url)); }) .catch((error: unknown) => { logger().error(error); emitWorkerMessage({ type: 'error', payload: { subtype: 'browser', error: 'Failed to ping browser' }, }); }); }, 10 * 1000); } }