-
Notifications
You must be signed in to change notification settings - Fork 125
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(cypress): command log steps rework
- Loading branch information
Showing
23 changed files
with
2,542 additions
and
1,243 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,179 @@ | ||
import type { Parameter } from "allure-js-commons"; | ||
import type { CypressLogEntry, LogStepDescriptor } from "../types.js"; | ||
import { isDefined } from "../utils.js"; | ||
import { reportStepStart } from "./lifecycle.js"; | ||
import serializePropValue from "./serialize.js"; | ||
import { getCurrentStep, getStepStack, pushStep, setupStepFinalization } from "./state.js"; | ||
import { ALLURE_STEP_CMD_SUBJECT, findAndStopStepWithSubsteps, isLogStep } from "./steps.js"; | ||
|
||
export const shouldCreateStepFromCommandLogEntry = (entry: CypressLogEntry) => { | ||
const { event, instrument } = entry.attributes; | ||
if (instrument !== "command") { | ||
// We are interested in the "TEST BODY" panel only for now. | ||
// Other instruments are logged in separate panels. | ||
return false; | ||
} | ||
|
||
if (event) { | ||
// Events are tricky to report as they may span across commands and even leave the test's scope. | ||
// We ignore them for now. | ||
return false; | ||
} | ||
|
||
if (isApiStepErrorLogEntry(entry)) { | ||
// Cypress don't create a log message for 'cy.then' except when it throws an error. | ||
// This is in particularly happens when the function passed to 'allure.step' throws. In such a case however, | ||
// creating an extra step from the log entry is redundant because the error is already included in the report as | ||
// a part of the step. | ||
return false; | ||
} | ||
|
||
return true; | ||
}; | ||
|
||
/** | ||
* Checks if the current step represents a cy.screenshot command log entry. If this is the case, associates the name | ||
* of the screenshot with the step. Later, that will allow converting the step with the attachment into the attachment | ||
* step. | ||
*/ | ||
export const setupScreenshotAttachmentStep = (originalName: string | undefined, name: string) => { | ||
const step = getCurrentStep(); | ||
if (step && isLogStep(step)) { | ||
const { | ||
name: commandName, | ||
props: { name: nameFromProps }, | ||
} = step.log.attributes.consoleProps(); | ||
if (commandName === "screenshot" && nameFromProps === originalName) { | ||
step.attachmentName = name; | ||
} | ||
} | ||
}; | ||
|
||
export const startCommandLogStep = (entry: CypressLogEntry) => { | ||
const currentLogEntry = getCurrentLogEntry(); | ||
if (typeof currentLogEntry !== "undefined" && shouldStopCurrentLogStep(currentLogEntry.log, entry)) { | ||
stopCommandLogStep(currentLogEntry.log.attributes.id); | ||
} | ||
|
||
pushLogEntry(entry); | ||
reportStepStart(entry.attributes.id, getCommandLogStepName(entry)); | ||
scheduleCommandLogStepStop(entry); | ||
}; | ||
|
||
export const stopCommandLogStep = (entryId: string) => findAndStopStepWithSubsteps(({ id }) => id === entryId); | ||
|
||
const pushLogEntry = (entry: CypressLogEntry) => { | ||
const id = entry.attributes.id; | ||
const stepDescriptor: LogStepDescriptor = { id, type: "log", log: entry }; | ||
pushStep(stepDescriptor); | ||
|
||
// Some properties of some Command Log entries are undefined at the time the entry is stopped. An example is the | ||
// Yielded property of some queries. We defer converting them to Allure step parameters until the test/hook ends. | ||
setupStepFinalization(stepDescriptor, (data) => { | ||
data.parameters = getCommandLogStepParameters(entry); | ||
if (stepDescriptor.attachmentName) { | ||
// Rename the step to match the attachment name. Once the names are the same, Allure will render the | ||
// attachment in the place of the step. | ||
data.name = stepDescriptor.attachmentName; | ||
} | ||
}); | ||
}; | ||
|
||
const scheduleCommandLogStepStop = (entry: CypressLogEntry) => { | ||
const { groupStart, end, id } = entry.attributes; | ||
if (end) { | ||
// Some entries are already completed (this is similar to the idea behind allure.logStep). | ||
// Cypress won't call entry.end() in such a case, so we need to stop such a step now. | ||
// Example: cy.log | ||
stopCommandLogStep(id); | ||
} else if (groupStart) { | ||
// A logging group must be stopped be the user via the Cypress.Log.endGroup() call. | ||
// If the call is missing, the corresponding step will be stopped either at the test's (the hook's) end. | ||
const originalEndGroup = entry.endGroup; | ||
entry.endGroup = function () { | ||
stopCommandLogStep(id); | ||
return originalEndGroup.call(this); | ||
}; | ||
} else { | ||
// Regular log entries are finalized by Cypress via the Cypress.Log.end() call. We're hooking into this function | ||
// to complete the step at the same time. | ||
// eslint-disable-next-line @typescript-eslint/unbound-method | ||
const originalEnd = entry.end; | ||
entry.end = function () { | ||
stopCommandLogStep(id); | ||
return originalEnd.call(this); | ||
}; | ||
} | ||
}; | ||
|
||
const isApiStepErrorLogEntry = ({ attributes: { name, consoleProps } }: CypressLogEntry) => | ||
name === "then" && Object.is(consoleProps().props["Applied To"], ALLURE_STEP_CMD_SUBJECT); | ||
|
||
const getCommandLogStepName = (entry: CypressLogEntry) => { | ||
const { name, message, displayName } = entry.attributes; | ||
const resolvedName = (displayName ?? name).trim(); | ||
const resolvedMessage = ( | ||
maybeGetAssertionLogMessage(entry) ?? | ||
maybeGetCucumberLogMessage(entry) ?? | ||
entry.attributes.renderProps().message ?? | ||
message | ||
).trim(); | ||
const stepName = [resolvedName, resolvedMessage].filter(Boolean).join(" "); | ||
return stepName; | ||
}; | ||
|
||
const getCommandLogStepParameters = (entry: CypressLogEntry) => | ||
getLogProps(entry) | ||
.map(([k, v]) => ({ | ||
name: k.toString(), | ||
value: serializePropValue(v), | ||
})) | ||
.filter(getPropValueSetFilter(entry)); | ||
|
||
const WELL_KNOWN_CUCUMBER_LOG_NAMES = ["Given", "When", "Then", "And"]; | ||
|
||
const maybeGetCucumberLogMessage = (entry: CypressLogEntry) => { | ||
const { | ||
attributes: { name, message }, | ||
} = entry; | ||
if (WELL_KNOWN_CUCUMBER_LOG_NAMES.includes(name.trim()) && message.startsWith("**") && message.endsWith("**")) { | ||
return message.substring(2, message.length - 2); | ||
} | ||
}; | ||
|
||
const getLogProps = (entry: CypressLogEntry) => { | ||
const { | ||
attributes: { consoleProps }, | ||
} = entry; | ||
const isAssertionWithMessage = !!maybeGetAssertionLogMessage(entry); | ||
|
||
// For assertion logs, we interpolate the 'Message' property, which contains unformatted assertion description, | ||
// directly into the step's name. | ||
// No need to keep the exact same information in the step's parameters. | ||
return Object.entries(consoleProps().props).filter( | ||
([k, v]) => isDefined(v) && !(isAssertionWithMessage && k === "Message"), | ||
); | ||
}; | ||
|
||
const maybeGetAssertionLogMessage = (entry: CypressLogEntry) => { | ||
if (isAssertLog(entry)) { | ||
const message = entry.attributes.consoleProps().props.Message; | ||
if (message && typeof message === "string") { | ||
return message; | ||
} | ||
} | ||
}; | ||
|
||
const isAssertLog = ({ attributes: { name } }: CypressLogEntry) => name === "assert"; | ||
|
||
const getCurrentLogEntry = () => getStepStack().findLast(isLogStep); | ||
|
||
const shouldStopCurrentLogStep = (currentLogEntry: CypressLogEntry, newLogEntry: CypressLogEntry) => { | ||
const { groupStart: currentEntryIsGroup, type: currentEntryType } = currentLogEntry.attributes; | ||
const { type: newEntryType } = newLogEntry.attributes; | ||
|
||
return !currentEntryIsGroup && (currentEntryType === "child" || newEntryType !== "child"); | ||
}; | ||
|
||
const getPropValueSetFilter = (entry: CypressLogEntry) => | ||
entry.attributes.name === "wrap" ? () => true : ({ name, value }: Parameter) => name !== "Yielded" || value !== "{}"; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,40 @@ | ||
import type { CypressLogEntry } from "../../types.js"; | ||
import { | ||
setupScreenshotAttachmentStep, | ||
shouldCreateStepFromCommandLogEntry, | ||
startCommandLogStep, | ||
} from "../commandLog.js"; | ||
import { reportScreenshot } from "../lifecycle.js"; | ||
import { reportStepError } from "../steps.js"; | ||
import { getFileNameFromPath } from "../utils.js"; | ||
|
||
export const registerCypressEventListeners = () => Cypress.on("fail", onFail).on("log:added", onLogAdded); | ||
|
||
export const enableReportingOfCypressScreenshots = () => Cypress.Screenshot.defaults({ onAfterScreenshot }); | ||
|
||
const onAfterScreenshot = ( | ||
...[, { name: originalName, path }]: Parameters<Cypress.ScreenshotDefaultsOptions["onAfterScreenshot"]> | ||
) => { | ||
const name = originalName ?? getFileNameFromPath(path); | ||
reportScreenshot(path, name); | ||
setupScreenshotAttachmentStep(originalName, name); | ||
}; | ||
|
||
const onLogAdded = (_: Cypress.ObjectLike, entry: CypressLogEntry) => { | ||
if (shouldCreateStepFromCommandLogEntry(entry)) { | ||
startCommandLogStep(entry); | ||
} | ||
}; | ||
|
||
const onFail = (error: Cypress.CypressError) => { | ||
reportStepError(error); | ||
|
||
// If there are more "fail" handlers yet to run, it's not our responsibility to throw. | ||
// Otherwise, we won't give them any chance to do their job (EventEmitter stops executing handlers as soon | ||
// as one of them throws - that is also true for eventemitter2, which is used by the browser-side of Cypress). | ||
if (noSubsequentFailListeners()) { | ||
throw error; | ||
} | ||
}; | ||
|
||
const noSubsequentFailListeners = () => Object.is(Cypress.listeners("fail").at(-1), onFail); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,12 @@ | ||
import { enableScopeLevelAfterHookReporting } from "../patching.js"; | ||
import { registerCypressEventListeners } from "./cypress.js"; | ||
import { injectFlushMessageHooks, registerMochaEventListeners } from "./mocha.js"; | ||
|
||
export const enableAllure = () => { | ||
registerMochaEventListeners(); | ||
registerCypressEventListeners(); | ||
injectFlushMessageHooks(); | ||
enableScopeLevelAfterHookReporting(); | ||
}; | ||
|
||
export { enableReportingOfCypressScreenshots } from "./cypress.js"; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,106 @@ | ||
import type { CypressHook, CypressSuite, CypressTest } from "../../types.js"; | ||
import { | ||
completeHookErrorReporting, | ||
completeSpecIfNoAfterHookLeft, | ||
flushRuntimeMessages, | ||
reportTestPass as onPass, | ||
reportTestSkip as onPending, | ||
reportSuiteEnd as onSuiteEnd, | ||
reportTestEnd as onTestEnd, | ||
reportHookEnd, | ||
reportHookStart, | ||
reportRunStart, | ||
reportSuiteStart, | ||
reportTestOrHookFail, | ||
reportTestStart, | ||
} from "../lifecycle.js"; | ||
import { initTestRuntime } from "../runtime.js"; | ||
import { applyTestPlan } from "../testplan.js"; | ||
import { isAllureHook, isRootAfterAllHook, isTestReported } from "../utils.js"; | ||
|
||
export const ALLURE_REPORT_SYSTEM_HOOK = "__allure_report_system_hook__"; | ||
|
||
export const registerMochaEventListeners = () => { | ||
((Cypress as any).mocha.getRunner() as Mocha.Runner) | ||
.on("start", onStart) | ||
.on("suite", onSuite) | ||
.on("suite end", onSuiteEnd) | ||
.on("hook", onHook) | ||
.on("hook end", onHookEnd) | ||
.on("test", onTest) | ||
.on("pass", onPass) | ||
.on("fail", onFail) | ||
.on("pending", onPending) | ||
.on("test end", onTestEnd); | ||
}; | ||
|
||
export const injectFlushMessageHooks = () => { | ||
afterEach(ALLURE_REPORT_SYSTEM_HOOK, flushRuntimeMessages); | ||
after(ALLURE_REPORT_SYSTEM_HOOK, onAfterAll); | ||
}; | ||
|
||
const onStart = () => { | ||
initTestRuntime(); | ||
reportRunStart(); | ||
}; | ||
|
||
const onSuite = (suite: CypressSuite) => { | ||
if (suite.root) { | ||
applyTestPlan(Cypress.spec, suite); | ||
} | ||
reportSuiteStart(suite); | ||
}; | ||
|
||
const onHook = (hook: CypressHook) => { | ||
if (isAllureHook(hook)) { | ||
return; | ||
} | ||
|
||
reportHookStart(hook); | ||
}; | ||
|
||
const onHookEnd = (hook: CypressHook) => { | ||
if (isAllureHook(hook)) { | ||
return; | ||
} | ||
|
||
reportHookEnd(hook); | ||
}; | ||
|
||
const onTest = (test: CypressTest) => { | ||
// Cypress emits an extra EVENT_TEST_BEGIN if the test is skipped. | ||
// reportTestSkip does that already, so we need to filter the extra event out. | ||
if (!isTestReported(test)) { | ||
reportTestStart(test); | ||
} | ||
}; | ||
|
||
const onFail = (testOrHook: CypressTest | CypressHook, err: Error) => { | ||
const isHook = "hookName" in testOrHook; | ||
if (isHook && isRootAfterAllHook(testOrHook)) { | ||
// Errors in spec-level 'after all' hooks are handled by Allure wrappers. | ||
return; | ||
} | ||
|
||
const isAllureHookFailure = isHook && isAllureHook(testOrHook); | ||
|
||
if (isAllureHookFailure) { | ||
// Normally, Allure hooks are skipped from the report. | ||
// In case of errors, it will be helpful to see them. | ||
reportHookStart(testOrHook, Date.now() - (testOrHook.duration ?? 0)); | ||
} | ||
|
||
// This will mark the fixture and the test (if any) as failed/broken. | ||
reportTestOrHookFail(err); | ||
|
||
if (isHook) { | ||
// This will end the fixture and test (if any) and will report the remaining | ||
// tests in the hook's suite (the ones that will be skipped by Cypress/Mocha). | ||
completeHookErrorReporting(testOrHook, err); | ||
} | ||
}; | ||
|
||
const onAfterAll = function (this: Mocha.Context) { | ||
flushRuntimeMessages(); | ||
completeSpecIfNoAfterHookLeft(this); | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,13 @@ | ||
import { enableAllure, enableReportingOfCypressScreenshots } from "./events/index.js"; | ||
import { isAllureInitialized, setAllureInitialized } from "./state.js"; | ||
|
||
export const initializeAllure = () => { | ||
if (isAllureInitialized()) { | ||
return; | ||
} | ||
|
||
setAllureInitialized(); | ||
|
||
enableAllure(); | ||
enableReportingOfCypressScreenshots(); | ||
}; |
Oops, something went wrong.