Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(console-reporter): Generating code snippets on error #2270

Draft
wants to merge 3 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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';
Expand Down Expand Up @@ -166,9 +168,10 @@ export class ConsoleReporter implements ListensToDomainEvents {
private readonly firstErrors: Map<string, FirstError> = new Map();
private readonly summaryFormatter: SummaryFormatter;
private readonly eventQueues = new DomainEventQueues();
private snippetGenerator: SnippetGenerator;

static fromJSON(config: ConsoleReporterConfig): StageCrewMemberBuilder<ConsoleReporter> {
return new ConsoleReporterBuilder(ConsoleReporter.theme(config.theme));
return new ConsoleReporterBuilder(ConsoleReporter.theme(config.theme), config.showSnippetsOnError ?? false);
}

/**
Expand Down Expand Up @@ -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);
}

/**
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -462,11 +472,11 @@ export class ConsoleReporter implements ListensToDomainEvents {
}

class ConsoleReporterBuilder implements StageCrewMemberBuilder<ConsoleReporter> {
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);
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Original file line number Diff line number Diff line change
@@ -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,
);
}
}
26 changes: 23 additions & 3 deletions packages/core/src/errors/ErrorStackParser.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import * as parser from 'error-stack-parser';
import path from 'path';

/**
* A thin wrapper around error-stack-parser module
Expand All @@ -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;
}
}
}
16 changes: 4 additions & 12 deletions packages/core/src/screenplay/Activity.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
import path from 'path';

import { ErrorStackParser } from '../errors';
import { FileSystemLocation, Path } from '../io';
import type { UsesAbilities } from './abilities';
Expand All @@ -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;

Expand Down Expand Up @@ -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:/, '')),
Expand Down
4 changes: 1 addition & 3 deletions packages/jasmine/src/monkeyPatched.ts
Original file line number Diff line number Diff line change
@@ -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 */

/**
Expand Down Expand Up @@ -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))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down