jest-roblox-assassin 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +64 -0
- package/package.json +36 -0
- package/src/cache.js +19 -0
- package/src/cli.js +833 -0
- package/src/docs.js +101 -0
- package/src/rewriter.js +562 -0
package/src/docs.js
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import chalk from "chalk";
|
|
2
|
+
import fs from "fs";
|
|
3
|
+
import path from "path";
|
|
4
|
+
import { ensureCache } from "./cache.js";
|
|
5
|
+
|
|
6
|
+
const DOCS_URL =
|
|
7
|
+
"https://raw.githubusercontent.com/Roblox/jest-roblox/master/docs/docs/CLI.md";
|
|
8
|
+
|
|
9
|
+
export async function getCliOptions() {
|
|
10
|
+
const cliOptionsPath = path.join(ensureCache(), "cli-options.json");
|
|
11
|
+
|
|
12
|
+
// Check if cached JSON exists
|
|
13
|
+
if (fs.existsSync(cliOptionsPath)) {
|
|
14
|
+
try {
|
|
15
|
+
const cached = JSON.parse(fs.readFileSync(cliOptionsPath, "utf-8"));
|
|
16
|
+
return cached;
|
|
17
|
+
} catch (error) {
|
|
18
|
+
console.warn(
|
|
19
|
+
"Failed to read cached CLI options, fetching fresh..."
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Fetch and parse
|
|
25
|
+
try {
|
|
26
|
+
const response = await fetch(DOCS_URL);
|
|
27
|
+
if (!response.ok) {
|
|
28
|
+
throw new Error(
|
|
29
|
+
`Failed to fetch documentation: ${response.statusText}`
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
const markdown = await response.text();
|
|
33
|
+
const options = parseMarkdown(markdown);
|
|
34
|
+
|
|
35
|
+
// Save to JSON for future use
|
|
36
|
+
fs.mkdirSync(path.dirname(cliOptionsPath), { recursive: true });
|
|
37
|
+
fs.writeFileSync(cliOptionsPath, JSON.stringify(options, null, 2));
|
|
38
|
+
|
|
39
|
+
return options;
|
|
40
|
+
} catch (error) {
|
|
41
|
+
console.error(
|
|
42
|
+
chalk.red(`Error fetching documentation: ${error.message}`)
|
|
43
|
+
);
|
|
44
|
+
return [];
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export async function showHelp() {
|
|
49
|
+
const options = await getCliOptions();
|
|
50
|
+
|
|
51
|
+
console.log("Usage: jestrbx [TestPathPatterns]");
|
|
52
|
+
console.log("");
|
|
53
|
+
|
|
54
|
+
for (const doc of options) {
|
|
55
|
+
console.log(`${chalk.green.bold(doc.name)} ${chalk.cyan(doc.type)}`);
|
|
56
|
+
console.log(`${doc.description.trim()}`);
|
|
57
|
+
console.log("");
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
console.log(
|
|
61
|
+
chalk.gray("Source: https://roblox.github.io/jest-roblox-internal/cli")
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function parseMarkdown(markdown) {
|
|
66
|
+
const options = [];
|
|
67
|
+
|
|
68
|
+
// Remove HTML comments
|
|
69
|
+
const cleanMarkdown = markdown.replace(/<!--[\s\S]*?-->/g, "");
|
|
70
|
+
|
|
71
|
+
// Match ### `optionName` [type]
|
|
72
|
+
const sectionRegex = /### `([^`]+)` \\?\[([^\]]+)\]([\s\S]*?)(?=\n### |$)/g;
|
|
73
|
+
let match;
|
|
74
|
+
|
|
75
|
+
while ((match = sectionRegex.exec(cleanMarkdown)) !== null) {
|
|
76
|
+
const name = match[1];
|
|
77
|
+
let type = match[2];
|
|
78
|
+
let description = match[3];
|
|
79
|
+
|
|
80
|
+
// Decode HTML entities in type
|
|
81
|
+
type = type.replace(/<?/g, "<").replace(/>?/g, ">");
|
|
82
|
+
|
|
83
|
+
// Remove images and links like [](...) 
|
|
84
|
+
description = description.replace(/\[!\[.*?\]\(.*?\)\]\(.*?\)/g, "");
|
|
85
|
+
description = description.replace(/!\[.*?\]\(.*?\)/g, "");
|
|
86
|
+
|
|
87
|
+
// Remove tip blocks
|
|
88
|
+
description = description.replace(/:::tip[\s\S]*?:::/g, "");
|
|
89
|
+
|
|
90
|
+
// Remove extra whitespace and newlines
|
|
91
|
+
description = description.trim();
|
|
92
|
+
|
|
93
|
+
options.push({
|
|
94
|
+
name: `--${name}`,
|
|
95
|
+
type: `[${type}]`,
|
|
96
|
+
description,
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return options;
|
|
101
|
+
}
|
package/src/rewriter.js
ADDED
|
@@ -0,0 +1,562 @@
|
|
|
1
|
+
import chalk from "chalk";
|
|
2
|
+
import fs from "fs";
|
|
3
|
+
import path from "path";
|
|
4
|
+
|
|
5
|
+
export class ResultRewriter {
|
|
6
|
+
INTERNAL_FRAME_PATTERNS = [
|
|
7
|
+
/rbxts_include\.node_modules/i,
|
|
8
|
+
/@rbxts-js\.Promise/i,
|
|
9
|
+
/@rbxts-js\.JestCircus/i,
|
|
10
|
+
];
|
|
11
|
+
|
|
12
|
+
constructor({
|
|
13
|
+
workspaceRoot,
|
|
14
|
+
projectRoot,
|
|
15
|
+
rootDir,
|
|
16
|
+
outDir,
|
|
17
|
+
datamodelPrefixSegments,
|
|
18
|
+
}) {
|
|
19
|
+
this.workspaceRoot = workspaceRoot || projectRoot;
|
|
20
|
+
this.projectRoot = projectRoot;
|
|
21
|
+
this.rootDir = rootDir;
|
|
22
|
+
this.outDir = outDir;
|
|
23
|
+
this.datamodelPrefixSegments = datamodelPrefixSegments;
|
|
24
|
+
|
|
25
|
+
const firstSegment = this.datamodelPrefixSegments[0];
|
|
26
|
+
this.stackFramePattern = new RegExp(
|
|
27
|
+
`(?:\\[string\\s+")?(${firstSegment}[^\\]":\\n]+)(?:"\\])?:([0-9]+)`,
|
|
28
|
+
"g"
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* A map from datamodel paths to their corresponding Luau and source file paths.
|
|
33
|
+
* @type {Map<string, { luauPath: string, sourcePath: string | undefined }>}
|
|
34
|
+
*/
|
|
35
|
+
this.modulePathMap = (() => {
|
|
36
|
+
const map = new Map();
|
|
37
|
+
const outRoot = path.join(this.projectRoot, this.outDir);
|
|
38
|
+
if (!fs.existsSync(outRoot)) return map;
|
|
39
|
+
|
|
40
|
+
function visit(folder) {
|
|
41
|
+
for (const entry of fs.readdirSync(folder, {
|
|
42
|
+
withFileTypes: true,
|
|
43
|
+
})) {
|
|
44
|
+
const abs = path.join(folder, entry.name);
|
|
45
|
+
if (entry.isDirectory()) {
|
|
46
|
+
visit(abs);
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
if (!entry.name.endsWith(".luau")) continue;
|
|
50
|
+
|
|
51
|
+
const rel = path.relative(outRoot, abs);
|
|
52
|
+
const withoutExt = rel.slice(0, -".luau".length);
|
|
53
|
+
const datamodelPath = [
|
|
54
|
+
...datamodelPrefixSegments,
|
|
55
|
+
...withoutExt.split(path.sep),
|
|
56
|
+
].join(".");
|
|
57
|
+
|
|
58
|
+
const candidateBases = [
|
|
59
|
+
withoutExt + ".ts",
|
|
60
|
+
withoutExt + ".tsx",
|
|
61
|
+
withoutExt + ".lua",
|
|
62
|
+
withoutExt + ".luau",
|
|
63
|
+
];
|
|
64
|
+
let sourcePath;
|
|
65
|
+
for (const candidate of candidateBases) {
|
|
66
|
+
const candidatePath = path.join(
|
|
67
|
+
projectRoot,
|
|
68
|
+
rootDir,
|
|
69
|
+
candidate
|
|
70
|
+
);
|
|
71
|
+
if (fs.existsSync(candidatePath)) {
|
|
72
|
+
sourcePath = candidatePath;
|
|
73
|
+
break;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
map.set(datamodelPath, {
|
|
78
|
+
luauPath: abs,
|
|
79
|
+
sourcePath,
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
visit(outRoot);
|
|
85
|
+
return map;
|
|
86
|
+
})();
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
fileCache = new Map();
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Reads lines from a file, with caching.
|
|
93
|
+
* @param {string} filePath The file path to read.
|
|
94
|
+
* @returns {string[]} The lines of the file.
|
|
95
|
+
*/
|
|
96
|
+
readLines(filePath) {
|
|
97
|
+
if (!filePath || !fs.existsSync(filePath)) return [];
|
|
98
|
+
if (this.fileCache.has(filePath)) return this.fileCache.get(filePath);
|
|
99
|
+
const lines = fs.readFileSync(filePath, "utf-8").split(/\r?\n/);
|
|
100
|
+
this.fileCache.set(filePath, lines);
|
|
101
|
+
return lines;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Finds the corresponding source line for a given Luau line.
|
|
106
|
+
* @param {string} luauPath The path to the Luau file.
|
|
107
|
+
* @param {string} sourcePath The path to the source file.
|
|
108
|
+
* @param {number} luauLineNumber The line number in the Luau file.
|
|
109
|
+
* @returns {number} The corresponding line number in the source file.
|
|
110
|
+
*/
|
|
111
|
+
findSourceLine(luauPath, sourcePath, luauLineNumber) {
|
|
112
|
+
const luauLines = this.readLines(luauPath);
|
|
113
|
+
const sourceLines = this.readLines(sourcePath);
|
|
114
|
+
if (!luauLines.length || !sourceLines.length) return luauLineNumber;
|
|
115
|
+
|
|
116
|
+
const target = (luauLines[luauLineNumber - 1] || "").trim();
|
|
117
|
+
if (!target) return luauLineNumber;
|
|
118
|
+
const normalizedTarget = target.replace(/\s+/g, "");
|
|
119
|
+
|
|
120
|
+
const exactIndex = sourceLines.findIndex(
|
|
121
|
+
(line) => line.trim() === target
|
|
122
|
+
);
|
|
123
|
+
if (exactIndex >= 0) return exactIndex + 1;
|
|
124
|
+
|
|
125
|
+
const looseIndex = sourceLines.findIndex((line) =>
|
|
126
|
+
line.replace(/\s+/g, "").includes(normalizedTarget)
|
|
127
|
+
);
|
|
128
|
+
if (looseIndex >= 0) return looseIndex + 1;
|
|
129
|
+
|
|
130
|
+
return luauLineNumber;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Formats a file path relative to the project root with forward slashes.
|
|
135
|
+
* @param {string} filePath The file path to format.
|
|
136
|
+
* @returns {string} The formatted path.
|
|
137
|
+
*/
|
|
138
|
+
formatPath(filePath) {
|
|
139
|
+
return path
|
|
140
|
+
.relative(this.workspaceRoot, filePath)
|
|
141
|
+
.split(path.sep)
|
|
142
|
+
.join("/")
|
|
143
|
+
.replace(/\\/g, "/");
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Maps a datamodel stack frame to its source location.
|
|
148
|
+
* @param {string} datamodelPath The path in the datamodel.
|
|
149
|
+
* @param {number} lineNumber The line number in the datamodel file.
|
|
150
|
+
* @returns {string | undefined} The mapped source location as "path:line" or undefined if not found.
|
|
151
|
+
*/
|
|
152
|
+
mapDatamodelFrame(datamodelPath, lineNumber) {
|
|
153
|
+
const entry = this.modulePathMap.get(datamodelPath);
|
|
154
|
+
if (!entry) return undefined;
|
|
155
|
+
const mappedLine = this.findSourceLine(
|
|
156
|
+
entry.luauPath,
|
|
157
|
+
entry.sourcePath,
|
|
158
|
+
lineNumber
|
|
159
|
+
);
|
|
160
|
+
const displayPath = entry.sourcePath
|
|
161
|
+
? this.formatPath(entry.sourcePath)
|
|
162
|
+
: datamodelPath;
|
|
163
|
+
return `${displayPath}:${mappedLine}`;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Cleans internal stack trace lines from a text block.
|
|
168
|
+
* @param {string} text The text block to clean.
|
|
169
|
+
* @returns {string} The cleaned text block.
|
|
170
|
+
*/
|
|
171
|
+
cleanInternalLines(text) {
|
|
172
|
+
if (!text) return text;
|
|
173
|
+
const lines = text.split(/\r?\n/);
|
|
174
|
+
const kept = lines.filter(
|
|
175
|
+
(line) =>
|
|
176
|
+
!this.INTERNAL_FRAME_PATTERNS.some((pat) => pat.test(line))
|
|
177
|
+
);
|
|
178
|
+
const squashed = kept.join("\n").replace(/\n{3,}/g, "\n\n");
|
|
179
|
+
return squashed.trimEnd();
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Formats failure messages with syntax highlighting and colors.
|
|
184
|
+
* @param {string} text The failure message text.
|
|
185
|
+
* @returns {string} The formatted failure message.
|
|
186
|
+
*/
|
|
187
|
+
formatFailureMessage(text) {
|
|
188
|
+
if (!text) return text;
|
|
189
|
+
|
|
190
|
+
// Color test header lines (e.g., "● test name")
|
|
191
|
+
text = text.replace(/^(\s*●.*)$/gm, (match) => chalk.bold.red(match));
|
|
192
|
+
|
|
193
|
+
// Fix indentation and color Expected: and Received: lines
|
|
194
|
+
text = text.replace(
|
|
195
|
+
/^\s+Expected:(.*)$/gm,
|
|
196
|
+
(match, value) => `\n Expected:${chalk.green(value)}`
|
|
197
|
+
);
|
|
198
|
+
text = text.replace(
|
|
199
|
+
/^\s+Received:(.*)$/gm,
|
|
200
|
+
(match, value) => ` Received:${chalk.red(value)}`
|
|
201
|
+
);
|
|
202
|
+
|
|
203
|
+
// Color expect assertions
|
|
204
|
+
text = text.replace(
|
|
205
|
+
/expect\((received)\)\.(\w+)\((expected)\)(\s*--\s*.+)?/g,
|
|
206
|
+
(match, receivedWord, method, expectedWord, description) => {
|
|
207
|
+
const colored =
|
|
208
|
+
chalk.gray("expect(") +
|
|
209
|
+
chalk.red(receivedWord) +
|
|
210
|
+
chalk.gray(").") +
|
|
211
|
+
chalk.white(method) +
|
|
212
|
+
chalk.gray("(") +
|
|
213
|
+
chalk.green(expectedWord) +
|
|
214
|
+
chalk.gray(")") +
|
|
215
|
+
(description ? chalk.gray(description) : "");
|
|
216
|
+
return colored;
|
|
217
|
+
}
|
|
218
|
+
);
|
|
219
|
+
|
|
220
|
+
// Color stack trace file paths
|
|
221
|
+
text = text.replace(
|
|
222
|
+
/((?:[\w@.\/\\-]*[\/\.\\][\w@.\/\\-]+)):(\d+)(?::(\d+))?/g,
|
|
223
|
+
(match, filePart, line, col) => {
|
|
224
|
+
const lineCol = chalk.gray(`:${line}${col ? `:${col}` : ""}`);
|
|
225
|
+
return `${chalk.cyan(filePart)}${lineCol}`;
|
|
226
|
+
}
|
|
227
|
+
);
|
|
228
|
+
|
|
229
|
+
return text;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Rewrites stack strings to map datamodel paths to source paths.
|
|
234
|
+
* @param {string} value The stack string to rewrite.
|
|
235
|
+
* @returns {string} The rewritten stack string.
|
|
236
|
+
*/
|
|
237
|
+
rewriteStackString(value) {
|
|
238
|
+
return typeof value === "string"
|
|
239
|
+
? this.cleanInternalLines(
|
|
240
|
+
value.replace(
|
|
241
|
+
this.stackFramePattern,
|
|
242
|
+
(match, dmPath, line) => {
|
|
243
|
+
const mapped = this.mapDatamodelFrame(
|
|
244
|
+
dmPath,
|
|
245
|
+
Number(line)
|
|
246
|
+
);
|
|
247
|
+
return mapped || match;
|
|
248
|
+
}
|
|
249
|
+
)
|
|
250
|
+
)
|
|
251
|
+
: value;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Rewrites a test case's failure messages and details.
|
|
256
|
+
* @param {object} testCase The test case to rewrite.
|
|
257
|
+
*/
|
|
258
|
+
rewriteTestCase(testCase) {
|
|
259
|
+
if (Array.isArray(testCase.failureMessages)) {
|
|
260
|
+
const rewritten = testCase.failureMessages.map(
|
|
261
|
+
this.rewriteStackString.bind(this)
|
|
262
|
+
);
|
|
263
|
+
let candidateFrame;
|
|
264
|
+
|
|
265
|
+
// Search all failure messages for a valid file frame
|
|
266
|
+
for (const msg of rewritten) {
|
|
267
|
+
const frame = this.parseFrame(msg);
|
|
268
|
+
if (frame) {
|
|
269
|
+
candidateFrame = frame;
|
|
270
|
+
break;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// Fallback to failureDetails if no frame found in messages
|
|
275
|
+
if (!candidateFrame && Array.isArray(testCase.failureDetails)) {
|
|
276
|
+
for (const detail of testCase.failureDetails) {
|
|
277
|
+
const stack =
|
|
278
|
+
this.rewriteStackString(detail.stack) ||
|
|
279
|
+
this.rewriteStackString(detail.__stack) ||
|
|
280
|
+
this.rewriteStackString(detail.message);
|
|
281
|
+
const frame = this.parseFrame(stack);
|
|
282
|
+
if (frame) {
|
|
283
|
+
candidateFrame = frame;
|
|
284
|
+
break;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
const codeFrame = candidateFrame
|
|
290
|
+
? this.buildCodeFrame(
|
|
291
|
+
candidateFrame.absPath,
|
|
292
|
+
candidateFrame.line,
|
|
293
|
+
candidateFrame.column
|
|
294
|
+
)
|
|
295
|
+
: undefined;
|
|
296
|
+
|
|
297
|
+
// Only append the code frame to the first message to avoid duplication
|
|
298
|
+
if (codeFrame && rewritten.length > 0) {
|
|
299
|
+
rewritten[0] = this.injectCodeFrame(
|
|
300
|
+
rewritten[0],
|
|
301
|
+
candidateFrame,
|
|
302
|
+
codeFrame
|
|
303
|
+
);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
testCase.failureMessages = rewritten.map(
|
|
307
|
+
this.formatFailureMessage.bind(this)
|
|
308
|
+
);
|
|
309
|
+
}
|
|
310
|
+
if (Array.isArray(testCase.failureDetails)) {
|
|
311
|
+
testCase.failureDetails = testCase.failureDetails.map((detail) => ({
|
|
312
|
+
...detail,
|
|
313
|
+
stack: this.formatFailureMessage(
|
|
314
|
+
this.rewriteStackString(detail.stack)
|
|
315
|
+
),
|
|
316
|
+
__stack: this.formatFailureMessage(
|
|
317
|
+
this.rewriteStackString(detail.__stack)
|
|
318
|
+
),
|
|
319
|
+
message: this.formatFailureMessage(
|
|
320
|
+
this.rewriteStackString(detail.message)
|
|
321
|
+
),
|
|
322
|
+
}));
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Extends a test file path by checking for common extensions.
|
|
328
|
+
* @param {string} testFilePath The original test file path.
|
|
329
|
+
* @returns {string} The extended test file path.
|
|
330
|
+
*/
|
|
331
|
+
extendTestFilePath(testFilePath) {
|
|
332
|
+
if (!testFilePath) return testFilePath;
|
|
333
|
+
const withExts = [".ts", ".tsx", ""]; // last entry preserves original
|
|
334
|
+
for (const ext of withExts) {
|
|
335
|
+
const candidate = path.join(
|
|
336
|
+
this.projectRoot,
|
|
337
|
+
`${testFilePath}${ext}`
|
|
338
|
+
);
|
|
339
|
+
if (fs.existsSync(candidate)) return candidate;
|
|
340
|
+
}
|
|
341
|
+
return path.join(this.projectRoot, testFilePath);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* Strips ANSI escape codes from a string.
|
|
346
|
+
* @param {string} str The string to strip.
|
|
347
|
+
* @returns {string} The stripped string.
|
|
348
|
+
*/
|
|
349
|
+
stripAnsi(str) {
|
|
350
|
+
return typeof str === "string"
|
|
351
|
+
? str.replace(/\u001b\[[0-9;]*m/g, "")
|
|
352
|
+
: str;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* Parses a stack frame from text and returns file path, line, and column.
|
|
357
|
+
* @param {string} text The text to parse.
|
|
358
|
+
* @returns {{ absPath: string, line: number, column: number } | undefined} The parsed frame info or undefined if not found.
|
|
359
|
+
*/
|
|
360
|
+
parseFrame(text) {
|
|
361
|
+
if (!text) return undefined;
|
|
362
|
+
const cleanText = this.stripAnsi(text);
|
|
363
|
+
// Match patterns like "src/test.ts:10" or "C:\path\test.ts:10"
|
|
364
|
+
// We look for something that looks like a file path followed by a colon and a number
|
|
365
|
+
const pattern =
|
|
366
|
+
/(?:^|\s|")((?:[a-zA-Z]:[\\\/][^:\s\n"]+|[\w@.\/\\-]+\.[a-z0-9]+)):(\d+)(?::(\d+))?/gi;
|
|
367
|
+
let match;
|
|
368
|
+
const candidates = [];
|
|
369
|
+
while ((match = pattern.exec(cleanText)) !== null) {
|
|
370
|
+
const [, filePart, lineStr, colStr] = match;
|
|
371
|
+
const absPath = path.isAbsolute(filePart)
|
|
372
|
+
? filePart
|
|
373
|
+
: path.join(this.workspaceRoot, filePart);
|
|
374
|
+
if (fs.existsSync(absPath)) {
|
|
375
|
+
candidates.push({
|
|
376
|
+
absPath,
|
|
377
|
+
line: Number(lineStr),
|
|
378
|
+
column: colStr ? Number(colStr) : 1,
|
|
379
|
+
score:
|
|
380
|
+
(filePart.includes(".") ? 1 : 0) +
|
|
381
|
+
(filePart.includes("/") || filePart.includes("\\")
|
|
382
|
+
? 1
|
|
383
|
+
: 0),
|
|
384
|
+
});
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
if (candidates.length === 0) return undefined;
|
|
389
|
+
// Sort by score descending, then by line number (prefer higher line numbers which are usually the actual error)
|
|
390
|
+
return candidates.sort(
|
|
391
|
+
(a, b) => b.score - a.score || b.line - a.line
|
|
392
|
+
)[0];
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* Syntax highlights code for terminal output.
|
|
397
|
+
* @param {string} text The code text to highlight.
|
|
398
|
+
* @returns {string} The highlighted code.
|
|
399
|
+
*/
|
|
400
|
+
highlightCode(text) {
|
|
401
|
+
if (!text) return text;
|
|
402
|
+
return text.replace(
|
|
403
|
+
/((["'`])(?:(?=(\\?))\3.)*?\2)|(\b\d+(?:\.\d+)?\b)|(=>|[,\.\+\-\*\/%=<>!?:;&|\[\]])/g,
|
|
404
|
+
(match, string, quote, escape, number, punctuation) => {
|
|
405
|
+
if (string) return chalk.green(match);
|
|
406
|
+
if (number) return chalk.magenta(match);
|
|
407
|
+
if (punctuation) return chalk.yellow(match);
|
|
408
|
+
return match;
|
|
409
|
+
}
|
|
410
|
+
);
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/**
|
|
414
|
+
* Builds a code frame for a given file and line.
|
|
415
|
+
* @param {string} absPath The absolute file path.
|
|
416
|
+
* @param {number} line The line to build the frame around.
|
|
417
|
+
* @param {number} column The column to point to.
|
|
418
|
+
* @param {number} context The number of context lines to include.
|
|
419
|
+
* @returns {string|undefined} The code frame as a string, or undefined if file not found.
|
|
420
|
+
*/
|
|
421
|
+
buildCodeFrame(absPath, line, column = 1, context = 2) {
|
|
422
|
+
const lines = this.readLines(absPath);
|
|
423
|
+
if (!lines.length) return undefined;
|
|
424
|
+
const start = Math.max(1, line - context);
|
|
425
|
+
const end = Math.min(lines.length, line + context + 1);
|
|
426
|
+
const frame = [];
|
|
427
|
+
|
|
428
|
+
for (let i = start; i <= end; i++) {
|
|
429
|
+
const isBright = i === start;
|
|
430
|
+
const lineNum = String(i).padStart(String(end).length, " ");
|
|
431
|
+
const gutter = `${
|
|
432
|
+
i === line ? chalk.bold.red(">") : " "
|
|
433
|
+
} ${chalk.grey(lineNum + " |")}`;
|
|
434
|
+
const rawContent = lines[i - 1] || "";
|
|
435
|
+
const highlightedContent = this.highlightCode(rawContent);
|
|
436
|
+
const content = ` ${gutter} ${highlightedContent}`;
|
|
437
|
+
frame.push(isBright ? content : chalk.dim(content));
|
|
438
|
+
}
|
|
439
|
+
return frame.join("\n");
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/**
|
|
443
|
+
* Appends code frames to messages.
|
|
444
|
+
* @param {Array|string} messages The messages to append to.
|
|
445
|
+
* @param {string} frameText The code frame text to append.
|
|
446
|
+
* @returns {Array|string} The messages with appended code frames.
|
|
447
|
+
*/
|
|
448
|
+
appendCodeFrames(messages, frameText) {
|
|
449
|
+
if (!Array.isArray(messages) || !frameText) return messages;
|
|
450
|
+
return messages.map((msg) =>
|
|
451
|
+
typeof msg === "string" ? `${msg}\n\n${frameText}` : msg
|
|
452
|
+
);
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/**
|
|
456
|
+
* Injects a code frame into a text block, moving the corresponding stack frame line to the bottom.
|
|
457
|
+
* @param {string} text The text block.
|
|
458
|
+
* @param {object} frame The parsed frame info.
|
|
459
|
+
* @param {string} codeFrame The code frame text.
|
|
460
|
+
* @returns {string} The updated text block.
|
|
461
|
+
*/
|
|
462
|
+
injectCodeFrame(text, frame, codeFrame) {
|
|
463
|
+
if (!codeFrame || !frame) return text;
|
|
464
|
+
|
|
465
|
+
const lines = text.split(/\r?\n/);
|
|
466
|
+
let frameLine = "";
|
|
467
|
+
let frameLineIndex = -1;
|
|
468
|
+
|
|
469
|
+
const displayPath = this.formatPath(frame.absPath);
|
|
470
|
+
const searchPath1 = `${displayPath}:${frame.line}`;
|
|
471
|
+
const searchPath2 = `${frame.absPath}:${frame.line}`;
|
|
472
|
+
|
|
473
|
+
for (let i = 0; i < lines.length; i++) {
|
|
474
|
+
const line = lines[i];
|
|
475
|
+
if (line.includes(searchPath1) || line.includes(searchPath2)) {
|
|
476
|
+
frameLine = line;
|
|
477
|
+
frameLineIndex = i;
|
|
478
|
+
break;
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
if (frameLineIndex !== -1) {
|
|
483
|
+
lines.splice(frameLineIndex, 1);
|
|
484
|
+
return `${lines
|
|
485
|
+
.join("\n")
|
|
486
|
+
.trimEnd()}\n\n${codeFrame}\n\n${frameLine}`;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
return `${text.trimEnd()}\n\n${codeFrame}`;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
/**
|
|
493
|
+
* Rewrites a test suite's results.
|
|
494
|
+
* @param {object} suite The test suite result to rewrite.
|
|
495
|
+
*/
|
|
496
|
+
rewriteSuiteResult(suite) {
|
|
497
|
+
if (!suite) return;
|
|
498
|
+
suite.testFilePath = this.formatPath(
|
|
499
|
+
this.extendTestFilePath(suite.testFilePath)
|
|
500
|
+
);
|
|
501
|
+
|
|
502
|
+
if (Array.isArray(suite.testResults)) {
|
|
503
|
+
suite.testResults.forEach((value) => {
|
|
504
|
+
this.rewriteTestCase(value);
|
|
505
|
+
});
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
if (suite.failureMessage) {
|
|
509
|
+
let rewritten = this.rewriteStackString(suite.failureMessage);
|
|
510
|
+
|
|
511
|
+
// Split by the test header " ● " to handle multiple failures in one string
|
|
512
|
+
const sections = rewritten.split(/(\s+●\s+)/);
|
|
513
|
+
if (sections.length > 1) {
|
|
514
|
+
for (let i = 2; i < sections.length; i += 2) {
|
|
515
|
+
const sectionContent = sections[i];
|
|
516
|
+
const frame = this.parseFrame(sectionContent);
|
|
517
|
+
if (frame) {
|
|
518
|
+
const codeFrame = this.buildCodeFrame(
|
|
519
|
+
frame.absPath,
|
|
520
|
+
frame.line,
|
|
521
|
+
frame.column
|
|
522
|
+
);
|
|
523
|
+
sections[i] =
|
|
524
|
+
this.injectCodeFrame(
|
|
525
|
+
sectionContent,
|
|
526
|
+
frame,
|
|
527
|
+
codeFrame
|
|
528
|
+
) + "\n";
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
rewritten = sections.join("");
|
|
532
|
+
} else {
|
|
533
|
+
const frame = this.parseFrame(rewritten);
|
|
534
|
+
if (frame) {
|
|
535
|
+
const codeFrame = this.buildCodeFrame(
|
|
536
|
+
frame.absPath,
|
|
537
|
+
frame.line,
|
|
538
|
+
frame.column
|
|
539
|
+
);
|
|
540
|
+
rewritten = this.injectCodeFrame(
|
|
541
|
+
rewritten,
|
|
542
|
+
frame,
|
|
543
|
+
codeFrame
|
|
544
|
+
) + "\n";
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
suite.failureMessage = this.formatFailureMessage(rewritten);
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
/**
|
|
553
|
+
* Rewrites parsed test results.
|
|
554
|
+
* @param {object} results The parsed test results.
|
|
555
|
+
*/
|
|
556
|
+
rewriteParsedResults(results) {
|
|
557
|
+
if (!results?.testResults) return;
|
|
558
|
+
for (const suite of results.testResults) {
|
|
559
|
+
this.rewriteSuiteResult(suite);
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
}
|