jest-roblox-assassin 1.0.0 → 1.1.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/src/rewriter.js CHANGED
@@ -1,32 +1,25 @@
1
1
  import chalk from "chalk";
2
2
  import fs from "fs";
3
3
  import path from "path";
4
+ import util from "util";
4
5
 
5
6
  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;
7
+ constructor({ rojoProject, compilerOptions, testLocationInResults }) {
8
+ this.rojoProject = rojoProject;
9
+ this.compilerOptions = compilerOptions;
10
+ this.testLocationInResults = Boolean(testLocationInResults);
11
+ this.luauPathMap = new Map();
12
+ const projectRoot = this.rojoProject?.root ?? process.cwd();
20
13
  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
- );
14
+
15
+ const rootDirRelative = compilerOptions?.rootDir ?? "src";
16
+ const outDirRelative = compilerOptions?.outDir ?? "out";
17
+ const absoluteRootDir = path.isAbsolute(rootDirRelative)
18
+ ? rootDirRelative
19
+ : path.join(projectRoot, rootDirRelative);
20
+ const absoluteOutDir = path.isAbsolute(outDirRelative)
21
+ ? outDirRelative
22
+ : path.join(projectRoot, outDirRelative);
30
23
 
31
24
  /**
32
25
  * A map from datamodel paths to their corresponding Luau and source file paths.
@@ -34,54 +27,82 @@ export class ResultRewriter {
34
27
  */
35
28
  this.modulePathMap = (() => {
36
29
  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
- ];
30
+ const sourcemap = rojoProject.sourcemap;
31
+ if (!sourcemap) return map;
32
+
33
+ const searchChildren = (node, parents) => {
34
+ for (const child of node.children) {
35
+ // Recurse into children
36
+ searchChildren(child, [...parents, child.name]);
37
+
38
+ // Process current child
39
+ const datamodelPath = [...parents, child.name].join(".");
40
+ const luauPath = child.filePaths[0];
41
+ if (!luauPath) continue;
42
+
43
+ const absoluteLuauPath = path.join(projectRoot, luauPath);
64
44
  let sourcePath;
65
- for (const candidate of candidateBases) {
66
- const candidatePath = path.join(
67
- projectRoot,
68
- rootDir,
69
- candidate
45
+
46
+ if (absoluteOutDir) {
47
+ const normalizedOutDir = absoluteOutDir.replace(
48
+ /\\/g,
49
+ "/"
70
50
  );
71
- if (fs.existsSync(candidatePath)) {
72
- sourcePath = candidatePath;
73
- break;
51
+ const normalizedLuau = absoluteLuauPath.replace(
52
+ /\\/g,
53
+ "/"
54
+ );
55
+ if (normalizedLuau.startsWith(normalizedOutDir)) {
56
+ const relativePath = path.relative(
57
+ absoluteOutDir,
58
+ absoluteLuauPath
59
+ );
60
+ let candidateSource = path.join(
61
+ absoluteRootDir,
62
+ relativePath
63
+ );
64
+ if (
65
+ path
66
+ .basename(candidateSource)
67
+ .startsWith("init.")
68
+ ) {
69
+ candidateSource = path.join(
70
+ path.dirname(candidateSource),
71
+ "index" + path.extname(candidateSource)
72
+ );
73
+ }
74
+
75
+ if (!fs.existsSync(candidateSource)) {
76
+ const withTs = candidateSource.replace(
77
+ /\.(lua|luau)$/,
78
+ ".ts"
79
+ );
80
+ const withTsx = candidateSource.replace(
81
+ /\.(lua|luau)$/,
82
+ ".tsx"
83
+ );
84
+ if (fs.existsSync(withTs)) {
85
+ candidateSource = withTs;
86
+ } else if (fs.existsSync(withTsx)) {
87
+ candidateSource = withTsx;
88
+ }
89
+ }
90
+
91
+ if (fs.existsSync(candidateSource)) {
92
+ sourcePath = candidateSource;
93
+ }
74
94
  }
75
95
  }
76
96
 
77
- map.set(datamodelPath, {
78
- luauPath: abs,
79
- sourcePath,
80
- });
97
+ const entry = { luauPath, absoluteLuauPath, sourcePath };
98
+ map.set(datamodelPath, entry);
99
+ const normalizedLuauPath = luauPath.replace(/\\/g, "/");
100
+ this.luauPathMap.set(normalizedLuauPath, entry);
81
101
  }
82
- }
102
+ };
103
+
104
+ searchChildren(sourcemap, []);
83
105
 
84
- visit(outRoot);
85
106
  return map;
86
107
  })();
87
108
  }
