UNPKG

creevey

Version:

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

219 lines (192 loc) 7.39 kB
import path from 'path'; import https from 'https'; import { exec } from 'shelljs'; import { stringify } from 'qs'; import { set } from 'lodash'; import { v4 } from 'uuid'; import { pathToFileURL } from 'url'; import { createRequire } from 'module'; import { type Config, type CreeveyStatus, isDefined } from '../types.js'; import type { Options } from '../schema.js'; const konturGitHost = 'git.skbkontur.ru'; const trackId = 232; // front_infra const origin = 'http://localhost/'; const category = 'tests_run'; const action = 'done'; function buildPathname(label: string, info: Record<string, unknown>): string { return `/track-event?${stringify({ id: trackId, c: category, a: action, l: label, cv: JSON.stringify(info), ts: new Date().toISOString(), url: origin, })}`; } function sanitizeGridUrl(gridUrl: string): string { const url = new URL(gridUrl); url.username = url.username ? '********' : ''; url.password = url.password ? '********' : ''; return url.toString(); } function tryGetRepoUrl(): [string | undefined, Error | null] { try { const gitRemoteOutput = exec('git remote -v', { silent: true }); const [, repoUrl] = /origin\s+(.*)\s+\(fetch\)/.exec(gitRemoteOutput.stdout) ?? []; return [repoUrl, null]; } catch (error) { return [undefined, error as Error]; } } function tryGetRootPath(): [string | undefined, Error | null] { try { const gitRevParseOutput = exec('git rev-parse --show-toplevel', { silent: true }); return [gitRevParseOutput.stdout.trim(), null]; } catch (error) { return [undefined, error as Error]; } } function tryGetStorybookVersion(): [string | undefined, Error | null] { try { const storybookPackageOutput = exec(`node -e "console.log(JSON.stringify(require('storybook/package.json')))"`, { silent: true, }); const storybookPackage = JSON.parse(storybookPackageOutput.stdout) as { version: string }; return [storybookPackage.version, null]; } catch (error) { return [undefined, error as Error]; } } function tryGetCreeveyVersion(): [string | undefined, Error | null] { try { const importMetaUrl = pathToFileURL(__filename).href; const _require = createRequire(importMetaUrl); const creeveyPackage = _require('creevey/package.json') as { version: string }; return [creeveyPackage.version, null]; } catch (error) { return [undefined, error as Error]; } } function sendRequest(options: https.RequestOptions): Promise<void> { return new Promise<void>((resolve, reject) => { const req = https.request(options, (res) => { if (res.statusCode) { if (res.statusCode >= 200 && res.statusCode < 300) { resolve(); } else if (res.statusCode >= 300 && res.statusCode < 400) { reject(new Error(`Redirection error: ${res.statusCode}`)); } else if (res.statusCode >= 400 && res.statusCode < 500) { reject(new Error(`Client error: ${res.statusCode}`)); } else if (res.statusCode >= 500 && res.statusCode < 600) { reject(new Error(`Server error: ${res.statusCode}`)); } else { reject(new Error(`Unexpected status code: ${res.statusCode}`)); } } else { reject(new Error('No status code received')); } }); req.on('error', reject); req.end(); }); } export async function sendScreenshotsCount( config: Partial<Config>, options: Options, status?: CreeveyStatus, ): Promise<void> { const [repoUrl] = tryGetRepoUrl(); const isKonturRepo = repoUrl?.includes(konturGitHost); if (!isKonturRepo || config.disableTelemetry) return; const uuid = v4(); const [creeveyVersion, creeveyVersionError] = tryGetCreeveyVersion(); const [storybookVersion, storybookVersionError] = tryGetStorybookVersion(); const [gitRootPath] = tryGetRootPath(); const gridUrl = config.gridUrl ? sanitizeGridUrl(config.gridUrl) : undefined; const configMeta = { runId: uuid, repoUrl: repoUrl ?? 'unknown', creeveyVersion: creeveyVersion ?? 'unknown', storybookVersion: storybookVersion ?? 'unknown', options, gridUrl, screenDir: config.screenDir ? path.relative(gitRootPath ?? process.cwd(), config.screenDir) : undefined, useDocker: config.useDocker, dockerImage: config.dockerImage, maxRetries: config.maxRetries, diffOptions: config.diffOptions, // eslint-disable-next-line @typescript-eslint/no-deprecated storiesProvider: config.storiesProvider?.providerName ?? 'unknown', errors: [creeveyVersionError, storybookVersionError].some(Boolean) ? [ creeveyVersionError ? `Error while getting creevey version: ${creeveyVersionError.message}` : undefined, storybookVersionError ? `Error while getting storybook version: ${storybookVersionError.message}` : undefined, ].filter(Boolean) : undefined, }; const browsersMeta = { runId: uuid, browsers: Object.fromEntries( Object.entries(config.browsers ?? {}).map(([name, browser]) => [ name, typeof browser === 'object' ? { name: name, gridUrl: browser.gridUrl ? sanitizeGridUrl(browser.gridUrl) : undefined, browserName: browser.browserName, // @ts-expect-error Support old config version // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment browserVersion: browser.seleniumCapabilities?.browserVersion ?? browser.browserVersion, // @ts-expect-error Support old config version // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment platformName: browser.seleniumCapabilities?.platformName ?? browser.platformName, viewport: browser.viewport, limit: browser.limit, dockerImage: browser.dockerImage, 'se:teamname': browser.seleniumCapabilities?.['se:teamname'], } : browser, ]), ), }; const tests: Record<string, unknown> = {}; Object.values(status?.tests ?? {}) .filter(isDefined) .forEach((test) => { set(tests, [...test.storyPath, test.testName, test.browser].filter(isDefined), test.id); }); const testsMeta = { runId: uuid, tests }; const fullPathname = buildPathname('tests', testsMeta); // NOTE: Keep request path shorter than 24k symbols const chunksCount = Math.ceil(fullPathname.length / 24_000); let chunks: string[] = []; if (chunksCount > 1) { const testsString = JSON.stringify(tests); const chunkSize = Math.ceil(testsString.length / chunksCount); chunks = Array.from({ length: chunksCount }) .map((_, chunkIndex) => testsString.slice(chunkIndex * chunkSize, (chunkIndex + 1) * chunkSize)) .map((testsPart, seq) => buildPathname('tests', { runId: uuid, seq, tests: testsPart })); } else { chunks = [fullPathname]; } await Promise.all([ sendRequest({ host: 'metrika.kontur.ru', path: buildPathname('config', configMeta), protocol: 'https:', }), sendRequest({ host: 'metrika.kontur.ru', path: buildPathname('browsers', browsersMeta), protocol: 'https:', }), ...chunks.map((chunk) => sendRequest({ host: 'metrika.kontur.ru', path: chunk, protocol: 'https:', }), ), ]); }