Skip to content

Commit c5a2248

Browse files
committed
feat: link stack entries to Github blob URLs
1 parent 52f1ecd commit c5a2248

File tree

6 files changed

+150
-6
lines changed

6 files changed

+150
-6
lines changed

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
"react": "^18.3.1",
1616
"source-map": "^0.7.4",
1717
"swc-plugin-coverage-instrument": "0.0.20",
18+
"terminal-link": "^3.0.0",
1819
"widest-line": "^5.0.0"
1920
},
2021
"devDependencies": {
@@ -59,4 +60,4 @@
5960
"main": "./dist/index.js",
6061
"module": "./dist/index.js",
6162
"types": "./dist/index.d.ts"
62-
}
63+
}

src/error/GitHubResolver.ts

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import { pathToFileURL } from 'node:url'
2+
import { SourceLocation, SourceLocationResolver } from './ErrorStackResolver'
3+
import { spawn } from 'node:child_process'
4+
5+
export class GitHubResolver implements SourceLocationResolver {
6+
constructor(
7+
readonly GITHUB_WORKSPACE = process.env.GITHUB_WORKSPACE,
8+
readonly GITHUB_SHA = process.env.GITHUB_SHA,
9+
readonly GITHUB_REPOSITORY = process.env.GITHUB_REPOSITORY,
10+
readonly GITHUB_SERVER_URL = process.env.GITHUB_SERVER_URL,
11+
) {
12+
if (GITHUB_WORKSPACE) {
13+
this.workspaceUrl = String(pathToFileURL(GITHUB_WORKSPACE))
14+
if (!this.workspaceUrl.endsWith('/')) {
15+
this.workspaceUrl += '/'
16+
}
17+
}
18+
}
19+
20+
protected workspaceUrl?: string
21+
protected resolvedWorkspaceUrl?: Promise<string>
22+
protected treeObjects?: Promise<string[]>
23+
24+
async resolve(loc: SourceLocation): Promise<SourceLocation | undefined> {
25+
if (!this.workspaceUrl || !loc.file?.startsWith(this.workspaceUrl)) {
26+
return undefined
27+
}
28+
const path = loc.file.substring(this.workspaceUrl.length)
29+
30+
if (!this.resolvedWorkspaceUrl) {
31+
this.resolvedWorkspaceUrl = this.getGitAbbrevSha()
32+
.then(abbrev => [
33+
this.GITHUB_SERVER_URL,
34+
this.GITHUB_REPOSITORY,
35+
'blob',
36+
abbrev,
37+
].join('/'))
38+
}
39+
if (!this.treeObjects) {
40+
this.treeObjects = this.getGitTreeObjects()
41+
}
42+
const resolvedUrl = await this.resolvedWorkspaceUrl
43+
const tree = await this.treeObjects
44+
45+
if (tree.includes(path)) {
46+
return {...loc,
47+
url: resolvedUrl + '/' + path + (loc.position ? `#L${loc.position.line}` : ''),
48+
}
49+
}
50+
}
51+
52+
protected getGitAbbrevSha(): Promise<string> {
53+
return this.runGitCmd([
54+
'rev-parse',
55+
'--short',
56+
'HEAD',
57+
])
58+
}
59+
60+
protected async getGitTreeObjects(): Promise<string[]> {
61+
const out = await this.runGitCmd([
62+
'ls-tree',
63+
'--name-only',
64+
'-r',
65+
'HEAD',
66+
])
67+
return out.split('\n')
68+
}
69+
70+
protected runGitCmd(args: string[]): Promise<string> {
71+
if (!this.GITHUB_WORKSPACE) {
72+
return Promise.reject(`GITHUB_WORKSPACE is not set.`)
73+
}
74+
75+
return new Promise((res, rej) => {
76+
const child = spawn('git', args, {
77+
cwd: this.GITHUB_WORKSPACE,
78+
stdio: ['ignore', 'pipe', 'pipe'],
79+
})
80+
let out = '', err = ''
81+
child.stdout.on('data', c => {
82+
out += String(c)
83+
})
84+
child.stderr.on('data', c => {
85+
err += String(c)
86+
})
87+
child.on('exit', (code, signal) => {
88+
if (code || signal) {
89+
rej(`Git command failed: ${JSON.stringify(args)}\n` + err)
90+
} else {
91+
res(out.trimEnd())
92+
}
93+
})
94+
})
95+
}
96+
}

