UNPKG

@shelex/cypress-allure-plugin

Version:
568 lines (503 loc) 18.6 kB
const { LabelName, Stage, Status } = require('@shelex/allure-js-commons-browser'); const crypto = require('crypto-js'); const AllureInterface = require('./AllureInterface'); const { languageLabel } = require('../languageLabel'); const logger = require('../debug'); const CypressHandler = require('./CypressHandler'); const { CucumberHandler } = require('./CucumberHandler'); const defineSuites = require('../defineSuites'); module.exports = class AllureReporter { constructor(runtime, options) { this.suites = []; this.steps = []; this.files = []; this.mochaIdToAllure = {}; this.labelStorage = []; this.runningTest = null; this.previousTestName = null; this.runtime = runtime; this.currentHook = null; this.parentStep = null; this.cy = new CypressHandler(this); this.gherkin = new CucumberHandler(this); this.config = options; this.defineSuiteLabelsFn = (titles) => titles; this.defineHistoryId = (testTitle) => testTitle; } /** * @returns {AllureInterface} */ getInterface() { return new AllureInterface(this, this.runtime); } get currentExecutable() { return ( this.currentStep || this.parentStep || this.currentHook || this.currentTest || this.currentSuite ); } get currentSuite() { if (this.suites.length === 0) { return null; } return this.suites[this.suites.length - 1]; } get currentSuiteIsGlobal() { return ( this.currentSuite && this.currentSuite.testResultContainer.name === 'Global' ); } get currentStep() { if (this.steps.length > 0) { return this.steps[this.steps.length - 1]; } return null; } get currentTest() { return this.runningTest; } set currentTest(test) { if (this.runningTest) { this.previousTestName = this.runningTest.info.name; } this.runningTest = test; } get testNameForAttachment() { const cyTest = cy.state().test; return ( (cyTest && cyTest.title) || (this.currentTest && this.currentTest.info.name) || this.previousTestName ); } originalNameOf(hook) { return hook.hookName || hook.originalTitle || hook.title; } /** * Adds package label, so tab 'packages' in report * will be populated with correct folder structure */ addPackageLabel() { const packagePath = Cypress.spec.relative .replace(/\//g, '.') .replace(/\\/g, '.'); this.currentTest.addLabel(LabelName.PACKAGE, packagePath); } startSuite(suite) { const suiteName = suite.fullTitle(); if (this.currentSuite) { if ( this.currentSuiteIsGlobal || (suite.parent && this.currentSuite.testResultContainer && suite.parent.title === this.currentSuite.testResultContainer.name) ) { /** * cypress creates suite for spec file * where global hooks and other nested suites are held * in order to have global hooks available * this global suite can just be renamed * to the first user's suite. * second condition is when nested mocha suites are used * so no need to end parent suite but just reuse */ this.currentSuite.testResultContainer.name = suiteName; return; } else { /** * but if previous suite is not global * it should be finished, and new one created */ logger.allure(`finish previous suite %O`, this.currentSuite); this.endSuite(true); } } const scope = this.currentSuite || this.runtime; const allureSuite = scope.startGroup(suiteName || 'Global'); logger.allure(`start suite %O`, allureSuite); this.pushSuite(allureSuite); } prepareAllureReport(hook, allureLogHooks = false) { logger.allure(`prepareAllureReport from hook %s`, hook.title); if (hook && hook.type === 'hook') { this.endHook(hook, false, true); if (!allureLogHooks) { // remove "after all" hook steps so hook will not be kept in the report this.currentHook.info['steps'] = []; } } this.endSuite(true, true); logger.allure(`end prepareAllureReport from hook %s`, hook.title); } endSuite(isGlobal = false, isAllureReport = false) { if (this.currentSuite && isGlobal) { this.cy.handleRemainingCommands(Status.PASSED); logger.allure(`finished cypress commands`); this.finishRemainingSteps(); logger.allure(`finished steps`); this.currentStep !== null && this.currentStep.endStep(); this.currentTest && this.currentTest.testResult.stage !== Stage.FINISHED && this.endTest(); this.currentSuite.endGroup(); if (!isAllureReport) { this.popSuite(); logger.allure(`finished suite`); this.currentTest = null; } } // restrict label storage to single suite scope // try to reapply labels from storage // as beforeEach\afterEach hooks for skipped tests are not executed // and handle labels provided in afterEach this.runtime.config.writer.tests.forEach((test) => this.labelStorage.forEach((label) => // in case label is missing - it will be applied to test // but not overwrite it applyLabel(test, label, false) ) ); this.labelStorage = []; } startCase(test, config) { logger.allure(`starting case %s %O`, test.title, test); if (this.currentSuite === null) { logger.allure(`no active suite available`); throw new Error('No active suite'); } /** * skipped test sometimes initially receives pending event * where test will be created * and then comes start test event followed by end test * so start for skipped test should be omitted * when current test is already skipped with same name */ if ( this.currentTest && this.currentTest.info.status === 'skipped' && this.currentTest.info.name === test.title ) { logger.allure(`skipped test already exists`); return; } this.cy.chain.clear(); this.currentTest = this.currentSuite.startTest(test.title); logger.allure(`created test: %O`, this.currentTest); const itemWithAttempt = { allureId: this.currentTest.uuid, attempt: test._currentRetry }; if (this.mochaIdToAllure[test.id]) { this.mochaIdToAllure[test.id].push(itemWithAttempt); } else { this.mochaIdToAllure[test.id] = [itemWithAttempt]; } this.currentTest.fullName = test.title; // handle history hash match for tests failed in before all hook if (test.state === Status.FAILED && test.hookName) { logger.allure( `found test failed in hook, fixing title to match history id` ); // "hooktype" hook: hookname for "testname" // grab just "testname" to consider for calculating historyid hash const match = test.title.match(/"(.*?)"/g).pop(); // set title if match does contain test title test.title = match === test.hookName ? test.title : match.replace(/"/g, ''); } this.currentTest.info.historyId = crypto .MD5(this.defineHistoryId(test.title, test.fullTitle())) .toString(crypto.enc.Hex); this.currentTest.info.stage = Stage.RUNNING; this.addPackageLabel(); if ( config && config.clearFilesForPreviousAttempt() && test._currentRetry > 0 ) { logger.allure(`clearing screenshots from previous retries`); // remove screenshots from previous attempt this.files = this.files.filter( (file) => file.testName !== test.title ); } if (config && config.addAnalyticLabels()) { logger.allure(`adding analytic labels`); this.currentTest.addLabel(LabelName.FRAMEWORK, 'Cypress'); const language = languageLabel(test); language && this.currentTest.addLabel(LabelName.LANGUAGE, language); } if (test.parent) { const titlePath = test.parent.titlePath(); // should add suite label for test if it has parent defineSuites( titlePath, Cypress.spec.absolute, this.defineSuiteLabelsFn ).forEach((label) => this.currentTest.addLabel(label.name, label.value) ); } } passTestCase(test) { if (this.currentTest === null) { logger.allure(`not found allure test, created new`); this.startCase(test); } this.updateTest(Status.PASSED); logger.allure(`set passed for test: %s %O`, test.title, test); } pendingTestCase(test) { const alreadyMapped = this.mochaIdToAllure[test.id]; if (alreadyMapped) { logger.allure(`test is already tracked, no need to create one`); } else { this.startCase(test); logger.allure( `created new test and set to pending: %s %O`, test.title, test ); } this.updateTest(Status.SKIPPED, { message: 'Test ignored' }); } failTestCase(test, error) { logger.allure( `received test failed event: %s, %O, %O`, test.title, test, error ); if (this.currentTest === null) { logger.allure(`not found test, created new`); this.startCase(test); } this.updateTest(Status.FAILED, { message: error.message, trace: error.stack }); if (test.type !== 'hook') { return; } logger.allure(`test error origin is hook`); this.endHook(test, true); /** * in case of failed before all cypress creates new test * which not produce any mocha events * as result should be finished manually * as well as in case tests are skipped * when beforeEach failed */ if ( !test.hookName || !['before all', 'before each'].includes(test.hookName) ) { return; } logger.allure( `finishing test as no events received for failed test in before all/each hook` ); if (!test.parent && !test.parent.tests) { return this.endTest(); } const registeredIds = Object.keys(this.mochaIdToAllure); const finishedTestIds = registeredIds.slice(0, -1); // handle remaining tests due to before all hook failure test.parent.tests // if mocha test id is not mapped to allure - means it is not yet finished .filter((test) => !finishedTestIds.includes(test.id)) .forEach((test, index) => { logger.allure( `found cancelled test due to before all hook: %O`, test ); // update current test to be failed due to beforeEach hook if (index === 0) { this.currentTest.info.name = test.title; this.updateTest(Status.BROKEN, { message: error.message, trace: error.stack }); } // create allure tests for remaining cases and mark as skipped else { this.startCase(test); this.updateTest(Status.SKIPPED); } this.endTest(); }); } writeAttachment(content, type) { return this.runtime.writeAttachment(content, type); } pushStep(step) { this.steps.push(step); } popStep() { return this.steps.pop(); } finishRemainingSteps(status = Status.PASSED) { const alreadyHasFailedStep = this.currentTest && this.currentTest.info && this.currentTest.info.steps.some( (step) => step.status === Status.FAILED ); this.steps.forEach((step) => { step.info.stage = Stage.FINISHED; if (!alreadyHasFailedStep) { if (step.info.steps.length) { step.info.status = step.info.steps.some( (step) => step.status === Status.FAILED ) ? Status.FAILED : Status.PASSED; } else { step.info.status = status; } } step.endStep(); }); this.steps = []; if (this.parentStep) { this.parentStep.info.stage = Stage.FINISHED; this.parentStep.info.status = status; this.parentStep.endStep(); } } startHook(hook) { logger.allure(`starting hook %s`, hook.title); if (!this.currentSuite || isEmpty(hook)) { logger.allure(`no suite or hook is empty function`); return; } /** * When hook is global - it is attached to suite * and will be displayed as precondition * `each` hooks will be available as test steps */ if (this.originalNameOf(hook).includes('all')) { const parent = this.currentSuite; const allureHook = this.originalNameOf(hook).includes('before') ? parent.addBefore() : parent.addAfter(); this.currentHook = allureHook; } else { if (!this.config.shouldLogCypress()) { return; } const customHookName = hook.title.replace( /"(before|after) each" hook:? */g, '' ); const step = this.currentTest.startStep( customHookName || hook.title ); this.currentHook = step; } } endHook(hook, failed = false, isAllureReport = false) { logger.allure(`finishing hook %s`, hook.title); if (!this.currentSuite || !this.currentHook || isEmpty(hook)) { logger.allure(`no suite or no hook or hook is empty function`); return; } // should define results property for all or each hook const currentHookInfo = this.originalNameOf(hook).includes('all') ? this.currentHook.info : this.currentHook.stepResult; if (hook.err) { currentHookInfo.status = Status.FAILED; currentHookInfo.stage = Stage.FINISHED; currentHookInfo.statusDetails = { message: hook.err.message, trace: hook.err.stack }; } else { currentHookInfo.status = Status.PASSED; currentHookInfo.stage = Stage.FINISHED; } // in case hook is a step we should complete it if (this.originalNameOf(hook).includes('each')) { this.currentHook && this.currentHook.endStep(); } !isAllureReport && !failed && (this.currentHook = null); this.finishRemainingSteps(currentHookInfo.status); } pushSuite(suite) { this.suites.push(suite); } popSuite() { this.suites.pop(); } updateTest(status, details) { if (this.currentTest === null) { throw new Error('finishing test while no test is running'); } // in case labels were defined outside of test // we could attach them from storage if (this.currentTest) { this.labelStorage.forEach((label) => applyLabel(this.currentTest.info, label) ); } (this.config.shouldLogCypress() || this.config.shouldLogGherkinSteps()) && this.cy.handleRemainingCommands(status); this.finishRemainingSteps(status); this.parentStep = null; logger.allure( `updating test %O to have status:%s and details: %O`, this.currentTest, status, details ); this.currentTest.statusDetails = details || null; this.currentTest.status = status; this.currentTest.stage = Stage.FINISHED; this.currentTest.testResult.stop = Date.now(); } endTest(test) { logger.allure(`finishing current test`); if (!this.currentTest) { return; } // update test, which may had a pending event previously if ( test && test.state && [Status.FAILED, Status.PASSED, Status.SKIPPED].includes(test.state) ) { this.updateTest( test.state, test.err && { message: test.err.message, trace: test.err.stack } ); } this.currentTest.endTest(); } loggingCommandStepsEnabled(enabled) { logger.allure(`setting step logging to: %s`, `${enabled}`); this.config.loggingCommandStepsEnabled = enabled; } }; const isEmpty = (hook) => hook && hook.body === 'function () {}'; const applyLabel = (test, label, shouldOverride = true) => { const indexOfExisting = test.labels.findIndex( (existingLabel) => existingLabel.name === label.name ); indexOfExisting === -1 ? test.labels.push(label) : shouldOverride && (test.labels[indexOfExisting].value = label.value); };