creevey
Version:
Cross-browser screenshot testing tool for Storybook with fancy UI Runner
246 lines (202 loc) • 7.01 kB
text/typescript
import { EventEmitter } from 'events';
import {
Config,
CreeveyStatus,
TestResult,
ApprovePayload,
isDefined,
CreeveyUpdate,
TestStatus,
ServerTest,
TEST_EVENTS,
FakeSuite,
FakeTest,
} from '../../types.js';
import Pool from './pool.js';
import { WorkerQueue } from './queue.js';
import { getTestPath } from '../utils.js';
import { getReporter } from '../reporters/index.js';
import type { TestsManager } from './testsManager.js';
// NOTE: This is workaround to fix parallel tests running with mocha-junit-reporter
let isJUnit = false;
class FakeRunner extends EventEmitter {
public stats = {
duration: 0,
failures: 0,
pending: 0,
};
}
export default class Runner extends EventEmitter {
private failFast: boolean;
private browsers: string[];
private scheduler: WorkerQueue;
private pools: Record<string, Pool> = {};
private fakeRunner: FakeRunner;
private config: Config;
public testsManager: TestsManager;
public get isRunning(): boolean {
return Object.values(this.pools).some((pool) => pool.isRunning);
}
constructor(config: Config, testsManager: TestsManager, gridUrl?: string) {
super();
this.config = config;
this.failFast = config.failFast;
this.testsManager = testsManager;
this.scheduler = new WorkerQueue(config.useWorkerQueue);
this.browsers = Object.keys(config.browsers);
const runner = new FakeRunner();
const Reporter = getReporter(config.reporter);
if (Reporter.name == 'MochaJUnitReporter') {
isJUnit = true;
}
new Reporter(runner, { reportDir: config.reportDir, reporterOptions: config.reporterOptions });
this.fakeRunner = runner;
this.browsers
.map((browser) => (this.pools[browser] = new Pool(this.scheduler, config, browser, gridUrl)))
.map((pool) => pool.on('test', this.handlePoolMessage));
}
private handlePoolMessage = (message: {
id: string;
workerId: number;
status: TestStatus;
result?: TestResult;
}): void => {
const { id, workerId, status, result } = message;
const test = this.testsManager.getTest(id);
if (!test) return;
const { browser, testName } = test;
const fakeSuite: FakeSuite = {
title: test.storyPath.slice(0, -1).join('/'),
fullTitle: () => fakeSuite.title,
titlePath: () => [fakeSuite.title],
tests: [],
};
const fakeTest: FakeTest = {
parent: fakeSuite,
title: [test.story.name, testName, browser].filter(isDefined).join('/'),
fullTitle: () => getTestPath(test).join('/'),
titlePath: () => getTestPath(test),
currentRetry: () => result?.retries,
retires: () => this.config.maxRetries,
slow: () => 1000,
err: result?.error,
creevey: {
testId: id,
workerId,
sessionId: result?.sessionId ?? id,
browserName: result?.browserName ?? browser,
willRetry: (result?.retries ?? 0) < this.config.maxRetries,
images: result?.images ?? {},
},
};
fakeSuite.tests.push(fakeTest);
const update = this.testsManager.updateTestStatus(id, status, result);
if (!update) return;
if (!result) {
this.fakeRunner.emit(TEST_EVENTS.TEST_BEGIN, fakeTest);
this.sendUpdate(update);
return;
}
const { duration, attachments } = result;
fakeTest.duration = duration;
fakeTest.attachments = attachments;
fakeTest.state = result.status === 'failed' ? 'failed' : 'passed';
if (duration !== undefined) {
fakeTest.speed = duration > fakeTest.slow() ? 'slow' : duration / 2 > fakeTest.slow() ? 'medium' : 'fast';
}
if (isJUnit) {
this.fakeRunner.emit(TEST_EVENTS.SUITE_BEGIN, fakeSuite);
}
if (result.status === 'failed') {
fakeTest.err = result.error;
this.fakeRunner.emit(TEST_EVENTS.TEST_FAIL, fakeTest, result.error);
this.fakeRunner.stats.failures++;
} else {
this.fakeRunner.emit(TEST_EVENTS.TEST_PASS, fakeTest);
this.fakeRunner.stats.duration += duration ?? 0;
}
if (isJUnit) {
this.fakeRunner.emit(TEST_EVENTS.SUITE_END, fakeSuite);
}
this.fakeRunner.emit(TEST_EVENTS.TEST_END, fakeTest);
this.sendUpdate(update);
if (this.failFast && status == 'failed') this.stop();
};
private handlePoolStop = (): void => {
if (!this.isRunning) {
this.fakeRunner.emit(TEST_EVENTS.RUN_END);
this.sendUpdate({ isRunning: false });
this.emit('stop');
}
};
public async init(): Promise<void> {
await Promise.all(Object.values(this.pools).map((pool) => pool.init()));
}
public updateTests(testsDiff: Partial<Record<string, ServerTest>>): void {
const update = this.testsManager.updateTests(testsDiff);
if (update) this.sendUpdate(update);
}
public start(ids: string[]): void {
type TestsByBrowser = Record<string, { id: string; path: string[] }[]>;
if (this.isRunning) return;
const testsToStart = ids
.map((id) => this.testsManager.getTest(id))
.filter(isDefined)
.filter((test) => !test.skip);
if (testsToStart.length == 0) return;
const pendingTests: CreeveyUpdate['tests'] = testsToStart.reduce(
(update: CreeveyUpdate['tests'], { id, storyId, browser, testName, storyPath }) => ({
...update,
[id]: { id, browser, testName, storyPath, status: 'pending', storyId },
}),
{},
);
this.sendUpdate({
isRunning: true,
tests: pendingTests,
});
const testsByBrowser: Partial<TestsByBrowser> = testsToStart.reduce((tests: Partial<TestsByBrowser>, test) => {
const { id, browser, testName, storyPath } = test;
const restPath = [...storyPath, testName].filter(isDefined);
// Update status to pending in TestsManager
this.testsManager.updateTestStatus(id, 'pending');
return {
...tests,
[browser]: [...(tests[browser] ?? []), { id, path: restPath }],
};
}, {});
this.fakeRunner.emit(TEST_EVENTS.RUN_BEGIN);
this.browsers.forEach((browser) => {
const pool = this.pools[browser];
const tests = testsByBrowser[browser];
if (tests && tests.length > 0 && pool.start(tests)) {
pool.once('stop', this.handlePoolStop);
}
});
}
public stop(): void {
if (!this.isRunning) return;
this.browsers.forEach((browser) => {
this.pools[browser].stop();
});
}
public get status(): CreeveyStatus {
return {
isRunning: this.isRunning,
tests: this.testsManager.getTestsData(),
browsers: this.browsers,
isUpdateMode: false,
};
}
public async approveAll(): Promise<void> {
const update = await this.testsManager.approveAll();
this.sendUpdate(update);
}
public async approve(payload: ApprovePayload): Promise<void> {
const update = await this.testsManager.approve(payload);
if (update) this.sendUpdate(update);
}
private sendUpdate(data: CreeveyUpdate): void {
this.emit('update', data);
}
}