@@ -131,56 +152,132 @@ export class ResultRewriter {
131
152
  }
132
153
 
133
154
  /**
134
- * Formats a file path relative to the project root with forward slashes.
155
+ * Finds the matcher column for an expectation on a line. Falls back to the start of `expect`.
156
+ * @param {string} lineText The source line text.
157
+ * @returns {number} The 1-based column index.
158
+ */
159
+ findExpectationColumn(lineText) {
160
+ if (!lineText) return 1;
161
+
162
+ const expectIndex = lineText.search(/\bexpect\s*\(/);
163
+ if (expectIndex === -1) return 1;
164
+
165
+ // Look for the last matcher call after the expect expression to cover any matcher name.
166
+ const afterExpect = lineText.slice(expectIndex);
167
+ const matcherRegex = /\.\s*([A-Za-z_$][\w$]*)\s*(?=\()/g;
168
+ let matcher;
169
+ let match;
170
+ while ((match = matcherRegex.exec(afterExpect)) !== null) {
171
+ matcher = match;
172
+ }
173
+
174
+ if (!matcher) {
175
+ return expectIndex + 1;
176
+ }
177
+
178
+ const matcherName = matcher[1];
179
+ const matcherNameOffset =
180
+ matcher.index + matcher[0].indexOf(matcherName);
181
+ return expectIndex + matcherNameOffset + 1;
182
+ }
183
+
184
+ /**
185
+ * Finds the line/column for a test title within a source file.
186
+ * @param {string} testTitle The title of the test case.
187
+ * @param {string} sourcePath The path to the source file.
188
+ * @returns {{ line: number, column: number } | undefined} The location with 1-based line and 0-based column.
189
+ */
190
+ findTestHeaderLocation(testTitle, sourcePath) {
191
+ if (!testTitle || !sourcePath) return undefined;
192
+ const lines = this.readLines(sourcePath);
193
+ if (!lines.length) return undefined;
194
+
195
+ const escapedTitle = testTitle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
196
+ const patterns = [
197
+ new RegExp(
198
+ "\\b(?:it|test|xtest|fit|ftest|xit)\\s*\\(\\s*[\"'`]" +
199
+ escapedTitle +
200
+ "[\"'`]",
201
+ "i"
202
+ ),
203
+ new RegExp("[\"'`]" + escapedTitle + "[\"'`]", "i"),
204
+ ];
205
+
206
+ for (let i = 0; i < lines.length; i++) {
207
+ const line = lines[i];
208
+ for (const pattern of patterns) {
209
+ const match = pattern.exec(line);
210
+ if (match) {
211
+ return { line: i + 1, column: match.index };
212
+ }
213
+ }
214
+ }
215
+
216
+ return undefined;
217
+ }
218
+
219
+ /**
220
+ * Formats a file path ready to be displayed to the terminal by making it relative and using forward slashes.
135
221
  * @param {string} filePath The file path to format.
136
222
  * @returns {string} The formatted path.
137
223
  */
138
224
  formatPath(filePath) {
139
- return path
140
- .relative(this.workspaceRoot, filePath)
141
- .split(path.sep)
142
- .join("/")
143
- .replace(/\\/g, "/");
225
+ const baseDir = this.projectRoot || process.cwd();
226
+ let relativePath = path.relative(baseDir, filePath);
227
+ if (!relativePath || relativePath.startsWith("..")) {
228
+ relativePath = path.relative(process.cwd(), filePath);
229
+ }
230
+ if (!relativePath) {
231
+ relativePath = filePath;
232
+ }
233
+ return relativePath.split(path.sep).join("/").replace(/\\/g, "/");
144
234
  }
145
235
 
146
236
  /**
147
237
  * Maps a datamodel stack frame to its source location.
148
238
  * @param {string} datamodelPath The path in the datamodel.
149
239
  * @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.
240
+ * @returns {{ line: number, column: number, file: string } | undefined} The mapped location.
151
241
  */
152
242
  mapDatamodelFrame(datamodelPath, lineNumber) {
153
- const entry = this.modulePathMap.get(datamodelPath);
243
+ const normalizedPath = datamodelPath.replace(/\\/g, "/");
244
+ let entry =
245
+ this.modulePathMap.get(datamodelPath) ||
246
+ this.modulePathMap.get(normalizedPath);
247
+ if (!entry) {
248
+ entry = this.luauPathMap.get(normalizedPath);
249
+ }
250
+ if (!entry && this.projectRoot) {
251
+ const candidateAbsolute = path.isAbsolute(datamodelPath)
252
+ ? path.resolve(datamodelPath)
253
+ : path.join(this.projectRoot, normalizedPath);
254
+ const relativeToRoot = path
255
+ .relative(this.projectRoot, candidateAbsolute)
256
+ .replace(/\\/g, "/");
257
+ entry = this.luauPathMap.get(relativeToRoot);
258
+ }
154
259
  if (!entry) return undefined;
260
+ const absoluteLuauPath =
261
+ entry.absoluteLuauPath ?? path.resolve(entry.luauPath);
155
262
  const mappedLine = this.findSourceLine(
156
- entry.luauPath,
157
- entry.sourcePath,
263
+ absoluteLuauPath,
264
+ entry.sourcePath ?? absoluteLuauPath,
158
265
  lineNumber
159
266
  );
160
- const displayPath = entry.sourcePath
161
- ? this.formatPath(entry.sourcePath)
162
- : datamodelPath;
163
- return `${displayPath}:${mappedLine}`;
267
+ const sourceForColumn = entry.sourcePath ?? absoluteLuauPath;
268
+ const sourceLines = this.readLines(sourceForColumn);
269
+ const lineText = sourceLines[mappedLine - 1] || "";
270
+ const column = this.findExpectationColumn(lineText);
271
+
272
+ return {
273
+ line: mappedLine,
274
+ column,
275
+ file: sourceForColumn,
276
+ };
164
277
  }
165
278
 
166
279
  /**
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.
280
+ * Formats `suite.failureMessage` text with colors for terminal output.
184
281
  * @param {string} text The failure message text.
185
282
  * @returns {string} The formatted failure message.
186
283
  */
@@ -234,122 +331,148 @@ export class ResultRewriter {
234
331
  * @param {string} value The stack string to rewrite.
235
332
  * @returns {string} The rewritten stack string.
236
333
  */
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;
334
+ rewriteStackString(value, options = {}) {
335
+ if (!value) return value;
336
+ const { absolutePaths = false } = options;
337
+
338
+ // Try to find matches in modulePathMap
339
+ const pattern =
340
+ /((?:[\w@.\/\\-]*[\/.\\][\w@.\/\\-]+)):(\d+)(?::(\d+))?/g;
341
+ let match;
342
+ let rewritten = value;
343
+ const processed = new Set();
344
+ while ((match = pattern.exec(value)) !== null) {
345
+ const [fullMatch, filePart, lineStr] = match;
346
+ const lineNumber = Number(lineStr);
347
+ if (processed.has(fullMatch)) continue;
348
+ processed.add(fullMatch);
349
+ const mapped = this.mapDatamodelFrame(filePart, lineNumber);
350
+ if (mapped) {
351
+ const filePath = absolutePaths
352
+ ? path.resolve(mapped.file)
353
+ : this.formatPath(mapped.file);
354
+ rewritten = rewritten
355
+ .split(fullMatch)
356
+ .join(`${filePath}:${mapped.line}:${mapped.column}`);
357
+ }
358
+ }
359
+
360
+ return rewritten;
252
361
  }
253
362
 
254
363
  /**
255
- * Rewrites a test case's failure messages and details.
256
- * @param {object} testCase The test case to rewrite.
364
+ * Rewrites `suite.testResults[].failureMessages` entries.
365
+ * @param {Array<string>} messages The failure messages array.
366
+ * @returns {Array<string>} The rewritten messages array.
257
367
  */
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
- }
368
+ rewriteFailureMessages(messages) {
369
+ if (!Array.isArray(messages)) return messages;
273
370
 
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
- }
371
+ const rewriteFailureMessage = (text) => {
372
+ if (!text) return text;
373
+
374
+ const rewritten = this.rewriteStackString(text, {
375
+ absolutePaths: true,
376
+ });
288
377
 
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
378
+ const lines = rewritten.split(/\r?\n/);
379
+ const resultLines = [];
380
+
381
+ for (const rawLine of lines) {
382
+ if (!rawLine.trim()) continue;
383
+
384
+ // Strip Luau [string "..."] wrappers if present
385
+ const strippedLine = rawLine.replace(
386
+ /^\[string\s+"(.+?)"\]\s*/i,
387
+ "$1"
388
+ );
389
+
390
+ const stackMatch = /^(.*):(\d+)(?::(\d+))?(?:\s|$)/.exec(
391
+ strippedLine
303
392
  );
393
+
394
+ if (stackMatch) {
395
+ const [, filePart, lineStr, colStr] = stackMatch;
396
+ const lineNumber = Number(lineStr);
397
+ const colNumber = Number(colStr || "1");
398
+ const mapped = this.mapDatamodelFrame(filePart, lineNumber);
399
+ const absFile = mapped
400
+ ? path.resolve(mapped.file)
401
+ : path.isAbsolute(filePart)
402
+ ? filePart
403
+ : path.resolve(
404
+ this.projectRoot || process.cwd(),
405
+ filePart
406
+ );
407
+ const finalLine = mapped?.line ?? lineNumber;
408
+ const finalCol = mapped?.column ?? colNumber;
409
+ resultLines.push(
410
+ ` at ${absFile}:${finalLine}:${finalCol}`
411
+ );
412
+ continue;
413
+ }
414
+
415
+ resultLines.push(strippedLine);
304
416
  }
305
417
 
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
- }
418
+ return resultLines.join("\n");
419
+ };
420
+
421
+ return messages
422
+ .map((msg) => rewriteFailureMessage(msg))
423
+ .filter((msg) => Boolean(msg));
324
424
  }
