From 7691e04996beb0a6f29f13787f8133b3797b8ca5 Mon Sep 17 00:00:00 2001 From: Maksim Stepanov <17935127+delatrie@users.noreply.github.com> Date: Mon, 2 Dec 2024 18:36:48 +0700 Subject: [PATCH 1/6] feat(commons): allow user-defined replacer for serialize --- packages/allure-js-commons/src/sdk/types.ts | 3 +++ packages/allure-js-commons/src/sdk/utils.ts | 18 ++++++++++++------ .../allure-js-commons/test/sdk/utils.spec.ts | 17 +++++++++++++++++ 3 files changed, 32 insertions(+), 6 deletions(-) diff --git a/packages/allure-js-commons/src/sdk/types.ts b/packages/allure-js-commons/src/sdk/types.ts index d5d8521ee..42c5e3ea2 100644 --- a/packages/allure-js-commons/src/sdk/types.ts +++ b/packages/allure-js-commons/src/sdk/types.ts @@ -111,7 +111,10 @@ export interface AllureResults { categories?: Category[]; } +export type SerializerReplacerFunc = (this: unknown, key: string, value: unknown) => unknown; + export type SerializeOptions = { maxDepth?: number; maxLength?: number; + replacer?: SerializerReplacerFunc; }; diff --git a/packages/allure-js-commons/src/sdk/utils.ts b/packages/allure-js-commons/src/sdk/utils.ts index 152f96f81..e9783f4bd 100644 --- a/packages/allure-js-commons/src/sdk/utils.ts +++ b/packages/allure-js-commons/src/sdk/utils.ts @@ -1,6 +1,6 @@ import type { FixtureResult, Label, StatusDetails, StepResult, TestResult } from "../model.js"; import { LabelName, Status } from "../model.js"; -import type { RuntimeMessage, SerializeOptions } from "./types.js"; +import type { RuntimeMessage, SerializeOptions, SerializerReplacerFunc } from "./types.js"; export const getStatusFromError = (error: Partial): Status => { switch (true) { @@ -144,15 +144,15 @@ export const getUnfinishedStepsMessages = (messages: RuntimeMessage[]) => { export const isPromise = (obj: any): obj is PromiseLike => !!obj && (typeof obj === "object" || typeof obj === "function") && typeof obj.then === "function"; -export const serialize = (value: any, { maxDepth = 0, maxLength = 0 }: SerializeOptions = {}): string => +export const serialize = (value: any, { maxDepth = 0, maxLength = 0, replacer }: SerializeOptions = {}): string => limitString( - typeof value === "object" ? JSON.stringify(value, createSerializeReplacer(maxDepth)) : String(value), + typeof value === "object" ? JSON.stringify(value, createSerializeReplacer(maxDepth, replacer)) : String(value), maxLength, ); -const createSerializeReplacer = (maxDepth: number) => { - const parents: any[] = []; - return function (this: any, _: string, value: unknown) { +const createSerializeReplacer = (maxDepth: number, userDefinedReplacer: SerializeOptions["replacer"]) => { + const parents: unknown[] = []; + const limitingReplacer = function (this: unknown, _: string, value: unknown) { if (typeof value !== "object" || value === null) { return value; } @@ -173,8 +173,14 @@ const createSerializeReplacer = (maxDepth: number) => { ? excludeCircularRefsFromSet(parents, value) : value; }; + return userDefinedReplacer ? composeReplacers(userDefinedReplacer, limitingReplacer) : limitingReplacer; }; +const composeReplacers = (first: SerializerReplacerFunc, second: SerializerReplacerFunc): SerializerReplacerFunc => + function (k, v) { + return second.call(this, k, first.call(this, k, v)); + }; + const excludeCircularRefsFromMap = (parents: any[], map: Map) => { return Array.from(map) .filter(([k]) => !parents.includes(k)) diff --git a/packages/allure-js-commons/test/sdk/utils.spec.ts b/packages/allure-js-commons/test/sdk/utils.spec.ts index 2a840c436..ba49ac5c5 100644 --- a/packages/allure-js-commons/test/sdk/utils.spec.ts +++ b/packages/allure-js-commons/test/sdk/utils.spec.ts @@ -487,4 +487,21 @@ describe("serialize", () => { }); }); }); + + describe("with a user-defined replacer", () => { + it("should call the user-defined replacer before its own", () => { + const calls: [any, string, any][] = []; + const replacer = function (this: any, k: string, v: any) { + calls.push([this, k, v]); + return k ? 1 : v; + }; + + const obj = { foo: "bar" }; + expect(serialize(obj, { replacer })).toBe(String.raw`{"foo":1}`); + expect(calls).toStrictEqual([ + [{ "": obj }, "", obj], + [obj, "foo", "bar"], + ]); + }); + }); }); From f034d978e6d6ddad78288ed8585d918d3444d872 Mon Sep 17 00:00:00 2001 From: Maksim Stepanov <17935127+delatrie@users.noreply.github.com> Date: Tue, 3 Dec 2024 05:15:17 +0700 Subject: [PATCH 2/6] feat(cypress): command log steps rework --- .../allure-cypress/src/browser/commandLog.ts | 179 +++ .../src/browser/events/cypress.ts | 40 + .../src/browser/events/index.ts | 12 + .../src/browser/events/mocha.ts | 106 ++ packages/allure-cypress/src/browser/index.ts | 13 + .../allure-cypress/src/browser/lifecycle.ts | 252 ++++ .../allure-cypress/src/browser/patching.ts | 130 ++ .../allure-cypress/src/browser/runtime.ts | 206 ++++ .../allure-cypress/src/browser/serialize.ts | 36 + .../allure-cypress/src/{ => browser}/state.ts | 33 +- packages/allure-cypress/src/browser/steps.ts | 156 +++ .../allure-cypress/src/browser/testplan.ts | 57 + packages/allure-cypress/src/browser/utils.ts | 140 +++ packages/allure-cypress/src/index.ts | 157 +-- packages/allure-cypress/src/reporter.ts | 84 +- packages/allure-cypress/src/runtime.ts | 693 ----------- .../allure-cypress/src/{model.ts => types.ts} | 90 +- packages/allure-cypress/src/utils.ts | 177 +-- .../allure-cypress/test/spec/commands.test.ts | 1088 ++++++++++++++--- .../test/spec/runtime/legacy/steps.test.ts | 6 +- .../test/spec/runtime/modern/steps.test.ts | 6 +- .../test/spec/screenshots.test.ts | 119 +- packages/allure-cypress/test/utils.ts | 5 +- 23 files changed, 2542 insertions(+), 1243 deletions(-) create mode 100644 packages/allure-cypress/src/browser/commandLog.ts create mode 100644 packages/allure-cypress/src/browser/events/cypress.ts create mode 100644 packages/allure-cypress/src/browser/events/index.ts create mode 100644 packages/allure-cypress/src/browser/events/mocha.ts create mode 100644 packages/allure-cypress/src/browser/index.ts create mode 100644 packages/allure-cypress/src/browser/lifecycle.ts create mode 100644 packages/allure-cypress/src/browser/patching.ts create mode 100644 packages/allure-cypress/src/browser/runtime.ts create mode 100644 packages/allure-cypress/src/browser/serialize.ts rename packages/allure-cypress/src/{ => browser}/state.ts (53%) create mode 100644 packages/allure-cypress/src/browser/steps.ts create mode 100644 packages/allure-cypress/src/browser/testplan.ts create mode 100644 packages/allure-cypress/src/browser/utils.ts delete mode 100644 packages/allure-cypress/src/runtime.ts rename packages/allure-cypress/src/{model.ts => types.ts} (67%) diff --git a/packages/allure-cypress/src/browser/commandLog.ts b/packages/allure-cypress/src/browser/commandLog.ts new file mode 100644 index 000000000..e27d5fa30 --- /dev/null +++ b/packages/allure-cypress/src/browser/commandLog.ts @@ -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 !== "{}"; diff --git a/packages/allure-cypress/src/browser/events/cypress.ts b/packages/allure-cypress/src/browser/events/cypress.ts new file mode 100644 index 000000000..fd0514d00 --- /dev/null +++ b/packages/allure-cypress/src/browser/events/cypress.ts @@ -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 +) => { + 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); diff --git a/packages/allure-cypress/src/browser/events/index.ts b/packages/allure-cypress/src/browser/events/index.ts new file mode 100644 index 000000000..94d6340d6 --- /dev/null +++ b/packages/allure-cypress/src/browser/events/index.ts @@ -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"; diff --git a/packages/allure-cypress/src/browser/events/mocha.ts b/packages/allure-cypress/src/browser/events/mocha.ts new file mode 100644 index 000000000..9c8b37dea --- /dev/null +++ b/packages/allure-cypress/src/browser/events/mocha.ts @@ -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); +}; diff --git a/packages/allure-cypress/src/browser/index.ts b/packages/allure-cypress/src/browser/index.ts new file mode 100644 index 000000000..7dd3e855e --- /dev/null +++ b/packages/allure-cypress/src/browser/index.ts @@ -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(); +}; diff --git a/packages/allure-cypress/src/browser/lifecycle.ts b/packages/allure-cypress/src/browser/lifecycle.ts new file mode 100644 index 000000000..37b6bce36 --- /dev/null +++ b/packages/allure-cypress/src/browser/lifecycle.ts @@ -0,0 +1,252 @@ +import { ContentType, Status } from "allure-js-commons"; +import type { StatusDetails } from "allure-js-commons"; +import { getMessageAndTraceFromError, getStatusFromError } from "allure-js-commons/sdk"; +import type { CypressFailMessage, CypressHook, CypressSuite, CypressTest, StepDescriptor } from "../types.js"; +import { getTestRuntime } from "./runtime.js"; +import { dropCurrentTest, enqueueRuntimeMessage, getCurrentTest, setCurrentTest } from "./state.js"; +import { finalizeSteps, stopAllSteps } from "./steps.js"; +import { + getStatusDataOfTestSkippedByHookError, + getStepStopData, + getSuitePath, + getTestSkipData, + getTestStartData, + getTestStopData, + isAllureHook, + isLastRootAfterHook, + isTestReported, + iterateTests, + markTestAsReported, +} from "./utils.js"; + +export const reportRunStart = () => { + enqueueRuntimeMessage({ + type: "cypress_run_start", + data: {}, + }); +}; + +export const reportSuiteStart = (suite: CypressSuite) => { + enqueueRuntimeMessage({ + type: "cypress_suite_start", + data: { + id: suite.id, + name: suite.title, + root: suite.root, + start: Date.now(), + }, + }); +}; + +export const reportSuiteEnd = (suite: CypressSuite) => { + enqueueRuntimeMessage({ + type: "cypress_suite_end", + data: { + root: suite.root, + stop: Date.now(), + }, + }); +}; + +export const reportHookStart = (hook: CypressHook, start?: number) => { + enqueueRuntimeMessage({ + type: "cypress_hook_start", + data: { + name: hook.title, + scopeType: hook.hookName.includes("each") ? "each" : "all", + position: hook.hookName.includes("before") ? "before" : "after", + start: start ?? Date.now(), + }, + }); +}; + +export const reportHookEnd = (hook: CypressHook) => { + finalizeSteps(); + enqueueRuntimeMessage({ + type: "cypress_hook_end", + data: { + duration: hook.duration ?? 0, + }, + }); +}; + +export const reportTestStart = (test: CypressTest) => { + setCurrentTest(test); + enqueueRuntimeMessage({ + type: "cypress_test_start", + data: getTestStartData(test), + }); + markTestAsReported(test); +}; + +export const reportStepStart = (id: string, name: string) => { + enqueueRuntimeMessage({ + type: "cypress_step_start", + data: { + id, + name, + start: Date.now(), + }, + }); +}; + +export const reportStepStop = (step: StepDescriptor, status?: Status, statusDetails?: StatusDetails) => { + enqueueRuntimeMessage({ + type: "cypress_step_stop", + data: getStepStopData(step, status, statusDetails), + }); +}; + +export const reportTestPass = () => { + enqueueRuntimeMessage({ + type: "cypress_test_pass", + data: {}, + }); +}; + +export const reportTestOrHookFail = (err: Error) => { + const status = getStatusFromError(err); + const statusDetails = getMessageAndTraceFromError(err); + + stopAllSteps(status, statusDetails); + + enqueueRuntimeMessage({ + type: "cypress_fail", + data: { + status, + statusDetails, + }, + }); +}; + +export const completeHookErrorReporting = (hook: CypressHook, err: Error) => { + const isEachHook = hook.hookName.includes("each"); + const suite = hook.parent!; + const testFailData = getStatusDataOfTestSkippedByHookError(hook.title, isEachHook, err, suite); + + // Cypress doens't emit 'hook end' if the hook has failed. + reportHookEnd(hook); + + // Cypress doens't emit 'test end' if the hook has failed. + // We must report the test's end manualy in case of a 'before each' hook. + reportCurrentTestIfAny(); + + // Cypress skips the remaining tests in the suite of a failed hook. + // We should include them to the report manually. + reportRemainingTests(suite, testFailData); +}; + +export const reportTestSkip = (test: CypressTest) => { + if (isTestReported(test)) { + stopAllSteps(Status.SKIPPED, { + message: "The test was skipped before the command was completed", + }); + } else { + reportTestStart(test); + } + + enqueueRuntimeMessage({ + type: "cypress_test_skip", + data: getTestSkipData(), + }); +}; + +export const reportTestEnd = (test: CypressTest) => { + finalizeSteps(); + enqueueRuntimeMessage({ + type: "cypress_test_end", + data: { + duration: test.duration ?? 0, + retries: (test as any)._retries ?? 0, + }, + }); + dropCurrentTest(); +}; + +export const reportScreenshot = (path: string, name: string) => { + enqueueRuntimeMessage({ + type: "attachment_path", + data: { path, name, contentType: ContentType.PNG }, + }); +}; + +export const completeSpecIfNoAfterHookLeft = (context: Mocha.Context) => { + if (isLastRootAfterHook(context)) { + const hook = context.test as CypressHook; + if (!isAllureHook(hook)) { + reportHookEnd(hook); + } + return completeSpecAsync(); + } +}; + +export const completeSpecOnAfterHookFailure = ( + context: Mocha.Context, + hookError: Error, +): Cypress.Chainable | undefined => { + try { + reportTestOrHookFail(hookError); + completeHookErrorReporting(context.test as CypressHook, hookError); + + // cy.task's then doesn't have onrejected, that's why we don't log async Allure errors here. + return completeSpecAsync(); + } catch (allureError) { + logAllureRootAfterError(context, allureError); + } +}; + +export const throwAfterSpecCompletion = (context: Mocha.Context, err: any) => { + const chain = completeSpecOnAfterHookFailure(context, err as Error)?.then(() => { + throw err; + }); + if (!chain) { + throw err; + } +}; + +export const flushRuntimeMessages = () => getTestRuntime().flushAllureMessagesToTask("reportAllureCypressSpecMessages"); + +export const completeSpecAsync = () => + getTestRuntime().flushAllureMessagesToTaskAsync("reportFinalAllureCypressSpecMessages"); + +const reportCurrentTestIfAny = () => { + const currentTest = getCurrentTest(); + if (currentTest) { + reportTestEnd(currentTest); + } +}; + +const reportRemainingTests = (suite: CypressSuite, testFailData: CypressFailMessage["data"]) => { + for (const test of iterateTests(suite)) { + // Some tests in the suite might've been already reported. + if (!isTestReported(test)) { + reportTestsSkippedByHookError( + test, + test.pending ? { ...getTestSkipData(), status: Status.SKIPPED } : testFailData, + ); + } + } +}; + +const reportTestsSkippedByHookError = (test: CypressTest, testFailData: CypressFailMessage["data"]) => { + enqueueRuntimeMessage({ + type: "cypress_skipped_test", + data: { + ...getTestStartData(test), + ...testFailData, + ...getTestStopData(test), + suites: getSuitePath(test).map((s) => s.id), + }, + }); + markTestAsReported(test); +}; + +const logAllureRootAfterError = (context: Mocha.Context, err: unknown) => { + // We play safe and swallow errors here to keep the original 'after all' error. + try { + // eslint-disable-next-line no-console + console.error(`Unexpected error when reporting the failure of ${context.test?.title ?? "'after all'"}`); + // eslint-disable-next-line no-console + console.error(err); + } catch {} +}; diff --git a/packages/allure-cypress/src/browser/patching.ts b/packages/allure-cypress/src/browser/patching.ts new file mode 100644 index 000000000..6bbc7bc93 --- /dev/null +++ b/packages/allure-cypress/src/browser/patching.ts @@ -0,0 +1,130 @@ +import { isPromise } from "allure-js-commons/sdk"; +import type { CypressSuiteFunction, DirectHookImplementation, HookImplementation } from "../types.js"; +import { + completeSpecIfNoAfterHookLeft, + completeSpecOnAfterHookFailure, + throwAfterSpecCompletion, +} from "./lifecycle.js"; + +/** + * Patches the `after` function, to inject reporting of spec-level + * `after` hooks defined by the user. + */ +export const enableScopeLevelAfterHookReporting = () => { + const [getSuiteDepth, incSuiteDepth, decSuiteDepth] = createSuiteDepthCounterState(); + patchDescribe(incSuiteDepth, decSuiteDepth); + patchAfter(getSuiteDepth); +}; + +const createSuiteDepthCounterState = (): [get: () => number, inc: () => void, dec: () => void] => { + let suiteDepth = 0; + return [ + () => suiteDepth, + () => { + suiteDepth++; + }, + () => { + suiteDepth--; + }, + ]; +}; + +const patchDescribe = (incSuiteDepth: () => void, decSuiteDepth: () => void) => { + const patchDescribeFn = + (target: CypressSuiteFunction): CypressSuiteFunction => + (title, configOrFn, fn) => { + incSuiteDepth(); + try { + return forwardDescribeCall(target, title, configOrFn, fn); + } finally { + decSuiteDepth(); + } + }; + const originalDescribeFn: Mocha.SuiteFunction = globalThis.describe; + const patchedDescribe = patchDescribeFn(originalDescribeFn) as Mocha.SuiteFunction; + patchedDescribe.only = patchDescribeFn( + originalDescribeFn.only as CypressSuiteFunction, + ) as Mocha.ExclusiveSuiteFunction; + patchedDescribe.skip = patchDescribeFn(originalDescribeFn.skip as CypressSuiteFunction) as Mocha.PendingSuiteFunction; + globalThis.describe = patchedDescribe; +}; + +const patchAfter = (getSuiteDepth: () => number) => { + const originalAfter = globalThis.after; + const patchedAfter = (nameOrFn: string | HookImplementation, fn?: HookImplementation): void => { + return typeof nameOrFn === "string" + ? originalAfter(nameOrFn, wrapRootAfterFn(getSuiteDepth, fn)) + : originalAfter(wrapRootAfterFn(getSuiteDepth, nameOrFn)!); + }; + globalThis.after = patchedAfter; +}; + +const forwardDescribeCall = (target: CypressSuiteFunction, ...args: Parameters) => { + const [title, configOrFn, fn] = args; + if (typeof fn === "undefined" && typeof configOrFn === "undefined") { + return target(title); + } else if (typeof configOrFn === "function") { + return target(title, configOrFn); + } else { + return target(title, configOrFn, fn); + } +}; + +const wrapRootAfterFn = (getSuiteDepth: () => number, fn?: HookImplementation): HookImplementation | undefined => { + if (getSuiteDepth() === 0 && fn) { + const wrappedFn = fn.length ? wrapAfterFnWithCallback(fn) : wrapAfterFnWithoutArgs(fn as DirectHookImplementation); + Object.defineProperty(wrappedFn, "name", { value: fn.name }); + return wrappedFn; + } + return fn; +}; + +const wrapAfterFnWithCallback = (fn: Mocha.Func): Mocha.Func => { + return function (this: Mocha.Context, done: Mocha.Done) { + const wrappedDone = (hookError?: Error) => { + if (hookError) { + if (!completeSpecOnAfterHookFailure(this, hookError)?.then(() => done(hookError))) { + done(hookError); + } + return; + } + + try { + if (completeSpecIfNoAfterHookLeft(this)?.then(() => done())) { + return; + } + } catch (allureError) { + done(allureError); + return; + } + + done(); + }; + return fn.bind(this)(wrappedDone); + }; +}; + +const wrapAfterFnWithoutArgs = (fn: DirectHookImplementation) => { + return function (this: Mocha.Context) { + let result; + let syncError: any; + + try { + result = fn.bind(this)(); + } catch (e) { + syncError = e; + } + + if (syncError) { + throwAfterSpecCompletion(this, syncError); + } else if (isPromise(result)) { + return result.then( + () => completeSpecIfNoAfterHookLeft(this), + (asyncError) => throwAfterSpecCompletion(this, asyncError), + ); + } else { + completeSpecIfNoAfterHookLeft(this); + return result; + } + }; +}; diff --git a/packages/allure-cypress/src/browser/runtime.ts b/packages/allure-cypress/src/browser/runtime.ts new file mode 100644 index 000000000..e4127ffc6 --- /dev/null +++ b/packages/allure-cypress/src/browser/runtime.ts @@ -0,0 +1,206 @@ +import { Status } from "allure-js-commons"; +import type { AttachmentOptions, Label, Link, ParameterMode, ParameterOptions } from "allure-js-commons"; +import { getMessageAndTraceFromError } from "allure-js-commons/sdk"; +import { getGlobalTestRuntime, setGlobalTestRuntime } from "allure-js-commons/sdk/runtime"; +import type { TestRuntime } from "allure-js-commons/sdk/runtime"; +import type { AllureCypressTaskArgs, CypressMessage } from "../types.js"; +import { enqueueRuntimeMessage, getRuntimeMessages, setRuntimeMessages } from "./state.js"; +import { ALLURE_STEP_CMD_SUBJECT, startAllureApiStep, stopCurrentAllureApiStep } from "./steps.js"; +import { uint8ArrayToBase64 } from "./utils.js"; + +export const initTestRuntime = () => setGlobalTestRuntime(new AllureCypressTestRuntime() as TestRuntime); + +export const getTestRuntime = () => getGlobalTestRuntime() as AllureCypressTestRuntime; + +class AllureCypressTestRuntime implements TestRuntime { + constructor() { + this.#resetMessages(); + } + + labels(...labels: Label[]) { + return this.#enqueueMessageAsync({ + type: "metadata", + data: { + labels, + }, + }); + } + + links(...links: Link[]) { + return this.#enqueueMessageAsync({ + type: "metadata", + data: { + links, + }, + }); + } + + parameter(name: string, value: string, options?: ParameterOptions) { + return this.#enqueueMessageAsync({ + type: "metadata", + data: { + parameters: [ + { + name, + value, + ...options, + }, + ], + }, + }); + } + + description(markdown: string) { + return this.#enqueueMessageAsync({ + type: "metadata", + data: { + description: markdown, + }, + }); + } + + descriptionHtml(html: string) { + return this.#enqueueMessageAsync({ + type: "metadata", + data: { + descriptionHtml: html, + }, + }); + } + + displayName(name: string) { + return this.#enqueueMessageAsync({ + type: "metadata", + data: { + displayName: name, + }, + }); + } + + historyId(value: string) { + return this.#enqueueMessageAsync({ + type: "metadata", + data: { + historyId: value, + }, + }); + } + + testCaseId(value: string) { + return this.#enqueueMessageAsync({ + type: "metadata", + data: { + testCaseId: value, + }, + }); + } + + // @ts-ignore + attachment(name: string, content: string, options: AttachmentOptions) { + // @ts-ignore + const attachmentRawContent: string | Uint8Array = content?.type === "Buffer" ? content.data : content; + const actualEncoding = typeof attachmentRawContent === "string" ? "utf8" : "base64"; + const attachmentContent = uint8ArrayToBase64(attachmentRawContent); + + return this.#enqueueMessageAsync({ + type: "attachment_content", + data: { + name, + content: attachmentContent, + encoding: actualEncoding, + contentType: options.contentType, + fileExtension: options.fileExtension, + }, + }); + } + + attachmentFromPath(name: string, path: string, options: Omit) { + return this.#enqueueMessageAsync({ + type: "attachment_path", + data: { + name, + path, + contentType: options.contentType, + fileExtension: options.fileExtension, + }, + }); + } + + logStep(name: string, status: Status = Status.PASSED, error?: Error) { + return cy + .wrap(ALLURE_STEP_CMD_SUBJECT, { log: false }) + .then(() => { + startAllureApiStep(name); + return Cypress.Promise.resolve(); + }) + .then(() => { + stopCurrentAllureApiStep(status, error ? getMessageAndTraceFromError(error) : undefined); + return Cypress.Promise.resolve(); + }); + } + + step(name: string, body: () => T | PromiseLike) { + return cy + .wrap(ALLURE_STEP_CMD_SUBJECT, { log: false }) + .then(() => { + startAllureApiStep(name); + return Cypress.Promise.resolve(body()); + }) + .then((result) => { + stopCurrentAllureApiStep(); + return result; + }); + } + + stepDisplayName(name: string) { + return this.#enqueueMessageAsync({ + type: "step_metadata", + data: { + name, + }, + }); + } + + stepParameter(name: string, value: string, mode?: ParameterMode) { + return this.#enqueueMessageAsync({ + type: "step_metadata", + data: { + parameters: [{ name, value, mode }], + }, + }); + } + + flushAllureMessagesToTask(taskName: string) { + const messages = this.#dequeueAllMessages(); + if (messages.length) { + cy.task(taskName, { absolutePath: Cypress.spec.absolute, messages }, { log: false }); + } + } + + flushAllureMessagesToTaskAsync(taskName: string): Cypress.Chainable | undefined { + const messages = this.#dequeueAllMessages(); + if (messages.length) { + const args: AllureCypressTaskArgs = { + absolutePath: Cypress.spec.absolute, + messages, + isInteractive: Cypress.config("isInteractive"), + }; + return cy.task(taskName, args, { log: false }); + } + } + + #resetMessages() { + setRuntimeMessages([]); + } + + #enqueueMessageAsync(message: CypressMessage): PromiseLike { + enqueueRuntimeMessage(message); + return Cypress.Promise.resolve(); + } + + #dequeueAllMessages() { + const messages = getRuntimeMessages(); + this.#resetMessages(); + return messages; + } +} diff --git a/packages/allure-cypress/src/browser/serialize.ts b/packages/allure-cypress/src/browser/serialize.ts new file mode 100644 index 000000000..c5a795ab6 --- /dev/null +++ b/packages/allure-cypress/src/browser/serialize.ts @@ -0,0 +1,36 @@ +import { serialize } from "allure-js-commons/sdk"; +import { getConfig } from "./state.js"; + +export default (value: unknown) => + isDomObject(value) ? stringifyAsDom(value) : serialize(value, getSerializeOptions()); + +const getSerializeOptions = () => { + const { + stepsFromCommands: { maxArgumentDepth: maxDepth, maxArgumentLength: maxLength }, + } = getConfig(); + + return { maxDepth, maxLength, nonNullReplacerWithDomSupport }; +}; + +const isDomObject = (value: unknown): value is object => + typeof value === "object" && value !== null && Cypress.dom.isDom(value); + +// @ts-ignore +const stringifyAsDom = (value: object) => Cypress.dom.stringify(value, "long"); + +const nonNullReplacerWithDomSupport = (_: string, value: unknown) => { + if (typeof value === "object") { + // Exclude null properties to make the result more compact. + if (value === null) { + return undefined; + } + + if (Cypress.dom.isDom(value)) { + // Window, document, and DOM element properties are serialized with Cypress.dom.stringify. + return stringifyAsDom(value); + } + } + + // Use the implementation from allure-js-commons/sdk in all other cases. + return value; +}; diff --git a/packages/allure-cypress/src/state.ts b/packages/allure-cypress/src/browser/state.ts similarity index 53% rename from packages/allure-cypress/src/state.ts rename to packages/allure-cypress/src/browser/state.ts index 3cb1e2db1..fb6a2154d 100644 --- a/packages/allure-cypress/src/state.ts +++ b/packages/allure-cypress/src/browser/state.ts @@ -1,5 +1,5 @@ -import type { AllureSpecState, CypressMessage, CypressTest } from "./model.js"; -import { DEFAULT_RUNTIME_CONFIG } from "./utils.js"; +import type { AllureSpecState, CypressMessage, CypressTest, StepDescriptor, StepFinalizer } from "../types.js"; +import { DEFAULT_RUNTIME_CONFIG, last, toReversed } from "../utils.js"; export const getAllureState = () => { let state = Cypress.env("allure") as AllureSpecState; @@ -11,6 +11,9 @@ export const getAllureState = () => { testPlan: undefined, currentTest: undefined, projectDir: undefined, + stepStack: [], + stepsToFinalize: [], + nextApiStepId: 0, }; Cypress.env("allure", state); } @@ -48,3 +51,29 @@ export const dropCurrentTest = () => { }; export const getConfig = () => getAllureState().config; + +export const getStepStack = () => getAllureState().stepStack; + +export const getCurrentStep = () => last(getStepStack()); + +export const pushStep = (step: StepDescriptor) => getStepStack().push(step); + +export const popStep = () => getStepStack().pop(); + +export const popSteps = (index: number) => toReversed(getStepStack().splice(index)); + +export const popAllSteps = () => popSteps(0); + +export const clearStepStack = () => { + getAllureState().stepStack = []; +}; + +export const setupStepFinalization = (step: T, finalizer?: StepFinalizer) => + getAllureState().stepsToFinalize.push([step, finalizer]); + +export const getStepsToFinalize = () => getAllureState().stepsToFinalize; + +export const clearStepsToFinalize = () => { + const state = getAllureState(); + state.stepsToFinalize = []; +}; diff --git a/packages/allure-cypress/src/browser/steps.ts b/packages/allure-cypress/src/browser/steps.ts new file mode 100644 index 000000000..1fb892e44 --- /dev/null +++ b/packages/allure-cypress/src/browser/steps.ts @@ -0,0 +1,156 @@ +import type { StatusDetails } from "allure-js-commons"; +import { Status } from "allure-js-commons"; +import { getMessageAndTraceFromError, getStatusFromError } from "allure-js-commons/sdk"; +import type { + ApiStepDescriptor, + CypressStepFinalizeMessage, + LogStepDescriptor, + StepDescriptor, + StepFinalizer, +} from "../types.js"; +import { popUntilFindIncluded } from "../utils.js"; +import { reportStepStart, reportStepStop } from "./lifecycle.js"; +import { + clearStepsToFinalize, + enqueueRuntimeMessage, + getStepStack, + getStepsToFinalize, + popAllSteps, + pushStep, + setupStepFinalization, +} from "./state.js"; +import { generateApiStepId } from "./utils.js"; + +export const ALLURE_STEP_CMD_SUBJECT = {}; + +export const isApiStep = (descriptor: StepDescriptor): descriptor is ApiStepDescriptor => { + return descriptor.type === "api"; +}; + +export const isLogStep = (descriptor: StepDescriptor): descriptor is LogStepDescriptor => { + return descriptor.type === "log"; +}; + +export const startAllureApiStep = (name: string) => reportStepStart(pushAllureStep(), name); + +export const pushAllureStep = () => { + const id = generateApiStepId(); + pushStep({ id, type: "api" }); + return id; +}; + +export const reportStepError = (error: Error) => { + const status = getStatusFromError(error); + const statusDetails = getMessageAndTraceFromError(error); + + // Cypress will abort the test/hook execution soon. No subsequent commands will be run, including the ones that + // have been scheduled by `allure.step` to stop the currently running steps. + // Additionally, we can't tell in advance if the current command log steps will be stopped normally or not. + // + // Given that, this function will stop all consecutive Allure API steps at the tip of the step stack. + // The command log steps will be given a chance to stop normally to get the most correct timings. + // + // The command log steps that won't stop normally (and Allure API substeps thereof) will be stopped during the + // test/hook finalization phase. + stopAllureApiStepStackTip(status, statusDetails); + + // It's not guaranteed for command log steps and intermediate Allure API steps to have access to the error at the + // moment they are stopped. + // Additionally, Cypress may not update the stack trace of the error at that time. Until that happens, the stack + // trace points deep in the bundled code, which is little to no use for the user. Therefore, we need to associate + // the remaining steps with the error object to grab the updated stack trace later. + associateErrorWithRunningSteps(error); +}; + +export const stopCurrentAllureApiStep = (status?: Status, statusDetails?: StatusDetails) => + findAndStopStepWithSubsteps((stepDescriptor) => isApiStep(stepDescriptor), status, statusDetails); + +export const findAndStopStepWithSubsteps = ( + pred: (stepEntry: StepDescriptor) => boolean, + status?: Status, + statusDetails?: StatusDetails, +) => stopSelectedSteps(popUntilFindIncluded(getStepStack(), pred), status, statusDetails); + +export const stopAllSteps = (status?: Status, statusDetails?: StatusDetails) => + stopSelectedSteps(popAllSteps(), status, statusDetails); + +export const finalizeSteps = () => { + // This will stop all dangling steps (like log groups with missing endGroup calls or logs that haven't been + // finished by Cypress due to an error). + stopAllSteps(); + + getStepsToFinalize().forEach(finalizeOneStep); + clearStepsToFinalize(); +}; + +export const resolveStepStatus = (step: StepDescriptor) => + step.error ? getStatusFromError(step.error) : Status.PASSED; + +const finalizeOneStep = ([step, finalizer]: [StepDescriptor, StepFinalizer | undefined]) => { + const { id, error } = step; + const data: CypressStepFinalizeMessage["data"] = { id }; + + if (error) { + // Cypress rewrites the stack trace of an error to point to the location in the test file. Until then, the stack + // trace points inside the messy bundle, which is not helpful. There are circumstances when we can't be sure this + // has happened when a step is about to stop. That's why we defer setting the status details until we are sure + // Cypress does its job. + data.statusDetails = getMessageAndTraceFromError(error); + } + + finalizer?.(data); + + enqueueRuntimeMessage({ + type: "cypress_step_finalize", + data, + }); +}; + +const stopAllureApiStepStackTip = (status: Status, statusDetails: StatusDetails) => { + const stepStack = getStepStack(); + const firstApiStepAfterLastLogStep = stepStack.at(stepStack.findLastIndex(isLogStep) + 1); + if (firstApiStepAfterLastLogStep) { + findAndStopStepWithSubsteps( + (logEntryOrMessage) => Object.is(logEntryOrMessage, firstApiStepAfterLastLogStep), + status, + statusDetails, + ); + } +}; + +const propagateErrorToStepDescriptor = (step: StepDescriptor, errorOfSubstep: Error | undefined) => { + if (isLogStep(step)) { + const error = step.log.attributes.error; + if (error) { + return (step.error = error); + } + } + + if (errorOfSubstep) { + step.error = errorOfSubstep; + } + + return step.error; +}; + +const stopSelectedSteps = (steps: readonly StepDescriptor[], status?: Status, statusDetails?: StatusDetails) => { + let error: Error | undefined; + for (const stepEntry of steps) { + error = propagateErrorToStepDescriptor(stepEntry, error); + stopStep(stepEntry, status, statusDetails); + } + + if (error) { + associateErrorWithRunningSteps(error); + } +}; + +const associateErrorWithRunningSteps = (error: Error) => getStepStack().forEach((step) => (step.error = error)); + +const stopStep = (step: StepDescriptor, status?: Status, statusDetails?: StatusDetails) => { + reportStepStop(step, status, statusDetails); + + if (isApiStep(step) && step.error) { + setupStepFinalization(step); + } +}; diff --git a/packages/allure-cypress/src/browser/testplan.ts b/packages/allure-cypress/src/browser/testplan.ts new file mode 100644 index 000000000..323b0c505 --- /dev/null +++ b/packages/allure-cypress/src/browser/testplan.ts @@ -0,0 +1,57 @@ +import { LabelName } from "allure-js-commons"; +import type { TestPlanV1 } from "allure-js-commons/sdk"; +import type { CypressSuite, CypressTest } from "../types.js"; +import { getAllureTestPlan } from "./state.js"; +import { getNamesAndLabels, resolveSpecRelativePath } from "./utils.js"; + +export const applyTestPlan = (spec: Cypress.Spec, root: CypressSuite) => { + const testPlan = getAllureTestPlan(); + if (testPlan) { + const specPath = resolveSpecRelativePath(spec); + for (const suite of iterateSuites(root)) { + const indicesToRemove = getIndicesOfDeselectedTests(testPlan, spec, specPath, suite.tests); + removeSortedIndices(suite.tests, indicesToRemove); + } + } +}; + +const iterateSuites = function* (parent: CypressSuite) { + const suiteStack: CypressSuite[] = []; + for (let s: CypressSuite | undefined = parent; s; s = suiteStack.pop()) { + yield s; + + // Pushing in reverse allows us to maintain depth-first pre-order traversal - + // the same order as used by Mocha & Cypress. + for (let i = s.suites.length - 1; i >= 0; i--) { + suiteStack.push(s.suites[i]); + } + } +}; + +const getIndicesOfDeselectedTests = ( + testPlan: TestPlanV1, + spec: Cypress.Spec, + specPath: string, + tests: readonly CypressTest[], +) => { + const indicesToRemove: number[] = []; + tests.forEach((test, index) => { + const { fullNameSuffix, labels } = getNamesAndLabels(spec, test); + const fullName = `${specPath}#${fullNameSuffix}`; + const allureId = labels.find(({ name }) => name === LabelName.ALLURE_ID)?.value; + + if (!includedInTestPlan(testPlan, fullName, allureId)) { + indicesToRemove.push(index); + } + }); + return indicesToRemove; +}; + +const removeSortedIndices = (arr: T[], indices: readonly number[]) => { + for (let i = indices.length - 1; i >= 0; i--) { + arr.splice(indices[i], 1); + } +}; + +const includedInTestPlan = (testPlan: TestPlanV1, fullName: string, allureId: string | undefined): boolean => + testPlan.tests.some((test) => (allureId && test.id?.toString() === allureId) || test.selector === fullName); diff --git a/packages/allure-cypress/src/browser/utils.ts b/packages/allure-cypress/src/browser/utils.ts new file mode 100644 index 000000000..ac38230fa --- /dev/null +++ b/packages/allure-cypress/src/browser/utils.ts @@ -0,0 +1,140 @@ +import { Status } from "allure-js-commons"; +import type { StatusDetails } from "allure-js-commons"; +import { extractMetadataFromString, getMessageAndTraceFromError, getStatusFromError } from "allure-js-commons/sdk"; +import type { CypressHook, CypressStepStopMessage, CypressSuite, CypressTest, StepDescriptor } from "../types.js"; +import { ALLURE_REPORT_SYSTEM_HOOK } from "./events/mocha.js"; +import { getAllureState, getProjectDir } from "./state.js"; +import { resolveStepStatus } from "./steps.js"; + +export const getFileNameFromPath = (path: string) => path.substring(path.lastIndexOf(IS_WIN ? "\\" : "/") + 1); + +export const resolveSpecRelativePath = (spec: Cypress.Spec) => { + const projectDir = getProjectDir(); + const specPath = projectDir ? spec.absolute.substring(projectDir.length + 1) : spec.relative; + return IS_WIN ? specPath.replaceAll("\\", "/") : specPath; +}; + +export const uint8ArrayToBase64 = (data: unknown) => { + // @ts-ignore + const u8arrayLike = Array.isArray(data) || data.buffer; + + if (!u8arrayLike) { + return data as string; + } + + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + return btoa(String.fromCharCode.apply(null, data as number[])); +}; + +export const getTestStartData = (test: CypressTest) => ({ + ...getNamesAndLabels(Cypress.spec, test), + start: test.wallClockStartedAt?.getTime() || Date.now(), +}); + +export const getTestStopData = (test: CypressTest) => ({ + duration: test.duration ?? 0, + retries: (test as any)._retries ?? 0, +}); + +export const getTestSkipData = () => ({ + statusDetails: { message: "This is a pending test" }, +}); + +export const getStepStopData = (step: StepDescriptor, status?: Status, statusDetails?: StatusDetails) => { + const data: CypressStepStopMessage["data"] = { + id: step.id, + stop: Date.now(), + status: status ?? resolveStepStatus(step), + }; + if (statusDetails) { + data.statusDetails = statusDetails; + } + return data; +}; + +const testReportedKey = Symbol("The test has been reported to Allure"); + +export const markTestAsReported = (test: CypressTest) => { + (test as any)[testReportedKey] = true; +}; + +export const isTestReported = (test: CypressTest) => (test as any)[testReportedKey] === true; + +export const iterateSuites = function* (parent: CypressSuite) { + const suiteStack: CypressSuite[] = []; + for (let s: CypressSuite | undefined = parent; s; s = suiteStack.pop()) { + yield s; + + // Pushing in reverse allows us to maintain depth-first pre-order traversal; + // the same order is used by Mocha & Cypress. + for (let i = s.suites.length - 1; i >= 0; i--) { + suiteStack.push(s.suites[i]); + } + } +}; + +export const iterateTests = function* (parent: CypressSuite) { + for (const suite of iterateSuites(parent)) { + yield* suite.tests; + } +}; + +export const getSuitePath = (test: CypressTest) => { + const suites: CypressSuite[] = []; + for (let s: CypressSuite | undefined = test.parent; s; s = s.parent) { + suites.push(s); + } + suites.reverse(); + return suites; +}; + +export const getSuiteTitlePath = (test: CypressTest): string[] => + getSuitePath(test) + .filter((s) => s.title) + .map((s) => s.title); + +export const generateApiStepId = () => (getAllureState().nextApiStepId++).toString(); + +export const getNamesAndLabels = (spec: Cypress.Spec, test: CypressTest) => { + const rawName = test.title; + const { cleanTitle: name, labels } = extractMetadataFromString(rawName); + const suites = test.titlePath().slice(0, -1); + const fullNameSuffix = `${[...suites, name].join(" ")}`; + return { name, labels, fullNameSuffix }; +}; + +export const isAllureHook = (hook: CypressHook) => hook.title.includes(ALLURE_REPORT_SYSTEM_HOOK); + +export const isRootAfterAllHook = (hook: CypressHook) => hook.parent!.root && hook.hookName === "after all"; + +export const isLastRootAfterHook = (context: Mocha.Context) => { + const currentAfterAll = context.test as CypressHook; + const rootSuite = (context.test as CypressHook).parent!; + const hooks = (rootSuite as any).hooks as CypressHook[]; + const lastAfterAll = hooks.findLast((h) => h.hookName === "after all"); + return lastAfterAll?.hookId === currentAfterAll.hookId; +}; + +export const getStatusDataOfTestSkippedByHookError = ( + hookTitle: string, + isEachHook: boolean, + err: Error, + suite: CypressSuite, +) => { + const status = isEachHook ? Status.SKIPPED : getStatusFromError(err); + const { message, trace } = getMessageAndTraceFromError(err); + return { + status, + statusDetails: { + message: isEachHook ? getSkipReason(hookTitle, suite) : message, + trace, + }, + }; +}; + +const getSkipReason = (hookTitle: string, suite: CypressSuite) => { + const suiteName = suite.title ? `'${suite.title}'` : "root"; + return `'${hookTitle}' defined in the ${suiteName} suite has failed`; +}; + +const IS_WIN = Cypress.platform === "win32"; diff --git a/packages/allure-cypress/src/index.ts b/packages/allure-cypress/src/index.ts index 32b860e57..03fbcc9fd 100644 --- a/packages/allure-cypress/src/index.ts +++ b/packages/allure-cypress/src/index.ts @@ -1,159 +1,4 @@ -import { getMessageAndTraceFromError, getStatusFromError } from "allure-js-commons/sdk"; -import { ALLURE_REPORT_SYSTEM_HOOK } from "./model.js"; -import type { CypressCommand, CypressHook, CypressSuite, CypressTest } from "./model.js"; -import { - completeHookErrorReporting, - completeSpecIfNoAfterHookLeft, - enableScopeLevelAfterHookReporting, - flushRuntimeMessages, - initTestRuntime, - reportCommandEnd, - reportCommandStart, - reportHookEnd, - reportHookStart, - reportRunStart, - reportScreenshot, - reportSuiteEnd, - reportSuiteStart, - reportTestEnd, - reportTestOrHookFail, - reportTestPass, - reportTestSkip, - reportTestStart, - reportUnfinishedSteps, -} from "./runtime.js"; -import { isAllureInitialized, setAllureInitialized } from "./state.js"; -import { applyTestPlan, isAllureHook, isRootAfterAllHook, isTestReported, shouldCommandBeSkipped } from "./utils.js"; - -const { - EVENT_RUN_BEGIN, - EVENT_SUITE_BEGIN, - EVENT_SUITE_END, - EVENT_HOOK_BEGIN, - EVENT_HOOK_END, - EVENT_TEST_BEGIN, - EVENT_TEST_PASS, - EVENT_TEST_FAIL, - EVENT_TEST_PENDING, - EVENT_TEST_END, -} = Mocha.Runner.constants; - -const initializeAllure = () => { - if (isAllureInitialized()) { - return; - } - - setAllureInitialized(); - - // @ts-ignore - Cypress.mocha - .getRunner() - .on(EVENT_RUN_BEGIN, () => { - initTestRuntime(); - reportRunStart(); - }) - .on(EVENT_SUITE_BEGIN, (suite: CypressSuite) => { - if (suite.root) { - applyTestPlan(Cypress.spec, suite); - } - reportSuiteStart(suite); - }) - .on(EVENT_SUITE_END, (suite: CypressSuite) => { - reportSuiteEnd(suite); - }) - .on(EVENT_HOOK_BEGIN, (hook: CypressHook) => { - if (isAllureHook(hook)) { - return; - } - - reportHookStart(hook); - }) - .on(EVENT_HOOK_END, (hook: CypressHook) => { - if (isAllureHook(hook)) { - return; - } - - reportHookEnd(hook); - }) - .on(EVENT_TEST_BEGIN, (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); - } - }) - .on(EVENT_TEST_PASS, () => { - reportTestPass(); - }) - .on(EVENT_TEST_FAIL, (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); - } - }) - .on(EVENT_TEST_PENDING, (test: CypressTest) => { - reportTestSkip(test); - }) - .on(EVENT_TEST_END, (test: CypressTest) => { - reportTestEnd(test); - }); - - Cypress.Screenshot.defaults({ - onAfterScreenshot: (_, { path, name }) => { - reportScreenshot(path, name); - }, - }); - - Cypress.on("fail", (err) => { - const status = getStatusFromError(err); - const statusDetails = getMessageAndTraceFromError(err); - - reportUnfinishedSteps(status, statusDetails); - - throw err; - }); - Cypress.on("command:start", (command: CypressCommand) => { - if (shouldCommandBeSkipped(command)) { - return; - } - - reportCommandStart(command); - }); - Cypress.on("command:end", (command: CypressCommand) => { - if (shouldCommandBeSkipped(command)) { - return; - } - - reportCommandEnd(); - }); - - afterEach(ALLURE_REPORT_SYSTEM_HOOK, flushRuntimeMessages); - - after(ALLURE_REPORT_SYSTEM_HOOK, function (this: Mocha.Context) { - flushRuntimeMessages(); - completeSpecIfNoAfterHookLeft(this); - }); - - enableScopeLevelAfterHookReporting(); -}; +import { initializeAllure } from "./browser/index.js"; initializeAllure(); diff --git a/packages/allure-cypress/src/reporter.ts b/packages/allure-cypress/src/reporter.ts index 03d68bce3..62dd26447 100644 --- a/packages/allure-cypress/src/reporter.ts +++ b/packages/allure-cypress/src/reporter.ts @@ -1,6 +1,6 @@ import type Cypress from "cypress"; import { ContentType, Stage, Status } from "allure-js-commons"; -import type { TestResult } from "allure-js-commons"; +import type { FixtureResult, TestResult } from "allure-js-commons"; import type { RuntimeMessage } from "allure-js-commons/sdk"; import { ReporterRuntime, @@ -21,19 +21,20 @@ import type { AllureCypressConfig, AllureCypressTaskArgs, AllureSpecState, - CypressCommandEndMessage, - CypressCommandStartMessage, CypressFailMessage, CypressHookEndMessage, CypressHookStartMessage, CypressSkippedTestMessage, + CypressStepFinalizeMessage, + CypressStepStartMessage, + CypressStepStopMessage, CypressSuiteEndMessage, CypressSuiteStartMessage, CypressTestEndMessage, CypressTestSkipMessage, CypressTestStartMessage, SpecContext, -} from "./model.js"; +} from "./types.js"; import { DEFAULT_RUNTIME_CONFIG, last } from "./utils.js"; export class AllureCypress { @@ -197,11 +198,14 @@ export class AllureCypress { case "cypress_test_end": this.#stopTest(context, message); break; - case "cypress_command_start": - this.#startCommand(context, message); + case "cypress_step_start": + this.#startStep(context, message.data); break; - case "cypress_command_end": - this.#stopCommand(context, message); + case "cypress_step_stop": + this.#stopStep(context, message.data); + break; + case "cypress_step_finalize": + this.#finalizeStep(context, message.data); break; default: this.#applyRuntimeApiMessages(context, message); @@ -279,6 +283,7 @@ export class AllureCypress { fixture.status ??= Status.PASSED; }); this.allureRuntime.stopFixture(fixtureUuid, { duration }); + this.#fixFixtureStepStops(fixtureUuid); context.fixture = undefined; } }; @@ -390,38 +395,64 @@ export class AllureCypress { testResult.stage = Stage.FINISHED; }); this.allureRuntime.stopTest(testUuid, { duration }); + this.#fixTestStepStops(testUuid); + }; + + #fixTestStepStops = (testUuid: string) => { + this.allureRuntime.updateTest(testUuid, this.#fixStepStops); }; - #startCommand = (context: SpecContext, { data: { name, args } }: CypressCommandStartMessage) => { + #fixFixtureStepStops = (fixtureUuid: string) => { + this.allureRuntime.updateFixture(fixtureUuid, this.#fixStepStops); + }; + + #fixStepStops = ({ stop, steps = [] }: TestResult | FixtureResult) => { + if (stop) { + // In some circumstances, steps becomes dangling and are stopped at the test end/hook end events, which happen + // chronologically after the test or fixture ends. This leads to the steps' stop time being greater than the one + // of the test/fixture. + // The only steps that may be affected are the rightmost descendants of the test/fixture. + for (let step = steps.at(-1); step; step = step.steps.at(-1)) { + if (step.stop && step.stop > stop) { + step.stop = stop; + } else { + // Steps are always stopped child-to-parent. If a step's stop time is OK, its substeps are also correct. + return; + } + } + } + }; + + #startStep = (context: SpecContext, { id, ...properties }: CypressStepStartMessage["data"]) => { const rootUuid = this.#resolveRootUuid(context); if (rootUuid) { - const stepUuid = this.allureRuntime.startStep(rootUuid, undefined, { - name, - parameters: args.map((arg, j) => ({ - name: `Argument [${j}]`, - value: arg, - })), - }); + const stepUuid = this.allureRuntime.startStep(rootUuid, undefined, properties); if (stepUuid) { - context.commandSteps.push(stepUuid); + context.stepsByFrontEndId.set(id, stepUuid); } } }; - #stopCommand = (context: SpecContext, { data: { status, statusDetails, stop } }: CypressCommandEndMessage) => { - const stepUuid = context.commandSteps.pop(); + #stopStep = (context: SpecContext, { id, stop, ...properties }: CypressStepStopMessage["data"]) => { + const stepUuid = context.stepsByFrontEndId.get(id); if (stepUuid) { this.allureRuntime.updateStep(stepUuid, (r) => { - r.status = status; - - if (statusDetails) { - r.statusDetails = statusDetails; - } + Object.assign(r, properties); }); this.allureRuntime.stopStep(stepUuid, { stop }); } }; + #finalizeStep = (context: SpecContext, { id, ...properties }: CypressStepFinalizeMessage["data"]) => { + const stepUuid = context.stepsByFrontEndId.get(id); + if (stepUuid) { + this.allureRuntime.updateStep(stepUuid, (r) => { + Object.assign(r, properties); + }); + context.stepsByFrontEndId.delete(id); + } + }; + #applyRuntimeApiMessages = (context: SpecContext, message: RuntimeMessage) => { const rootUuid = this.#resolveRootUuid(context); if (rootUuid) { @@ -482,7 +513,7 @@ export class AllureCypress { specPath, test: undefined, fixture: undefined, - commandSteps: [], + stepsByFrontEndId: new Map(), videoScope: this.allureRuntime.startScope(), suiteIdToScope: new Map(), suiteScopeToId: new Map(), @@ -501,6 +532,9 @@ const createRuntimeState = (allureConfig?: AllureCypressConfig): AllureSpecState messages: [], testPlan: parseTestPlan(), projectDir: getProjectRoot(), + stepStack: [], + stepsToFinalize: [], + nextApiStepId: 0, }); const getRuntimeConfigDefaults = ({ diff --git a/packages/allure-cypress/src/runtime.ts b/packages/allure-cypress/src/runtime.ts deleted file mode 100644 index 65ac9ffe2..000000000 --- a/packages/allure-cypress/src/runtime.ts +++ /dev/null @@ -1,693 +0,0 @@ -import { ContentType, Status } from "allure-js-commons"; -import type { AttachmentOptions, Label, Link, ParameterMode, ParameterOptions, StatusDetails } from "allure-js-commons"; -import { - getMessageAndTraceFromError, - getStatusFromError, - getUnfinishedStepsMessages, - isPromise, - serialize, -} from "allure-js-commons/sdk"; -import type { RuntimeMessage } from "allure-js-commons/sdk"; -import { getGlobalTestRuntime, setGlobalTestRuntime } from "allure-js-commons/sdk/runtime"; -import type { TestRuntime } from "allure-js-commons/sdk/runtime"; -import type { - AllureCypressTaskArgs, - CypressCommand, - CypressCommandEndMessage, - CypressFailMessage, - CypressHook, - CypressMessage, - CypressSuite, - CypressSuiteFunction, - CypressTest, - DirectHookImplementation, - HookImplementation, -} from "./model.js"; -import { ALLURE_REPORT_STEP_COMMAND } from "./model.js"; -import { - dropCurrentTest, - enqueueRuntimeMessage, - getConfig, - getCurrentTest, - getRuntimeMessages, - setCurrentTest, - setRuntimeMessages, -} from "./state.js"; -import { - getSuites, - getTestSkipData, - getTestStartData, - getTestStopData, - isAllureHook, - isTestReported, - iterateTests, - markTestAsReported, - uint8ArrayToBase64, -} from "./utils.js"; - -export class AllureCypressTestRuntime implements TestRuntime { - constructor() { - this.#resetMessages(); - } - - labels(...labels: Label[]) { - return this.#enqueueMessageAsync({ - type: "metadata", - data: { - labels, - }, - }); - } - - links(...links: Link[]) { - return this.#enqueueMessageAsync({ - type: "metadata", - data: { - links, - }, - }); - } - - parameter(name: string, value: string, options?: ParameterOptions) { - return this.#enqueueMessageAsync({ - type: "metadata", - data: { - parameters: [ - { - name, - value, - ...options, - }, - ], - }, - }); - } - - description(markdown: string) { - return this.#enqueueMessageAsync({ - type: "metadata", - data: { - description: markdown, - }, - }); - } - - descriptionHtml(html: string) { - return this.#enqueueMessageAsync({ - type: "metadata", - data: { - descriptionHtml: html, - }, - }); - } - - displayName(name: string) { - return this.#enqueueMessageAsync({ - type: "metadata", - data: { - displayName: name, - }, - }); - } - - historyId(value: string) { - return this.#enqueueMessageAsync({ - type: "metadata", - data: { - historyId: value, - }, - }); - } - - testCaseId(value: string) { - return this.#enqueueMessageAsync({ - type: "metadata", - data: { - testCaseId: value, - }, - }); - } - - // @ts-ignore - attachment(name: string, content: string, options: AttachmentOptions) { - // @ts-ignore - const attachmentRawContent: string | Uint8Array = content?.type === "Buffer" ? content.data : content; - const actualEncoding = typeof attachmentRawContent === "string" ? "utf8" : "base64"; - const attachmentContent = uint8ArrayToBase64(attachmentRawContent); - - return this.#enqueueMessageAsync({ - type: "attachment_content", - data: { - name, - content: attachmentContent, - encoding: actualEncoding, - contentType: options.contentType, - fileExtension: options.fileExtension, - }, - }); - } - - attachmentFromPath(name: string, path: string, options: Omit) { - return this.#enqueueMessageAsync({ - type: "attachment_path", - data: { - name, - path, - contentType: options.contentType, - fileExtension: options.fileExtension, - }, - }); - } - - logStep(name: string, status: Status = Status.PASSED, error?: Error) { - return cy - .wrap(ALLURE_REPORT_STEP_COMMAND, { log: false }) - .then(() => { - this.#enqueueMessageAsync({ - type: "step_start", - data: { - name, - start: Date.now(), - }, - }); - - return Cypress.Promise.resolve(); - }) - .then(() => { - return this.#enqueueMessageAsync({ - type: "step_stop", - data: { - status: status, - stop: Date.now(), - statusDetails: error ? { ...getMessageAndTraceFromError(error) } : undefined, - }, - }); - }); - } - - step(name: string, body: () => T | PromiseLike) { - return cy - .wrap(ALLURE_REPORT_STEP_COMMAND, { log: false }) - .then(() => { - this.#enqueueMessageAsync({ - type: "step_start", - data: { - name, - start: Date.now(), - }, - }); - - return Cypress.Promise.resolve(body()); - }) - .then((result) => { - return this.#enqueueMessageAsync({ - type: "step_stop", - data: { - status: Status.PASSED, - stop: Date.now(), - }, - }).then(() => result); - }); - } - - stepDisplayName(name: string) { - return this.#enqueueMessageAsync({ - type: "step_metadata", - data: { - name, - }, - }); - } - - stepParameter(name: string, value: string, mode?: ParameterMode) { - return this.#enqueueMessageAsync({ - type: "step_metadata", - data: { - parameters: [{ name, value, mode }], - }, - }); - } - - flushAllureMessagesToTask = (taskName: string) => { - const messages = this.#dequeueAllMessages(); - if (messages.length) { - cy.task(taskName, { absolutePath: Cypress.spec.absolute, messages }, { log: false }); - } - }; - - flushAllureMessagesToTaskAsync = (taskName: string): Cypress.Chainable | undefined => { - const messages = this.#dequeueAllMessages(); - if (messages.length) { - const args: AllureCypressTaskArgs = { - absolutePath: Cypress.spec.absolute, - messages, - isInteractive: Cypress.config("isInteractive"), - }; - return cy.task(taskName, args, { log: false }); - } - }; - - #resetMessages = () => setRuntimeMessages([]); - - #enqueueMessageAsync = (message: CypressMessage): PromiseLike => { - enqueueRuntimeMessage(message); - return Cypress.Promise.resolve(); - }; - - #dequeueAllMessages = () => { - const messages = getRuntimeMessages(); - this.#resetMessages(); - return messages; - }; -} - -export const initTestRuntime = () => setGlobalTestRuntime(new AllureCypressTestRuntime() as TestRuntime); - -export const getTestRuntime = () => getGlobalTestRuntime() as AllureCypressTestRuntime; - -export const reportRunStart = () => { - enqueueRuntimeMessage({ - type: "cypress_run_start", - data: {}, - }); -}; - -export const reportSuiteStart = (suite: CypressSuite) => { - enqueueRuntimeMessage({ - type: "cypress_suite_start", - data: { - id: suite.id, - name: suite.title, - root: suite.root, - start: Date.now(), - }, - }); -}; - -export const reportSuiteEnd = (suite: CypressSuite) => { - enqueueRuntimeMessage({ - type: "cypress_suite_end", - data: { - root: suite.root, - stop: Date.now(), - }, - }); -}; - -export const reportHookStart = (hook: CypressHook, start?: number) => { - enqueueRuntimeMessage({ - type: "cypress_hook_start", - data: { - name: hook.title, - scopeType: hook.hookName.includes("each") ? "each" : "all", - position: hook.hookName.includes("before") ? "before" : "after", - start: start ?? Date.now(), - }, - }); -}; - -export const reportHookEnd = (hook: CypressHook) => { - enqueueRuntimeMessage({ - type: "cypress_hook_end", - data: { - duration: hook.duration ?? 0, - }, - }); -}; - -export const reportTestStart = (test: CypressTest) => { - setCurrentTest(test); - enqueueRuntimeMessage({ - type: "cypress_test_start", - data: getTestStartData(test), - }); - markTestAsReported(test); -}; - -export const reportUnfinishedSteps = (status: Status, statusDetails?: StatusDetails) => { - const runtimeMessages = getRuntimeMessages() as RuntimeMessage[]; - const unfinishedStepsMessages = getUnfinishedStepsMessages(runtimeMessages); - unfinishedStepsMessages.forEach(() => { - enqueueRuntimeMessage({ - type: "step_stop", - data: { - stop: Date.now(), - status, - statusDetails, - }, - }); - }); -}; - -export const reportTestPass = () => { - reportUnfinishedSteps(Status.PASSED); - enqueueRuntimeMessage({ - type: "cypress_test_pass", - data: {}, - }); -}; - -export const reportTestSkip = (test: CypressTest) => { - if (isTestReported(test)) { - reportUnfinishedCommand(Status.SKIPPED, { - message: "The test was skipped before the command was completed", - }); - } else { - reportTestStart(test); - } - - enqueueRuntimeMessage({ - type: "cypress_test_skip", - data: getTestSkipData(), - }); -}; - -export const reportCommandStart = (command: CypressCommand) => { - const { - stepsFromCommands: { maxArgumentDepth, maxArgumentLength }, - } = getConfig(); - enqueueRuntimeMessage({ - type: "cypress_command_start", - data: { - name: `Command "${command.attributes.name}"`, - args: command.attributes.args.map((arg) => - serialize(arg, { maxDepth: maxArgumentDepth, maxLength: maxArgumentLength }), - ), - start: Date.now(), - }, - }); -}; - -export const reportCommandEnd = () => { - enqueueRuntimeMessage({ - type: "cypress_command_end", - data: { - status: Status.PASSED, - stop: Date.now(), - }, - }); -}; - -export const reportScreenshot = (path: string, name: string) => { - enqueueRuntimeMessage({ - type: "attachment_path", - data: { - path: path, - name: name || "Screenshot", - contentType: ContentType.PNG, - }, - }); -}; - -export const reportUnfinishedCommand = (status: Status, statusDetails?: StatusDetails) => { - const runtimeMessages = getRuntimeMessages(); - const startCommandMessageIdx = runtimeMessages.toReversed().findIndex(({ type }) => type === "cypress_command_start"); - const stopCommandMessageIdx = runtimeMessages.toReversed().findIndex(({ type }) => type === "cypress_command_end"); - const hasUnfinishedCommand = startCommandMessageIdx > stopCommandMessageIdx; - - const data: CypressCommandEndMessage["data"] = { status, stop: Date.now() }; - if (statusDetails) { - data.statusDetails = statusDetails; - } - - if (hasUnfinishedCommand) { - enqueueRuntimeMessage({ type: "cypress_command_end", data }); - } -}; - -export const reportTestOrHookFail = (err: Error) => { - const status = getStatusFromError(err); - const statusDetails = getMessageAndTraceFromError(err); - - reportUnfinishedCommand(status, statusDetails); - - enqueueRuntimeMessage({ - type: "cypress_fail", - data: { - status, - statusDetails, - }, - }); -}; - -export const reportTestEnd = (test: CypressTest) => { - enqueueRuntimeMessage({ - type: "cypress_test_end", - data: { - duration: test.duration ?? 0, - retries: (test as any)._retries ?? 0, - }, - }); - dropCurrentTest(); -}; - -export const completeHookErrorReporting = (hook: CypressHook, err: Error) => { - const isEachHook = hook.hookName.includes("each"); - const suite = hook.parent!; - const testFailData = getStatusDataOfTestSkippedByHookError(hook.title, isEachHook, err, suite); - - // Cypress doens't emit 'hook end' if the hook has failed. - reportHookEnd(hook); - - // Cypress doens't emit 'test end' if the hook has failed. - // We must report the test's end manualy in case of a 'before each' hook. - reportCurrentTestIfAny(); - - // Cypress skips the remaining tests in the suite of a failed hook. - // We should include them to the report manually. - reportRemainingTests(suite, testFailData); -}; - -/** - * Patches the `after` function, to inject an extra `after` hook after each spec-level - * `after` hook defined by the user. - */ -export const enableScopeLevelAfterHookReporting = () => { - const [getSuiteDepth, incSuiteDepth, decSuiteDepth] = createSuiteDepthCounterState(); - patchDescribe(incSuiteDepth, decSuiteDepth); - patchAfter(getSuiteDepth); -}; - -export const flushRuntimeMessages = () => getTestRuntime().flushAllureMessagesToTask("reportAllureCypressSpecMessages"); - -export const completeSpec = () => - getTestRuntime().flushAllureMessagesToTaskAsync("reportFinalAllureCypressSpecMessages"); - -export const completeSpecIfNoAfterHookLeft = (context: Mocha.Context) => { - if (isLastRootAfterHook(context)) { - const hook = context.test as CypressHook; - if (!isAllureHook(hook)) { - reportHookEnd(hook); - } - return completeSpec(); - } -}; - -const completeSpecOnAfterHookFailure = ( - context: Mocha.Context, - hookError: Error, -): Cypress.Chainable | undefined => { - try { - reportTestOrHookFail(hookError); - completeHookErrorReporting(context.test as CypressHook, hookError); - - // cy.task's then doesn't have onrejected, that's why we don't log async Allure errors here. - return completeSpec(); - } catch (allureError) { - logAllureRootAfterError(context, allureError); - } -}; - -const reportCurrentTestIfAny = () => { - const currentTest = getCurrentTest(); - if (currentTest) { - reportTestEnd(currentTest); - } -}; - -const reportRemainingTests = (suite: CypressSuite, testFailData: CypressFailMessage["data"]) => { - for (const test of iterateTests(suite)) { - // Some tests in the suite might've been already reported. - if (!isTestReported(test)) { - reportTestsSkippedByHookError( - test, - test.pending ? { ...getTestSkipData(), status: Status.SKIPPED } : testFailData, - ); - } - } -}; - -const reportTestsSkippedByHookError = (test: CypressTest, testFailData: CypressFailMessage["data"]) => { - enqueueRuntimeMessage({ - type: "cypress_skipped_test", - data: { - ...getTestStartData(test), - ...testFailData, - ...getTestStopData(test), - suites: getSuites(test).map((s) => s.id), - }, - }); - markTestAsReported(test); -}; - -const getStatusDataOfTestSkippedByHookError = ( - hookTitle: string, - isEachHook: boolean, - err: Error, - suite: CypressSuite, -) => { - const status = isEachHook ? Status.SKIPPED : getStatusFromError(err); - const { message, trace } = getMessageAndTraceFromError(err); - return { - status, - statusDetails: { - message: isEachHook ? getSkipReason(hookTitle, suite) : message, - trace, - }, - }; -}; - -const getSkipReason = (hookTitle: string, suite: CypressSuite) => { - const suiteName = suite.title ? `'${suite.title}'` : "root"; - return `'${hookTitle}' defined in the ${suiteName} suite has failed`; -}; - -const forwardDescribeCall = (target: CypressSuiteFunction, ...args: Parameters) => { - const [title, configOrFn, fn] = args; - if (typeof fn === "undefined" && typeof configOrFn === "undefined") { - return target(title); - } else if (typeof configOrFn === "function") { - return target(title, configOrFn); - } else { - return target(title, configOrFn, fn); - } -}; - -const patchDescribe = (incSuiteDepth: () => void, decSuiteDepth: () => void) => { - const patchDescribeFn = - (target: CypressSuiteFunction): CypressSuiteFunction => - (title, configOrFn, fn) => { - incSuiteDepth(); - try { - return forwardDescribeCall(target, title, configOrFn, fn); - } finally { - decSuiteDepth(); - } - }; - const originalDescribeFn: Mocha.SuiteFunction = globalThis.describe; - const patchedDescribe = patchDescribeFn(originalDescribeFn) as Mocha.SuiteFunction; - patchedDescribe.only = patchDescribeFn( - originalDescribeFn.only as CypressSuiteFunction, - ) as Mocha.ExclusiveSuiteFunction; - patchedDescribe.skip = patchDescribeFn(originalDescribeFn.skip as CypressSuiteFunction) as Mocha.PendingSuiteFunction; - globalThis.describe = patchedDescribe; -}; - -const createSuiteDepthCounterState = (): [get: () => number, inc: () => void, dec: () => void] => { - let suiteDepth = 0; - return [ - () => suiteDepth, - () => { - suiteDepth++; - }, - () => { - suiteDepth--; - }, - ]; -}; - -const patchAfter = (getSuiteDepth: () => number) => { - const originalAfter = globalThis.after; - const patchedAfter = (nameOrFn: string | HookImplementation, fn?: HookImplementation): void => { - return typeof nameOrFn === "string" - ? originalAfter(nameOrFn, wrapRootAfterFn(getSuiteDepth, fn)) - : originalAfter(wrapRootAfterFn(getSuiteDepth, nameOrFn)!); - }; - globalThis.after = patchedAfter; -}; - -const wrapRootAfterFn = (getSuiteDepth: () => number, fn?: HookImplementation): HookImplementation | undefined => { - if (getSuiteDepth() === 0 && fn) { - const wrappedFn = fn.length ? wrapAfterFnWithCallback(fn) : wrapAfterFnWithoutArgs(fn as DirectHookImplementation); - Object.defineProperty(wrappedFn, "name", { value: fn.name }); - return wrappedFn; - } - return fn; -}; - -const wrapAfterFnWithCallback = (fn: Mocha.Func): Mocha.Func => { - return function (this: Mocha.Context, done: Mocha.Done) { - const wrappedDone = (hookError?: Error) => { - if (hookError) { - if (!completeSpecOnAfterHookFailure(this, hookError)?.then(() => done(hookError))) { - done(hookError); - } - return; - } - - try { - if (completeSpecIfNoAfterHookLeft(this)?.then(() => done())) { - return; - } - } catch (allureError) { - done(allureError); - return; - } - - done(); - }; - return fn.bind(this)(wrappedDone); - }; -}; - -const wrapAfterFnWithoutArgs = (fn: DirectHookImplementation) => { - return function (this: Mocha.Context) { - let result; - let syncError: any; - - try { - result = fn.bind(this)(); - } catch (e) { - syncError = e; - } - - if (syncError) { - throwAfterSpecCompletion(this, syncError); - } else if (isPromise(result)) { - return result.then( - () => completeSpecIfNoAfterHookLeft(this), - (asyncError) => throwAfterSpecCompletion(this, asyncError), - ); - } else { - completeSpecIfNoAfterHookLeft(this); - return result; - } - }; -}; - -const throwAfterSpecCompletion = (context: Mocha.Context, err: any) => { - const chain = completeSpecOnAfterHookFailure(context, err as Error)?.then(() => { - throw err; - }); - if (!chain) { - throw err; - } -}; - -const logAllureRootAfterError = (context: Mocha.Context, err: unknown) => { - // We play safe and swallow errors here to keep the original 'after all' error. - try { - // eslint-disable-next-line no-console - console.error(`Unexpected error when reporting the failure of ${context.test?.title ?? "'after all'"}`); - // eslint-disable-next-line no-console - console.error(err); - } catch {} -}; - -const isLastRootAfterHook = (context: Mocha.Context) => { - const currentAfterAll = context.test as CypressHook; - const rootSuite = (context.test as CypressHook).parent!; - const hooks = (rootSuite as any).hooks as CypressHook[]; - const lastAfterAll = hooks.findLast((h) => h.hookName === "after all"); - return lastAfterAll?.hookId === currentAfterAll.hookId; -}; diff --git a/packages/allure-cypress/src/model.ts b/packages/allure-cypress/src/types.ts similarity index 67% rename from packages/allure-cypress/src/model.ts rename to packages/allure-cypress/src/types.ts index 7f1aee304..e9ec9f295 100644 --- a/packages/allure-cypress/src/model.ts +++ b/packages/allure-cypress/src/types.ts @@ -1,11 +1,12 @@ -import type { Label, Status, StatusDetails } from "allure-js-commons"; -import type { RuntimeMessage, TestPlanV1 } from "allure-js-commons/sdk"; +import type { Label, Parameter, Status, StatusDetails } from "allure-js-commons"; +import type { + RuntimeMessage, + RuntimeStartStepMessage, + RuntimeStopStepMessage, + TestPlanV1, +} from "allure-js-commons/sdk"; import type { ReporterConfig } from "allure-js-commons/sdk/reporter"; -export const ALLURE_REPORT_SYSTEM_HOOK = "__allure_report_system_hook__"; - -export const ALLURE_REPORT_STEP_COMMAND = "__allure_report_step_command__"; - export type AllureCypressConfig = ReporterConfig & { videoOnFailOnly?: boolean; stepsFromCommands?: Partial; @@ -38,6 +39,31 @@ export type CypressCommand = { state: "passed" | "failed" | "queued"; }; +export type CypressLogEntry = ReturnType & { + attributes: { + id: string; + error?: Error; + name: string; + message: string; + displayName?: string; + event: boolean; + type: string; + instrument: string; + groupStart: boolean; + createdAtTimestamp: number; + updatedAtTimestamp: number; + end?: boolean; + renderProps: () => { + message: string; + }; + consoleProps: () => { + name: string; + props: Record; + }; + }; + endGroup: () => unknown; +}; + export type CupressRunStart = { type: "cypress_run_start"; data: object; @@ -125,34 +151,46 @@ export type CypressTestEndMessage = { }; }; -export type CypressCommandStartMessage = { - type: "cypress_command_start"; +export type CypressStepStartMessage = { + type: "cypress_step_start"; data: { + id: string; name: string; - args: string[]; start: number; }; }; -export type CypressCommandEndMessage = { - type: "cypress_command_end"; +export type CypressStepStopMessage = { + type: "cypress_step_stop"; data: { + id: string; status: Status; statusDetails?: StatusDetails; stop: number; }; }; +export type CypressStepFinalizeMessage = { + type: "cypress_step_finalize"; + data: { + id: string; + name?: string; + parameters?: Parameter[]; + statusDetails?: StatusDetails; + }; +}; + export type CypressMessage = - | RuntimeMessage + | Exclude | CupressRunStart | CypressSuiteStartMessage | CypressSuiteEndMessage | CypressHookStartMessage | CypressHookEndMessage | CypressTestStartMessage - | CypressCommandStartMessage - | CypressCommandEndMessage + | CypressStepStartMessage + | CypressStepStopMessage + | CypressStepFinalizeMessage | CypressTestPassMessage | CypressFailMessage | CypressTestSkipMessage @@ -163,7 +201,7 @@ export type SpecContext = { specPath: string; test: string | undefined; fixture: string | undefined; - commandSteps: string[]; + stepsByFrontEndId: Map; videoScope: string; suiteIdToScope: Map; suiteScopeToId: Map; @@ -173,6 +211,25 @@ export type SpecContext = { failed: boolean; }; +type StepDescriptorBase = { + id: string; + error?: Error; +}; + +export type LogStepDescriptor = StepDescriptorBase & { + type: "log"; + attachmentName?: string; + log: CypressLogEntry; +}; + +export type ApiStepDescriptor = StepDescriptorBase & { + type: "api"; +}; + +export type StepDescriptor = LogStepDescriptor | ApiStepDescriptor; + +export type StepFinalizer = (message: CypressStepFinalizeMessage["data"]) => void; + export type AllureSpecState = { config: { stepsFromCommands: { @@ -185,6 +242,9 @@ export type AllureSpecState = { projectDir?: string; messages: CypressMessage[]; currentTest?: CypressTest; + stepStack: StepDescriptor[]; + stepsToFinalize: [step: StepDescriptor, finalizer: StepFinalizer | undefined][]; + nextApiStepId: number; }; export type AllureCypressTaskArgs = { diff --git a/packages/allure-cypress/src/utils.ts b/packages/allure-cypress/src/utils.ts index 3251bc9df..477f99bc6 100644 --- a/packages/allure-cypress/src/utils.ts +++ b/packages/allure-cypress/src/utils.ts @@ -1,10 +1,3 @@ -import { LabelName, Status } from "allure-js-commons"; -import { extractMetadataFromString, getMessageAndTraceFromError, getStatusFromError } from "allure-js-commons/sdk"; -import type { TestPlanV1 } from "allure-js-commons/sdk"; -import { ALLURE_REPORT_STEP_COMMAND, ALLURE_REPORT_SYSTEM_HOOK } from "./model.js"; -import type { CypressCommand, CypressHook, CypressSuite, CypressTest } from "./model.js"; -import { getAllureTestPlan, getProjectDir } from "./state.js"; - export const DEFAULT_RUNTIME_CONFIG = { stepsFromCommands: { maxArgumentLength: 128, @@ -12,59 +5,24 @@ export const DEFAULT_RUNTIME_CONFIG = { }, }; -export const uint8ArrayToBase64 = (data: unknown) => { - // @ts-ignore - const u8arrayLike = Array.isArray(data) || data.buffer; - - if (!u8arrayLike) { - return data as string; - } - - // eslint-disable-next-line @typescript-eslint/no-unsafe-argument - return btoa(String.fromCharCode.apply(null, data as number[])); -}; - -export const getSuites = (test: CypressTest) => { - const suites: CypressSuite[] = []; - for (let s: CypressSuite | undefined = test.parent; s; s = s.parent) { - suites.push(s); - } - suites.reverse(); - return suites; -}; - -export const getSuitePath = (test: CypressTest): string[] => - getSuites(test) - .filter((s) => s.title) - .map((s) => s.title); - -export const shouldCommandBeSkipped = (command: CypressCommand) => { - if (last(command.attributes.args)?.log === false) { - return true; - } - - if (command.attributes.name === "task" && command.attributes.args[0] === "reportAllureRuntimeMessages") { - return true; - } - - // we don't need to report then commands because it's just a promise handle - if (command.attributes.name === "then") { - return true; - } - - // we should skip artificial wrap from allure steps - if (command.attributes.name === "wrap" && command.attributes.args[0] === ALLURE_REPORT_STEP_COMMAND) { - return true; - } - - return false; +/** + * Pops items from an array into a new one. The item that matches the predicate is the last item to pop. + * If there is no such item in the array, the array is left unmodified. + * @param items An array to pop the items from. + * @param pred A predicate that defines the last item to pop. + * @returns An array of popped items. The first popped item becomes the first element of this array. + */ +export const popUntilFindIncluded = (items: T[], pred: (value: T) => boolean) => { + const index = items.findIndex(pred); + return index !== -1 ? toReversed(items.splice(index)) : []; }; export const toReversed = (arr: T[]): T[] => { - const result: T[] = []; + const len = arr.length; + const result: T[] = new Array(len); - for (let i = arr.length - 1; i >= 0; i--) { - result.push(arr[i]); + for (let i = 0; i < len; i++) { + result[len - i - 1] = arr[i]; } return result; @@ -74,109 +32,4 @@ export const last = (arr: T[]): T | undefined => { return arr[arr.length - 1]; }; -const resolveSpecRelativePath = (spec: Cypress.Spec) => { - const projectDir = getProjectDir(); - const specPath = projectDir ? spec.absolute.substring(projectDir.length + 1) : spec.relative; - const win = Cypress.platform === "win32"; - return win ? specPath.replaceAll("\\", "/") : specPath; -}; - -export const getNamesAndLabels = (spec: Cypress.Spec, test: CypressTest) => { - const rawName = test.title; - const { cleanTitle: name, labels } = extractMetadataFromString(rawName); - const suites = test.titlePath().slice(0, -1); - const fullNameSuffix = `${[...suites, name].join(" ")}`; - return { name, labels, fullNameSuffix }; -}; - -export const getTestStartData = (test: CypressTest) => ({ - ...getNamesAndLabels(Cypress.spec, test), - start: test.wallClockStartedAt?.getTime() || Date.now(), -}); - -export const getTestStopData = (test: CypressTest) => ({ - duration: test.duration ?? 0, - retries: (test as any)._retries ?? 0, -}); - -export const getTestSkipData = () => ({ - statusDetails: { message: "This is a pending test" }, -}); - -export const applyTestPlan = (spec: Cypress.Spec, root: CypressSuite) => { - const testPlan = getAllureTestPlan(); - if (testPlan) { - const specPath = resolveSpecRelativePath(spec); - for (const suite of iterateSuites(root)) { - const indicesToRemove = getIndicesOfDeselectedTests(testPlan, spec, specPath, suite.tests); - removeSortedIndices(suite.tests, indicesToRemove); - } - } -}; - -export const resolveStatusWithDetails = (error: Error | undefined) => - error - ? { - status: getStatusFromError(error), - statusDetails: getMessageAndTraceFromError(error), - } - : { status: Status.PASSED }; - -const testReportedKey = Symbol("The test was reported to Allure"); - -export const markTestAsReported = (test: CypressTest) => { - (test as any)[testReportedKey] = true; -}; - -export const isTestReported = (test: CypressTest) => (test as any)[testReportedKey] === true; - -export const iterateSuites = function* (parent: CypressSuite) { - const suiteStack: CypressSuite[] = []; - for (let s: CypressSuite | undefined = parent; s; s = suiteStack.pop()) { - yield s; - - // Pushing in reverse allows us to maintain depth-first pre-order traversal - - // the same order as used by Mocha & Cypress. - for (let i = s.suites.length - 1; i >= 0; i--) { - suiteStack.push(s.suites[i]); - } - } -}; - -export const iterateTests = function* (parent: CypressSuite) { - for (const suite of iterateSuites(parent)) { - yield* suite.tests; - } -}; - -export const isAllureHook = (hook: CypressHook) => hook.title.includes(ALLURE_REPORT_SYSTEM_HOOK); - -export const isRootAfterAllHook = (hook: CypressHook) => hook.parent!.root && hook.hookName === "after all"; - -const includedInTestPlan = (testPlan: TestPlanV1, fullName: string, allureId: string | undefined): boolean => - testPlan.tests.some((test) => (allureId && test.id?.toString() === allureId) || test.selector === fullName); - -const getIndicesOfDeselectedTests = ( - testPlan: TestPlanV1, - spec: Cypress.Spec, - specPath: string, - tests: readonly CypressTest[], -) => { - const indicesToRemove: number[] = []; - tests.forEach((test, index) => { - const { fullNameSuffix, labels } = getNamesAndLabels(spec, test); - const fullName = `${specPath}#${fullNameSuffix}`; - const allureId = labels.find(({ name }) => name === LabelName.ALLURE_ID)?.value; - - if (!includedInTestPlan(testPlan, fullName, allureId)) { - indicesToRemove.push(index); - } - }); - return indicesToRemove; -}; - -const removeSortedIndices = (arr: T[], indices: readonly number[]) => { - for (let i = indices.length - 1; i >= 0; i--) { - arr.splice(indices[i], 1); - } -}; +export const isDefined = (value: T | undefined): value is T => typeof value !== "undefined"; diff --git a/packages/allure-cypress/test/spec/commands.test.ts b/packages/allure-cypress/test/spec/commands.test.ts index 7f584c544..ec5e0c255 100644 --- a/packages/allure-cypress/test/spec/commands.test.ts +++ b/packages/allure-cypress/test/spec/commands.test.ts @@ -1,221 +1,1019 @@ -import { expect, it } from "vitest"; +/* eslint max-lines: 0 */ +import { beforeAll, describe, expect, it } from "vitest"; import { Stage, Status } from "allure-js-commons"; +import type { TestResult } from "allure-js-commons"; import { issue } from "allure-js-commons"; import { runCypressInlineTest } from "../utils.js"; -it("reports test with cypress command", async () => { +it("should create steps from cypress commands", async () => { const { tests } = await runCypressInlineTest({ "cypress/e2e/sample.cy.js": () => ` - it("with commands", () => { + it("foo", () => { cy.log(1); - cy.log("2"); - cy.log([1, 2, 3]); - cy.log({ foo: 1, bar: 2, baz: 3 }); + cy.wrap(1); + cy.wrap("foo", { log: false }); // this one must be ignored + cy.wrap("bar").should("eq", "bar").and("not.eq", "baz"); // assertion steps are grouped under the "wrap" + cy.wrap("baz", { log: false }).should("eq", "baz").and("not.eq", "bar"); // assertion steps are hoisted }); `, }); - expect(tests).toHaveLength(1); - expect(tests[0].status).toBe(Status.PASSED); - expect(tests[0].stage).toBe(Stage.FINISHED); - expect(tests[0].steps).toHaveLength(4); - expect(tests[0].steps).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - name: String.raw`Command "log"`, - parameters: expect.arrayContaining([ - expect.objectContaining({ - name: String.raw`Argument [0]`, - value: JSON.stringify(1), - }), - ]), - }), - expect.objectContaining({ - name: String.raw`Command "log"`, - parameters: expect.arrayContaining([ - expect.objectContaining({ - name: String.raw`Argument [0]`, - value: "2", - }), - ]), - }), - expect.objectContaining({ - name: String.raw`Command "log"`, - parameters: expect.arrayContaining([ - expect.objectContaining({ - name: String.raw`Argument [0]`, - value: JSON.stringify([1, 2, 3]), - }), - ]), - }), - expect.objectContaining({ - name: String.raw`Command "log"`, - parameters: expect.arrayContaining([ - expect.objectContaining({ - name: String.raw`Argument [0]`, - value: JSON.stringify({ foo: 1, bar: 2, baz: 3 }), - }), - ]), - }), - ]), - ); -}); - -it("doesn't report cypress command when they shouldn't be reported", async () => { - const { tests } = await runCypressInlineTest({ - "cypress/e2e/sample.cy.js": () => ` - it("with commands", () => { - cy.log(1, { log: false }); - cy.log("2", { log: false }); - cy.log([1, 2, 3], { log: false }); - cy.log({ foo: 1, bar: 2, baz: 3 }, { log: false }); - }); - `, - }); - - expect(tests).toHaveLength(1); - expect(tests[0].status).toBe(Status.PASSED); - expect(tests[0].stage).toBe(Stage.FINISHED); - expect(tests[0].steps).toHaveLength(0); -}); - -it("should impose limits on command arguments", async () => { - issue("1070"); - const { tests } = await runCypressInlineTest({ - "cypress/e2e/sample.cy.js": () => ` - it("foo", () => { - const obj1 = {}; - obj1.ref = obj1; // should remove a direct circular reference 'ref' - cy.wrap(obj1); - - const sibling = {}; - cy.wrap({ ref: { foo: sibling, bar: sibling } }); // it's okay to have the same object on different paths - - const obj2 = { ref: {} }; - obj2.ref.ref = obj2; - cy.wrap(obj2); // should remove an indirect circular reference 'ref.ref' - - cy.wrap("A".repeat(1000)); // should truncate string values - cy.wrap(Array(1000).fill("A")); // should truncate objects - cy.wrap({ foo: { bar: { baz: {}, qux: "qut" } } }) // should remove 'baz' because it creates nesting level 4 - }); - `, - }); - expect(tests).toEqual([ expect.objectContaining({ + name: "foo", + status: Status.PASSED, + stage: Stage.FINISHED, steps: [ expect.objectContaining({ + name: "log 1", + status: Status.PASSED, + stage: Stage.FINISHED, parameters: [ { - name: "Argument [0]", - value: JSON.stringify({}), + name: "message", + value: "1", }, ], + steps: [], }), expect.objectContaining({ + name: "wrap 1", + status: Status.PASSED, + stage: Stage.FINISHED, parameters: [ { - name: "Argument [0]", - value: JSON.stringify({ ref: { foo: {}, bar: {} } }), + name: "Yielded", + value: "1", }, ], + steps: [], }), expect.objectContaining({ + name: "wrap bar", + status: Status.PASSED, + stage: Stage.FINISHED, parameters: [ { - name: "Argument [0]", - value: JSON.stringify({ ref: {} }), + name: "Yielded", + value: "bar", }, ], + steps: [ + expect.objectContaining({ + name: "assert expected bar to equal bar", + status: Status.PASSED, + stage: Stage.FINISHED, + parameters: [ + { + name: "actual", + value: "bar", + }, + { + name: "expected", + value: "bar", + }, + ], + steps: [], + }), + expect.objectContaining({ + name: "assert expected bar to not equal baz", + status: Status.PASSED, + stage: Stage.FINISHED, + parameters: [ + { + name: "actual", + value: "bar", + }, + { + name: "expected", + value: "baz", + }, + ], + steps: [], + }), + ], }), expect.objectContaining({ + name: "assert expected baz to equal baz", + status: Status.PASSED, + stage: Stage.FINISHED, parameters: [ { - name: "Argument [0]", - value: `${"A".repeat(128)}...`, + name: "actual", + value: "baz", }, - ], - }), - expect.objectContaining({ - parameters: [ { - name: "Argument [0]", - value: `[${String.raw`"A",`.repeat(31)}"A"...`, + name: "expected", + value: "baz", }, ], + steps: [], }), expect.objectContaining({ + name: "assert expected baz to not equal bar", + status: Status.PASSED, + stage: Stage.FINISHED, parameters: [ { - name: "Argument [0]", - value: JSON.stringify({ foo: { bar: { qux: "qut" } } }), + name: "actual", + value: "baz", + }, + { + name: "expected", + value: "bar", }, ], + steps: [], }), ], }), ]); }); -it("should take the limits from the config", async () => { - issue("1070"); - const { tests } = await runCypressInlineTest({ - "cypress/e2e/sample.cy.js": () => ` +describe("parameter serialization", () => { + it("should convert parameter values to JSON strings", async () => { + const { tests } = await runCypressInlineTest({ + "cypress/e2e/sample.cy.js": () => ` it("foo", () => { - cy.wrap("A".repeat(100)); // should truncate string values - cy.wrap(Array(100).fill("A")); // should truncate objects - cy.wrap({ foo: { bar: { }, baz: "qux" } }) // should remove 'bar' that creates nesting level 3 but keep 'baz' + cy.log(1); + cy.log("1"); + cy.log([1, 2, 3]); + cy.log({ foo: 1, bar: 2, baz: 3 }); }); `, - "cypress.config.js": ({ allureCypressReporterModulePath }) => ` - const { allureCypress } = require("${allureCypressReporterModulePath}"); - - module.exports = { - e2e: { - baseUrl: "https://allurereport.org", - viewportWidth: 1240, - setupNodeEvents: (on, config) => { - allureCypress(on, config, { - stepsFromCommands: { - maxArgumentLength: 25, - maxArgumentDepth: 2, - } - }); + }); + + expect(tests).toEqual([ + expect.objectContaining({ + name: "foo", + status: Status.PASSED, + steps: [ + expect.objectContaining({ + name: "log 1", + parameters: [ + { + name: "message", + value: "1", + }, + ], + }), + expect.objectContaining({ + name: "log 1", + parameters: [ + { + name: "message", + value: "1", + }, + ], + }), + expect.objectContaining({ + name: "log [1, 2, 3]", + parameters: [ + { + name: "message", + value: "[1,2,3]", + }, + ], + }), + expect.objectContaining({ + name: "log Object{3}", + parameters: [ + { + name: "message", + value: String.raw`{"foo":1,"bar":2,"baz":3}`, + }, + ], + }), + ], + }), + ]); + }); + + it("should impose limits on command arguments", async () => { + issue("1070"); + const { tests } = await runCypressInlineTest({ + "cypress/e2e/sample.cy.js": () => ` + it("foo", () => { + const obj1 = {}; + obj1.ref = obj1; // should remove a direct circular reference 'ref' + cy.wrap(obj1); + + const sibling = {}; + cy.wrap({ ref: { foo: sibling, bar: sibling } }); // it's okay to have the same object on different paths + + const obj2 = { ref: {} }; + obj2.ref.ref = obj2; + cy.wrap(obj2); // should remove an indirect circular reference 'ref.ref' + + cy.wrap("A".repeat(1000)); // should truncate string values + cy.wrap(Array(1000).fill("A")); // should truncate objects + cy.wrap({ foo: { bar: { baz: {}, qux: "qut" } } }) // should remove 'baz' because it creates nesting level 4 + }); + `, + }); + + expect(tests).toEqual([ + expect.objectContaining({ + steps: [ + expect.objectContaining({ + parameters: [ + { + name: "Yielded", + value: JSON.stringify({}), + }, + ], + }), + expect.objectContaining({ + parameters: [ + { + name: "Yielded", + value: JSON.stringify({ ref: { foo: {}, bar: {} } }), + }, + ], + }), + expect.objectContaining({ + parameters: [ + { + name: "Yielded", + value: JSON.stringify({ ref: {} }), + }, + ], + }), + expect.objectContaining({ + parameters: [ + { + name: "Yielded", + value: `${"A".repeat(128)}...`, + }, + ], + }), + expect.objectContaining({ + parameters: [ + { + name: "Yielded", + value: `[${String.raw`"A",`.repeat(31)}"A"...`, + }, + ], + }), + expect.objectContaining({ + parameters: [ + { + name: "Yielded", + value: JSON.stringify({ foo: { bar: { qux: "qut" } } }), + }, + ], + }), + ], + }), + ]); + }); - return config; + it("should take the limits from the config", async () => { + issue("1070"); + const { tests } = await runCypressInlineTest({ + "cypress/e2e/sample.cy.js": () => ` + it("foo", () => { + cy.wrap("A".repeat(100)); // should truncate string values + cy.wrap(Array(100).fill("A")); // should truncate objects + cy.wrap({ foo: { bar: { }, baz: "qux" } }) // should remove 'bar' that creates nesting level 3 but keep 'baz' + }); + `, + "cypress.config.js": ({ allureCypressReporterModulePath }) => ` + const { allureCypress } = require("${allureCypressReporterModulePath}"); + + module.exports = { + e2e: { + baseUrl: "https://allurereport.org", + viewportWidth: 1240, + setupNodeEvents: (on, config) => { + allureCypress(on, config, { + stepsFromCommands: { + maxArgumentLength: 25, + maxArgumentDepth: 2, + } + }); + + return config; + }, }, - }, - }; - `, + }; + `, + }); + + expect(tests).toEqual([ + expect.objectContaining({ + steps: [ + expect.objectContaining({ + parameters: [ + { + name: "Yielded", + value: `${"A".repeat(25)}...`, + }, + ], + }), + expect.objectContaining({ + parameters: [ + { + name: "Yielded", + value: `[${String.raw`"A",`.repeat(6)}...`, + }, + ], + }), + expect.objectContaining({ + parameters: [ + { + name: "Yielded", + value: JSON.stringify({ foo: { baz: "qux" } }), + }, + ], + }), + ], + }), + ]); + }); +}); + +it("should create steps from log groups", async () => { + const { tests } = await runCypressInlineTest({ + "cypress/e2e/sample.cy.js": () => ` + it("foo", () => { + const log = Cypress.log({ + name: "bar", + message: "baz", + groupStart: true, + type: "parent", + }); + log.endGroup(); + }); + `, }); expect(tests).toEqual([ expect.objectContaining({ + status: Status.PASSED, + stage: Stage.FINISHED, steps: [ expect.objectContaining({ - parameters: [ + name: "bar baz", + status: Status.PASSED, + parameters: [], + }), + ], + }), + ]); +}); + +it("should nest command steps in log group steps", async () => { + const { tests } = await runCypressInlineTest({ + "cypress/e2e/sample.cy.js": () => ` + it("foo", () => { + const log = Cypress.log({ + name: "bar", + message: "baz", + groupStart: true, + type: "parent", + }); + cy.log("qux"); + cy.wrap("qut").should("eq", "qut").and("not.eq", "qat").then(() => { + log.endGroup(); + }); + }); + `, + }); + + expect(tests).toEqual([ + expect.objectContaining({ + status: Status.PASSED, + stage: Stage.FINISHED, + steps: [ + expect.objectContaining({ + name: "bar baz", + status: Status.PASSED, + parameters: [], + steps: [ + expect.objectContaining({ + name: "log qux", + status: Status.PASSED, + parameters: [ + { + name: "message", + value: "qux", + }, + ], + }), + expect.objectContaining({ + name: "wrap qut", + status: Status.PASSED, + parameters: [ + { + name: "Yielded", + value: "qut", + }, + ], + steps: [ + expect.objectContaining({ + name: "assert expected qut to equal qut", + status: Status.PASSED, + parameters: [ + { + name: "actual", + value: "qut", + }, + { + name: "expected", + value: "qut", + }, + ], + }), + expect.objectContaining({ + name: "assert expected qut to not equal qat", + status: Status.PASSED, + parameters: [ + { + name: "actual", + value: "qut", + }, + { + name: "expected", + value: "qat", + }, + ], + }), + ], + }), + ], + }), + ], + }), + ]); +}); + +describe("mixed steps", () => { + let test: TestResult; + + beforeAll(async () => { + const { tests } = await runCypressInlineTest({ + "cypress/e2e/sample.cy.js": ({ allureCommonsModulePath }) => ` + import { step } from "${allureCommonsModulePath}"; + it("foo", () => { + const log = Cypress.log({ + name: "bar", + message: "baz", + groupStart: true, + type: "parent", + }); + cy.then(() => new Cypress.Promise((r) => setTimeout(r, 1))); + cy.log("qux"); + cy.then(() => new Cypress.Promise((r) => setTimeout(r, 1))); + cy.wrap("qut").should((r) => new Cypress.Promise((resolve) => setTimeout(() => resolve(r), 1)).then((r) => { + expect(r).eq("qut"); + })).and((r) => new Cypress.Promise((resolve) => setTimeout(() => resolve(r), 1)).then((r) => { + expect(r).not.eq("qat"); + })); + cy.then(() => new Cypress.Promise((r) => setTimeout(r, 1))); + step("ztu", () => new Cypress.Promise((r) => setTimeout(r, 1))).then(() => { + log.endGroup(); + }); + }); + `, + }); + + test = tests[0]; + }); + + it("should stop command log steps and keep groups before starting a lambda step", () => { + expect(test).toEqual( + expect.objectContaining({ + status: Status.PASSED, + stage: Stage.FINISHED, + steps: [ + expect.objectContaining({ + name: "bar baz", + status: Status.PASSED, + parameters: [], + steps: [ + expect.objectContaining({ + name: "log qux", + status: Status.PASSED, + parameters: [ + { + name: "message", + value: "qux", + }, + ], + }), + expect.objectContaining({ + name: "wrap qut", + status: Status.PASSED, + parameters: [ + { + name: "Yielded", + value: "qut", + }, + ], + steps: [ + expect.objectContaining({ + name: "assert expected qut to equal qut", + status: Status.PASSED, + parameters: [ + { + name: "actual", + value: "qut", + }, + { + name: "expected", + value: "qut", + }, + ], + }), + expect.objectContaining({ + name: "assert expected qut to not equal qat", + status: Status.PASSED, + parameters: [ + { + name: "actual", + value: "qut", + }, + { + name: "expected", + value: "qat", + }, + ], + }), + ], + }), + expect.objectContaining({ + name: "ztu", + status: Status.PASSED, + parameters: [], + }), + ], + }), + ], + }), + ); + }); + + it("should keep the timings consistent", () => { + const { + start: testStart, + stop: testStop, + steps: [ + { + start: logGroupStart, + stop: logGroupStop, + steps: [ + { start: logStart, stop: logStop }, { - name: "Argument [0]", - value: `${"A".repeat(25)}...`, + start: wrapStart, + stop: wrapStop, + steps: [{ start: assertEqStart, stop: assertEqStop }, { start: assertNeqStart, stop: assertNeqStop }], }, + { start: apiStepStart, stop: apiStepStop }, + ], + }, + ], + } = test; + + const expectedOrder = [ + testStart, + logGroupStart, + logStart, + logStop, + wrapStart, + assertEqStart, + assertEqStop, + assertNeqStart, + assertNeqStop, + wrapStop, + apiStepStart, + apiStepStop, + logGroupStop, + testStop, + ]; + + expect(expectedOrder.toSorted()).toEqual(expectedOrder); + }); +}); + +describe("failed and broken tests with mixed steps", () => { + let failedTest: TestResult; + let brokenTest: TestResult; + + beforeAll(async () => { + const { tests } = await runCypressInlineTest({ + "cypress/e2e/sample.cy.js": ({ allureCommonsModulePath }) => ` + import { step } from "${allureCommonsModulePath}"; + it("failed test", () => { + step("api step 1", () => { + const log = Cypress.log({ + name: "log", + message: "group step 1", + groupStart: true, + type: "parent", + }); + step("api step 2", () => { + cy.log("foo"); + cy.wrap("bar", { timeout: 0 }).should("eq", "baz").and("not.eq", "baz"); + }).then(() => { + log.endGroup(); + }); + }); + }); + + it("broken test", () => { + step("api step 1", () => { + const log = Cypress.log({ + name: "log", + message: "group step 1", + groupStart: true, + type: "parent", + }); + step("api step 2", () => { + cy.log("foo"); + cy.wrap("bar").should("eq", "bar").then(() => { + return step("api step 3", () => { + throw new Error("baz"); + }); + }); + }).then(() => { + log.endGroup(); + }); + }); + }); + `, + }); + + failedTest = tests.find((t) => t.name === "failed test")!; + brokenTest = tests.find((t) => t.name === "broken test")!; + }); + + it("should stop all steps with correct statuses", () => { + expect(failedTest).toMatchObject({ + status: Status.FAILED, + stage: Stage.FINISHED, + steps: [ + expect.objectContaining({ + name: "api step 1", + status: Status.FAILED, + stage: Stage.FINISHED, + statusDetails: expect.objectContaining({ + message: expect.stringContaining("expected 'bar' to equal 'baz'"), + trace: expect.stringContaining("sample.cy.js"), + }), + parameters: [], + steps: [ + expect.objectContaining({ + name: "log group step 1", + status: Status.FAILED, + stage: Stage.FINISHED, + statusDetails: expect.objectContaining({ + message: expect.stringContaining("expected 'bar' to equal 'baz'"), + trace: expect.stringContaining("sample.cy.js"), + }), + parameters: [], + steps: [ + expect.objectContaining({ + name: "api step 2", + status: Status.FAILED, + stage: Stage.FINISHED, + parameters: [], + steps: [ + expect.objectContaining({ + name: "log foo", + status: Status.PASSED, + stage: Stage.FINISHED, + parameters: [ + { + name: "message", + value: "foo", + }, + ], + steps: [], + }), + expect.objectContaining({ + name: "wrap bar", + status: Status.FAILED, + stage: Stage.FINISHED, + statusDetails: expect.objectContaining({ + message: expect.stringContaining("expected 'bar' to equal 'baz'"), + trace: expect.stringContaining("sample.cy.js"), + }), + parameters: [], + steps: [ + expect.objectContaining({ + name: "assert expected bar to equal baz", + status: Status.FAILED, + stage: Stage.FINISHED, + statusDetails: expect.objectContaining({ + message: expect.stringContaining("expected 'bar' to equal 'baz'"), + trace: expect.stringContaining("sample.cy.js"), + }), + parameters: [ + { + name: "actual", + value: "bar", + }, + { + name: "expected", + value: "baz", + }, + ], + steps: [], + }), + ], + }), + ], + }), + ], + }), ], }), + ], + }); + + expect(brokenTest).toMatchObject({ + status: Status.BROKEN, + stage: Stage.FINISHED, + steps: [ expect.objectContaining({ - parameters: [ + name: "api step 1", + status: Status.BROKEN, + stage: Stage.FINISHED, + statusDetails: expect.objectContaining({ + message: "baz", + trace: expect.stringContaining("sample.cy.js"), + }), + steps: [ + expect.objectContaining({ + name: "log group step 1", + status: Status.BROKEN, + stage: Stage.FINISHED, + statusDetails: expect.objectContaining({ + message: "baz", + trace: expect.stringContaining("sample.cy.js"), + }), + parameters: [], + steps: [ + expect.objectContaining({ + name: "api step 2", + status: Status.BROKEN, + stage: Stage.FINISHED, + statusDetails: expect.objectContaining({ + message: "baz", + trace: expect.stringContaining("sample.cy.js"), + }), + parameters: [], + steps: [ + expect.objectContaining({ + name: "log foo", + status: Status.PASSED, + stage: Stage.FINISHED, + parameters: [ + { + name: "message", + value: "foo", + }, + ], + }), + expect.objectContaining({ + name: "wrap bar", + status: Status.PASSED, + stage: Stage.FINISHED, + parameters: [ + { + name: "Yielded", + value: "bar", + }, + ], + steps: [ + expect.objectContaining({ + name: "assert expected bar to equal bar", + status: Status.PASSED, + stage: Stage.FINISHED, + parameters: [ + { + name: "actual", + value: "bar", + }, + { + name: "expected", + value: "bar", + }, + ], + }), + ], + }), + expect.objectContaining({ + name: "api step 3", + status: Status.BROKEN, + stage: Stage.FINISHED, + statusDetails: expect.objectContaining({ + message: "baz", + trace: expect.stringContaining("sample.cy.js"), + }), + parameters: [], + steps: [], + }), + ], + }), + ], + }), + ], + }), + ], + }); + }); + + it("should keep the timings consistent", () => { + const { + start: testStartFailed, + stop: testStopFailed, + steps: [ + { + start: apiStep1StartFailed, + stop: apiStep1StopFailed, + steps: [ { - name: "Argument [0]", - value: `[${String.raw`"A",`.repeat(6)}...`, + start: logGroupStartFailed, + stop: logGroupStopFailed, + steps: [ + { + start: apiStep2StartFailed, + stop: apiStep2StopFailed, + steps: [ + { start: logStartFailed, stop: logStopFailed }, + { + start: wrapStartFailed, + stop: wrapStopFailed, + steps: [{ start: assertStartFailed, stop: assertStopFailed }], + }, + ], + }, + ], }, ], - }), - expect.objectContaining({ - parameters: [ + }, + ], + } = failedTest; + const { + start: testStartBroken, + stop: testStopBroken, + steps: [ + { + start: apiStep1StartBroken, + stop: apiStep1StopBroken, + steps: [ { - name: "Argument [0]", - value: JSON.stringify({ foo: { baz: "qux" } }), + start: logGroupStartBroken, + stop: logGroupStopBroken, + steps: [ + { + start: apiStep2StartBroken, + stop: apiStep2StopBroken, + steps: [ + { start: logStartBroken, stop: logStopBroken }, + { + start: wrapStartBroken, + stop: wrapStopBroken, + steps: [{ start: assertStartBroken, stop: assertStopBroken }], + }, + { start: apiStep3StartBroken, stop: apiStep3StopBroken }, + ], + }, + ], }, ], + }, + ], + } = brokenTest; + + const expectedOrder = [ + testStartFailed, + apiStep1StartFailed, + logGroupStartFailed, + apiStep2StartFailed, + logStartFailed, + logStopFailed, + wrapStartFailed, + assertStartFailed, + assertStopFailed, + wrapStopFailed, + apiStep2StopFailed, + logGroupStopFailed, + apiStep1StopFailed, + testStopFailed, + testStartBroken, + apiStep1StartBroken, + logGroupStartBroken, + apiStep2StartBroken, + logStartBroken, + logStopBroken, + wrapStartBroken, + assertStartBroken, + assertStopBroken, + wrapStopBroken, + apiStep3StartBroken, + apiStep3StopBroken, + apiStep2StopBroken, + logGroupStopBroken, + apiStep1StopBroken, + testStopBroken, + ]; + + expect(expectedOrder.toSorted()).toEqual(expectedOrder); + }); +}); + +it("should support commands in hooks", async () => { + const { tests, groups } = await runCypressInlineTest({ + "cypress/e2e/sample.cy.js": () => ` + beforeEach(() => { + const log = Cypress.log({ + name: "log", + message: "step", + groupStart: true, + type: "parent", + }); + cy.log("foo"); + cy.wrap("bar").should("eq", "bar").and("not.eq", "baz").then(() => { + log.endGroup(); + }); + cy.log("qux"); + }); + + it("qut", () => {}); + `, + }); + + expect(tests).toEqual([ + expect.objectContaining({ + name: "qut", + status: Status.PASSED, + stage: Stage.FINISHED, + steps: [], + }), + ]); + expect(groups).toEqual([ + expect.objectContaining({ + children: [tests[0].uuid], + befores: [ + expect.objectContaining({ + name: String.raw`"before each" hook`, + status: Status.PASSED, + stage: Stage.FINISHED, + steps: [ + expect.objectContaining({ + name: "log step", + status: Status.PASSED, + stage: Stage.FINISHED, + parameters: [], + steps: [ + expect.objectContaining({ + name: "log foo", + status: Status.PASSED, + stage: Stage.FINISHED, + parameters: [{ name: "message", value: "foo" }], + steps: [], + }), + expect.objectContaining({ + name: "wrap bar", + status: Status.PASSED, + stage: Stage.FINISHED, + parameters: [{ name: "Yielded", value: "bar" }], + steps: [ + expect.objectContaining({ + name: "assert expected bar to equal bar", + status: Status.PASSED, + stage: Stage.FINISHED, + parameters: [ + { name: "actual", value: "bar" }, + { name: "expected", value: "bar" }, + ], + steps: [], + }), + expect.objectContaining({ + name: "assert expected bar to not equal baz", + status: Status.PASSED, + stage: Stage.FINISHED, + parameters: [ + { name: "actual", value: "bar" }, + { name: "expected", value: "baz" }, + ], + steps: [], + }), + ], + }), + ], + }), + expect.objectContaining({ + name: "log qux", + status: Status.PASSED, + stage: Stage.FINISHED, + steps: [], + parameters: [{ name: "message", value: "qux" }], + }), + ], }), ], }), diff --git a/packages/allure-cypress/test/spec/runtime/legacy/steps.test.ts b/packages/allure-cypress/test/spec/runtime/legacy/steps.test.ts index 318766f13..16f88159c 100644 --- a/packages/allure-cypress/test/spec/runtime/legacy/steps.test.ts +++ b/packages/allure-cypress/test/spec/runtime/legacy/steps.test.ts @@ -110,7 +110,7 @@ it("step with screenshot", async () => { it("manual", () => { step("foo", () => { - cy.screenshot("foo"); + cy.screenshot("bar"); }); }); `, @@ -124,10 +124,10 @@ it("step with screenshot", async () => { name: "foo", steps: expect.arrayContaining([ expect.objectContaining({ - name: String.raw`Command "screenshot"`, + name: "bar", attachments: expect.arrayContaining([ expect.objectContaining({ - name: "foo", + name: "bar", type: ContentType.PNG, }), ]), diff --git a/packages/allure-cypress/test/spec/runtime/modern/steps.test.ts b/packages/allure-cypress/test/spec/runtime/modern/steps.test.ts index 058a70f0d..f409a6358 100644 --- a/packages/allure-cypress/test/spec/runtime/modern/steps.test.ts +++ b/packages/allure-cypress/test/spec/runtime/modern/steps.test.ts @@ -110,7 +110,7 @@ it("step with screenshot", async () => { it("manual", () => { step("foo", () => { - cy.screenshot("foo"); + cy.screenshot("bar"); }); }); `, @@ -124,10 +124,10 @@ it("step with screenshot", async () => { name: "foo", steps: expect.arrayContaining([ expect.objectContaining({ - name: String.raw`Command "screenshot"`, + name: "bar", attachments: expect.arrayContaining([ expect.objectContaining({ - name: "foo", + name: "bar", type: ContentType.PNG, }), ]), diff --git a/packages/allure-cypress/test/spec/screenshots.test.ts b/packages/allure-cypress/test/spec/screenshots.test.ts index 878de886a..3c4cec338 100644 --- a/packages/allure-cypress/test/spec/screenshots.test.ts +++ b/packages/allure-cypress/test/spec/screenshots.test.ts @@ -1,5 +1,5 @@ import { expect, it } from "vitest"; -import { ContentType, Status } from "allure-js-commons"; +import { ContentType, Stage, Status } from "allure-js-commons"; import { runCypressInlineTest } from "../utils.js"; it("attaches screenshots for failed specs", async () => { @@ -11,26 +11,42 @@ it("attaches screenshots for failed specs", async () => { `, }); - expect(tests).toHaveLength(1); - expect(tests[0].steps).toHaveLength(1); - expect(tests[0].steps).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - name: String.raw`Command "wrap"`, - status: Status.FAILED, - attachments: [ - expect.objectContaining({ - name: "Screenshot", - type: ContentType.PNG, - }), - ], - }), - ]), - ); + expect(tests).toEqual([ + expect.objectContaining({ + name: "failed", + status: Status.FAILED, + stage: Stage.FINISHED, + steps: [ + expect.objectContaining({ + name: "wrap 1", + status: Status.FAILED, + stage: Stage.FINISHED, + parameters: [], + steps: [ + expect.objectContaining({ + name: "assert expected 1 to equal 2", + status: Status.FAILED, + stage: Stage.FINISHED, + parameters: [ + { name: "actual", value: "1" }, + { name: "expected", value: "2" }, + ], + }), + ], + }), + ], + attachments: [ + expect.objectContaining({ + name: "failed (failed).png", + type: ContentType.PNG, + }), + ], + }), + ]); - const [attachment] = tests[0].steps[0].attachments; + const source = tests[0].attachments[0].source; - expect(attachments).toHaveProperty(attachment.source); + expect(attachments).toHaveProperty(source); }); it("attaches runtime screenshots", async () => { @@ -38,27 +54,58 @@ it("attaches runtime screenshots", async () => { "cypress/e2e/sample.cy.js": () => ` it("manual", () => { cy.screenshot("foo"); + cy.screenshot(); }); `, }); - expect(tests).toHaveLength(1); - expect(tests[0].steps).toHaveLength(1); - expect(tests[0].steps).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - name: String.raw`Command "screenshot"`, - attachments: [ - expect.objectContaining({ - name: "foo", - type: ContentType.PNG, - }), - ], - }), - ]), - ); + expect(tests).toEqual([ + expect.objectContaining({ + name: "manual", + status: Status.PASSED, + stage: Stage.FINISHED, + steps: [ + expect.objectContaining({ + name: "foo", + status: Status.PASSED, + stage: Stage.FINISHED, + steps: [], + attachments: [ + expect.objectContaining({ + name: "foo", + type: ContentType.PNG, + }), + ], + }), + expect.objectContaining({ + name: "manual.png", + status: Status.PASSED, + stage: Stage.FINISHED, + steps: [], + attachments: [ + expect.objectContaining({ + name: "manual.png", + type: ContentType.PNG, + }), + ], + }), + ], + }), + ]); - const [attachment] = tests[0].steps[0].attachments; + const [ + { + steps: [ + { + attachments: [{ source: source1 }], + }, + { + attachments: [{ source: source2 }], + }, + ], + }, + ] = tests; - expect(attachments).toHaveProperty(attachment.source); + expect(attachments).toHaveProperty(source1); + expect(attachments).toHaveProperty(source2); }); diff --git a/packages/allure-cypress/test/utils.ts b/packages/allure-cypress/test/utils.ts index beca31d53..a6530e88e 100644 --- a/packages/allure-cypress/test/utils.ts +++ b/packages/allure-cypress/test/utils.ts @@ -83,11 +83,10 @@ export const runCypressInlineTest = async ( const allureCypressModulePath = require.resolve("allure-cypress"); const allureCypressReporterModulePath = require.resolve("allure-cypress/reporter"); - // eslint-disable-next-line guard-for-in - for (const testFile in testFilesToWrite) { + for (const [testFile, getContent] of Object.entries(testFilesToWrite)) { const fileDir = dirname(join(testDir, testFile)); await mkdir(fileDir, { recursive: true }); - const content = testFilesToWrite[testFile]({ + const content = getContent({ allureCommonsModulePath: getPosixPath(relative(fileDir, allureCommonsModulePath)), allureCypressModulePath: getPosixPath(relative(fileDir, allureCypressModulePath)), allureCypressReporterModulePath: getPosixPath(relative(fileDir, allureCypressReporterModulePath)), From b7e32c34ba8f6f1499023696a4c89c63617d4958 Mon Sep 17 00:00:00 2001 From: Maksim Stepanov <17935127+delatrie@users.noreply.github.com> Date: Tue, 3 Dec 2024 18:17:25 +0700 Subject: [PATCH 3/6] test(cypress): toSorted -> sort (node 18 compat) --- packages/allure-cypress/test/spec/commands.test.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/allure-cypress/test/spec/commands.test.ts b/packages/allure-cypress/test/spec/commands.test.ts index ec5e0c255..0d2b0dc72 100644 --- a/packages/allure-cypress/test/spec/commands.test.ts +++ b/packages/allure-cypress/test/spec/commands.test.ts @@ -591,8 +591,9 @@ describe("mixed steps", () => { logGroupStop, testStop, ]; + const actualOrder = [...expectedOrder].sort(); - expect(expectedOrder.toSorted()).toEqual(expectedOrder); + expect(actualOrder).toEqual(expectedOrder); }); }); @@ -920,8 +921,9 @@ describe("failed and broken tests with mixed steps", () => { apiStep1StopBroken, testStopBroken, ]; + const actualOrder = [...expectedOrder].sort(); - expect(expectedOrder.toSorted()).toEqual(expectedOrder); + expect(actualOrder).toEqual(expectedOrder); }); }); From 8a53c4e385d85becb167409b557d292a2e8c5b91 Mon Sep 17 00:00:00 2001 From: Maksim Stepanov <17935127+delatrie@users.noreply.github.com> Date: Tue, 3 Dec 2024 20:57:35 +0700 Subject: [PATCH 4/6] test(cypress): increase hookTimeout to 45s --- packages/allure-cypress/vitest.config.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/allure-cypress/vitest.config.ts b/packages/allure-cypress/vitest.config.ts index 445f68681..e05d71626 100644 --- a/packages/allure-cypress/vitest.config.ts +++ b/packages/allure-cypress/vitest.config.ts @@ -5,6 +5,7 @@ export default defineConfig({ dir: "./test/spec", fileParallelism: false, testTimeout: 45000, + hookTimeout: 45000, setupFiles: ["./vitest-setup.ts"], reporters: ["default", ["allure-vitest/reporter", { resultsDir: "./out/allure-results", From ca859622a8e163bd155b258aa466dab572dde367 Mon Sep 17 00:00:00 2001 From: Maksim Stepanov <17935127+delatrie@users.noreply.github.com> Date: Thu, 5 Dec 2024 16:14:02 +0700 Subject: [PATCH 5/6] refactor(cypress): make depth counter state an object --- .../allure-cypress/src/browser/patching.ts | 32 +++++++++++-------- 1 file changed, 19 insertions(+), 13 deletions(-) diff --git a/packages/allure-cypress/src/browser/patching.ts b/packages/allure-cypress/src/browser/patching.ts index 6bbc7bc93..e754bbcff 100644 --- a/packages/allure-cypress/src/browser/patching.ts +++ b/packages/allure-cypress/src/browser/patching.ts @@ -6,38 +6,44 @@ import { throwAfterSpecCompletion, } from "./lifecycle.js"; +type SuiteDepthCounter = { + getSuiteDepth: () => number; + incrementSuiteDepth: () => void; + decrementSuiteDepth: () => void; +}; + /** * Patches the `after` function, to inject reporting of spec-level * `after` hooks defined by the user. */ export const enableScopeLevelAfterHookReporting = () => { - const [getSuiteDepth, incSuiteDepth, decSuiteDepth] = createSuiteDepthCounterState(); - patchDescribe(incSuiteDepth, decSuiteDepth); - patchAfter(getSuiteDepth); + const suiteDepthCounter = createSuiteDepthCounterState(); + patchDescribe(suiteDepthCounter); + patchAfter(suiteDepthCounter); }; -const createSuiteDepthCounterState = (): [get: () => number, inc: () => void, dec: () => void] => { +const createSuiteDepthCounterState = (): SuiteDepthCounter => { let suiteDepth = 0; - return [ - () => suiteDepth, - () => { + return { + getSuiteDepth: () => suiteDepth, + incrementSuiteDepth: () => { suiteDepth++; }, - () => { + decrementSuiteDepth: () => { suiteDepth--; }, - ]; + }; }; -const patchDescribe = (incSuiteDepth: () => void, decSuiteDepth: () => void) => { +const patchDescribe = ({ incrementSuiteDepth, decrementSuiteDepth }: SuiteDepthCounter) => { const patchDescribeFn = (target: CypressSuiteFunction): CypressSuiteFunction => (title, configOrFn, fn) => { - incSuiteDepth(); + incrementSuiteDepth(); try { return forwardDescribeCall(target, title, configOrFn, fn); } finally { - decSuiteDepth(); + decrementSuiteDepth(); } }; const originalDescribeFn: Mocha.SuiteFunction = globalThis.describe; @@ -49,7 +55,7 @@ const patchDescribe = (incSuiteDepth: () => void, decSuiteDepth: () => void) => globalThis.describe = patchedDescribe; }; -const patchAfter = (getSuiteDepth: () => number) => { +const patchAfter = ({ getSuiteDepth }: SuiteDepthCounter) => { const originalAfter = globalThis.after; const patchedAfter = (nameOrFn: string | HookImplementation, fn?: HookImplementation): void => { return typeof nameOrFn === "string" From f93bfa3a1fba614d687ff5bc04118eee4551b8c4 Mon Sep 17 00:00:00 2001 From: Maksim Stepanov <17935127+delatrie@users.noreply.github.com> Date: Thu, 5 Dec 2024 16:15:05 +0700 Subject: [PATCH 6/6] refactor(cypress): move the IS_WIN constant at the top --- packages/allure-cypress/src/browser/utils.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/allure-cypress/src/browser/utils.ts b/packages/allure-cypress/src/browser/utils.ts index ac38230fa..573a11e30 100644 --- a/packages/allure-cypress/src/browser/utils.ts +++ b/packages/allure-cypress/src/browser/utils.ts @@ -6,6 +6,8 @@ import { ALLURE_REPORT_SYSTEM_HOOK } from "./events/mocha.js"; import { getAllureState, getProjectDir } from "./state.js"; import { resolveStepStatus } from "./steps.js"; +const IS_WIN = Cypress.platform === "win32"; + export const getFileNameFromPath = (path: string) => path.substring(path.lastIndexOf(IS_WIN ? "\\" : "/") + 1); export const resolveSpecRelativePath = (spec: Cypress.Spec) => { @@ -136,5 +138,3 @@ const getSkipReason = (hookTitle: string, suite: CypressSuite) => { const suiteName = suite.title ? `'${suite.title}'` : "root"; return `'${hookTitle}' defined in the ${suiteName} suite has failed`; }; - -const IS_WIN = Cypress.platform === "win32";