Skip to content

Commit

Permalink
feat: add error snippets
Browse files Browse the repository at this point in the history
  • Loading branch information
KuznetsovRoman committed Apr 26, 2024
1 parent 04c2c2a commit be2a68f
Show file tree
Hide file tree
Showing 16 changed files with 880 additions and 11 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
Original file line number Diff line number Diff line change
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
2 changes: 1 addition & 1 deletion src/browser/stacktrace/utils.ts
Original file line number Diff line number Diff line change
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
4 changes: 4 additions & 0 deletions src/error-snippets/constants.ts
Original file line number Diff line number Diff line change
@@ -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;
61 changes: 61 additions & 0 deletions src/error-snippets/frames.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import _ from "lodash";
import ErrorStackParser from "error-stack-parser";
import logger from "../utils/logger";
import { softFileURLToPath } from "./utils";
import type { ResolvedFrame, SufficientStackFrame } from "./types";

// prettier-ignore
const frameRelevance: Record<string, { value: number; matcher: (fileName: string) => boolean }> = {
repl: { value: 0, matcher: fileName => /^REPL\d+$/.test(fileName) },
nodeNative: { value: 0, matcher: fileName => fileName === "native" },
nodeInternals: { value: 0, matcher: fileName => /^node:[a-zA-Z\-_]/.test(fileName) || /^[a-zA-Z\-_]+\.[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 frameRelevance) {
if (frameRelevance[factor].matcher(fileName)) {
return frameRelevance[factor].value;
}
}

return 0;
};

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

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

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

if (currentFrameRelevance > frameRelevance) {
relevantFrame = currentFrame as SufficientStackFrame;
frameRelevance = currentFrameRelevance;
}
}

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

return null;
}
};

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

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

return sourceMaps ? resolveWithSourceMap(stackFrame, sourceMaps) : resolveWithStackFrame(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 stackFrameResolver(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
Original file line number Diff line number Diff line change
@@ -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 resolveWithSourceMap = (
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
Original file line number Diff line number Diff line change
@@ -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 be2a68f

Please sign in to comment.