diff --git a/package-lock.json b/package-lock.json index b48974ba2..f0d073d06 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "8.10.1", "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", @@ -42,6 +43,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", @@ -67,6 +69,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", @@ -2634,6 +2637,12 @@ "integrity": "sha512-yOlFc+7UtL/89t2ZhjPvvB/DeAr3r+Dq58IgzsFkOAvVC6NMJXmCGjbptdXdR9qsX7pKcTL+s87FtYREi2dEEQ==", "dev": true }, + "node_modules/@types/babel__code-frame": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/@types/babel__code-frame/-/babel__code-frame-7.0.6.tgz", + "integrity": "sha512-Anitqkl3+KrzcW2k77lRlg/GfLZLWXBuNgbEcIOU6M92yw42vsd3xV/Z/yAHEj8m+KUjL6bWOVOFqX8PFPJ4LA==", + "dev": true + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -8706,6 +8715,15 @@ "source-map": "~0.6.1" } }, + "node_modules/escodegen/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "optional": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/eslint": { "version": "8.25.0", "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.25.0.tgz", @@ -10195,6 +10213,15 @@ "uglify-js": "^3.1.4" } }, + "node_modules/handlebars/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/handlebars/node_modules/uglify-js": { "version": "3.17.0", "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.17.0.tgz", @@ -14712,12 +14739,11 @@ "integrity": "sha512-lJUL9imLTNi1ZfXT+DU6rBBdbiKGBuay9B6xGSPVjUeQwaH1RIGqef8RZkUtHioLmSNpPR5M4HVKJGm1j8FWVQ==" }, "node_modules/source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "devOptional": true, + "version": "0.7.4", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.4.tgz", + "integrity": "sha512-l3BikUxvPOcn5E74dZiq5BGsTb5yEwhaTSzccU6t4sDOH8NWJCstKO5QT2CvtFoK6F0saL7p9xHAqHOlCPJygA==", "engines": { - "node": ">=0.10.0" + "node": ">= 8" } }, "node_modules/source-map-js": { @@ -19197,6 +19223,12 @@ "integrity": "sha512-yOlFc+7UtL/89t2ZhjPvvB/DeAr3r+Dq58IgzsFkOAvVC6NMJXmCGjbptdXdR9qsX7pKcTL+s87FtYREi2dEEQ==", "dev": true }, + "@types/babel__code-frame": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/@types/babel__code-frame/-/babel__code-frame-7.0.6.tgz", + "integrity": "sha512-Anitqkl3+KrzcW2k77lRlg/GfLZLWXBuNgbEcIOU6M92yw42vsd3xV/Z/yAHEj8m+KUjL6bWOVOFqX8PFPJ4LA==", + "dev": true + }, "@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -23654,6 +23686,14 @@ "estraverse": "^5.2.0", "esutils": "^2.0.2", "source-map": "~0.6.1" + }, + "dependencies": { + "source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "optional": true + } } }, "eslint": { @@ -24726,6 +24766,12 @@ "wordwrap": "^1.0.0" }, "dependencies": { + "source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true + }, "uglify-js": { "version": "3.17.0", "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.17.0.tgz", @@ -28066,10 +28112,9 @@ } }, "source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "devOptional": true + "version": "0.7.4", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.4.tgz", + "integrity": "sha512-l3BikUxvPOcn5E74dZiq5BGsTb5yEwhaTSzccU6t4sDOH8NWJCstKO5QT2CvtFoK6F0saL7p9xHAqHOlCPJygA==" }, "source-map-js": { "version": "1.2.0", diff --git a/package.json b/package.json index 01834f3b4..a6194d7ac 100644 --- a/package.json +++ b/package.json @@ -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", @@ -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", @@ -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", diff --git a/src/browser/stacktrace/utils.ts b/src/browser/stacktrace/utils.ts index b1a82e582..a42a6ef06 100644 --- a/src/browser/stacktrace/utils.ts +++ b/src/browser/stacktrace/utils.ts @@ -7,7 +7,7 @@ export type RawStackFrames = string; type ErrorWithStack = SetRequired; -const getErrorTitle = (e: Error): string => { +export const getErrorTitle = (e: Error): string => { let errorName = e.name; if (!errorName && e.stack) { @@ -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 { diff --git a/src/error-snippets/constants.ts b/src/error-snippets/constants.ts new file mode 100644 index 000000000..cbfd02fd4 --- /dev/null +++ b/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; diff --git a/src/error-snippets/frames.ts b/src/error-snippets/frames.ts new file mode 100644 index 000000000..1507126e6 --- /dev/null +++ b/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 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 }, +}); diff --git a/src/error-snippets/index.ts b/src/error-snippets/index.ts new file mode 100644 index 000000000..753012bd1 --- /dev/null +++ b/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 => { + 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 => { + 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; + } +}; diff --git a/src/error-snippets/source-maps.ts b/src/error-snippets/source-maps.ts new file mode 100644 index 000000000..e2162acfa --- /dev/null +++ b/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 => { + 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; +}; + +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 }; +}; diff --git a/src/error-snippets/types.ts b/src/error-snippets/types.ts new file mode 100644 index 000000000..0205856d5 --- /dev/null +++ b/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; + +export type ResolvedFrame = { source: string; file: string; location: { line: number; column: number } }; diff --git a/src/error-snippets/utils.ts b/src/error-snippets/utils.ts new file mode 100644 index 000000000..45b01e87f --- /dev/null +++ b/src/error-snippets/utils.ts @@ -0,0 +1,85 @@ +import path from "path"; +import { fileURLToPath } from "url"; +import fs from "fs-extra"; +import { codeFrameColumns } from "@babel/code-frame"; +import { getErrorTitle } from "../browser/stacktrace/utils"; +import { SNIPPET_LINES_ABOVE, SNIPPET_LINES_BELOW, SOURCE_MAP_URL_COMMENT } from "./constants"; + +interface FormatFileNameHeaderOpts { + line: number; + linesAbove: number; + linesBelow: number; +} + +interface FormatErrorSnippetOpts { + file: string; + source: string; + location: { line: number; column: number }; +} + +export const softFileURLToPath = (fileName: string): string => { + if (!fileName.startsWith("file://")) { + return fileName; + } + + try { + return fileURLToPath(fileName); + } catch (_) { + return fileName; + } +}; + +export const formatFileNameHeader = (fileName: string, opts: FormatFileNameHeaderOpts): string => { + const lineNumberWidth = String(opts.line - opts.linesAbove).length; + const offsetWidth = String(opts.line + opts.linesBelow).length; + + const filePath = softFileURLToPath(fileName); + const relativeFileName = path.isAbsolute(filePath) ? path.relative(process.cwd(), filePath) : filePath; + const lineNumberOffset = ".".repeat(lineNumberWidth).padStart(offsetWidth); + const offset = ` ${lineNumberOffset} |`; + const grayBegin = "\x1B[90m"; + const grayEnd = "\x1B[39m"; + + return [offset + ` // ${relativeFileName}`, offset].map(line => grayBegin + line + grayEnd + "\n").join(""); +}; + +export const formatErrorSnippet = (error: Error, { file, source, location }: FormatErrorSnippetOpts): string => { + const linesAbove = SNIPPET_LINES_ABOVE; + const linesBelow = SNIPPET_LINES_BELOW; + const formattedFileNameHeader = formatFileNameHeader(file, { linesAbove, linesBelow, line: location.line }); + + const snippet = + formattedFileNameHeader + + codeFrameColumns( + source, + { start: location }, + { + linesAbove, + linesBelow, + message: getErrorTitle(error), + highlightCode: true, + forceColor: true, + }, + ); + + return `\n${snippet}\n`; +}; + +export const getSourceCodeFile = async (fileName: string): Promise => { + const filePath = softFileURLToPath(fileName); + + if (path.isAbsolute(filePath)) { + return fs.readFile(filePath, "utf8"); + } + + const response = await fetch(filePath); + const responseText = await response.text(); + + if (responseText.includes(SOURCE_MAP_URL_COMMENT) || !response.headers.has("SourceMap")) { + return responseText; + } + + const sourceMapUrl = response.headers.get("SourceMap"); + + return responseText + "\n" + SOURCE_MAP_URL_COMMENT + sourceMapUrl; +}; diff --git a/src/reporters/flat.js b/src/reporters/flat.js index 101e93901..c264814f7 100644 --- a/src/reporters/flat.js +++ b/src/reporters/flat.js @@ -37,6 +37,9 @@ module.exports = class FlatReporter extends BaseReporter { const icon = testCase.isFailed ? icons.FAIL : icons.RETRY; this.informer.log(` ${testCase.browserId}`); + if (testCase.errorSnippet) { + testCase.errorSnippet.split("\n").forEach(line => this.informer.log(line)); + } this.informer.log(` ${icon} ${testCase.error}`); }); }); diff --git a/src/reporters/utils/helpers.js b/src/reporters/utils/helpers.js index dab82d3da..45b595a6b 100644 --- a/src/reporters/utils/helpers.js +++ b/src/reporters/utils/helpers.js @@ -2,6 +2,7 @@ const path = require("path"); const chalk = require("chalk"); +const stripAnsi = require("strip-ansi"); const _ = require("lodash"); const getSkipReason = test => test && (getSkipReason(test.parent) || test.skipReason); @@ -42,7 +43,11 @@ exports.getTestInfo = test => { }; if (test.err) { - testInfo.error = getTestError(test); + testInfo.error = chalk.supportsColor ? getTestError(test) : stripAnsi(getTestError(test)); + } + + if (test.err && test.err.snippet) { + testInfo.errorSnippet = chalk.supportsColor ? test.err.snippet : stripAnsi(test.err.snippet); } if (test.pending) { diff --git a/src/worker/runner/test-runner/index.js b/src/worker/runner/test-runner/index.js index 364927a3f..283eb373d 100644 --- a/src/worker/runner/test-runner/index.js +++ b/src/worker/runner/test-runner/index.js @@ -10,6 +10,7 @@ const { AssertViewError } = require("../../../browser/commands/assert-view/error const history = require("../../../browser/history"); const { SAVE_HISTORY_MODE } = require("../../../constants/config"); const { filterExtraWdioFrames } = require("../../../browser/stacktrace/utils"); +const { extendWithCodeSnippet } = require("../../../error-snippets"); module.exports = class TestRunner extends Runner { static create(...args) { @@ -122,6 +123,8 @@ module.exports = class TestRunner extends Runner { if (error) { filterExtraWdioFrames(error); + await extendWithCodeSnippet(error); + throw Object.assign(error, results); } diff --git a/test/src/error-snippets/frames.ts b/test/src/error-snippets/frames.ts new file mode 100644 index 000000000..821ddaa80 --- /dev/null +++ b/test/src/error-snippets/frames.ts @@ -0,0 +1,161 @@ +import sinon, { type SinonStub } from "sinon"; +import proxyquire from "proxyquire"; +import { resolveLocationWithStackFrame } from "../../../src/error-snippets/frames"; +import type { SufficientStackFrame } from "../../../src/error-snippets/types"; + +describe("error-snippets/frames", () => { + const sandbox = sinon.createSandbox(); + + let logger: { warn: SinonStub }; + + describe("findRelevantStackFrame", () => { + // prettier-ignore + const frames = { + browser: { + wdio: " at async Element.elementErrorHandlerCallbackFn (http://localhost:4001/node_modules/webdriverio/build/middlewares.js?v=80fca7b2:18:32)", + unknownWdio: " at http://localhost:4001/node_modules/webdriverio/build/commands/browser/waitUntil.js?v=80fca7b2:39:23", + projectDeps: " at async Socket. (http://localhost:4001/node_modules/testplane/build/src/runner/browser-env/vite/browser-modules/mocha/index.js?v=80fca7b2:54:17)", + user: " at async Object. (http://localhost:4001/Users/foo/bar/project/tests-dir/test-name.tsx?import:12:18)", + repl: " at :1:5", + }, + node: { + wdio: " at Element.elementErrorHandlerCallbackFn (/Users/foo/bar/project/node_modules/webdriverio/build/middlewares.js?v=80fca7b2:18:32)", + unknownWdio: " at file:///Users/foo/bar/project/node_modules/webdriverio/build/commands/browser/waitUntil.js:39:23", + projectDeps: " at Object.tryCatcher (/Users/foo/bar/project/node_modules/bluebird/js/release/util.js:16:23)", + user: " at Object. (/Users/foo/bar/project/tests-dir/test-name.js:10:23)", + repl: " at REPL1:1:1", + internal1: " at Module._compile (node:internal/modules/cjs/loader:1159:14)", + internal2: " at Script.runInThisContext (node:vm:129:12)", + }, + } as const; + + let findRelevantStackFrame: (err: Error) => SufficientStackFrame | null; + + const buildError_ = (...errFrames: string[]): Error => { + if (!errFrames.length) { + errFrames = [frames.browser.wdio, frames.node.wdio]; + } + + const error = new Error("foo"); + + error.stack = `${error.name}: ${error.message}\n${errFrames.join("\n")}`; + + return error; + }; + + beforeEach(() => { + logger = { warn: sandbox.stub() }; + findRelevantStackFrame = proxyquire("../../../src/error-snippets/frames", { + "../utils/logger": logger, + }).findRelevantStackFrame; + }); + + it("should return null without throwing exceptions on bad input", () => { + const stackFrame = findRelevantStackFrame("foo" as unknown as Error); + + assert.calledOnceWith(logger.warn, "Unable to find relevant stack frame:"); + assert.isNull(stackFrame); + }); + + it("should not return repl frames", () => { + const error = buildError_(frames.browser.repl, frames.node.repl); + + const stackFrame = findRelevantStackFrame(error); + + console.log(stackFrame); + assert.notCalled(logger.warn); + assert.isNull(stackFrame); + }); + + it("should not return internal frames", () => { + const error = buildError_(frames.node.internal1, frames.node.internal2); + + const stackFrame = findRelevantStackFrame(error); + + assert.notCalled(logger.warn); + assert.isNull(stackFrame); + }); + + it("should return wdio frame, if there is nothing left", () => { + const error = buildError_(frames.node.internal1, frames.node.repl, frames.browser.wdio); + + const stackFrame = findRelevantStackFrame(error); + + assert.match(stackFrame, { + functionName: "async Element.elementErrorHandlerCallbackFn", + columnNumber: 32, + lineNumber: 18, + }); + }); + + it("should return first seen frame with equal relevance", () => { + const error = buildError_(frames.node.internal2, frames.node.repl, frames.node.wdio, frames.browser.wdio); + + const stackFrame = findRelevantStackFrame(error); + + assert.match(stackFrame, { + functionName: "Element.elementErrorHandlerCallbackFn", + columnNumber: 32, + lineNumber: 18, + }); + }); + + it("should rate other project deps frame more, than wdio frame", () => { + const error = buildError_(frames.browser.wdio, frames.browser.unknownWdio, frames.node.projectDeps); + + const stackFrame = findRelevantStackFrame(error); + + assert.match(stackFrame, { + functionName: "Object.tryCatcher", + columnNumber: 23, + lineNumber: 16, + }); + }); + + it("should value user frame above all for browser", () => { + const error = buildError_( + frames.browser.wdio, + frames.browser.projectDeps, + frames.browser.repl, + frames.browser.user, + ); + + const stackFrame = findRelevantStackFrame(error); + + assert.match(stackFrame, { + functionName: "async Object.", + columnNumber: 18, + lineNumber: 12, + }); + }); + + it("should value user frame above all for node", () => { + const error = buildError_(frames.node.wdio, frames.node.unknownWdio, frames.node.repl, frames.node.user); + + const stackFrame = findRelevantStackFrame(error); + + assert.match(stackFrame, { + functionName: "Object.", + columnNumber: 23, + lineNumber: 10, + }); + }); + }); + + it("resolveLocationWithStackFrame", () => { + const resolvedFrame = resolveLocationWithStackFrame( + { + fileName: "filename", + lineNumber: 100, + columnNumber: 500, + } as SufficientStackFrame, + "foo", + ); + + assert.deepEqual(resolvedFrame, { + file: "filename", + source: "foo", + location: { line: 100, column: 500 }, + }); + }); +}); diff --git a/test/src/error-snippets/index.ts b/test/src/error-snippets/index.ts new file mode 100644 index 000000000..a1fdee4eb --- /dev/null +++ b/test/src/error-snippets/index.ts @@ -0,0 +1,131 @@ +import fs from "fs-extra"; +import sinon, { type SinonStub } from "sinon"; +import proxyquire from "proxyquire"; + +describe("error-snippets", () => { + const cloneError = (err: Error): Error => { + const newError = err; + + newError.name = err.name; + newError.message = err.message; + newError.stack = err.stack; + + return newError; + }; + + describe("extendWithCodeSnippet", () => { + const sandbox = sinon.createSandbox(); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let extendWithCodeSnippet: (err?: any) => Promise; + let fsReadFileStub: SinonStub; + let fetchStub: SinonStub; + let formatErrorSnippetStub: SinonStub; + let findRelevantStackFrameStub: SinonStub; + let loggerStub: { warn: SinonStub }; + + beforeEach(() => { + fsReadFileStub = sandbox.stub(fs, "readFile"); + fetchStub = sandbox.stub(globalThis, "fetch"); + + formatErrorSnippetStub = sandbox.stub(); + findRelevantStackFrameStub = sandbox.stub(); + loggerStub = { warn: sandbox.stub() }; + + extendWithCodeSnippet = proxyquire("../../../src/error-snippets", { + "./utils": { formatErrorSnippet: formatErrorSnippetStub }, + "./frames": { findRelevantStackFrame: findRelevantStackFrameStub }, + "../utils/logger": loggerStub, + }).extendWithCodeSnippet; + }); + + afterEach(() => sandbox.restore()); + + it("should not modify error, if it is falsy", async () => { + await assert.eventually.isUndefined(extendWithCodeSnippet()); + await assert.eventually.isNull(extendWithCodeSnippet(null)); + await assert.eventually.equal(extendWithCodeSnippet(""), ""); + + assert.notCalled(loggerStub.warn); + }); + + it("should not modify error, if could not find relevant stack frame", async () => { + const error = new Error("test error"); + const savedError = cloneError(error); + findRelevantStackFrameStub.returns(null); + + await extendWithCodeSnippet(error); + + assert.notCalled(loggerStub.warn); + assert.strictEqual(error, savedError); + }); + + it("should not modify error, if stack frame resolver fails", async () => { + const error = new Error("test error"); + const savedError = cloneError(error); + const stackFrame = { fileName: "/foo/file1" }; + findRelevantStackFrameStub.returns(stackFrame); + fsReadFileStub.withArgs("/foo/file1").rejects(new Error("read file error")); + + const result = await extendWithCodeSnippet(error); + + assert.strictEqual(error, savedError); + assert.strictEqual(error, result); + assert.calledOnceWith(loggerStub.warn, "Unable to apply code snippet:"); + }); + + it("should return the same Object.is object", async () => { + const error = new Error("test error"); + const stackFrame = { fileName: "/file/file1", lineNumber: 100, columnNumber: 500 }; + findRelevantStackFrameStub.returns(stackFrame); + formatErrorSnippetStub.returns("code snippet"); + fsReadFileStub.resolves("source code"); + + const result = await extendWithCodeSnippet(error); + + assert.notCalled(loggerStub.warn); + assert.equal(error, result); + }); + + it("should return error with snippet for file path", async () => { + const error = new Error("test error"); + const stackFrame = { fileName: "/file/file1", lineNumber: 100, columnNumber: 500 }; + findRelevantStackFrameStub.returns(stackFrame); + formatErrorSnippetStub.returns("code snippet"); + fsReadFileStub.resolves("source code"); + + const result = await extendWithCodeSnippet(error); + + assert.notCalled(loggerStub.warn); + assert.equal(result.snippet, "code snippet"); + }); + + it("should return error with snippet for network url", async () => { + const error = new Error("test error"); + const stackFrame = { fileName: "http://localhost:3000/file/file1", lineNumber: 100, columnNumber: 500 }; + findRelevantStackFrameStub.returns(stackFrame); + formatErrorSnippetStub.returns("code snippet"); + fetchStub.withArgs("http://localhost:3000/file/file1").resolves({ + text: () => Promise.resolve("source code"), + headers: new Map(), + }); + + const result = await extendWithCodeSnippet(error); + + assert.notCalled(loggerStub.warn); + assert.equal(result.snippet, "code snippet"); + }); + + it("should return error, if formatFileNameHeader fails", async () => { + const error = new Error("test error"); + const stackFrame = { fileName: "/file/file1", lineNumber: 100, columnNumber: 500 }; + findRelevantStackFrameStub.returns(stackFrame); + fsReadFileStub.returns("source code"); + formatErrorSnippetStub.throws(new Error()); + + await extendWithCodeSnippet(error); + + assert.calledOnceWith(loggerStub.warn, "Unable to apply code snippet:"); + }); + }); +}); diff --git a/test/src/error-snippets/source-maps.ts b/test/src/error-snippets/source-maps.ts new file mode 100644 index 000000000..88952ac9c --- /dev/null +++ b/test/src/error-snippets/source-maps.ts @@ -0,0 +1,97 @@ +import sinon, { type SinonStub } from "sinon"; +import { SourceMapConsumer, type BasicSourceMapConsumer } from "source-map"; +import { extractSourceMaps, resolveLocationWithSourceMap } from "./../../../src/error-snippets/source-maps"; +import type { SufficientStackFrame, ResolvedFrame } from "../../../src/error-snippets/types"; + +describe("error-snippets/source-maps", () => { + const sandbox = sinon.createSandbox(); + + let fetchStub: SinonStub; + + beforeEach(() => { + fetchStub = sandbox.stub(globalThis, "fetch"); + }); + + afterEach(() => sandbox.restore()); + + describe("extractSourceMaps", () => { + it("should return null if source maps comment is not present in file content", async () => { + const fileContents = 'console.log("Hello, World!");'; + const fileName = "test.js"; + + const result = await extractSourceMaps(fileContents, fileName); + + assert.isNull(result); + }); + + it("should return a SourceMapConsumer instance if source maps comment is present in file content", async () => { + const inlineSourceMap = + "data:application/json;base64,eyJ2ZXJzaW9uIjozLCJzb3VyY2VzIjpbXSwibmFtZXMiOltdLCJtYXBwaW5ncyI6IiJ9"; + const fileContents = `console.log("Hello, World!");\n//# sourceMappingURL=${inlineSourceMap}`; + const fileName = "test.js"; + fetchStub.withArgs(inlineSourceMap).resolves({ + text: () => Promise.resolve('{"version":3,"sources":[],"names":[],"mappings":""}'), + headers: new Map(), + }); + + const result = await extractSourceMaps(fileContents, fileName); + + assert.instanceOf(result, SourceMapConsumer); + }); + }); + + describe("resolveLocationWithSourceMap", () => { + it("should throw an error when source is null", async () => { + const sourceMaps = (await new SourceMapConsumer( + JSON.stringify({ + version: 3, + sources: [], + names: [], + mappings: "", + }), + )) as BasicSourceMapConsumer; + const stackFrame = { lineNumber: 5, columnNumber: 10 } as SufficientStackFrame; + + const fn = (): ResolvedFrame => resolveLocationWithSourceMap(stackFrame, sourceMaps); + + assert.throw(fn, "File source code could not be evaluated from the source map"); + }); + + it("should throw an error when line or column is null", async () => { + const sourceMaps = (await new SourceMapConsumer( + JSON.stringify({ + version: 3, + sources: ["file1"], + names: [], + mappings: "", + sourcesContent: ["content"], + }), + )) as BasicSourceMapConsumer; + sandbox.stub(sourceMaps, "originalPositionFor").returns({ source: "file1" }); + const stackFrame = { lineNumber: 5, columnNumber: 10 } as SufficientStackFrame; + + const fn = (): ResolvedFrame => resolveLocationWithSourceMap(stackFrame, sourceMaps); + + assert.throw(fn, "Line and column could not be evaluated from the source map"); + }); + + it("should return ResolvedFrame", async () => { + const sourceMaps = (await new SourceMapConsumer( + JSON.stringify({ + version: 3, + sources: ["file1"], + names: [], + mappings: "AAAA;AACA", + sourcesContent: ["content"], + }), + )) as BasicSourceMapConsumer; + sourceMaps.file = "file:///file1"; + sandbox.stub(sourceMaps, "originalPositionFor").returns({ source: "file1", line: 100, column: 500 }); + const stackFrame = { lineNumber: 1, columnNumber: 1 } as SufficientStackFrame; + + const result = resolveLocationWithSourceMap(stackFrame, sourceMaps); + + assert.deepEqual(result, { file: "/file1", source: "content", location: { line: 100, column: 500 } }); + }); + }); +}); diff --git a/test/src/error-snippets/utils.ts b/test/src/error-snippets/utils.ts new file mode 100644 index 000000000..22f2bf564 --- /dev/null +++ b/test/src/error-snippets/utils.ts @@ -0,0 +1,202 @@ +import path from "path"; +import sinon from "sinon"; +import url from "url"; +import fs from "fs-extra"; +import { + softFileURLToPath, + formatFileNameHeader, + getSourceCodeFile, + formatErrorSnippet, +} from "../../../src/error-snippets/utils"; +import type { codeFrameColumns } from "@babel/code-frame"; + +const codeFrame = require("@babel/code-frame"); // eslint-disable-line @typescript-eslint/no-var-requires + +const withGray = (line: string): string => "\x1B[90m" + line + "\x1B[39m"; + +describe("error-snippets/utils", () => { + const sandbox = sinon.createSandbox(); + + afterEach(() => sandbox.restore()); + + describe("softFileURLToPath", () => { + it("should return same fileName if it does not start with 'file://'", () => { + const file = "data/myFile.txt"; + + const result = softFileURLToPath(file); + + assert.equal(result, file); + }); + + it("should convert file URL to path if fileName starts with 'file://'", () => { + const file = "file:///data/myFile.txt"; + + const result = softFileURLToPath(file); + + assert.equal(result, "/data/myFile.txt"); + }); + + it("should return same fileName if it starts with 'file://' but conversion fails", () => { + sandbox.stub(url, "fileURLToPath").throws(new Error("foo")); + const file = "file://invalid/path.txt"; + + const result = softFileURLToPath(file); + + assert.equal(result, file); + }); + }); + + describe("formatFileNameHeader", () => { + it("should handle line Offset correctly", () => { + const fileName = "myFile.txt"; + + const opts = { + line: 10, + linesAbove: 5, + linesBelow: 5, + }; + + const result = formatFileNameHeader(fileName, opts); + const expectedLine1 = `\x1B[90m . | // myFile.txt\x1B[39m\n`; + const expectedLine2 = `\x1B[90m . |\x1B[39m\n`; + const expected = expectedLine1 + expectedLine2; + + assert.equal(result, expected); + }); + + it("should return correct format for different options", () => { + const opts = { + line: 100, + linesAbove: 50, + linesBelow: 50, + }; + + const result = formatFileNameHeader("myFile.txt", opts); + const expected = [" .. | // myFile.txt", " .. |"] + .map(withGray) + .map(line => line + "\n") + .join(""); + + assert.equal(result, expected); + }); + }); + + describe("formatErrorSnippet", () => { + let codeFrameColumnsStub: typeof codeFrameColumns; + + beforeEach(() => { + codeFrameColumnsStub = sandbox.stub(codeFrame, "codeFrameColumns").returns("code snippet"); + }); + + it("should pass right args to codeFrameColumns", () => { + const err = new Error("foo"); + + formatErrorSnippet(err, { + file: "file", + source: "source", + location: { line: 100, column: 500 }, + }); + + assert.calledOnceWith( + codeFrameColumnsStub, + "source", + { start: { line: 100, column: 500 } }, + { + linesAbove: 2, + linesBelow: 3, + message: "Error: foo", + highlightCode: true, + forceColor: true, + }, + ); + }); + + it("should start and end with a new line", () => { + const err = new Error("foo"); + + const snippet = formatErrorSnippet(err, { + file: "file", + source: "source", + location: { line: 100, column: 500 }, + }); + + assert.isTrue(snippet.startsWith("\n")); + assert.isTrue(snippet.endsWith("\n")); + }); + + it("should include formatted relative file name", () => { + sandbox.stub(path, "isAbsolute").returns(true); + sandbox.stub(path, "relative").returns("relative-file-path"); + const err = new Error("foo"); + + const snippet = formatErrorSnippet(err, { + file: "file", + source: "source", + location: { line: 100, column: 500 }, + }); + + assert.isTrue(snippet.includes("| // relative-file-path")); + }); + }); + + describe("getSourceCodeFile", () => { + let fetchStub: sinon.SinonStub; + let fsReadFileStub: sinon.SinonStub; + + beforeEach(() => { + fetchStub = sandbox.stub(globalThis, "fetch"); + fsReadFileStub = sandbox.stub(fs, "readFile"); + }); + + it("should read from file if file is absolute path", async () => { + const absolutePath = "/foo/code.js"; + const fileContent = "bar"; + fsReadFileStub.withArgs("/foo/code.js").resolves(fileContent); + + const response = await getSourceCodeFile(absolutePath); + + assert.equal(response, fileContent); + assert.notCalled(fetchStub); + }); + + it("should fetch if file is URL", async () => { + const url = "http://example.com/code.js"; + const fileContent = "Hello, World!"; + fetchStub.withArgs(url).resolves({ + headers: new Map(), + text: () => Promise.resolve(fileContent), + }); + + const result = await getSourceCodeFile(url); + + assert.calledOnceWith(fetchStub, url); + assert.equal(result, fileContent); + }); + + it("should fetch with resolving SourceMap header if file is URL", async () => { + const url = "http://example.com/code.js"; + const fileContent = "Hello, World!"; + const headers = new Headers(); + headers.append("SourceMap", "source-map.js.map"); + const response = new Response(fileContent, { headers }); + fetchStub.resolves(response); + + const result = await getSourceCodeFile(url); + + assert.equal(result, "Hello, World!\n//# sourceMappingURL=source-map.js.map"); + }); + + it("should return response text if response text includes sourceMappingURL", async () => { + const url = "http://example.com/code.js"; + const fileContent = `"Hello, World!\n//# sourceMappingURL=some-source-map.js.map"`; + const headers = new Headers(); + headers.append("SourceMap", "another-source-map.js.map"); + const response = new Response(fileContent, { headers }); + fetchStub.resolves(response); + + const result = await getSourceCodeFile(url); + + assert.equal(result, fileContent); + }); + }); +});