creevey
Version:
Cross-browser screenshot testing tool for Storybook with fancy UI Runner
219 lines (192 loc) • 7.39 kB
text/typescript
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:',
}),
),
]);
}