UNPKG

@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
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); } }