jest-roblox-assassin 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/cli.js ADDED
@@ -0,0 +1,833 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { TestPathPatterns } from "@jest/pattern";
4
+ import { DefaultReporter, SummaryReporter } from "@jest/reporters";
5
+ import { Command } from "commander";
6
+ import dotenv from "dotenv";
7
+ import fs from "fs";
8
+ import path from "path";
9
+ import * as rbxluau from "rbxluau";
10
+ import { pathToFileURL } from "url";
11
+ import { ensureCache } from "./cache.js";
12
+ import { getCliOptions } from "./docs.js";
13
+ import { ResultRewriter } from "./rewriter.js";
14
+
15
+ const cachePath = ensureCache();
16
+ const luauOutputPath = path.join(cachePath, "luau_output.log");
17
+
18
+ // Load environment variables from .env file
19
+ dotenv.config({ quiet: true });
20
+
21
+ // Fetch CLI options and build commander program
22
+ const cliOptions = await getCliOptions();
23
+
24
+ const program = new Command();
25
+
26
+ program
27
+ .name("jestrbx")
28
+ .description("Delightful Roblox testing.")
29
+ .version("1.0.0")
30
+ .argument("[testPathPattern]", "test path pattern to match")
31
+ .option("--place <file>", "path to Roblox place file")
32
+ .option("--project <file>", "path to project JSON file")
33
+ .option("--config <file>", "path to Jest config file")
34
+ .option(
35
+ "--maxWorkers <number>",
36
+ "maximum number of parallel workers to use"
37
+ );
38
+
39
+ // Add options from fetched documentation
40
+ function collect(value, previous) {
41
+ return previous.concat([value]);
42
+ }
43
+
44
+ for (const opt of cliOptions) {
45
+ const flagName = opt.name.replace(/^--/, "");
46
+ const isArray = opt.type.includes("array");
47
+ const isNumber = opt.type.includes("number");
48
+ const isString = opt.type.includes("string") || opt.type.includes("regex");
49
+
50
+ let flags = opt.name;
51
+ // Add short flags for common options
52
+ const shortFlags = {
53
+ verbose: "-v",
54
+ testNamePattern: "-t",
55
+ };
56
+ if (shortFlags[flagName]) {
57
+ flags = `${shortFlags[flagName]}, ${opt.name}`;
58
+ }
59
+
60
+ // Handle value placeholder
61
+ if (isString) {
62
+ flags += " <value>";
63
+ } else if (isNumber) {
64
+ flags += " <ms>";
65
+ }
66
+
67
+ const description = opt.description.split("\n")[0]; // First line only
68
+
69
+ if (isArray) {
70
+ program.option(flags, description, collect, []);
71
+ } else if (isNumber) {
72
+ program.option(flags, description, Number);
73
+ } else {
74
+ program.option(flags, description);
75
+ }
76
+ }
77
+
78
+ program.parse();
79
+
80
+ const options = program.opts();
81
+ const [testPathPattern] = program.args;
82
+
83
+ // Load config file if specified
84
+ let configFileOptions = {};
85
+ if (options.config) {
86
+ const configPath = path.resolve(options.config);
87
+ if (!fs.existsSync(configPath)) {
88
+ console.error(`Config file not found: ${configPath}`);
89
+ process.exit(1);
90
+ }
91
+ try {
92
+ const configUrl = pathToFileURL(configPath).href;
93
+ const configModule = await import(configUrl);
94
+ configFileOptions = configModule.default || configModule;
95
+ } catch (error) {
96
+ console.error(`Failed to load config file: ${error.message}`);
97
+ process.exit(1);
98
+ }
99
+ }
100
+
101
+ // Build jestOptions from config file first, then override with CLI arguments
102
+ const jestOptions = { ...configFileOptions };
103
+ if (options.ci) jestOptions.ci = true;
104
+ if (options.clearMocks) jestOptions.clearMocks = true;
105
+ if (options.debug) jestOptions.debug = true;
106
+ if (options.expand) jestOptions.expand = true;
107
+ if (options.json) jestOptions.json = true;
108
+ if (options.listTests) jestOptions.listTests = true;
109
+ if (options.noStackTrace) jestOptions.noStackTrace = true;
110
+ if (options.passWithNoTests) jestOptions.passWithNoTests = true;
111
+ if (options.resetMocks) jestOptions.resetMocks = true;
112
+ if (options.showConfig) jestOptions.showConfig = true;
113
+ if (options.updateSnapshot) jestOptions.updateSnapshot = true;
114
+ if (options.verbose) jestOptions.verbose = true;
115
+ if (options.testTimeout) jestOptions.testTimeout = options.testTimeout;
116
+ if (options.maxWorkers) jestOptions.maxWorkers = options.maxWorkers;
117
+ if (options.testNamePattern)
118
+ jestOptions.testNamePattern = options.testNamePattern;
119
+ if (options.testPathPattern)
120
+ jestOptions.testPathPattern = options.testPathPattern;
121
+ else if (testPathPattern) jestOptions.testPathPattern = testPathPattern;
122
+ if (options.testMatch && options.testMatch.length > 0)
123
+ jestOptions.testMatch = options.testMatch;
124
+ if (
125
+ options.testPathIgnorePatterns &&
126
+ options.testPathIgnorePatterns.length > 0
127
+ ) {
128
+ jestOptions.testPathIgnorePatterns = options.testPathIgnorePatterns;
129
+ }
130
+ if (options.reporters && options.reporters.length > 0)
131
+ jestOptions.reporters = options.reporters;
132
+
133
+ const placeFile = options.place;
134
+ let projectFile = options.project ? path.resolve(options.project) : undefined;
135
+
136
+ const workspaceRoot = placeFile
137
+ ? path.dirname(path.resolve(placeFile))
138
+ : process.cwd();
139
+
140
+ let projectRoot = workspaceRoot;
141
+
142
+ if (!projectFile) {
143
+ const defaultProject = path.join(projectRoot, "default.project.json");
144
+ if (fs.existsSync(defaultProject)) {
145
+ projectFile = defaultProject;
146
+ } else {
147
+ // Search up to 2 levels deep
148
+ const getSubdirs = (dir) => {
149
+ try {
150
+ return fs
151
+ .readdirSync(dir, { withFileTypes: true })
152
+ .filter(
153
+ (dirent) =>
154
+ dirent.isDirectory() &&
155
+ !dirent.name.startsWith(".") &&
156
+ dirent.name !== "node_modules"
157
+ )
158
+ .map((dirent) => path.join(dir, dirent.name));
159
+ } catch {
160
+ return [];
161
+ }
162
+ };
163
+
164
+ const level1 = getSubdirs(projectRoot);
165
+ for (const dir of level1) {
166
+ const p = path.join(dir, "default.project.json");
167
+ if (fs.existsSync(p)) {
168
+ projectFile = p;
169
+ projectRoot = dir;
170
+ break;
171
+ }
172
+ }
173
+
174
+ if (!projectFile) {
175
+ for (const dir of level1) {
176
+ const level2 = getSubdirs(dir);
177
+ for (const dir2 of level2) {
178
+ const p = path.join(dir2, "default.project.json");
179
+ if (fs.existsSync(p)) {
180
+ projectFile = p;
181
+ projectRoot = dir2;
182
+ break;
183
+ }
184
+ }
185
+ if (projectFile) break;
186
+ }
187
+ }
188
+ }
189
+ }
190
+
191
+ const tsConfigPath = path.join(projectRoot, "tsconfig.json");
192
+
193
+ const stripJsonComments = (text) =>
194
+ text.replace(/\/\*[\s\S]*?\*\//g, "").replace(/^\s*\/\/.*$/gm, "");
195
+
196
+ const readJsonWithComments = (jsonPath) => {
197
+ if (!fs.existsSync(jsonPath)) return undefined;
198
+ const raw = fs.readFileSync(jsonPath, "utf-8");
199
+ try {
200
+ return JSON.parse(stripJsonComments(raw));
201
+ } catch (error) {
202
+ return undefined;
203
+ }
204
+ };
205
+
206
+ const compilerOptions =
207
+ readJsonWithComments(tsConfigPath)?.compilerOptions || {};
208
+
209
+ const rootDir = compilerOptions.rootDir || "src";
210
+ const outDir = compilerOptions.outDir || "out";
211
+
212
+ const findDatamodelPath = (tree, targetPath, currentPath = []) => {
213
+ const normalize = (p) =>
214
+ path
215
+ .normalize(p)
216
+ .replace(/[\\\/]$/, "")
217
+ .replace(/\\/g, "/");
218
+ const normalizedTarget = normalize(targetPath);
219
+
220
+ if (tree.$path && normalize(tree.$path) === normalizedTarget) {
221
+ return currentPath;
222
+ }
223
+
224
+ for (const [key, value] of Object.entries(tree)) {
225
+ if (key.startsWith("$")) continue;
226
+ if (typeof value !== "object") continue;
227
+
228
+ const found = findDatamodelPath(value, targetPath, [
229
+ ...currentPath,
230
+ key,
231
+ ]);
232
+ if (found) return found;
233
+ }
234
+ return undefined;
235
+ };
236
+
237
+ const projectJson = projectFile ? readJsonWithComments(projectFile) : undefined;
238
+ let datamodelPrefixSegments = projectJson
239
+ ? findDatamodelPath(projectJson.tree, outDir)
240
+ : undefined;
241
+
242
+ if (!datamodelPrefixSegments || datamodelPrefixSegments.length === 0) {
243
+ console.warn(
244
+ `Could not determine datamodel prefix for outDir "${outDir}".`
245
+ );
246
+ datamodelPrefixSegments = ["ReplicatedStorage", ...rootDir.split(path.sep)];
247
+ }
248
+
249
+ // Get test filter from environment variable (set by VS Code extension)
250
+ if (process.env.JEST_TEST_NAME_PATTERN) {
251
+ jestOptions.testNamePattern = process.env.JEST_TEST_NAME_PATTERN;
252
+ }
253
+
254
+ const testPathPatterns = new TestPathPatterns(
255
+ jestOptions.testPathPattern ? [jestOptions.testPathPattern] : []
256
+ );
257
+
258
+ // Helper function to execute Luau and parse results
259
+ async function executeLuauTest(testOptions, workerOutputPath) {
260
+ const luauScript = `
261
+ local jestOptions = game:GetService("HttpService"):JSONDecode([===[${JSON.stringify(
262
+ testOptions
263
+ )}]===])
264
+ jestOptions.reporters = {} -- Redundant reporters, handled in JS
265
+
266
+ local runCLI
267
+ local projects = {}
268
+ for i, v in pairs(game:GetDescendants()) do
269
+ if v.Name == "cli" and v.Parent.Name == "JestCore" and v:IsA("ModuleScript") then
270
+ local reading = require(v)
271
+ if reading and reading.runCLI then
272
+ if runCLI then
273
+ warn("Multiple JestCore CLI modules found;" .. v:GetFullName())
274
+ end
275
+ runCLI = reading.runCLI
276
+ end
277
+ elseif v.Name == "jest.config" and v:IsA("ModuleScript") then
278
+ table.insert(projects, v.Parent)
279
+ end
280
+ end
281
+
282
+ if not runCLI then
283
+ error("Could not find JestCore CLI module")
284
+ end
285
+ if #projects == 0 then
286
+ error("Could not find any jest.config modules")
287
+ end
288
+
289
+ local success, resolved = runCLI(game, jestOptions, projects):await()
290
+
291
+ if jestOptions.showConfig then
292
+ return 0
293
+ end
294
+
295
+ print("__SUCCESS_START__")
296
+ print(success)
297
+ print("__SUCCESS_END__")
298
+ print("__RESULT_START__")
299
+ print(game:GetService("HttpService"):JSONEncode(resolved))
300
+ print("__RESULT_END__")
301
+ return 0
302
+ `;
303
+
304
+ const luauExitCode = await rbxluau.executeLuau(luauScript, {
305
+ place: placeFile,
306
+ silent: true,
307
+ exit: false,
308
+ out: workerOutputPath,
309
+ });
310
+ const outputLog = fs.readFileSync(workerOutputPath, "utf-8");
311
+
312
+ if (luauExitCode !== 0) {
313
+ throw new Error(
314
+ `Luau script execution failed with exit code: ${luauExitCode}\n${outputLog}`
315
+ );
316
+ }
317
+
318
+ if (testOptions.showConfig) {
319
+ const firstBrace = outputLog.indexOf("{");
320
+ const lastBrace = outputLog.lastIndexOf("}");
321
+ return {
322
+ config: JSON.parse(outputLog.slice(firstBrace, lastBrace + 1)),
323
+ };
324
+ }
325
+
326
+ const successMatch = outputLog.match(
327
+ /__SUCCESS_START__\s*(true|false)\s*__SUCCESS_END__/s
328
+ );
329
+ const resultMatch = outputLog.match(
330
+ /__RESULT_START__\s*([\s\S]*?)\s*__RESULT_END__/s
331
+ );
332
+
333
+ if (!successMatch || !resultMatch) {
334
+ throw new Error(`Failed to parse output log:\n${outputLog}`);
335
+ }
336
+
337
+ return JSON.parse(resultMatch[1]);
338
+ }
339
+
340
+ // Helper function to discover test files from filesystem
341
+ function discoverTestFilesFromFilesystem() {
342
+ const outDirPath = path.join(projectRoot, outDir);
343
+
344
+ if (!fs.existsSync(outDirPath)) {
345
+ if (jestOptions.verbose) {
346
+ console.log(`Output directory not found: ${outDirPath}`);
347
+ }
348
+ return [];
349
+ }
350
+
351
+ // Default test patterns if none specified
352
+ const defaultTestMatch = [
353
+ "**/__tests__/**/*.[jt]s?(x)",
354
+ "**/?(*.)+(spec|test).[jt]s?(x)",
355
+ ];
356
+
357
+ const testMatchPatterns =
358
+ jestOptions.testMatch && jestOptions.testMatch.length > 0
359
+ ? jestOptions.testMatch
360
+ : defaultTestMatch;
361
+
362
+ // Convert glob patterns to work with .luau files in outDir
363
+ const luauPatterns = testMatchPatterns.map((pattern) => {
364
+ // Replace js/ts extensions with luau
365
+ return pattern
366
+ .replace(/\.\[jt\]s\?\(x\)/g, ".luau")
367
+ .replace(/\.\[jt\]sx?/g, ".luau")
368
+ .replace(/\.tsx?/g, ".luau")
369
+ .replace(/\.jsx?/g, ".luau")
370
+ .replace(/\.ts/g, ".luau")
371
+ .replace(/\.js/g, ".luau");
372
+ });
373
+
374
+ // Add patterns for native .luau test files
375
+ if (
376
+ !luauPatterns.some(
377
+ (p) => p.includes(".spec.luau") || p.includes(".test.luau")
378
+ )
379
+ ) {
380
+ luauPatterns.push("**/__tests__/**/*.spec.luau");
381
+ luauPatterns.push("**/__tests__/**/*.test.luau");
382
+ luauPatterns.push("**/*.spec.luau");
383
+ luauPatterns.push("**/*.test.luau");
384
+ }
385
+
386
+ const testFiles = [];
387
+
388
+ // Simple recursive file finder with glob-like pattern matching
389
+ function findFiles(dir, baseDir) {
390
+ try {
391
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
392
+ for (const entry of entries) {
393
+ const fullPath = path.join(dir, entry.name);
394
+ const relativePath = path
395
+ .relative(baseDir, fullPath)
396
+ .replace(/\\/g, "/");
397
+
398
+ if (entry.isDirectory()) {
399
+ // Skip node_modules and hidden directories
400
+ if (
401
+ !entry.name.startsWith(".") &&
402
+ entry.name !== "node_modules"
403
+ ) {
404
+ findFiles(fullPath, baseDir);
405
+ }
406
+ } else if (entry.isFile() && entry.name.endsWith(".luau")) {
407
+ // Check if file matches any test pattern
408
+ const isTestFile = luauPatterns.some((pattern) => {
409
+ return matchGlobPattern(relativePath, pattern);
410
+ });
411
+
412
+ if (isTestFile) {
413
+ testFiles.push(relativePath);
414
+ }
415
+ }
416
+ }
417
+ } catch (error) {
418
+ // Ignore errors reading directories
419
+ }
420
+ }
421
+
422
+ // Simple glob pattern matcher
423
+ function matchGlobPattern(filePath, pattern) {
424
+ // Handle common glob patterns
425
+ let regexPattern = pattern
426
+ .replace(/\./g, "\\.")
427
+ .replace(/\*\*/g, "{{GLOBSTAR}}")
428
+ .replace(/\*/g, "[^/]*")
429
+ .replace(/{{GLOBSTAR}}/g, ".*")
430
+ .replace(/\?/g, ".");
431
+
432
+ // Handle optional groups like ?(x)
433
+ regexPattern = regexPattern.replace(/\\\?\(([^)]+)\)/g, "($1)?");
434
+
435
+ // Handle pattern groups like +(spec|test)
436
+ regexPattern = regexPattern.replace(/\+\(([^)]+)\)/g, "($1)+");
437
+
438
+ try {
439
+ const regex = new RegExp(`^${regexPattern}$`, "i");
440
+ return regex.test(filePath);
441
+ } catch {
442
+ // If pattern is invalid, fall back to simple check
443
+ return filePath.includes(".spec.") || filePath.includes(".test.");
444
+ }
445
+ }
446
+
447
+ findFiles(outDirPath, outDirPath);
448
+
449
+ // Apply testPathIgnorePatterns if specified
450
+ let filteredFiles = testFiles;
451
+ if (
452
+ jestOptions.testPathIgnorePatterns &&
453
+ jestOptions.testPathIgnorePatterns.length > 0
454
+ ) {
455
+ filteredFiles = testFiles.filter((file) => {
456
+ return !jestOptions.testPathIgnorePatterns.some((pattern) => {
457
+ try {
458
+ const regex = new RegExp(pattern);
459
+ return regex.test(file);
460
+ } catch {
461
+ return file.includes(pattern);
462
+ }
463
+ });
464
+ });
465
+ }
466
+
467
+ // Apply testPathPattern filter if specified
468
+ if (jestOptions.testPathPattern) {
469
+ const pathPatternRegex = new RegExp(jestOptions.testPathPattern, "i");
470
+ filteredFiles = filteredFiles.filter((file) =>
471
+ pathPatternRegex.test(file)
472
+ );
473
+ }
474
+
475
+ // Convert to roblox-jest path format (e.g., "src/__tests__/add.spec")
476
+ // These paths are relative to projectRoot, use forward slashes, and have no extension
477
+ const jestPaths = filteredFiles.map((file) => {
478
+ // Remove .luau extension
479
+ const withoutExt = file.replace(/\.luau$/, "");
480
+ // Normalize to forward slashes
481
+ const normalizedPath = withoutExt.replace(/\\/g, "/");
482
+ // Prepend the rootDir (since outDir maps to rootDir in the place)
483
+ return `${rootDir}/${normalizedPath}`;
484
+ });
485
+
486
+ if (jestOptions.verbose) {
487
+ console.log(
488
+ `Discovered ${jestPaths.length} test file(s) from filesystem`
489
+ );
490
+ }
491
+
492
+ return jestPaths;
493
+ }
494
+
495
+ const actualStartTime = Date.now();
496
+ let parsedResults;
497
+
498
+ if (jestOptions.showConfig) {
499
+ const result = await executeLuauTest(jestOptions, luauOutputPath);
500
+ console.log(result.config);
501
+ process.exit(0);
502
+ }
503
+
504
+ // Check if we should use parallel execution
505
+ const maxWorkers = jestOptions.maxWorkers || 1;
506
+ const useParallel = maxWorkers > 1;
507
+
508
+ if (useParallel) {
509
+ // Discover test files from filesystem (fast, no caching needed)
510
+ const testSuites = discoverTestFilesFromFilesystem();
511
+
512
+ if (jestOptions.verbose) {
513
+ console.log(`Found ${testSuites.length} test suite(s)`);
514
+ }
515
+
516
+ if (testSuites.length === 0) {
517
+ console.warn("No test suites found");
518
+ parsedResults = {
519
+ globalConfig: {
520
+ rootDir: workspaceRoot,
521
+ },
522
+ results: {
523
+ numPassedTests: 0,
524
+ numFailedTests: 0,
525
+ numTotalTests: 0,
526
+ testResults: [],
527
+ success: true,
528
+ },
529
+ };
530
+ } else if (testSuites.length === 1) {
531
+ // If only one test suite, no point in splitting
532
+ if (jestOptions.verbose) {
533
+ console.log("Running single test suite");
534
+ }
535
+ parsedResults = await executeLuauTest(jestOptions, luauOutputPath);
536
+ } else {
537
+ // Split test suites across workers
538
+ const workers = [];
539
+ const suitesPerWorker = Math.ceil(testSuites.length / maxWorkers);
540
+
541
+ for (let i = 0; i < maxWorkers; i++) {
542
+ const start = i * suitesPerWorker;
543
+ const end = Math.min(start + suitesPerWorker, testSuites.length);
544
+ const workerSuites = testSuites.slice(start, end);
545
+
546
+ if (workerSuites.length === 0) break;
547
+
548
+ workers.push({
549
+ id: i,
550
+ suites: workerSuites,
551
+ });
552
+ }
553
+
554
+ if (jestOptions.verbose) {
555
+ console.log(`Running tests with ${workers.length} worker(s)`);
556
+ }
557
+
558
+ // Execute workers in parallel
559
+ const workerResults = await Promise.all(
560
+ workers.map(async (worker) => {
561
+ const workerOptions = {
562
+ ...jestOptions,
563
+ };
564
+
565
+ // Create a testPathPattern regex that matches this worker's suites
566
+ // Each suite is a datamodel path like "ReplicatedStorage.src.__tests__.add.spec"
567
+ // We escape special regex chars and join with | for OR matching
568
+ const escapedPaths = worker.suites.map((s) =>
569
+ s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
570
+ );
571
+ workerOptions.testPathPattern = `(${escapedPaths.join("|")})$`;
572
+
573
+ const workerOutputPath = path.join(
574
+ cachePath,
575
+ `luau_output_worker_${worker.id}.log`
576
+ );
577
+
578
+ try {
579
+ return await executeLuauTest(
580
+ workerOptions,
581
+ workerOutputPath
582
+ );
583
+ } finally {
584
+ // Clean up worker output file
585
+ if (fs.existsSync(workerOutputPath)) {
586
+ fs.unlinkSync(workerOutputPath);
587
+ }
588
+ }
589
+ })
590
+ );
591
+
592
+ // Combine results from all workers
593
+ const combinedTestResults = [];
594
+ let numPassedTests = 0;
595
+ let numFailedTests = 0;
596
+ let numPendingTests = 0;
597
+ let numTodoTests = 0;
598
+ let numTotalTests = 0;
599
+ let numPassedTestSuites = 0;
600
+ let numFailedTestSuites = 0;
601
+ let numPendingTestSuites = 0;
602
+ let numRuntimeErrorTestSuites = 0;
603
+ let numTotalTestSuites = 0;
604
+ let allSuccess = true;
605
+ let globalConfig = null;
606
+ const combinedSnapshot = {
607
+ added: 0,
608
+ fileDeleted: false,
609
+ matched: 0,
610
+ unchecked: 0,
611
+ uncheckedKeys: [],
612
+ unmatched: 0,
613
+ updated: 0,
614
+ filesAdded: 0,
615
+ filesRemoved: 0,
616
+ filesRemovedList: [],
617
+ filesUnmatched: 0,
618
+ filesUpdated: 0,
619
+ didUpdate: false,
620
+ total: 0,
621
+ failure: false,
622
+ uncheckedKeysByFile: [],
623
+ };
624
+
625
+ for (const result of workerResults) {
626
+ if (result.results) {
627
+ numPassedTests += result.results.numPassedTests || 0;
628
+ numFailedTests += result.results.numFailedTests || 0;
629
+ numPendingTests += result.results.numPendingTests || 0;
630
+ numTodoTests += result.results.numTodoTests || 0;
631
+ numTotalTests += result.results.numTotalTests || 0;
632
+ numPassedTestSuites += result.results.numPassedTestSuites || 0;
633
+ numFailedTestSuites += result.results.numFailedTestSuites || 0;
634
+ numPendingTestSuites +=
635
+ result.results.numPendingTestSuites || 0;
636
+ numRuntimeErrorTestSuites +=
637
+ result.results.numRuntimeErrorTestSuites || 0;
638
+ numTotalTestSuites += result.results.numTotalTestSuites || 0;
639
+ allSuccess = allSuccess && result.results.success;
640
+ combinedTestResults.push(...(result.results.testResults || []));
641
+
642
+ // Aggregate snapshot data
643
+ if (result.results.snapshot) {
644
+ const snap = result.results.snapshot;
645
+ combinedSnapshot.added += snap.added || 0;
646
+ combinedSnapshot.matched += snap.matched || 0;
647
+ combinedSnapshot.unchecked += snap.unchecked || 0;
648
+ combinedSnapshot.unmatched += snap.unmatched || 0;
649
+ combinedSnapshot.updated += snap.updated || 0;
650
+ combinedSnapshot.filesAdded += snap.filesAdded || 0;
651
+ combinedSnapshot.filesRemoved += snap.filesRemoved || 0;
652
+ combinedSnapshot.filesUnmatched += snap.filesUnmatched || 0;
653
+ combinedSnapshot.filesUpdated += snap.filesUpdated || 0;
654
+ combinedSnapshot.total += snap.total || 0;
655
+ combinedSnapshot.didUpdate =
656
+ combinedSnapshot.didUpdate || snap.didUpdate || false;
657
+ combinedSnapshot.failure =
658
+ combinedSnapshot.failure || snap.failure || false;
659
+ if (snap.filesRemovedList) {
660
+ combinedSnapshot.filesRemovedList.push(
661
+ ...snap.filesRemovedList
662
+ );
663
+ }
664
+ if (snap.uncheckedKeysByFile) {
665
+ combinedSnapshot.uncheckedKeysByFile.push(
666
+ ...snap.uncheckedKeysByFile
667
+ );
668
+ }
669
+ if (snap.uncheckedKeys) {
670
+ combinedSnapshot.uncheckedKeys.push(
671
+ ...snap.uncheckedKeys
672
+ );
673
+ }
674
+ }
675
+ }
676
+ // Use globalConfig from first worker
677
+ if (!globalConfig && result.globalConfig) {
678
+ globalConfig = result.globalConfig;
679
+ }
680
+ }
681
+
682
+ parsedResults = {
683
+ globalConfig: globalConfig || { rootDir: workspaceRoot },
684
+ results: {
685
+ numPassedTests,
686
+ numFailedTests,
687
+ numPendingTests,
688
+ numTodoTests,
689
+ numTotalTests,
690
+ numPassedTestSuites,
691
+ numFailedTestSuites,
692
+ numPendingTestSuites,
693
+ numRuntimeErrorTestSuites,
694
+ numTotalTestSuites,
695
+ testResults: combinedTestResults,
696
+ success: allSuccess,
697
+ snapshot: combinedSnapshot,
698
+ startTime: 0,
699
+ wasInterrupted: false,
700
+ openHandles: [],
701
+ },
702
+ };
703
+ }
704
+ } else {
705
+ // Single worker execution (original behavior)
706
+ parsedResults = await executeLuauTest(jestOptions, luauOutputPath);
707
+ }
708
+
709
+ new ResultRewriter({
710
+ workspaceRoot,
711
+ projectRoot,
712
+ rootDir,
713
+ outDir,
714
+ datamodelPrefixSegments,
715
+ }).rewriteParsedResults(parsedResults.results);
716
+
717
+ // Fix globalConfig - set rootDir to current working directory if null
718
+ const globalConfig = {
719
+ ...parsedResults.globalConfig,
720
+ ...jestOptions,
721
+ rootDir: parsedResults.globalConfig.rootDir || workspaceRoot,
722
+ testPathPatterns,
723
+ };
724
+
725
+ const reporterConfigs = [];
726
+
727
+ if (jestOptions.reporters && jestOptions.reporters.length > 0) {
728
+ // Custom reporters specified
729
+ for (const reporterEntry of jestOptions.reporters) {
730
+ // Reporter can be a string or [string, options]
731
+ const reporterName = Array.isArray(reporterEntry)
732
+ ? reporterEntry[0]
733
+ : reporterEntry;
734
+ const reporterOptions = Array.isArray(reporterEntry)
735
+ ? reporterEntry[1]
736
+ : undefined;
737
+
738
+ if (reporterName === "default") {
739
+ reporterConfigs.push({
740
+ Reporter: DefaultReporter,
741
+ options: reporterOptions,
742
+ });
743
+ } else if (reporterName === "summary") {
744
+ reporterConfigs.push({
745
+ Reporter: SummaryReporter,
746
+ options: reporterOptions,
747
+ });
748
+ } else {
749
+ try {
750
+ const ReporterModule = await import(reporterName);
751
+ if (ReporterModule && ReporterModule.default) {
752
+ reporterConfigs.push({
753
+ Reporter: ReporterModule.default,
754
+ options: reporterOptions,
755
+ });
756
+ } else {
757
+ console.warn(
758
+ `Reporter module "${reporterName}" does not have a default export.`
759
+ );
760
+ }
761
+ } catch (error) {
762
+ console.warn(
763
+ `Failed to load reporter module "${reporterName}": ${error.message}`
764
+ );
765
+ }
766
+ }
767
+ }
768
+ } else {
769
+ // Default reporters
770
+ reporterConfigs.push({ Reporter: DefaultReporter, options: undefined });
771
+ reporterConfigs.push({ Reporter: SummaryReporter, options: undefined });
772
+ }
773
+
774
+ for (const { Reporter, options: reporterOptions } of reporterConfigs) {
775
+ const reporter = new Reporter(globalConfig, reporterOptions);
776
+
777
+ // Create aggregated results in the format Jest expects
778
+ const aggregatedResults = {
779
+ ...parsedResults.results,
780
+ numPassedTests: parsedResults.results.numPassedTests || 0,
781
+ numFailedTests: parsedResults.results.numFailedTests || 0,
782
+ numTotalTests: parsedResults.results.numTotalTests || 0,
783
+ testResults: parsedResults.results.testResults || [],
784
+ startTime: actualStartTime,
785
+ snapshot: parsedResults.results.snapshot || {
786
+ added: 0,
787
+ fileDeleted: false,
788
+ matched: 0,
789
+ unchecked: 0,
790
+ uncheckedKeys: [],
791
+ unmatched: 0,
792
+ updated: 0,
793
+ },
794
+ wasInterrupted: false,
795
+ };
796
+
797
+ // Call reporter lifecycle methods if they exist
798
+ if (typeof reporter.onRunStart === "function") {
799
+ await Promise.resolve(
800
+ reporter.onRunStart(aggregatedResults, {
801
+ estimatedTime: 0,
802
+ showStatus: true,
803
+ })
804
+ );
805
+ }
806
+
807
+ // Report each test result
808
+ if (parsedResults.results.testResults) {
809
+ for (const testResult of parsedResults.results.testResults) {
810
+ if (typeof reporter.onTestResult === "function") {
811
+ await Promise.resolve(
812
+ reporter.onTestResult(
813
+ { context: { config: globalConfig } },
814
+ testResult,
815
+ aggregatedResults
816
+ )
817
+ );
818
+ } else if (typeof reporter.onTestStart === "function") {
819
+ await Promise.resolve(reporter.onTestStart(testResult));
820
+ }
821
+ }
822
+ }
823
+
824
+ // Complete the run
825
+ if (typeof reporter.onRunComplete === "function") {
826
+ await Promise.resolve(
827
+ reporter.onRunComplete(new Set(), aggregatedResults)
828
+ );
829
+ }
830
+ }
831
+
832
+ // Exit with appropriate code
833
+ process.exit(parsedResults.results.success ? 0 : 1);