Skip to content

Commit 8e26bea

Browse files
authored
Make loadJS use the same error handling as runJS (#199)
1 parent a335563 commit 8e26bea

7 files changed

Lines changed: 205 additions & 130 deletions

File tree

.changeset/early-rules-knock.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+
Make loadJS share error mapping logic with runJS

src/index.ts

Lines changed: 19 additions & 89 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import * as puppeteer from 'puppeteer';
2-
import { relative, join, isAbsolute, dirname, posix, sep, resolve } from 'path';
2+
import { relative, join, isAbsolute, dirname } from 'path';
33
import type { BoundQueries } from './pptr-testing-library';
44
import { getQueriesForElement } from './pptr-testing-library';
55
import { connectToBrowser } from './connect-to-browser';
@@ -11,16 +11,13 @@ import _ansiRegex from 'ansi-regex';
1111
import { fileURLToPath } from 'url';
1212
import type { PleasantestUser } from './user';
1313
import { pleasantestUser } from './user';
14-
import {
15-
assertElementHandle,
16-
printStackLine,
17-
removeFuncFromStackTrace,
18-
} from './utils';
14+
import { assertElementHandle, removeFuncFromStackTrace } from './utils';
1915
import type { ModuleServerOpts } from './module-server';
2016
import { createModuleServer } from './module-server';
2117
import { cleanupClientRuntimeServer } from './module-server/client-runtime-server';
2218
import { Console } from 'console';
2319
import { createBuildStatusTracker } from './module-server/build-status-tracker';
20+
import { sourceMapErrorFromBrowser } from './source-map-error-from-browser';
2421

2522
export { JSHandle, ElementHandle } from 'puppeteer';
2623
koloristOpts.enabled = true;
@@ -320,7 +317,7 @@ const createTab = async ({
320317
// This uses the testPath as the url so that if there are relative imports
321318
// in the inline code, the relative imports are resolved relative to the test file
322319
const url = `http://localhost:${port}/${testPath}?inline-code=${encodedCode}&build-id=${buildStatus.buildId}`;
323-
const res = (await safeEvaluate(
320+
const res = await safeEvaluate(
324321
runJS,
325322
new Function(
326323
'...args',
@@ -334,91 +331,13 @@ const createTab = async ({
334331
: e)`,
335332
) as () => any,
336333
...(Array.isArray(args) ? (args as any) : []),
337-
)) as undefined | { message: string; stack: string };
334+
);
338335

339336
const errorsFromBuild = buildStatus.complete();
340337
// It only throws the first one but that is probably OK
341338
if (errorsFromBuild) throw errorsFromBuild[0];
342339

343-
if (res === undefined) return;
344-
if (typeof res !== 'object') throw res;
345-
const { message, stack } = res;
346-
const parsedStack = parseStackTrace(stack);
347-
const modifiedStack = parsedStack.map(async (stackItem) => {
348-
if (stackItem.raw.startsWith(stack.slice(0, stack.indexOf('\n'))))
349-
return null;
350-
if (!stackItem.fileName) return stackItem.raw;
351-
const fileName = stackItem.fileName;
352-
const line = stackItem.line;
353-
const column = stackItem.column;
354-
if (!fileName.startsWith(`http://localhost:${port}`))
355-
return stackItem.raw;
356-
const url = new URL(fileName);
357-
const osPath = url.pathname.slice(1).split(posix.sep).join(sep);
358-
// Absolute file path
359-
const file = resolve(process.cwd(), osPath);
360-
// Rollup-style Unix-normalized path "id":
361-
const id = file.split(sep).join(posix.sep);
362-
const transformResult = requestCache.get(id);
363-
const map = typeof transformResult === 'object' && transformResult.map;
364-
if (!map) {
365-
let p = url.pathname;
366-
const npmPrefix = '/@npm/';
367-
if (p.startsWith(npmPrefix))
368-
p = join(process.cwd(), 'node_modules', p.slice(npmPrefix.length));
369-
return printStackLine(p, line, column, stackItem.name);
370-
}
371-
372-
const { SourceMapConsumer } = await import('source-map');
373-
const consumer = await new SourceMapConsumer(map as any);
374-
const sourceLocation = consumer.originalPositionFor({
375-
line,
376-
column: column - 1, // Source-map uses zero-based column numbers
377-
});
378-
consumer.destroy();
379-
return printStackLine(
380-
join(process.cwd(), url.pathname),
381-
sourceLocation.line ?? line,
382-
sourceLocation.column === null
383-
? column
384-
: // Convert back from zero-based column to 1-based
385-
sourceLocation.column + 1,
386-
stackItem.name,
387-
);
388-
});
389-
const errorName = stack.slice(0, stack.indexOf(':')) || 'Error';
390-
const specializedErrors = {
391-
EvalError,
392-
RangeError,
393-
ReferenceError,
394-
SyntaxError,
395-
TypeError,
396-
URIError,
397-
} as any;
398-
const ErrorConstructor: ErrorConstructor =
399-
specializedErrors[errorName] || Error;
400-
const error = new ErrorConstructor(message);
401-
402-
const finalStack = (await Promise.all(modifiedStack))
403-
.filter(Boolean)
404-
.join('\n');
405-
406-
// If the browser error did not provide a stack, use the stack trace from node
407-
if (finalStack) {
408-
error.stack = `${errorName}: ${message}\n${finalStack}`;
409-
} else {
410-
removeFuncFromStackTrace(error, runJS);
411-
if (error.stack)
412-
error.stack = error.stack
413-
.split('\n')
414-
.filter(
415-
// This was appearing in stack traces and it messed up the Jest output
416-
(line) => !(/runMicrotasks/.test(line) && /<anonymous>/.test(line)),
417-
)
418-
.join('\n');
419-
}
420-
421-
throw error;
340+
await sourceMapErrorFromBrowser(res, requestCache, port, runJS);
422341
};
423342

424343
const injectHTML: PleasantestUtils['injectHTML'] = async (html) => {
@@ -459,10 +378,21 @@ const createTab = async ({
459378
const fullPath = jsPath.startsWith('.')
460379
? join(dirname(testPath), jsPath)
461380
: jsPath;
462-
await safeEvaluate(
381+
const buildStatus = createBuildStatusTracker();
382+
const url = `http://localhost:${port}/${fullPath}?build-id=${buildStatus.buildId}`;
383+
const res = await safeEvaluate(
463384
loadJS,
464-
`import(${JSON.stringify(`http://localhost:${port}/${fullPath}`)})`,
385+
`import(${JSON.stringify(url)})
386+
.catch(e => e instanceof Error
387+
? { message: e.message, stack: e.stack }
388+
: e)`,
465389
);
390+
391+
const errorsFromBuild = buildStatus.complete();
392+
// It only throws the first one but that is probably OK
393+
if (errorsFromBuild) throw errorsFromBuild[0];
394+
395+
await sourceMapErrorFromBrowser(res, requestCache, port, loadJS);
466396
};
467397

468398
const utils: PleasantestUtils = {
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import { join, posix, sep, resolve } from 'path';
2+
import { parseStackTrace } from 'errorstacks';
3+
import _ansiRegex from 'ansi-regex';
4+
import { printStackLine, removeFuncFromStackTrace } from './utils';
5+
import type { SourceDescription } from 'rollup';
6+
7+
export const sourceMapErrorFromBrowser = async (
8+
res: unknown,
9+
requestCache: Map<string, SourceDescription>,
10+
port: number,
11+
func: (...args: any[]) => void,
12+
) => {
13+
if (res === undefined) return;
14+
if (
15+
typeof res !== 'object' ||
16+
!res ||
17+
!('message' in res) ||
18+
!('stack' in res)
19+
)
20+
throw res;
21+
const { message, stack } = res as {
22+
message: string;
23+
stack: string;
24+
};
25+
const parsedStack = parseStackTrace(stack);
26+
const modifiedStack = parsedStack.map(async (stackItem) => {
27+
if (stackItem.raw.startsWith(stack.slice(0, stack.indexOf('\n'))))
28+
return null;
29+
if (!stackItem.fileName) return stackItem.raw;
30+
const fileName = stackItem.fileName;
31+
const line = stackItem.line;
32+
const column = stackItem.column;
33+
if (!fileName.startsWith(`http://localhost:${port}`)) return stackItem.raw;
34+
const url = new URL(fileName);
35+
const osPath = url.pathname.slice(1).split(posix.sep).join(sep);
36+
// Absolute file path
37+
const file = resolve(process.cwd(), osPath);
38+
// Rollup-style Unix-normalized path "id":
39+
const id = file.split(sep).join(posix.sep);
40+
const transformResult = requestCache.get(id);
41+
const map = typeof transformResult === 'object' && transformResult.map;
42+
if (!map) {
43+
let p = url.pathname;
44+
const npmPrefix = '/@npm/';
45+
if (p.startsWith(npmPrefix))
46+
p = join(process.cwd(), 'node_modules', p.slice(npmPrefix.length));
47+
return printStackLine(p, line, column, stackItem.name);
48+
}
49+
50+
const { SourceMapConsumer } = await import('source-map');
51+
const consumer = await new SourceMapConsumer(map as any);
52+
const sourceLocation = consumer.originalPositionFor({
53+
line,
54+
column: column - 1, // Source-map uses zero-based column numbers
55+
});
56+
consumer.destroy();
57+
return printStackLine(
58+
join(process.cwd(), url.pathname),
59+
sourceLocation.line ?? line,
60+
sourceLocation.column === null
61+
? column
62+
: // Convert back from zero-based column to 1-based
63+
sourceLocation.column + 1,
64+
stackItem.name,
65+
);
66+
});
67+
const errorName = stack.slice(0, stack.indexOf(':')) || 'Error';
68+
const ErrorConstructor = specializedErrors[errorName] || Error;
69+
const error = new ErrorConstructor(message);
70+
71+
const finalStack = (await Promise.all(modifiedStack))
72+
.filter(Boolean)
73+
.join('\n');
74+
75+
// If the browser error did not provide a stack, use the stack trace from node
76+
if (finalStack) {
77+
error.stack = `${errorName}: ${message}\n${finalStack}`;
78+
} else {
79+
removeFuncFromStackTrace(error, func);
80+
if (error.stack)
81+
error.stack = error.stack
82+
.split('\n')
83+
.filter(
84+
// This was appearing in stack traces and it messed up the Jest output
85+
(line) => !(/runMicrotasks/.test(line) && /<anonymous>/.test(line)),
86+
)
87+
.join('\n');
88+
}
89+
90+
throw error;
91+
};
92+
93+
const specializedErrors: Record<string, ErrorConstructor | undefined> = {
94+
EvalError,
95+
RangeError,
96+
ReferenceError,
97+
SyntaxError,
98+
TypeError,
99+
URIError,
100+
};

tests/test-utils.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { parseStackTrace } from 'errorstacks';
22
import { promises as fs } from 'fs';
33
import * as path from 'path';
4+
import ansiRegex from 'ansi-regex';
45

56
export const printErrorFrames = async (error?: Error) => {
67
if (!error?.stack) return '';
@@ -45,3 +46,27 @@ export const printErrorFrames = async (error?: Error) => {
4546
`\n${'-'.repeat(55)}\n`,
4647
);
4748
};
49+
50+
const stripAnsi = (input: string) => input.replace(ansiRegex(), '');
51+
52+
const removeLineNumbers = (input: string) => {
53+
const lineRegex = /^(\s*>?\s*)(\d+)/gm;
54+
const fileRegex = new RegExp(`${process.cwd()}([a-zA-Z/._-]*)[\\d:]*`, 'g');
55+
return (
56+
input
57+
.replace(
58+
lineRegex,
59+
(_match, whitespace, numbers) =>
60+
`${whitespace}${'#'.repeat(numbers.length)}`,
61+
)
62+
// Take out the file paths so the tests will pass on more than 1 person's machine
63+
.replace(fileRegex, '<root>$1:###:###')
64+
);
65+
};
66+
67+
export const formatErrorWithCodeFrame = <T extends any>(input: Promise<T>) =>
68+
input.catch((error) => {
69+
error.message = removeLineNumbers(stripAnsi(error.message));
70+
error.stack = removeLineNumbers(stripAnsi(error.stack));
71+
throw error;
72+
});

tests/utils/external-throwing.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
let foo: string;
2+
3+
throw new Error('asdf');

tests/utils/loadJS.test.ts

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,40 @@
1+
import { withBrowser } from 'pleasantest';
2+
import { formatErrorWithCodeFrame, printErrorFrames } from '../test-utils';
3+
14
test.todo('loads from .ts file with transpiling');
2-
test.todo('if the file throws an error the error is source mapped');
3-
test.todo('if the file has a syntax error the location is source mapped');
5+
6+
test(
7+
'if the file throws an error the error is source mapped',
8+
withBrowser(async ({ utils }) => {
9+
const error = await utils
10+
.loadJS('./external-throwing.ts')
11+
.catch((error) => error);
12+
expect(await printErrorFrames(error)).toMatchInlineSnapshot(`
13+
"Error: asdf
14+
-------------------------------------------------------
15+
tests/utils/external-throwing.ts
16+
17+
throw new Error('asdf');
18+
^"
19+
`);
20+
}),
21+
);
22+
23+
test(
24+
'if the file has a syntax error the location is source mapped',
25+
withBrowser(async ({ utils }) => {
26+
const loadPromise = utils.loadJS('./external-with-syntax-error.ts');
27+
await expect(formatErrorWithCodeFrame(loadPromise)).rejects
28+
.toThrowErrorMatchingInlineSnapshot(`
29+
"[esbuild] The constant \\"someVariable\\" must be initialized
30+
31+
<root>/tests/utils/external-with-syntax-error.ts:###:###
32+
33+
# | // @ts-expect-error: this is intentionally invalid
34+
> # | const someVariable: string;
35+
| ^
36+
# |
37+
"
38+
`);
39+
}),
40+
);

0 commit comments

Comments
 (0)