UNPKG

creevey

Version:

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

311 lines (274 loc) 8.95 kB
import path from 'path'; import { mkdirSync, writeFileSync } from 'fs'; import EventEmitter from 'events'; import { ServerTest, TestMeta, TestResult, TestStatus, CreeveyUpdate, ApprovePayload, isDefined, isFunction, CreeveyStatus, } from '../../types.js'; import { tryToLoadTestsData } from '../utils.js'; import { copyFile, mkdir, writeFile } from 'fs/promises'; /** * TestsManager is responsible for all operations related to test data management * including loading, saving, merging, and updating test data. * It extends EventEmitter to emit update events that can be subscribed to. */ export class TestsManager extends EventEmitter { private tests: Partial<Record<string, ServerTest>> = {}; private screenDir: string; private reportDir: string; /** * Creates a new TestsManager instance * @param screenDir Directory for storing reference images * @param reportDir Directory for storing reports and screenshots */ constructor(screenDir: string, reportDir: string) { super(); this.screenDir = screenDir; this.reportDir = reportDir; } /** * Get a copy of all tests * @returns all tests */ public getTests(): Partial<Record<string, ServerTest>> { return this.tests; } /** * Get a test by ID * @param id Test ID * @returns Test data */ public getTest(id: string): ServerTest | undefined { return this.tests[id]; } /** * Get test data in a format suitable for status reporting * @returns Test data in the format needed for status */ public getTestsData(): CreeveyStatus['tests'] { const testsData: CreeveyStatus['tests'] = {}; Object.entries(this.tests).forEach(([id, test]) => { if (!test) return; const { story: _, fn: __, ...testData } = test; testsData[id] = testData; }); return testsData; } /** * Load tests from a report file */ public loadTestsFromReport(): Partial<Record<string, ServerTest>> { // TODO: Move to utils const reportDataPath = path.join(this.reportDir, 'data.js'); const testsFromReport = tryToLoadTestsData(reportDataPath) ?? {}; return testsFromReport; } /** * Merge tests from report with tests from stories */ public mergeTests( testsWithReports: CreeveyStatus['tests'], testsFromStories: Partial<Record<string, ServerTest>>, ): Partial<Record<string, ServerTest>> { Object.values(testsFromStories) .filter(isDefined) .forEach((test) => { const testWithReport = testsWithReports[test.id]; if (!testWithReport) return; test.retries = testWithReport.retries; if (testWithReport.status === 'success' || testWithReport.status === 'failed') { test.status = testWithReport.status; } test.results = testWithReport.results; test.approved = testWithReport.approved; }); return testsFromStories; } /** * Update tests with incremental changes * @param testsDiff Tests to update or remove */ public updateTests(testsDiff: Partial<Record<string, ServerTest>>): CreeveyUpdate | null { const tests: CreeveyUpdate['tests'] = {}; const removedTests: TestMeta[] = []; Object.entries(testsDiff).forEach(([id, newTest]) => { if (newTest) { if (this.tests[id]) { this.tests[id] = { ...newTest, retries: this.tests[id].retries, results: this.tests[id].results, approved: this.tests[id].approved, }; } else { this.tests[id] = newTest; } const { story: _, fn: __, ...restTest } = newTest; tests[id] = { status: 'unknown', ...restTest }; } else if (this.tests[id]) { const { id: testId, browser, testName, storyPath, storyId } = this.tests[id]; removedTests.push({ id: testId, browser, testName, storyPath, storyId }); // eslint-disable-next-line @typescript-eslint/no-dynamic-delete delete this.tests[id]; } }); this.saveTestsToJson(); const update = { tests, removedTests }; this.emit('update', update); return update; } /** * Update test result * @param id Test ID * @param status New test status * @param result Optional test result */ public updateTestStatus(id: string, status: TestStatus, result?: TestResult): CreeveyUpdate | null { // TODO Handle 'retrying' status const test = this.tests[id]; if (!test) return null; const { browser, testName, storyPath, storyId } = test; test.status = status === 'retrying' ? 'failed' : status; if (!result) { // NOTE: Running status const update = { tests: { [id]: { id, browser, testName, storyPath, status, storyId } } }; this.emit('update', update); return update; } test.results ??= []; test.results.push(result); if (status === 'failed') { test.approved = null; } const update = { tests: { [id]: { id, browser, testName, storyPath, status, approved: test.approved, results: [result], storyId, }, }, }; this.emit('update', update); return update; } /** * Save tests to JSON file * @param reportDir Directory to save the JSON file */ public saveTestsToJson(): void { mkdirSync(this.reportDir, { recursive: true }); writeFileSync( path.join(this.reportDir, 'tests.json'), // eslint-disable-next-line @typescript-eslint/no-unsafe-return JSON.stringify(this.tests, (_, value) => (isFunction(value) ? value.toString() : value), 2), ); } /** * Save test data to a module * @param data Test data to include in the module */ public async saveTestData(data: CreeveyStatus['tests'] = this.getTestsData()): Promise<void> { const dataModule = ` (function (root, factory) { if (typeof module === 'object' && module.exports) { module.exports = factory(); } else { root.__CREEVEY_DATA__ = factory(); } }(this, function () { return ${JSON.stringify(data)} })); `; await writeFile(path.join(this.reportDir, 'data.js'), dataModule); } /** * Copy image for approval * @param test Test data * @param image Image name * @param actual Actual image path */ private async copyImage(test: ServerTest, image: string, actual: string): Promise<void> { const { browser, testName, storyPath } = test; const restPath = [...storyPath, testName].filter(isDefined); const testPath = path.join(...restPath, image == browser ? '' : browser); const srcImagePath = path.join(this.reportDir, testPath, actual); const dstImagePath = path.join(this.screenDir, testPath, `${image}.png`); await mkdir(path.join(this.screenDir, testPath), { recursive: true }); await copyFile(srcImagePath, dstImagePath); } /** * Approve a specific test * @param payload Approval payload with test ID, retry index, and image name */ public async approve({ id, retry, image }: ApprovePayload): Promise<CreeveyUpdate | null> { const test = this.tests[id]; if (!test?.results) return null; const result = test.results[retry]; if (!result.images) return null; const images = result.images[image]; if (!images) return null; test.approved ??= {}; const { browser, testName, storyPath, storyId } = test; await this.copyImage(test, image, images.actual); test.approved[image] = retry; if (Object.keys(result.images).every((name) => typeof test.approved?.[name] == 'number')) { test.status = 'approved'; } const update = { tests: { [id]: { id, browser, testName, storyPath, status: test.status, approved: test.approved, storyId, }, }, }; this.emit('update', update); return update; } /** * Approve all failed tests */ public async approveAll(): Promise<CreeveyUpdate> { const updatedTests: NonNullable<CreeveyUpdate['tests']> = {}; for (const test of Object.values(this.tests)) { if (!test?.results) continue; const retry = test.results.length - 1; const { images, status } = test.results.at(retry) ?? {}; if (!images || status != 'failed') continue; for (const [name, image] of Object.entries(images)) { if (!image) continue; await this.copyImage(test, name, image.actual); test.approved ??= {}; test.approved[name] = retry; test.status = 'approved'; updatedTests[test.id] = { id: test.id, browser: test.browser, storyPath: test.storyPath, storyId: test.storyId, status: test.status, approved: { [name]: retry }, }; } } const result = { tests: updatedTests }; this.emit('update', result); return result; } }