Skip to content

Commit

Permalink
feat(cypress): command log steps rework
Browse files Browse the repository at this point in the history
  • Loading branch information
delatrie committed Dec 2, 2024
1 parent 7691e04 commit f034d97
Show file tree
Hide file tree
Showing 23 changed files with 2,542 additions and 1,243 deletions.
179 changes: 179 additions & 0 deletions packages/allure-cypress/src/browser/commandLog.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
import type { Parameter } from "allure-js-commons";
import type { CypressLogEntry, LogStepDescriptor } from "../types.js";
import { isDefined } from "../utils.js";
import { reportStepStart } from "./lifecycle.js";
import serializePropValue from "./serialize.js";
import { getCurrentStep, getStepStack, pushStep, setupStepFinalization } from "./state.js";
import { ALLURE_STEP_CMD_SUBJECT, findAndStopStepWithSubsteps, isLogStep } from "./steps.js";

export const shouldCreateStepFromCommandLogEntry = (entry: CypressLogEntry) => {
const { event, instrument } = entry.attributes;
if (instrument !== "command") {
// We are interested in the "TEST BODY" panel only for now.
// Other instruments are logged in separate panels.
return false;
}

if (event) {
// Events are tricky to report as they may span across commands and even leave the test's scope.
// We ignore them for now.
return false;
}

if (isApiStepErrorLogEntry(entry)) {
// Cypress don't create a log message for 'cy.then' except when it throws an error.
// This is in particularly happens when the function passed to 'allure.step' throws. In such a case however,
// creating an extra step from the log entry is redundant because the error is already included in the report as
// a part of the step.
return false;
}

return true;
};

/**
* Checks if the current step represents a cy.screenshot command log entry. If this is the case, associates the name
* of the screenshot with the step. Later, that will allow converting the step with the attachment into the attachment
* step.
*/
export const setupScreenshotAttachmentStep = (originalName: string | undefined, name: string) => {
const step = getCurrentStep();
if (step && isLogStep(step)) {
const {
name: commandName,
props: { name: nameFromProps },
} = step.log.attributes.consoleProps();
if (commandName === "screenshot" && nameFromProps === originalName) {
step.attachmentName = name;
}
}
};

export const startCommandLogStep = (entry: CypressLogEntry) => {
const currentLogEntry = getCurrentLogEntry();
if (typeof currentLogEntry !== "undefined" && shouldStopCurrentLogStep(currentLogEntry.log, entry)) {
stopCommandLogStep(currentLogEntry.log.attributes.id);
}

pushLogEntry(entry);
reportStepStart(entry.attributes.id, getCommandLogStepName(entry));
scheduleCommandLogStepStop(entry);
};

export const stopCommandLogStep = (entryId: string) => findAndStopStepWithSubsteps(({ id }) => id === entryId);

const pushLogEntry = (entry: CypressLogEntry) => {
const id = entry.attributes.id;
const stepDescriptor: LogStepDescriptor = { id, type: "log", log: entry };
pushStep(stepDescriptor);

// Some properties of some Command Log entries are undefined at the time the entry is stopped. An example is the
// Yielded property of some queries. We defer converting them to Allure step parameters until the test/hook ends.
setupStepFinalization(stepDescriptor, (data) => {
data.parameters = getCommandLogStepParameters(entry);
if (stepDescriptor.attachmentName) {
// Rename the step to match the attachment name. Once the names are the same, Allure will render the
// attachment in the place of the step.
data.name = stepDescriptor.attachmentName;
}
});
};

const scheduleCommandLogStepStop = (entry: CypressLogEntry) => {
const { groupStart, end, id } = entry.attributes;
if (end) {
// Some entries are already completed (this is similar to the idea behind allure.logStep).
// Cypress won't call entry.end() in such a case, so we need to stop such a step now.
// Example: cy.log
stopCommandLogStep(id);
} else if (groupStart) {
// A logging group must be stopped be the user via the Cypress.Log.endGroup() call.
// If the call is missing, the corresponding step will be stopped either at the test's (the hook's) end.
const originalEndGroup = entry.endGroup;
entry.endGroup = function () {
stopCommandLogStep(id);
return originalEndGroup.call(this);
};
} else {
// Regular log entries are finalized by Cypress via the Cypress.Log.end() call. We're hooking into this function
// to complete the step at the same time.
// eslint-disable-next-line @typescript-eslint/unbound-method
const originalEnd = entry.end;
entry.end = function () {
stopCommandLogStep(id);
return originalEnd.call(this);
};
}
};

