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/README.md +23 -13
- package/package.json +16 -12
- package/src/cache.js +1 -0
- package/src/cli.js +160 -774
- package/src/discovery.js +355 -0
- package/src/rewriter.js +454 -257
- package/src/runJestRoblox.js +838 -0
- package/src/sourcemap.js +243 -0
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
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
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
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
|
38
|
-
if (!
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
for (const
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
45
|
+
|
|
46
|
+
if (absoluteOutDir) {
|
|
47
|
+
const normalizedOutDir = absoluteOutDir.replace(
|
|
48
|
+
/\\/g,
|
|
49
|
+
"/"
|
|
70
50
|
);
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
.
|
|
143
|
-
|
|
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 {
|
|
240
|
+
* @returns {{ line: number, column: number, file: string } | undefined} The mapped location.
|
|
151
241
|
*/
|
|
152
242
|
mapDatamodelFrame(datamodelPath, lineNumber) {
|
|
153
|
-
const
|
|
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
|
-
|
|
157
|
-
entry.sourcePath,
|
|
263
|
+
absoluteLuauPath,
|
|
264
|
+
entry.sourcePath ?? absoluteLuauPath,
|
|
158
265
|
lineNumber
|
|
159
266
|
);
|
|
160
|
-
const
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
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
|
|
256
|
-
* @param {
|
|
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
|
-
|
|
259
|
-
if (Array.isArray(
|
|
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
|
-
|
|
275
|
-
if (!
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
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
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
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
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
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
|
-
*
|
|
328
|
-
*
|
|
329
|
-
*
|
|
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
|
-
|
|
434
|
+
datamodelPathToSourcePath(testFilePath) {
|
|
332
435
|
if (!testFilePath) return testFilePath;
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
);
|
|
339
|
-
|
|
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
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
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 =
|
|
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.
|
|
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
|
-
|
|
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(
|
|
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
|
|
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
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
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 (
|
|
474
|
-
|
|
475
|
-
if (
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
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
|
-
|
|
483
|
-
|
|
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
|
-
|
|
499
|
-
|
|
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
|
|
504
|
-
this.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|