diff --git a/CHANGELOG.md b/CHANGELOG.md index 4360153..c1e86ab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,5 @@ +### Added +- `cucumberStepStart` and `cucumberStepEnd` commands for reporting `cypress-cucumber-preprocessor` scenario steps as nested steps in RP. ### Security - Updated versions of vulnerable packages (@reportportal/client-javascript, glob). diff --git a/README.md b/README.md index 8192482..c52e673 100644 --- a/README.md +++ b/README.md @@ -437,6 +437,64 @@ jobs: **Note:** The example provided for Cypress version <= 9. For Cypress version >= 10 usage of `cypress-io/github-action` may be changed. +## Cypress-cucumber-preprocessor execution + +### Configuration: +Specify the options in the cypress.config.js: + +```javascript +const { defineConfig } = require('cypress'); +const createBundler = require('@bahmutov/cypress-esbuild-preprocessor'); +const preprocessor = require('@badeball/cypress-cucumber-preprocessor'); +const createEsbuildPlugin = require('@badeball/cypress-cucumber-preprocessor/esbuild').default; +const registerReportPortalPlugin = require('@reportportal/agent-js-cypress/lib/plugin'); + +module.exports = defineConfig({ + reporter: '@reportportal/agent-js-cypress', + reporterOptions: { + endpoint: 'http://your-instance.com:8080/api/v1', + apiKey: 'reportportalApiKey', + launch: 'LAUNCH_NAME', + project: 'PROJECT_NAME', + description: 'LAUNCH_DESCRIPTION', + }, + e2e: { + async setupNodeEvents(on, config) { + await preprocessor.addCucumberPreprocessorPlugin(on, config); + on( + 'file:preprocessor', + createBundler({ + plugins: [createEsbuildPlugin(config)], + }), + ); + registerReportPortalPlugin(on, config); + + return config; + }, + specPattern: 'cypress/e2e/**/*.feature', + supportFile: 'cypress/support/e2e.js', + }, +}); +``` + +### Scenario steps +At the moment it is not possible to subscribe to start and end of scenario steps events. To solve the problem with displaying steps in the ReportPortal, the agent provides special commands: `cucumberStepStart`, `cucumberStepEnd`. +To work correctly, these commands must be called in the `BeforeStep`/`AfterStep` hooks. + +```javascript +import { BeforeStep, AfterStep } from '@badeball/cypress-cucumber-preprocessor'; + +BeforeStep((step) => { + cy.cucumberStepStart(step); +}); + +AfterStep((step) => { + cy.cucumberStepEnd(step); +}); +``` + +You can avoid duplicating this logic in each step definitions. Instead, add it to the `cypress/support/step_definitions.js` file and include the path to this file in the `stepDefinitions` array (if necessary). These hooks will be used for all step definitions. + # Copyright Notice Licensed under the [Apache License v2.0](LICENSE) diff --git a/lib/commands/reportPortalCommands.d.ts b/lib/commands/reportPortalCommands.d.ts index 77b3d0e..6e3df17 100644 --- a/lib/commands/reportPortalCommands.d.ts +++ b/lib/commands/reportPortalCommands.d.ts @@ -53,6 +53,11 @@ declare global { launchError(message: string, file?: RP_FILE): Chainable; launchFatal(message: string, file?: RP_FILE): Chainable; + // Waiting for migrate to TypeScript + // Expected step: IStepHookParameter (https://github.com/badeball/cypress-cucumber-preprocessor/blob/055d8df6a62009c94057b0d894a30e142cb87b94/lib/public-member-types.ts#L39) + cucumberStepStart(step: any): Chainable; + + cucumberStepEnd(step: any): Chainable; setStatus(status: RP_STATUS, suiteTitle?: string): Chainable; diff --git a/lib/commands/reportPortalCommands.js b/lib/commands/reportPortalCommands.js index 5a9da37..7b25c0c 100644 --- a/lib/commands/reportPortalCommands.js +++ b/lib/commands/reportPortalCommands.js @@ -131,6 +131,17 @@ Cypress.Commands.add('launchFatal', (message, file) => { }); }); +/** + * Cucumber Scenario's steps commands + */ +Cypress.Commands.add('cucumberStepStart', (step) => { + cy.task('rp_cucumberStepStart', step); +}); + +Cypress.Commands.add('cucumberStepEnd', (step) => { + cy.task('rp_cucumberStepEnd', step); +}); + /** * Attributes command */ diff --git a/lib/constants.js b/lib/constants.js index f110d24..c37d318 100644 --- a/lib/constants.js +++ b/lib/constants.js @@ -59,6 +59,14 @@ const reporterEvents = { SCREENSHOT: 'screenshot', SET_STATUS: 'setStatus', SET_LAUNCH_STATUS: 'setLaunchStatus', + CUCUMBER_STEP_START: 'cucumberStepStart', + CUCUMBER_STEP_END: 'cucumberStepEnd', +}; + +const cucumberKeywordMap = { + Outcome: 'Then', + Action: 'When', + Context: 'Given', }; module.exports = { @@ -66,5 +74,6 @@ module.exports = { logLevels, entityType, hookTypesMap, + cucumberKeywordMap, reporterEvents, }; diff --git a/lib/cypressReporter.js b/lib/cypressReporter.js index 5431ff6..796f50d 100644 --- a/lib/cypressReporter.js +++ b/lib/cypressReporter.js @@ -81,6 +81,10 @@ class CypressReporter extends Mocha.reporters.Base { this.worker.send({ event: reporterEvents.SET_STATUS, statusInfo }); const setLaunchStatusListener = (statusInfo) => this.worker.send({ event: reporterEvents.SET_LAUNCH_STATUS, statusInfo }); + const cucumberStepStartListener = (step) => + this.worker.send({ event: reporterEvents.CUCUMBER_STEP_START, step }); + const cucumberStepEndListener = (step) => + this.worker.send({ event: reporterEvents.CUCUMBER_STEP_END, step }); startIPCServer( (server) => { @@ -93,6 +97,8 @@ class CypressReporter extends Mocha.reporters.Base { server.on(IPC_EVENTS.SCREENSHOT, screenshotListener); server.on(IPC_EVENTS.SET_STATUS, setStatusListener); server.on(IPC_EVENTS.SET_LAUNCH_STATUS, setLaunchStatusListener); + server.on(IPC_EVENTS.CUCUMBER_STEP_START, cucumberStepStartListener); + server.on(IPC_EVENTS.CUCUMBER_STEP_END, cucumberStepEndListener); }, (server) => { server.off(IPC_EVENTS.CONFIG, '*'); @@ -104,6 +110,8 @@ class CypressReporter extends Mocha.reporters.Base { server.off(IPC_EVENTS.SCREENSHOT, '*'); server.off(IPC_EVENTS.SET_STATUS, '*'); server.off(IPC_EVENTS.SET_LAUNCH_STATUS, '*'); + server.off(IPC_EVENTS.CUCUMBER_STEP_START, '*'); + server.off(IPC_EVENTS.CUCUMBER_STEP_END, '*'); }, ); CypressReporter.worker = this.worker; diff --git a/lib/ipcEvents.js b/lib/ipcEvents.js index 9d56dec..f4be177 100644 --- a/lib/ipcEvents.js +++ b/lib/ipcEvents.js @@ -24,6 +24,8 @@ const IPC_EVENTS = { SCREENSHOT: 'screenshot', SET_STATUS: 'setStatus', SET_LAUNCH_STATUS: 'setLaunchStatus', + CUCUMBER_STEP_START: 'cucumberStepStart', + CUCUMBER_STEP_END: 'cucumberStepEnd', }; module.exports = { IPC_EVENTS }; diff --git a/lib/plugin/index.js b/lib/plugin/index.js index 9fc5be7..0ef1958 100644 --- a/lib/plugin/index.js +++ b/lib/plugin/index.js @@ -50,6 +50,14 @@ const registerReportPortalPlugin = (on, config, callbacks) => { ipc.of.reportportal.emit(IPC_EVENTS.SET_LAUNCH_STATUS, statusInfo); return null; }, + rp_cucumberStepStart(step) { + ipc.of.reportportal.emit(IPC_EVENTS.CUCUMBER_STEP_START, step); + return null; + }, + rp_cucumberStepEnd(step) { + ipc.of.reportportal.emit(IPC_EVENTS.CUCUMBER_STEP_END, step); + return null; + }, }); on('after:screenshot', (screenshotInfo) => { diff --git a/lib/reporter.js b/lib/reporter.js index 1e08d3a..c1cf8ee 100644 --- a/lib/reporter.js +++ b/lib/reporter.js @@ -16,13 +16,14 @@ const RPClient = require('@reportportal/client-javascript'); -const { entityType, logLevels, testItemStatuses } = require('./constants'); +const { entityType, logLevels, testItemStatuses, cucumberKeywordMap } = require('./constants'); const { getScreenshotAttachment, getTestStartObject, getTestEndObject, getHookStartObject, getAgentInfo, + getCodeRef, } = require('./utils'); const { createMergeLaunchLockFile, deleteMergeLaunchLockFile } = require('./mergeLaunchesUtils'); @@ -55,6 +56,7 @@ class Reporter { this.suiteTestCaseIds = new Map(); this.pendingTestsIds = []; this.suiteStatuses = new Map(); + this.cucumberSteps = new Map(); } resetCurrentTestFinishParams() { @@ -137,7 +139,12 @@ class Reporter { ); promiseErrorHandler(promise, 'Fail to start test'); this.testItemIds.set(test.id, tempId); - this.currentTestTempInfo = { tempId, startTime: startTestObj.startTime }; + this.currentTestTempInfo = { + tempId, + codeRef: test.codeRef, + startTime: startTestObj.startTime, + cucumberStepIds: new Set(), + }; if (this.pendingTestsIds.includes(test.id)) { this.testEnd(test); } @@ -161,6 +168,7 @@ class Reporter { testId = this.testItemIds.get(test.id); } this.sendLogOnFinishFailedItem(test, testId); + this.finishFailedStep(test); const testInfo = Object.assign({}, test, this.currentTestFinishParams); const finishTestItemPromise = this.client.finishTestItem( testId, @@ -181,6 +189,75 @@ class Reporter { } } + cucumberStepStart(data) { + const { testStepId, pickleStep } = data; + const parent = this.currentTestTempInfo; + + if (!parent) return; + + const keyword = cucumberKeywordMap[pickleStep.type]; + const stepName = pickleStep.text; + const codeRef = getCodeRef([stepName], parent.codeRef); + + const stepData = { + name: keyword ? `${keyword} ${stepName}` : stepName, + startTime: this.client.helpers.now(), + type: entityType.STEP, + codeRef, + hasStats: false, + }; + + const { tempId, promise } = this.client.startTestItem( + stepData, + this.tempLaunchId, + parent.tempId, + ); + promiseErrorHandler(promise, 'Fail to start step'); + this.cucumberSteps.set(testStepId, { tempId, tempParentId: parent.tempId, testStepId }); + parent.cucumberStepIds.add(testStepId); + } + + finishFailedStep(test) { + if (test.status === FAILED) { + const step = this.getCurrentCucumberStep(); + + if (!step) return; + + this.cucumberStepEnd({ + testStepId: step.testStepId, + testStepResult: { + status: testItemStatuses.FAILED, + message: test.err.stack, + }, + }); + } + } + + cucumberStepEnd(data) { + const { testStepId, testStepResult = { status: testItemStatuses.PASSED } } = data; + const step = this.cucumberSteps.get(testStepId); + + if (!step) return; + + if (testStepResult.status === testItemStatuses.FAILED) { + this.sendLog(step.tempId, { + time: this.client.helpers.now(), + level: logLevels.ERROR, + message: testStepResult.message, + }); + } + + this.client.finishTestItem(step.tempId, { + status: testStepResult.status, + endTime: this.client.helpers.now(), + }); + + this.cucumberSteps.delete(testStepId); + if (this.currentTestTempInfo) { + this.currentTestTempInfo.cucumberStepIds.delete(testStepId); + } + } + hookStart(hook) { const hookStartObject = getHookStartObject(hook); switch (hookStartObject.type) { @@ -227,6 +304,24 @@ class Reporter { return currentSuiteInfo && currentSuiteInfo.tempId; } + getCurrentCucumberStep() { + if (this.currentTestTempInfo && this.currentTestTempInfo.cucumberStepIds.size > 0) { + const testStepId = Array.from(this.currentTestTempInfo.cucumberStepIds.values())[ + this.currentTestTempInfo.cucumberStepIds.size - 1 + ]; + + return this.cucumberSteps.get(testStepId); + } + + return null; + } + + getCurrentCucumberStepId() { + const step = this.getCurrentCucumberStep(); + + return step && step.tempId; + } + sendLog(tempId, { level, message = '', file }) { return this.client.sendLog( tempId, @@ -241,7 +336,9 @@ class Reporter { sendLogToCurrentItem(log) { const tempItemId = - (this.currentTestTempInfo && this.currentTestTempInfo.tempId) || this.getCurrentSuiteId(); + this.getCurrentCucumberStepId() || + (this.currentTestTempInfo && this.currentTestTempInfo.tempId) || + this.getCurrentSuiteId(); if (tempItemId) { const promise = this.sendLog(tempItemId, log); promiseErrorHandler(promise, 'Fail to send log to current item'); diff --git a/lib/worker.js b/lib/worker.js index c6dce77..913a1d8 100644 --- a/lib/worker.js +++ b/lib/worker.js @@ -99,6 +99,12 @@ process.on('message', (message) => { case reporterEvents.SET_LAUNCH_STATUS: reporter.setLaunchStatus(message.statusInfo); break; + case reporterEvents.CUCUMBER_STEP_START: + reporter.cucumberStepStart(message.step); + break; + case reporterEvents.CUCUMBER_STEP_END: + reporter.cucumberStepEnd(message.step); + break; default: break; } diff --git a/test/mock/mock.js b/test/mock/mock.js index 616e907..c805f24 100644 --- a/test/mock/mock.js +++ b/test/mock/mock.js @@ -1,3 +1,5 @@ +const currentDate = new Date().valueOf(); + class RPClient { constructor(config) { this.config = config; @@ -25,6 +27,10 @@ class RPClient { this.sendLog = jest.fn().mockReturnValue({ promise: Promise.resolve('ok'), }); + + this.helpers = { + now: jest.fn().mockReturnValue(currentDate), + }; } } @@ -40,7 +46,6 @@ const getDefaultConfig = () => ({ }, }); -const currentDate = new Date().valueOf(); const RealDate = Date; const MockedDate = (...attrs) => diff --git a/test/reporter.test.js b/test/reporter.test.js index cd3e80a..848e1f8 100644 --- a/test/reporter.test.js +++ b/test/reporter.test.js @@ -5,6 +5,34 @@ const Reporter = require('./../lib/reporter'); const sep = path.sep; +const tempLaunchId = 'temp-launch-id'; +const tempTestId = 'temp-test-id'; +const tempStepId = 'temp-step-id'; +const testStepId = 'test-step-id'; + +const mockInputStep = { + testStepId, + pickleStep: { + type: 'Action', + text: 'step-name', + }, +}; +const mockStep = { + tempId: tempStepId, + parentId: tempTestId, + testStepId, +}; +const mockCurrentTestTempInfo = { + tempId: tempTestId, + codeRef: 'test-code-ref', + startTime: currentDate, + cucumberStepIds: new Set(), +}; +const mockCurrentTestTempInfoWithStep = { + ...mockCurrentTestTempInfo, + cucumberStepIds: new Set(testStepId), +}; + describe('reporter script', () => { let reporter; @@ -470,6 +498,81 @@ describe('reporter script', () => { }); }); + describe('cucumberStepStart', function () { + it('startTestItem should be called with parameters', function () { + const spyStartTestItem = jest.spyOn(reporter.client, 'startTestItem'); + + reporter.currentTestTempInfo = mockCurrentTestTempInfo; + reporter.tempLaunchId = tempLaunchId; + + reporter.cucumberStepStart(mockInputStep); + + expect(spyStartTestItem).toHaveBeenCalledTimes(1); + expect(spyStartTestItem).toHaveBeenCalledWith( + { + name: 'When step-name', + startTime: currentDate, + type: 'step', + codeRef: 'test-code-ref/step-name', + hasStats: false, + }, + tempLaunchId, + tempTestId, + ); + }); + }); + + describe('cucumberStepEnd', function () { + beforeEach(function () { + reporter.currentTestTempInfo = mockCurrentTestTempInfoWithStep; + reporter.cucumberSteps.set(testStepId, mockStep); + }); + + it('end passed step: finishTestItem should be called with parameters', function () { + const spyFinishTestItem = jest.spyOn(reporter.client, 'finishTestItem'); + + reporter.cucumberStepEnd(mockInputStep); + + expect(spyFinishTestItem).toHaveBeenCalledTimes(1); + expect(spyFinishTestItem).toHaveBeenCalledWith(tempStepId, { + status: 'passed', + endTime: currentDate, + }); + }); + + it('end failed step: finishTestItem should be called with failed status', function () { + const spyFinishTestItem = jest.spyOn(reporter.client, 'finishTestItem'); + const errorMessage = 'error-message'; + + reporter.cucumberStepEnd({ + ...mockInputStep, + testStepResult: { status: 'failed', message: errorMessage }, + }); + + expect(spyFinishTestItem).toHaveBeenCalledWith(tempStepId, { + status: 'failed', + endTime: currentDate, + }); + }); + + it('end failed step: should call sendLog on step fail', function () { + const spySendLog = jest.spyOn(reporter, 'sendLog'); + const errorMessage = 'error-message'; + + reporter.cucumberStepEnd({ + ...mockInputStep, + testStepResult: { status: 'failed', message: errorMessage }, + }); + + expect(spySendLog).toHaveBeenCalledTimes(1); + expect(spySendLog).toHaveBeenCalledWith(tempStepId, { + time: currentDate, + level: 'error', + message: errorMessage, + }); + }); + }); + describe('hookStart', function () { beforeEach(function () { reporter.tempLaunchId = 'tempLaunchId'; @@ -491,7 +594,7 @@ describe('reporter script', () => { }; const expectedHookStartObject = { name: 'hook name', - startTime: currentDate, + startTime: currentDate - 1, type: 'BEFORE_METHOD', }; @@ -598,6 +701,7 @@ describe('reporter script', () => { describe('send log', () => { beforeEach(() => { jest.clearAllMocks(); + reporter.currentTestTempInfo = mockCurrentTestTempInfo; }); it('sendLog: client.sendLog should be called with parameters', function () { const spySendLog = jest.spyOn(reporter.client, 'sendLog'); @@ -626,11 +730,14 @@ describe('reporter script', () => { message: 'error message', time: currentDate, }; - reporter.currentTestTempInfo = { tempId: 'tempTestItemId' }; reporter.sendLogToCurrentItem(logObj); - expect(spySendLog).toHaveBeenCalledWith('tempTestItemId', expectedLogObj, undefined); + expect(spySendLog).toHaveBeenCalledWith( + mockCurrentTestTempInfo.tempId, + expectedLogObj, + undefined, + ); }); it('sendLogToCurrentItem: client.sendLog rejected promise should be handled', async function () { const spyConsoleError = jest.spyOn(global.console, 'error').mockImplementation();