UNPKG

@serenity-js/jasmine

Version:

Serenity/JS test runner adapter for Jasmine, enabling the use of the Screenplay Pattern in Jasmine-based test suites and leveraging Serenity/JS reporting capabilities

344 lines (294 loc) 11.9 kB
import { AssertionError, ErrorSerialiser, ImplementationPendingError, type Serenity, TestCompromisedError } from '@serenity-js/core'; import { type DomainEvent, SceneFinished, SceneFinishes, SceneStarts, SceneTagged, TaskFinished, TaskStarts, TestRunFinished, TestRunFinishes, TestRunnerDetected, TestRunStarts, TestSuiteFinished, TestSuiteStarts, } from '@serenity-js/core/lib/events/index.js'; import type { RequirementsHierarchy } from '@serenity-js/core/lib/io/index.js'; import { FileSystemLocation, Path } from '@serenity-js/core/lib/io/index.js'; import { ActivityDetails, Category, CorrelationId, ExecutionCompromised, ExecutionFailedWithAssertionError, ExecutionFailedWithError, ExecutionSkipped, ExecutionSuccessful, ImplementationPending, Name, type Outcome, type ProblemIndication, ScenarioDetails, type Tag, Tags, TestSuiteDetails, } from '@serenity-js/core/lib/model/index.js'; import type { Expectation, JasmineDoneInfo, JasmineReporter, JasmineStartedInfo, SpecResult, SuiteResult } from './jasmine/index.js'; /** * [Jasmine reporter](https://jasmine.github.io/tutorials/custom_reporter) that translates Jasmine-specific test events * to Serenity/JS events. */ export class SerenityReporterForJasmine implements JasmineReporter { private static readonly errorMessagePattern = /^([^\s:]*Error):\s(.*)$/m; private describes: SuiteResult[] = []; private currentSceneId: CorrelationId = undefined; /** * @param {Serenity} serenity */ constructor( private readonly serenity: Serenity, private readonly requirementsHierachy: RequirementsHierarchy ) { } jasmineStarted(info: JasmineStartedInfo): void { this.emit(new TestRunStarts(this.serenity.currentTime())); } suiteStarted(result: SuiteResult): void { this.describes.push(result); this.emit(new TestSuiteStarts(this.testSuiteDetailsOf(result), this.serenity.currentTime())); } suiteDone(result: SuiteResult): void { this.describes = this.describes.filter(suite => suite.id !== result.id); this.emit(new TestSuiteFinished(this.testSuiteDetailsOf(result), this.outcomeFrom(result), this.serenity.currentTime())); } specStarted(result: SpecResult): void { this.currentSceneId = this.serenity.assignNewSceneId(); const { scenarioDetails, scenarioTags } = this.scenarioDetailsOf(result); this.emit( new SceneStarts(this.currentSceneId, scenarioDetails, this.serenity.currentTime()), ... this.requirementsHierachy.requirementTagsFor(scenarioDetails.location.path, Tags.stripFrom(this.currentFeatureNameFor(result))) .map(tag => new SceneTagged(this.currentSceneId, tag, this.serenity.currentTime())), new TestRunnerDetected(this.currentSceneId, new Name('Jasmine'), this.serenity.currentTime()), ... scenarioTags.map(tag => new SceneTagged(this.currentSceneId, tag, this.serenity.currentTime())) ); } specDone(result: SpecResult): Promise<void> { /** * Serenity doesn't allow for more than one failure per activity, but Jasmine does. * If there are multiple failures we wrap them up in fake activities so that they're all reported correctly. */ if (result.failedExpectations.length > 1) { result.failedExpectations.forEach(failedExpectation => { const sceneId = this.serenity.currentSceneId(); const activityDetails = new ActivityDetails( new Name('Expectation'), new FileSystemLocation( Path.from(result.location.path), result.location.line, result.location.column, ), ); const activityId = this.serenity.assignNewActivityId(activityDetails); this.emit( new TaskStarts(sceneId, activityId, activityDetails, this.serenity.currentTime()), new TaskFinished(sceneId, activityId, activityDetails, this.failureOutcomeFrom(failedExpectation), this.serenity.currentTime()), ); }); } const { scenarioDetails } = this.scenarioDetailsOf(result), outcome = this.outcomeFrom(result); this.emit(new SceneFinishes( this.currentSceneId, this.serenity.currentTime(), )); return this.serenity.waitForNextCue() .then(() => { this.emit(new SceneFinished( this.currentSceneId, scenarioDetails, outcome, this.serenity.currentTime(), )); }, error => { this.emit(new SceneFinished( this.currentSceneId, scenarioDetails, new ExecutionFailedWithError(error), this.serenity.currentTime(), )); throw error; }); } jasmineDone(suiteInfo: JasmineDoneInfo): Promise<void> { this.emit(new TestRunFinishes(this.serenity.currentTime())); return this.serenity.waitForNextCue() .then(() => { this.emit(new TestRunFinished(new ExecutionSuccessful(), this.serenity.currentTime())); }) .catch(error => { this.emit(new TestRunFinished(new ExecutionFailedWithError(error), this.serenity.currentTime())); throw error; }); } /** * @private * @param {DomainEvent[]} events */ private emit(...events: DomainEvent[]): void { events.forEach(event => this.serenity.announce(event)); } /** * @private * @param {SpecResult} spec * @returns {ScenarioDetails} */ private scenarioDetailsOf(spec: SpecResult): { scenarioDetails: ScenarioDetails, scenarioTags: Tag[] } { const name = this.currentScenarioNameFor(spec.description); const featureName = this.currentFeatureNameFor(spec); return { scenarioDetails: new ScenarioDetails( new Name(Tags.stripFrom(name)), new Category(Tags.stripFrom(featureName)), FileSystemLocation.fromJSON(spec.location as any), ), scenarioTags: Tags.from(`${ featureName } ${ name }`) }; } /** * @private * @param {SuiteResult} result * @returns {TestSuiteDetails} */ private testSuiteDetailsOf(result: SuiteResult): TestSuiteDetails { return new TestSuiteDetails( new Name(result.description), FileSystemLocation.fromJSON(result.location as any), new CorrelationId(result.id), ); } /** * @private * @returns {string} */ private currentFeatureNameFor(spec: SpecResult): string { const path = new Path(spec.location.path); return this.describes[0] ? this.describes[0].description : this.serenity.cwd().relative(path).value; } /** * @private * @param {string} itBlockDescription * @returns {string} */ private currentScenarioNameFor(itBlockDescription: string): string { const [ topSuite_, ...rest ] = this.describes; return rest.reverse() .reduce((name, current) => `${ current.description } ${ name }`, itBlockDescription); } /** * @private * @param {SpecResult | SuiteResult} result * @returns {Outcome} */ private outcomeFrom(result: SpecResult | SuiteResult): Outcome { switch (result.status) { case 'failed': return this.failureOutcomeFrom(result.failedExpectations[0]); case 'pending': return new ImplementationPending(new ImplementationPendingError((result as any).pendingReason || '')); case 'excluded': return new ExecutionSkipped(); case 'passed': // eslint-disable-line unicorn/no-useless-switch-case default: return new ExecutionSuccessful(); } } /** * @private * @param {Expectation} failure * @returns {ProblemIndication} */ private failureOutcomeFrom(failure: Expectation): ProblemIndication { const error = this.errorFrom(failure); if (error instanceof AssertionError) { // sadly, Jasmine error propagation mechanism is rather basic // and unable to serialise the expected/actual properties of the AssertionError object return new ExecutionFailedWithAssertionError(error); } if (error instanceof TestCompromisedError) { return new ExecutionCompromised(error); } if (failure.matcherName) { // the presence of a non-empty matcherName property indicates a Jasmine-specific assertion error return new ExecutionFailedWithAssertionError( this.serenity.createError(AssertionError, { message: failure.message, diff: { expected: failure.expected, actual: failure.actual, }, cause: error, }), ); } return new ExecutionFailedWithError(error); } private errorFrom(failure: Expectation): Error { if (this.containsCorrectlySerialisedError(failure)) { return ErrorSerialiser.deserialiseFromStackTrace(failure.stack); } if (this.containsIncorrectlySerialisedErrorWithErrorPropertiesInStack(failure)) { return ErrorSerialiser.deserialiseFromStackTrace(this.repairedStackTraceOf(failure)); } if (this.containsIncorrectlySerialisedError(failure)) { return ErrorSerialiser.deserialiseFromStackTrace(this.repairedStackTraceOf(failure)); } return new Error(failure.message); } private containsCorrectlySerialisedError(failure: Expectation): boolean { return !! failure.stack && SerenityReporterForJasmine.errorMessagePattern.test(failure.stack.split('\n')[0]); } private containsIncorrectlySerialisedErrorWithErrorPropertiesInStack(failure: Expectation): boolean { return !! failure.stack && failure.stack.startsWith('error properties: ') && SerenityReporterForJasmine.errorMessagePattern.test(failure.message); } private containsIncorrectlySerialisedError(failure: Expectation): boolean { return !! failure.stack && SerenityReporterForJasmine.errorMessagePattern.test(failure.message); } /** * It seems like Jasmine mixes up serialisation and display logic, * which means that its "failure.stack" is not really an Error stacktrace, * but rather something along the lines of: * "error properties: AssertionError: undefined" * where the error message is lost, and there's an "error properties:" prefix present. * * Probably caused by this: * https://github.com/jasmine/jasmine/blob/b4cbe9850fbe192eaffeae450669f96e79a574ed/src/core/ExceptionFormatter.js#L93 * * @param {Expectation} failure */ private repairedStackTraceOf(failure: Expectation): string { const lastLine = failure.message.split('\n').pop(); const frames = failure.stack.split('\n').filter(line => ! line.startsWith('error properties:')) return [ failure.message, ...frames.slice(frames.indexOf(lastLine) + 1), ].join('\n'); } }