jest-roblox-assassin 1.0.0 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +23 -13
- package/package.json +16 -12
- package/src/cache.js +1 -0
- package/src/cli.js +160 -774
- package/src/discovery.js +355 -0
- package/src/rewriter.js +454 -257
- package/src/runJestRoblox.js +838 -0
- package/src/sourcemap.js +243 -0
package/src/cli.js
CHANGED
|
@@ -1,833 +1,219 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
-
import
|
|
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
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
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
|
|
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
|
|
15
|
+
// Fetch CLI options and build yargs instance
|
|
22
16
|
const cliOptions = await getCliOptions();
|
|
23
17
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
.
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
.option(
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
|
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: "
|
|
54
|
-
testNamePattern: "
|
|
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
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
-
|
|
84
|
-
|
|
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
|
-
|
|
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
|
|
137
|
-
|
|
138
|
-
|
|
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
|
-
|
|
114
|
+
// Extract testPathPattern from positional args
|
|
115
|
+
const [testPathPattern] = args._;
|
|
141
116
|
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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
|
-
|
|
165
|
-
|
|
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
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
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
|
|
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
|
|
197
|
-
if (!fs.existsSync(jsonPath)) return undefined;
|
|
198
|
-
const raw = fs.readFileSync(jsonPath, "utf-8");
|
|
134
|
+
const runOnce = async () => {
|
|
199
135
|
try {
|
|
200
|
-
return
|
|
136
|
+
return await runJestRoblox({
|
|
137
|
+
...args,
|
|
138
|
+
place: absolutePlace ?? args.place,
|
|
139
|
+
testPathPattern,
|
|
140
|
+
});
|
|
201
141
|
} catch (error) {
|
|
202
|
-
|
|
142
|
+
console.error(error?.stack || error?.message || String(error));
|
|
143
|
+
return 1;
|
|
203
144
|
}
|
|
204
145
|
};
|
|
205
146
|
|
|
206
|
-
|
|
207
|
-
|
|
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
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
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
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
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
|
-
|
|
319
|
-
|
|
320
|
-
|
|
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
|
-
|
|
327
|
-
|
|
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 (
|
|
334
|
-
|
|
172
|
+
if (pending) {
|
|
173
|
+
pending = false;
|
|
174
|
+
await triggerRun();
|
|
335
175
|
}
|
|
176
|
+
};
|
|
336
177
|
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
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
|
-
|
|
705
|
-
|
|
706
|
-
|
|
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
|
-
|
|
189
|
+
console.log(
|
|
190
|
+
`Watching ${path.relative(process.cwd(), absolutePlace)} for changes. Press Ctrl+C to exit.`
|
|
191
|
+
);
|
|
726
192
|
|
|
727
|
-
|
|
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
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
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
|
-
}
|
|
769
|
-
// Default reporters
|
|
770
|
-
reporterConfigs.push({ Reporter: DefaultReporter, options: undefined });
|
|
771
|
-
reporterConfigs.push({ Reporter: SummaryReporter, options: undefined });
|
|
772
|
-
}
|
|
202
|
+
});
|
|
773
203
|
|
|
774
|
-
|
|
775
|
-
|
|
204
|
+
watcher.on("error", (error) => {
|
|
205
|
+
console.error(`Watcher error: ${error?.message || error}`);
|
|
206
|
+
});
|
|
776
207
|
|
|
777
|
-
|
|
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
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
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
|
-
|
|
825
|
-
|
|
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);
|