@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
text/typescript
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');
}
}