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
|
@@ -0,0 +1,838 @@
|
|
|
1
|
+
import { TestPathPatterns } from "@jest/pattern";
|
|
2
|
+
import {
|
|
3
|
+
DefaultReporter,
|
|
4
|
+
SummaryReporter,
|
|
5
|
+
VerboseReporter,
|
|
6
|
+
} from "@jest/reporters";
|
|
7
|
+
import fs from "fs";
|
|
8
|
+
import libCoverage from "istanbul-lib-coverage";
|
|
9
|
+
import libReport from "istanbul-lib-report";
|
|
10
|
+
import reports from "istanbul-reports";
|
|
11
|
+
import fetch from "node-fetch";
|
|
12
|
+
import path from "path";
|
|
13
|
+
import process from "process";
|
|
14
|
+
import { executeLuau } from "rbxluau";
|
|
15
|
+
import { pathToFileURL } from "url";
|
|
16
|
+
import { zstdDecompressSync } from "zlib";
|
|
17
|
+
import { ensureCache } from "./cache.js";
|
|
18
|
+
import {
|
|
19
|
+
discoverCompilerOptions,
|
|
20
|
+
discoverRojoProject,
|
|
21
|
+
discoverTestFilesFromFilesystem,
|
|
22
|
+
findPlaceFile,
|
|
23
|
+
} from "./discovery.js";
|
|
24
|
+
import { ResultRewriter } from "./rewriter.js";
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Executes JestRoblox with the given options, collects results and outputs them using reporters.
|
|
28
|
+
* The options can also affect the behavior of what is done with the results.
|
|
29
|
+
* @param {object} options The CLI options to run JestRoblox with.
|
|
30
|
+
* @returns {Promise<number>} Exit code (0 for success, 1 for failure).
|
|
31
|
+
*/
|
|
32
|
+
export default async function runJestRoblox(options) {
|
|
33
|
+
// Discover place file if not specified
|
|
34
|
+
if (!options.place) {
|
|
35
|
+
options.place = findPlaceFile();
|
|
36
|
+
}
|
|
37
|
+
if (!options.place) {
|
|
38
|
+
console.error(
|
|
39
|
+
"--place option is required to run tests. No .rbxl or .rbxlx file found in current directory or nearby."
|
|
40
|
+
);
|
|
41
|
+
return 1;
|
|
42
|
+
}
|
|
43
|
+
if (!fs.existsSync(options.place)) {
|
|
44
|
+
console.error("Invalid --place file specified: " + options.place);
|
|
45
|
+
return 1;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Load config file if specified
|
|
49
|
+
let configFileOptions = {};
|
|
50
|
+
if (options.config) {
|
|
51
|
+
const configPath = path.resolve(options.config);
|
|
52
|
+
if (!fs.existsSync(configPath)) {
|
|
53
|
+
console.error(`Config file not found: ${configPath}`);
|
|
54
|
+
return 1;
|
|
55
|
+
}
|
|
56
|
+
try {
|
|
57
|
+
const configUrl = pathToFileURL(configPath).href;
|
|
58
|
+
const configModule = await import(configUrl);
|
|
59
|
+
configFileOptions = configModule.default || configModule;
|
|
60
|
+
for (const key of Object.keys(configFileOptions)) {
|
|
61
|
+
options[key] = configFileOptions[key];
|
|
62
|
+
}
|
|
63
|
+
} catch (error) {
|
|
64
|
+
console.error(`Failed to load config file: ${error.message}`);
|
|
65
|
+
return 1;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (process.env.JEST_TEST_NAME_PATTERN) {
|
|
70
|
+
options.testNamePattern = process.env.JEST_TEST_NAME_PATTERN;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const rojoProject = discoverRojoProject(
|
|
74
|
+
options.project ? path.resolve(options.project) : undefined
|
|
75
|
+
);
|
|
76
|
+
const compilerOptions = discoverCompilerOptions(options.tsconfig);
|
|
77
|
+
const rewriter = new ResultRewriter({
|
|
78
|
+
compilerOptions,
|
|
79
|
+
rojoProject,
|
|
80
|
+
testLocationInResults: options.testLocationInResults,
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
const actualStartTime = Date.now();
|
|
84
|
+
let parsedResults;
|
|
85
|
+
|
|
86
|
+
if (options.showConfig) {
|
|
87
|
+
console.log((await executeLuauTest(options)).config);
|
|
88
|
+
return 0;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (options.listTests) {
|
|
92
|
+
const result = JSON.parse(await executeLuauTest(options));
|
|
93
|
+
const reconstructed = [];
|
|
94
|
+
for (const testPath of result)
|
|
95
|
+
reconstructed.push(rewriter.datamodelPathToSourcePath(testPath));
|
|
96
|
+
|
|
97
|
+
if (options.json) {
|
|
98
|
+
const out = JSON.stringify(reconstructed);
|
|
99
|
+
if (options.outputFile) {
|
|
100
|
+
fs.writeFileSync(options.outputFile, out, "utf-8");
|
|
101
|
+
} else {
|
|
102
|
+
console.log(out);
|
|
103
|
+
}
|
|
104
|
+
} else {
|
|
105
|
+
for (const testPath of reconstructed) {
|
|
106
|
+
console.log(testPath);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return 0;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Check if we should use parallel execution
|
|
113
|
+
const maxWorkers = options.maxWorkers || 1;
|
|
114
|
+
const useParallel = maxWorkers > 1;
|
|
115
|
+
const executeSingleWorker = async () => {
|
|
116
|
+
return (await executeLuauTest(options)) ?? { exit: 1 };
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
if (useParallel && !options.testPathPattern) {
|
|
120
|
+
// Discover test files from filesystem
|
|
121
|
+
const testSuites = discoverTestFilesFromFilesystem(
|
|
122
|
+
compilerOptions,
|
|
123
|
+
options
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
if (options.verbose) {
|
|
127
|
+
console.log(`Found ${testSuites.length} test suite(s)`);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (testSuites.length === 0) {
|
|
131
|
+
console.warn("No test suites found");
|
|
132
|
+
parsedResults = {
|
|
133
|
+
globalConfig: {
|
|
134
|
+
rootDir: cwd,
|
|
135
|
+
},
|
|
136
|
+
results: {
|
|
137
|
+
numPassedTests: 0,
|
|
138
|
+
numFailedTests: 0,
|
|
139
|
+
numTotalTests: 0,
|
|
140
|
+
testResults: [],
|
|
141
|
+
success: true,
|
|
142
|
+
},
|
|
143
|
+
};
|
|
144
|
+
} else if (testSuites.length <= 1) {
|
|
145
|
+
// Only one test suite, run single worker
|
|
146
|
+
parsedResults = await executeSingleWorker();
|
|
147
|
+
} else {
|
|
148
|
+
// Split test suites across workers
|
|
149
|
+
const workers = [];
|
|
150
|
+
const suitesPerWorker = Math.ceil(testSuites.length / maxWorkers);
|
|
151
|
+
|
|
152
|
+
for (let i = 0; i < maxWorkers; i++) {
|
|
153
|
+
const start = i * suitesPerWorker;
|
|
154
|
+
const end = Math.min(
|
|
155
|
+
start + suitesPerWorker,
|
|
156
|
+
testSuites.length
|
|
157
|
+
);
|
|
158
|
+
const workerSuites = testSuites.slice(start, end);
|
|
159
|
+
|
|
160
|
+
if (workerSuites.length === 0) break;
|
|
161
|
+
|
|
162
|
+
workers.push({
|
|
163
|
+
id: i,
|
|
164
|
+
suites: workerSuites,
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (options.verbose) {
|
|
169
|
+
console.log(`Running tests with ${workers.length} worker(s)`);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Execute workers in parallel
|
|
173
|
+
const workerResults = await Promise.all(
|
|
174
|
+
workers.map(async (worker) => {
|
|
175
|
+
const workerOptions = {
|
|
176
|
+
...options,
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
// Create a testPathPattern regex that matches this worker's suites
|
|
180
|
+
// Each suite is a datamodel path like "ReplicatedStorage.src.__tests__.add.spec"
|
|
181
|
+
// We escape special regex chars and join with | for OR matching
|
|
182
|
+
const escapedPaths = worker.suites.map((s) =>
|
|
183
|
+
s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
|
|
184
|
+
);
|
|
185
|
+
workerOptions.testPathPattern = `(${escapedPaths.join(
|
|
186
|
+
"|"
|
|
187
|
+
)})$`;
|
|
188
|
+
|
|
189
|
+
return await executeLuauTest(workerOptions);
|
|
190
|
+
})
|
|
191
|
+
);
|
|
192
|
+
|
|
193
|
+
// Combine results from all workers
|
|
194
|
+
const combinedTestResults = [];
|
|
195
|
+
let numPassedTests = 0;
|
|
196
|
+
let numFailedTests = 0;
|
|
197
|
+
let numPendingTests = 0;
|
|
198
|
+
let numTodoTests = 0;
|
|
199
|
+
let numTotalTests = 0;
|
|
200
|
+
let numPassedTestSuites = 0;
|
|
201
|
+
let numFailedTestSuites = 0;
|
|
202
|
+
let numPendingTestSuites = 0;
|
|
203
|
+
let numRuntimeErrorTestSuites = 0;
|
|
204
|
+
let numTotalTestSuites = 0;
|
|
205
|
+
let allSuccess = true;
|
|
206
|
+
let globalConfig = null;
|
|
207
|
+
const combinedSnapshot = {
|
|
208
|
+
added: 0,
|
|
209
|
+
fileDeleted: false,
|
|
210
|
+
matched: 0,
|
|
211
|
+
unchecked: 0,
|
|
212
|
+
uncheckedKeys: [],
|
|
213
|
+
unmatched: 0,
|
|
214
|
+
updated: 0,
|
|
215
|
+
filesAdded: 0,
|
|
216
|
+
filesRemoved: 0,
|
|
217
|
+
filesRemovedList: [],
|
|
218
|
+
filesUnmatched: 0,
|
|
219
|
+
filesUpdated: 0,
|
|
220
|
+
didUpdate: false,
|
|
221
|
+
total: 0,
|
|
222
|
+
failure: false,
|
|
223
|
+
uncheckedKeysByFile: [],
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
for (const result of workerResults) {
|
|
227
|
+
if (result.results) {
|
|
228
|
+
numPassedTests += result.results.numPassedTests || 0;
|
|
229
|
+
numFailedTests += result.results.numFailedTests || 0;
|
|
230
|
+
numPendingTests += result.results.numPendingTests || 0;
|
|
231
|
+
numTodoTests += result.results.numTodoTests || 0;
|
|
232
|
+
numTotalTests += result.results.numTotalTests || 0;
|
|
233
|
+
numPassedTestSuites +=
|
|
234
|
+
result.results.numPassedTestSuites || 0;
|
|
235
|
+
numFailedTestSuites +=
|
|
236
|
+
result.results.numFailedTestSuites || 0;
|
|
237
|
+
numPendingTestSuites +=
|
|
238
|
+
result.results.numPendingTestSuites || 0;
|
|
239
|
+
numRuntimeErrorTestSuites +=
|
|
240
|
+
result.results.numRuntimeErrorTestSuites || 0;
|
|
241
|
+
numTotalTestSuites +=
|
|
242
|
+
result.results.numTotalTestSuites || 0;
|
|
243
|
+
allSuccess = allSuccess && result.results.success;
|
|
244
|
+
combinedTestResults.push(
|
|
245
|
+
...(result.results.testResults || [])
|
|
246
|
+
);
|
|
247
|
+
|
|
248
|
+
// Aggregate snapshot data
|
|
249
|
+
if (result.results.snapshot) {
|
|
250
|
+
const snap = result.results.snapshot;
|
|
251
|
+
combinedSnapshot.added += snap.added || 0;
|
|
252
|
+
combinedSnapshot.matched += snap.matched || 0;
|
|
253
|
+
combinedSnapshot.unchecked += snap.unchecked || 0;
|
|
254
|
+
combinedSnapshot.unmatched += snap.unmatched || 0;
|
|
255
|
+
combinedSnapshot.updated += snap.updated || 0;
|
|
256
|
+
combinedSnapshot.filesAdded += snap.filesAdded || 0;
|
|
257
|
+
combinedSnapshot.filesRemoved += snap.filesRemoved || 0;
|
|
258
|
+
combinedSnapshot.filesUnmatched +=
|
|
259
|
+
snap.filesUnmatched || 0;
|
|
260
|
+
combinedSnapshot.filesUpdated += snap.filesUpdated || 0;
|
|
261
|
+
combinedSnapshot.total += snap.total || 0;
|
|
262
|
+
combinedSnapshot.didUpdate =
|
|
263
|
+
combinedSnapshot.didUpdate ||
|
|
264
|
+
snap.didUpdate ||
|
|
265
|
+
false;
|
|
266
|
+
combinedSnapshot.failure =
|
|
267
|
+
combinedSnapshot.failure || snap.failure || false;
|
|
268
|
+
if (snap.filesRemovedList) {
|
|
269
|
+
combinedSnapshot.filesRemovedList.push(
|
|
270
|
+
...snap.filesRemovedList
|
|
271
|
+
);
|
|
272
|
+
}
|
|
273
|
+
if (snap.uncheckedKeysByFile) {
|
|
274
|
+
combinedSnapshot.uncheckedKeysByFile.push(
|
|
275
|
+
...snap.uncheckedKeysByFile
|
|
276
|
+
);
|
|
277
|
+
}
|
|
278
|
+
if (snap.uncheckedKeys) {
|
|
279
|
+
combinedSnapshot.uncheckedKeys.push(
|
|
280
|
+
...snap.uncheckedKeys
|
|
281
|
+
);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
// Use globalConfig from first worker
|
|
286
|
+
if (!globalConfig && result.globalConfig) {
|
|
287
|
+
globalConfig = result.globalConfig;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
parsedResults = {
|
|
292
|
+
globalConfig: globalConfig || { rootDir: cwd },
|
|
293
|
+
results: {
|
|
294
|
+
numPassedTests,
|
|
295
|
+
numFailedTests,
|
|
296
|
+
numPendingTests,
|
|
297
|
+
numTodoTests,
|
|
298
|
+
numTotalTests,
|
|
299
|
+
numPassedTestSuites,
|
|
300
|
+
numFailedTestSuites,
|
|
301
|
+
numPendingTestSuites,
|
|
302
|
+
numRuntimeErrorTestSuites,
|
|
303
|
+
numTotalTestSuites,
|
|
304
|
+
testResults: combinedTestResults,
|
|
305
|
+
success: allSuccess,
|
|
306
|
+
snapshot: combinedSnapshot,
|
|
307
|
+
startTime: 0,
|
|
308
|
+
wasInterrupted: false,
|
|
309
|
+
openHandles: [],
|
|
310
|
+
},
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
} else {
|
|
314
|
+
parsedResults = await executeSingleWorker();
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
if (parsedResults.exit !== undefined) return parsedResults.exit;
|
|
318
|
+
|
|
319
|
+
rewriter.rewriteParsedResults(parsedResults.results);
|
|
320
|
+
|
|
321
|
+
// Rewrite coverage paths if coverage data is available
|
|
322
|
+
if (parsedResults.coverage) {
|
|
323
|
+
parsedResults.coverage = rewriter.rewriteCoverageData(
|
|
324
|
+
parsedResults.coverage
|
|
325
|
+
);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
if (options.passWithNoTests && parsedResults.results.numTotalTests === 0) {
|
|
329
|
+
parsedResults.results.success = true;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// Fix globalConfig - set rootDir to current working directory if null
|
|
333
|
+
const globalConfig = {
|
|
334
|
+
...(parsedResults.globalConfig || {}),
|
|
335
|
+
...options,
|
|
336
|
+
rootDir:
|
|
337
|
+
(parsedResults.globalConfig &&
|
|
338
|
+
parsedResults.globalConfig.rootDir) ||
|
|
339
|
+
process.cwd(),
|
|
340
|
+
testPathPatterns: new TestPathPatterns(
|
|
341
|
+
options.testPathPattern ? [options.testPathPattern] : []
|
|
342
|
+
),
|
|
343
|
+
};
|
|
344
|
+
|
|
345
|
+
const reporterConfigs = [];
|
|
346
|
+
|
|
347
|
+
if (options.reporters && options.reporters.length > 0) {
|
|
348
|
+
// Custom reporters specified
|
|
349
|
+
for (const reporterEntry of options.reporters) {
|
|
350
|
+
// Reporter can be a string or [string, options]
|
|
351
|
+
const reporterName = Array.isArray(reporterEntry)
|
|
352
|
+
? reporterEntry[0]
|
|
353
|
+
: reporterEntry;
|
|
354
|
+
const reporterOptions = Array.isArray(reporterEntry)
|
|
355
|
+
? reporterEntry[1]
|
|
356
|
+
: undefined;
|
|
357
|
+
|
|
358
|
+
if (reporterName === "default") {
|
|
359
|
+
reporterConfigs.push({
|
|
360
|
+
Reporter: DefaultReporter,
|
|
361
|
+
options: reporterOptions,
|
|
362
|
+
});
|
|
363
|
+
} else if (reporterName === "summary") {
|
|
364
|
+
reporterConfigs.push({
|
|
365
|
+
Reporter: SummaryReporter,
|
|
366
|
+
options: reporterOptions,
|
|
367
|
+
});
|
|
368
|
+
} else {
|
|
369
|
+
try {
|
|
370
|
+
// Convert absolute paths to file URLs for ESM loader compatibility
|
|
371
|
+
let moduleToImport = reporterName;
|
|
372
|
+
if (path.isAbsolute(reporterName)) {
|
|
373
|
+
moduleToImport = pathToFileURL(reporterName).href;
|
|
374
|
+
}
|
|
375
|
+
const ReporterModule = await import(moduleToImport);
|
|
376
|
+
if (ReporterModule) {
|
|
377
|
+
reporterConfigs.push({
|
|
378
|
+
Reporter: ReporterModule.default ?? ReporterModule,
|
|
379
|
+
options: reporterOptions,
|
|
380
|
+
});
|
|
381
|
+
} else {
|
|
382
|
+
console.warn(
|
|
383
|
+
`Reporter module "${reporterName}" does not have a default export.`
|
|
384
|
+
);
|
|
385
|
+
}
|
|
386
|
+
} catch (error) {
|
|
387
|
+
console.warn(
|
|
388
|
+
`Failed to load reporter module "${reporterName}": ${error.message}`
|
|
389
|
+
);
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
} else {
|
|
394
|
+
// Default reporters
|
|
395
|
+
reporterConfigs.push({
|
|
396
|
+
Reporter: options.verbose ? VerboseReporter : DefaultReporter,
|
|
397
|
+
options: undefined,
|
|
398
|
+
});
|
|
399
|
+
reporterConfigs.push({ Reporter: SummaryReporter, options: undefined });
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
for (const { Reporter, options: reporterOptions } of reporterConfigs) {
|
|
403
|
+
const reporter = new Reporter(globalConfig, reporterOptions);
|
|
404
|
+
|
|
405
|
+
// Create aggregated results in the format Jest expects
|
|
406
|
+
const aggregatedResults = {
|
|
407
|
+
...parsedResults.results,
|
|
408
|
+
numPassedTests: parsedResults.results.numPassedTests || 0,
|
|
409
|
+
numFailedTests: parsedResults.results.numFailedTests || 0,
|
|
410
|
+
numTotalTests: parsedResults.results.numTotalTests || 0,
|
|
411
|
+
testResults: parsedResults.results.testResults || [],
|
|
412
|
+
startTime: actualStartTime,
|
|
413
|
+
snapshot: parsedResults.results.snapshot || {
|
|
414
|
+
added: 0,
|
|
415
|
+
fileDeleted: false,
|
|
416
|
+
matched: 0,
|
|
417
|
+
unchecked: 0,
|
|
418
|
+
uncheckedKeys: [],
|
|
419
|
+
unmatched: 0,
|
|
420
|
+
updated: 0,
|
|
421
|
+
},
|
|
422
|
+
wasInterrupted: false,
|
|
423
|
+
};
|
|
424
|
+
|
|
425
|
+
// Call reporter lifecycle methods if they exist
|
|
426
|
+
if (typeof reporter.onRunStart === "function") {
|
|
427
|
+
await Promise.resolve(
|
|
428
|
+
reporter.onRunStart(aggregatedResults, {
|
|
429
|
+
estimatedTime: 0,
|
|
430
|
+
showStatus: true,
|
|
431
|
+
})
|
|
432
|
+
);
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
// Report each test result
|
|
436
|
+
if (parsedResults.results.testResults) {
|
|
437
|
+
for (const testResult of parsedResults.results.testResults) {
|
|
438
|
+
if (typeof reporter.onTestResult === "function") {
|
|
439
|
+
await Promise.resolve(
|
|
440
|
+
reporter.onTestResult(
|
|
441
|
+
{ context: { config: globalConfig } },
|
|
442
|
+
testResult,
|
|
443
|
+
aggregatedResults
|
|
444
|
+
)
|
|
445
|
+
);
|
|
446
|
+
} else if (typeof reporter.onTestStart === "function") {
|
|
447
|
+
await Promise.resolve(reporter.onTestStart(testResult));
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// Complete the run
|
|
453
|
+
if (typeof reporter.onRunComplete === "function") {
|
|
454
|
+
await Promise.resolve(
|
|
455
|
+
reporter.onRunComplete(new Set(), aggregatedResults)
|
|
456
|
+
);
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
// Generate coverage reports if coverage data is available
|
|
461
|
+
if (parsedResults.coverage) {
|
|
462
|
+
await generateCoverageReports(parsedResults.coverage, options);
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
if (options.json) {
|
|
466
|
+
const json = JSON.stringify(rewriter.json(parsedResults));
|
|
467
|
+
if (options.outputFile) {
|
|
468
|
+
fs.writeFileSync(options.outputFile, json, "utf-8");
|
|
469
|
+
console.log(`Test results written to: ${options.outputFile}`);
|
|
470
|
+
} else {
|
|
471
|
+
console.log(json);
|
|
472
|
+
}
|
|
473
|
+
// Handle early exit signals (e.g., "No tests found" with passWithNoTests)
|
|
474
|
+
if (parsedResults.exit !== undefined) {
|
|
475
|
+
return parsedResults.exit;
|
|
476
|
+
}
|
|
477
|
+
return parsedResults.results.success ? 0 : 1;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
return parsedResults.results.success ? 0 : 1;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
/**
|
|
484
|
+
* Executes the Luau script to run Jest tests with the given options.
|
|
485
|
+
* @param {object} options The Jest options to pass to the Luau script.
|
|
486
|
+
* @returns {Promise<any>} The parsed results from the Luau script.
|
|
487
|
+
*/
|
|
488
|
+
async function executeLuauTest(options) {
|
|
489
|
+
const cachePath = ensureCache();
|
|
490
|
+
const randomHash = options.debug
|
|
491
|
+
? "debug"
|
|
492
|
+
: Math.random().toString(36).substring(2, 8);
|
|
493
|
+
const luauOutputPath = path.join(
|
|
494
|
+
cachePath,
|
|
495
|
+
`luau_output_${randomHash}.log`
|
|
496
|
+
);
|
|
497
|
+
|
|
498
|
+
const resultSplitMarker = `__JEST_RESULT_START__`;
|
|
499
|
+
|
|
500
|
+
const luauScript = `
|
|
501
|
+
local HttpService = game:GetService("HttpService")
|
|
502
|
+
local jestOptions = HttpService:JSONDecode([===[${JSON.stringify(options)}]===])
|
|
503
|
+
-- These options are handled in JS
|
|
504
|
+
jestOptions.reporters = {}
|
|
505
|
+
jestOptions.json = jestOptions.listTests == true
|
|
506
|
+
jestOptions.watch = nil
|
|
507
|
+
jestOptions.watchAll = nil
|
|
508
|
+
|
|
509
|
+
local coverage
|
|
510
|
+
local runCLI
|
|
511
|
+
local projects = {}
|
|
512
|
+
local testFiles = {}
|
|
513
|
+
for i, v in pairs(game:GetDescendants()) do
|
|
514
|
+
if v.Name == "cli" and v.Parent.Name == "JestCore" and v:IsA("ModuleScript") then
|
|
515
|
+
local reading = require(v)
|
|
516
|
+
if reading and reading.runCLI then
|
|
517
|
+
if runCLI then
|
|
518
|
+
warn("Multiple JestCore CLI modules found;" .. v:GetFullName())
|
|
519
|
+
end
|
|
520
|
+
runCLI = reading.runCLI
|
|
521
|
+
end
|
|
522
|
+
elseif v.Name == "jest.config" and v:IsA("ModuleScript") then
|
|
523
|
+
local fullName = v:GetFullName()
|
|
524
|
+
if not fullName:find("rbxts_include") and not fullName:find("node_modules") then
|
|
525
|
+
table.insert(projects, v.Parent)
|
|
526
|
+
end
|
|
527
|
+
elseif v.Name == "coverage" and v:FindFirstChild("src") then
|
|
528
|
+
local coverageCandidate = require(v.src)
|
|
529
|
+
if coverageCandidate and coverageCandidate.instrument then
|
|
530
|
+
coverage = coverageCandidate
|
|
531
|
+
end
|
|
532
|
+
elseif v.Name:find(".spec") or v.Name:find(".test") then
|
|
533
|
+
table.insert(testFiles, v)
|
|
534
|
+
end
|
|
535
|
+
end
|
|
536
|
+
|
|
537
|
+
if not runCLI then
|
|
538
|
+
error("Could not find JestCore CLI module")
|
|
539
|
+
end
|
|
540
|
+
if #projects == 0 then
|
|
541
|
+
error("Could not find any jest.config modules")
|
|
542
|
+
end
|
|
543
|
+
|
|
544
|
+
pcall(function()
|
|
545
|
+
settings().Studio.ScriptTimeoutLength = -1 -- Disable script timeout
|
|
546
|
+
end)
|
|
547
|
+
|
|
548
|
+
local runningCoverage = false
|
|
549
|
+
if jestOptions.coverage or jestOptions.collectCoverage then
|
|
550
|
+
if coverage then
|
|
551
|
+
runningCoverage = true
|
|
552
|
+
table.insert(testFiles, game:GetService("ReplicatedStorage"):FindFirstChild("rbxts_include"))
|
|
553
|
+
coverage.instrument(nil, testFiles) -- TODO: support coveragePathIgnorePatterns
|
|
554
|
+
else
|
|
555
|
+
warn("Coverage requested but coverage module not found")
|
|
556
|
+
end
|
|
557
|
+
end
|
|
558
|
+
|
|
559
|
+
local success, resolved = runCLI(game, jestOptions, projects):await()
|
|
560
|
+
|
|
561
|
+
if jestOptions.showConfig or jestOptions.listTests then
|
|
562
|
+
return 0
|
|
563
|
+
end
|
|
564
|
+
|
|
565
|
+
if resolved and type(resolved) == "table" then
|
|
566
|
+
resolved.resolveSuccess = success
|
|
567
|
+
if runningCoverage then
|
|
568
|
+
resolved.coverage = coverage.istanbul()
|
|
569
|
+
end
|
|
570
|
+
end
|
|
571
|
+
|
|
572
|
+
local payload = HttpService:JSONEncode(resolved)
|
|
573
|
+
local payloadSize = #payload
|
|
574
|
+
|
|
575
|
+
local EncodingService = game:GetService("EncodingService")
|
|
576
|
+
local bufferPayload = buffer.fromstring(payload)
|
|
577
|
+
|
|
578
|
+
local compressionStartTime = os.clock()
|
|
579
|
+
local compressed = EncodingService:CompressBuffer(bufferPayload, Enum.CompressionAlgorithm.Zstd, 9)
|
|
580
|
+
if jestOptions.debug then
|
|
581
|
+
print("Compression took " .. ((os.clock() - compressionStartTime) * 1000) .. "ms")
|
|
582
|
+
end
|
|
583
|
+
|
|
584
|
+
print("${resultSplitMarker}")
|
|
585
|
+
if buffer.len(compressed) <= 4194304 then
|
|
586
|
+
return compressed
|
|
587
|
+
end
|
|
588
|
+
|
|
589
|
+
if jestOptions.debug then
|
|
590
|
+
print("Payload size " .. payloadSize .. " bytes exceeds 4MB, uploading to GitHub")
|
|
591
|
+
end
|
|
592
|
+
|
|
593
|
+
-- Direct return is not possible; send to user-specified github repo
|
|
594
|
+
local repo = "${process.env.JEST_ASSASSIN_PAYLOAD_REPO ?? ""}"
|
|
595
|
+
if not repo or repo == "" then
|
|
596
|
+
error("Payload too large (" .. payloadSize .. " bytes) and no JEST_ASSASSIN_PAYLOAD_REPO specified")
|
|
597
|
+
end
|
|
598
|
+
local gh_token = "${process.env.JEST_ASSASSIN_GITHUB_TOKEN ?? ""}"
|
|
599
|
+
if not gh_token or gh_token == "" then
|
|
600
|
+
error("Payload too large (" .. payloadSize .. " bytes) and no JEST_ASSASSIN_GITHUB_TOKEN specified")
|
|
601
|
+
end
|
|
602
|
+
local fileName = "${
|
|
603
|
+
process.env.JEST_ASSASSIN_PAYLOAD_FILENAME ?? "jest_payload"
|
|
604
|
+
}"
|
|
605
|
+
local url = "https://api.github.com/repos/" .. repo .. "/contents/" .. fileName
|
|
606
|
+
|
|
607
|
+
-- Obtain SHA of existing file if it exists
|
|
608
|
+
local existingSha
|
|
609
|
+
local getSuccess, getResponse = pcall(function()
|
|
610
|
+
return HttpService:GetAsync(url, false, {
|
|
611
|
+
["Authorization"] = "token " .. gh_token
|
|
612
|
+
})
|
|
613
|
+
end)
|
|
614
|
+
if getSuccess then
|
|
615
|
+
existingSha = HttpService:JSONDecode(getResponse).sha
|
|
616
|
+
end
|
|
617
|
+
|
|
618
|
+
local putPayload = {
|
|
619
|
+
message = "JestRoblox large payload upload",
|
|
620
|
+
content = bufferPayload,
|
|
621
|
+
branch = "main",
|
|
622
|
+
}
|
|
623
|
+
if existingSha then
|
|
624
|
+
putPayload.sha = existingSha
|
|
625
|
+
end
|
|
626
|
+
|
|
627
|
+
local putSuccess, putResponse = pcall(function()
|
|
628
|
+
return game:GetService("HttpService"):RequestAsync({
|
|
629
|
+
Url = url,
|
|
630
|
+
Method = "PUT",
|
|
631
|
+
Headers = {
|
|
632
|
+
["Authorization"] = "token " .. gh_token,
|
|
633
|
+
["Content-Type"] = "application/json"
|
|
634
|
+
},
|
|
635
|
+
Body = HttpService:JSONEncode(putPayload)
|
|
636
|
+
})
|
|
637
|
+
end)
|
|
638
|
+
if not putSuccess then
|
|
639
|
+
error("Failed to upload large payload to GitHub: " .. putResponse)
|
|
640
|
+
end
|
|
641
|
+
if putResponse.Success ~= true then
|
|
642
|
+
error("GitHub API returned error: " .. tostring(putResponse.StatusCode) .. " - " .. tostring(putResponse.Body))
|
|
643
|
+
end
|
|
644
|
+
return "__PAYLOAD_URL_START__" .. url .. "__PAYLOAD_URL_END__"
|
|
645
|
+
`;
|
|
646
|
+
|
|
647
|
+
const luauExitCode = await executeLuau(luauScript, {
|
|
648
|
+
place: options.place,
|
|
649
|
+
silent: true,
|
|
650
|
+
exit: false,
|
|
651
|
+
out: luauOutputPath,
|
|
652
|
+
});
|
|
653
|
+
|
|
654
|
+
const outputLog = fs.readFileSync(luauOutputPath, "utf-8");
|
|
655
|
+
if (!options.debug) {
|
|
656
|
+
// Clean up Luau output file
|
|
657
|
+
try {
|
|
658
|
+
fs.unlinkSync(luauOutputPath);
|
|
659
|
+
} catch {
|
|
660
|
+
// Ignore
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
if (luauExitCode !== 0) {
|
|
665
|
+
throw new Error(
|
|
666
|
+
`Luau script execution failed with exit code: ${luauExitCode}\n${outputLog}`
|
|
667
|
+
);
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
if (options.listTests) {
|
|
671
|
+
return outputLog;
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
if (options.debug) {
|
|
675
|
+
console.log(outputLog);
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
if (options.showConfig) {
|
|
679
|
+
const firstBrace = outputLog.indexOf("{");
|
|
680
|
+
const lastMarker = outputLog.lastIndexOf(resultSplitMarker);
|
|
681
|
+
// Find the last brace BEFORE the result marker, not the last brace in the entire output
|
|
682
|
+
let lastBrace = -1;
|
|
683
|
+
for (let i = lastMarker - 1; i >= 0; i--) {
|
|
684
|
+
if (outputLog[i] === "}") {
|
|
685
|
+
lastBrace = i;
|
|
686
|
+
break;
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
return {
|
|
690
|
+
config:
|
|
691
|
+
lastBrace !== -1 && firstBrace !== -1
|
|
692
|
+
? outputLog.slice(firstBrace, lastBrace + 1)
|
|
693
|
+
: null,
|
|
694
|
+
};
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
const resultMarkerSplit = outputLog.split(resultSplitMarker);
|
|
698
|
+
if (resultMarkerSplit.length < 2) {
|
|
699
|
+
throw new Error(`No result found in output log:\n${outputLog}`);
|
|
700
|
+
}
|
|
701
|
+
const [miscOutput, luauReturnRaw] = resultMarkerSplit;
|
|
702
|
+
|
|
703
|
+
let jestPayloadRaw;
|
|
704
|
+
const payloadUrlMatch = luauReturnRaw.match(
|
|
705
|
+
/__PAYLOAD_URL_START__(.+?)__PAYLOAD_URL_END__/
|
|
706
|
+
);
|
|
707
|
+
const payloadUrl = payloadUrlMatch ? payloadUrlMatch[1] : null;
|
|
708
|
+
if (payloadUrl) {
|
|
709
|
+
// Fetch payload from GitHub
|
|
710
|
+
const gh_token = process.env.JEST_ASSASSIN_GITHUB_TOKEN;
|
|
711
|
+
if (!gh_token) {
|
|
712
|
+
throw new Error(
|
|
713
|
+
"Payload too large; JEST_ASSASSIN_GITHUB_TOKEN not specified"
|
|
714
|
+
);
|
|
715
|
+
}
|
|
716
|
+
const payloadUrlResponse = await fetch(payloadUrl, {
|
|
717
|
+
headers: {
|
|
718
|
+
Authorization: `token ${gh_token}`,
|
|
719
|
+
},
|
|
720
|
+
});
|
|
721
|
+
if (!payloadUrlResponse.ok)
|
|
722
|
+
throw new Error(
|
|
723
|
+
`Failed to fetch large payload from GitHub: ${payloadUrlResponse.status} ${payloadUrlResponse.statusText}`
|
|
724
|
+
);
|
|
725
|
+
|
|
726
|
+
const gitUrl = (await payloadUrlResponse.json()).git_url;
|
|
727
|
+
if (!gitUrl)
|
|
728
|
+
throw new Error(
|
|
729
|
+
`Invalid response from GitHub when fetching payload: ${await payloadUrlResponse.text()}`
|
|
730
|
+
);
|
|
731
|
+
|
|
732
|
+
const gitResponse = await fetch(gitUrl, {
|
|
733
|
+
headers: {
|
|
734
|
+
Authorization: `token ${gh_token}`,
|
|
735
|
+
},
|
|
736
|
+
});
|
|
737
|
+
if (!gitResponse.ok)
|
|
738
|
+
throw new Error(
|
|
739
|
+
`Failed to fetch large payload content from GitHub: ${gitResponse.status} ${gitResponse.statusText}`
|
|
740
|
+
);
|
|
741
|
+
|
|
742
|
+
const data = await gitResponse.json();
|
|
743
|
+
if (!data.content)
|
|
744
|
+
throw new Error(
|
|
745
|
+
`Invalid content response from GitHub when fetching payload: ${await gitResponse.text()}`
|
|
746
|
+
);
|
|
747
|
+
|
|
748
|
+
jestPayloadRaw = Buffer.from(data.content, "base64").toString("utf-8");
|
|
749
|
+
} else {
|
|
750
|
+
jestPayloadRaw = luauReturnRaw;
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
if (miscOutput.includes("No tests found, exiting with code")) {
|
|
754
|
+
const startIndex = miscOutput.indexOf(
|
|
755
|
+
"No tests found, exiting with code"
|
|
756
|
+
);
|
|
757
|
+
// The marker is not in miscOutput (it was already split off), so just use the end of miscOutput
|
|
758
|
+
const message = miscOutput.slice(startIndex).trim();
|
|
759
|
+
console.log(message);
|
|
760
|
+
return {
|
|
761
|
+
exit: options.passWithNoTests ? 0 : 1,
|
|
762
|
+
};
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
if (!jestPayloadRaw)
|
|
766
|
+
throw new Error(`Failed to retrieve test results:\n${outputLog}`);
|
|
767
|
+
|
|
768
|
+
let jestPayload = JSON.parse(jestPayloadRaw);
|
|
769
|
+
if (!jestPayload)
|
|
770
|
+
throw new Error(`Failed to parse test results:\n${jestPayloadRaw}`);
|
|
771
|
+
|
|
772
|
+
if (jestPayload.t === "buffer") {
|
|
773
|
+
const bufferData = Buffer.from(jestPayload.base64, "base64");
|
|
774
|
+
jestPayload = zstdDecompressSync(bufferData).toString("utf-8");
|
|
775
|
+
jestPayload = JSON.parse(jestPayload);
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
if (!jestPayload.resolveSuccess)
|
|
779
|
+
throw new Error(`Failed to resolve test results:\n${jestPayloadRaw}`);
|
|
780
|
+
|
|
781
|
+
return jestPayload;
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
/**
|
|
785
|
+
* Generates coverage reports using Istanbul.
|
|
786
|
+
* @param {object} coverageData The coverage data in Istanbul format.
|
|
787
|
+
* @param {object} options The CLI options containing coverageDirectory.
|
|
788
|
+
*/
|
|
789
|
+
async function generateCoverageReports(coverageData, options) {
|
|
790
|
+
const coverageDir = options.coverageDirectory || "coverage";
|
|
791
|
+
const coverageFile = path.join(coverageDir, "coverage-final.json");
|
|
792
|
+
|
|
793
|
+
// Create coverage directory if it doesn't exist
|
|
794
|
+
if (!fs.existsSync(coverageDir)) {
|
|
795
|
+
fs.mkdirSync(coverageDir, { recursive: true });
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
// Write coverage-final.json
|
|
799
|
+
fs.writeFileSync(coverageFile, JSON.stringify(coverageData, null, 2));
|
|
800
|
+
|
|
801
|
+
// Create coverage map
|
|
802
|
+
const coverageMap = libCoverage.createCoverageMap(coverageData);
|
|
803
|
+
|
|
804
|
+
// Create report context
|
|
805
|
+
const context = libReport.createContext({
|
|
806
|
+
dir: coverageDir,
|
|
807
|
+
coverageMap: coverageMap,
|
|
808
|
+
defaultSummarizer: "nested",
|
|
809
|
+
});
|
|
810
|
+
|
|
811
|
+
// Generate report formats
|
|
812
|
+
const reportFormats = [
|
|
813
|
+
"html",
|
|
814
|
+
"text",
|
|
815
|
+
"text-summary",
|
|
816
|
+
"lcov",
|
|
817
|
+
"json-summary",
|
|
818
|
+
"json",
|
|
819
|
+
"cobertura",
|
|
820
|
+
];
|
|
821
|
+
|
|
822
|
+
for (const formatName of reportFormats) {
|
|
823
|
+
try {
|
|
824
|
+
const report = reports.create(formatName);
|
|
825
|
+
report.execute(context);
|
|
826
|
+
} catch (error) {
|
|
827
|
+
if (options.verbose) {
|
|
828
|
+
console.warn(
|
|
829
|
+
`Failed to generate ${formatName} coverage report: ${error.message}`
|
|
830
|
+
);
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
if (options.verbose) {
|
|
836
|
+
console.log(`Coverage reports generated in ${coverageDir}`);
|
|
837
|
+
}
|
|
838
|
+
}
|