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.
@@ -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
+ }