UNPKG

creevey

Version:

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

381 lines 14.8 kB
"use strict"; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); const promises_1 = __importDefault(require("fs/promises")); const crypto_1 = require("crypto"); const types_js_1 = require("../types.js"); const testsManager_js_1 = require("../server/master/testsManager.js"); const utils_js_1 = require("../server/utils.js"); const assert_1 = __importDefault(require("assert")); /** * Simple async queue to handle operations in sequence without returning promises * from reporter methods that should be synchronous */ class AsyncQueue { queue; constructor() { this.queue = Promise.resolve(); } /** * Add an async operation to the queue * @param operation Async operation to execute */ enqueue(operation) { this.queue = this.queue.then(operation).catch((error) => { console.error(`Error in async queue: ${error instanceof Error ? error.message : String(error)}`); }); } /** * Wait for all operations in the queue to complete */ async waitForCompletion() { await this.queue; } } /** * CreeveyPlaywrightReporter is a Playwright reporter that integrates with Creevey * to provide visual testing capabilities and use Creevey's UI for reviewing and approving screenshots. */ class CreeveyPlaywrightReporter { testsManager = null; host; port; debug; testIdMap = new Map(); // Maps Playwright test IDs to Creevey test IDs asyncQueue = new AsyncQueue(); abortPoll = null; creeveyApiEndpoint = null; /** * Creates a new instance of the CreeveyPlaywrightReporter * @param options Configuration options for the reporter */ constructor(options) { this.host = options?.host ?? 'localhost'; this.port = options?.port ?? 3000; this.debug = options?.debug ?? !!process.env.PWDEBUG; this.pollCreeveyApi(); } /** * Called when the test run starts * @param config Playwright configuration * @param suite Test suite information */ onBegin(config, suite) { this.logDebug('CreeveyPlaywrightReporter started'); const [snapshotDir, ...restSnapshotDirs] = [...new Set(config.projects.map((project) => project.snapshotDir))]; const [outputDir, ...restOutputDirs] = [...new Set(config.projects.map((project) => project.outputDir))]; (0, assert_1.default)(restSnapshotDirs.length === 0, 'Currently multiple snapshot directories are not supported'); (0, assert_1.default)(restOutputDirs.length === 0, 'Currently multiple output directories are not supported'); // Initialize TestsManager this.testsManager = new testsManager_js_1.TestsManager(snapshotDir, outputDir); this.testsManager.on('update', (update) => { this.commitTestResults(update); }); // Use the async queue to handle initialization without returning a promise this.asyncQueue.enqueue(async () => { (0, assert_1.default)(this.testsManager, 'TestsManager is not initialized'); try { await promises_1.default.mkdir(outputDir, { recursive: true }); await (0, utils_js_1.copyStatics)(outputDir); } catch (error) { this.logError(`Failed to initialize report directory: ${error instanceof Error ? error.message : String(error)}`); } try { const testsList = suite .allTests() .map((test) => { const creeveyTest = this.mapToCreeveyTest(test); if (!creeveyTest) return; this.testIdMap.set(test.id, creeveyTest.id); return creeveyTest; }) .filter(types_js_1.isDefined); const tests = {}; for (const test of testsList) { tests[test.id] = test; } this.testsManager.updateTests(tests); } catch (error) { this.logError(`Could not start Creevey server: ${error instanceof Error ? error.message : String(error)}`); console.log('Screenshots will still be captured but UI will not be available'); } }); } /** * Called when a test begins * @param test Test case information * @param result Test result (initially empty) */ onTestBegin(test, _result) { try { (0, assert_1.default)(this.testsManager, 'TestsManager is not initialized'); const creeveyTestId = this.testIdMap.get(test.id); if (creeveyTestId) { // Update test status to running this.testsManager.updateTestStatus(creeveyTestId, 'running'); this.logDebug(`Test started: ${test.title} (${creeveyTestId})`); } } catch (error) { this.logError(`Error in onTestBegin: ${error instanceof Error ? error.message : String(error)}`); } } /** * Called when a test step begins * @param test Test case information * @param result Test result * @param step Test step information */ onStepBegin(test, _result, step) { /* [Creevey Reporter] Step started: browserType.launch in test: 100 X 100 Vs 2000 X 100 [Creevey Reporter] Step started: browserType.launch in test: Side By Side [Creevey Reporter] Step started: browserType.launch in test: Full */ if (this.debug) { this.logDebug(`Step started: ${step.title} in test: ${test.title}`); } } /** * Called when a test step ends * @param test Test case information * @param result Test result * @param step Test step information */ onStepEnd(_test, _result, step) { try { // If step has attachments, process them if (step.attachments.length > 0) { this.logDebug(`Processing ${step.attachments.length} attachments from step: ${step.title}`); // We'll process attachments in onTestEnd for simplicity in this initial implementation } } catch (error) { this.logError(`Error in onStepEnd: ${error instanceof Error ? error.message : String(error)}`); } } /** * Called when a test ends * @param test Test case information * @param result Test result */ onTestEnd(test, result) { const creeveyTestId = this.testIdMap.get(test.id); // Use the async queue to handle result processing without returning a promise this.asyncQueue.enqueue(async () => { try { // Process test results and screenshots await this.processTestResult(test, result); if (creeveyTestId) { this.logDebug(`Test ended: ${test.title} (${creeveyTestId}) with status: ${result.status}`); } } catch (error) { this.logError(`Error in onTestEnd: ${error instanceof Error ? error.message : String(error)}`); } }); } /** * Called when the test run ends * @param result The overall test run result */ async onEnd(result) { // Use the async queue to handle final operations without returning a promise this.asyncQueue.enqueue(async () => { try { (0, assert_1.default)(this.testsManager, 'TestsManager is not initialized'); // Save test data await this.testsManager.saveTestData(); this.abortPoll?.(); this.logDebug(`Test run ended with status: ${result.status}`); // TODO: Tell how to run reporter `yarn creevey update ./report --ui` } catch (error) { this.logError(`Error during reporter cleanup: ${error instanceof Error ? error.message : String(error)}`); } }); // Wait for all previous operations to complete await this.asyncQueue.waitForCompletion(); } pollCreeveyApi() { const { host, port } = this; const setAbort = (fn) => { this.abortPoll = fn; }; const setApiEndpoint = (endpoint) => { this.creeveyApiEndpoint = endpoint; }; const manager = () => this.testsManager; const commit = (update) => { this.commitTestResults(update); }; function poll() { void Promise.race([ fetch(`http://${host}:${port}/ping`, { method: 'GET', }).then((response) => response.text()), new Promise((_, reject) => setTimeout(() => { reject(new Error('Creevey API is not available')); }, 1000)), ]) .then((text) => { if (text !== 'pong') throw new Error('Creevey API is not available'); setApiEndpoint(`http://${host}:${port}`); const testsManager = manager(); if (testsManager) { commit({ tests: testsManager.getTestsData() }); } }) .catch(() => { const timeout = setTimeout(poll, 1000); setAbort(() => { clearTimeout(timeout); }); }); } poll(); } commitTestResults(update) { if (!this.creeveyApiEndpoint) return; void fetch(`${this.creeveyApiEndpoint}/tests`, { method: 'POST', body: JSON.stringify(update), }); } /** * Maps a Playwright test to a Creevey test format * @param test Playwright test case * @returns Creevey test object or null if mapping fails */ mapToCreeveyTest(test) { try { const storyName = test.title; const storyTitle = test.parent.title; const projectName = test.parent.project()?.name ?? 'chromium'; const testPath = [storyTitle, storyName, projectName]; const { description: storyId } = test.annotations.find((annotation) => annotation.type === 'storyId') ?? {}; // Generate a unique test ID const testId = (0, crypto_1.createHash)('sha1').update(testPath.join('/')).digest('hex'); // Create the test metadata const testMeta = { id: testId, storyPath: [...storyTitle.split('/').map((x) => x.trim()), storyName], browser: projectName, storyId: storyId ?? '', }; // Create a stub ServerTest object // This is missing the story and fn properties which would be used in a real Creevey test // However, for our reporter purposes, we just need the metadata const serverTest = { ...testMeta, story: { parameters: {}, initialArgs: {}, argTypes: {}, component: '', componentId: '', name: storyName, tags: [], title: storyTitle, kind: storyTitle, id: storyId ?? '', story: storyName, }, // Placeholder fn: async () => { /* Empty function as placeholder */ }, // Placeholder }; return serverTest; } catch (error) { this.logError(`Error mapping test to Creevey format: ${error instanceof Error ? error.message : String(error)}`); return null; } } /** * Process a test result and any attachments * @param test Playwright test case * @param result Playwright test result */ async processTestResult(test, result) { const creeveyTestId = this.testIdMap.get(test.id); if (!creeveyTestId) { this.logError(`No Creevey test ID found for test: ${test.title}`); return Promise.resolve(); } (0, assert_1.default)(this.testsManager, 'TestsManager is not initialized'); // Determine test status let status; switch (result.status) { case 'passed': status = 'success'; break; case 'failed': case 'timedOut': status = 'failed'; break; default: status = 'unknown'; } // Process attachments const images = {}; const attachmentPaths = []; const projectName = test.parent.project()?.name ?? 'chromium'; for (const attachment of result.attachments) { const { name, path: attachmentPath } = attachment; if (!attachmentPath) continue; attachmentPaths.push(attachmentPath); switch (true) { case name.includes('actual'): { images[projectName] = { ...images[projectName], actual: name }; break; } case name.includes('expect'): { images[projectName] = { ...images[projectName], expect: name }; break; } case name.includes('diff'): { images[projectName] = { ...images[projectName], diff: name }; break; } } } // Update test status and result const testResult = { status: status === 'success' ? 'success' : 'failed', retries: result.retry, images, error: result.error?.message ?? undefined, duration: result.duration, attachments: attachmentPaths, browserName: projectName, }; this.testsManager.updateTestStatus(creeveyTestId, status, testResult); } /** * Logs a debug message if debug mode is enabled * @param message Message to log */ logDebug(message) { if (this.debug) { console.log(`[Creevey Reporter] ${message}`); } } /** * Logs an error message * @param message Error message to log */ logError(message) { console.error(`[Creevey Reporter] ERROR: ${message}`); } } exports.default = CreeveyPlaywrightReporter; //# sourceMappingURL=reporter.js.map