Skip to content

Commit 68b2493

Browse files
committed
feat: resolve source locations in error stacks
1 parent 8bd47b0 commit 68b2493

29 files changed

+698
-210
lines changed

src/conductor/ChromeTestConductor.ts

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ import puppeteer, { Page } from 'puppeteer-core'
22
import { TestConductor } from './TestConductor'
33
import { HttpReporterServer } from './HttpReporterServer'
44
import { TestReporter } from './TestReporter'
5-
import { ErrorStackResolver } from './ErrorStackResolver'
65
import { AbortablePromise } from '../util/AbortablePromise'
76

87
export class ChromeTestConductor extends TestConductor {
@@ -11,7 +10,6 @@ export class ChromeTestConductor extends TestConductor {
1110
title?: string,
1211
setupFiles: URL[] = [],
1312
coverageVar = '__coverage__',
14-
errorStackResolver = new ErrorStackResolver([]),
1513
readonly browser = puppeteer.launch({
1614
executablePath: '/usr/bin/chromium',
1715
headless: 'new',
@@ -24,11 +22,9 @@ export class ChromeTestConductor extends TestConductor {
2422
}),
2523
) {
2624
super(title, setupFiles, coverageVar)
27-
28-
this.reporterServer = new HttpReporterServer(errorStackResolver)
2925
}
3026

31-
readonly reporterServer: HttpReporterServer
27+
readonly reporterServer = new HttpReporterServer()
3228

3329
async close(): Promise<void> {
3430
await Promise.all([
Lines changed: 87 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -1,72 +1,115 @@
1+
import { fileURLToPath } from 'node:url'
12
import { RawSourceMap, SourceMapConsumer } from 'source-map'
23

34
export type FileServer = {
45
url: string
56
getFile: (path: string) => Promise<string>
6-
origin: string
7+
origin?: string
78
}
89

910
export class ErrorStackResolver {
1011
constructor(
1112
readonly fileServers: Array<FileServer>,
12-
) {
13-
}
13+
) {}
1414

1515
async rewriteStack(
1616
stack: string,
1717
) {
18-
const re = /(?<pre>\s+at [^\n)]+\()(?<url>\w+:\/\/[^/?]*[^)]*):(?<line>\d+):(?<column>\d+)(?<post>\)$)/gm
19-
let r: RegExpExecArray|null
20-
// eslint-disable-next-line no-cond-assign
21-
while ((r = re.exec(stack)) && r?.groups) {
22-
const url = r.groups.url
23-
const line = Number(r.groups.line)
24-
const column = Number(r.groups.column)
25-
for (const server of this.fileServers) {
26-
if (url.startsWith(server.url) && (server.url.endsWith('/') || url[server.url.length] === '/')) {
27-
const subpath = trimStart(url.substring(server.url.length), '/')
28-
let subPathAndPos = `${subpath}:${line}:${column}`
18+
for (let end = stack.length, start = end;
19+
start = stack.lastIndexOf('\n', start - 1), start >= 0;
20+
end = start
21+
) {
22+
const l = stack.substring(start, end)
23+
if (l === '\n') {
24+
continue
25+
}
26+
const at = l.match(/^\s+at /)
27+
if (!at) {
28+
break
29+
}
30+
const m = l.match(/ \((?<file>[^)]+):(?<line>\d+):(?<column>\d+)\)$/)
31+
if (!m?.groups || m.index === undefined) {
32+
continue
33+
}
2934

30-
// TODO: handle errors when resolving code positions
35+
const name = l.substring(at[0].length, m.index)
36+
const {file, line, column} = m.groups
3137

32-
const content = await server.getFile(subpath)
33-
const mapMatch = String(content).match(/\n\/\/# sourceMappingURL=data:application\/json;charset=utf-8;base64,(?<encodedMap>[0-9a-zA-Z+/]+)\s*$/)
34-
if (mapMatch?.groups) {
35-
const map = JSON.parse(Buffer.from(mapMatch.groups.encodedMap, 'base64').toString('utf8')) as RawSourceMap
36-
const original = await SourceMapConsumer.with(map, null, consumer => {
37-
return consumer.originalPositionFor({ line, column })
38-
})
39-
if (original.source) {
40-
subPathAndPos = `${subpath.substring(0, subpath.length - map.file.length)}${original.source}:${String(original.line)}:${String(original.column)}`
41-
}
42-
}
43-
const newStackEntry = r.groups.pre
44-
+ server.origin
45-
+ (server.origin.endsWith('/') ? '' : '/')
46-
+ subPathAndPos
47-
+ r.groups.post
38+
const server = this.getServer(file)
39+
if (!server) {
40+
continue
41+
}
4842

49-
stack = stack.substring(0, r.index)
50-
+ newStackEntry
51-
+ stack.substring(r.index + r[0].length)
43+
const map = await server.getFile(server.path).then(
44+
source => this.getSourceMap(source, file),
45+
() => undefined,
46+
)
5247

53-
re.lastIndex += newStackEntry.length - r[0].length
48+
let resolved = map
49+
? await SourceMapConsumer.with(map.raw, map.url, consumer => {
50+
return consumer.originalPositionFor({line: Number(line), column: Number(column)})
51+
})
52+
: undefined
5453

55-
break
54+
if (!resolved?.source) {
55+
resolved = {
56+
source: server.origin
57+
? server.origin + (server.origin.endsWith('/') ? '' : '/') + server.path
58+
: null,
59+
name: null,
60+
line: null,
61+
column: null,
5662
}
5763
}
64+
65+
if (resolved.source?.startsWith('file://')) {
66+
resolved.source = fileURLToPath(resolved.source)
67+
}
68+
69+
stack = stack.substring(0, start)
70+
+ at[0]
71+
+ (resolved.name ?? name)
72+
+ ' ('
73+
+ (resolved.source ?? file)
74+
+ (resolved.line !== null && resolved.column !== null
75+
? `:${resolved.line}:${resolved.column}`
76+
: `:${line}:${column}`
77+
)
78+
+ ')'
79+
+ stack.substring(end)
5880
}
5981
return stack
6082
}
61-
}
6283

63-
function trimStart(
64-
str: string,
65-
chars: string,
66-
) {
67-
for (let i = 0; ; i++) {
68-
if (i >= str.length || !chars.includes(str[i])) {
69-
return str.substring(i)
84+
protected getServer(
85+
url: string,
86+
) {
87+
for (const server of this.fileServers) {
88+
const pre = server.url.endsWith('/') ? server.url : server.url + '/'
89+
if (url.startsWith(pre)) {
90+
return {
91+
origin: server.origin,
92+
getFile: (p: string) => server.getFile(p),
93+
path: url.substring(pre.length),
94+
}
95+
}
96+
}
97+
}
98+
99+
protected getSourceMap(
100+
source: string,
101+
sourceUrl: string,
102+
) {
103+
const sourceMappingURL = source.match(/\n\/\/# sourceMappingURL=([^\n]+)\s*$/m)?.[1]
104+
105+
if (sourceMappingURL?.startsWith('data:application/json')) {
106+
const encodedMap = sourceMappingURL.match(/^data:application\/json(?:;charset=[-\w]+)?;base64,(?<encoded>[0-9a-zA-Z+/]+)/)?.groups?.encoded
107+
if (encodedMap) {
108+
return {
109+
raw: JSON.parse(Buffer.from(encodedMap, 'base64').toString()) as RawSourceMap,
110+
url: sourceUrl,
111+
}
112+
}
70113
}
71114
}
72115
}

src/conductor/HttpReporterServer.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import { createServer, IncomingMessage, ServerResponse } from 'http'
2-
import { ErrorStackResolver } from './ErrorStackResolver'
32
import { TestCompleteData, TestErrorData, TestReporter, TestResultData, TestScheduleData } from './TestReporter'
43

54
export type HttpReporterReport = {
@@ -13,7 +12,6 @@ export type HttpReporterReport = {
1312

1413
export class HttpReporterServer {
1514
constructor(
16-
public readonly errorStackResolver: ErrorStackResolver,
1715
protected readonly host = '127.0.0.1',
1816
) {
1917
this.url = new Promise((res, rej) => this.http.listen(0, '127.0.0.1', () => {

src/conductor/NodeTestConductor.ts

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@ import url from 'node:url'
55
import { TestConductor } from './TestConductor'
66
import { HttpReporterServer } from './HttpReporterServer'
77
import { TestReporter } from './TestReporter'
8-
import { ErrorStackResolver } from './ErrorStackResolver'
98
import { AbortablePromise } from '../util/AbortablePromise'
109

1110
const loaderPathEnv = 'ToolboxNodeLoadersPath'
@@ -24,14 +23,11 @@ export class NodeTestConductor extends TestConductor {
2423
title?: string,
2524
setupFiles: URL[] = [],
2625
coverageVar = '__coverage__',
27-
errorStackResolver = new ErrorStackResolver([]),
2826
) {
2927
super(title, setupFiles, coverageVar)
30-
31-
this.reporterServer = new HttpReporterServer(errorStackResolver)
3228
}
3329

34-
readonly reporterServer: HttpReporterServer
30+
readonly reporterServer = new HttpReporterServer()
3531

3632
async close(): Promise<void> {
3733
await this.reporterServer.close()

src/conductor/TestRun/TestError.ts

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,16 @@
11
import { TestHook } from './TestHook'
2+
import { XError } from '../../error/XError'
23

3-
export class TestError {
4+
export class TestError extends XError {
45
constructor(
5-
readonly error: Error|string,
6+
error: Error|string,
67
readonly hook?: TestHook,
7-
) {}
8-
9-
toString() {
10-
return typeof this.error === 'string'
11-
? this.error
12-
: this.error.stack ?? `${this.error.name}: ${this.error.message}`
8+
) {
9+
if (typeof error === 'string') {
10+
super('', error)
11+
} else {
12+
super(error.name, error.message, error.stack, error.cause)
13+
}
1314
}
1415
}
1516

src/conductor/TestRun/TestResult.ts

Lines changed: 14 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,25 @@
11
import { TestResultType } from './enum'
2+
import { XError } from '../../error/XError'
23

34
export class TestResult {
45
constructor(
56
readonly type: TestResultType,
6-
readonly error?: Error|string,
7+
error?: Error|string,
78
readonly duration?: number,
8-
) {}
9+
) {
10+
this.error = TestResultError.from(error)
11+
}
12+
readonly error
13+
}
914

10-
getErrorAsString() {
11-
if (!this.error) {
12-
return ''
13-
} else if (typeof this.error === 'string') {
14-
return this.error
15-
} else if (this.error.stack) {
16-
return this.error.stack
17-
} else {
18-
return this.error.name + ': ' + this.error.message
15+
export class TestResultError extends XError {
16+
static from(e?: Error|string) {
17+
if (e === undefined) {
18+
return undefined
19+
} else if (typeof e === 'string') {
20+
return new TestResultError('', e)
1921
}
22+
return new TestResultError(e.name, e.message, e.stack, e.cause)
2023
}
2124
}
2225

src/error/ErrorStackResolver.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { SourceLocation, StackEntry } from './XError'
2+
3+
export type { SourceLocation }
4+
5+
export interface SourceLocationResolver {
6+
resolve(loc: SourceLocation): Promise<SourceLocation|undefined>|SourceLocation|undefined
7+
}
8+
9+
export class ErrorStackResolver {
10+
constructor(
11+
public resolvers: SourceLocationResolver[],
12+
) {}
13+
14+
#results: Record<string, Promise<SourceLocation>> = {}
15+
16+
clear() {
17+
this.#results = {}
18+
}
19+
20+
async resolve(entry: StackEntry) {
21+
if (!(entry.raw in this.#results)) {
22+
const p = this.#doResolve(entry)
23+
this.#results[entry.raw] = p
24+
}
25+
26+
return await this.#results[entry.raw]
27+
}
28+
29+
async #doResolve(entry: StackEntry) {
30+
let loc: SourceLocation = {
31+
file: entry.file,
32+
position: entry.position,
33+
name: entry.name,
34+
}
35+
for (const r of this.resolvers) {
36+
loc = await r.resolve(loc) ?? loc
37+
}
38+
return loc
39+
}
40+
}

src/error/PathResolver.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { SourceLocationResolver } from './ErrorStackResolver'
2+
import { SourceLocation } from './XError'
3+
4+
export class PathResolver implements SourceLocationResolver {
5+
constructor(
6+
path: string,
7+
resolved: string,
8+
) {
9+
this.path = path + (path.endsWith('/') ? '' : '/')
10+
this.resolved = resolved
11+
? resolved + (resolved.endsWith('/') ? '' : '/')
12+
: ''
13+
}
14+
readonly path: string
15+
readonly resolved: string
16+
17+
resolve(loc: SourceLocation) {
18+
if (loc.file?.startsWith(this.path)) {
19+
return { ...loc,
20+
file: this.resolved + loc.file.substring(this.path.length),
21+
}
22+
}
23+
}
24+
}

0 commit comments

Comments
 (0)