Skip to content

Commit 2334321

Browse files
committed
Fixed reporting of load-time errors from modules imported by specs
[#178764731]
1 parent 3c4ef58 commit 2334321

File tree

13 files changed

+231
-22
lines changed

13 files changed

+231
-22
lines changed

.circleci/config.yml

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,20 @@ executors:
1010
docker:
1111
- image: cimg/node:16.1.0
1212
working_directory: ~/workspace
13-
node14:
13+
node14_latest:
1414
docker:
1515
- image: circleci/node:14
1616
working_directory: ~/workspace
17+
# 14.8 is the first version with top level await in ES modules.
18+
node14_8:
19+
docker:
20+
- image: circleci/node:14.8
21+
working_directory: ~/workspace
22+
# 14.7 is the last version without top level await in ES modules.
23+
node14_7:
24+
docker:
25+
- image: circleci/node:14.7
26+
working_directory: ~/workspace
1727
node12_latest:
1828
docker:
1929
- image: circleci/node:12
@@ -22,10 +32,12 @@ executors:
2232
docker:
2333
- image: circleci/node:12.0
2434
working_directory: ~/workspace
35+
# 12.17 is the first version with dynamic import() of commonjs modules
2536
node12_17:
2637
docker:
2738
- image: circleci/node:12.17
2839
working_directory: ~/workspace
40+
# 12.17 is the last version without dynamic import() of commonjs modules
2941
node12_16:
3042
docker:
3143
- image: circleci/node:12.16
@@ -61,8 +73,14 @@ workflows:
6173
executor: node16
6274
name: node_16
6375
- build_and_test:
64-
executor: node14
65-
name: node_14
76+
executor: node14_latest
77+
name: node_14_latest
78+
- build_and_test:
79+
executor: node14_8
80+
name: node_14_8
81+
- build_and_test:
82+
executor: node14_7
83+
name: node_14_7
6684
- build_and_test:
6785
executor: node12_latest
6886
name: node_12_latest

.eslintignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
spec/fixtures/cjs-syntax-error/syntax_error.js
2+
spec/fixtures/esm-importing-commonjs-syntax-error/syntax_error.js
23
spec/fixtures/js-loader-import/*.js

lib/loader.js

Lines changed: 95 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -12,16 +12,7 @@ Loader.prototype.load = function(path, alwaysImport) {
1212
// Node enforces this on Windows but not on other OSes.
1313
const url = `file://${path}`;
1414
return this.import_(url).catch(function(e) {
15-
if (e.message.indexOf(path) !== -1 || e.stack.indexOf(path) !== -1) {
16-
return Promise.reject(e);
17-
} else {
18-
// When an ES module has a syntax error, the resulting exception does not
19-
// include the filename. Add it. We lose the stack trace in the process,
20-
// but the stack trace is usually not useful since it contains only frames
21-
// from the Node module loader.
22-
const updatedError = new Error(`While loading ${path}: ${e.constructor.name}: ${e.message}`);
23-
return Promise.reject(updatedError);
24-
}
15+
return Promise.reject(fixupImportException(e, path));
2516
});
2617
} else {
2718
return new Promise(resolve => {
@@ -38,3 +29,97 @@ function requireShim(path) {
3829
function importShim(path) {
3930
return import(path);
4031
}
32+
33+
34+
function fixupImportException(e, importedPath) {
35+
// When an ES module has a syntax error, the resulting exception does not
36+
// include the filename, which the user will need to debug the problem. We
37+
// need to fix those up to include the filename. However, other kinds of load-
38+
// time errors *do* include the filename and usually the line number. We need
39+
// to leave those alone.
40+
//
41+
// Some examples of load-time errors that we need to deal with:
42+
// 1. Syntax error in an ESM spec:
43+
// SyntaxError: missing ) after argument list
44+
// at Loader.moduleStrategy (node:internal/modules/esm/translators:147:18)
45+
// at async link (node:internal/modules/esm/module_job:64:21)
46+
//
47+
// 2. Syntax error in an ES module imported from an ESM spec. This is exactly
48+
// the same as #1: there is no way to tell which file actually has the syntax
49+
// error.
50+
//
51+
// 3. Syntax error in a CommonJS module imported by an ES module:
52+
// /path/to/commonjs_with_syntax_error.js:2
53+
//
54+
//
55+
//
56+
// SyntaxError: Unexpected end of input
57+
// at Object.compileFunction (node:vm:355:18)
58+
// at wrapSafe (node:internal/modules/cjs/loader:1038:15)
59+
// at Module._compile (node:internal/modules/cjs/loader:1072:27)
60+
// at Object.Module._extensions..js (node:internal/modules/cjs/loader:1137:10)
61+
// at Module.load (node:internal/modules/cjs/loader:988:32)
62+
// at Function.Module._load (node:internal/modules/cjs/loader:828:14)
63+
// at ModuleWrap.<anonymous> (node:internal/modules/esm/translators:201:29)
64+
// at ModuleJob.run (node:internal/modules/esm/module_job:175:25)
65+
// at async Loader.import (node:internal/modules/esm/loader:178:24)
66+
// at async file:///path/to/esm_that_imported_cjs.mjs:2:11
67+
//
68+
// Note: For Jasmine's purposes, case 3 only occurs in Node >= 14.8. Older
69+
// versions don't support top-level await, without which it's not possible to
70+
// load a CommonJS module from an ES module at load-time. The entire content
71+
// above, including the file path and the three blank lines, is part of the
72+
// error's `stack` property. There may or may not be any stack trace after the
73+
// SyntaxError line, and if there's a stack trace it may or may not contain
74+
// any useful information.
75+
//
76+
// 4. Any other kind of exception thrown at load time
77+
//
78+
// Error: nope
79+
// at Object.<anonymous> (/path/to/file_throwing_error.js:1:7)
80+
// at Module._compile (node:internal/modules/cjs/loader:1108:14)
81+
// at Object.Module._extensions..js (node:internal/modules/cjs/loader:1137:10)
82+
// at Module.load (node:internal/modules/cjs/loader:988:32)
83+
// at Function.Module._load (node:internal/modules/cjs/loader:828:14)
84+
// at ModuleWrap.<anonymous> (node:internal/modules/esm/translators:201:29)
85+
// at ModuleJob.run (node:internal/modules/esm/module_job:175:25)
86+
// at async Loader.import (node:internal/modules/esm/loader:178:24)
87+
// at async file:///path_to_file_importing_broken_file.mjs:1:1
88+
//
89+
// We need to replace the error with a useful one in cases 1 and 2, but not in
90+
// cases 3 and 4. Distinguishing among them can be tricky. Simple heuristics
91+
// like checking the stack trace for the name of the file we imported fail
92+
// because it often shows up even when the error was elsewhere, e.g. at the
93+
// bottom of the stack traces in the examples for cases 3 and 4 above. To add
94+
// to the fun, file paths in errors on Windows can be either Windows style
95+
// paths (c:\path\to\file.js) or URLs (file:///c:/path/to/file.js).
96+
97+
if (!(e instanceof SyntaxError)) {
98+
return e;
99+
}
100+
101+
const escapedWin = escapeStringForRegexp(importedPath.replace(/\//g, '\\'));
102+
const windowsPathRegex = new RegExp('[a-zA-z]:\\\\([^\\s]+\\\\|)' + escapedWin);
103+
const windowsUrlRegex = new RegExp('file:///[a-zA-z]:\\\\([^\\s]+\\\\|)' + escapedWin);
104+
const anyUnixPathFirstLineRegex = /^\/[^\s:]+:\d/;
105+
const anyWindowsPathFirstLineRegex = /^[a-zA-Z]:(\\[^\s\\:]+)+:/;
106+
107+
if (e.message.indexOf(importedPath) !== -1
108+
|| e.stack.indexOf(importedPath) !== -1
109+
|| e.stack.match(windowsPathRegex) || e.stack.match(windowsUrlRegex)
110+
|| e.stack.match(anyUnixPathFirstLineRegex)
111+
|| e.stack.match(anyWindowsPathFirstLineRegex)) {
112+
return e;
113+
} else {
114+
return new Error(`While loading ${importedPath}: ${e.constructor.name}: ${e.message}`);
115+
}
116+
}
117+
118+
// Adapted from Sindre Sorhus's escape-string-regexp (MIT license)
119+
function escapeStringForRegexp(string) {
120+
// Escape characters with special meaning either inside or outside character sets.
121+
// Use a simple backslash escape when it’s always valid, and a `\xnn` escape when the simpler form would be disallowed by Unicode patterns’ stricter grammar.
122+
return string
123+
.replace(/[|\\{}()[\]^$+*?.]/g, '\\$&')
124+
.replace(/-/g, '\\x2d');
125+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
require('./syntax_error');
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"spec_dir": ".",
3+
"spec_files": [
4+
"spec.mjs"
5+
],
6+
"helpers": []
7+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
await import('./intermediate.js');
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
{
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"spec_dir": ".",
3+
"spec_files": [
4+
"spec.mjs"
5+
],
6+
"helpers": []
7+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
import './throws.mjs';
2+
3+
it('a spec', () => {});
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
throw new Error('nope');
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
await Promise.resolve();

spec/integration_spec.js

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,32 @@ describe('Integration', function () {
8989
expect(output).toContain('syntax_error.mjs');
9090
});
9191

92+
it('handles syntax errors from a CommonJS module loaded from an ESM spec properly', async function() {
93+
try {
94+
await import('./fixtures/topLevelAwaitSentinel.mjs');
95+
} catch (e) {
96+
if (e instanceof SyntaxError && e.message === 'Unexpected reserved word') {
97+
pending('This Node version does not support top-level await');
98+
} else if (e.message === 'Not supported') {
99+
pending('This Node version does not support dynamic import');
100+
} else {
101+
throw e;
102+
}
103+
}
104+
105+
const {exitCode, output} = await runJasmine('spec/fixtures/esm-importing-commonjs-syntax-error', true);
106+
expect(exitCode).toEqual(1);
107+
expect(output).toContain('SyntaxError');
108+
expect(output).toContain('syntax_error.js');
109+
});
110+
111+
it('handles exceptions thrown from a module loaded from an ESM spec properly', async function() {
112+
const {exitCode, output} = await runJasmine('spec/fixtures/esm-indirect-error', true);
113+
expect(exitCode).toEqual(1);
114+
expect(output).toContain('nope');
115+
expect(output).toContain('throws.mjs');
116+
});
117+
92118
it('can configure the env via the `env` config property', async function() {
93119
const {exitCode, output} = await runJasmine('spec/fixtures/env-config');
94120
expect(exitCode).toEqual(0);

spec/loader_spec.js

Lines changed: 66 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -70,24 +70,81 @@ function esModuleSharedExamples(extension, alwaysImport) {
7070
await expectAsync(loaderPromise).toBeResolved();
7171
});
7272

73-
it("adds the filename to errors that don't include it", async function() {
73+
it("adds the filename to ES module syntax errors", async function() {
7474
const underlyingError = new SyntaxError('some details but no filename, not even in the stack trace');
75-
const importShim = () => Promise.reject(underlyingError);
76-
const loader = new Loader({importShim});
75+
const loader = new Loader({importShim: () => Promise.reject(underlyingError)});
7776

7877
await expectAsync(loader.load(`foo.${extension}`, alwaysImport)).toBeRejectedWithError(
7978
`While loading foo.${extension}: SyntaxError: some details but no filename, not even in the stack trace`
8079
);
8180
});
8281

83-
it('propagates errors that already contain the filename without modifying them', async function () {
84-
const requireShim = jasmine.createSpy('requireShim');
82+
it('does not modify errors that are not SyntaxError instances', async function() {
8583
const underlyingError = new Error('nope');
86-
underlyingError.stack = underlyingError.stack.replace('loader_spec.js', `foo.${extension}`);
87-
const importShim = jasmine.createSpy('importShim')
88-
.and.callFake(() => Promise.reject(underlyingError));
89-
const loader = new Loader({requireShim, importShim});
84+
const loader = new Loader({importShim: () => Promise.reject(underlyingError)});
9085

9186
await expectAsync(loader.load(`foo.${extension}`, alwaysImport)).toBeRejectedWith(underlyingError);
9287
});
88+
89+
it('does not modify SyntaxErrors that mention the imported filename as a Unix-style path', async function() {
90+
const underlyingError = new SyntaxError('nope');
91+
underlyingError.stack = `/the/absolute/path/to/foo.${extension}:1\n` +
92+
'\n' +
93+
'\n' +
94+
'\n' +
95+
'maybe some more stack\n';
96+
const loader = new Loader({importShim: () => Promise.reject(underlyingError)});
97+
98+
await expectAsync(loader.load(`path/to/foo.${extension}`, alwaysImport))
99+
.toBeRejectedWith(underlyingError);
100+
});
101+
102+
it('does not modify SyntaxErrors that mention the imported filename as a Unix-style file URL', async function() {
103+
const underlyingError = new SyntaxError('nope');
104+
underlyingError.stack += `\n at async file:///the/absolute/path/to/foo.${extension}:1:1`;
105+
const loader = new Loader({importShim: () => Promise.reject(underlyingError)});
106+
107+
await expectAsync(loader.load(`path/to/foo.${extension}`, alwaysImport))
108+
.toBeRejectedWith(underlyingError);
109+
});
110+
111+
it('does not modify SyntaxErrors that mention the imported filename as a Windows-style path', async function() {
112+
const underlyingError = new SyntaxError('nope');
113+
underlyingError.stack = `c:\\the\\absolute\\path\\to\\foo.${extension}:1\n` +
114+
'\n' +
115+
'\n' +
116+
'\n' +
117+
'maybe some more stack\n';
118+
const loader = new Loader({importShim: () => Promise.reject(underlyingError)});
119+
120+
await expectAsync(loader.load(`path/to/foo.${extension}`, alwaysImport))
121+
.toBeRejectedWith(underlyingError);
122+
});
123+
124+
it('does not modify SyntaxErrors that mention the imported filename as a Windows-style file URL', async function() {
125+
const underlyingError = new SyntaxError('nope');
126+
underlyingError.stack += `\n at async file:///c:/the/absolute/path/to/foo.${extension}:1:1`;
127+
const loader = new Loader({importShim: () => Promise.reject(underlyingError)});
128+
129+
await expectAsync(loader.load(`path/to/foo.${extension}`, alwaysImport))
130+
.toBeRejectedWith(underlyingError);
131+
});
132+
133+
it('does not modify SyntaxErrors when the stack trace starts with any Unix-style path', async function() {
134+
const underlyingError = new SyntaxError('nope');
135+
underlyingError.stack = '/some/path/to/a/file.js:1\n\n\n\n' + underlyingError.stack;
136+
const loader = new Loader({importShim: () => Promise.reject(underlyingError)});
137+
138+
await expectAsync(loader.load(`path/to/some/other/file.${extension}`, alwaysImport))
139+
.toBeRejectedWith(underlyingError);
140+
});
141+
142+
it('does not modify SyntaxErrors when the stack trace starts with any Windows-style path', async function() {
143+
const underlyingError = new SyntaxError('nope');
144+
underlyingError.stack = 'c:\\some\\path\\to\\a\\file.js:1\n\n\n\n' + underlyingError.stack;
145+
const loader = new Loader({importShim: () => Promise.reject(underlyingError)});
146+
147+
await expectAsync(loader.load(`path/to/some/other/file.${extension}`, alwaysImport))
148+
.toBeRejectedWith(underlyingError);
149+
});
93150
}

0 commit comments

Comments
 (0)