creevey
Version:
Cross-browser screenshot testing tool for Storybook with fancy UI Runner
872 lines (764 loc) • 31.6 kB
text/typescript
import { Args } from '@storybook/csf';
import { SET_GLOBALS, UPDATE_STORY_ARGS, STORY_RENDERED } from '@storybook/core-events';
import chalk from 'chalk';
import http from 'http';
import https from 'https';
import Logger from 'loglevel';
import prefix from 'loglevel-plugin-prefix';
import { Context, Suite, Test } from 'mocha';
import { networkInterfaces } from 'os';
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 {
BrowserConfig,
Config,
CreeveyStoryParams,
isDefined,
noop,
StorybookGlobals,
StoryInput,
StoriesRaw,
Options,
} from '../../types.js';
import { colors, logger } from '../logger.js';
import { emitStoriesMessage, subscribeOn } from '../messages.js';
import { isShuttingDown, LOCALHOST_REGEXP, runSequence } from '../utils.js';
import { Preferences } from 'selenium-webdriver/lib/logging.js';
interface ElementRect {
top: number;
left: number;
width: number;
height: number;
}
declare global {
interface Window {
__CREEVEY_RESTORE_SCROLL__?: () => void;
__CREEVEY_UPDATE_GLOBALS__: (globals: StorybookGlobals) => void;
__CREEVEY_INSERT_IGNORE_STYLES__: (ignoreElements: string[]) => HTMLStyleElement;
__CREEVEY_REMOVE_IGNORE_STYLES__: (ignoreStyles: HTMLStyleElement) => void;
}
}
// type UnPromise<P> = P extends Promise<infer T> ? T : never;
const storybookRootID = 'storybook-root';
const DOCKER_INTERNAL = 'host.docker.internal';
let browserName = '';
let browser: WebDriver | null = null;
// let context: UnPromise<ReturnType<typeof BrowsingContext>> | null = null;
let creeveyServerHost: string | null = null;
let creeveyServerPort: number | 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)
}`,
),
);
}
});
}),
);
}
function getAddresses(): string[] {
// TODO Check if docker is used
return [DOCKER_INTERNAL].concat(
...Object.values(networkInterfaces())
.filter(isDefined)
.map((network) => network.filter((info) => info.family == 'IPv4').map((info) => info.address)),
);
}
async function resolveStorybookUrl(storybookUrl: string, checkUrl: (url: string) => Promise<boolean>): Promise<string> {
logger().debug('Resolving storybook url');
const addresses = getAddresses();
for (const ip of addresses) {
const resolvedUrl = storybookUrl.replace(LOCALHOST_REGEXP, ip);
logger().debug(`Checking storybook availability on ${chalk.magenta(resolvedUrl)}`);
if (await checkUrl(resolvedUrl)) {
logger().debug(`Resolved storybook url ${chalk.magenta(resolvedUrl)}`);
return resolvedUrl;
}
}
throw new Error('Please specify `storybookUrl` with IP address that accessible from remote browser');
}
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;
}
function getUrlChecker(browser: WebDriver): (url: string) => Promise<boolean> {
return async (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(browser, 'about:blank', (source: string) => !source.includes('<body></body>'));
logger().debug(`Opening ${chalk.magenta(url)} and checking the page source`);
const source = await openUrlAndWaitForPageSource(
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;
}
};
}
async function buildWebdriver(
gridUrl: string,
capabilities: Capabilities,
prefs: Preferences,
): Promise<readonly [string, WebDriver]> {
const maxRetries = 5;
let maybeResult = null;
let retries = 0;
do {
maybeResult = await Promise.race([
new Promise<null>((resolve) => {
setTimeout(() => {
retries += 1;
resolve(null);
}, 120_000);
}),
(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 browser = 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 browser.getWindowHandle();
// context = await BrowsingContext(browser, { browsingContextId: id });
const sessionId = (await browser.getSession()).getId();
if (retry != retries) {
void browser.quit();
return null;
}
return [sessionId, browser] as const;
})(),
]);
if (maybeResult) break;
} while (retries < maxRetries);
if (!maybeResult) throw new Error('Failed to initialize session to Selenium Grid due to many retries');
return maybeResult;
}
async function waitForStorybook(browser: WebDriver): Promise<void> {
logger().debug('Waiting for `setStories` event to make sure that storybook is initiated');
const isTimeout = await Promise.race([
new Promise<boolean>((resolve) => {
setTimeout(() => {
resolve(true);
}, 60000);
}),
(async () => {
let wait = true;
do {
try {
wait = await browser.executeScript<boolean>(function (SET_GLOBALS: string): boolean {
if (typeof window.__STORYBOOK_ADDONS_CHANNEL__ == 'undefined') return true;
if (window.__STORYBOOK_ADDONS_CHANNEL__.last(SET_GLOBALS) == undefined) return true;
return false;
}, SET_GLOBALS);
} catch (e: unknown) {
logger().debug('An error has been caught during the script:', e);
}
} while (wait);
return false;
})(),
]);
// TODO Change the message to describe a reason why it might happen
if (isTimeout) throw new Error('Failed to wait `setStories` event');
}
async function resetMousePosition(browser: WebDriver): Promise<void> {
logger().debug('Resetting mouse position to the top-left corner');
const browserName = (await browser.getCapabilities()).getBrowserName();
const [browserVersion] =
(await browser.getCapabilities()).getBrowserVersion()?.split('.') ??
((await 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 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 browser
.actions({ bridge: true })
.move({
origin: 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 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 browser.actions().move({ origin: Origin.VIEWPORT, x: 0, y: 0 }).perform();
}
}
async function resizeViewport(browser: WebDriver, viewport: { width: number; height: number }): Promise<void> {
const windowRect = await browser.manage().window().getRect();
const { innerWidth, innerHeight } = await 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 browser
.manage()
.window()
.setRect({
width: viewport.width + dWidth,
height: viewport.height + dHeight,
});
}
const getScrollBarWidth: (browser: WebDriver) => Promise<number> = (() => {
let scrollBarWidth: number | null = null;
return async (browser: WebDriver): Promise<number> => {
if (scrollBarWidth != null) return Promise.resolve(scrollBarWidth);
scrollBarWidth = await 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;
};
})();
// NOTE Firefox and Safari take viewport screenshot without scrollbars
async function hasScrollBar(browser: WebDriver): Promise<boolean> {
const browserName = (await browser.getCapabilities()).getBrowserName();
const [browserVersion] = (await browser.getCapabilities()).getBrowserVersion()?.split('.') ?? [];
return (
browserName != 'Safari' &&
// NOTE This need to work with keweb selenium grid
!(browserName == 'firefox' && browserVersion == '61')
);
}
async function takeCompositeScreenshot(
browser: WebDriver,
windowRect: ElementRect,
elementRect: ElementRect,
): Promise<string> {
const screens = [];
const isScreenshotWithoutScrollBar = !(await hasScrollBar(browser));
const scrollBarWidth = await getScrollBarWidth(browser);
// 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 browser.executeScript(
function (x: number, y: number) {
window.scrollTo(x, y);
},
dx,
dy,
);
screens.push(await 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).toString('base64');
}
export async function takeScreenshot(
browser: WebDriver,
captureElement?: string | null,
ignoreElements?: string | string[] | null,
): Promise<string> {
let screenshot: string;
const ignoreStyles = await insertIgnoreStyles(browser, ignoreElements);
if (logger().getLevel() <= Logger.levels.DEBUG) {
const { innerWidth, innerHeight } = await 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 browser.takeScreenshot();
logger().debug('Viewport screenshot is captured');
} else {
logger().debug(`Checking is element ${chalk.cyan(captureElement)} fit into viewport`);
const rects = await browser.executeScript<{ elementRect: ElementRect; windowRect: ElementRect } | undefined>(
function (selector: string): { elementRect: ElementRect; windowRect: ElementRect } | undefined {
window.scrollTo(0, 0); // TODO Maybe we should remove same code from `resetMousePosition`
// 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: {
// eslint-disable-next-line @typescript-eslint/no-deprecated
top: Math.round(window.scrollY || window.pageYOffset),
// eslint-disable-next-line @typescript-eslint/no-deprecated
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()
// : // TODO pointer-events: none, need to research
// await takeCompositeScreenshot(browser, windowRect, elementRect);
screenshot = isFitIntoViewport
? await browser.findElement(By.css(captureElement)).takeScreenshot()
: // TODO pointer-events: none, need to research
await takeCompositeScreenshot(browser, windowRect, elementRect);
logger().debug(`${chalk.cyan(captureElement)} is captured`);
}
} finally {
await removeIgnoreStyles(browser, ignoreStyles);
}
return screenshot;
}
async function selectStory(browser: WebDriver, storyId: string, waitForReady = false): Promise<boolean> {
logger().debug(`Triggering 'SetCurrentStory' event with storyId ${chalk.magenta(storyId)}`);
const result = await browser.executeAsyncScript<[error?: string | null, isCaptureCalled?: boolean] | null>(
function (
id: string,
shouldWaitForReady: boolean,
callback: (response: [error?: string | null, isCaptureCalled?: boolean]) => void,
) {
if (typeof window.__CREEVEY_SELECT_STORY__ == 'undefined') {
callback([
"Creevey can't switch story. This may happened if forget to add `creevey` addon to your storybook config, or storybook not loaded in browser due syntax error.",
]);
return;
}
void window.__CREEVEY_SELECT_STORY__(id, shouldWaitForReady, callback);
},
storyId,
waitForReady,
);
const [errorMessage, isCaptureCalled = false] = result ?? [];
if (errorMessage) throw new Error(errorMessage);
return isCaptureCalled;
}
export async function updateStorybookGlobals(browser: WebDriver, globals: StorybookGlobals): Promise<void> {
logger().debug('Applying storybook globals');
await browser.executeScript(function (globals: StorybookGlobals) {
window.__CREEVEY_UPDATE_GLOBALS__(globals);
}, globals);
}
function appendIframePath(url: string): string {
return `${url.replace(/\/$/, '')}/iframe.html`;
}
async function openStorybookPage(
browser: WebDriver,
storybookUrl: string,
resolver?: () => Promise<string>,
): Promise<void> {
if (!LOCALHOST_REGEXP.test(storybookUrl)) {
return browser.get(appendIframePath(storybookUrl));
}
try {
if (resolver) {
logger().debug('Resolving storybook url with custom resolver');
const resolvedUrl = await resolver();
logger().debug(`Resolver storybook url ${resolvedUrl}`);
await browser.get(appendIframePath(resolvedUrl));
} else {
// NOTE: getUrlChecker already calls `browser.get` so we don't need another one
await resolveStorybookUrl(appendIframePath(storybookUrl), getUrlChecker(browser));
}
} catch (error) {
logger().error('Failed to resolve storybook URL', error instanceof Error ? error.message : '');
throw error;
}
}
async function resolveCreeveyHost(browser: WebDriver, port: number, host?: string): Promise<void> {
const fetcher = function (hosts: string[], port: number, callback: (host?: string | null) => void) {
void Promise.all(
hosts.map(function (host) {
return Promise.race([
// eslint-disable-next-line @typescript-eslint/restrict-plus-operands
fetch('http://' + host + ':' + port + '/ping').then(function (response) {
return response.text();
}),
new Promise((_resolve, reject) => {
setTimeout(reject, 5000);
}),
])
.then(function (pong) {
return pong == 'pong' ? host : null;
})
.catch(function () {
return null;
});
}),
).then(function (hosts) {
callback(
hosts.find(function (host) {
return host != null;
}),
);
});
};
const addresses = host ? [host] : getAddresses();
creeveyServerPort = port;
creeveyServerHost = await browser.executeAsyncScript(fetcher, addresses, port);
if (creeveyServerHost == null) throw new Error("Can't reach creevey server from a browser");
}
export async function loadStoriesFromBrowser(): Promise<StoriesRaw> {
if (!browser) throw new Error("Can't get stories from browser if webdriver isn't connected");
const stories = await browser.executeAsyncScript<StoriesRaw | undefined>(function (
callback: (stories: StoriesRaw | undefined) => void,
) {
void window.__CREEVEY_GET_STORIES__().then(callback);
});
if (!stories) throw new Error("Can't get stories, it seems creevey or storybook API isn't available");
return stories;
}
export async function getBrowser(config: Config, options: Options & { browser: string }): Promise<WebDriver | null> {
if (browser) return browser;
browserName = options.browser;
const browserConfig = config.browsers[browserName] as BrowserConfig;
const {
gridUrl = config.gridUrl,
storybookUrl: address = config.storybookUrl,
limit,
viewport,
_storybookGlobals,
...userCapabilities
} = browserConfig;
void limit;
const realAddress = address;
// TODO Define some capabilities explicitly and define typings
const capabilities = new Capabilities({ ...userCapabilities, pageLoadStrategy: PageLoadStrategy.EAGER });
subscribeOn('shutdown', () => {
void browser?.quit().finally(() => process.exit());
browser = null;
});
const url = new URL(gridUrl);
url.username = url.username ? '********' : '';
url.password = url.password ? '********' : '';
logger().debug(`(${browserName}) Connecting to Selenium ${chalk.magenta(url.toString())}`);
const prefs = new logging.Preferences();
if (options.trace) {
for (const type of Object.values(logging.Type)) {
prefs.setLevel(type as string, logging.Level.ALL);
}
}
let sessionId;
let browserHost = '';
[sessionId, browser] = await buildWebdriver(gridUrl, capabilities, prefs);
try {
const { Name } = await getSessionData(gridUrl, sessionId);
if (typeof Name == 'string') browserHost = Name;
} catch {
/* noop */
}
logger().debug(
`(${browserName}) Connected successful with ${[chalk.green(browserHost), chalk.magenta(sessionId)]
.filter(Boolean)
.join(':')}`,
);
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)}`;
},
});
try {
await runSequence(
[
() => browser?.manage().setTimeouts({ pageLoad: 60000, script: 60000 }),
() => browser && openStorybookPage(browser, realAddress, config.resolveStorybookUrl),
() => browser && waitForStorybook(browser),
() => browser && resolveCreeveyHost(browser, options.port, config.host),
() => browser && updateBrowserGlobalVariables(browser),
() => _storybookGlobals && browser && updateStorybookGlobals(browser, _storybookGlobals),
// 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
() => viewport && browser && resizeViewport(browser, viewport),
],
() => !isShuttingDown.current,
);
} catch (originalError) {
if (isShuttingDown.current) {
browser.quit().catch(noop);
browser = null;
return null;
}
const currentUrl = await browser.getCurrentUrl();
const error = new Error(
`Can't load storybook root page${currentUrl ? ` by URL ${currentUrl}` : ''}: ${originalError instanceof Error ? originalError.message : ((originalError ?? 'Unknown error') as string)}`,
);
if (originalError instanceof Error) error.stack = originalError.stack;
throw error;
}
return browser;
}
async function updateBrowserGlobalVariables(browser: WebDriver) {
await browser.executeScript(
function (workerId: number, creeveyHost: string, creeveyPort: number) {
window.__CREEVEY_WORKER_ID__ = workerId;
window.__CREEVEY_SERVER_HOST__ = creeveyHost;
window.__CREEVEY_SERVER_PORT__ = creeveyPort;
},
process.pid,
creeveyServerHost,
creeveyServerPort,
);
}
async function updateStoryArgs(browser: WebDriver, story: StoryInput, updatedArgs: Args): Promise<void> {
await 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,
UPDATE_STORY_ARGS,
STORY_RENDERED,
);
}
export async function closeBrowser(): Promise<void> {
if (!browser) return;
try {
await browser.quit();
} finally {
browser = null;
}
}
export async function switchStory(this: Context): Promise<void> {
let testOrSuite: Test | Suite | undefined = this.currentTest;
if (!testOrSuite) throw new Error("Can't switch story, because test context doesn't have 'currentTest' field");
this.testScope.length = 0;
this.screenshots.length = 0;
this.testScope.push(this.browserName);
while (testOrSuite?.title) {
this.testScope.push(testOrSuite.title);
testOrSuite = testOrSuite.parent;
}
const story = this.currentTest?.ctx?.story as StoryInput | undefined;
if (!story) throw new Error(`Current test '${this.testScope.join('/')}' context doesn't have 'story' field`);
const { id, title, name, parameters } = story;
const {
captureElement = `#${storybookRootID}`,
waitForReady,
ignoreElements,
} = (parameters.creevey ?? {}) as CreeveyStoryParams;
logger().debug(`Switching to story ${chalk.cyan(title)}/${chalk.cyan(name)} by id ${chalk.magenta(id)}`);
if (captureElement)
Object.defineProperty(this, 'captureElement', {
enumerable: true,
configurable: true,
get: () => this.browser.findElement(By.css(captureElement)),
});
else Reflect.deleteProperty(this, 'captureElement');
this.takeScreenshot = () => takeScreenshot(this.browser, captureElement, ignoreElements);
this.updateStoryArgs = (updatedArgs: Args) => updateStoryArgs(this.browser, story, updatedArgs);
this.testScope.reverse();
let storyPlayResolver: (isCompleted: boolean) => void;
let waitForComplete = new Promise<boolean>((resolve) => (storyPlayResolver = resolve));
const unsubscribe = subscribeOn('stories', (message) => {
if (message.type != 'capture') return;
const { payload = {}, payload: { imageName } = {} } = message;
void takeScreenshot(
this.browser,
payload.captureElement ?? captureElement,
payload.ignoreElements ?? ignoreElements,
).then((screenshot) => {
this.screenshots.push({ imageName, screenshot });
void this.browser
.executeAsyncScript<boolean>(function (callback: (isCompleted: boolean) => void) {
window.__CREEVEY_HAS_PLAY_COMPLETED_YET__(callback);
})
.then((isCompleted) => {
storyPlayResolver(isCompleted);
});
emitStoriesMessage({ type: 'capture' });
});
});
await updateBrowserGlobalVariables(this.browser);
await resetMousePosition(this.browser);
const isCaptureCalled = await selectStory(this.browser, id, waitForReady);
if (isCaptureCalled) {
while (!(await waitForComplete)) {
waitForComplete = new Promise<boolean>((resolve) => (storyPlayResolver = resolve));
}
}
unsubscribe();
logger().debug(`Story ${chalk.magenta(id)} ready for capturing`);
}
async function insertIgnoreStyles(
browser: WebDriver,
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 browser.executeScript(function (ignoreSelectors: string[]) {
return window.__CREEVEY_INSERT_IGNORE_STYLES__(ignoreSelectors);
}, ignoreSelectors);
}
async function removeIgnoreStyles(browser: WebDriver, ignoreStyles: WebElement | null): Promise<void> {
if (ignoreStyles) {
logger().debug('Revert hiding ignored elements');
await browser.executeScript(function (ignoreStyles: HTMLStyleElement) {
window.__CREEVEY_REMOVE_IGNORE_STYLES__(ignoreStyles);
}, ignoreStyles);
}
}