diff --git a/benchmark/esm/startup-esm-graph.js b/benchmark/esm/startup-esm-graph.js new file mode 100644 index 000000000000..7684ba67f6a6 --- /dev/null +++ b/benchmark/esm/startup-esm-graph.js @@ -0,0 +1,66 @@ +'use strict'; + +const common = require('../common.js'); +const fs = require('fs'); +const path = require('path'); +const { pathToFileURL } = require('url'); +const tmpdir = require('../../test/common/tmpdir'); + +const BRANCHING_FACTOR = 10; +const benchmarkDirectory = tmpdir.resolve('esm-graph'); + +// Each measured iteration has to load a graph the ESM cache has never seen, so the +// fixture holds one independent copy of the graph per iteration. +function graphDirectory(modules, copy) { + return path.join(benchmarkDirectory, `g${modules}-${copy}`); +} + +function entryURL(modules, copy) { + return pathToFileURL(path.join(graphDirectory(modules, copy), 'mod0.mjs')).href; +} + +// Build a complete BRANCHING_FACTOR-ary tree of `modules` + 1 modules rooted at mod0: +// module i imports modules BRANCHING_FACTOR*i+1 through BRANCHING_FACTOR*i+BRANCHING_FACTOR, +// capped at the total, so the shape approximates a real dependency tree rather than +// one module with hundreds of direct imports. +function createGraph(modules, copy) { + const dir = graphDirectory(modules, copy); + fs.mkdirSync(dir, { recursive: true }); + const total = modules + 1; + for (let i = 0; i < total; i++) { + let source = ''; + for (let c = 1; c <= BRANCHING_FACTOR; c++) { + const child = BRANCHING_FACTOR * i + c; + if (child < total) { + source += `import './mod${child}.mjs';\n`; + } + } + source += `export const value${i} = ${i};\n`; + fs.writeFileSync(path.join(dir, `mod${i}.mjs`), source); + } +} + +const bench = common.createBenchmark(main, { + modules: [250, 500, 1000, 2000], + n: [10], +}, { + setup(configs) { + // Build every fixture once here rather than per configuration: writing tens of + // thousands of files is far more expensive than the work being measured. + tmpdir.refresh(); + const maxN = configs.reduce((max, c) => Math.max(max, c.n), 0); + for (const modules of new Set(configs.map((c) => c.modules))) { + for (let copy = 0; copy < maxN; copy++) { + createGraph(modules, copy); + } + } + }, +}); + +async function main({ n, modules }) { + bench.start(); + for (let i = 0; i < n; i++) { + await import(entryURL(modules, i)); + } + bench.end(n); +} diff --git a/lib/internal/modules/esm/loader.js b/lib/internal/modules/esm/loader.js index f92e87785ce1..489d8ff91e35 100644 --- a/lib/internal/modules/esm/loader.js +++ b/lib/internal/modules/esm/loader.js @@ -11,6 +11,7 @@ const { PromisePrototypeThen, RegExpPrototypeSymbolReplace, encodeURIComponent, + globalThis, hardenRegExp, } = primordials; @@ -22,6 +23,11 @@ const { kRequiredModuleSymbol, } = require('internal/modules/cjs/loader'); const { imported_cjs_symbol } = internalBinding('symbols'); +const { + privateSymbols: { + entry_point_module_private_symbol, + }, +} = internalBinding('util'); const assert = require('internal/assert'); const { @@ -350,6 +356,39 @@ class ModuleLoader { return { wrap: job.module, namespace: job.runSync(parent).namespace }; } + /** + * Load and evaluate the entry point module. + * The graph is always loaded and linked synchronously. When it contains no top-level + * await it is evaluated synchronously too, so startup creates no promises at all. + * When it does contain top-level await, evaluation falls back to `job.run()` here, + * reusing the job that was just built rather than making the caller re-enter the + * loader and redo the resolution and linking for the same module. + * @param {string} url The URL of the entry point module. + * @returns {ModuleJobSync|undefined} The job still needing asynchronous evaluation when + * the graph has top-level await, otherwise `undefined` - the module is then already + * fully evaluated and no promise was ever created. + */ + importForEntryPoint(url) { + return onImport.traceSync(() => { + const request = { specifier: url, phase: kEvaluationPhase, attributes: kEmptyObject, __proto__: null }; + const job = this.getOrCreateModuleJob(undefined, request, kImportInImportedESM); + job.instantiateSync(); + if (job.module.hasAsyncGraph) { + // Hand the job back so the caller can evaluate it through the ordinary + // asynchronous entry point machinery, without re-resolving and re-linking + // the module the way re-entering `import()` would. + return job; + } + globalThis[entry_point_module_private_symbol] = job.module; + job.runSync(); + return undefined; + }, { + __proto__: null, + parentURL: undefined, + url, + }); + } + /** * Check invariants on a cached module job when require()'d from ESM. * @param {string} specifier The first parameter of require(). @@ -572,10 +611,14 @@ class ModuleLoader { assert(moduleOrModulePromise instanceof ModuleWrap, `Expected ModuleWrap for loading ${url}`); } - // TODO(joyeecheung): use ModuleJobSync for kRequireInImportedCJS too. - const ModuleJobCtor = (requestType === kImportInRequiredESM ? ModuleJobSync : ModuleJob); const isMain = (parentURL === undefined); const inspectBrk = (isMain && getOptionValue('--inspect-brk')); + // Use ModuleJobSync whenever we're on the main thread (not the async loader hook worker), + // except for kRequireInImportedCJS (TODO: consolidate that case too). + // TODO(joyeecheung): use ModuleJobSync for kRequireInImportedCJS too. + const ModuleJobCtor = (this.isForAsyncLoaderHookWorker || + requestType === kRequireInImportedCJS) ? + ModuleJob : ModuleJobSync; job = new ModuleJobCtor( this, url, @@ -602,8 +645,9 @@ class ModuleLoader { */ getOrCreateModuleJob(parentURL, request, requestType) { let maybePromise; - if (requestType === kRequireInImportedCJS || requestType === kImportInRequiredESM) { - // In these two cases, resolution must be synchronous. + if (!this.isForAsyncLoaderHookWorker) { + // On the main thread, always resolve synchronously; + // `resolveSync` coordinates with the async loader hook worker if needed. maybePromise = this.resolveSync(parentURL, request); assert(!isPromise(maybePromise)); } else { diff --git a/lib/internal/modules/esm/module_job.js b/lib/internal/modules/esm/module_job.js index c54706a9e0af..c8506b42ff3f 100644 --- a/lib/internal/modules/esm/module_job.js +++ b/lib/internal/modules/esm/module_job.js @@ -54,7 +54,7 @@ const { getSourceMapsSupport, } = require('internal/source_map/source_map_cache'); const assert = require('internal/assert'); -const resolvedPromise = PromiseResolve(); +let resolvedPromise; const { setHasStartedUserESMExecution, urlToFilename, @@ -65,7 +65,6 @@ const { ERR_REQUIRE_ASYNC_MODULE, ERR_REQUIRE_ESM_RACE_CONDITION, } = require('internal/errors').codes; -let hasPausedEntry = false; const CJSGlobalLike = [ 'require', @@ -241,6 +240,58 @@ function getTopLevelAwaitLocations(root) { return locations; } +/** + * If `error` is a SyntaxError from V8 for a missing named export on a CJS module, rewrite its message to the friendlier + * "Named export '...' not found..." form. Must be called after `decorateErrorStack(error)` so that the arrow (source + * context with the import statement text) has been prepended to `error.stack`. + * @param {Error} error + * @param {ModuleWrap} module The parent module that triggered the instantiation. + * @param {boolean[]} commonJsDeps Per-request array indicating whether each dependency is a CJS module, aligned with + * `module.getModuleRequests()`. + */ +const handleCJSNamedExportError = (error, module, commonJsDeps) => { + // TODO(@bcoe): Add source map support to exception that occurs as result + // of missing named export. This is currently not possible because + // stack trace originates in module_job, not the file itself. A hidden + // symbol with filename could be set in node_errors.cc to facilitate this. + if (!getSourceMapsSupport().enabled && + StringPrototypeIncludes(error.message, + ' does not provide an export named')) { + const splitStack = StringPrototypeSplit(error.stack, '\n', 2); + const { 1: childSpecifier, 2: name } = RegExpPrototypeExec( + /module '(.*)' does not provide an export named '(.+)'/, + error.message); + const moduleRequests = module.getModuleRequests(); + let isCommonJS = false; + for (let i = 0; i < moduleRequests.length; ++i) { + if (moduleRequests[i].specifier === childSpecifier) { + isCommonJS = commonJsDeps[i]; + break; + } + } + if (isCommonJS) { + const importStatement = splitStack[1]; + // TODO(@ctavan): The original error stack only provides the single + // line which causes the error. For multi-line import statements we + // cannot generate an equivalent object destructuring assignment by + // just parsing the error stack. + const oneLineNamedImports = RegExpPrototypeExec(/{.*}/, importStatement); + const destructuringAssignment = oneLineNamedImports && + RegExpPrototypeSymbolReplace(/\s+as\s+/g, oneLineNamedImports, ': '); + error.message = `Named export '${name}' not found. The requested module` + + ` '${childSpecifier}' is a CommonJS module, which may not support` + + ' all module.exports as named exports.\nCommonJS modules can ' + + 'always be imported via the default export, for example using:' + + `\n\nimport pkg from '${childSpecifier}';\n${ + destructuringAssignment ? + `const ${destructuringAssignment} = pkg;\n` : ''}`; + const newStack = StringPrototypeSplit(error.stack, '\n'); + newStack[3] = `SyntaxError: ${error.message}`; + error.stack = ArrayPrototypeJoin(newStack, '\n'); + } + } +}; + class ModuleJobBase { constructor(loader, url, importAttributes, phase, isMain, inspectBrk) { assert(typeof phase === 'number'); @@ -455,63 +506,17 @@ class ModuleJob extends ModuleJobBase { await addJobsToDependencyGraph(this); try { - if (!hasPausedEntry && this.inspectBrk) { - hasPausedEntry = true; - const initWrapper = internalBinding('inspector').callAndPauseOnStart; - initWrapper(this.module.instantiate, this.module); - } else { - this.module.instantiate(); - } - } catch (e) { - decorateErrorStack(e); - // TODO(@bcoe): Add source map support to exception that occurs as result - // of missing named export. This is currently not possible because - // stack trace originates in module_job, not the file itself. A hidden - // symbol with filename could be set in node_errors.cc to facilitate this. - if (!getSourceMapsSupport().enabled && - StringPrototypeIncludes(e.message, - ' does not provide an export named')) { - const splitStack = StringPrototypeSplit(e.stack, '\n', 2); - const { 1: childSpecifier, 2: name } = RegExpPrototypeExec( - /module '(.*)' does not provide an export named '(.+)'/, - e.message); - const moduleRequests = this.module.getModuleRequests(); - let isCommonJS = false; - for (let i = 0; i < moduleRequests.length; ++i) { - if (moduleRequests[i].specifier === childSpecifier) { - isCommonJS = this.commonJsDeps[i]; - break; - } - } - - if (isCommonJS) { - const importStatement = splitStack[1]; - // TODO(@ctavan): The original error stack only provides the single - // line which causes the error. For multi-line import statements we - // cannot generate an equivalent object destructuring assignment by - // just parsing the error stack. - const oneLineNamedImports = RegExpPrototypeExec(/{.*}/, importStatement); - const destructuringAssignment = oneLineNamedImports && - RegExpPrototypeSymbolReplace(/\s+as\s+/g, oneLineNamedImports, ': '); - e.message = `Named export '${name}' not found. The requested module` + - ` '${childSpecifier}' is a CommonJS module, which may not support` + - ' all module.exports as named exports.\nCommonJS modules can ' + - 'always be imported via the default export, for example using:' + - `\n\nimport pkg from '${childSpecifier}';\n${ - destructuringAssignment ? - `const ${destructuringAssignment} = pkg;\n` : ''}`; - const newStack = StringPrototypeSplit(e.stack, '\n'); - newStack[3] = `SyntaxError: ${e.message}`; - e.stack = ArrayPrototypeJoin(newStack, '\n'); - } - } - throw e; + this.module.instantiate(); + } catch (error) { + decorateErrorStack(error); + handleCJSNamedExportError(error, this.module, this.commonJsDeps); + throw error; } for (const dependencyJob of jobsInGraph) { // Calling `this.module.instantiate()` instantiates not only the // ModuleWrap in this module, but all modules in the graph. - dependencyJob.instantiated = resolvedPromise; + dependencyJob.instantiated = resolvedPromise ??= PromiseResolve(); } } @@ -577,12 +582,14 @@ class ModuleJob extends ModuleJobBase { /** * This is a fully synchronous job and does not spawn additional threads in any way. - * All the steps are ensured to be synchronous and it throws on instantiating - * an asynchronous graph. It also disallows CJS <-> ESM cycles. + * Loading and linking are always synchronous. Evaluation via runSync() throws on an + * asynchronous graph; evaluation via run() falls back to async for top-level await. + * It also disallows CJS <-> ESM cycles. * - * This is used for ES modules loaded via require(esm). Modules loaded by require() in - * imported CJS are handled by ModuleJob with the isForRequireInImportedCJS set to true instead. - * The two currently have different caching behaviors. + * Used for all ES module imports on the main thread, regardless of how the import was + * triggered (entry point, import(), require(esm), --import, etc.). + * Modules loaded by require() in imported CJS are handled by ModuleJob with the + * isForRequireInImportedCJS set to true instead. The two currently have different caching behaviors. * TODO(joyeecheung): consolidate this with the isForRequireInImportedCJS variant of ModuleJob. */ class ModuleJobSync extends ModuleJobBase { @@ -623,38 +630,76 @@ class ModuleJobSync extends ModuleJobBase { return PromiseResolve(this.module); } - async run() { + instantiateSync() { + try { + this.module.instantiate(); + } catch (error) { + decorateErrorStack(error); + handleCJSNamedExportError(error, this.module, this.commonJsDeps); + throw error; + } + } + + async run(isEntryPoint = false) { assert(this.shouldRunModule(this.phase)); - // This path is hit by a require'd module that is imported again. const status = this.module.getStatus(); debug('ModuleJobSync.run()', status, this.module); // If the module was previously required and errored, reject from import() again. if (status === kErrored) { throw this.module.getError(); - } else if (status > kInstantiated) { + } + if (status > kInstantiated) { + // Already evaluated (e.g. previously require()'d and now import()'d again). if (this.evaluationPromise) { await this.evaluationPromise; } return { __proto__: null, module: this.module }; - } else if (status === kInstantiated || status === kUninstantiated) { - // If we get here, the module was initially required and is now being imported. - // The require() module failed either because the graph has TLA (kInstantiated), - // or instantiation failed (kUninstantiated, e.g. missing named export). - // Try finishing the instantiation - if it succeeds, proceed to evaluation, - // otherwise the branch below re-throw any instantiation error. - if (status === kUninstantiated) { - this.module.instantiate(); - } + } + if (status < kInstantiated) { + // Either a fresh module - links were already resolved synchronously in the constructor - + // or one that was initially require()'d and is now being imported after its instantiation + // failed (e.g. a missing named export). Try finishing the instantiation: if it succeeds, + // proceed to evaluation, otherwise re-throw the instantiation error. + this.instantiateSync(); + } + // `status === kInstantiated`: either just instantiated above, or previously instantiated + // but evaluation was deferred (e.g. TLA detected by a prior `runSync()` call) + if (isEntryPoint) { + globalThis[entry_point_module_private_symbol] = this.module; + } + setHasStartedUserESMExecution(); + if (this.module.hasAsyncGraph) { + // Has top-level `await`: fall back to async evaluation const timeout = -1; const breakOnSigint = false; - this.evaluationPromise = this.module.evaluate(timeout, breakOnSigint); + if (this.inspectBrk) { + const { callAndPauseOnStart } = internalBinding('inspector'); + this.evaluationPromise = callAndPauseOnStart(this.module.evaluate, this.module, timeout, breakOnSigint); + } else { + this.evaluationPromise = this.module.evaluate(timeout, breakOnSigint); + } await this.evaluationPromise; this.evaluationPromise = undefined; return { __proto__: null, module: this.module }; } + // No top-level `await`: evaluate synchronously + try { + this.#evaluateSync(); + } catch (evaluateError) { + explainCommonJSGlobalLikeNotDefinedError(evaluateError, this.module.url, this.module.hasTopLevelAwait); + throw evaluateError; + } + return { __proto__: null, module: this.module }; + } - assert.fail('Unexpected status of a module that is imported again after being required. ' + - `Status = ${status}`); + // Evaluate this module synchronously, pausing at the first line of user land JS + // that runs if --inspect-brk is active - that may well be inside a dependency. + #evaluateSync() { + if (this.inspectBrk) { + const { callAndPauseOnStart } = internalBinding('inspector'); + return callAndPauseOnStart(this.module.evaluateSync, this.module); + } + return this.module.evaluateSync(); } runSync(parent) { @@ -662,15 +707,14 @@ class ModuleJobSync extends ModuleJobBase { assert(this.shouldRunModule(this.phase)); // TODO(joyeecheung): Reject graphs with top-level await _before_ instantiation, so that the // async graph error supersedes instantiation (mismatch export) errors in the graph. - // TODO(joyeecheung): add the error decoration logic from the async instantiate. - this.module.instantiate(); + this.instantiateSync(); // On the deprecated async loader hook worker thread, dependencies linked by an // earlier import may not be walkable synchronously, so double-check with // V8 now that the graph is instantiated. this.throwIfAsyncGraph(parent); setHasStartedUserESMExecution(); try { - const namespace = this.module.evaluateSync(); + const namespace = this.#evaluateSync(); return { __proto__: null, module: this.module, namespace }; } catch (e) { explainCommonJSGlobalLikeNotDefinedError(e, this.module.url, this.module.hasTopLevelAwait); diff --git a/lib/internal/modules/run_main.js b/lib/internal/modules/run_main.js index 87319e6a94cb..b0d7d4845eaf 100644 --- a/lib/internal/modules/run_main.js +++ b/lib/internal/modules/run_main.js @@ -140,6 +140,8 @@ function runEntryPointWithESMLoader(callback) { * Because of module detection, this function will attempt to run ambiguous (no explicit extension, no * `package.json` type field) entry points as CommonJS first; under certain conditions, it will retry running as ESM. * @param {string} main - First positional CLI argument, such as `'entry.js'` from `node entry.js` + * @returns {Promise|undefined} A promise when the entry point is still being evaluated + * asynchronously because its graph contains top-level await, otherwise `undefined`. */ function executeUserEntryPoint(main = process.argv[1]) { if (getOptionValue('[vfs_load_set]')) { @@ -173,6 +175,31 @@ function executeUserEntryPoint(main = process.argv[1]) { const mainPath = resolvedMain || main; const mainURL = getOptionValue('--entry-url') ? new URL(mainPath, getCWDURL()) : pathToFileURL(mainPath); + // When no async loader hooks or --import are registered, load the entry point + // directly. The graph is loaded and linked synchronously, and evaluated + // synchronously too unless it contains top-level await. + if (getOptionValue('--experimental-loader').length === 0 && + getOptionValue('--import').length === 0) { + const cascadedLoader = require('internal/modules/esm/loader').getOrInitializeCascadedLoader(); + const job = cascadedLoader.importForEntryPoint(mainURL.href); + if (job === undefined) { + // Evaluated synchronously; no promise was created at any point. + return; + } + // Top-level await: evaluate through the usual asynchronous entry point path, + // reusing the job that was already resolved, linked and instantiated above. + return runEntryPointWithESMLoader(async () => { + // Begin evaluation inside a microtask, as `import()` does by awaiting the + // module job before running it, so that a top-level await in the entry point + // resumes in the same microtask drain instead of unwinding to the main stack + // and letting the `process.nextTick` queue run first. + await null; + // Await rather than returning the job's promise: resolving a promise *with* a + // thenable performs a `then` lookup on it, which user code can observe. + await job.run(true); + }); + } + runEntryPointWithESMLoader((cascadedLoader) => { // Note that if the graph contains unsettled TLA, this may never resolve // even after the event loop stops running. diff --git a/test/es-module/test-esm-cjs-named-error.mjs b/test/es-module/test-esm-cjs-named-error.mjs index 98dedbf065f3..8e69d077c186 100644 --- a/test/es-module/test-esm-cjs-named-error.mjs +++ b/test/es-module/test-esm-cjs-named-error.mjs @@ -1,5 +1,7 @@ -import '../common/index.mjs'; +import { spawnPromisified } from '../common/index.mjs'; +import * as fixtures from '../common/fixtures.mjs'; import assert from 'assert'; +import { execPath } from 'node:process'; const fixtureBase = '../fixtures/es-modules/package-cjs-named-error'; @@ -75,3 +77,17 @@ await assert.rejects(async () => { await assert.rejects(async () => { await import(`${fixtureBase}/escaped-single-quote.mjs`); }, /import pkg from '\.\/oh'no\.cjs'/, 'should support relative specifiers with escaped single quote'); + +const entryPoint = fixtures.path('es-modules', 'package-cjs-named-error', 'single-quote.mjs'); +const { code, stderr } = await spawnPromisified(execPath, [entryPoint]); +assert.strictEqual(code, 1); +assert.ok(stderr.includes("Named export 'comeOn' not found."), + 'entry point should show the missing named export'); +assert.ok(stderr.includes('CommonJS modules can always be imported via the default export'), + 'entry point should show the CommonJS named export hint'); +assert.ok(stderr.includes("import pkg from './fail.cjs';"), + 'entry point hint should recommend the default import'); +assert.ok(stderr.includes('const { comeOn } = pkg;'), + 'entry point hint should show the named import as destructuring'); +assert.ok(stderr.includes("import { comeOn } from './fail.cjs';"), + 'entry point error should include the source import statement'); diff --git a/test/es-module/test-esm-sync-import.mjs b/test/es-module/test-esm-sync-import.mjs new file mode 100644 index 000000000000..832dd161f3e3 --- /dev/null +++ b/test/es-module/test-esm-sync-import.mjs @@ -0,0 +1,52 @@ +// Flags: --no-warnings +import { spawnPromisified } from '../common/index.mjs'; +import * as fixtures from '../common/fixtures.mjs'; +import { describe, it } from 'node:test'; +import assert from 'node:assert'; + + +describe('synchronous ESM loading', () => { + it('should create minimal promises for ESM importing ESM', async () => { + // import-esm.mjs imports imported-esm.mjs — a pure ESM graph. + const count = await getPromiseCount(fixtures.path('es-modules', 'import-esm.mjs')); + // V8's Module::Evaluate returns one promise for the entire graph. + assert.strictEqual(count, 1); + }); + + it('should create minimal promises for ESM importing CJS', async () => { + // builtin-imports-case.mjs imports node:assert (builtin) + dep1.js and dep2.js (CJS). + const count = await getPromiseCount(fixtures.path('es-modules', 'builtin-imports-case.mjs')); + // V8 creates one promise for the ESM entry evaluation, plus one per CJS module + // in the graph (each CJS namespace is wrapped in a promise). + // entry (ESM, 1) + node:assert (CJS, 1) + dep1.js (CJS, 1) + dep2.js (CJS, 1) = 4. + assert.strictEqual(count, 4); + }); + + it('should fall back to async evaluation for top-level await', async () => { + // tla/resolved.mjs uses top-level await, so the sync path detects TLA + // and falls back to async evaluation. + const count = await getPromiseCount(fixtures.path('es-modules', 'tla', 'resolved.mjs')); + // The async fallback creates more promises — just verify the module + // still runs successfully. The promise count will be higher than the + // sync path but should remain bounded. + assert(count > 1, `Expected TLA fallback to create multiple promises, got ${count}`); + }); + + it('should create minimal promises when entry point is CJS importing ESM', async () => { + // When a CJS entry point uses require(esm), the ESM module is loaded via + // ModuleJobSync, so the same promise minimization applies. + const count = await getPromiseCount(fixtures.path('es-modules', 'require-esm-entry.cjs')); + // V8's Module::Evaluate returns one promise for the ESM module. + assert.strictEqual(count, 1); + }); +}); + + +async function getPromiseCount(entry) { + const { stdout, stderr, code } = await spawnPromisified(process.execPath, [ + '--trace-promises', + entry, + ]); + assert.strictEqual(code, 0, `child failed:\nstdout: ${stdout}\nstderr: ${stderr}`); + return stderr.match(/created promise #/g)?.length ?? 0; +} diff --git a/test/fixtures/es-modules/require-esm-entry.cjs b/test/fixtures/es-modules/require-esm-entry.cjs new file mode 100644 index 000000000000..ce03a24bece9 --- /dev/null +++ b/test/fixtures/es-modules/require-esm-entry.cjs @@ -0,0 +1 @@ +require('./imported-esm.mjs'); diff --git a/test/fixtures/source-map/output/source_map_assert_no_source_map.snapshot b/test/fixtures/source-map/output/source_map_assert_no_source_map.snapshot index 0a2ea1a45fe6..cc9ba4849109 100644 --- a/test/fixtures/source-map/output/source_map_assert_no_source_map.snapshot +++ b/test/fixtures/source-map/output/source_map_assert_no_source_map.snapshot @@ -1,6 +1,6 @@ -node:internal/modules/run_main: - triggerUncaughtException( - ^ +node:internal/modules/esm/module_job: + return this.module.evaluateSync(); + ^ AssertionError [ERR_ASSERTION]: The expression evaluated to a falsy value: diff --git a/test/parallel/test-inspector-debug-brk-flag-esm-tla.js b/test/parallel/test-inspector-debug-brk-flag-esm-tla.js new file mode 100644 index 000000000000..56f0840d8ac0 --- /dev/null +++ b/test/parallel/test-inspector-debug-brk-flag-esm-tla.js @@ -0,0 +1,49 @@ +'use strict'; +const common = require('../common'); + +// Test that --inspect-brk pauses at the first executable line of an ESM entry +// point that uses top-level await (which takes the async evaluation path). + +common.skipIfInspectorDisabled(); + +const assert = require('assert'); +const fixtures = require('../common/fixtures'); +const { NodeInstance } = require('../common/inspector-helper.js'); + +async function testBreakpointOnStart(session) { + const commands = [ + { 'method': 'Runtime.enable' }, + { 'method': 'Debugger.enable' }, + { 'method': 'Debugger.setPauseOnExceptions', + 'params': { 'state': 'none' } }, + { 'method': 'Debugger.setAsyncCallStackDepth', + 'params': { 'maxDepth': 0 } }, + { 'method': 'Profiler.enable' }, + { 'method': 'Profiler.setSamplingInterval', + 'params': { 'interval': 100 } }, + { 'method': 'Debugger.setBlackboxPatterns', + 'params': { 'patterns': [] } }, + { 'method': 'Runtime.runIfWaitingForDebugger' }, + ]; + + await session.send({ method: 'NodeRuntime.enable' }); + await session.waitForNotification('NodeRuntime.waitingForDebugger'); + await session.send(commands); + await session.send({ method: 'NodeRuntime.disable' }); + // Static import declarations are not executable statements, so the break + // happens at the first executable line (await setTimeout(9), line index 4). + await session.waitForBreakOnLine(4, session.scriptURL()); +} + +async function runTests() { + const child = new NodeInstance(['--inspect-brk=0'], '', + fixtures.path('es-modules', 'esm-top-level-await.mjs')); + const session = await child.connectInspectorSession(); + + await testBreakpointOnStart(session); + await session.runToCompletion(); + + assert.strictEqual((await child.expectShutdown()).exitCode, 0); +} + +runTests().then(common.mustCall());