const isApiStepErrorLogEntry = ({ attributes: { name, consoleProps } }: CypressLogEntry) =>
name === "then" && Object.is(consoleProps().props["Applied To"], ALLURE_STEP_CMD_SUBJECT);

const getCommandLogStepName = (entry: CypressLogEntry) => {
const { name, message, displayName } = entry.attributes;
const resolvedName = (displayName ?? name).trim();
const resolvedMessage = (
maybeGetAssertionLogMessage(entry) ??
maybeGetCucumberLogMessage(entry) ??
entry.attributes.renderProps().message ??
message
).trim();
const stepName = [resolvedName, resolvedMessage].filter(Boolean).join(" ");
return stepName;
};

const getCommandLogStepParameters = (entry: CypressLogEntry) =>
getLogProps(entry)
.map(([k, v]) => ({
name: k.toString(),
value: serializePropValue(v),
}))
.filter(getPropValueSetFilter(entry));

const WELL_KNOWN_CUCUMBER_LOG_NAMES = ["Given", "When", "Then", "And"];

const maybeGetCucumberLogMessage = (entry: CypressLogEntry) => {
const {
attributes: { name, message },
} = entry;
if (WELL_KNOWN_CUCUMBER_LOG_NAMES.includes(name.trim()) && message.startsWith("**") && message.endsWith("**")) {
return message.substring(2, message.length - 2);
}
};

const getLogProps = (entry: CypressLogEntry) => {
const {
attributes: { consoleProps },
} = entry;
const isAssertionWithMessage = !!maybeGetAssertionLogMessage(entry);

// For assertion logs, we interpolate the 'Message' property, which contains unformatted assertion description,
// directly into the step's name.
// No need to keep the exact same information in the step's parameters.
return Object.entries(consoleProps().props).filter(
([k, v]) => isDefined(v) && !(isAssertionWithMessage && k === "Message"),
);
};

const maybeGetAssertionLogMessage = (entry: CypressLogEntry) => {
if (isAssertLog(entry)) {
const message = entry.attributes.consoleProps().props.Message;
if (message && typeof message === "string") {
return message;
}
}
};

const isAssertLog = ({ attributes: { name } }: CypressLogEntry) => name === "assert";

const getCurrentLogEntry = () => getStepStack().findLast(isLogStep);

const shouldStopCurrentLogStep = (currentLogEntry: CypressLogEntry, newLogEntry: CypressLogEntry) => {
const { groupStart: currentEntryIsGroup, type: currentEntryType } = currentLogEntry.attributes;
const { type: newEntryType } = newLogEntry.attributes;

return !currentEntryIsGroup && (currentEntryType === "child" || newEntryType !== "child");
};

const getPropValueSetFilter = (entry: CypressLogEntry) =>
entry.attributes.name === "wrap" ? () => true : ({ name, value }: Parameter) => name !== "Yielded" || value !== "{}";
40 changes: 40 additions & 0 deletions packages/allure-cypress/src/browser/events/cypress.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import type { CypressLogEntry } from "../../types.js";
import {
setupScreenshotAttachmentStep,
shouldCreateStepFromCommandLogEntry,
startCommandLogStep,
} from "../commandLog.js";
import { reportScreenshot } from "../lifecycle.js";
import { reportStepError } from "../steps.js";
import { getFileNameFromPath } from "../utils.js";

export const registerCypressEventListeners = () => Cypress.on("fail", onFail).on("log:added", onLogAdded);

export const enableReportingOfCypressScreenshots = () => Cypress.Screenshot.defaults({ onAfterScreenshot });

const onAfterScreenshot = (
...[, { name: originalName, path }]: Parameters<Cypress.ScreenshotDefaultsOptions["onAfterScreenshot"]>
) => {
const name = originalName ?? getFileNameFromPath(path);
reportScreenshot(path, name);
setupScreenshotAttachmentStep(originalName, name);
};

const onLogAdded = (_: Cypress.ObjectLike, entry: CypressLogEntry) => {
if (shouldCreateStepFromCommandLogEntry(entry)) {
startCommandLogStep(entry);
}
};

const onFail = (error: Cypress.CypressError) => {
reportStepError(error);

// If there are more "fail" handlers yet to run, it's not our responsibility to throw.
// Otherwise, we won't give them any chance to do their job (EventEmitter stops executing handlers as soon
// as one of them throws - that is also true for eventemitter2, which is used by the browser-side of Cypress).
if (noSubsequentFailListeners()) {
throw error;
}
};

