UNPKG

creevey

Version:

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

521 lines (456 loc) 14.5 kB
import type { StoryContextForEnhancers, DecoratorFunction } from '@storybook/csf'; import type { IKey } from 'selenium-webdriver/lib/input.js'; import type { Worker as ClusterWorker } from 'cluster'; import type { until, WebDriver, WebElementPromise } from 'selenium-webdriver'; import type Pixelmatch from 'pixelmatch'; import type { Context } from 'mocha'; import type { expect } from 'chai'; /* eslint-disable @typescript-eslint/no-explicit-any */ export type DiffOptions = typeof Pixelmatch extends ( x1: any, x2: any, x3: any, x4: any, x5: any, options?: infer T, ) => void ? T : never; /* eslint-enable @typescript-eslint/no-explicit-any */ export interface SetStoriesData { v?: number; globalParameters: { creevey?: CreeveyStoryParams }; kindParameters: Partial<Record<string, { fileName: string; creevey?: CreeveyStoryParams }>>; stories: StoriesRaw; } export type StoriesRaw = Record<string, StoryContextForEnhancers>; export type StoryInput = StoriesRaw extends Record<string, infer S> ? S : never; export interface StoryMeta { title: string; component?: unknown; decorators?: DecoratorFunction[]; parameters?: { creevey?: CreeveyStoryParams; [name: string]: unknown; }; } export interface CreeveyMeta { parameters?: { creevey?: CreeveyStoryParams; [name: string]: unknown; }; } export interface CSFStory<StoryFnReturnType = unknown> { (): StoryFnReturnType; /** * @deprecated * CSF .story annotations deprecated; annotate story functions directly: * - StoryFn.story.name => StoryFn.storyName * - StoryFn.story.(parameters|decorators) => StoryFn.(parameters|decorators) * See https://github.com/storybookjs/storybook/blob/next/MIGRATION.md#hoisted-csf-annotations for details and codemod. */ story?: { name?: string; decorators?: DecoratorFunction[]; parameters?: { creevey?: CreeveyStoryParams; [name: string]: unknown; }; }; storyName?: string; decorators?: DecoratorFunction[]; parameters?: { creevey?: CreeveyStoryParams; [name: string]: unknown; }; } export interface CreeveyStory { parameters?: { creevey?: CreeveyStoryParams; [name: string]: unknown; }; } export interface Capabilities { browserName: string; browserVersion?: string; platformName?: string; /** * @deprecated use `browserVersion` instead */ version?: string; [prop: string]: unknown; } export type BrowserConfig = Capabilities & { limit?: number; gridUrl?: string; storybookUrl?: string; /** * Storybook's globals to set in a specific browser * @see https://github.com/storybookjs/storybook/blob/v6.0.0/docs/essentials/toolbars-and-globals.md */ _storybookGlobals?: StorybookGlobals; /** * Specify custom docker image. Used only with `useDocker == true` * @default `selenoid/${browserName}:${browserVersion ?? 'latest'}` */ dockerImage?: string; /** * Command to start standalone webdriver * Used only with `useDocker == false` */ webdriverCommand?: string[]; viewport?: { width: number; height: number }; }; export type StorybookGlobals = Record<string, unknown>; export type Browser = boolean | string | BrowserConfig; export interface HookConfig { before?: () => unknown; after?: () => unknown; } export interface DockerAuth { key?: string; username?: string; password?: string; auth?: string; email?: string; serveraddress?: string; } export interface Config { /** * Url to Selenium grid hub or standalone selenium. * By default creevey will use docker containers */ gridUrl: string; /** * Url where storybook hosted on * @default 'http://localhost:6006' */ storybookUrl: string; /** * Url where storybook hosted on */ resolveStorybookUrl?: () => Promise<string>; /** * Command to automatically start Storybook if it is not running. * For example, `npm run storybook`, `yarn run storybook` etc. */ storybookAutorunCmd?: string; /** * Absolute path to directory with reference images * @default path.join(process.cwd(), './images') */ screenDir: string; /** * Absolute path where test reports and diff images would be saved * @default path.join(process.cwd(), './report') */ reportDir: string; /** * How much test would be retried * @default 0 */ maxRetries: number; /** * Define pixelmatch diff options * @default { threshold: 0, includeAA: true } */ diffOptions: DiffOptions; /** * Browser capabilities * @default { chrome: true } */ browsers: Record<string, Browser>; /** * Hooks that allow run custom script before and after creevey start */ hooks: HookConfig; /** * Creevey automatically download latest selenoid binary. You can define path to different verison. * Works only with `useDocker == false` */ selenoidPath?: string; /** * Creevey has two built-in stories providers. * * `nodejsStoriesProvider` - The first one is used by default except if CSFv3 is enabled in Storybook. * This provider builds and runs storybook in nodejs env, that allows write interaction tests by using Selenium API. * The downside is it depends from project build specific and slightly increases init time. * * `browserStoriesProvider` - The second one is used by default with CSFv3 storybook feature. * It load stories from storybook which is running in browser, like storyshots or loki do it. * The downside of this, you can't use interaction tests in Creevey, unless you use CSFv3. * Where you can define `play` method for each story * * Usage * ``` typescript * import { nodejsStoriesProvider as provider } from 'creevey' * // or * import { browserStoriesProvider as provider } from 'creevey' * * // Creevey config * module.exports = { * storiesProvider: provider * } * ``` */ storiesProvider: StoriesProvider; /** * Define custom babel options for load stories transformation */ babelOptions: (options: Record<string, unknown>) => Record<string, unknown>; /** * Allows you to start selenoid without docker * and use standalone browsers * @default true */ useDocker: boolean; /** * Custom selenoid docker image * @default 'aerokube/selenoid:latest-release' */ dockerImage: string; /** * Should Creevey pull docker images or use local ones * @default true */ pullImages: boolean; /** * Define auth config for private docker registry */ dockerAuth?: DockerAuth; /** * Enable to stop tests running right after the first failed test. * The `--ui` CLI option ignores this option */ failFast: boolean; /** * Start workers in sequential queue * @default false */ useWorkerQueue: boolean; /** * Specify platform for docker images */ dockerImagePlatform: string; testsRegex?: RegExp; testsDir?: string; tsConfig?: string; /** * Telemetry contains information about Creevey and Storybook versions, used Creevey config, browsers and tests meta. * It's being sent only for projects from git.skbkontur.ru * @default false */ disableTelemetry?: boolean; /** * Define a host where is creevey-server hosting. * It can be used for networks behind NAT */ host?: string; } // eslint-disable-next-line @typescript-eslint/no-explicit-any export interface StoriesProvider<T = any> { (config: Config, options: T, storiesListener: (stories: Map<string, StoryInput[]>) => void): Promise<StoriesRaw>; providerName?: string; } export type CreeveyConfig = Partial<Config>; export interface Options { _: string[]; config?: string; port: number; ui: boolean; update: boolean | string; debug: boolean; trace: boolean; tests: boolean; browser?: string; reporter?: string; screenDir?: string; reportDir?: string; storybookUrl?: string; storybookAutorunCmd?: string; saveReport: boolean; failFast?: boolean; } export type WorkerMessage = { type: 'ready'; payload?: never } | { type: 'error'; payload: { error: string } }; export type StoriesMessage = | { type: 'get'; payload?: never } | { type: 'set'; payload: { stories: StoriesRaw; oldTests: string[] } } | { type: 'update'; payload: [string, StoryInput[]][] } | { type: 'capture'; payload?: CaptureOptions }; export type TestMessage = | { type: 'start'; payload: { id: string; path: string[]; retries: number } } | { type: 'end'; payload: TestResult }; export type WebpackMessage = | { type: 'success'; payload?: never } | { type: 'fail'; payload?: never } | { type: 'rebuild succeeded'; payload?: never } | { type: 'rebuild failed'; payload?: never }; export type DockerMessage = { type: 'start'; payload?: never } | { type: 'success'; payload: { gridUrl: string } }; export type ShutdownMessage = object; export type ProcessMessage = | (WorkerMessage & { scope: 'worker' }) | (StoriesMessage & { scope: 'stories' }) | (TestMessage & { scope: 'test' }) | (WebpackMessage & { scope: 'webpack' }) | (DockerMessage & { scope: 'docker' }) | (ShutdownMessage & { scope: 'shutdown' }); export type WorkerHandler = (message: WorkerMessage) => void; export type StoriesHandler = (message: StoriesMessage) => void; export type TestHandler = (message: TestMessage) => void; export type WebpackHandler = (message: WebpackMessage) => void; export type DockerHandler = (message: DockerMessage) => void; export type ShutdownHandler = (message: ShutdownMessage) => void; export interface Worker extends ClusterWorker { isRunning?: boolean; isShuttingDown?: boolean; } export interface Images { actual: string; expect?: string; diff?: string; error?: string; } export type TestStatus = 'unknown' | 'pending' | 'running' | 'failed' | 'approved' | 'success' | 'retrying'; export interface TestResult { status: 'failed' | 'success'; // TODO Remove checks `name == browser` in TestResultsView // images?: Partial<{ [name: string]: Images }> | Images; images?: Partial<Record<string, Images>>; error?: string; } export interface ImagesError extends Error { images: string | Partial<Record<string, string>>; } export interface TestMeta { id: string; storyPath: string[]; browser: string; testName?: string; storyId: string; } export interface TestData extends TestMeta { skip?: boolean | string; retries?: number; status?: TestStatus; results?: TestResult[]; approved?: Partial<Record<string, number>> | null; } export interface ServerTest extends TestData { story: StoryInput; fn: (this: Context) => Promise<void>; } export interface CreeveyStatus { isRunning: boolean; tests: Partial<Record<string, TestData>>; browsers: string[]; } export interface CreeveyUpdate { isRunning?: boolean; // TODO Use Map instead tests?: Partial<Record<string, TestData>>; removedTests?: TestMeta[]; } export interface SkipOption { in?: string | string[] | RegExp; kinds?: string | string[] | RegExp; stories?: string | string[] | RegExp; tests?: string | string[] | RegExp; } export type SkipOptions = boolean | string | Record<string, SkipOption | SkipOption[]>; export interface CreeveyTestController { browser: WebDriver; until: typeof until; keys: IKey; expect: typeof expect; takeScreenshot: () => Promise<string>; updateStoryArgs: (updatedArgs: Record<string, unknown>) => Promise<void>; readonly captureElement?: WebElementPromise; } export type CreeveyTestFunction = (this: CreeveyTestController) => Promise<void>; export interface CaptureOptions { imageName?: string; captureElement?: string | null; ignoreElements?: string | string[] | null; } export interface CreeveyStoryParams extends CaptureOptions { waitForReady?: boolean; delay?: number | { for: string[]; ms: number }; skip?: SkipOptions; tests?: Record<string, CreeveyTestFunction>; } export interface ApprovePayload { id: string; retry: number; image: string; } export type Request = | { type: 'status' } | { type: 'start'; payload: string[] } | { type: 'stop' } | { type: 'approve'; payload: ApprovePayload } | { type: 'approveAll' }; export type Response = | { type: 'status'; payload: CreeveyStatus } | { type: 'update'; payload: CreeveyUpdate } | { type: 'capture' }; export interface CreeveyTest extends TestData { checked: boolean; } export interface CreeveySuite { path: string[]; skip: boolean; status?: TestStatus; opened: boolean; checked: boolean; indeterminate: boolean; // TODO Use Map instead children: Partial<Record<string, CreeveySuite | CreeveyTest>>; } export type ImagesViewMode = 'side-by-side' | 'swap' | 'slide' | 'blend'; export function noop(): void { /* noop */ } export function isDefined<T>(value: T | null | undefined): value is T { return value !== null && value !== undefined; } export function isTest(x?: CreeveySuite | CreeveyTest): x is CreeveyTest { return ( isDefined(x) && isObject(x) && 'id' in x && 'storyId' in x && typeof x.id == 'string' && typeof x.storyId == 'string' ); } export function isObject(x: unknown): x is Record<string, unknown> { return typeof x == 'object' && x != null; } export function isString(x: unknown): x is string { return typeof x == 'string'; } // eslint-disable-next-line @typescript-eslint/no-explicit-any export function isFunction(x: unknown): x is (...args: any[]) => any { return typeof x == 'function'; } export function isImageError(error: unknown): error is ImagesError { return error instanceof Error && 'images' in error; } export function isProcessMessage(message: unknown): message is ProcessMessage { return isObject(message) && 'scope' in message; } export function isWorkerMessage(message: unknown): message is WorkerMessage { return isProcessMessage(message) && message.scope == 'worker'; } export function isStoriesMessage(message: unknown): message is StoriesMessage { return isProcessMessage(message) && message.scope == 'stories'; } export function isTestMessage(message: unknown): message is TestMessage { return isProcessMessage(message) && message.scope == 'test'; } export function isWebpackMessage(message: unknown): message is WebpackMessage { return isProcessMessage(message) && message.scope == 'webpack'; } export function isDockerMessage(message: unknown): message is DockerMessage { return isProcessMessage(message) && message.scope == 'docker'; }