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/LICENSE +21 -0
- package/README.md +64 -0
- package/package.json +36 -0
- package/src/cache.js +19 -0
- package/src/cli.js +833 -0
- package/src/docs.js +101 -0
- package/src/rewriter.js +562 -0
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);
|