diff --git a/packages/console-reporter/src/stage/crew/console-reporter/ConsoleReporter.ts b/packages/console-reporter/src/stage/crew/console-reporter/ConsoleReporter.ts index fd8263e8190..57f58d08b22 100644 --- a/packages/console-reporter/src/stage/crew/console-reporter/ConsoleReporter.ts +++ b/packages/console-reporter/src/stage/crew/console-reporter/ConsoleReporter.ts @@ -14,6 +14,7 @@ import { TestRunFinished, TestRunStarts, } from '@serenity-js/core/lib/events'; +import type { FileSystem } from '@serenity-js/core/lib/io'; import type { CorrelationId, Name, @@ -34,6 +35,7 @@ import { ensure, isDefined, match } from 'tiny-types'; import type { ConsoleReporterConfig } from './ConsoleReporterConfig'; import { Printer } from './Printer'; +import SnippetGenerator from './SnippetGenerator'; import { Summary } from './Summary'; import { SummaryFormatter } from './SummaryFormatter'; import type { TerminalTheme} from './themes'; @@ -166,9 +168,10 @@ export class ConsoleReporter implements ListensToDomainEvents { private readonly firstErrors: Map = new Map(); private readonly summaryFormatter: SummaryFormatter; private readonly eventQueues = new DomainEventQueues(); + private snippetGenerator: SnippetGenerator; static fromJSON(config: ConsoleReporterConfig): StageCrewMemberBuilder { - return new ConsoleReporterBuilder(ConsoleReporter.theme(config.theme)); + return new ConsoleReporterBuilder(ConsoleReporter.theme(config.theme), config.showSnippetsOnError ?? false); } /** @@ -225,17 +228,21 @@ export class ConsoleReporter implements ListensToDomainEvents { /** * @param {Printer} printer * @param {TerminalTheme} theme + * @param {FileSystem} fileSystem * @param {Stage} [stage=undefined] */ constructor( private readonly printer: Printer, private readonly theme: TerminalTheme, + private readonly showSnippets: boolean, + fileSystem: FileSystem, private readonly stage?: Stage, ) { ensure('printer', printer, isDefined()); ensure('theme', theme, isDefined()); this.summaryFormatter = new SummaryFormatter(this.theme); + this.snippetGenerator = new SnippetGenerator(fileSystem); } /** @@ -280,6 +287,9 @@ export class ConsoleReporter implements ListensToDomainEvents { private printTestRunErrorOutcome(outcome: ProblemIndication): void { this.printer.println(this.theme.outcome(outcome, outcome.error.stack)); + if (this.showSnippets){ + this.printer.println(this.snippetGenerator.createSnippetFor(outcome)); + } } private printScene(sceneId: CorrelationId): void { @@ -462,11 +472,11 @@ export class ConsoleReporter implements ListensToDomainEvents { } class ConsoleReporterBuilder implements StageCrewMemberBuilder { - constructor(private readonly theme: TerminalTheme) { + constructor(private readonly theme: TerminalTheme, private readonly showSnippets = false) { } - build({ stage, outputStream }: { stage: Stage; outputStream: OutputStream; }): ConsoleReporter { - return new ConsoleReporter(new Printer(outputStream), this.theme, stage); + build({ stage, outputStream, fileSystem }: { stage: Stage; outputStream: OutputStream; fileSystem: FileSystem; }): ConsoleReporter { + return new ConsoleReporter(new Printer(outputStream), this.theme, this.showSnippets, fileSystem, stage); } } diff --git a/packages/console-reporter/src/stage/crew/console-reporter/ConsoleReporterConfig.ts b/packages/console-reporter/src/stage/crew/console-reporter/ConsoleReporterConfig.ts index 78c554e3a6a..de04cfdbab7 100644 --- a/packages/console-reporter/src/stage/crew/console-reporter/ConsoleReporterConfig.ts +++ b/packages/console-reporter/src/stage/crew/console-reporter/ConsoleReporterConfig.ts @@ -8,5 +8,10 @@ export interface ConsoleReporterConfig { * Choose a colour theme optimised for light, dark, or monochromatic terminals. * Or, use 'auto' to automatically pick the most suitable one. */ - theme: 'light' | 'dark' | 'mono' | 'auto' + theme: 'light' | 'dark' | 'mono' | 'auto'; + + /** + * Specify if you want to show code snippets on error. Defaults to false + */ + showSnippetsOnError?: boolean; } diff --git a/packages/console-reporter/src/stage/crew/console-reporter/SnippetGenerator.ts b/packages/console-reporter/src/stage/crew/console-reporter/SnippetGenerator.ts new file mode 100644 index 00000000000..3920693ca29 --- /dev/null +++ b/packages/console-reporter/src/stage/crew/console-reporter/SnippetGenerator.ts @@ -0,0 +1,59 @@ +import { ErrorStackParser } from '@serenity-js/core/lib'; +import type { FileSystem } from '@serenity-js/core/lib/io'; +import { Path } from '@serenity-js/core/lib/io'; +import { FileSystemLocation } from '@serenity-js/core/lib/io'; +import type { ProblemIndication } from '@serenity-js/core/lib/model'; + +/** + * Class for generating code snippets + */ +export default class SnippetGenerator { + + constructor(private readonly fileSystem: FileSystem){ + + } + + /** + * Creates snippet for outcome's error + * + * @param error decoupled error from `ProblemIndication` + * @returns + * code snippet with few code lines surrounding error + */ + createSnippetFor({error}: ProblemIndication): string { + const location = this.parseToFSLoc(error); + if (!location){ + return ''; // no need to generate snippet for non-user code + } + const {path, line} = location; + const data = this.fileSystem.readFileSync(path, {encoding: 'utf8'}).split('\n'); + const start = Math.max(0, line - 2); + const end = Math.min(data.length, line+3); + + const tokens: string[] = []; + tokens.push(`at ${path.value.split('/').slice(-1)}:${line}`) + + for (let lineIndex = start; lineIndex < end; lineIndex++) { + const suffix = (lineIndex === line? '>' : ' ') + lineIndex; + tokens.push(`${suffix} | ${data[lineIndex]}`); + } + return tokens.join('\n'); + } + + private parseToFSLoc(error: Error): FileSystemLocation | undefined { + const stackFrames = ErrorStackParser.parse(error) + .withOnlyUserFrames() + .andGet(); + + if (stackFrames.length === 0){ + return undefined; + } + const invocationFrame = stackFrames[0]; + + return new FileSystemLocation( + Path.from(invocationFrame.fileName?.replace(/^file:/, '')), + invocationFrame.lineNumber, + invocationFrame.columnNumber, + ); + } +} \ No newline at end of file diff --git a/packages/core/src/errors/ErrorStackParser.ts b/packages/core/src/errors/ErrorStackParser.ts index f04f9b59c89..1e3b6a84e36 100644 --- a/packages/core/src/errors/ErrorStackParser.ts +++ b/packages/core/src/errors/ErrorStackParser.ts @@ -1,4 +1,5 @@ import * as parser from 'error-stack-parser'; +import path from 'path'; /** * A thin wrapper around error-stack-parser module @@ -9,7 +10,26 @@ import * as parser from 'error-stack-parser'; * @group Errors */ export class ErrorStackParser { - parse(error: Error): parser.StackFrame[] { - return parser.parse(error); + private constructor(private frames: parser.StackFrame[]){ + + } + static parse(error: Error): ErrorStackParser { + return new ErrorStackParser(parser.parse(error)); + } + + withOnlyUserFrames(): ErrorStackParser{ + const nonSerenityNodeModulePattern = new RegExp(`node_modules` + `\\` + path.sep + `(?!@serenity-js`+ `\\` + path.sep +`)`); + + this.frames = this.frames.filter(frame => ! ( + frame?.fileName.startsWith('node:') || // node 16 and 18 + frame?.fileName.startsWith('internal') || // node 14 + nonSerenityNodeModulePattern.test(frame?.fileName) // ignore node_modules, except for @serenity-js/* + )); + + return this; + } + + andGet(): parser.StackFrame[]{ + return this.frames; } -} +} \ No newline at end of file diff --git a/packages/core/src/screenplay/Activity.ts b/packages/core/src/screenplay/Activity.ts index a734e4550b8..8d905aa44e4 100644 --- a/packages/core/src/screenplay/Activity.ts +++ b/packages/core/src/screenplay/Activity.ts @@ -1,5 +1,3 @@ -import path from 'path'; - import { ErrorStackParser } from '../errors'; import { FileSystemLocation, Path } from '../io'; import type { UsesAbilities } from './abilities'; @@ -18,8 +16,6 @@ import type { AnswersQuestions } from './questions'; * @group Screenplay Pattern */ export abstract class Activity { - - private static errorStackParser = new ErrorStackParser(); readonly #description: string; readonly #location: FileSystemLocation; @@ -70,18 +66,14 @@ export abstract class Activity { const error = new Error('Caller location marker'); Error.stackTraceLimit = originalStackTraceLimit; - const nonSerenityNodeModulePattern = new RegExp(`node_modules` + `\\` + path.sep + `(?!@serenity-js`+ `\\` + path.sep +`)`); + const parser = ErrorStackParser.parse(error); - const frames = this.errorStackParser.parse(error); - const userLandFrames = frames.filter(frame => ! ( - frame?.fileName.startsWith('node:') || // node 16 and 18 - frame?.fileName.startsWith('internal') || // node 14 - nonSerenityNodeModulePattern.test(frame?.fileName) // ignore node_modules, except for @serenity-js/* - )); + const fallbackFrame = parser.andGet().at(-1); + const userLandFrames = parser.withOnlyUserFrames().andGet(); const index = Math.min(Math.max(1, frameOffset), userLandFrames.length - 1); // use the desired user-land frame, or the last one from the stack trace for internal invocations - const invocationFrame = userLandFrames[index] || frames.at(-1); + const invocationFrame = userLandFrames[index] || fallbackFrame; return new FileSystemLocation( Path.from(invocationFrame.fileName?.replace(/^file:/, '')), diff --git a/packages/jasmine/src/monkeyPatched.ts b/packages/jasmine/src/monkeyPatched.ts index b79118287f1..bada36fa4c7 100644 --- a/packages/jasmine/src/monkeyPatched.ts +++ b/packages/jasmine/src/monkeyPatched.ts @@ -1,7 +1,5 @@ import { ErrorStackParser } from '@serenity-js/core/lib/errors/index.js'; -const parser = new ErrorStackParser(); - /* eslint-disable @typescript-eslint/explicit-module-boundary-types, @typescript-eslint/ban-types */ /** @@ -47,7 +45,7 @@ export function monkeyPatched( * @package */ function callerLocation() { - const frames = parser.parse(new Error('fake error')); + const frames = ErrorStackParser.parse(new Error('fake error')).andGet(); const found = frames .filter(frame => ! /(node_modules)/.test(frame.fileName)) diff --git a/packages/serenity-bdd/src/stage/crew/serenity-bdd-reporter/processors/mappers/errorReportFrom.ts b/packages/serenity-bdd/src/stage/crew/serenity-bdd-reporter/processors/mappers/errorReportFrom.ts index 4e548e029c4..e249d5fc88f 100644 --- a/packages/serenity-bdd/src/stage/crew/serenity-bdd-reporter/processors/mappers/errorReportFrom.ts +++ b/packages/serenity-bdd/src/stage/crew/serenity-bdd-reporter/processors/mappers/errorReportFrom.ts @@ -52,9 +52,8 @@ function errorMessageOf(maybeError: any): string { function errorStackOf(maybeError: any) { if (isDefined(maybeError) && isDefined(maybeError.stack)) { - const parser = new ErrorStackParser(); - return parser.parse(maybeError).map(frame => ({ + return ErrorStackParser.parse(maybeError).andGet().map(frame => ({ declaringClass: '', methodName: frame.functionName ? `${ frame.functionName }(${ (frame.args || []).join(', ') })` : '', fileName: frame.fileName,