jest-roblox-assassin 1.0.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/cli.js CHANGED
@@ -1,833 +1,219 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- import { TestPathPatterns } from "@jest/pattern";
4
- import { DefaultReporter, SummaryReporter } from "@jest/reporters";
5
- import { Command } from "commander";
3
+ import chokidar from "chokidar";
6
4
  import dotenv from "dotenv";
7
- import fs from "fs";
8
5
  import path from "path";
9
- import * as rbxluau from "rbxluau";
10
- import { pathToFileURL } from "url";
11
- import { ensureCache } from "./cache.js";
6
+ import yargs from "yargs";
7
+ import { hideBin } from "yargs/helpers";
8
+ import { findPlaceFile } from "./discovery.js";
12
9
  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");
10
+ import runJestRoblox from "./runJestRoblox.js";
17
11
 
18
12
  // Load environment variables from .env file
19
13
  dotenv.config({ quiet: true });
20
14
 
21
- // Fetch CLI options and build commander program
15
+ // Fetch CLI options and build yargs instance
22
16
  const cliOptions = await getCliOptions();
23
17
 
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
- }
18
+ let yargsInstance = yargs(hideBin(process.argv))
19
+ .scriptName("jestrbx")
20
+ .positional("testPathPattern", {
21
+ describe: "test path pattern to match",
22
+ type: "string",
23
+ })
24
+ .option("place", {
25
+ describe: "path to Roblox place file",
26
+ type: "string",
27
+ })
28
+ .option("project", {
29
+ describe:
30
+ "path to Rojo project JSON file. Used to map output back to source files",
31
+ type: "string",
32
+ })
33
+ .option("tsconfig", {
34
+ describe:
35
+ "path to tsconfig.json file. Used to map output back to source files",
36
+ type: "string",
37
+ })
38
+ .option("config", {
39
+ describe: "path to Jest config file",
40
+ type: "string",
41
+ })
42
+ .option("maxWorkers", {
43
+ describe: "EXPERIMENTAL: maximum number of parallel workers to use",
44
+ type: "number",
45
+ })
46
+ .option("testLocationInResults", {
47
+ describe:
48
+ "Adds a location field to test results. Useful if you want to report the location of a test in a reporter.",
49
+ type: "boolean",
50
+ })
51
+ .option("coverage", {
52
+ describe:
53
+ "Indicates that test coverage information should be collected and reported in the output.",
54
+ type: "boolean",
55
+ alias: "collectCoverage",
56
+ })
57
+ .option("watch", {
58
+ describe: "Alias of watchAll. Watches the place file and reruns tests on changes.",
59
+ type: "boolean",
60
+ })
61
+ .option("watchAll", {
62
+ describe: "Watches the place file and reruns tests on changes.",
63
+ type: "boolean",
64
+ })
65
+ .option("useStderr", {
66
+ describe: "Divert all output to stderr.",
67
+ type: "boolean",
68
+ })
69
+ .option("outputFile", {
70
+ describe:
71
+ "Write test results to a file when the --json option is also specified. The returned JSON structure is documented in testResultsProcessor.",
72
+ type: "string",
73
+ });
43
74
 
75
+ // Add dynamically fetched CLI options
44
76
  for (const opt of cliOptions) {
45
77
  const flagName = opt.name.replace(/^--/, "");
46
78
  const isArray = opt.type.includes("array");
47
79
  const isNumber = opt.type.includes("number");
48
- const isString = opt.type.includes("string") || opt.type.includes("regex");
80
+ const isBoolean = opt.type.includes("boolean");
81
+
82
+ const description = opt.description.split("\n")[0]; // First line only
49
83
 
50
- let flags = opt.name;
51
84
  // Add short flags for common options
52
85
  const shortFlags = {
53
- verbose: "-v",
54
- testNamePattern: "-t",
86
+ verbose: "v",
87
+ testNamePattern: "t",
55
88
  };
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
89
 
78
- program.parse();
79
-
80
- const options = program.opts();
81
- const [testPathPattern] = program.args;
90
+ const optionConfig = {
91
+ describe: description,
92
+ type: isBoolean
93
+ ? "boolean"
94
+ : isArray
95
+ ? "array"
96
+ : isNumber
97
+ ? "number"
98
+ : "string",
99
+ };
82
100
 
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);
101
+ if (shortFlags[flagName]) {
102
+ optionConfig.alias = shortFlags[flagName];
98
103
  }
99
- }
100
104
 
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;
105
+ yargsInstance = yargsInstance.option(flagName, optionConfig);
129
106
  }
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
107
 
136
- const workspaceRoot = placeFile
137
- ? path.dirname(path.resolve(placeFile))
138
- : process.cwd();
108
+ const args = await yargsInstance
109
+ .help("help", "Show help message")
110
+ .alias("help", "h")
111
+ .alias("version", "v")
112
+ .strict(false).argv;
139
113
 
