Skip to content

Commit

Permalink
Merge pull request #919 from gemini-testing/HERMIONE-1517.snippets
Browse files Browse the repository at this point in the history
feat: add error snippets
  • Loading branch information
KuznetsovRoman committed May 1, 2024
2 parents 08cbe17 + a78ba06 commit ddc3e28
Show file tree
Hide file tree
Showing 16 changed files with 922 additions and 12 deletions.
63 changes: 54 additions & 9 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions package.json
Expand Up @@ -48,6 +48,7 @@
},
"license": "MIT",
"dependencies": {
"@babel/code-frame": "7.24.2",
"@gemini-testing/commander": "2.15.3",
"@types/mocha": "10.0.1",
"@wdio/globals": "8.21.0",
Expand Down Expand Up @@ -81,6 +82,7 @@
"sizzle": "2.3.6",
"socket.io": "4.7.5",
"socket.io-client": "4.7.5",
"source-map": "0.7.4",
"strftime": "0.10.2",
"strip-ansi": "6.0.1",
"temp": "0.8.3",
Expand All @@ -102,6 +104,7 @@
"@commitlint/config-conventional": "^19.0.3",
"@sinonjs/fake-timers": "10.3.0",
"@swc/core": "1.3.40",
"@types/babel__code-frame": "7.0.6",
"@types/babel__core": "7.20.5",
"@types/bluebird": "3.5.38",
"@types/chai": "4.3.4",
Expand Down
11 changes: 9 additions & 2 deletions src/browser/stacktrace/utils.ts
Expand Up @@ -7,7 +7,7 @@ export type RawStackFrames = string;

type ErrorWithStack = SetRequired<Error, "stack">;

const getErrorTitle = (e: Error): string => {
export const getErrorTitle = (e: Error): string => {
let errorName = e.name;

if (!errorName && e.stack) {
Expand Down Expand Up @@ -35,10 +35,17 @@ const getErrorRawStackFrames = (e: ErrorWithStack): RawStackFrames => {
return e.stack.slice(errorTitleStackIndex + errorTitle.length);
}

const errorString = e.toString ? e.toString() + "\n" : "";
const errorStringIndex = e.stack.indexOf(errorString);

if (errorString && errorStringIndex !== -1) {
return e.stack.slice(errorStringIndex + errorString.length);
}

const errorMessageStackIndex = e.stack.indexOf(e.message);
const errorMessageEndsStackIndex = e.stack.indexOf("\n", errorMessageStackIndex + e.message.length);

return e.stack.slice(errorMessageEndsStackIndex);
return e.stack.slice(errorMessageEndsStackIndex + 1);
};

export function captureRawStackFrames(filterFunc?: (...args: unknown[]) => unknown): RawStackFrames {
Expand Down
4 changes: 4 additions & 0 deletions src/error-snippets/constants.ts
@@ -0,0 +1,4 @@
export const SOURCE_MAP_URL_COMMENT = "//# sourceMappingURL=";
export const SOURCE_MAP_HEADER = "SourceMap";
export const SNIPPET_LINES_ABOVE = 2;
export const SNIPPET_LINES_BELOW = 3;
74 changes: 74 additions & 0 deletions src/error-snippets/frames.ts
@@ -0,0 +1,74 @@
import _ from "lodash";
import ErrorStackParser from "error-stack-parser";
import logger from "../utils/logger";
import { softFileURLToPath } from "./utils";
import type { ResolvedFrame, SufficientStackFrame } from "./types";

/**
* @description
* Rank values:
*
* 0: Can't extract code snippet; useless
*
* 1: WebdriverIO internals: Better than nothing
*
* 2: Project internals: Better than WebdriverIO internals, but worse, than user code part
*
* 3: User code: Best choice
*/
const FRAME_REELVANCE: Record<string, { value: number; matcher: (fileName: string) => boolean }> = {
repl: { value: 0, matcher: fileName => /^REPL\d+$/.test(fileName) },
nodeInternals: { value: 0, matcher: fileName => /^node:[a-zA-Z\-_]/.test(fileName) },
wdioInternals: { value: 1, matcher: fileName => fileName.includes("/node_modules/webdriverio/") },
projectInternals: { value: 2, matcher: fileName => fileName.includes("/node_modules/") },
userCode: { value: 3, matcher: () => true },
} as const;

const getFrameRelevance = (frame: StackFrame): number => {
if ([frame.fileName, frame.lineNumber, frame.columnNumber].some(_.isUndefined)) {
return 0;
}

const fileName: string = softFileURLToPath(frame.fileName!);

for (const factor in FRAME_REELVANCE) {
if (FRAME_REELVANCE[factor].matcher(fileName)) {
return FRAME_REELVANCE[factor].value;
}
}

return 0;
};

export const findRelevantStackFrame = (error: Error): SufficientStackFrame | null => {
try {
const parsedStackFrames = ErrorStackParser.parse(error);

let relevantFrame: SufficientStackFrame | null = null;
let relevantFrameRank = 0;

for (const currentFrame of parsedStackFrames) {
const currentFrameRank = getFrameRelevance(currentFrame);

if (currentFrameRank > relevantFrameRank) {
relevantFrame = currentFrame as SufficientStackFrame;
relevantFrameRank = currentFrameRank;
}
}

return relevantFrame;
} catch (findError) {
logger.warn("Unable to find relevant stack frame:", findError);

return null;
}
};

export const resolveLocationWithStackFrame = (
stackFrame: SufficientStackFrame,
fileContents: string,
): ResolvedFrame => ({
file: softFileURLToPath(stackFrame.fileName),
source: fileContents,
location: { line: stackFrame.lineNumber, column: stackFrame.columnNumber },
});
38 changes: 38 additions & 0 deletions src/error-snippets/index.ts
@@ -0,0 +1,38 @@
import { findRelevantStackFrame, resolveLocationWithStackFrame } from "./frames";
import { extractSourceMaps, resolveLocationWithSourceMap } from "./source-maps";
import { getSourceCodeFile, formatErrorSnippet } from "./utils";
import logger from "../utils/logger";
import type { ResolvedFrame, SufficientStackFrame, WithSnippetError } from "./types";

const stackFrameLocationResolver = async (stackFrame: SufficientStackFrame): Promise<ResolvedFrame> => {
const fileContents = await getSourceCodeFile(stackFrame.fileName);
const sourceMaps = await extractSourceMaps(fileContents, stackFrame.fileName);

return sourceMaps
? resolveLocationWithSourceMap(stackFrame, sourceMaps)
: resolveLocationWithStackFrame(stackFrame, fileContents);
};

export const extendWithCodeSnippet = async (err: WithSnippetError): Promise<WithSnippetError> => {
if (!err) {
return err;
}

try {
const relevantStackFrame = findRelevantStackFrame(err);

if (!relevantStackFrame) {
return err;
}

const { file, source, location } = await stackFrameLocationResolver(relevantStackFrame);

err.snippet = formatErrorSnippet(err, { file, source, location });

return err;
} catch (snippetError) {
logger.warn("Unable to apply code snippet:", snippetError);

return err;
}
};
45 changes: 45 additions & 0 deletions src/error-snippets/source-maps.ts
@@ -0,0 +1,45 @@
import { SourceMapConsumer, type BasicSourceMapConsumer } from "source-map";
import url from "url";
import { SOURCE_MAP_URL_COMMENT } from "./constants";
import { softFileURLToPath, getSourceCodeFile } from "./utils";
import type { SufficientStackFrame, ResolvedFrame } from "./types";

export const extractSourceMaps = async (
fileContents: string,
fileName: string,
): Promise<BasicSourceMapConsumer | null> => {
const sourceMapsStartIndex = fileContents.indexOf(SOURCE_MAP_URL_COMMENT);
const sourceMapsEndIndex = fileContents.indexOf("\n", sourceMapsStartIndex);

if (sourceMapsStartIndex === -1) {
return null;
}

const sourceMapUrl =
sourceMapsEndIndex === -1
? fileContents.slice(sourceMapsStartIndex + SOURCE_MAP_URL_COMMENT.length)
: fileContents.slice(sourceMapsStartIndex + SOURCE_MAP_URL_COMMENT.length, sourceMapsEndIndex);

const sourceMaps = await getSourceCodeFile(url.resolve(fileName, sourceMapUrl));

return new SourceMapConsumer(sourceMaps) as Promise<BasicSourceMapConsumer>;
};

export const resolveLocationWithSourceMap = (
stackFrame: SufficientStackFrame,
sourceMaps: BasicSourceMapConsumer,
): ResolvedFrame => {
const positions = sourceMaps.originalPositionFor({ line: stackFrame.lineNumber, column: stackFrame.columnNumber });
const source = positions.source ? sourceMaps.sourceContentFor(positions.source) : null;
const location = { line: positions.line!, column: positions.column! };

if (!source) {
throw new Error("File source code could not be evaluated from the source map");
}

if (!location.line || !location.column) {
throw new Error("Line and column could not be evaluated from the source map");
}

return { file: softFileURLToPath(sourceMaps.file), source, location };
};
7 changes: 7 additions & 0 deletions src/error-snippets/types.ts
@@ -0,0 +1,7 @@
import type { SetRequired } from "type-fest";

export type WithSnippetError = Error & { snippet?: string };

export type SufficientStackFrame = SetRequired<StackFrame, "fileName" | "lineNumber" | "columnNumber">;

export type ResolvedFrame = { source: string; file: string; location: { line: number; column: number } };

0 comments on commit ddc3e28

Please sign in to comment.