UNPKG

creevey

Version:

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

247 lines (216 loc) 7.46 kB
import chai from 'chai'; import { BaseCreeveyTestContext, Config, CreeveyWebdriver, ServerTest, TestMessage, TestResult, isDefined, isImageError, } from '../../types.js'; import { WorkerOptions } from '../../schema.js'; import { subscribeOn, emitTestMessage, emitWorkerMessage } from '../messages.js'; import chaiImage from './chai-image.js'; import { getMatchers, getOdiffMatchers } from './match-image.js'; import { loadTestsFromStories } from '../stories.js'; import { logger } from '../logger.js'; import { getTestPath } from '../utils.js'; import { ImageContext } from '../compare.js'; async function getTestsFromStories( config: Config, browserName: string, webdriver: CreeveyWebdriver, ): Promise<Map<string, ServerTest>> { const testsById = new Map<string, ServerTest>(); const tests = await loadTestsFromStories( [browserName], // eslint-disable-next-line @typescript-eslint/no-deprecated (listener) => config.storiesProvider(config, listener, webdriver), (testsDiff) => { Object.entries(testsDiff).forEach(([id, newTest]) => { if (newTest) testsById.set(id, newTest); else testsById.delete(id); }); }, ); Object.values(tests) .filter(isDefined) .forEach((test) => testsById.set(test.id, test)); return testsById; } function runHandler(browserName: string, result: Omit<TestResult, 'status'>, error?: unknown): void { // TODO How handle browser corruption? const { images } = result; if (images != null && isImageError(error)) { if (typeof error.images == 'string') { const image = images[browserName]; if (image) image.error = error.images; } else { const imageErrors = error.images ?? {}; Object.keys(imageErrors).forEach((imageName) => { const image = images[imageName]; if (image) image.error = imageErrors[imageName]; }); } } if (error || (images != null && Object.values(images).some((image) => image?.error != null))) { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const errorMessage = result.error!; const isUnexpectedError = hasTimeout(errorMessage) || hasDisconnected(errorMessage) || (images != null && Object.values(images).some((image) => hasTimeout(image?.error))); if (isUnexpectedError) emitWorkerMessage({ type: 'error', payload: { subtype: 'unknown', error: errorMessage } }); else emitTestMessage({ type: 'end', payload: { status: 'failed', ...result, }, }); } else { emitTestMessage({ type: 'end', payload: { status: 'success', ...result, }, }); } } async function setupWebdriver(webdriver: CreeveyWebdriver): Promise<[string, CreeveyWebdriver] | undefined> { if ((await webdriver.openBrowser(true)) == null) { logger().error('Failed to start browser'); emitWorkerMessage({ type: 'error', payload: { subtype: 'browser', error: 'Failed to start browser' }, }); return; } const sessionId = await webdriver.getSessionId(); return [sessionId, webdriver]; } function serializeError(error: unknown): string { if (!error) return 'Unknown error'; if (error instanceof Error) return error.stack ?? error.message; return typeof error === 'object' ? JSON.stringify(error) : (error as string); } function hasDisconnected(str: string | null | undefined): boolean { return str?.toLowerCase().includes('disconnected') ?? false; } function hasTimeout(str: string | null | undefined): boolean { return str?.toLowerCase().includes('timeout') ?? false; } export async function start(browser: string, gridUrl: string, config: Config, options: WorkerOptions): Promise<void> { const imagesContext: ImageContext = { attachments: [], testFullPath: [], images: {}, }; const Webdriver = config.webdriver; const [sessionId, webdriver] = (await setupWebdriver(new Webdriver(browser, gridUrl, config, options.debug ?? false))) ?? []; if (!webdriver || !sessionId) return; const { matchImage, matchImages } = options.odiff ? await getOdiffMatchers(imagesContext, config) : await getMatchers(imagesContext, config); chai.use(chaiImage(matchImage, matchImages)); const tests = await (async () => { try { return await getTestsFromStories(config, browser, webdriver); } catch (error) { logger().error('Failed to get tests from stories:', error); emitWorkerMessage({ type: 'error', payload: { subtype: 'browser', error: serializeError(error) }, }); return null; } })(); if (!tests) return; subscribeOn('test', (message: TestMessage) => { if (message.type != 'start') return; const test = tests.get(message.payload.id); if (!test) { const error = `Test with id ${message.payload.id} not found`; logger().error(error); emitWorkerMessage({ type: 'error', payload: { subtype: 'test', error }, }); return; } const baseContext: BaseCreeveyTestContext = { browserName: browser, // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment get webdriver() { // @ts-expect-error We defined separate d.ts declarations for each webdriver // eslint-disable-next-line @typescript-eslint/no-unsafe-return return webdriver.browser; }, screenshots: [], matchImage: matchImage, matchImages: matchImages, // NOTE: Deprecated expect: chai.expect, }; imagesContext.attachments = []; imagesContext.testFullPath = getTestPath(test); imagesContext.images = {}; let error = undefined; void (async () => { let timeout; let isRejected = false; const start = Date.now(); try { await Promise.race([ new Promise( (_, reject) => (timeout = setTimeout(() => { isRejected = true; reject(new Error(`Timeout of ${config.testTimeout}ms exceeded`)); }, config.testTimeout)), ), (async () => { const context = await webdriver.switchStory(test.story, baseContext); await test.fn(context); })(), ]); } catch (testError) { error = testError; } const duration = Date.now() - start; clearTimeout(timeout); await webdriver.afterTest(test); // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition if (isRejected) { emitWorkerMessage({ type: 'error', payload: { subtype: 'unknown', error: serializeError(error) }, }); } else { const result = { sessionId, browserName: baseContext.browserName, workerId: process.pid, images: imagesContext.images, error: error ? serializeError(error) : undefined, duration, attachments: imagesContext.attachments, retries: message.payload.retries, }; runHandler(baseContext.browserName, result, error); } })().catch((error: unknown) => { logger().error('Unexpected error:', error); emitWorkerMessage({ type: 'error', payload: { subtype: 'test', error: serializeError(error) }, }); }); }); logger().info('Browser is ready'); emitWorkerMessage({ type: 'ready' }); }