const noSubsequentFailListeners = () => Object.is(Cypress.listeners("fail").at(-1), onFail);
12 changes: 12 additions & 0 deletions packages/allure-cypress/src/browser/events/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { enableScopeLevelAfterHookReporting } from "../patching.js";
import { registerCypressEventListeners } from "./cypress.js";
import { injectFlushMessageHooks, registerMochaEventListeners } from "./mocha.js";

export const enableAllure = () => {
registerMochaEventListeners();
registerCypressEventListeners();
injectFlushMessageHooks();
enableScopeLevelAfterHookReporting();
};

export { enableReportingOfCypressScreenshots } from "./cypress.js";
106 changes: 106 additions & 0 deletions packages/allure-cypress/src/browser/events/mocha.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import type { CypressHook, CypressSuite, CypressTest } from "../../types.js";
import {
completeHookErrorReporting,
completeSpecIfNoAfterHookLeft,
flushRuntimeMessages,
reportTestPass as onPass,
reportTestSkip as onPending,
reportSuiteEnd as onSuiteEnd,
reportTestEnd as onTestEnd,
reportHookEnd,
reportHookStart,
reportRunStart,
reportSuiteStart,
reportTestOrHookFail,
reportTestStart,
} from "../lifecycle.js";
import { initTestRuntime } from "../runtime.js";
import { applyTestPlan } from "../testplan.js";
import { isAllureHook, isRootAfterAllHook, isTestReported } from "../utils.js";

export const ALLURE_REPORT_SYSTEM_HOOK = "__allure_report_system_hook__";

export const registerMochaEventListeners = () => {
((Cypress as any).mocha.getRunner() as Mocha.Runner)
.on("start", onStart)
.on("suite", onSuite)
.on("suite end", onSuiteEnd)
.on("hook", onHook)
.on("hook end", onHookEnd)
.on("test", onTest)
.on("pass", onPass)
.on("fail", onFail)
.on("pending", onPending)
.on("test end", onTestEnd);
};

export const injectFlushMessageHooks = () => {
afterEach(ALLURE_REPORT_SYSTEM_HOOK, flushRuntimeMessages);
after(ALLURE_REPORT_SYSTEM_HOOK, onAfterAll);
};

const onStart = () => {
initTestRuntime();
reportRunStart();
};

const onSuite = (suite: CypressSuite) => {
if (suite.root) {
applyTestPlan(Cypress.spec, suite);
}
reportSuiteStart(suite);
};

const onHook = (hook: CypressHook) => {
if (isAllureHook(hook)) {
return;
}

reportHookStart(hook);
};

const onHookEnd = (hook: CypressHook) => {
if (isAllureHook(hook)) {
return;
}

reportHookEnd(hook);
};

const onTest = (test: CypressTest) => {
// Cypress emits an extra EVENT_TEST_BEGIN if the test is skipped.
// reportTestSkip does that already, so we need to filter the extra event out.
if (!isTestReported(test)) {
reportTestStart(test);
}
};

const onFail = (testOrHook: CypressTest | CypressHook, err: Error) => {
const isHook = "hookName" in testOrHook;
if (isHook && isRootAfterAllHook(testOrHook)) {
// Errors in spec-level 'after all' hooks are handled by Allure wrappers.
return;
}

const isAllureHookFailure = isHook && isAllureHook(testOrHook);

if (isAllureHookFailure) {
// Normally, Allure hooks are skipped from the report.
// In case of errors, it will be helpful to see them.
reportHookStart(testOrHook, Date.now() - (testOrHook.duration ?? 0));
}

// This will mark the fixture and the test (if any) as failed/broken.
reportTestOrHookFail(err);

if (isHook) {
// This will end the fixture and test (if any) and will report the remaining
// tests in the hook's suite (the ones that will be skipped by Cypress/Mocha).
completeHookErrorReporting(testOrHook, err);
}
};

const onAfterAll = function (this: Mocha.Context) {
flushRuntimeMessages();
completeSpecIfNoAfterHookLeft(this);
};
13 changes: 13 additions & 0 deletions packages/allure-cypress/src/browser/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { enableAllure, enableReportingOfCypressScreenshots } from "./events/index.js";
import { isAllureInitialized, setAllureInitialized } from "./state.js";

export const initializeAllure = () => {
if (isAllureInitialized()) {
return;
}

setAllureInitialized();

enableAllure();
enableReportingOfCypressScreenshots();
};
Loading

0 comments on commit f034d97

Please sign in to comment.