@frinzekt/cypress-allure-plugin
Version:
allure reporting plugin for cypress modified for video. Originally by @shelex
681 lines (598 loc) • 23.5 kB
JavaScript
/**
* A lot of credit to Sergey Korol <serhii.s.korol@gmail.com> who made
* Allure-mocha reporter: "https://github.com/allure-framework/allure-js/tree/master/packages/allure-mocha"
*/
const { LabelName, Stage, Status, ContentType } = require('allure-js-commons');
const path = require('path');
const { createHash } = require('crypto');
const AllureInterface = require('./AllureInterface');
const { tagToLabel, tagToLink } = require('../gherkinToLabel');
const stubbedAllure = require('../stubbedAllure');
const callbacks = ['then', 'spread', 'each', 'within'];
module.exports = class AllureReporter {
constructor(runtime, options) {
this.suites = [];
this.steps = [];
this.commands = [];
this.files = [];
this.currentChainer = null;
this.runningTest = null;
this.previousTestName = null;
this.runtime = runtime;
this.currentHook = null;
this.parentStep = null;
this.logCypress = options.logCypress || false;
}
/**
* @returns {Allure}
*/
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) ||
previousTestName
);
}
originalNameOf(hook) {
return hook.hookName || hook.originalTitle || hook.title;
}
startSuite(suiteName) {
if (this.currentSuite) {
if (this.currentSuiteIsGlobal) {
/**
* 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
*/
this.currentSuite.testResultContainer.name = suiteName;
return;
} else {
/**
* but if previous suite is not global
* it should be finished, and new one created
*/
this.endSuite(true);
}
}
const scope = this.currentSuite || this.runtime;
const suite = scope.startGroup(suiteName || 'Global');
this.pushSuite(suite);
}
endSuite(isGlobal = false) {
if (this.currentSuite && isGlobal) {
this.cyCommandsFinish('passed');
this.finishAllSteps(Status.PASSED);
this.currentStep !== null && this.currentStep.endStep();
this.currentSuite.endGroup();
this.popSuite();
}
}
startCase(test, clearFilesForPreviousAttempt) {
if (this.currentSuite === null) {
throw new Error('No active suite');
}
/**
* for skipped test firstly comes 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
*/
if (this.currentTest && this.currentTest.info.status === 'skipped') {
return;
}
this.commands = [];
this.currentTest = this.currentSuite.startTest(test.title);
this.currentTest.fullName = test.title;
this.currentTest.historyId = createHash('md5')
.update(test.fullTitle())
.digest('hex');
this.currentTest.stage = Stage.RUNNING;
if (clearFilesForPreviousAttempt) {
const state = cy && cy.state().runnable;
if (state && state.prevAttempts && state.prevAttempts.length > 0) {
// remove screenshots from previous attempt
this.files = this.files.filter(
(file) => file.testName !== test.title
);
}
}
if (test.parent) {
const titlePath = test.parent.titlePath();
// should add suite label for test if it has parent
if (titlePath.length === 1) {
this.currentTest.addLabel(LabelName.SUITE, titlePath.pop());
} else {
const [parentSuite, suite, ...subSuites] = titlePath;
if (parentSuite) {
this.currentTest.addLabel(
LabelName.PARENT_SUITE,
parentSuite
);
}
if (suite) {
this.currentTest.addLabel(LabelName.SUITE, suite);
}
if (subSuites.length > 0) {
this.currentTest.addLabel(
LabelName.SUB_SUITE,
subSuites.join(' > ')
);
}
}
}
}
// accept cucumber tags from cypress-cucumber-preprocessor as commands
handleCucumberTags() {
if (globalThis && globalThis.testState) {
const { testState } = globalThis;
const { currentTest } = this;
/**
* tags set on test level has higher priority
* to not be overwritten by feature tags
*/
['feature', 'currentScenario'].forEach(function (type) {
testState[type] &&
testState[type].tags
// check for labels
.filter(function ({ name }) {
const match = tagToLabel.exec(name);
if (match) {
const [, command, value] = match;
currentTest.addLabel(command, value);
}
return !match;
})
// check for links
.filter(function ({ name }) {
const match = tagToLink.exec(name);
if (match) {
const [, command, name, url] = match;
const urlPrefix = Cypress.env(
command === 'issue'
? 'issuePrefix'
: 'tmsPrefix'
);
currentTest.addLink(
urlPrefix ? `${urlPrefix}${url}` : url,
name,
command
);
}
return !match;
})
// add other tags
.forEach(function ({ name }) {
currentTest.addLabel('tag', name.replace('@', ''));
});
});
currentTest.addLabel('feature', testState.feature.name);
}
}
passTestCase(test) {
if (this.currentTest === null) {
this.startCase(test);
}
this.updateTest(Status.PASSED);
}
pendingTestCase(test) {
this.startCase(test);
this.updateTest(Status.SKIPPED, { message: 'Test ignored' });
}
failTestCase(test, error) {
if (this.currentTest === null) {
this.startCase(test);
} else {
const latestStatus = this.currentTest.status;
// if test already has a failed state, we should not overwrite it
if (latestStatus && latestStatus !== Status.PASSED) {
return;
}
}
this.updateTest(Status.FAILED, {
message: error.message,
trace: error.stack
});
/**
* in case error comes from hook
*/
if (test.type === 'hook') {
this.endHook(test, true);
/**
* in case of before all cypress creates new test
* which not produce any mocha events
* as result should be finished manually
*/
if (test.hookName && test.hookName === 'before all') {
this.endTest();
}
}
}
writeAttachment(content, type) {
return this.runtime.writeAttachment(content, type);
}
pushStep(step) {
this.steps.push(step);
}
popStep() {
return this.steps.pop();
}
finishAllSteps(status = Status.PASSED) {
this.steps.forEach((step) => {
step.info.stage = Stage.FINISHED;
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) {
if (!this.currentSuite) {
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 {
const step = this.currentTest.startStep(hook.title);
this.currentHook = step;
}
}
endHook(hook, failed = false) {
if (!this.currentSuite) {
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();
}
!failed && (this.currentHook = null);
}
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');
}
this.logCypress && this.cyCommandsFinish(status);
this.finishAllSteps(status);
this.parentStep = null;
details && (this.currentTest.statusDetails = details);
this.currentTest.status = status;
this.currentTest.stage = Stage.FINISHED;
this.currentTest.testResult.stop = Date.now();
}
endTest() {
this.currentTest && this.currentTest.endTest();
if (this.currentTest.status === Status.SKIPPED) {
this.currentTest = null;
}
}
cyCommandExecutable(command) {
if (command.parent) {
const parent = this.commands.find(
(c) => c.id === command.parent && c.step && !c.finished
);
if (!parent) {
return this.currentExecutable;
}
// such commands contain argument
// which is basically a function that will be executed
if (callbacks.includes(parent.name)) {
return this.cyCommandExecutable(parent);
}
// in case latest step in newer then parent - attach to user defined step
return this.currentStep &&
this.currentStep.info.start > parent.step.info.start
? this.currentStep
: parent.step;
}
return this.currentExecutable;
}
cyCommandEnqueue(attributes) {
// skipped are:
// assertions, as they don't receive command:start and command:end events and are hardly trackable
// allure custom command used for interacting with allure api
// commands, where second argument has {log: false}
// commands, which are child commands of cy.allure command
const commandShouldBeSkipped = (attributes) => {
return (
attributes.type === 'assertion' ||
attributes.name === 'allure' ||
(Object.getOwnPropertyNames(
stubbedAllure.reporter.getInterface()
).includes(attributes.name) &&
attributes.type === 'child') ||
(attributes.args &&
attributes.args.length &&
attributes.args.find((arg) => arg && arg.log === false))
);
};
if (commandShouldBeSkipped(attributes)) {
return;
}
// prepare chainer command object with specific information to process it with events
const command = {
id: attributes.chainerId,
name: attributes.name,
type: attributes.type,
parent: this.currentChainer && this.currentChainer.chainerId,
children: [],
passed: true, // will be set false in case failed or failed child command
finished: false,
step: null,
commandLog: null
};
this.commands.push(command);
// in case command in enqueued while there was active chainer - treat it as parent
// so this command should be added as child to track if we should finish parent command step
if (command.parent) {
const parent = this.commands.find(
(c) => c.id === command.parent && !c.finished && c.step
);
// set new child from start as command queue works as LIFO (last in - first out) approach
parent && parent.children.unshift(command.id);
}
}
cyCommandStart(attributes) {
// check if we have enqueued command
const command = this.commands.find(
(command) =>
command.id === attributes.chainerId &&
command.name === attributes.name &&
!command.step &&
!command.finished
);
if (!command) {
return;
}
command.commandLog = attributes;
// add dummy allure step implementation for "then" commands to avoid adding them to report
// as they mostly expose other plugin internals and other functions not related to test
// on other hand, if they produce info for command log - step will be created when command end
if (callbacks.includes(command.name)) {
command.step = {
info: {},
stepResult: {},
endStep() {}
};
} else {
const executable = this.cyCommandExecutable(command);
const commandArgs =
attributes.args.length &&
attributes.args.map((a) => `"${String(a)}"`).join('; ');
const step = executable.startStep(
`${command.name}${commandArgs ? ` (${commandArgs})` : ''}`
);
command.step = step;
}
this.currentChainer = attributes;
}
cyCommandEnd(attributes, failed = false) {
// check if we have enqueued command
const command = this.commands.find(
(command) =>
command.id === attributes.chainerId &&
command.name === attributes.name &&
command.step &&
!command.finished
);
if (!command) {
return;
}
this.currentChainer = null;
// in case no children enqueued - finish this step
if (!command.children.length || failed) {
// check if command has some entries for command log
if (command.commandLog.logs.length) {
// set first command log (which refers to current command) as last
// and process other child logs first (asserts are processed such way)
command.commandLog.logs.push(command.commandLog.logs.shift());
command.commandLog.logs.forEach((entry, index) => {
const log = entry.toJSON();
// for main log (which we set last) we should finish command step
if (index === command.commandLog.logs.length - 1) {
// in case "then" command has some logging - create step for that
if (callbacks.includes(command.name)) {
const executable = this.cyCommandExecutable(
command
);
const step = this.cyStartStepForLog(
executable,
log
);
command.step = step;
if (log.name === 'step') {
this.finishAllSteps(
command.passed
? Status.PASSED
: Status.FAILED
);
this.steps.push(step);
this.parentStep = step;
}
}
const commandPassed = this.cyCommandEndStep(
command.step,
log,
command.passed
);
!commandPassed && (command.passed = false);
} else {
// handle case when other logs refer to chained assertions
// so steps should be created
const executable = this.cyCommandExecutable({
id: log.id,
parent: command.parent
});
const step = this.cyStartStepForLog(executable, log);
const commandPassed = this.cyCommandEndStep(step, log);
!commandPassed && (command.passed = false);
}
});
} else {
this.cyCommandEndStep(
command.step,
{
state: command.passed ? 'passed' : 'failed'
},
command.passed
);
}
command.finished = true;
!command.passed && (failed = true);
// notify parent that one of child commands is finished
// and pass status
this.cyRemoveChildFromParent(command, failed);
}
}
cyRemoveChildFromParent(child, failed = false) {
if (child.parent) {
const parent = this.commands.find(
(c) => c.id === child.parent && c.step && !c.finished
);
// better to skip case when no parent found
if (!parent) {
return;
}
const childIndex = parent.children.indexOf(child.id);
// if found child - remove it from parent
if (childIndex > -1) {
parent.children.splice(childIndex, 1);
// update status of parent in case any of children failed
if (!child.passed || failed) {
parent.passed = false;
}
}
// finish parent step when no children left or when test is failed
if (!parent.children.length || failed) {
!parent.passed && (failed = true);
this.cyCommandEnd(parent.commandLog, failed);
}
}
}
cyCommandsFinish(state = 'failed') {
// process all not finished steps from chainer left
// usually is executed on fail
this.commands
.filter((c) => !c.finished && c.step)
.reverse()
.forEach((command) => {
!command.finished &&
this.cyCommandEnd(command.commandLog, state === 'failed');
});
this.currentChainer = null;
}
cyCommandEndStep(step, log, commandStatus) {
let passed =
commandStatus !== undefined
? commandStatus
: log.state !== 'failed';
step.info.stage = Stage.FINISHED;
if (log && log.err) {
passed = false;
}
step.info.status = passed ? Status.PASSED : Status.FAILED;
log.name !== 'step' && step.endStep();
return passed;
}
cyStartStepForLog(executable, log) {
// define step name based on cypress log name or messages
const messages = {
xhr: () =>
`${
(log.consoleProps.Stubbed === 'Yes' ? 'STUBBED ' : '') +
log.consoleProps.Method
} ${log.consoleProps.URL}`,
step: () => `${log.displayName}${log.message.replace(/\*/g, '')}`,
stub: () =>
`${log.name} [ function: ${log.functionName} ] ${
log.alias ? `as ${log.alias}` : ''
}`,
route: () => `${log.name} ${log.method} ${log.url}`,
default: () =>
log.message ? `${log.message} ${log.name}` : `${log.name}`
};
// handle cases with stubs name containing increments (stub-1, stub-2, etc.)
const lookupName = log.name.startsWith('stub') ? 'stub' : log.name;
const message = messages[lookupName] || messages.default;
// in case log name is "step" - assumed that it comes from cucumber preprocessor
// in case it is cucumber step - executable should be current test
if (log.name === 'step') {
executable = this.currentTest;
}
return executable.startStep(message());
}
};