UNPKG

creevey

Version:

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

247 lines (216 loc) 8.43 kB
import path from 'path'; import chai from 'chai'; import chalk from 'chalk'; import { Stats } from 'fs'; import assert from 'assert'; import { stat, readdir, readFile, writeFile, mkdir } from 'fs/promises'; import Mocha, { Context, MochaOptions } from 'mocha'; import { Key, until } from 'selenium-webdriver'; import { Config, Images, Options, TestMessage, isImageError } from '../../types.js'; import { subscribeOn, emitTestMessage, emitWorkerMessage } from '../messages.js'; import chaiImage from './chai-image.js'; import { getBrowser, switchStory } from '../selenium/index.js'; import { CreeveyReporter, TeamcityReporter } from './reporter.js'; import { addTestsFromStories } from './helpers.js'; import { logger } from '../logger.js'; async function getStat(filePath: string): Promise<Stats | null> { try { return await stat(filePath); } catch (error) { if (typeof error == 'object' && error && (error as { code?: unknown }).code === 'ENOENT') { return null; } throw error; } } async function getLastImageNumber(imageDir: string, imageName: string): Promise<number> { const actualImagesRegexp = new RegExp(`${imageName}-actual-(\\d+)\\.png`); try { return ( (await readdir(imageDir)) .map((filename) => filename.replace(actualImagesRegexp, '$1')) .map(Number) .filter((x) => !isNaN(x)) .sort((a, b) => b - a)[0] ?? 0 ); } catch (_error) { return 0; } } // FIXME browser options hotfix export async function start(config: Config, options: Options & { browser: string }): Promise<void> { let retries = 0; let images: Partial<Record<string, Images>> = {}; let error: string | undefined = undefined; const screenshots: { imageName?: string; screenshot: string }[] = []; const testScope: string[] = []; function runHandler(failures: number): void { if (failures > 0 && (error || Object.values(images).some((image) => image?.error != null))) { const isTimeout = hasTimeout(error) || Object.values(images).some((image) => hasTimeout(image?.error)); const payload: { status: 'failed'; images: typeof images; error?: string } = { status: 'failed', images, error, }; if (isTimeout) emitWorkerMessage({ type: 'error', payload: { error: error ?? 'Unknown error' } }); else emitTestMessage({ type: 'end', payload }); } else { emitTestMessage({ type: 'end', payload: { status: 'success', images } }); } } async function saveImages(imageDir: string, images: { name: string; data: Buffer }[]): Promise<void> { await mkdir(imageDir, { recursive: true }); for (const { name, data } of images) { await writeFile(path.join(imageDir, name), data); } } async function getExpected( assertImageName?: string, ): Promise< | { expected: Buffer | null; onCompare: (actual: Buffer, expect?: Buffer, diff?: Buffer) => Promise<void> } | Buffer | null > { // context => [title, name, test, browser] // rootSuite -> kindSuite -> storyTest -> [browsers.png] // rootSuite -> kindSuite -> storySuite -> test -> [browsers.png] const testPath = [...testScope]; const imageName = assertImageName ?? testPath.pop(); assert(typeof imageName === 'string', `Can't get image name from empty test scope`); const imagesMeta: { name: string; data: Buffer }[] = []; const reportImageDir = path.join(config.reportDir, ...testPath); const imageNumber = (await getLastImageNumber(reportImageDir, imageName)) + 1; const actualImageName = `${imageName}-actual-${imageNumber}.png`; const image = (images[imageName] = images[imageName] ?? { actual: actualImageName }); const onCompare = async (actual: Buffer, expect?: Buffer, diff?: Buffer): Promise<void> => { imagesMeta.push({ name: image.actual, data: actual }); if (diff && expect) { image.expect = `${imageName}-expect-${imageNumber}.png`; image.diff = `${imageName}-diff-${imageNumber}.png`; imagesMeta.push({ name: image.expect, data: expect }); imagesMeta.push({ name: image.diff, data: diff }); } if (options.saveReport) { await saveImages(reportImageDir, imagesMeta); } }; const expectImageDir = path.join(config.screenDir, ...testPath); const expectImageStat = await getStat(path.join(expectImageDir, `${imageName}.png`)); if (!expectImageStat) return { expected: null, onCompare }; const expected = await readFile(path.join(expectImageDir, `${imageName}.png`)); return { expected, onCompare }; } const mochaOptions: MochaOptions = { timeout: 30000, reporter: process.env.TEAMCITY_VERSION ? TeamcityReporter : (options.reporter ?? CreeveyReporter), reporterOptions: { reportDir: config.reportDir, topLevelSuite: options.browser, get willRetry() { return retries < config.maxRetries; }, get images() { return images; }, get sessionId() { return sessionId; }, }, }; const mocha = new Mocha(mochaOptions); mocha.cleanReferencesAfterRun(false); chai.use(chaiImage(getExpected, config.diffOptions)); const browser = await (async () => { try { return await getBrowser(config, options); } catch (error) { const errorMessage = error instanceof Error ? error.message : ((error ?? 'Unknown error') as string); logger().error('Failed to initiate webdriver:', errorMessage); emitWorkerMessage({ type: 'error', payload: { error: errorMessage }, }); return null; } })(); if (browser == null) return; await addTestsFromStories(mocha.suite, config, { browser: options.browser, watch: options.ui, debug: options.debug, port: options.port, }); const sessionId = (await browser.getSession()).getId(); const interval = setInterval( () => // NOTE Simple way to keep session alive void browser.getCurrentUrl().then((url) => { logger().debug('current url', chalk.magenta(url)); }), 10 * 1000, ); subscribeOn('shutdown', () => { clearInterval(interval); }); mocha.suite.beforeAll(function (this: Context) { this.config = config; this.browser = browser; this.until = until; this.keys = Key; this.expect = chai.expect; this.browserName = options.browser; this.testScope = testScope; this.screenshots = screenshots; }); mocha.suite.beforeEach(switchStory); if (options.trace) { mocha.suite.afterEach(async function (this: Context) { const output: string[] = []; const types = await browser.manage().logs().getAvailableLogTypes(); for (const type of types) { const logs = await browser.manage().logs().get(type); output.push(logs.map((log) => JSON.stringify(log.toJSON(), null, 2)).join('\n')); } logger().debug( '----------', this.currentTest?.titlePath().join('/'), '----------\n', output.join('\n'), '\n----------------------------------------------------------------------------------------------------', ); }); } subscribeOn('test', (message: TestMessage) => { if (message.type != 'start') return; const test = message.payload; const testPath = test.path.join(' ').replace(/[|\\{}()[\]^$+*?.-]/g, '\\$&'); images = {}; error = undefined; retries = test.retries; mocha.grep(new RegExp(`^${testPath}$`)); mocha.unloadFiles(); const runner = mocha.run(runHandler); // TODO How handle browser corruption? runner.on('fail', (_test, reason: unknown) => { if (!(reason instanceof Error)) { error = reason as string; } else if (!isImageError(reason)) { error = reason.stack ?? reason.message; } else if (typeof reason.images == 'string') { const image = images[testScope.slice(-1)[0]]; if (image) image.error = reason.images; } else { const imageErrors = reason.images; Object.keys(imageErrors).forEach((imageName) => { const image = images[imageName]; if (image) image.error = imageErrors[imageName]; }); } }); }); logger().info('Worker is ready'); emitWorkerMessage({ type: 'ready' }); } function hasTimeout(str: string | null | undefined): boolean { return str?.toLowerCase().includes('timeout') ?? false; }