From 2d10b1f89fb5d1fb45e0cc5f8b5cf91b7ccb508d Mon Sep 17 00:00:00 2001 From: Alexander Prokhorov Date: Thu, 5 Nov 2026 22:53:48 +0200 Subject: [PATCH 1/2] Inline callsite-record implementation Vendor the verified 4.1.7 source and MIT license, replace package imports, and add rendering regression coverage. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> # Conflicts: # package-lock.json --- Gulpfile.js | 3 +- package-lock.json | 79 +---- package.json | 6 +- src/api/test-controller/assertion.ts | 2 +- src/api/test-controller/index.d.ts | 2 +- src/assertions/executor.ts | 2 +- src/errors/get-callsite.js | 4 +- .../test-run/render-error-template/utils.ts | 2 +- src/test-run/bookmark.ts | 2 +- src/test-run/index.ts | 2 +- .../request/create-request-options.ts | 2 +- src/test-run/request/send.ts | 2 +- src/utils/callsite-record/LICENSE | 22 ++ src/utils/callsite-record/README.md | 16 + src/utils/callsite-record/index.d.ts | 68 ++++ src/utils/callsite-record/index.js | 334 ++++++++++++++++++ .../callsite-record/renderers/default.js | 44 +++ src/utils/callsite-record/renderers/html.js | 38 ++ .../callsite-record/renderers/index.d.ts | 37 ++ .../callsite-record/renderers/no-color.js | 28 ++ src/utils/get-renderes.ts | 2 +- src/utils/prerender-callsite.ts | 2 +- src/utils/render-callsite-sync.js | 2 +- test/server/callsite-record-test.js | 131 +++++++ test/server/data/callsite-record/source.js | 2 + test/server/data/test-callsite/index.js | 4 +- 26 files changed, 755 insertions(+), 83 deletions(-) create mode 100644 src/utils/callsite-record/LICENSE create mode 100644 src/utils/callsite-record/README.md create mode 100644 src/utils/callsite-record/index.d.ts create mode 100644 src/utils/callsite-record/index.js create mode 100644 src/utils/callsite-record/renderers/default.js create mode 100644 src/utils/callsite-record/renderers/html.js create mode 100644 src/utils/callsite-record/renderers/index.d.ts create mode 100644 src/utils/callsite-record/renderers/no-color.js create mode 100644 test/server/callsite-record-test.js create mode 100644 test/server/data/callsite-record/source.js diff --git a/Gulpfile.js b/Gulpfile.js index 2eefdcf97c4..5025c5bc1cc 100644 --- a/Gulpfile.js +++ b/Gulpfile.js @@ -213,8 +213,9 @@ gulp.step('templates', () => { return gulp .src([ 'src/**/*.mustache', + 'src/utils/callsite-record/LICENSE', '!src/**/*.js.wrapper.mustache', - ]) + ], { base: 'src' }) .pipe(gulp.dest('lib')); }); diff --git a/package-lock.json b/package-lock.json index 4982af020d0..165a6e4b0f1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27,7 +27,6 @@ "@babel/preset-react": "^7.22.15", "@babel/runtime": "^7.23.2", "@devexpress/bin-v8-flags-filter": "^1.3.0", - "@devexpress/callsite-record": "^4.1.6", "@types/node": "20.14.5", "address": "^2.0.2", "async-exit-hook": "^1.1.2", @@ -36,7 +35,7 @@ "bowser": "^2.8.1", "callsite": "^1.0.0", "chai": "4.3.4", - "chalk": "^2.3.0", + "chalk": "^2.4.0", "chrome-remote-interface": "^0.32.2", "coffeescript": "^2.3.1", "commander": "^8.3.0", @@ -55,6 +54,7 @@ "globby": "^11.0.4", "graceful-fs": "^4.1.11", "graphlib": "^2.1.5", + "highlight-es": "^1.0.0", "http-status-codes": "^2.2.0", "humanize-duration": "^3.25.0", "import-lazy": "^3.1.0", @@ -77,6 +77,7 @@ "parse5": "^1.5.0", "pify": "^2.3.0", "pinkie": "^2.0.4", + "pinkie-promise": "^2.0.0", "pngjs": "^3.3.1", "pretty-hrtime": "^1.0.3", "promisify-event": "^1.0.0", @@ -123,6 +124,7 @@ "@types/dedent": "^0.7.0", "@types/diff": "^4.0.2", "@types/json5": "0.0.30", + "@types/lodash": "^4.14.72", "@types/mime-db": "^1.27.0", "@types/mustache": "^0.8.32", "@types/prompts": "^2.0.14", @@ -218,6 +220,7 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", @@ -1950,21 +1953,6 @@ "integrity": "sha512-LWLNfYGwVJKYpmHUDoODltnlqxdEAl5Qmw7ha1+TSpsABeF94NKSWkQTTV1TB4CM02j2pZyqn36nHgaFl8z7qw==", "license": "MIT" }, - "node_modules/@devexpress/callsite-record": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/@devexpress/callsite-record/-/callsite-record-4.1.7.tgz", - "integrity": "sha512-qr3VQYc0KopduFkEY6SxaOIi1Xhm0jIWQfrxxMVboI/p2rjF/Mj/iqaiUxQQP6F3ujpW/7l0mzhf17uwcFZhBA==", - "license": "MIT", - "dependencies": { - "@types/lodash": "^4.14.72", - "callsite": "^1.0.0", - "chalk": "^2.4.0", - "error-stack-parser": "^2.1.4", - "highlight-es": "^1.0.0", - "lodash": "4.6.1 || ^4.16.1", - "pinkie-promise": "^2.0.0" - } - }, "node_modules/@devexpress/gulp-qunit-harness": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@devexpress/gulp-qunit-harness/-/gulp-qunit-harness-1.1.0.tgz", @@ -2860,9 +2848,6 @@ "arm" ], "dev": true, - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2880,9 +2865,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2900,9 +2882,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2920,9 +2899,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2940,9 +2916,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2960,9 +2933,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2980,9 +2950,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -3000,9 +2967,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -3020,9 +2984,6 @@ "arm" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -3046,9 +3007,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -3072,9 +3030,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -3098,9 +3053,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -3124,9 +3076,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -3150,9 +3099,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -3176,9 +3122,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -3202,9 +3145,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -3690,6 +3630,7 @@ "version": "4.17.25", "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.25.tgz", "integrity": "sha512-+K1NIO8I+F9/wNulfVvu23QYd0Pe9/OCqRrim4NoYIf1VoEDL90Ve4ClzpyqBLc7NpGGWRvYNCKZ1BE/Jpf8dQ==", + "dev": true, "license": "MIT" }, "node_modules/@types/mime-db": { @@ -3828,6 +3769,7 @@ "integrity": "sha512-CG4nPk1f2zc8yw4pALqHsFYH2hdo+h1T9daSp21+Hnxi9LOE3GT9hAfTKJCBXVNM2GmYs1eMEP615wPoeOgk3A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.71.0", "@typescript-eslint/types": "8.71.0", @@ -4243,6 +4185,7 @@ "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -5009,6 +4952,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.11.26", "caniuse-lite": "^1.0.30001813", @@ -5290,6 +5234,7 @@ "resolved": "https://registry.npmjs.org/chai/-/chai-4.3.4.tgz", "integrity": "sha512-yS5H68VYOCtN1cjfwumDSuzn/9c+yza4f3reKXlE5rUg7SFcCEy90gJvydNgOYtblyf4Zi6jIWRnXOgErta0KA==", "license": "MIT", + "peer": true, "dependencies": { "assertion-error": "^1.1.0", "check-error": "^1.0.2", @@ -6357,6 +6302,7 @@ "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -11968,6 +11914,7 @@ "integrity": "sha512-cIFJOD1DESzpjOBl763Kp1AH7UE/0fcdHe6rZXUdQ9c50uvgigvW97u3IcSeBwOkgqL/PXPBktBCh0KEu5L8XQ==", "dev": true, "license": "MIT", + "peer": true, "bin": { "rollup": "dist/bin/rollup" }, @@ -13406,6 +13353,7 @@ "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -13600,6 +13548,7 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz", "integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==", "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" diff --git a/package.json b/package.json index 139ea6f3946..22114e26ce6 100644 --- a/package.json +++ b/package.json @@ -76,7 +76,6 @@ "@babel/preset-react": "^7.22.15", "@babel/runtime": "^7.23.2", "@devexpress/bin-v8-flags-filter": "^1.3.0", - "@devexpress/callsite-record": "^4.1.6", "@types/node": "20.14.5", "address": "^2.0.2", "async-exit-hook": "^1.1.2", @@ -85,7 +84,7 @@ "bowser": "^2.8.1", "callsite": "^1.0.0", "chai": "4.3.4", - "chalk": "^2.3.0", + "chalk": "^2.4.0", "chrome-remote-interface": "^0.32.2", "coffeescript": "^2.3.1", "commander": "^8.3.0", @@ -104,6 +103,7 @@ "globby": "^11.0.4", "graceful-fs": "^4.1.11", "graphlib": "^2.1.5", + "highlight-es": "^1.0.0", "http-status-codes": "^2.2.0", "humanize-duration": "^3.25.0", "import-lazy": "^3.1.0", @@ -126,6 +126,7 @@ "parse5": "^1.5.0", "pify": "^2.3.0", "pinkie": "^2.0.4", + "pinkie-promise": "^2.0.0", "pngjs": "^3.3.1", "pretty-hrtime": "^1.0.3", "promisify-event": "^1.0.0", @@ -169,6 +170,7 @@ "@types/dedent": "^0.7.0", "@types/diff": "^4.0.2", "@types/json5": "0.0.30", + "@types/lodash": "^4.14.72", "@types/mime-db": "^1.27.0", "@types/mustache": "^0.8.32", "@types/prompts": "^2.0.14", diff --git a/src/api/test-controller/assertion.ts b/src/api/test-controller/assertion.ts index d57b4adcdc3..03633a86960 100644 --- a/src/api/test-controller/assertion.ts +++ b/src/api/test-controller/assertion.ts @@ -19,7 +19,7 @@ import { } from '../../test-run/commands/assertion'; import { AssertionWithoutMethodCallError } from '../../errors/test-run'; import TestController from './index'; -import { CallsiteRecord } from '@devexpress/callsite-record'; +import { CallsiteRecord } from '../../utils/callsite-record'; import { AssertionOptions } from '../../test-run/commands/options'; import { isClientFunction, isSelector } from '../../client-functions/types'; import addWarning from '../../notifications/add-rendered-warning'; diff --git a/src/api/test-controller/index.d.ts b/src/api/test-controller/index.d.ts index ee030e8ecdc..892771d27f1 100644 --- a/src/api/test-controller/index.d.ts +++ b/src/api/test-controller/index.d.ts @@ -1,7 +1,7 @@ import TestRun from '../../test-run'; import WarningLog from '../../notifications/warning-log'; import { CommandBase } from '../../test-run/commands/base'; -import { CallsiteRecord } from '@devexpress/callsite-record'; +import { CallsiteRecord } from '../../utils/callsite-record'; export default class TestController { public browser: string; diff --git a/src/assertions/executor.ts b/src/assertions/executor.ts index feded60a5ff..91ab4f13ac5 100644 --- a/src/assertions/executor.ts +++ b/src/assertions/executor.ts @@ -5,7 +5,7 @@ import { ExternalAssertionLibraryError, AssertionUnawaitedPromiseError } from '. import ReExecutablePromise from '../utils/re-executable-promise'; import getFn from './get-fn'; import { AssertionCommand } from '../test-run/commands/assertion'; -import { CallsiteRecord } from '@devexpress/callsite-record'; +import { CallsiteRecord } from '../utils/callsite-record'; const ASSERTION_DELAY = 200; diff --git a/src/errors/get-callsite.js b/src/errors/get-callsite.js index 2a40cfff0ef..16df85dbcb0 100644 --- a/src/errors/get-callsite.js +++ b/src/errors/get-callsite.js @@ -1,4 +1,4 @@ -import createCallsiteRecord from '@devexpress/callsite-record'; +import createCallsiteRecord from '../utils/callsite-record'; import stackCleaningHook from './stack-cleaning-hook'; import { wrapCallSite } from 'source-map-support'; @@ -25,7 +25,7 @@ export function getCallsiteForMethod (methodName, typeName) { export function getCallsiteForError (error, isCallsiteFrame) { // NOTE: "source-map-support" process this kind of error automatically, cause - // in this case there is an appeal to "err.stack" inside "@devexpress/callsite-record" which + // in this case there is an appeal to "err.stack" inside "callsite-record" which // provokes wrapping of frames, so there is no need to specify "processFrameFn". return getCallsite({ forError: error, isCallsiteFrame }); } diff --git a/src/errors/test-run/render-error-template/utils.ts b/src/errors/test-run/render-error-template/utils.ts index fa35bf39703..6f464c23571 100644 --- a/src/errors/test-run/render-error-template/utils.ts +++ b/src/errors/test-run/render-error-template/utils.ts @@ -1,4 +1,4 @@ -import { renderers } from '@devexpress/callsite-record'; +import { renderers } from '../../../utils/callsite-record'; import { UncaughtTestCafeErrorInCustomScript } from '../../test-run'; import { shouldSkipCallsite } from '../utils'; import TEMPLATES from '../templates'; diff --git a/src/test-run/bookmark.ts b/src/test-run/bookmark.ts index 82489ca98a5..70ea6448cca 100644 --- a/src/test-run/bookmark.ts +++ b/src/test-run/bookmark.ts @@ -17,7 +17,7 @@ import Role, { RedirectUrl } from '../role/role'; import { DEFAULT_SPEED_VALUE } from '../configuration/default-values'; import BrowserConsoleMessages from './browser-console-messages'; import { CommandBase } from './commands/base'; -import { CallsiteRecord } from '@devexpress/callsite-record'; +import { CallsiteRecord } from '../utils/callsite-record'; export default class TestRunBookmark { private readonly testRun: TestRun; diff --git a/src/test-run/index.ts b/src/test-run/index.ts index f291b0dc613..6c39c7dafb8 100644 --- a/src/test-run/index.ts +++ b/src/test-run/index.ts @@ -95,7 +95,7 @@ import DriverStatus from '../client/driver/status'; import { CommandBase, ActionCommandBase } from './commands/base.js'; import Role from '../role/role'; import { TestRunErrorBase } from '../shared/errors'; -import { CallsiteRecord } from '@devexpress/callsite-record'; +import { CallsiteRecord } from '../utils/callsite-record'; import EventEmitter from 'events'; import getAssertionTimeout from '../utils/get-options/get-assertion-timeout'; import { AssertionCommand } from './commands/assertion'; diff --git a/src/test-run/request/create-request-options.ts b/src/test-run/request/create-request-options.ts index baee5d99325..4d116df720a 100644 --- a/src/test-run/request/create-request-options.ts +++ b/src/test-run/request/create-request-options.ts @@ -25,7 +25,7 @@ import HTTP_HEADERS from '../../utils/http-headers'; import { RUNTIME_ERRORS } from '../../errors/types'; import { APIError } from '../../errors/runtime'; import { GetProxyUrlCommand } from '../commands/actions'; -import { CallsiteRecord } from '@devexpress/callsite-record'; +import { CallsiteRecord } from '../../utils/callsite-record'; const DEFAULT_ACCEPT = { [HTTP_HEADERS.accept]: `${CONTENT_TYPES.json}, ${CONTENT_TYPES.textPlain}, ${CONTENT_TYPES.all}` }; const METHODS_WITH_CONTENT_TYPE = ['POST', 'PUT', 'PATCH']; diff --git a/src/test-run/request/send.ts b/src/test-run/request/send.ts index 6ee2096a962..387ac78bbed 100644 --- a/src/test-run/request/send.ts +++ b/src/test-run/request/send.ts @@ -5,7 +5,7 @@ import { createRequestOptions } from './create-request-options'; import { processResponseData } from './process-response-data'; import HTTP_HEADERS from '../../utils/http-headers'; import { RequestRuntimeError } from '../../errors/runtime'; -import { CallsiteRecord } from '@devexpress/callsite-record'; +import { CallsiteRecord } from '../../utils/callsite-record'; import { RUNTIME_ERRORS } from '../../errors/types'; import { diff --git a/src/utils/callsite-record/LICENSE b/src/utils/callsite-record/LICENSE new file mode 100644 index 00000000000..c0b99513040 --- /dev/null +++ b/src/utils/callsite-record/LICENSE @@ -0,0 +1,22 @@ +The MIT License (MIT) + +Copyright (c) 2016 Ivan Nikulin (ifaaan@gmail.com, https://github.com/inikulin) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + diff --git a/src/utils/callsite-record/README.md b/src/utils/callsite-record/README.md new file mode 100644 index 00000000000..2a504206c3d --- /dev/null +++ b/src/utils/callsite-record/README.md @@ -0,0 +1,16 @@ +# Callsite record + +This directory incorporates the implementation and type declarations from +[DevExpress/callsite-record v4.1.7](https://github.com/DevExpress/callsite-record/tree/v4.1.7), +commit `f746091a7cafec7e4c4f92aef9a03d196c0b362b`. This commit also matches the +published `@devexpress/callsite-record@4.1.7` package's `gitHead`. + +The original MIT license and attribution are preserved in `LICENSE`, which is +copied into `lib/utils/callsite-record` for distribution. Formatting is adapted +to TestCafe's lint rules; the CommonJS API and rendering behavior are preserved. +The accompanying declarations describe the same type-only `CallsiteRecord` +export as the original package. + +Runtime dependencies are declared directly in TestCafe's `package.json`. +The example lockfiles describe published TestCafe releases, not this local +implementation, and retain those releases' original dependency graphs. diff --git a/src/utils/callsite-record/index.d.ts b/src/utils/callsite-record/index.d.ts new file mode 100644 index 00000000000..8f9feff8455 --- /dev/null +++ b/src/utils/callsite-record/index.d.ts @@ -0,0 +1,68 @@ +// Type definitions for callsite-record v4.0.0 +// Project: https://github.com/inikulin/callsite-record +// Definitions by: Alvis HT Tang +// Definitions: https://github.com/DefinitelyTyped/DefinitelyTyped + +import { Renderer, Renderers } from './renderers'; +import 'error-stack-parser'; + +declare namespace createCallsiteRecord { + const renderers: Renderers; + + type StackFrame = StackFrame.StackFrameOptions; + type isCallSiteFrame = (stackFrame: StackFrame) => boolean; + type processFrameFn = (stackFrame: StackFrame) => StackFrame; + type stackFilter = ( + stackFrame: StackFrame, + idx: number, + isV8StackFrame: boolean + ) => boolean; + + interface RenderOptions { + codeFrame?: boolean; + frameSize?: number; + renderer?: Renderer; + stack?: boolean; + stackFilter?: stackFilter; + } + + class CallsiteRecord { + public static fromError( + error: Error, + isCallsiteFrame: isCallSiteFrame, + processFrameFn: processFrameFn + ): CallsiteRecord | null; + + public static fromStackFrames( + stackFrames: StackFrame[], + fnName: string, + typeName: string, + processFrameFn: processFrameFn + ): CallsiteRecord | null; + + public render(options: RenderOptions): Promise; + public renderSync(options: RenderOptions): string; + } + + interface CreateCallsiteRecordOptions { + forError: Error; + isCallsiteFrame?: isCallSiteFrame; + byFunctionName: string; + typeName?: string; + processFrameFn?: processFrameFn; + } +} + +declare function createCallsiteRecord( + options: + | Pick< + createCallsiteRecord.CreateCallsiteRecordOptions, + 'forError' | 'isCallsiteFrame' | 'processFrameFn' + > + | Pick< + createCallsiteRecord.CreateCallsiteRecordOptions, + 'byFunctionName' | 'typeName' | 'processFrameFn' + > +): createCallsiteRecord.CallsiteRecord | null; + +export = createCallsiteRecord; diff --git a/src/utils/callsite-record/index.js b/src/utils/callsite-record/index.js new file mode 100644 index 00000000000..e16238f9b8f --- /dev/null +++ b/src/utils/callsite-record/index.js @@ -0,0 +1,334 @@ +const fs = require('fs'); +const Promise = require('pinkie-promise'); +const callsite = require('callsite'); +const stackParser = require('error-stack-parser'); +const padStart = require('lodash').padStart; +const defaults = require('lodash').defaults; +const highlight = require('highlight-es'); + +const renderers = { + default: require('./renderers/default'), + noColor: require('./renderers/no-color'), + html: require('./renderers/html'), +}; + + +const NEWLINE = /\r\n|[\n\r\u2028\u2029]/; + +// Utils +function parseStack (error) { + try { + return stackParser.parse(error); + } + catch (err) { + return null; + } +} + +function getFrameTypeName (frame) { + // NOTE: this throws in node 10 for non-methods + try { + return frame.getTypeName(); + } + catch (err) { + // Preserve the upstream diagnostic for unsupported V8 frame types. + // eslint-disable-next-line no-console + console.log(err); + return null; + } +} + +function findClosestNonNativeAncestorFrameIdx (stackFrames, curIdx) { + for (let i = curIdx + 1; i < stackFrames.length; i++) { + if (!stackFrames[i].isNative() && stackFrames[i].getFileName()) + return i; + } + + return null; +} + +function isV8StackFrame (frame) { + return /CallSite/.test(frame.constructor); +} + +function getFrameMethodName (frame, funcName) { + // NOTE: Code was partially adopted from the V8 code + // (see: https://github.com/v8/v8/blob/3c3d7e7be80f45eeea0dc74a71d7552e2afc2985/src/js/messages.js#L647) + const typeName = frame.getTypeName(); + const methodName = frame.getMethodName(); + + if (funcName) { + let name = ''; + const funcNameStartsWithTypeName = typeName && funcName.indexOf(typeName) === 0; + const expectedEndMethodIndex = methodName && funcName.length - methodName.length - 1; + const actualMethodIndex = funcName.indexOf('.' + methodName); + const funcNameEndsWithMethodName = actualMethodIndex >= 0 && expectedEndMethodIndex >= 0 && + actualMethodIndex === expectedEndMethodIndex; + + if (!funcNameStartsWithTypeName) + name = typeName + '.'; + + name += funcName; + + if (!funcNameEndsWithMethodName) + name += ' [as ' + methodName + ']'; + + return name; + } + + return typeName + '.' + (methodName || ''); +} + +function processFrames (stackFrames, processFn) { + return processFn ? stackFrames.map(processFn) : stackFrames; +} + +function getFilePath (filename) { + if (filename.startsWith('file:')) + return new URL(filename); + + return filename; +} + +function getFrameNames (frame) { + return { + functionName: frame.getFunctionName(), + methodName: frame.getMethodName(), + typeName: getFrameTypeName(frame), + }; +} + +// CallsiteRecord +const CallsiteRecord = function (filename, lineNum, callsiteFrameIdx, stackFrames) { + this.filename = filename; + this.lineNum = lineNum; + this.callsiteFrameIdx = callsiteFrameIdx; + this.stackFrames = stackFrames; + this.isV8Frames = isV8StackFrame(this.stackFrames[0]); +}; + +CallsiteRecord.prototype._getFrameName = function (frame) { + // NOTE: Code was partially adopted from the V8 code + // (see: https://github.com/v8/v8/blob/3c3d7e7be80f45eeea0dc74a71d7552e2afc2985/src/js/messages.js#L647) + let funcName = frame.getFunctionName(); + + if (!this.isV8Frames) + return funcName || ''; + + const isCtor = frame.isConstructor(); + const isMethod = !frame.isToplevel() && !isCtor; + + if (isMethod) + return getFrameMethodName(frame, funcName); + + funcName = funcName || ''; + + return isCtor ? 'new ' + funcName : funcName; +}; + +CallsiteRecord.prototype._getFrameLocation = function (frame) { + // NOTE: Code was partially adopted from the V8 code + // (see: https://github.com/v8/v8/blob/3c3d7e7be80f45eeea0dc74a71d7552e2afc2985/src/js/messages.js#L647) + if (this.isV8Frames && frame.isNative()) + return 'native'; + + let location = frame.getFileName(); + const lineNum = frame.getLineNumber(); + const colNum = frame.getColumnNumber(); + + if (this.isV8Frames && !location) { + location = frame.isEval() ? frame.getEvalOrigin() + ', ' : ''; + location += ''; + } + + if (lineNum) { + location += ':' + lineNum; + + if (colNum) + location += ':' + colNum; + } + + return location; +}; + +CallsiteRecord.prototype._getCodeFrameLines = function (fileContent, frameSize) { + const lines = fileContent.split(NEWLINE); + const startLineIdx = Math.max(0, this.lineNum - frameSize); + const endLineIdx = Math.min(lines.length - 1, this.lineNum + frameSize); + let maxLineNumDigits = 0; + const frameLines = []; + + for (let i = startLineIdx; i <= endLineIdx; i++) { + const num = String(i + 1); + + maxLineNumDigits = Math.max(maxLineNumDigits, num.length); + + frameLines.push({ + num: num, + src: lines[i], + base: i === this.lineNum, + }); + } + + frameLines.forEach(line => { + line.num = padStart(line.num, maxLineNumDigits); + }); + + return frameLines; +}; + +CallsiteRecord.prototype._renderCodeFrame = function (fileContent, renderer, frameSize) { + if (renderer.syntax) + fileContent = highlight(fileContent, renderer.syntax); + + const lines = this._getCodeFrameLines(fileContent, frameSize); + const lastIdx = lines.length - 1; + + const frame = lines + .reduce((sourceFrame, line, idx) => { + const isLast = idx === lastIdx; + + return sourceFrame + renderer.codeLine(line.num, line.base, line.src, isLast); + }, ''); + + return renderer.codeFrame(frame); +}; + + +CallsiteRecord.prototype._renderStack = function (renderer, stackFilter) { + const record = this; + let entries = this.stackFrames.slice(this.callsiteFrameIdx); + + if (stackFilter) { + entries = entries.filter((frame, idx) => { + return stackFilter(frame, idx, record.isV8Frames); + }); + } + + const lastIdx = entries.length - 1; + + const rendered = entries.reduce((str, frame, idx) => { + const isLast = idx === lastIdx; + const name = record._getFrameName(frame); + const location = record._getFrameLocation(frame); + + return str + renderer.stackLine(name, location, isLast); + }, ''); + + return rendered ? renderer.stack(rendered) : ''; +}; + +CallsiteRecord.prototype._renderRecord = function (fileContent, opts) { + opts = defaults({}, opts, { + renderer: renderers.default, + frameSize: 5, + stack: true, + codeFrame: true, + stackFilter: null, + }, opts); + + const codeFrame = opts.codeFrame ? this._renderCodeFrame(fileContent, opts.renderer, opts.frameSize) : ''; + const stack = opts.stack ? this._renderStack(opts.renderer, opts.stackFilter) : ''; + + return codeFrame + stack; +}; + +CallsiteRecord.prototype.renderSync = function (opts) { + const filePath = getFilePath(this.filename); + const fileContent = fs. readFileSync(filePath).toString(); + + return this._renderRecord(fileContent, opts); +}; + +CallsiteRecord.prototype.render = function (opts) { + const record = this; + + return new Promise((resolve, reject) => { + const filePath = getFilePath(record.filename); + + fs.readFile(filePath, (err, fileContent) => { + if (err) + reject(err); + else + resolve(record._renderRecord(fileContent.toString(), opts)); + }); + }); +}; + +// Static +CallsiteRecord.fromStackFrames = function (stackFrames, fnName, typeName, processFrameFn) { + if (typeName && fnName === 'constructor') + fnName = typeName; + + for (let i = 0; i < stackFrames.length; i++) { + const frameNames = getFrameNames(stackFrames[i]); + const fnNameMatch = frameNames.functionName === fnName || frameNames.methodName === fnName; + const typeNameMatch = !typeName || + frameNames.typeName === typeName || + // NOTE: frameNames.typeName === null on Node 16+ when calling a class constructor + fnNameMatch && frameNames.typeName === null && fnName === typeName; + + if (fnNameMatch && typeNameMatch) { + const callsiteFrameIdx = findClosestNonNativeAncestorFrameIdx(stackFrames, i); + + if (callsiteFrameIdx !== null) { + stackFrames = processFrames(stackFrames, processFrameFn); + + const callsiteFrame = stackFrames[callsiteFrameIdx]; + const filename = callsiteFrame.getFileName(); + const lineNum = callsiteFrame.getLineNumber() - 1; + + return new CallsiteRecord(filename, lineNum, callsiteFrameIdx, stackFrames); + } + + return null; + } + } + + return null; +}; + +CallsiteRecord.fromError = function (error, isCallsiteFrame, processFrameFn) { + let stackFrames = parseStack(error); + + if (stackFrames) { + if (typeof isCallsiteFrame === 'function') { + while (stackFrames.length) { + if (isCallsiteFrame(stackFrames[0])) + break; + + stackFrames.shift(); + } + } + + if (stackFrames.length) { + stackFrames = processFrames(stackFrames, processFrameFn); + + const filename = stackFrames[0].getFileName(); + const lineNum = stackFrames[0].getLineNumber() - 1; + + return filename && !isNaN(lineNum) ? new CallsiteRecord(filename, lineNum, 0, stackFrames) : null; + } + } + + return null; +}; + +// API +module.exports = function createCallsiteRecord (options) { /*{ forError, isCallsiteFrame, byFunctionName, typeName, processFrameFn }*/ + if (options.forError) + return CallsiteRecord.fromError(options.forError, options.isCallsiteFrame, options.processFrameFn); + + else if (options.byFunctionName) { + const stackFrames = callsite(); + + // NOTE: remove API call + stackFrames.shift(); + + return CallsiteRecord.fromStackFrames(stackFrames, options.byFunctionName, options.typeName, options.processFrameFn); + } + + return null; +}; + +module.exports.renderers = renderers; diff --git a/src/utils/callsite-record/renderers/default.js b/src/utils/callsite-record/renderers/default.js new file mode 100644 index 00000000000..795c95c8837 --- /dev/null +++ b/src/utils/callsite-record/renderers/default.js @@ -0,0 +1,44 @@ +const chalk = require('chalk'); +const asIs = require('lodash').identity; + +module.exports = { + syntax: { + string: chalk.green, + punctuator: chalk.grey, + keyword: chalk.cyan, + number: chalk.magenta, + regex: chalk.magenta, + comment: chalk.grey.bold, + invalid: chalk.inverse, + }, + + codeFrame: asIs, + + codeLine: function (num, base, src, isLast) { + const prefix = base ? ' > ' : ' '; + let lineNum = prefix + num + ' '; + + if (base) + lineNum = chalk.bgRed(lineNum); + + let line = lineNum + '|' + src; + + if (!isLast) + line += '\n'; + + return line; + }, + + stackLine: function (name, location, isLast) { + let line = ' at ' + chalk.bold(name) + ' (' + chalk.grey.underline(location) + ')'; + + if (!isLast) + line += '\n'; + + return line; + }, + + stack: function (stack) { + return '\n\n' + stack; + }, +}; diff --git a/src/utils/callsite-record/renderers/html.js b/src/utils/callsite-record/renderers/html.js new file mode 100644 index 00000000000..03960ea24cf --- /dev/null +++ b/src/utils/callsite-record/renderers/html.js @@ -0,0 +1,38 @@ +const escapeHtml = require('lodash').escape; + +module.exports = { + syntax: ['string', 'punctuator', 'keyword', 'number', 'regex', 'comment', 'invalid'].reduce((syntaxRenderer, tokenType) => { + syntaxRenderer[tokenType] = function (str) { + return '' + escapeHtml(str) + ''; + }; + + return syntaxRenderer; + }, {}), + + codeFrame: function (str) { + return '
' + str + '
'; + }, + + codeLine: function (num, base, src, isLast) { + const lineClass = isLast ? 'code-line-last' : 'code-line'; + const numClass = base ? 'code-line-num-base' : 'code-line-num'; + + return '
' + + '
' + num + '
' + + '
' + src + '
' + + '
'; + }, + + stackLine: function (name, location, isLast) { + const lineClass = isLast ? 'stack-line-last' : 'stack-line'; + + return '
' + + '
' + escapeHtml(name) + '
' + + '
' + escapeHtml(location) + '
' + + '
'; + }, + + stack: function (stack) { + return '
' + stack + '
'; + }, +}; diff --git a/src/utils/callsite-record/renderers/index.d.ts b/src/utils/callsite-record/renderers/index.d.ts new file mode 100644 index 00000000000..8fff2e01978 --- /dev/null +++ b/src/utils/callsite-record/renderers/index.d.ts @@ -0,0 +1,37 @@ +// Type definitions for callsite-record v4.0.0 +// Project: https://github.com/inikulin/callsite-record +// Definitions by: Alvis HT Tang +// Definitions: https://github.com/DefinitelyTyped/DefinitelyTyped + +import { Chalk } from 'chalk'; +import { identity } from 'lodash'; + +export interface RendererWithoutSyntax { + codeFrame: typeof identity; + + codeLine: (num: number, base: string, src: string, isLast: boolean) => string; + + stackLine: (name: string, location: string, isLast: boolean) => string; + + stack: (stack: string) => string; +} + +export interface RendererWithSyntax extends RendererWithoutSyntax { + syntax: { + string: Chalk; + punctuator: Chalk; + keyword: Chalk; + number: Chalk; + regex: Chalk; + comment: Chalk; + invalid: Chalk; + }; +} + +export type Renderer = RendererWithoutSyntax | RendererWithSyntax; + +export interface Renderers { + default: RendererWithSyntax; + html: RendererWithSyntax; + noColor: RendererWithoutSyntax; +} diff --git a/src/utils/callsite-record/renderers/no-color.js b/src/utils/callsite-record/renderers/no-color.js new file mode 100644 index 00000000000..d98f0b064f9 --- /dev/null +++ b/src/utils/callsite-record/renderers/no-color.js @@ -0,0 +1,28 @@ +const asIs = require('lodash').identity; + +module.exports = { + codeFrame: asIs, + + codeLine: function (num, base, src, isLast) { + const prefix = base ? ' > ' : ' '; + let line = prefix + num + ' |' + src; + + if (!isLast) + line += '\n'; + + return line; + }, + + stackLine: function (name, location, isLast) { + let line = ' at ' + name + ' (' + location + ')'; + + if (!isLast) + line += '\n'; + + return line; + }, + + stack: function (stack) { + return '\n\n' + stack; + }, +}; diff --git a/src/utils/get-renderes.ts b/src/utils/get-renderes.ts index e49de113355..b46e9ceb98c 100644 --- a/src/utils/get-renderes.ts +++ b/src/utils/get-renderes.ts @@ -1,5 +1,5 @@ import { CALLSITE_RECORD_CLASS_NAME } from '../test-run/execute-js-expression/constants'; -import { CallsiteRecord, renderers as renderersRecord } from '@devexpress/callsite-record'; +import { CallsiteRecord, renderers as renderersRecord } from './callsite-record'; import { RawCommandCallsiteRecord, renderers as renderersCommand } from './raw-command-callsite-record'; type Renderers = typeof renderersRecord | typeof renderersCommand; diff --git a/src/utils/prerender-callsite.ts b/src/utils/prerender-callsite.ts index 343447ea9a2..059e4a9b2a6 100644 --- a/src/utils/prerender-callsite.ts +++ b/src/utils/prerender-callsite.ts @@ -1,4 +1,4 @@ -import { CallsiteRecord } from '@devexpress/callsite-record'; +import { CallsiteRecord } from './callsite-record'; import renderCallsiteSync from './render-callsite-sync'; import createStackFilter from '../errors/create-stack-filter'; import { RawCommandCallsiteRecord } from './raw-command-callsite-record'; diff --git a/src/utils/render-callsite-sync.js b/src/utils/render-callsite-sync.js index 23d70a0a804..fcb041ba3c7 100644 --- a/src/utils/render-callsite-sync.js +++ b/src/utils/render-callsite-sync.js @@ -1,4 +1,4 @@ -import { renderers as renderersRecord } from '@devexpress/callsite-record'; +import { renderers as renderersRecord } from './callsite-record'; import { renderers as renderersCommand } from './raw-command-callsite-record'; diff --git a/test/server/callsite-record-test.js b/test/server/callsite-record-test.js new file mode 100644 index 00000000000..a4949150c8e --- /dev/null +++ b/test/server/callsite-record-test.js @@ -0,0 +1,131 @@ +const { expect } = require('chai'); +const fs = require('fs'); +const { pathToFileURL } = require('url'); +const createCallsiteRecord = require('../../lib/utils/callsite-record'); +const getRenderers = require('../../lib/utils/get-renderes'); +const { getCallsiteForMethod } = require('../../lib/errors/get-callsite'); + +const filename = require.resolve('./data/callsite-record/source'); +const renderers = createCallsiteRecord.renderers; + +function createError (location = filename) { + const error = new Error('test'); + + error.stack = `Error: test\n at example (${location}:2:7)`; + + return error; +} + +describe('Local callsite record', () => { + it('Captures method callsites with the expected constructor name', () => { + function capture () { + return getCallsiteForMethod('capture'); + } + + const record = capture(); + + expect(record.constructor.name).equal('CallsiteRecord'); + expect(record.filename).equal(__filename); + expect(getRenderers(record)).equal(renderers); + expect(record.renderSync({ renderer: renderers.noColor, frameSize: 0, stack: false })) + .match(/ > \d+ \| {8}const record = capture\(\);/); + }); + + for (const rendererName of ['default', 'noColor', 'html']) { + it(`Renders identical sync and async output with ${rendererName}`, async () => { + const record = createCallsiteRecord({ forError: createError() }); + const options = { renderer: renderers[rendererName], frameSize: 0 }; + const sync = record.renderSync(options); + + expect(await record.render(options)).equal(sync); + + if (rendererName === 'noColor') + expect(sync).equal(` > 2 |throw new Error(value);\n\n at example (${filename}:2:7)`); + else if (rendererName === 'html') { + expect(sync).include('
'); + expect(sync).include('throw'); + expect(sync).include('
example
'); + } + }); + } + + it('Escapes source and stack content in HTML', () => { + const error = createError(); + + error.stack = `Error: test\n at