src/error/XError.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,7 @@ function* genStackEntries(stack: string) {
9494

9595
export type SourceLocation = {
9696
file?: string
97+
url?: string
9798
position?: Position
9899
name?: string
99100
}
@@ -109,11 +110,17 @@ export class StackEntry {
109110
}
110111
readonly resolved
111112

112-
toString() {
113+
toString(
114+
useUrl = false,
115+
) {
113116
const e = this.resolved.get()
114117

115118
const pos = e.position ? `:${e.position.line}:${e.position.column}` : ''
116-
if (e.name && e.file) {
119+
if (useUrl && e.url && e.name) {
120+
return `${e.name} (${e.url})`
121+
} else if (useUrl && e.url) {
122+
return `${e.url}`
123+
} else if (e.name && e.file) {
117124
return `${e.name} (${e.file}${pos})`
118125
} else if (e.file) {
119126
return `${e.file}${pos}`

src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import { TesterCli } from './ui/cli/TesterCli'
2020
import { ErrorStackResolver, SourceLocation, SourceLocationResolver } from './error/ErrorStackResolver'
2121
import { SourceMapResolver } from './error/SourceMapResolver'
2222
import { PathResolver } from './error/PathResolver'
23+
import { GitHubResolver } from './error/GitHubResolver'
2324

2425
export type { TestContext } from './runner/TestContext'
2526

@@ -373,6 +374,7 @@ export async function setupToolboxTester(
373374
const errorStackResolver = new ErrorStackResolver([
374375
new SourceMapResolver(String(await fileServer.url), fileServer.provider),
375376
runner,
377+
new GitHubResolver(),
376378
new PathResolver(String(pathToFileURL(projectDir)), ''),
377379
])
378380

src/ui/cli/Error.tsx

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,13 @@ export function StackEntryText({
1616
], [entry])
1717
tester.resolveErrorStackEntry(entry)
1818

19-
return <Text color="redBright" dimColor>at {String(entry)}</Text>
19+
// A clickable link would be preferable,
20+
// but GitHub Actions logs don't support the escape sequence for hyperlinks.
21+
// See https://github.com/orgs/community/discussions/119709
22+
23+
return <Text color="redBright" dimColor>
24+
{`at ${entry.toString(true)}`}
25+
</Text>
2026
}
2127

2228
export function XErrorComponent({
@@ -36,7 +42,11 @@ export function XErrorComponent({
3642
</Line>
3743
))}
3844
{error.stackEntries?.map((entry, i, a) => (
39-
<Line key={`stack-${i}`}>
45+
<Line key={`stack-${i}`}
46+
// If overflowX is set, Links are truncated although the box measurements are correct.
47+
overflow="visible"
48+
overflowY="hidden"
49+
>
4050
{border && (<Text color="grey" dimColor>{
4151
(i === a.length - 1) ? '╰ ' : '╎ '
4252
}</Text>)}

yarn.lock

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -685,6 +685,13 @@ ansi-colors@^4.1.3:
685685
resolved "https://registry.yarnpkg.com/ansi-colors/-/ansi-colors-4.1.3.tgz#37611340eb2243e70cc604cad35d63270d48781b"
686686
integrity sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==
687687

688+
ansi-escapes@^5.0.0:
689+
version "5.0.0"
690+
resolved "https://registry.yarnpkg.com/ansi-escapes/-/ansi-escapes-5.0.0.tgz#b6a0caf0eef0c41af190e9a749e0c00ec04bb2a6"
691+
integrity sha512-5GFMVX8HqE/TB+FuBJGuO5XG0WrsA6ptUqoODaT/n9mmUaZFkqnBueB4leqGBCmrUHnCnC4PCZTCd0E7QQ83bA==
692+
dependencies:
693+
type-fest "^1.0.2"
694+
688695
ansi-escapes@^7.0.0:
689696
version "7.0.0"
690697
resolved "https://registry.yarnpkg.com/ansi-escapes/-/ansi-escapes-7.0.0.tgz#00fc19f491bbb18e1d481b97868204f92109bfe7"
@@ -3231,13 +3238,21 @@ supports-color@^5.3.0:
32313238
dependencies:
32323239
has-flag "^3.0.0"
32333240

3234-
supports-color@^7.1.0:
3241+
supports-color@^7.0.0, supports-color@^7.1.0:
32353242
version "7.2.0"
32363243
resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-7.2.0.tgz#1b7dcdcb32b8138801b3e478ba6a51caa89648da"
32373244
integrity sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==
32383245
dependencies:
32393246
has-flag "^4.0.0"
32403247

3248+
supports-hyperlinks@^2.2.0:
3249+
version "2.3.0"
3250+
resolved "https://registry.yarnpkg.com/supports-hyperlinks/-/supports-hyperlinks-2.3.0.tgz#3943544347c1ff90b15effb03fc14ae45ec10624"
3251+
integrity sha512-RpsAZlpWcDwOPQA22aCH4J0t7L8JmAvsCxfOSEwm7cQs3LshN36QaTkwd70DnBOXDWGssw2eUoc8CaRWT0XunA==
3252+
dependencies:
3253+
has-flag "^4.0.0"
3254+
supports-color "^7.0.0"
3255+
32413256
supports-preserve-symlinks-flag@^1.0.0:
32423257
version "1.0.0"
32433258
resolved "https://registry.yarnpkg.com/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz#6eda4bd344a3c94aea376d4cc31bc77311039e09"
@@ -3266,6 +3281,14 @@ tar-stream@^3.1.5:
32663281
fast-fifo "^1.2.0"
32673282
streamx "^2.15.0"
32683283

3284+
terminal-link@^3.0.0:
3285+
version "3.0.0"
3286+
resolved "https://registry.yarnpkg.com/terminal-link/-/terminal-link-3.0.0.tgz#91c82a66b52fc1684123297ce384429faf72ac5c"
3287+
integrity sha512-flFL3m4wuixmf6IfhFJd1YPiLiMuxEc8uHRM1buzIeZPm22Au2pDqBJQgdo7n1WfPU1ONFGv7YDwpFBmHGF6lg==
3288+
dependencies:
3289+
ansi-escapes "^5.0.0"
3290+
supports-hyperlinks "^2.2.0"
3291+
32693292
text-table@^0.2.0:
32703293
version "0.2.0"
32713294
resolved "https://registry.yarnpkg.com/text-table/-/text-table-0.2.0.tgz#7f5ee823ae805207c00af2df4a84ec3fcfa570b4"
@@ -3329,6 +3352,11 @@ type-fest@^0.20.2:
33293352
resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.20.2.tgz#1bf207f4b28f91583666cb5fbd327887301cd5f4"
33303353
integrity sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==
33313354

3355+
type-fest@^1.0.2:
3356+
version "1.4.0"
3357+
resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-1.4.0.tgz#e9fb813fe3bf1744ec359d55d1affefa76f14be1"
3358+
integrity sha512-yGSza74xk0UG8k+pLh5oeoYirvIiWo5t0/o3zHHAO2tRDiZcxWP7fywNlXhqb6/r6sWvwi+RsyQMWhVLe4BVuA==
3359+
33323360
type-fest@^4.27.0:
33333361
version "4.31.0"
33343362
resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-4.31.0.tgz#a3de630c96eb77c281b6ba2affa5dae5fb3c326c"

0 commit comments

Comments
 (0)