Repository: denoland/deno
Pull request: fix(ext/node): add util.getCallSites

This PR adds [getCallSites](https://nodejs.org/api/util.html#utilgetcallsitesframecount-options) API to "node:util".

This unblocks https://github.com/denoland/deno/issues/28318. Currently the task is blocked by the unavailability of `util.getCallSites` because it's used in [`test/common/index.js`](https://github.com/denoland/node_test/blob/8846b5392f4ebd3d4e995b9bed61c23c0192d806/test/common/index.js#L31)

Changed files:
filename: ext/node/polyfills/util.ts
patch: @@ -4,12 +4,14 @@ import { primordials } from "ext:core/mod.js";
 const {
   ArrayIsArray,
   ArrayPrototypeJoin,
+  ArrayPrototypeMap,
   Date,
   DatePrototypeGetDate,
   DatePrototypeGetHours,
   DatePrototypeGetMinutes,
   DatePrototypeGetMonth,
   DatePrototypeGetSeconds,
+  ErrorCaptureStackTrace,
   ErrorPrototype,
   NumberPrototypeToString,
   ObjectDefineProperty,
@@ -48,6 +50,8 @@ import { isDeepStrictEqual } from "ext:deno_node/internal/util/comparisons.ts";
 import process from "node:process";
 import {
   validateAbortSignal,
+  validateNumber,
+  validateObject,
   validateString,
 } from "ext:deno_node/internal/validators.mjs";
 import { parseArgs } from "ext:deno_node/internal/util/parse_args/parse_args.js";
@@ -318,6 +322,63 @@ export async function aborted(
   return abortPromise.promise;
 }
 
+function prepareStackTrace(_error, stackTraces) {
+  return ArrayPrototypeMap(stackTraces, (stack) => {
+    return ({
+      functionName: stack.getFunctionName() ?? "",
+      // TODO(kt3k): This needs to be script's id
+      scriptId: "0",
+      scriptName: stack.getFileName(),
+      lineNumber: stack.getLineNumber(),
+      column: stack.getColumnNumber(),
+      columnNumber: stack.getColumnNumber(),
+    });
+  });
+}
+
+const kDefaultMaxCallStackSizeToCapture = 200;
+
+// deno-lint-ignore-start
+/**
+ * Returns the call sites of the current call stack
+ * @param frameCount The limit of the number of frames to return
+ * @param _options The options
+ * @returns The call sites
+ */
+export function getCallSites(
+  frameCount = 10,
+  options: unknown = { __proto__: null },
+) {
+  validateNumber(
+    frameCount,
+    "frameCount",
+    0,
+    kDefaultMaxCallStackSizeToCapture,
+  );
+  if (options) {
+    validateObject(options, "options");
+  }
+  const target = {};
+  // deno-lint-ignore prefer-primordials
+  const original = Error.prepareStackTrace;
+  // deno-lint-ignore prefer-primordials
+  const limitOriginal = Error.stackTraceLimit;
+
+  // deno-lint-ignore prefer-primordials
+  Error.stackTraceLimit = frameCount;
+  // deno-lint-ignore prefer-primordials
+  Error.prepareStackTrace = prepareStackTrace;
+  ErrorCaptureStackTrace(target, getCallSites);
+
+  const capturedTraces = target.stack;
+  // deno-lint-ignore prefer-primordials
+  Error.prepareStackTrace = original;
+  // deno-lint-ignore prefer-primordials
+  Error.stackTraceLimit = limitOriginal;
+
+  return capturedTraces;
+}
+
 export { getSystemErrorName, isDeepStrictEqual };
 
 export default {
@@ -340,6 +401,7 @@ export default {
   isPrimitive,
   isBuffer,
   _extend,
+  getCallSites,
   getSystemErrorName,
   aborted,
   deprecate,

filename: tests/node_compat/config.jsonc
patch: @@ -155,6 +155,7 @@
       "test-url-pathtofileurl.js",
       "test-url-urltooptions.js",
       "test-util-format.js",
+      "test-util-getcallsites.js",
       "test-util-inspect-namespace.js",
       "test-util-inspect-proxy.js",
       "test-util-inspect.js",
@@ -227,6 +228,7 @@
       "elipses.txt",
       "empty.txt",
       "exit.js",
+      "get-call-sites.js",
       "GH-1899-output.js",
       "loop.js",
       "parent-process-nonpersistent.js",
@@ -1224,6 +1226,7 @@
       "test-util-deprecate-invalid-code.js",
       "test-util-deprecate.js",
       "test-util-format.js",
+      "test-util-getcallsites.js",
       "test-util-inherits.js",
       "test-util-inspect-getters-accessing-this.js",
       "test-util-inspect-long-running.js",

filename: tests/node_compat/runner/TODO.md
patch: @@ -1,7 +1,7 @@
 <!-- deno-fmt-ignore-file -->
 # Remaining Node Tests
 
-1147 tests out of 3993 have been ported from Node 23.9.0 (28.73% ported, 71.78% remaining).
+1149 tests out of 3993 have been ported from Node 23.9.0 (28.78% ported, 71.75% remaining).
 
 NOTE: This file should not be manually edited. Please edit `tests/node_compat/config.json` and run `deno task setup` in `tests/node_compat/runner` dir instead.
 
@@ -2378,7 +2378,6 @@ NOTE: This file should not be manually edited. Please edit `tests/node_compat/co
 - [parallel/test-util-callbackify.js](https://github.com/nodejs/node/tree/v23.9.0/test/parallel/test-util-callbackify.js)
 - [parallel/test-util-emit-experimental-warning.js](https://github.com/nodejs/node/tree/v23.9.0/test/parallel/test-util-emit-experimental-warning.js)
 - [parallel/test-util-getcallsite.js](https://github.com/nodejs/node/tree/v23.9.0/test/parallel/test-util-getcallsite.js)
-- [parallel/test-util-getcallsites.js](https://github.com/nodejs/node/tree/v23.9.0/test/parallel/test-util-getcallsites.js)
 - [parallel/test-util-internal.js](https://github.com/nodejs/node/tree/v23.9.0/test/parallel/test-util-internal.js)
 - [parallel/test-util-parse-env.js](https://github.com/nodejs/node/tree/v23.9.0/test/parallel/test-util-parse-env.js)
 - [parallel/test-util-sigint-watchdog.js](https://github.com/nodejs/node/tree/v23.9.0/test/parallel/test-util-sigint-watchdog.js)

filename: tests/node_compat/test/fixtures/get-call-sites.js
patch: @@ -0,0 +1,11 @@
+// deno-fmt-ignore-file
+// deno-lint-ignore-file
+
+// Copyright Joyent and Node contributors. All rights reserved. MIT license.
+// Taken from Node 23.9.0
+// This file is automatically generated by `tests/node_compat/runner/setup.ts`. Do not modify this file manually.
+
+const util = require('node:util');
+const assert = require('node:assert');
+assert.ok(util.getCallSites().length > 1);
+process.stdout.write(util.getCallSites()[0].scriptName);

filename: tests/node_compat/test/parallel/test-util-getcallsites.js
patch: @@ -0,0 +1,185 @@
+// deno-fmt-ignore-file
+// deno-lint-ignore-file
+
+// Copyright Joyent and Node contributors. All rights reserved. MIT license.
+// Taken from Node 23.9.0
+// This file is automatically generated by `tests/node_compat/runner/setup.ts`. Do not modify this file manually.
+
+'use strict';
+
+const common = require('../common');
+
+const fixtures = require('../common/fixtures');
+const file = fixtures.path('get-call-sites.js');
+
+const { getCallSites } = require('node:util');
+const { spawnSync } = require('node:child_process');
+const assert = require('node:assert');
+
+{
+  const callSites = getCallSites();
+  assert.ok(callSites.length > 1);
+  assert.match(
+    callSites[0].scriptName,
+    /test-util-getcallsites/,
+    'node:util should be ignored',
+  );
+}
+
+{
+  const callSites = getCallSites(3);
+  assert.strictEqual(callSites.length, 3);
+  assert.match(
+    callSites[0].scriptName,
+    /test-util-getcallsites/,
+    'node:util should be ignored',
+  );
+}
+
+// Guarantee dot-left numbers are ignored
+{
+  const callSites = getCallSites(3.6);
+  assert.strictEqual(callSites.length, 3);
+}
+
+{
+  const callSites = getCallSites(3.4);
+  assert.strictEqual(callSites.length, 3);
+}
+
+{
+  assert.throws(() => {
+    // Max than kDefaultMaxCallStackSizeToCapture
+    getCallSites(201);
+  }, common.expectsError({
+    code: 'ERR_OUT_OF_RANGE'
+  }));
+  assert.throws(() => {
+    getCallSites(-1);
+  }, common.expectsError({
+    code: 'ERR_OUT_OF_RANGE'
+  }));
+  assert.throws(() => {
+    getCallSites([]);
+  }, common.expectsError({
+    code: 'ERR_INVALID_ARG_TYPE'
+  }));
+  assert.throws(() => {
+    getCallSites({}, {});
+  }, common.expectsError({
+    code: 'ERR_INVALID_ARG_TYPE'
+  }));
+  assert.throws(() => {
+    getCallSites(10, 10);
+  }, common.expectsError({
+    code: 'ERR_INVALID_ARG_TYPE'
+  }));
+}
+
+{
+  const callSites = getCallSites(1);
+  assert.strictEqual(callSites.length, 1);
+  assert.match(
+    callSites[0].scriptName,
+    /test-util-getcallsites/,
+    'node:util should be ignored',
+  );
+}
+
+// ScriptId is a string.
+{
+  const callSites = getCallSites(1);
+  assert.strictEqual(callSites.length, 1);
+  assert.strictEqual(typeof callSites[0].scriptId, 'string');
+}
+
+/* NOTE(kt3k): Skipping this test for now because Deno can't run eval string as commonjs module
+// Guarantee [eval] will appear on stacktraces when using -e
+{
+  const { status, stderr, stdout } = spawnSync(
+    process.execPath,
+    [
+      '-e',
+      `const util = require('util');
+       const assert = require('assert');
+       assert.ok(util.getCallSites().length > 1);
+       process.stdout.write(util.getCallSites()[0].scriptName);
+      `,
+    ],
+  );
+  assert.strictEqual(status, 0, stderr.toString());
+  assert.strictEqual(stdout.toString(), '[eval]');
+}
+*/
+
+// Guarantee the stacktrace[0] is the filename
+{
+  const { status, stderr, stdout } = spawnSync(
+    process.execPath,
+    [file],
+  );
+  assert.strictEqual(status, 0, stderr.toString());
+  assert.strictEqual(stdout.toString(), file);
+}
+
+// Error.stackTraceLimit should not influence callsite size
+{
+  const originalStackTraceLimit = Error.stackTraceLimit;
+  Error.stackTraceLimit = 0;
+  const callSites = getCallSites();
+  assert.notStrictEqual(callSites.length, 0);
+  Error.stackTraceLimit = originalStackTraceLimit;
+}
+
+/* NOTE(kt3k): Skipping source map related test cases.
+{
+  const { status, stderr, stdout } = spawnSync(process.execPath, [
+    '--no-warnings',
+    '--experimental-transform-types',
+    fixtures.path('typescript/ts/test-get-callsite.ts'),
+  ]);
+
+  const output = stdout.toString();
+  assert.strictEqual(stderr.toString(), '');
+  assert.match(output, /lineNumber: 8/);
+  assert.match(output, /column: 18/);
+  assert.match(output, /columnNumber: 18/);
+  assert.match(output, /test-get-callsite\.ts/);
+  assert.strictEqual(status, 0);
+}
+
+{
+  const { status, stderr, stdout } = spawnSync(process.execPath, [
+    '--no-warnings',
+    '--experimental-transform-types',
+    '--no-enable-source-maps',
+    fixtures.path('typescript/ts/test-get-callsite.ts'),
+  ]);
+
+  const output = stdout.toString();
+  assert.strictEqual(stderr.toString(), '');
+  // Line should be wrong when sourcemaps are disable
+  assert.match(output, /lineNumber: 2/);
+  assert.match(output, /column: 18/);
+  assert.match(output, /columnNumber: 18/);
+  assert.match(output, /test-get-callsite\.ts/);
+  assert.strictEqual(status, 0);
+}
+
+{
+  // Source maps should be disabled when options.sourceMap is false
+  const { status, stderr, stdout } = spawnSync(process.execPath, [
+    '--no-warnings',
+    '--experimental-transform-types',
+    fixtures.path('typescript/ts/test-get-callsite-explicit.ts'),
+  ]);
+
+  const output = stdout.toString();
+  assert.strictEqual(stderr.toString(), '');
+  assert.match(output, /lineNumber: 2/);
+  assert.match(output, /column: 18/);
+  assert.match(output, /columnNumber: 18/);
+  assert.match(output, /test-get-callsite-explicit\.ts/);
+  assert.strictEqual(status, 0);
+}
+*/