325
425
 
326
426
  /**
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.
427
+ * Converts a testFilePath from the Jest datamodel format to the source file path.
428
+ * A testFilePath looks like: `parent/testName.spec`, where ancestors after parent are sliced off.
429
+ * Hence, only infering from the modulePathMap is possible. If conflicts arise, no match is made and the original path is returned.
430
+ *
431
+ * @param {string} testFilePath The raw testFilePath from runner results.
432
+ * @returns {string} The source file path.
330
433
  */
331
- extendTestFilePath(testFilePath) {
434
+ datamodelPathToSourcePath(testFilePath) {
332
435
  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;
436
+
437
+ // Attempt direct lookup first
438
+ const normalizedPath = testFilePath.replace(/\\/g, "/");
439
+ let entry =
440
+ this.modulePathMap.get(testFilePath) ||
441
+ this.modulePathMap.get(normalizedPath);
442
+ if (!entry) {
443
+ entry = this.luauPathMap.get(normalizedPath);
444
+ }
445
+ if (!entry && this.projectRoot) {
446
+ const candidateAbsolute = path.isAbsolute(testFilePath)
447
+ ? path.resolve(testFilePath)
448
+ : path.join(this.projectRoot, normalizedPath);
449
+ const relativeToRoot = path
450
+ .relative(this.projectRoot, candidateAbsolute)
451
+ .replace(/\\/g, "/");
452
+ entry = this.luauPathMap.get(relativeToRoot);
453
+ }
454
+ if (entry?.sourcePath) {
455
+ return entry.sourcePath;
340
456
  }
341
- return path.join(this.projectRoot, testFilePath);
342
- }
343
457
 
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;
458
+ let matchingPath = testFilePath
459
+ .replace(/\.(lua|luau)$/, "")
460
+ .replace(/\\/g, ".")
461
+ .replace(/\//g, ".");
462
+
463
+ const matches = [];
464
+ for (const [dmPath, paths] of this.modulePathMap.entries()) {
465
+ if (dmPath.endsWith(matchingPath)) {
466
+ matches.push(paths.sourcePath ?? paths.luauPath);
467
+ }
468
+ }
469
+
470
+ if (matches.length === 1) {
471
+ return matches[0];
472
+ }
473
+
474
+ // Last resort: return as-is joined with projectRoot
475
+ return path.join(this.rojoProject.root, testFilePath);
353
476
  }
354
477
 
355
478
  /**
@@ -359,7 +482,7 @@ export class ResultRewriter {
359
482
  */
360
483
  parseFrame(text) {
361
484
  if (!text) return undefined;
362
- const cleanText = this.stripAnsi(text);
485
+ const cleanText = util.stripVTControlCharacters(text);
363
486
  // Match patterns like "src/test.ts:10" or "C:\path\test.ts:10"
364
487
  // We look for something that looks like a file path followed by a colon and a number
365
488
  const pattern =
@@ -370,7 +493,7 @@ export class ResultRewriter {
370
493
  const [, filePart, lineStr, colStr] = match;
371
494
  const absPath = path.isAbsolute(filePart)
372
495
  ? filePart
373
- : path.join(this.workspaceRoot, filePart);
496
+ : path.join(this.rojoProject.root, filePart);
374
497
  if (fs.existsSync(absPath)) {
375
498
  candidates.push({
376
499
  absPath,
@@ -386,10 +509,7 @@ export class ResultRewriter {
386
509
  }
387
510
 
388
511
  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];
512
+ return candidates[0];
393
513
  }
394
514
 
395
515
  /**
@@ -421,13 +541,19 @@ export class ResultRewriter {
421
541
  buildCodeFrame(absPath, line, column = 1, context = 2) {
422
542
  const lines = this.readLines(absPath);
423
543
  if (!lines.length) return undefined;
544
+ // Replace tabs with 4 spaces for consistent formatting
545
+ lines.forEach((_, idx) => {
546
+ lines[idx] = lines[idx].replace(/\t/g, " ");
547
+ });
424
548
  const start = Math.max(1, line - context);
425
549
  const end = Math.min(lines.length, line + context + 1);
426
550
  const frame = [];
427
551
 
552
+ const digitWidth = String(end).length;
553
+
428
554
  for (let i = start; i <= end; i++) {
429
555
  const isBright = i === start;
430
- const lineNum = String(i).padStart(String(end).length, " ");
556
+ const lineNum = String(i).padStart(digitWidth, " ");
431
557
  const gutter = `${
432
558
  i === line ? chalk.bold.red(">") : " "
433
559
  } ${chalk.grey(lineNum + " |")}`;
@@ -453,7 +579,7 @@ export class ResultRewriter {
453
579
  }
454
580
 
455
581
  /**
456
- * Injects a code frame into a text block, moving the corresponding stack frame line to the bottom.
582
+ * Injects a code frame into a text block, moving all stack trace lines to below the code frame.
457
583
  * @param {string} text The text block.
458
584
  * @param {object} frame The parsed frame info.
459
585
  * @param {string} codeFrame The code frame text.
@@ -463,30 +589,22 @@ export class ResultRewriter {
463
589
  if (!codeFrame || !frame) return text;
464
590
 
465
591
  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}`;
592
+ const stackLines = [];
593
+ const nonStackLines = [];
594
+ const pattern =
595
+ /(?:^|\s|")((?:[a-zA-Z]:[\\\/][^:\s\n"]+|[\w@.\/\\-]+\.[a-z0-9]+)):(\d+)(?::(\d+))?/gi;
472
596
 
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;
597
+ for (const line of lines) {
598
+ pattern.lastIndex = 0; // Reset regex state
599
+ if (pattern.test(line)) {
600
+ stackLines.push(line);
601
+ } else {
602
+ nonStackLines.push(line);
479
603
  }
480
604
  }
481
605
 
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}`;
606
+ const mainText = nonStackLines.join("\n");
607
+ return `${mainText}\n\n${codeFrame}\n\n${stackLines.join("\n")}`;
490
608
  }
491
609
 
492
610
  /**
@@ -495,14 +613,35 @@ export class ResultRewriter {
495
613
  */
496
614
  rewriteSuiteResult(suite) {
497
615
  if (!suite) return;
498
- suite.testFilePath = this.formatPath(
499
- this.extendTestFilePath(suite.testFilePath)
500
- );
616
+ const sourcePath = this.datamodelPathToSourcePath(suite.testFilePath);
617
+ const resolvedTestFilePath = path.resolve(sourcePath);
618
+ suite.testFilePath = resolvedTestFilePath;
501
619
 
502
620
  if (Array.isArray(suite.testResults)) {
503
- suite.testResults.forEach((value) => {
504
- this.rewriteTestCase(value);
505
- });
621
+ for (const testResult of suite.testResults) {
622
+ if (this.testLocationInResults && !testResult.location) {
623
+ const location =
624
+ this.findTestHeaderLocation(
625
+ testResult.title,
626
+ sourcePath
627
+ ) ||
628
+ (sourcePath !== resolvedTestFilePath
629
+ ? this.findTestHeaderLocation(
630
+ testResult.title,
631
+ resolvedTestFilePath
632
+ )
633
+ : undefined);
634
+ if (location) {
635
+ testResult.location = location;
636
+ }
637
+ }
638
+
639
+ if (Array.isArray(testResult.failureMessages)) {
640
+ testResult.failureMessages = this.rewriteFailureMessages(
641
+ testResult.failureMessages
642
+ );
643
+ }
644
+ }
506
645
  }
507
646
 
508
647
  if (suite.failureMessage) {
@@ -510,39 +649,29 @@ export class ResultRewriter {
510
649
 
511
650
  // Split by the test header " ● " to handle multiple failures in one string
512
651
  const sections = rewritten.split(/(\s+●\s+)/);
652
+
653
+ const rewriteSection = (sectionContent) => {
654
+ const frame = this.parseFrame(sectionContent);
655
+ if (!frame) return sectionContent;
656
+
657
+ const codeFrame = this.buildCodeFrame(
658
+ frame.absPath,
659
+ frame.line,
660
+ frame.column
661
+ );
662
+ return (
663
+ this.injectCodeFrame(sectionContent, frame, codeFrame) +
664
+ "\n"
665
+ );
666
+ };
667
+
513
668
  if (sections.length > 1) {
514
669
  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
- }
670
+ sections[i] = rewriteSection(sections[i]);
530
671
  }
531
672
  rewritten = sections.join("");
532
673
  } 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
- }
674
+ rewritten = rewriteSection(rewritten);
546
675
  }
547
676
 
548
677
  suite.failureMessage = this.formatFailureMessage(rewritten);
@@ -559,4 +688,72 @@ export class ResultRewriter {
559
688
  this.rewriteSuiteResult(suite);
560
689
  }
561
690
  }
691
+
692
+ /**
693
+ * Rewrites coverage data to use source file paths instead of datamodel paths.
694
+ * @param {object} coverageData The coverage data.
695
+ * @returns {object} The rewritten coverage data.
696
+ */
697
+ rewriteCoverageData(coverageData) {
698
+ if (!coverageData) return coverageData;
699
+
700
+ const rewritten = {};
701
+ for (const [datamodelPath, coverage] of Object.entries(coverageData)) {
702
+ // Skip the "total" key
703
+ if (datamodelPath === "total") {
704
+ rewritten[datamodelPath] = coverage;
705
+ continue;
706
+ }
707
+
708
+ // Convert datamodel path to luau path
709
+ // Coverage data uses slashes, but modulePathMap uses dots
710
+ const normalizedPath = datamodelPath.replace(/\//g, ".");
711
+ let entry = this.modulePathMap.get(normalizedPath);
712
+
713
+ const finalPath = path.resolve(entry?.luauPath ?? datamodelPath);
714
+
715
+ // Clone the coverage object and update the path property
716
+ const rewrittenCoverage = { ...coverage };
717
+ if (rewrittenCoverage.path) {
718
+ rewrittenCoverage.path = finalPath;
719
+ }
720
+
721
+ rewritten[finalPath] = rewrittenCoverage;
722
+ }
723
+
724
+ return rewritten;
725
+ }
726
+
727
+ /**
728
+ * Converts raw Jest results into JSON format similar to `jest --json` output.
729
+ * @param {{ results: object, coverage?: object, globalConfig?: object }} jestRunCliReturn The raw Jest results.
730
+ */
731
+ json(jestRunCliReturn) {
732
+ const results = { ...jestRunCliReturn.results };
733
+
734
+ if (jestRunCliReturn.coverage) {
735
+ results.coverageMap = jestRunCliReturn.coverage;
736
+ }
737
+
738
+ for (const suite of results.testResults) {
739
+ suite.message = suite.failureMessage ?? "";
740
+ delete suite.failureMessage;
741
+
742
+ suite.assertionResults = suite.testResults || [];
743
+ delete suite.testResults;
744
+
745
+ suite.name = suite.testFilePath;
746
+ delete suite.testFilePath;
747
+
748
+ let overallPassed = true;
749
+ for (const testResult of suite.testResults || []) {
750
+ if (testResult.status === "failed") {
751
+ overallPassed = false;
752
+ break;
753
+ }
754
+ }
755
+ suite.status = overallPassed ? "passed" : "failed";
756
+ }
757
+ return results;
758
+ }
562
759
  }