@serenity-js/playwright-test
Version:
Serenity/JS test runner adapter for Playwright Test, combining Playwright's developer experience with the advanced reporting and automation capabilities of Serenity/JS
252 lines (205 loc) • 9.19 kB
text/typescript
import path from 'node:path';
import { type FullConfig } from '@playwright/test';
import { type TestCase, type TestResult } from '@playwright/test/reporter';
import { Duration, LogicError, Timestamp } from '@serenity-js/core';
import {
type DomainEvent,
InteractionFinished,
RetryableSceneDetected,
SceneFinished,
SceneTagged
} from '@serenity-js/core/lib/events';
import { Path } from '@serenity-js/core/lib/io';
import type { CorrelationId,Outcome } from '@serenity-js/core/lib/model';
import {
ArbitraryTag,
ExecutionFailedWithAssertionError,
ExecutionFailedWithError,
ExecutionIgnored,
ExecutionSkipped,
ExecutionSuccessful
} from '@serenity-js/core/lib/model';
import { type JSONObject } from 'tiny-types';
import { WorkerEventStreamReader } from '../api/WorkerEventStreamReader';
import { WorkerEventStreamWriter } from '../api/WorkerEventStreamWriter';
import { EventFactory, PlaywrightSceneId } from '../events';
import { PlaywrightErrorParser } from './PlaywrightErrorParser';
export class PlaywrightEventBuffer {
private readonly errorParser = new PlaywrightErrorParser();
private readonly eventStreamReader = new WorkerEventStreamReader();
private eventFactory: EventFactory;
private readonly events = new Map<string, DomainEvent[]>();
private readonly deferredSceneFinishedEvents = new Map<string, {
event: SceneFinished;
outputDirectory: string;
workerIndex: number;
}>();
configure(config: Pick<FullConfig, 'rootDir'>): void {
this.eventFactory = new EventFactory(Path.from(config.rootDir));
}
appendTestStart(test: TestCase, result: TestResult): void {
this.events.set(
this.sceneId(test, result).value,
this.eventFactory.createSceneStartEvents(test, result),
);
}
appendRetryableSceneEvents(test: TestCase, result: TestResult): void {
const sceneId = this.sceneId(test, result);
const sceneEndTime = new Timestamp(result.startTime).plus(Duration.ofMilliseconds(result.duration));
this.events.get(sceneId.value).push(
new RetryableSceneDetected(sceneId, sceneEndTime),
);
if (result.retry > 0 || result.status !== 'passed') {
this.events.get(sceneId.value).push(
new SceneTagged(
sceneId,
new ArbitraryTag('retried'), // todo: replace with a dedicated tag
sceneEndTime,
),
);
}
}
deferAppendingSceneFinishedEvent(test: TestCase, result: TestResult): void {
const sceneId = this.sceneId(test, result);
const scenarioOutcome = this.outcomeFrom(test, result);
this.deferredSceneFinishedEvents.set(sceneId.value, {
event: this.eventFactory.createSceneFinishedEvent(test, result, scenarioOutcome),
outputDirectory: test.parent.project().outputDir,
workerIndex: result.workerIndex,
});
}
private determineScenarioOutcome(
worstInteractionOutcome: Outcome,
scenarioOutcome: Outcome,
): Outcome {
if (worstInteractionOutcome instanceof ExecutionFailedWithAssertionError) {
return worstInteractionOutcome;
}
return worstInteractionOutcome.isWorseThan(scenarioOutcome)
? worstInteractionOutcome
: scenarioOutcome;
}
appendCrashedWorkerEvents(test: TestCase, result: TestResult): void {
const workerStreamId = WorkerEventStreamWriter.workerStreamIdFor(result.workerIndex).value;
const sceneId = this.sceneId(test, result);
this.events.get(sceneId.value).push(
...this.readEventStream(
test.parent.project().outputDir,
workerStreamId,
sceneId.value,
),
);
}
appendSceneEvents(test: TestCase, result: TestResult): void {
const sceneId = this.sceneId(test, result);
this.events.get(sceneId.value).push(
...this.readEventStream(test.parent.project().outputDir, sceneId.value),
);
}
private readEventStream(
outputDirectory: string,
streamId: string,
expectedSceneId: string = streamId,
): DomainEvent[] {
const pathToEventStreamFile = path.join(outputDirectory, 'serenity', streamId, 'events.ndjson');
if (this.eventStreamReader.hasStream(pathToEventStreamFile)) {
return this.eventStreamReader.read(
pathToEventStreamFile,
(event: { type: string, value: JSONObject }) => {
// re-attach events from orphaned beforeAll to the test case
const hasSceneId = event.value['sceneId'] !== undefined;
const isAttachedToScene = event.value['sceneId'] === expectedSceneId
if(hasSceneId && ! isAttachedToScene) {
event.value['sceneId'] = expectedSceneId;
}
return event;
}
);
}
return [];
}
appendSceneFinishedEvent(test: TestCase, result: TestResult): void {
const sceneId = this.sceneId(test, result);
const worstInteractionOutcome = this.determineWorstInteractionOutcome(this.events.get(sceneId.value));
const scenarioOutcome = this.determineScenarioOutcome(
worstInteractionOutcome,
this.outcomeFrom(test, result),
);
this.events.get(sceneId.value).push(
this.eventFactory.createSceneFinishedEvent(test, result, scenarioOutcome)
);
}
flush(test: TestCase, result: TestResult): DomainEvent[] {
const sceneId = this.sceneId(test, result);
const events = this.events.get(sceneId.value);
if (! events) {
throw new LogicError(`No events found for test: ${ sceneId.value }`);
}
this.events.delete(sceneId.value);
return events;
}
flushAllDeferred(): DomainEvent[] {
const allEvents = [];
for (const [ testId, events ] of this.events.entries()) {
const scenarioEvents = [];
scenarioEvents.push(...events);
if (this.deferredSceneFinishedEvents.has(testId)) {
const lastRecordedEvent = scenarioEvents.at(-1);
const deferredSceneFinished = this.deferredSceneFinishedEvents.get(testId);
const eventStream = this.readEventStream(
deferredSceneFinished.outputDirectory,
deferredSceneFinished.event.sceneId.value,
);
const firstEventSinceLastIndex = eventStream.findIndex(event => lastRecordedEvent.equals(event));
const eventsSinceLast = firstEventSinceLastIndex === -1
? eventStream
: eventStream.slice(firstEventSinceLastIndex);
scenarioEvents.push(...eventsSinceLast);
const worstInteractionOutcome = this.determineWorstInteractionOutcome(scenarioEvents);
const sceneFinishedEvent = new SceneFinished(
deferredSceneFinished.event.sceneId,
deferredSceneFinished.event.details,
this.determineScenarioOutcome(worstInteractionOutcome, deferredSceneFinished.event.outcome),
deferredSceneFinished.event.timestamp,
)
scenarioEvents.push(sceneFinishedEvent);
}
allEvents.push(...scenarioEvents);
}
this.events.clear();
this.deferredSceneFinishedEvents.clear();
return allEvents;
}
private determineWorstInteractionOutcome(events: DomainEvent[]): Outcome {
let worstInteractionOutcome: Outcome = new ExecutionSuccessful();
for (const event of events) {
if (event instanceof InteractionFinished && event.outcome.isWorseThan(worstInteractionOutcome)) {
worstInteractionOutcome = event.outcome;
}
}
return worstInteractionOutcome;
}
private outcomeFrom(test: TestCase, result: TestResult): Outcome {
const outcome = test.outcome();
if (outcome === 'skipped') {
return new ExecutionSkipped();
}
if (outcome === 'unexpected' && result.status === 'passed') {
return new ExecutionFailedWithError(
new LogicError(`Scenario expected to fail, but ${ result.status }`),
);
}
if ([ 'failed', 'interrupted', 'timedOut' ].includes(result.status)) {
if (test.retries > result.retry) {
return new ExecutionIgnored(this.errorParser.errorFrom(result.error));
}
return new ExecutionFailedWithError(
this.errorParser.errorFrom(result.error),
);
}
return new ExecutionSuccessful();
}
private sceneId(test: TestCase, result: TestResult): CorrelationId {
return PlaywrightSceneId.from(test.parent.project()?.name, test, result);
}
}