creevey
Version:
Cross-browser screenshot testing tool for Storybook with fancy UI Runner
220 lines (185 loc) • 8.52 kB
text/typescript
import cluster from 'cluster';
import path from 'path';
import sh from 'shelljs';
import { getUserAgent } from 'package-manager-detector/detect';
import { resolveCommand } from 'package-manager-detector/commands';
import { readConfig, defaultBrowser } from './config.js';
import { Config, BrowserConfigObject, isWorkerMessage } from '../types.js';
import { WorkerOptions, OptionsSchema, WorkerOptionsSchema, Options } from '../schema.js';
import { logger } from './logger.js';
import { getStorybookUrl, checkIsStorybookConnected } from './connection.js';
import { SeleniumWebdriver } from './selenium/webdriver.js';
import { LOCALHOST_REGEXP } from './webdriver.js';
import { isInsideDocker, killTree, resolvePlaywrightBrowserType, shutdownWithError } from './utils.js';
import { sendWorkerMessage, subscribeOn } from './messages.js';
import { buildImage } from './docker.js';
import { mkdir, writeFile } from 'fs/promises';
import assert from 'assert';
import * as v from 'valibot';
import { PlaywrightWebdriver } from '../playwright.js';
async function getPlaywrightVersion(): Promise<string> {
const {
default: { version },
} = await import('playwright-core/package.json', { with: { type: 'json' } });
return version;
}
async function startSelenoid(config: Config, debug = false): Promise<string | undefined> {
const { startSelenoidContainer, startSelenoidStandalone } = await import('./selenium/selenoid.js');
const gridUrl = 'http://localhost:4444/wd/hub';
if (config.useDocker) {
const host = await startSelenoidContainer(config, debug);
return isInsideDocker ? gridUrl.replace(LOCALHOST_REGEXP, host) : gridUrl;
}
await startSelenoidStandalone(config, debug);
return gridUrl;
}
async function startPlaywright(config: Config, browser: string, version: string, debug = false): Promise<string> {
// TODO Re-use dockerImage
const { startPlaywrightContainer } = await import('./playwright/docker.js');
const { browserName } = config.browsers[browser] as BrowserConfigObject;
const imageName = `creevey/${browserName}:v${version}`;
const host = await startPlaywrightContainer(imageName, browser, config, debug);
return host;
}
async function buildPlaywright(config: Config, version: string): Promise<void> {
const { playwrightDockerFile } = await import('./playwright/docker-file.js');
const {
default: { version: creeveyVersion },
} = await import('../../package.json', { with: { type: 'json' } });
const browsers = [...new Set(Object.values(config.browsers).map((c) => (c as BrowserConfigObject).browserName))];
await Promise.all(
browsers.map(async (browserName) => {
const imageName = `creevey/${browserName}:v${version}`;
const dockerfile = await playwrightDockerFile(browserName, version, config.experimental?.npmRegistry);
await buildImage(imageName, creeveyVersion, dockerfile);
}),
);
const { default: getPort } = await import('get-port');
cluster.on('message', (worker, message: unknown) => {
if (!isWorkerMessage(message)) return;
const workerMessage = message;
if (workerMessage.type != 'port') return;
void getPort().then((port) => {
sendWorkerMessage(worker, {
type: 'port',
payload: { port },
});
});
});
}
async function startWebdriverServer(config: Config, options: Options): Promise<string | undefined> {
if (config.webdriver === SeleniumWebdriver) {
return startSelenoid(config, options.debug);
// TODO Worker might want to use docker image of browser or start standalone selenium
} else {
if (config.gridUrl) return undefined;
if (config.useDocker) {
const version = await getPlaywrightVersion();
await buildPlaywright(config, version);
}
// TODO Support gridUrl for playwright
// NOTE: There is no grid for playwright right now
}
}
async function waitForStorybook(config: Config, options: Options): Promise<void> {
const [localUrl, remoteUrl] = getStorybookUrl(config, options);
if (options.storybookStart) {
const pm = getUserAgent();
assert(pm, new Error('Failed to detect current package manager'));
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const { command, args } = resolveCommand(pm, 'run', ['storybook', 'dev'])!;
const storybookPort = new URL(localUrl).port;
const storybookCommand = config.storybookAutorunCmd ?? [command, ...args, '--ci', '-p', storybookPort].join(' ');
logger().info(`Start Storybook via \`${storybookCommand}\`, it should be accessible at:`);
logger().info(`Local - ${localUrl}`);
if (remoteUrl && localUrl != remoteUrl) logger().info(`On your network - ${remoteUrl}`);
logger().info('Waiting Storybook...');
const storybook = sh.exec(storybookCommand, { async: true });
subscribeOn('shutdown', () => {
if (storybook.pid) void killTree(storybook.pid);
});
} else {
logger().info('Storybook should be started and be accessible at:');
logger().info(`Local - ${localUrl}`);
if (remoteUrl && localUrl != remoteUrl) logger().info(`On your network - ${remoteUrl}`);
logger().info(
'Tip: Creevey can start Storybook automatically by using `-s` option at the command line. (e.g., yarn/npm run creevey -s)',
);
logger().info('Waiting Storybook...');
}
if (options.storybookStart || process.env.CI !== 'true') {
const isConnected = await checkIsStorybookConnected(localUrl);
if (isConnected) {
logger().info('Storybook connected!\n');
} else {
logger().error('Storybook is not responding. Please start Storybook and restart Creevey');
shutdownWithError();
}
}
}
// TODO Why docker containers are not deleting after stop?
export default async function (command: 'report' | 'test' | 'worker', options: Options | WorkerOptions): Promise<void> {
const config = await readConfig(options);
await import('./shutdown.js');
if (v.is(OptionsSchema, options)) {
const { port, reportDir = config.reportDir } = options;
// TODO Add package.json with `"type": "commonjs"` as workaround for esm packages to load `data.js`
await mkdir(reportDir, { recursive: true });
await writeFile(path.join(reportDir, 'package.json'), '{"type": "commonjs"}');
if (command == 'report') {
const { report } = await import('./report.js');
const { default: getPort } = await import('get-port');
const freePort = await getPort({ port });
report(config, reportDir, freePort);
return;
}
if (cluster.isPrimary) {
let gridUrl: string | undefined = config.gridUrl;
if (config.hooks.before) {
await config.hooks.before();
}
if (!(gridUrl || (Object.values(config.browsers) as BrowserConfigObject[]).every(({ gridUrl }) => gridUrl))) {
gridUrl = await startWebdriverServer(config, options);
}
await waitForStorybook(config, options);
if (config.webdriver === SeleniumWebdriver) {
try {
await import('selenium-webdriver');
} catch {
logger().error('Failed to start Creevey, missing required dependency: "selenium-webdriver"');
process.exit(-1);
}
} else {
try {
await import('playwright-core');
} catch {
logger().error('Failed to start Creevey, missing required dependency: "playwright-core"');
process.exit(-1);
}
}
logger().info('Starting Master Process');
const { default: getPort } = await import('get-port');
const freePort = await getPort({ port });
return (await import('./master/start.js')).start(gridUrl, freePort, config, options);
}
}
if (v.is(WorkerOptionsSchema, options) && cluster.isWorker) {
let gridUrl = options.gridUrl;
const { browser = defaultBrowser, debug } = options;
if (!gridUrl) {
if (config.webdriver === PlaywrightWebdriver) {
if (config.useDocker) {
const version = await getPlaywrightVersion();
gridUrl = await startPlaywright(config, browser, version, debug);
} else {
const { browserName } = config.browsers[browser] as BrowserConfigObject;
gridUrl = `creevey://${resolvePlaywrightBrowserType(browserName)}`;
}
} else {
assert(gridUrl, 'Grid URL is required for Selenium');
}
}
logger().info(`Starting Worker for ${browser}`);
return (await import('./worker/start.js')).start(browser, gridUrl, config, options);
}
}