140
- let projectRoot = workspaceRoot;
114
+ // Extract testPathPattern from positional args
115
+ const [testPathPattern] = args._;
141
116
 
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
- };
117
+ // watch is a compat alias for watchAll in this tool
118
+ if (args.watch && !args.watchAll) {
119
+ args.watchAll = true;
120
+ }
163
121
 
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
- }
122
+ const watchMode = Boolean(args.watchAll);
123
+ const resolvedPlace = watchMode ? args.place ?? findPlaceFile() : args.place;
173
124
 
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
- }
125
+ if (watchMode && !resolvedPlace) {
126
+ console.error(
127
+ "Watch mode requires a --place file or a discoverable place in the current workspace."
128
+ );
129
+ process.exit(1);
189
130
  }
190
131
 
191
- const tsConfigPath = path.join(projectRoot, "tsconfig.json");
192
-
193
- const stripJsonComments = (text) =>
194
- text.replace(/\/\*[\s\S]*?\*\//g, "").replace(/^\s*\/\/.*$/gm, "");
132
+ const absolutePlace = resolvedPlace ? path.resolve(resolvedPlace) : undefined;
195
133
 
196
- const readJsonWithComments = (jsonPath) => {
197
- if (!fs.existsSync(jsonPath)) return undefined;
198
- const raw = fs.readFileSync(jsonPath, "utf-8");
134
+ const runOnce = async () => {
199
135
  try {
200
- return JSON.parse(stripJsonComments(raw));
136
+ return await runJestRoblox({
137
+ ...args,
138
+ place: absolutePlace ?? args.place,
139
+ testPathPattern,
140
+ });
201
141
  } catch (error) {
202
- return undefined;
142
+ console.error(error?.stack || error?.message || String(error));
143
+ return 1;
203
144
  }
204
145
  };
205
146
 
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)];
147
+ if (!watchMode) {
148
+ process.exit(await runOnce());
247
149
  }
248
150
 
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
151
+ let running = false;
152
+ let pending = false;
153
+ let lastExitCode = 0;
154
+ const DEBOUNCE_MS = 2000;
155
+ let debounceTimer = null;
156
+ let lastReason = null;
265
157
 
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
- );
158
+ const triggerRun = async (reason) => {
159
+ if (running) {
160
+ pending = true;
161
+ return;
316
162
  }
317
163
 
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
- };
164
+ running = true;
165
+ if (reason) {
166
+ console.log(`\nChange detected (${reason}). Running tests...`);
324
167
  }
325
168
 
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
- );
169
+ lastExitCode = await runOnce();
170
+ running = false;
332
171
 
333
- if (!successMatch || !resultMatch) {
334
- throw new Error(`Failed to parse output log:\n${outputLog}`);
172
+ if (pending) {
173
+ pending = false;
174
+ await triggerRun();
335
175
  }
176
+ };
336
177
 
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
- };
178
+ const scheduleRun = (reason) => {
179
+ lastReason = reason ?? lastReason;
180
+ if (debounceTimer) {
181
+ clearTimeout(debounceTimer);
703
182
  }
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,
183
+ debounceTimer = setTimeout(() => {
184
+ debounceTimer = null;
185
+ triggerRun(lastReason);
186
+ }, DEBOUNCE_MS);
723
187
  };
724
188
 
725
- const reporterConfigs = [];
189
+ console.log(
190
+ `Watching ${path.relative(process.cwd(), absolutePlace)} for changes. Press Ctrl+C to exit.`
191
+ );
726
192
 
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;
193
+ const watcher = chokidar.watch(absolutePlace, { ignoreInitial: true });
737
194
 
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
- }
195
+ watcher.on("all", (event, changedPath) => {
196
+ if (event === "change" || event === "add" || event === "unlink") {
197
+ const reason = changedPath
198
+ ? path.relative(process.cwd(), changedPath)
199
+ : event;
200
+ scheduleRun(reason);
767
201
  }
768
- } else {
769
- // Default reporters
770
- reporterConfigs.push({ Reporter: DefaultReporter, options: undefined });
771
- reporterConfigs.push({ Reporter: SummaryReporter, options: undefined });
772
- }
202
+ });
773
203
 
774
- for (const { Reporter, options: reporterOptions } of reporterConfigs) {
775
- const reporter = new Reporter(globalConfig, reporterOptions);
204
+ watcher.on("error", (error) => {
205
+ console.error(`Watcher error: ${error?.message || error}`);
206
+ });
776
207
 
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
- }
208
+ await triggerRun("initial run");
806
209
 
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
- }
210
+ const cleanup = () => {
211
+ watcher.close().catch?.(() => {});
212
+ if (debounceTimer) {
213
+ clearTimeout(debounceTimer);
822
214
  }
215
+ process.exit(lastExitCode);
216
+ };
823
217
 
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);
218
+ process.on("SIGINT", cleanup);
219
+ process.on("SIGTERM", cleanup);