Skip to content

Commit e06e6bc

Browse files
authored
Add source maps to module server (#126)
1 parent f2632ce commit e06e6bc

14 files changed

Lines changed: 467 additions & 106 deletions

.changeset/nice-chairs-chew.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'pleasantest': minor
3+
---
4+
5+
Improve error message display for errors coming from browsers

package-lock.json

Lines changed: 58 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
],
1010
"license": "MIT",
1111
"devDependencies": {
12+
"@ampproject/remapping": "1.0.1",
1213
"@babel/core": "7.14.6",
1314
"@babel/preset-env": "7.14.7",
1415
"@babel/preset-typescript": "7.14.5",
@@ -47,6 +48,7 @@
4748
"rollup-plugin-prettier": "2.1.0",
4849
"rollup-plugin-terser": "7.0.2",
4950
"sass": "1.35.1",
51+
"simple-code-frame": "1.1.1",
5052
"smoldash": "0.9.0",
5153
"typescript": "4.3.4"
5254
},

src/index.ts

Lines changed: 74 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ import { pleasantestUser } from './user';
1414
import { assertElementHandle, removeFuncFromStackTrace } from './utils';
1515
import { createModuleServer } from './module-server';
1616
import { cleanupClientRuntimeServer } from './module-server/client-runtime-server';
17+
import { Console } from 'console';
18+
1719
export { JSHandle, ElementHandle } from 'puppeteer';
1820
koloristOpts.enabled = true;
1921
const ansiRegex = _ansiRegex({ onlyFirst: true });
@@ -215,7 +217,7 @@ const createTab = async ({
215217
const browser = await connectToBrowser('chromium', headless);
216218
const browserContext = await browser.createIncognitoBrowserContext();
217219
const page = await browserContext.newPage();
218-
const { port, close: closeServer } = await createModuleServer();
220+
const { requestCache, port, close: closeServer } = await createModuleServer();
219221

220222
if (device) {
221223
if (!headless) {
@@ -245,6 +247,9 @@ const createTab = async ({
245247
// So we don't need to print it _again_ in node, since it already came from node
246248
if (text.includes('%c')) return;
247249
const type = message.type();
250+
// Create a new console instance instead of using the global one
251+
// Because the global one is overridden by Jest, and it adds a misleading second stack trace and code frame below it
252+
const console = new Console(process.stdout, process.stderr);
248253
if (type === 'error') {
249254
const error = new Error(text);
250255
const location = message.location();
@@ -278,7 +283,8 @@ const createTab = async ({
278283
};
279284

280285
const runJS: PleasantestUtils['runJS'] = async (code, args) => {
281-
const encodedCode = encodeURIComponent(code);
286+
// For some reason encodeURIComponent doesn't encode '
287+
const encodedCode = encodeURIComponent(code).replace(/'/g, '%27');
282288
// This uses the testPath as the url so that if there are relative imports
283289
// in the inline code, the relative imports are resolved relative to the test file
284290
const url = `http://localhost:${port}/${testPath}?inline-code=${encodedCode}`;
@@ -299,49 +305,51 @@ const createTab = async ({
299305
)) as undefined | { message: string; stack: string };
300306
if (res === undefined) return;
301307
if (typeof res !== 'object') throw res;
302-
const { message, stack } = res;
303-
// TODO: re-enable source map usage once source map support is added to module server
304-
// Const parsedStack = parseStackTrace(stack);
305-
// const modifiedStack = parsedStack.map(async (stackItem) => {
306-
// if (!stackItem.fileName) return stackItem.raw;
307-
// let fileName = stackItem.fileName;
308-
// let line = stackItem.line;
309-
// let column = stackItem.column;
310-
// if (!fileName.startsWith(`http://localhost:${port}`))
311-
// return stackItem.raw;
312-
// const url = new URL(fileName);
313-
// const localFileName = path.join(process.cwd(), url.pathname);
314-
// const transformResult = await server.transformRequest(
315-
// url.pathname + url.search,
316-
// );
317-
// const map = typeof transformResult === 'object' && transformResult?.map;
318-
// if (!map) return stackItem.raw;
319-
320-
// const { SourceMapConsumer } = await import('source-map');
321-
// const consumer = await new SourceMapConsumer(map as any);
322-
// const sourceLocation = consumer.originalPositionFor({ line, column });
323-
// consumer.destroy();
324-
// if (sourceLocation.line === null || sourceLocation.column === null)
325-
// return stackItem.raw;
326-
327-
// const inlineCode = url.searchParams.get('inline-code');
328-
// if (inlineCode) {
329-
// const fileSrc = await fs.readFile(localFileName, 'utf8');
330-
// const inlineStartIdx = fileSrc.indexOf(inlineCode);
331-
// if (inlineStartIdx === -1) return stackItem.raw;
332-
// const linesTillInlineCode = (
333-
// fileSrc.slice(0, inlineStartIdx).match(/\n/g) || []
334-
// ).length;
335-
// column = sourceLocation.column + 1;
336-
// line = sourceLocation.line + linesTillInlineCode;
337-
// } else {
338-
// column = sourceLocation.column + 1;
339-
// line = sourceLocation.line;
340-
// }
341-
342-
// fileName = localFileName;
343-
// return ` at ${fileName}:${line}:${column}`;
344-
// });
308+
let { message, stack } = res;
309+
if (message.includes(`Failed to fetch dynamically imported module: ${url}`))
310+
message =
311+
'Failed to load runJS code (most likely due to a transpilation error)';
312+
313+
const parsedStack = parseStackTrace(stack);
314+
let isFirst = true;
315+
const modifiedStack = parsedStack.map(async (stackItem) => {
316+
if (stackItem.raw.startsWith(stack.slice(0, stack.indexOf('\n'))))
317+
return null;
318+
if (!stackItem.fileName) return stackItem.raw;
319+
const fileName = stackItem.fileName;
320+
const line = stackItem.line;
321+
const column = stackItem.column;
322+
if (!fileName.startsWith(`http://localhost:${port}`))
323+
return stackItem.raw;
324+
const url = new URL(fileName);
325+
const id = `.${url.pathname}`;
326+
const transformResult = requestCache.get(id);
327+
const map = typeof transformResult === 'object' && transformResult.map;
328+
if (!map) return stackItem.raw;
329+
const { SourceMapConsumer } = await import('source-map');
330+
const consumer = await new SourceMapConsumer(map as any);
331+
const sourceLocation = consumer.originalPositionFor({ line, column });
332+
consumer.destroy();
333+
if (sourceLocation.line === null || sourceLocation.column === null)
334+
return stackItem.raw;
335+
const mappedColumn = sourceLocation.column + 1;
336+
const mappedLine = sourceLocation.line;
337+
const mappedPath = sourceLocation.source || url.pathname;
338+
// If the stack frame has a name (i.e. function name), then display it
339+
// _unless_ the stack frame is the first frame
340+
// because if the function name is displayed in the first stack frame,
341+
// then Jest cannot parse the stack trace to display the code frame
342+
const location =
343+
stackItem.name && !isFirst // Have to use isFirst instead of array loop index because function has early returns
344+
? `${stackItem.name} (${mappedPath}:${mappedLine}:${mappedColumn})`
345+
: `${
346+
// The first line has to be an absolute path,
347+
// otherwise Jest won't recognize it for the code frame
348+
isFirst ? path.join(process.cwd(), mappedPath) : mappedPath
349+
}:${mappedLine}:${mappedColumn}`;
350+
isFirst = false;
351+
return ` at ${location}`;
352+
});
345353
const errorName = stack.slice(0, stack.indexOf(':')) || 'Error';
346354
const specializedErrors = {
347355
EvalError,
@@ -351,14 +359,29 @@ const createTab = async ({
351359
TypeError,
352360
URIError,
353361
} as any;
354-
const ErrorConstructor = specializedErrors[errorName] || Error;
362+
const ErrorConstructor: ErrorConstructor =
363+
specializedErrors[errorName] || Error;
355364
const error = new ErrorConstructor(message);
356-
error.stack = stack;
357365

358-
// TODO: re-enable source map usage once source map support is added to module server
359-
// Error.stack = `${errorName}: ${message}\n${(
360-
// await Promise.all(modifiedStack)
361-
// ).join('\n')}`;
366+
const finalStack = (await Promise.all(modifiedStack))
367+
.filter(Boolean)
368+
.join('\n');
369+
370+
// If the browser error did not provide a stack, use the stack trace from node
371+
if (finalStack) {
372+
error.stack = `${errorName}: ${message}\n${finalStack}`;
373+
} else {
374+
removeFuncFromStackTrace(error, runJS);
375+
if (error.stack)
376+
error.stack = error.stack
377+
.split('\n')
378+
.filter(
379+
// This was appearing in stack traces and it messed up the Jest output
380+
(line) => !(/runMicrotasks/.test(line) && /<anonymous>/.test(line)),
381+
)
382+
.join('\n');
383+
}
384+
362385
throw error;
363386
};
364387

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
// Copied from https://github.com/vitejs/vite/blob/d97b33a8cb9a72ed64244f239900a9a862b6ba68/packages/vite/src/node/utils.ts#L431
2+
3+
/*
4+
https://github.com/vitejs/vite/blob/main/LICENSE
5+
MIT License
6+
Copyright (c) 2019-present, Yuxi (Evan) You and Vite contributors
7+
Permission is hereby granted, free of charge, to any person obtaining a copy
8+
of this software and associated documentation files (the "Software"), to deal
9+
in the Software without restriction, including without limitation the rights
10+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
11+
copies of the Software, and to permit persons to whom the Software is
12+
furnished to do so, subject to the following conditions:
13+
The above copyright notice and this permission notice shall be included in all
14+
copies or substantial portions of the Software.
15+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21+
SOFTWARE.
22+
*/
23+
24+
/*
25+
Differences from original:
26+
- Function style changed
27+
- ESLint fixes
28+
- Order of source maps is reversed in input
29+
- Make it not break if a sourcemap is missing sources
30+
*/
31+
32+
import remapping from '@ampproject/remapping';
33+
import type {
34+
DecodedSourceMap,
35+
RawSourceMap,
36+
} from '@ampproject/remapping/dist/types/types';
37+
38+
// Based on https://github.com/sveltejs/svelte/blob/abf11bb02b2afbd3e4cac509a0f70e318c306364/src/compiler/utils/mapped_code.ts#L221
39+
const nullSourceMap: RawSourceMap = {
40+
names: [],
41+
sources: [],
42+
mappings: '',
43+
version: 3,
44+
};
45+
export const combineSourceMaps = (
46+
filename: string,
47+
_sourceMapList: (DecodedSourceMap | RawSourceMap)[],
48+
): RawSourceMap => {
49+
const sourceMapList = _sourceMapList
50+
.map((map) => {
51+
// eslint-disable-next-line @cloudfour/typescript-eslint/no-unnecessary-condition
52+
if (!map.sources) map.sources = [];
53+
return map;
54+
})
55+
.reverse();
56+
if (
57+
sourceMapList.length === 0 ||
58+
sourceMapList.every((m) => m.sources.length === 0)
59+
)
60+
return { ...nullSourceMap };
61+
62+
let mapIndex = 1;
63+
const useArrayInterface =
64+
sourceMapList.slice(0, -1).find((m) => m.sources.length !== 1) ===
65+
undefined;
66+
const map = useArrayInterface
67+
? remapping(sourceMapList, () => null, true)
68+
: remapping(
69+
sourceMapList[0],
70+
(sourcefile) =>
71+
sourcefile === filename && sourceMapList[mapIndex]
72+
? sourceMapList[mapIndex++]
73+
: { ...nullSourceMap },
74+
true,
75+
);
76+
77+
if (!map.file) delete map.file;
78+
79+
return map as RawSourceMap;
80+
};

0 commit comments

Comments
 (0)