@isentinel/jest-roblox 0.2.7 → 0.3.1

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/dist/cli.mjs CHANGED
@@ -1,2207 +1,33 @@
1
- import { A as collectMounts, B as createOpenCloudBackend, D as formatTypecheckSummary, F as loadConfig$1, G as VALID_BACKENDS, J as isValidBackend, M as findInTree, N as pruneAncestors, O as formatBanner, P as resolveNestedProjects, R as createStudioBackend, S as formatAgentMultiProject, T as formatResult, W as ROOT_ONLY_KEYS, X as LuauScriptError, Y as hashBuffer, _ as resolveTsconfigDirectories, a as formatAnnotations, c as visitBlock, d as buildProjectJob, f as execute, g as processProjectResult, h as loadCoverageManifest, i as runTypecheck, j as collectPaths, k as combineSourceMappers, m as formatExecuteOutput, n as parseGameOutput, o as formatJobSummary, p as executeBackend, r as writeGameOutput, s as resolveGitHubActionsOptions, t as formatGameOutputNotice, v as rojoProjectSchema, w as formatMultiProjectResult, x as writeJsonFile, y as findFormatterOptions } from "./game-output-CCPIQMWm.mjs";
2
- import { createRequire } from "node:module";
3
- import { type } from "arktype";
4
- import assert from "node:assert";
5
- import * as fs$1 from "node:fs";
6
- import fs from "node:fs";
7
- import * as path$1 from "node:path";
8
- import path from "node:path";
1
+ import { A as mergeCliWithConfig, D as LuauScriptError, E as formatBanner, H as ConfigError, R as VALID_BACKENDS, U as version, V as isValidBackend, f as outputMultiResult, j as loadConfig, p as outputSingleResult, t as runJestRoblox, y as parseGameOutput } from "./run-Cl5gYSQr.mjs";
2
+ import { OpenCloudError } from "@bedrock-rbx/ocale";
9
3
  import process from "node:process";
10
4
  import { parseArgs as parseArgs$1 } from "node:util";
11
- import { isAgent } from "std-env";
12
5
  import color from "tinyrainbow";
13
- import { WebSocketServer } from "ws";
14
- import * as os from "node:os";
15
- import { Buffer } from "node:buffer";
16
- import { loadConfig } from "c12";
17
- import { getTsconfig } from "get-tsconfig";
18
- import { TraceMap, originalPositionFor } from "@jridgewell/trace-mapping";
19
- import * as cp from "node:child_process";
20
- import { RojoResolver } from "@roblox-ts/rojo-resolver";
21
- import picomatch from "picomatch";
22
- import istanbulCoverage from "istanbul-lib-coverage";
23
- import istanbulReport from "istanbul-lib-report";
24
- import istanbulReports from "istanbul-reports";
25
- //#region package.json
26
- var version = "0.2.7";
27
- //#endregion
28
- //#region src/backends/auto.ts
29
- var StudioWithFallback = class {
30
- studio;
31
- kind = "studio";
32
- constructor(studio) {
33
- this.studio = studio;
34
- }
35
- async close() {
36
- await this.studio.close?.();
37
- }
38
- async runTests(options) {
39
- try {
40
- return await this.studio.runTests(options);
41
- } catch (err) {
42
- if (isStudioBusyError(err)) {
43
- process.stderr.write("Studio busy, falling back to Open Cloud\n");
44
- return createOpenCloudBackend().runTests(options);
45
- }
46
- throw err;
47
- }
48
- }
49
- };
50
- function isStudioBusyError(error) {
51
- if (error instanceof LuauScriptError) return /previous call to start play session/i.test(error.message);
52
- return typeof error === "object" && error !== null && "code" in error && error.code === "EADDRINUSE";
53
- }
54
- async function probeStudioPlugin(port, timeoutMs, createServer = (wsPort) => {
55
- return new WebSocketServer({ port: wsPort });
56
- }) {
57
- return new Promise((resolve) => {
58
- const wss = createServer(port);
59
- const timer = setTimeout(() => {
60
- wss.close();
61
- resolve({ detected: false });
62
- }, timeoutMs);
63
- wss.on("connection", (ws) => {
64
- clearTimeout(timer);
65
- resolve({
66
- detected: true,
67
- server: wss,
68
- socket: ws
69
- });
70
- });
71
- wss.on("error", () => {
72
- clearTimeout(timer);
73
- wss.close();
74
- resolve({ detected: false });
75
- });
76
- });
77
- }
78
- async function resolveBackend(config, probe = probeStudioPlugin) {
79
- if (config.backend === "studio") return createStudioBackend({
80
- port: config.port,
81
- timeout: config.timeout
82
- });
83
- if (config.backend === "open-cloud") return createOpenCloudBackend();
84
- const probeResult = await probe(config.port, 500);
85
- if (probeResult.detected) {
86
- process.stderr.write("Backend: studio (plugin detected)\n");
87
- const studio = createStudioBackend({
88
- port: config.port,
89
- preConnected: {
90
- server: probeResult.server,
91
- socket: probeResult.socket
92
- },
93
- timeout: config.timeout
94
- });
95
- if (hasOpenCloudCredentials()) return new StudioWithFallback(studio);
96
- return studio;
97
- }
98
- if (hasOpenCloudCredentials()) {
99
- process.stderr.write("Backend: open-cloud (no plugin, using Open Cloud)\n");
100
- return createOpenCloudBackend();
101
- }
102
- throw new Error("No backend available: Studio plugin not detected and Open Cloud env vars (ROBLOX_OPEN_CLOUD_API_KEY, ROBLOX_UNIVERSE_ID, ROBLOX_PLACE_ID) are missing");
103
- }
104
- function hasOpenCloudCredentials() {
105
- return process.env["ROBLOX_OPEN_CLOUD_API_KEY"] !== void 0 && process.env["ROBLOX_UNIVERSE_ID"] !== void 0 && process.env["ROBLOX_PLACE_ID"] !== void 0;
106
- }
107
- //#endregion
108
- //#region src/config/errors.ts
109
- var ConfigError = class extends Error {
110
- hint;
111
- constructor(message, hint) {
112
- super(message);
113
- this.hint = hint;
114
- }
115
- };
116
- //#endregion
117
- //#region src/utils/extensions.ts
118
- function stripTsExtension(pattern) {
119
- return pattern.replace(/\.(tsx?|luau?)$/, "");
120
- }
121
- //#endregion
122
- //#region src/luau/eval-literals.ts
123
- /**
124
- * Evaluate the first return expression in a Lute-stripped AST root block,
125
- * supporting only literal values (string, boolean, number, nil, table, cast).
126
- *
127
- * Accepts `unknown` and narrows safely — no type casts on JSON.parse needed.
128
- */
129
- function evalLuauReturnLiterals(root) {
130
- if (!isObject(root) || !Array.isArray(root["statements"])) throw new Error("Config file has no return statement");
131
- const returnStat = root["statements"].find((stat) => isObject(stat) && stat["tag"] === "return");
132
- if (!isObject(returnStat) || !Array.isArray(returnStat["expressions"])) throw new Error("Config file has no return statement");
133
- const first = returnStat["expressions"][0];
134
- if (!isObject(first) || !("node" in first)) throw new Error("Return statement has no expressions");
135
- return evalExpr(first["node"]);
136
- }
137
- function isObject(value) {
138
- return typeof value === "object" && value !== null;
139
- }
140
- function evalExpr(node) {
141
- if (!isObject(node)) return;
142
- let current = node;
143
- while (current["tag"] === "cast" && isObject(current["operand"])) current = current["operand"];
144
- const { tag } = current;
145
- if (tag === "boolean" || tag === "number") return current["value"];
146
- if (tag === "string") return current["text"];
147
- if (tag === "table" && Array.isArray(current["entries"])) return evalTable(current["entries"]);
148
- }
149
- function evalTable(entries) {
150
- if (entries.length === 0) return {};
151
- const first = entries[0];
152
- if (isObject(first) && first["kind"] === "list") return entries.map((entry) => isObject(entry) ? evalExpr(entry["value"]) : void 0);
153
- const result = {};
154
- for (const entry of entries) {
155
- if (!isObject(entry) || entry["kind"] !== "record") continue;
156
- const { key, value } = entry;
157
- if (isObject(key) && typeof key["text"] === "string") result[key["text"]] = evalExpr(value);
158
- }
159
- return result;
160
- }
161
- //#endregion
162
- //#region src/luau/parse-ast.luau
163
- var parse_ast_default = "local fs = require(\"@std/fs\")\nlocal json = require(\"@std/json\")\nlocal process = require(\"@std/process\")\nlocal syntax = require(\"@std/syntax\")\n\nlocal rawArgs = process.args\nlocal userArgs: { string } = {}\nlocal pastSeparator = false\n\nfor _, arg in rawArgs do\n if pastSeparator then\n table.insert(userArgs, arg)\n elseif arg == \"--\" then\n pastSeparator = true\n end\nend\n\nlocal luauRoot = userArgs[1]\nif not luauRoot then\n error(\"Usage: lute run parse-ast.luau -- <file.luau | luau-root> [output-dir]\")\nend\n\nluauRoot = string.gsub(luauRoot, \"\\\\\", \"/\")\n\n-- Fields to keep per AST tag (beyond tag/kind/location which are always kept).\n-- Tags shared by stat/expr variants are merged — nil fields are harmless.\nlocal KEEP: { [string]: { string } } = {\n assign = { \"values\", \"variables\" },\n binary = { \"lhsOperand\", \"rhsOperand\" },\n block = { \"statements\" },\n boolean = { \"value\" },\n call = { \"arguments\", \"func\" },\n cast = { \"operand\" },\n compoundassign = { \"value\", \"variable\" },\n conditional = { \"condition\", \"thenBlock\", \"elseifs\", \"elseBlock\", \"thenExpr\", \"elseExpr\" },\n [\"do\"] = { \"body\" },\n expression = { \"expression\" },\n [\"for\"] = { \"body\", \"from\", \"to\", \"step\" },\n forin = { \"body\", \"values\" },\n [\"function\"] = { \"body\", \"name\", \"func\" },\n global = { \"name\" },\n group = { \"expression\" },\n index = { \"expression\", \"index\" },\n indexname = { \"expression\", \"accessor\", \"index\" },\n instantiate = { \"expr\" },\n interpolatedstring = { \"expressions\" },\n [\"local\"] = { \"values\", \"variables\" },\n localfunction = { \"name\", \"func\" },\n number = { \"value\" },\n [\"repeat\"] = { \"body\", \"condition\" },\n [\"return\"] = { \"expressions\" },\n string = { \"text\" },\n table = { \"entries\" },\n unary = { \"operand\" },\n [\"while\"] = { \"body\", \"condition\" },\n}\n\nlocal function strip(value: any): any\n if type(value) ~= \"table\" then\n return value\n end\n\n -- LuauSpan — has beginLine, no tag\n if value.beginLine ~= nil then\n return value\n end\n\n -- Token — has text but no tag, reduce to {text}\n if value.text ~= nil and value.tag == nil then\n return { text = value.text }\n end\n\n -- AST node — has tag, keep only allowlisted fields\n if value.tag ~= nil then\n local result = { tag = value.tag, kind = value.kind, location = value.location }\n local fields = KEEP[value.tag :: string]\n if fields then\n for _, field in fields do\n if value[field] ~= nil then\n result[field] = strip(value[field])\n end\n end\n end\n\n return result\n end\n\n -- Other tables (arrays, Pairs, ElseIf structs) — recurse all fields\n local result: any = {}\n for k, v in value do\n result[k] = strip(v)\n end\n\n return result\nend\n\n-- Single-file mode: parse one file, print stripped AST to stdout\nif string.sub(luauRoot, -5) == \".luau\" or string.sub(luauRoot, -4) == \".lua\" then\n local source = fs.readFileToString(luauRoot)\n local parseResult = syntax.parse(source)\n print(json.serialize(strip(parseResult.root)))\n return\nend\n\nlocal outputDir = userArgs[2]\nif not outputDir then\n error(\"Usage: lute run parse-ast.luau -- <luau-root> <output-dir>\")\nend\n\noutputDir = string.gsub(outputDir, \"\\\\\", \"/\")\n\n-- Discover .luau files recursively, skipping node_modules, dot dirs, spec/test files\nlocal function discoverFiles(directory: string, relativeTo: string, results: { string })\n local entries = fs.listDirectory(directory)\n for _, entry in entries do\n local fullPath = directory .. \"/\" .. entry.name\n if entry.type == \"dir\" then\n if entry.name == \"node_modules\" or entry.name == \".jest-roblox-coverage\" then\n continue\n end\n\n if string.sub(entry.name, 1, 1) == \".\" then\n continue\n end\n\n discoverFiles(fullPath, relativeTo, results)\n elseif\n entry.type == \"file\"\n and (string.sub(entry.name, -5) == \".luau\" or string.sub(entry.name, -4) == \".lua\")\n then\n if\n string.sub(entry.name, -10) == \".spec.luau\"\n or string.sub(entry.name, -10) == \".test.luau\"\n or string.sub(entry.name, -9) == \".spec.lua\"\n or string.sub(entry.name, -9) == \".test.lua\"\n or string.sub(entry.name, -10) == \".snap.luau\"\n or string.sub(entry.name, -9) == \".snap.lua\"\n then\n continue\n end\n\n -- Compute relative path\n local relative = string.sub(fullPath, #relativeTo + 2)\n table.insert(results, relative)\n end\n end\nend\n\nlocal function dirname(filepath: string): string\n local pos = string.find(filepath, \"/[^/]*$\")\n if pos then\n return string.sub(filepath, 1, pos - 1)\n end\n\n return \"\"\nend\n\n-- Optional 3rd arg: path to skip list JSON file.\n-- Skipped files are still included in the file list but not parsed.\nlocal skipListPath = userArgs[3]\nlocal skipSet: { [string]: boolean } = {}\nif skipListPath then\n local skipJson = fs.readFileToString(skipListPath)\n local skipList = json.deserialize(skipJson) :: { string }\n for _, entry in skipList do\n skipSet[entry] = true\n end\nend\n\nlocal files: { string } = {}\ndiscoverFiles(luauRoot, luauRoot, files)\n\n-- Parse, strip, and write per-file AST JSON\nfor _, relativePath in files do\n if skipSet[relativePath] then\n continue\n end\n\n local fullPath = luauRoot .. \"/\" .. relativePath\n local source = fs.readFileToString(fullPath)\n local parseResult = syntax.parse(source)\n local stripped = strip(parseResult.root)\n\n local outPath = outputDir .. \"/\" .. relativePath .. \".json\"\n local dir = dirname(outPath)\n if dir ~= \"\" then\n fs.createDirectory(dir, { makeParents = true })\n end\n\n fs.writeStringToFile(outPath, json.serialize(stripped))\nend\n\n-- Print file list to stdout (tiny — just paths)\nprint(json.serialize(files :: json.Array))\n";
164
- //#endregion
165
- //#region src/config/luau-config-loader.ts
166
- let cachedTemporaryDirectory$1;
167
- /**
168
- * Parse a .luau config file via Lute and evaluate its return expression.
169
- */
170
- function loadLuauConfig(filePath) {
171
- const temporaryDirectory = getTemporaryDirectory$1();
172
- const scriptPath = path$1.join(temporaryDirectory, "parse-ast.luau");
173
- fs$1.writeFileSync(scriptPath, parse_ast_default);
174
- let stdout;
175
- try {
176
- stdout = cp.execFileSync("lute", [
177
- "run",
178
- scriptPath,
179
- "--",
180
- path$1.resolve(filePath)
181
- ], {
182
- encoding: "utf-8",
183
- maxBuffer: 1024 * 1024
184
- });
185
- } catch (err) {
186
- if (err instanceof Error && "code" in err && err.code === "ENOENT") throw new Error("lute is required to load .luau config files but was not found on PATH");
187
- throw new Error(`Failed to evaluate Luau config ${filePath}`, { cause: err });
188
- }
189
- let ast;
190
- try {
191
- ast = JSON.parse(stdout);
192
- } catch (err) {
193
- throw new Error(`Failed to parse AST JSON from Luau config ${filePath}`, { cause: err });
194
- }
195
- const result = evalLuauReturnLiterals(ast);
196
- if (typeof result !== "object" || result === null) throw new Error(`Luau config ${filePath} must return a table`);
197
- return result;
198
- }
199
- /**
200
- * Check if `<cwd>/<directoryOrFile>/jest.config.luau` exists. Returns the
201
- * resolved path if found, undefined otherwise.
202
- */
203
- function findLuauConfigFile(directoryOrFile, cwd) {
204
- const resolved = path$1.resolve(cwd, directoryOrFile, "jest.config.luau");
205
- if (fs$1.existsSync(resolved)) return resolved;
206
- }
207
- function getTemporaryDirectory$1() {
208
- if (cachedTemporaryDirectory$1 !== void 0 && fs$1.existsSync(cachedTemporaryDirectory$1)) return cachedTemporaryDirectory$1;
209
- cachedTemporaryDirectory$1 = fs$1.mkdtempSync(path$1.join(os.tmpdir(), "jest-roblox-luau-config-"));
210
- return cachedTemporaryDirectory$1;
211
- }
212
- //#endregion
213
- //#region src/config/projects.ts
214
- function extractStaticRoot(pattern) {
215
- const globChars = new Set([
216
- "*",
217
- "?",
218
- "[",
219
- "{"
220
- ]);
221
- let firstGlobIndex = -1;
222
- for (const [index, char] of [...pattern].entries()) if (globChars.has(char)) {
223
- firstGlobIndex = index;
224
- break;
225
- }
226
- if (firstGlobIndex === -1) {
227
- const directory = path$1.posix.dirname(pattern);
228
- return {
229
- glob: path$1.posix.basename(pattern),
230
- root: directory
231
- };
232
- }
233
- const lastSlash = pattern.slice(0, firstGlobIndex).lastIndexOf("/");
234
- if (lastSlash === -1) throw new Error("Include pattern must have a static directory prefix");
235
- return {
236
- glob: pattern.slice(lastSlash + 1),
237
- root: pattern.slice(0, lastSlash)
238
- };
239
- }
240
- function mapFsRootToDataModel(outDirectory, rojoTree) {
241
- const normalized = outDirectory.replace(/\/$/, "");
242
- const result = findInTree(rojoTree, normalized, "");
243
- if (result === void 0) {
244
- const available = [];
245
- collectPaths(rojoTree, available);
246
- let message = `No Rojo tree mapping found for path: ${normalized}`;
247
- if (available.length > 0) message += `\n\nAvailable $path entries: ${available.join(", ")}`;
248
- const hint = normalized.startsWith("src/") ? "Path starts with \"src/\" — if using roblox-ts, set \"outDir\" in your project config to the compiled output directory (e.g. \"out/client\")" : void 0;
249
- throw new ConfigError(message, hint);
250
- }
251
- return result;
252
- }
253
- function extractProjectRoots(include) {
254
- const rootMap = /* @__PURE__ */ new Map();
255
- for (const pattern of include) {
256
- const { glob, root } = extractStaticRoot(pattern);
257
- const stripped = stripTsExtension(glob);
258
- const qualified = stripped.includes("/") ? stripped : `**/${stripped}`;
259
- let patterns = rootMap.get(root);
260
- if (patterns === void 0) {
261
- patterns = [];
262
- rootMap.set(root, patterns);
263
- }
264
- patterns.push(qualified);
265
- }
266
- return [...rootMap.entries()].map(([root, testMatch]) => ({
267
- root,
268
- testMatch
269
- }));
270
- }
271
- function applyProjectRoot(include, projectRoot) {
272
- if (projectRoot === void 0) return include;
273
- return include.map((pattern) => path$1.posix.join(projectRoot, pattern));
274
- }
275
- function createFsClassifier(rootDirectory) {
276
- return function classify(fsPath) {
277
- const absolute = path$1.isAbsolute(fsPath) ? fsPath : path$1.resolve(rootDirectory, fsPath);
278
- const stat = fs.statSync(absolute, { throwIfNoEntry: false });
279
- if (stat === void 0) return "missing";
280
- return stat.isDirectory() ? "directory" : "file";
281
- };
282
- }
283
- function validateProjects(projects) {
284
- const names = /* @__PURE__ */ new Set();
285
- for (const project of projects) {
286
- const name = displayNameOf(project);
287
- if (name === "") throw new Error("Project must have a non-empty displayName");
288
- if (names.has(name)) throw new Error(`Duplicate project displayName: ${name}`);
289
- names.add(name);
290
- if (project.include.length === 0) throw new Error(`Project "${name}" must have at least one include pattern`);
291
- }
292
- }
293
- const PROJECT_ONLY_KEYS = new Set([
294
- "displayName",
295
- "exclude",
296
- "include",
297
- "outDir",
298
- "root"
299
- ]);
300
- function resolveProjectConfig(project, rootConfig, rojoTree, classify) {
301
- const rootPrefixedInclude = applyProjectRoot(project.include, project.root);
302
- const roots = extractProjectRoots(rootPrefixedInclude);
303
- const testMatch = roots.flatMap((entry) => entry.testMatch);
304
- const rojoMounts = resolveMounts(project, roots, rojoTree, classify);
305
- const projects = rojoMounts.map((mount) => mount.dataModelPath);
306
- const singleMount = rojoMounts.length === 1 ? rojoMounts[0] : void 0;
307
- const config = mergeProjectConfig(rootConfig, project);
308
- const displayName = displayNameOf(project);
309
- return {
310
- config,
311
- displayColor: typeof project.displayName === "string" ? void 0 : project.displayName.color,
312
- displayName,
313
- include: rootPrefixedInclude,
314
- outDir: singleMount?.fsPath,
315
- projects,
316
- rojoMounts,
317
- testMatch
318
- };
319
- }
320
- async function loadProjectConfigFile(filePath, cwd) {
321
- const luauConfigPath = findLuauConfigFile(filePath, cwd);
322
- if (luauConfigPath !== void 0) return buildProjectConfigFromLuau(luauConfigPath, filePath);
323
- let result;
324
- try {
325
- result = await loadConfig({
326
- name: "jest-project",
327
- configFile: filePath,
328
- configFileRequired: true,
329
- cwd,
330
- dotenv: false,
331
- globalRc: false,
332
- omit$Keys: true,
333
- packageJson: false,
334
- rcFile: false
6
+ //#region src/utils/error-chain.ts
7
+ const MAX_DEPTH = 5;
8
+ function walkErrorChain(err) {
9
+ const entries = [];
10
+ let current = err;
11
+ while (current instanceof Error && entries.length < MAX_DEPTH) {
12
+ entries.push({
13
+ name: current.constructor.name,
14
+ code: readStringProperty(current, "code"),
15
+ errno: readStringProperty(current, "errno"),
16
+ message: current.message,
17
+ syscall: readStringProperty(current, "syscall")
335
18
  });
336
- } catch (err) {
337
- const message = err instanceof Error ? err.message : String(err);
338
- throw new Error(`Failed to load project config file ${filePath}: ${message}`, { cause: err });
19
+ current = current.cause;
339
20
  }
340
- const { config } = result;
341
- if ((typeof config.displayName === "string" ? config.displayName : config.displayName.name) === "") throw new Error(`Project config file "${filePath}" must have a displayName`);
342
- deriveIncludeFromTestMatch(config, path$1.posix.dirname(filePath), resolveTsconfigDirectories(cwd));
343
- return config;
21
+ return entries;
344
22
  }
345
- async function resolveAllProjects(entries, rootConfig, rojoTree, cwd) {
346
- const projects = [];
347
- for (const entry of entries) if (typeof entry === "string") {
348
- const loaded = await loadProjectConfigFile(entry, cwd);
349
- projects.push(loaded);
350
- } else projects.push(entry.test);
351
- validateProjects(projects);
352
- const classify = createFsClassifier(cwd);
353
- return projects.map((project) => resolveProjectConfig(project, rootConfig, rojoTree, classify));
354
- }
355
- function displayNameOf(project) {
356
- return typeof project.displayName === "string" ? project.displayName : project.displayName.name;
357
- }
358
- function mergeProjectConfig(rootConfig, project) {
359
- const merged = { ...rootConfig };
360
- for (const [key, value] of Object.entries(project)) if (!PROJECT_ONLY_KEYS.has(key) && value !== void 0) merged[key] = value;
361
- return merged;
362
- }
363
- function dedupeMounts(mounts) {
364
- const seen = /* @__PURE__ */ new Set();
365
- const result = [];
366
- for (const mount of mounts) if (!seen.has(mount.dataModelPath)) {
367
- seen.add(mount.dataModelPath);
368
- result.push(mount);
369
- }
370
- return result;
371
- }
372
- function joinProjectRoot(relativePath, projectRoot) {
373
- return projectRoot !== void 0 ? path$1.posix.join(projectRoot, relativePath) : relativePath;
374
- }
375
- function pruneAncestorMounts(mounts) {
376
- const dataModelPaths = mounts.map((mount) => mount.dataModelPath);
377
- const surviving = new Set(pruneAncestors(dataModelPaths));
378
- return mounts.filter((mount) => surviving.has(mount.dataModelPath));
379
- }
380
- function unmappableRootError(project, root, rojoTree) {
381
- const name = displayNameOf(project);
382
- const available = [];
383
- collectPaths(rojoTree, available);
384
- let message = `Project "${name}": include root "${root}" did not match any Rojo $path entry or subdirectory.`;
385
- if (available.length > 0) message += `\n\nAvailable $path entries: ${available.join(", ")}`;
386
- const hint = root.startsWith("src/") ? "Path starts with \"src/\" — if using roblox-ts, set \"outDir\" in your project config to the compiled output directory (e.g. \"out/client\")" : void 0;
387
- return new ConfigError(message, hint);
388
- }
389
- function filterMountsForRoot(allMounts, root) {
390
- return allMounts.filter((mount) => mount.fsPath === root || mount.fsPath.startsWith(`${root}/`));
391
- }
392
- function resolveMounts(project, roots, rojoTree, classify) {
393
- if (project.outDir !== void 0) {
394
- const resolvedOutDirectory = joinProjectRoot(project.outDir, project.root);
395
- return [{
396
- dataModelPath: mapFsRootToDataModel(resolvedOutDirectory, rojoTree),
397
- fsPath: resolvedOutDirectory
398
- }];
399
- }
400
- let collectedMounts;
401
- const allMounts = [];
402
- for (const { root } of roots) {
403
- const exact = findInTree(rojoTree, root, "");
404
- if (exact !== void 0) {
405
- allMounts.push({
406
- dataModelPath: exact,
407
- fsPath: root
408
- });
409
- continue;
410
- }
411
- collectedMounts ??= collectMounts(rojoTree, "", classify);
412
- const expanded = filterMountsForRoot(collectedMounts, root);
413
- if (expanded.length === 0) throw unmappableRootError(project, root, rojoTree);
414
- allMounts.push(...expanded);
415
- }
416
- return pruneAncestorMounts(dedupeMounts(allMounts));
417
- }
418
- function copyLuauOptionalFields(raw, config) {
419
- const record = config;
420
- for (const key of LUAU_BOOLEAN_KEYS) if (typeof raw[key] === "boolean") record[key] = raw[key];
421
- for (const key of LUAU_NUMBER_KEYS) if (typeof raw[key] === "number") record[key] = raw[key];
422
- for (const key of LUAU_STRING_KEYS) if (typeof raw[key] === "string") record[key] = raw[key];
423
- for (const key of LUAU_STRING_ARRAY_KEYS) if (Array.isArray(raw[key])) record[key] = raw[key];
424
- }
425
- function buildProjectConfigFromLuau(luauConfigPath, directoryPath) {
426
- const raw = loadLuauConfig(luauConfigPath);
427
- const { displayName } = raw;
428
- if (typeof displayName !== "string" || displayName === "") throw new Error(`Luau config file "${luauConfigPath}" must have a displayName string`);
429
- const testMatch = Array.isArray(raw["testMatch"]) ? raw["testMatch"] : void 0;
430
- const config = {
431
- displayName,
432
- include: testMatch !== void 0 ? testMatch.map((pattern) => path$1.posix.join(directoryPath, `${pattern}.luau`)) : [path$1.posix.join(directoryPath, "**/*.spec.luau")]
433
- };
434
- if (testMatch !== void 0) config.testMatch = testMatch;
435
- copyLuauOptionalFields(raw, config);
436
- return config;
437
- }
438
- /**
439
- * When a project config provides `testMatch` but not `include`, derive
440
- * `include` by appending `.ts` and `.tsx` extensions. This lets users
441
- * write project configs with the standard Jest `testMatch` field without
442
- * needing the CLI-specific `include`.
443
- */
444
- function deriveIncludeFromTestMatch(config, configDirectory, tsconfig) {
445
- const raw = config;
446
- if (raw["include"] !== void 0) return;
447
- if (!Array.isArray(raw["testMatch"])) return;
448
- config.include = raw["testMatch"].flatMap((pattern) => {
449
- return (/\.(tsx?|luau?)$/.test(pattern) ? [pattern] : [`${pattern}.ts`, `${pattern}.tsx`]).map((extension) => path$1.posix.join(configDirectory, extension));
450
- });
451
- const { outDir, rootDir } = tsconfig;
452
- if (raw["outDir"] === void 0 && rootDir !== void 0 && outDir !== void 0) {
453
- const rootPrefix = `${rootDir}/`;
454
- if (configDirectory.startsWith(rootPrefix)) config.outDir = `${outDir}/${configDirectory.slice(rootPrefix.length)}`;
455
- }
456
- }
457
- const LUAU_BOOLEAN_KEYS = [
458
- "automock",
459
- "clearMocks",
460
- "injectGlobals",
461
- "mockDataModel",
462
- "resetMocks",
463
- "resetModules",
464
- "restoreMocks"
465
- ];
466
- const LUAU_NUMBER_KEYS = ["slowTestThreshold", "testTimeout"];
467
- const LUAU_STRING_KEYS = ["testEnvironment"];
468
- const LUAU_STRING_ARRAY_KEYS = ["setupFiles", "setupFilesAfterEnv"];
469
- //#endregion
470
- //#region src/config/setup-resolver.ts
471
- const PROBE_EXTENSIONS = [
472
- ".ts",
473
- ".tsx",
474
- ".lua",
475
- ".luau"
476
- ];
477
- function createSetupResolver(options) {
478
- const { configDirectory, resolveModule, rojoConfigPath } = options;
479
- const resolve = resolveModule ?? createRequire(path$1.join(configDirectory, "noop.js")).resolve;
480
- const rojoResolver = RojoResolver.fromPath(rojoConfigPath);
481
- return (input) => {
482
- let absolutePath;
483
- if (isRelativePath(input)) absolutePath = path$1.resolve(configDirectory, input);
484
- else {
485
- resolvePackageSpecifier(resolve, input);
486
- absolutePath = path$1.resolve(configDirectory, "node_modules", input);
487
- }
488
- const rbxPath = rojoResolver.getRbxPathFromFilePath(absolutePath);
489
- if (rbxPath === void 0) throw new Error(`No matching path found in rojo project tree for "${input}" (resolved to: ${absolutePath})`);
490
- return rbxPath.join("/");
491
- };
492
- }
493
- function isRelativePath(input) {
494
- return input.startsWith("./") || input.startsWith("../");
495
- }
496
- function resolvePackageSpecifier(resolve, input) {
497
- try {
498
- resolve(input);
499
- return;
500
- } catch {}
501
- for (const extension of PROBE_EXTENSIONS) try {
502
- resolve(`${input}${extension}`);
503
- return;
504
- } catch {}
505
- throw new Error(`Could not resolve module "${input}". Ensure the package is installed.`);
506
- }
507
- //#endregion
508
- //#region src/config/stubs.ts
509
- const HEADER = "-- Auto-generated by jest-roblox (do not edit)\n";
510
- const STUB_FILENAME = "jest.config.lua";
511
- const USER_LUAU_FILENAME = "jest.config.luau";
512
- const SKIP_FIELDS = new Set(["exclude", "include"]);
513
- function serializeToLuau(config) {
514
- let output = `${HEADER}return {\n`;
515
- for (const [key, value] of Object.entries(config)) {
516
- if (SKIP_FIELDS.has(key)) continue;
517
- if (value === void 0) continue;
518
- let serialized;
519
- if (key === "testMatch" && Array.isArray(value)) serialized = serializeLuauValue(value.map((pattern) => stripTsExtension(pattern)), " ");
520
- else serialized = serializeLuauValue(value, " ");
521
- output += `\t${key} = ${serialized},\n`;
522
- }
523
- output += "}\n";
524
- return output;
525
- }
526
- function generateProjectConfigs(projects) {
527
- for (const project of projects) {
528
- const directory = path.dirname(project.outputPath);
529
- if (hasUserAuthoredConfig(directory)) continue;
530
- const content = serializeToLuau(project.config);
531
- fs.mkdirSync(directory, { recursive: true });
532
- fs.writeFileSync(project.outputPath, content, "utf8");
533
- }
534
- }
535
- /**
536
- * Refuse to let any downstream write land outside `rootDirectory`. Mount
537
- * fsPaths originate from the rojo tree and `resolveNestedProjects` can emit
538
- * values with `..` segments (nested projects relocate paths); combined with
539
- * `path.resolve` on Windows, a permissive fsPath could send stub writes to
540
- * `D:\outside\...`. Guard at every write-site.
541
- */
542
- function assertMountContained(project, fsPath, rootDirectory) {
543
- if (path.isAbsolute(fsPath)) throw new Error(`Project "${project.displayName}" mount fsPath must be relative, got: ${fsPath}`);
544
- const rootResolved = path.resolve(rootDirectory);
545
- const mountResolved = path.resolve(rootDirectory, fsPath);
546
- if (mountResolved !== rootResolved && !mountResolved.startsWith(rootResolved + path.sep)) throw new Error(`Project "${project.displayName}" mount fsPath escapes root directory: ${fsPath}`);
547
- }
548
- /**
549
- * For a multi-mount project, the stubs-per-mount rule requires that a
550
- * user-authored config at any tracked FS path must appear at every tracked
551
- * FS path (or none). Throws `ConfigError` when the project has partial
552
- * coverage — some mounts have a user config and others don't.
553
- */
554
- function assertStubCollisionRule(project, rootDirectory) {
555
- const withUser = [];
556
- const withoutUser = [];
557
- for (const mount of project.rojoMounts) {
558
- assertMountContained(project, mount.fsPath, rootDirectory);
559
- if (hasUserAuthoredConfig(path.resolve(rootDirectory, mount.fsPath))) withUser.push(mount.fsPath);
560
- else withoutUser.push(mount.fsPath);
561
- }
562
- if (withUser.length === 0 || withoutUser.length === 0) return;
563
- const withUserList = withUser.join(", ");
564
- const withoutUserList = withoutUser.join(", ");
565
- throw new ConfigError(`Project "${project.displayName}": user-authored jest config present at some mounts but not others.\n\nWith user config: ${withUserList}\nWithout user config: ${withoutUserList}\n\nFor multi-mount projects, either every tracked mount must have a user config, or none.`);
566
- }
567
- function syncStubsToShadowDirectory(projects, rootDirectory, shadowDirectory) {
568
- let changed = false;
569
- const expectedPaths = /* @__PURE__ */ new Set();
570
- for (const project of projects) for (const mount of project.rojoMounts) {
571
- const result = syncMountStub(project, mount.fsPath, rootDirectory, shadowDirectory);
572
- if (result.targetPath !== void 0) expectedPaths.add(result.targetPath);
573
- changed ||= result.changed;
574
- }
575
- for (const existing of findShadowStubs(shadowDirectory)) if (!expectedPaths.has(existing)) {
576
- fs.unlinkSync(existing);
577
- removeEmptyParents(path.dirname(existing), shadowDirectory);
578
- changed = true;
579
- }
580
- return changed;
581
- }
582
- function escapeString(value) {
583
- return value.replace(/\\/g, "\\\\").replace(/"/g, "\\\"").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t");
584
- }
585
- function serializeLuauValue(value, indent) {
586
- if (typeof value === "string") return `"${escapeString(value)}"`;
587
- if (typeof value === "boolean") return value ? "true" : "false";
588
- if (typeof value === "number") return String(value);
589
- if (Array.isArray(value)) return `{ ${value.map((item) => serializeLuauValue(item, indent)).join(", ")} }`;
590
- if (typeof value === "object" && value !== null) {
591
- const nextIndent = `${indent}\t`;
592
- return `{ ${Object.entries(value).filter(([, value_]) => value_ !== void 0).map(([key, value_]) => `${key} = ${serializeLuauValue(value_, nextIndent)}`).join(", ")} }`;
593
- }
23
+ function readStringProperty(err, key) {
24
+ const value = Reflect.get(err, key);
25
+ if (value === void 0 || value === null) return;
594
26
  return String(value);
595
27
  }
596
- function hasUserAuthoredConfig(directoryPath) {
597
- const luauPath = path.join(directoryPath, USER_LUAU_FILENAME);
598
- if (fs.existsSync(luauPath)) return true;
599
- const luaPath = path.join(directoryPath, STUB_FILENAME);
600
- if (!fs.existsSync(luaPath)) return false;
601
- return !fs.readFileSync(luaPath, "utf8").startsWith(HEADER);
602
- }
603
- function syncMountStub(project, fsPath, rootDirectory, shadowDirectory) {
604
- assertMountContained(project, fsPath, rootDirectory);
605
- const sourcePath = path.resolve(rootDirectory, fsPath, STUB_FILENAME);
606
- if (!fs.existsSync(sourcePath)) return {
607
- changed: false,
608
- targetPath: void 0
609
- };
610
- const targetPath = path.resolve(shadowDirectory, fsPath, STUB_FILENAME);
611
- const sourceContent = fs.readFileSync(sourcePath);
612
- if (fs.existsSync(targetPath)) {
613
- const targetContent = fs.readFileSync(targetPath);
614
- if (Buffer.compare(sourceContent, targetContent) === 0) return {
615
- changed: false,
616
- targetPath
617
- };
618
- }
619
- fs.mkdirSync(path.dirname(targetPath), { recursive: true });
620
- fs.copyFileSync(sourcePath, targetPath);
621
- return {
622
- changed: true,
623
- targetPath
624
- };
625
- }
626
- function findShadowStubs(directory) {
627
- const results = [];
628
- if (!fs.existsSync(directory)) return results;
629
- const entries = fs.readdirSync(directory, { withFileTypes: true });
630
- for (const entry of entries) {
631
- const fullPath = path.resolve(directory, entry.name);
632
- if (entry.isDirectory()) results.push(...findShadowStubs(fullPath));
633
- else if (entry.name === STUB_FILENAME) results.push(fullPath);
634
- }
635
- return results;
636
- }
637
- function removeEmptyParents(directory, stopAt) {
638
- const resolved = path.resolve(directory);
639
- const resolvedStop = path.resolve(stopAt);
640
- if (resolved === resolvedStop || !resolved.startsWith(resolvedStop)) return;
641
- try {
642
- if (fs.readdirSync(resolved).length === 0) {
643
- fs.rmdirSync(resolved);
644
- removeEmptyParents(path.dirname(resolved), stopAt);
645
- }
646
- } catch {}
647
- }
648
- //#endregion
649
- //#region src/coverage/derive-coverage-from.ts
650
- /**
651
- * Derives `collectCoverageFrom` glob patterns from project `include` patterns.
652
- *
653
- * Extracts the static root directory from each include pattern and generates
654
- * coverage globs that match source files within those roots, excluding test
655
- * files. The source extension (`.ts`, `.tsx`, `.luau`, `.lua`) is inferred from
656
- * each include pattern. Returns `undefined` when no roots can be extracted
657
- * (preserving default all-files behavior).
658
- */
659
- function deriveCoverageFromIncludes(projects) {
660
- const rootsByExtension = /* @__PURE__ */ new Map();
661
- for (const project of projects) for (const pattern of project.include) {
662
- const extension = inferSourceExtension(pattern);
663
- try {
664
- const { root } = extractStaticRoot(pattern);
665
- const roots = rootsByExtension.get(extension) ?? /* @__PURE__ */ new Set();
666
- roots.add(root);
667
- rootsByExtension.set(extension, roots);
668
- } catch {}
669
- }
670
- if (rootsByExtension.size === 0) return;
671
- const patterns = [];
672
- for (const [extension, roots] of rootsByExtension) for (const root of roots) patterns.push(`${root}/**/*${extension}`);
673
- for (const extension of rootsByExtension.keys()) patterns.push(`!**/*.spec${extension}`, `!**/*.test${extension}`);
674
- return patterns;
675
- }
676
- /**
677
- * Infers the source file extension from an include pattern by stripping the
678
- * `.spec` or `.test` suffix. Throws when the pattern has no recognizable test
679
- * extension so that misconfigured globs fail loudly.
680
- */
681
- function inferSourceExtension(pattern) {
682
- const match = pattern.match(/\.(?:spec|test)(\.\w+)$/);
683
- if (!match) throw new Error(`Cannot infer source extension from include pattern "${pattern}". Patterns must end with .spec.<ext> or .test.<ext> (e.g. **/*.spec.ts, **/*.test.luau).`);
684
- const [, extension] = match;
685
- return extension;
686
- }
687
- //#endregion
688
- //#region src/coverage/mapper.ts
689
- const positionSchema = type({
690
- column: "number",
691
- line: "number"
692
- });
693
- const spanSchema = type({
694
- end: positionSchema,
695
- start: positionSchema
696
- });
697
- const functionEntrySchema = type({
698
- name: "string",
699
- location: type({
700
- end: positionSchema,
701
- start: positionSchema
702
- })
703
- });
704
- const branchEntrySchema = type({
705
- locations: type("Record<string, unknown>[]"),
706
- type: "string"
707
- });
708
- const NESTED_RECORD = "Record<string, Record<string, unknown>>";
709
- const coverageMapSchema = type({
710
- "branchMap?": NESTED_RECORD,
711
- "functionMap?": NESTED_RECORD,
712
- "statementMap": type(NESTED_RECORD)
713
- });
714
- function mapCoverageToTypeScript(coverageData, manifest) {
715
- const pendingStatements = /* @__PURE__ */ new Map();
716
- const pendingFunctions = /* @__PURE__ */ new Map();
717
- const pendingBranches = /* @__PURE__ */ new Map();
718
- for (const [fileKey, fileCoverage] of Object.entries(coverageData)) {
719
- const record = manifest.files[fileKey];
720
- if (record === void 0) continue;
721
- const resources = loadFileResources(record);
722
- if (resources === void 0) continue;
723
- if (resources.traceMap === void 0) {
724
- passthroughFileStatements(resources, fileCoverage, pendingStatements);
725
- passthroughFileFunctions(resources, fileCoverage, pendingFunctions);
726
- passthroughFileBranches(resources, fileCoverage, pendingBranches);
727
- } else {
728
- const mapped = {
729
- coverageMap: resources.coverageMap,
730
- sourceMapDirectory: resources.sourceMapDirectory,
731
- traceMap: resources.traceMap
732
- };
733
- mapFileFunctions(mapped, fileCoverage, pendingFunctions, mapFileStatements(mapped, fileCoverage, pendingStatements));
734
- mapFileBranches(mapped, fileCoverage, pendingBranches);
735
- }
736
- }
737
- return buildResult(pendingStatements, pendingFunctions, pendingBranches);
738
- }
739
- function loadFileResources(record) {
740
- let coverageMapRaw;
741
- try {
742
- coverageMapRaw = fs$1.readFileSync(record.coverageMapPath, "utf-8");
743
- } catch {
744
- return;
745
- }
746
- const parsed = coverageMapSchema(JSON.parse(coverageMapRaw));
747
- if (parsed instanceof type.errors) return;
748
- let traceMap;
749
- try {
750
- traceMap = new TraceMap(fs$1.readFileSync(record.sourceMapPath, "utf-8"));
751
- } catch {}
752
- const sourceMapDirectory = path$1.posix.dirname(record.sourceMapPath);
753
- return {
754
- coverageMap: parsed,
755
- sourceKey: record.key,
756
- sourceMapDirectory,
757
- traceMap
758
- };
759
- }
760
- function toIstanbulColumn(luauColumn) {
761
- return Math.max(0, luauColumn - 1);
762
- }
763
- function passthroughFileStatements(resources, fileCoverage, pending) {
764
- let fileStatements = pending.get(resources.sourceKey);
765
- if (fileStatements === void 0) {
766
- fileStatements = /* @__PURE__ */ new Map();
767
- pending.set(resources.sourceKey, fileStatements);
768
- }
769
- for (const [statementId, rawSpan] of Object.entries(resources.coverageMap.statementMap)) {
770
- const span = spanSchema(rawSpan);
771
- if (span instanceof type.errors) continue;
772
- const hitCount = fileCoverage.s[statementId] ?? 0;
773
- fileStatements.set(statementId, {
774
- end: {
775
- column: toIstanbulColumn(span.end.column),
776
- line: span.end.line
777
- },
778
- hitCount,
779
- start: {
780
- column: toIstanbulColumn(span.start.column),
781
- line: span.start.line
782
- }
783
- });
784
- }
785
- }
786
- function passthroughFileFunctions(resources, fileCoverage, pendingFunctions) {
787
- if (resources.coverageMap.functionMap === void 0) return;
788
- let fileFunctions = pendingFunctions.get(resources.sourceKey);
789
- if (fileFunctions === void 0) {
790
- fileFunctions = [];
791
- pendingFunctions.set(resources.sourceKey, fileFunctions);
792
- }
793
- for (const [functionId, rawEntry] of Object.entries(resources.coverageMap.functionMap)) {
794
- const entry = functionEntrySchema(rawEntry);
795
- if (entry instanceof type.errors) continue;
796
- fileFunctions.push({
797
- name: entry.name,
798
- hitCount: fileCoverage.f?.[functionId] ?? 0,
799
- loc: {
800
- end: {
801
- column: toIstanbulColumn(entry.location.end.column),
802
- line: entry.location.end.line
803
- },
804
- start: {
805
- column: toIstanbulColumn(entry.location.start.column),
806
- line: entry.location.start.line
807
- }
808
- }
809
- });
810
- }
811
- }
812
- function passthroughFileBranches(resources, fileCoverage, pendingBranches) {
813
- if (resources.coverageMap.branchMap === void 0) return;
814
- let fileBranches = pendingBranches.get(resources.sourceKey);
815
- if (fileBranches === void 0) {
816
- fileBranches = [];
817
- pendingBranches.set(resources.sourceKey, fileBranches);
818
- }
819
- for (const [branchId, rawEntry] of Object.entries(resources.coverageMap.branchMap)) {
820
- const entry = branchEntrySchema(rawEntry);
821
- if (entry instanceof type.errors) continue;
822
- const armHitCounts = fileCoverage.b?.[branchId] ?? [];
823
- const locations = [];
824
- for (const rawLocation of entry.locations) {
825
- const location = spanSchema(rawLocation);
826
- if (location instanceof type.errors) continue;
827
- locations.push({
828
- end: {
829
- column: toIstanbulColumn(location.end.column),
830
- line: location.end.line
831
- },
832
- start: {
833
- column: toIstanbulColumn(location.start.column),
834
- line: location.start.line
835
- }
836
- });
837
- }
838
- if (locations.length === 0) continue;
839
- const firstLocation = locations[0];
840
- const lastLocation = locations[locations.length - 1];
841
- assert(firstLocation !== void 0 && lastLocation !== void 0, "Branch locations must not be empty after filtering");
842
- fileBranches.push({
843
- armHitCounts: entry.locations.map((_, index) => armHitCounts[index] ?? 0),
844
- loc: {
845
- end: lastLocation.end,
846
- start: firstLocation.start
847
- },
848
- locations,
849
- type: entry.type
850
- });
851
- }
852
- }
853
- /**
854
- * Resolves a source path from a source map against the source map's directory.
855
- * Source maps produce paths relative to the .map file (e.g.,
856
- * `../../../packages/src/file.ts` from `out/packages/src/file.lua.map`).
857
- * Joining with the map directory normalizes these to cwd-relative paths.
858
- * Paths that are already cwd-relative (no `..` prefix) pass through unchanged.
859
- */
860
- function resolveSourcePath(source, sourceMapDirectory) {
861
- const normalized = source.replaceAll("\\", "/");
862
- if (!normalized.startsWith("..")) return normalized;
863
- return path$1.posix.normalize(path$1.posix.join(sourceMapDirectory, normalized));
864
- }
865
- function mapStatement(traceMap, span, sourceMapDirectory) {
866
- const mappedStart = originalPositionFor(traceMap, {
867
- column: Math.max(0, span.start.column - 1),
868
- line: span.start.line
869
- });
870
- const mappedEnd = originalPositionFor(traceMap, {
871
- column: Math.max(0, span.end.column - 1),
872
- line: span.end.line
873
- });
874
- if (mappedStart.source === null || mappedEnd.source === null || mappedStart.source !== mappedEnd.source) return;
875
- const resolvedSource = resolveSourcePath(mappedStart.source, sourceMapDirectory);
876
- return {
877
- end: {
878
- column: mappedEnd.column,
879
- line: mappedEnd.line,
880
- source: resolvedSource
881
- },
882
- start: {
883
- column: mappedStart.column,
884
- line: mappedStart.line,
885
- source: resolvedSource
886
- }
887
- };
888
- }
889
- function maxPosition(a, b) {
890
- if (a.line > b.line) return a;
891
- if (b.line > a.line) return b;
892
- return a.column >= b.column ? a : b;
893
- }
894
- function addOrCoalesce(pending, start, end, hitCount) {
895
- const tsPath = start.source;
896
- let fileStatements = pending.get(tsPath);
897
- if (fileStatements === void 0) {
898
- fileStatements = /* @__PURE__ */ new Map();
899
- pending.set(tsPath, fileStatements);
900
- }
901
- const coalescenceKey = `${String(start.line)}:${String(start.column)}`;
902
- const existing = fileStatements.get(coalescenceKey);
903
- if (existing !== void 0) {
904
- existing.hitCount += hitCount;
905
- existing.end = maxPosition(existing.end, {
906
- column: end.column,
907
- line: end.line
908
- });
909
- } else fileStatements.set(coalescenceKey, {
910
- end: {
911
- column: end.column,
912
- line: end.line
913
- },
914
- hitCount,
915
- start: {
916
- column: start.column,
917
- line: start.line
918
- }
919
- });
920
- }
921
- function mapFileStatements(resources, fileCoverage, pending) {
922
- const resolvedTsPaths = /* @__PURE__ */ new Set();
923
- for (const [statementId, rawSpan] of Object.entries(resources.coverageMap.statementMap)) {
924
- const span = spanSchema(rawSpan);
925
- if (span instanceof type.errors) continue;
926
- const hitCount = fileCoverage.s[statementId] ?? 0;
927
- const mapped = mapStatement(resources.traceMap, span, resources.sourceMapDirectory);
928
- if (mapped === void 0) continue;
929
- resolvedTsPaths.add(mapped.start.source);
930
- addOrCoalesce(pending, mapped.start, mapped.end, hitCount);
931
- }
932
- return resolvedTsPaths;
933
- }
934
- function mapFileFunctions(resources, fileCoverage, pendingFunctions, resolvedTsPaths) {
935
- if (resources.coverageMap.functionMap === void 0) return;
936
- for (const [functionId, rawEntry] of Object.entries(resources.coverageMap.functionMap)) {
937
- const entry = functionEntrySchema(rawEntry);
938
- if (entry instanceof type.errors) continue;
939
- const hitCount = fileCoverage.f?.[functionId] ?? 0;
940
- const mapped = mapStatement(resources.traceMap, entry.location, resources.sourceMapDirectory);
941
- if (mapped !== void 0) {
942
- const tsPath = mapped.start.source;
943
- let fileFunctions = pendingFunctions.get(tsPath);
944
- if (fileFunctions === void 0) {
945
- fileFunctions = [];
946
- pendingFunctions.set(tsPath, fileFunctions);
947
- }
948
- fileFunctions.push({
949
- name: entry.name,
950
- hitCount,
951
- loc: {
952
- end: {
953
- column: mapped.end.column,
954
- line: mapped.end.line
955
- },
956
- start: {
957
- column: mapped.start.column,
958
- line: mapped.start.line
959
- }
960
- }
961
- });
962
- continue;
963
- }
964
- const fallbackPath = resolvedTsPaths.values().next().value;
965
- if (fallbackPath === void 0) continue;
966
- let fileFunctions = pendingFunctions.get(fallbackPath);
967
- if (fileFunctions === void 0) {
968
- fileFunctions = [];
969
- pendingFunctions.set(fallbackPath, fileFunctions);
970
- }
971
- fileFunctions.push({
972
- name: entry.name,
973
- hitCount,
974
- loc: {
975
- end: {
976
- column: 0,
977
- line: 1
978
- },
979
- start: {
980
- column: 0,
981
- line: 1
982
- }
983
- }
984
- });
985
- }
986
- }
987
- function mapBranchArmLocations(traceMap, rawLocations, sourceMapDirectory) {
988
- const mappedLocations = [];
989
- let tsPath;
990
- for (const rawLocation of rawLocations) {
991
- const location = spanSchema(rawLocation);
992
- if (location instanceof type.errors) return;
993
- const mapped = mapStatement(traceMap, location, sourceMapDirectory);
994
- if (mapped === void 0) return;
995
- if (tsPath === void 0) tsPath = mapped.start.source;
996
- else if (tsPath !== mapped.start.source) return;
997
- mappedLocations.push({
998
- end: {
999
- column: mapped.end.column,
1000
- line: mapped.end.line
1001
- },
1002
- start: {
1003
- column: mapped.start.column,
1004
- line: mapped.start.line
1005
- }
1006
- });
1007
- }
1008
- if (tsPath === void 0 || mappedLocations.length === 0) return;
1009
- return {
1010
- locations: mappedLocations,
1011
- tsPath
1012
- };
1013
- }
1014
- function mapFileBranches(resources, fileCoverage, pendingBranches) {
1015
- if (resources.coverageMap.branchMap === void 0) return;
1016
- for (const [branchId, rawEntry] of Object.entries(resources.coverageMap.branchMap)) {
1017
- const entry = branchEntrySchema(rawEntry);
1018
- if (entry instanceof type.errors) continue;
1019
- const armHitCounts = fileCoverage.b?.[branchId] ?? [];
1020
- const result = mapBranchArmLocations(resources.traceMap, entry.locations, resources.sourceMapDirectory);
1021
- if (result === void 0) continue;
1022
- let fileBranches = pendingBranches.get(result.tsPath);
1023
- if (fileBranches === void 0) {
1024
- fileBranches = [];
1025
- pendingBranches.set(result.tsPath, fileBranches);
1026
- }
1027
- const firstLocation = result.locations[0];
1028
- const lastLocation = result.locations[result.locations.length - 1];
1029
- assert(firstLocation !== void 0 && lastLocation !== void 0, "Branch locations must not be empty after successful mapping");
1030
- fileBranches.push({
1031
- armHitCounts: entry.locations.map((_, index) => armHitCounts[index] ?? 0),
1032
- loc: {
1033
- end: lastLocation.end,
1034
- start: firstLocation.start
1035
- },
1036
- locations: result.locations,
1037
- type: entry.type
1038
- });
1039
- }
1040
- }
1041
- function populateStatements(file, statementMap) {
1042
- if (statementMap === void 0) return;
1043
- let index = 0;
1044
- for (const statement of statementMap.values()) {
1045
- const id = String(index);
1046
- file.statementMap[id] = {
1047
- end: statement.end,
1048
- start: statement.start
1049
- };
1050
- file.s[id] = statement.hitCount;
1051
- index++;
1052
- }
1053
- }
1054
- function populateFunctions(file, fileFunctions) {
1055
- if (fileFunctions === void 0) return;
1056
- let functionIndex = 0;
1057
- for (const func of fileFunctions) {
1058
- const id = String(functionIndex);
1059
- file.fnMap[id] = {
1060
- name: func.name,
1061
- loc: func.loc
1062
- };
1063
- file.f[id] = func.hitCount;
1064
- functionIndex++;
1065
- }
1066
- }
1067
- function populateBranches(file, fileBranches) {
1068
- if (fileBranches === void 0) return;
1069
- let branchIndex = 0;
1070
- for (const branch of fileBranches) {
1071
- const id = String(branchIndex);
1072
- file.branchMap[id] = {
1073
- loc: branch.loc,
1074
- locations: branch.locations,
1075
- type: branch.type
1076
- };
1077
- file.b[id] = branch.armHitCounts;
1078
- branchIndex++;
1079
- }
1080
- }
1081
- function buildResult(pending, pendingFunctions, pendingBranches) {
1082
- const files = {};
1083
- const allPaths = new Set([
1084
- ...pending.keys(),
1085
- ...pendingFunctions.keys(),
1086
- ...pendingBranches.keys()
1087
- ]);
1088
- for (const tsPath of allPaths) {
1089
- const file = {
1090
- b: {},
1091
- branchMap: {},
1092
- f: {},
1093
- fnMap: {},
1094
- path: tsPath,
1095
- s: {},
1096
- statementMap: {}
1097
- };
1098
- populateStatements(file, pending.get(tsPath));
1099
- populateFunctions(file, pendingFunctions.get(tsPath));
1100
- populateBranches(file, pendingBranches.get(tsPath));
1101
- files[tsPath] = file;
1102
- }
1103
- return { files };
1104
- }
1105
- //#endregion
1106
- //#region src/coverage/merge-raw-coverage.ts
1107
- /**
1108
- * Additively merge two raw coverage datasets. Overlapping files have their
1109
- * hit counts summed (matching istanbul-lib-coverage's semantics).
1110
- */
1111
- function mergeRawCoverage(target, source) {
1112
- if (target === void 0) return source;
1113
- if (source === void 0) return target;
1114
- const result = { ...target };
1115
- for (const [filePath, fileCoverage] of Object.entries(source)) {
1116
- const existing = result[filePath];
1117
- result[filePath] = existing === void 0 ? { ...fileCoverage } : mergeFileCoverage(existing, fileCoverage);
1118
- }
1119
- return result;
1120
- }
1121
- function sumScalars(a, b) {
1122
- const result = { ...a };
1123
- for (const [key, value] of Object.entries(b)) result[key] = (result[key] ?? 0) + value;
1124
- return result;
1125
- }
1126
- function sumBranches(a, b) {
1127
- const result = { ...a };
1128
- for (const [key, bArms] of Object.entries(b)) {
1129
- const aArms = result[key];
1130
- if (aArms === void 0) {
1131
- result[key] = [...bArms];
1132
- continue;
1133
- }
1134
- const length = Math.max(aArms.length, bArms.length);
1135
- result[key] = Array.from({ length }, (_, index) => (aArms[index] ?? 0) + (bArms[index] ?? 0));
1136
- }
1137
- return result;
1138
- }
1139
- function mergeFileCoverage(a, b) {
1140
- const merged = { s: sumScalars(a.s, b.s) };
1141
- if (a.f !== void 0 || b.f !== void 0) merged.f = sumScalars(a.f ?? {}, b.f ?? {});
1142
- if (a.b !== void 0 || b.b !== void 0) merged.b = sumBranches(a.b ?? {}, b.b ?? {});
1143
- return merged;
1144
- }
1145
- //#endregion
1146
- //#region src/coverage/coverage-collector.ts
1147
- const INSTRUMENTABLE_STATEMENT_TAGS = new Set([
1148
- "assign",
1149
- "break",
1150
- "compoundassign",
1151
- "conditional",
1152
- "continue",
1153
- "do",
1154
- "expression",
1155
- "for",
1156
- "forin",
1157
- "function",
1158
- "local",
1159
- "localfunction",
1160
- "repeat",
1161
- "return",
1162
- "while"
1163
- ]);
1164
- const END_KEYWORD_LENGTH = 3;
1165
- function collectCoverage(root) {
1166
- let statementIndex = 1;
1167
- let functionIndex = 1;
1168
- let branchIndex = 1;
1169
- const statements = [];
1170
- const functions = [];
1171
- const branches = [];
1172
- const implicitElseProbes = [];
1173
- const exprIfProbes = [];
1174
- const namedFunctions = /* @__PURE__ */ new Set();
1175
- visitBlock(root, {
1176
- visitExprFunction(node) {
1177
- if (namedFunctions.has(node)) return true;
1178
- const first = getBodyFirstStatement(node.body);
1179
- functions.push({
1180
- name: "(anonymous)",
1181
- bodyFirstColumn: first.column,
1182
- bodyFirstLine: first.line,
1183
- index: functionIndex,
1184
- location: { ...node.location }
1185
- });
1186
- functionIndex++;
1187
- return true;
1188
- },
1189
- visitExprIfElse(node) {
1190
- const branch = {
1191
- arms: [],
1192
- branchType: "expr-if",
1193
- index: branchIndex
1194
- };
1195
- let armIndex = 1;
1196
- branch.arms.push({
1197
- bodyFirstColumn: 0,
1198
- bodyFirstLine: 0,
1199
- location: { ...node.thenExpr.location }
1200
- });
1201
- exprIfProbes.push({
1202
- armIndex,
1203
- branchIndex,
1204
- exprLocation: { ...node.thenExpr.location }
1205
- });
1206
- armIndex++;
1207
- for (const elseif of node.elseifs) {
1208
- branch.arms.push({
1209
- bodyFirstColumn: 0,
1210
- bodyFirstLine: 0,
1211
- location: { ...elseif.thenExpr.location }
1212
- });
1213
- exprIfProbes.push({
1214
- armIndex,
1215
- branchIndex,
1216
- exprLocation: { ...elseif.thenExpr.location }
1217
- });
1218
- armIndex++;
1219
- }
1220
- branch.arms.push({
1221
- bodyFirstColumn: 0,
1222
- bodyFirstLine: 0,
1223
- location: { ...node.elseExpr.location }
1224
- });
1225
- exprIfProbes.push({
1226
- armIndex,
1227
- branchIndex,
1228
- exprLocation: { ...node.elseExpr.location }
1229
- });
1230
- branches.push(branch);
1231
- branchIndex++;
1232
- return true;
1233
- },
1234
- visitStatBlock(block) {
1235
- for (const stmt of block.statements) if (INSTRUMENTABLE_STATEMENT_TAGS.has(stmt.tag)) {
1236
- statements.push({
1237
- index: statementIndex,
1238
- location: { ...stmt.location }
1239
- });
1240
- statementIndex++;
1241
- }
1242
- return true;
1243
- },
1244
- visitStatFunction(node) {
1245
- const name = extractFunctionName(node);
1246
- const first = getBodyFirstStatement(node.func.body);
1247
- namedFunctions.add(node.func);
1248
- functions.push({
1249
- name,
1250
- bodyFirstColumn: first.column,
1251
- bodyFirstLine: first.line,
1252
- index: functionIndex,
1253
- location: { ...node.location }
1254
- });
1255
- functionIndex++;
1256
- return true;
1257
- },
1258
- visitStatIf(node) {
1259
- const branch = {
1260
- arms: [],
1261
- branchType: "if",
1262
- index: branchIndex
1263
- };
1264
- const thenFirst = getBodyFirstStatement(node.thenBlock);
1265
- branch.arms.push({
1266
- bodyFirstColumn: thenFirst.column,
1267
- bodyFirstLine: thenFirst.line,
1268
- location: { ...node.thenBlock.location }
1269
- });
1270
- for (const elseif of node.elseifs) {
1271
- const elseifFirst = getBodyFirstStatement(elseif.thenBlock);
1272
- branch.arms.push({
1273
- bodyFirstColumn: elseifFirst.column,
1274
- bodyFirstLine: elseifFirst.line,
1275
- location: { ...elseif.thenBlock.location }
1276
- });
1277
- }
1278
- const { elseBlock } = node;
1279
- const hasExplicitElse = elseBlock !== void 0 && elseBlock.statements.length > 0;
1280
- if (hasExplicitElse) {
1281
- const elseFirst = getBodyFirstStatement(elseBlock);
1282
- branch.arms.push({
1283
- bodyFirstColumn: elseFirst.column,
1284
- bodyFirstLine: elseFirst.line,
1285
- location: { ...elseBlock.location }
1286
- });
1287
- }
1288
- if (!hasExplicitElse) {
1289
- branch.arms.push({
1290
- bodyFirstColumn: 0,
1291
- bodyFirstLine: 0,
1292
- location: {
1293
- beginColumn: node.location.beginColumn,
1294
- beginLine: node.location.beginLine,
1295
- endColumn: node.location.beginColumn,
1296
- endLine: node.location.beginLine
1297
- }
1298
- });
1299
- implicitElseProbes.push({
1300
- armIndex: branch.arms.length,
1301
- branchIndex,
1302
- endColumn: node.location.endColumn - END_KEYWORD_LENGTH,
1303
- endLine: node.location.endLine
1304
- });
1305
- }
1306
- branches.push(branch);
1307
- branchIndex++;
1308
- return true;
1309
- },
1310
- visitStatLocalFunction(node) {
1311
- const name = node.name.name.text;
1312
- const first = getBodyFirstStatement(node.func.body);
1313
- namedFunctions.add(node.func);
1314
- functions.push({
1315
- name,
1316
- bodyFirstColumn: first.column,
1317
- bodyFirstLine: first.line,
1318
- index: functionIndex,
1319
- location: { ...node.location }
1320
- });
1321
- functionIndex++;
1322
- return true;
1323
- }
1324
- });
1325
- return {
1326
- branches,
1327
- exprIfProbes,
1328
- functions,
1329
- implicitElseProbes,
1330
- statements
1331
- };
1332
- }
1333
- function getBodyFirstStatement(block) {
1334
- const first = block.statements[0];
1335
- if (first !== void 0) return {
1336
- column: first.location.beginColumn,
1337
- line: first.location.beginLine
1338
- };
1339
- return {
1340
- column: block.location.beginColumn,
1341
- line: block.location.beginLine
1342
- };
1343
- }
1344
- function extractExprName(expr) {
1345
- if (expr.tag === "global") return expr.name.text;
1346
- if (expr.tag === "indexname") return `${extractExprName(expr.expression)}${expr.accessor.text}${expr.index.text}`;
1347
- return "(anonymous)";
1348
- }
1349
- function extractFunctionName(node) {
1350
- return extractExprName(node.name);
1351
- }
1352
- //#endregion
1353
- //#region src/coverage/coverage-map-builder.ts
1354
- function buildCoverageMap$1(result) {
1355
- const statementMap = {};
1356
- for (const statement of result.statements) statementMap[String(statement.index)] = {
1357
- end: {
1358
- column: statement.location.endColumn,
1359
- line: statement.location.endLine
1360
- },
1361
- start: {
1362
- column: statement.location.beginColumn,
1363
- line: statement.location.beginLine
1364
- }
1365
- };
1366
- const functionMap = {};
1367
- for (const func of result.functions) functionMap[String(func.index)] = {
1368
- name: func.name,
1369
- location: {
1370
- end: {
1371
- column: func.location.endColumn,
1372
- line: func.location.endLine
1373
- },
1374
- start: {
1375
- column: func.location.beginColumn,
1376
- line: func.location.beginLine
1377
- }
1378
- }
1379
- };
1380
- const branchMap = {};
1381
- for (const branch of result.branches) branchMap[String(branch.index)] = {
1382
- locations: branch.arms.map((arm) => {
1383
- return {
1384
- end: {
1385
- column: arm.location.endColumn,
1386
- line: arm.location.endLine
1387
- },
1388
- start: {
1389
- column: arm.location.beginColumn,
1390
- line: arm.location.beginLine
1391
- }
1392
- };
1393
- }),
1394
- type: branch.branchType
1395
- };
1396
- return {
1397
- branchMap,
1398
- functionMap,
1399
- statementMap
1400
- };
1401
- }
1402
- //#endregion
1403
- //#region src/coverage/probe-inserter.ts
1404
- function insertProbes(source, result, fileKey) {
1405
- const lines = splitLines(source);
1406
- applyProbes(lines, collectProbes(result));
1407
- return buildPreamble(extractModeDirective(lines), fileKey, result) + lines.join("\n");
1408
- }
1409
- function collectProbes(result) {
1410
- const probes = [];
1411
- for (const stmt of result.statements) probes.push({
1412
- column: stmt.location.beginColumn,
1413
- line: stmt.location.beginLine,
1414
- text: `__cov_s[${stmt.index}] += 1; `
1415
- });
1416
- for (const func of result.functions) if (func.bodyFirstLine > 0) probes.push({
1417
- column: func.bodyFirstColumn,
1418
- line: func.bodyFirstLine,
1419
- text: `__cov_f[${func.index}] += 1; `
1420
- });
1421
- for (const branch of result.branches) for (let armIndex = 0; armIndex < branch.arms.length; armIndex++) {
1422
- const arm = branch.arms[armIndex];
1423
- if (arm !== void 0 && arm.bodyFirstLine > 0) probes.push({
1424
- column: arm.bodyFirstColumn,
1425
- line: arm.bodyFirstLine,
1426
- text: `__cov_b[${branch.index}][${armIndex + 1}] += 1; `
1427
- });
1428
- }
1429
- for (const probe of result.implicitElseProbes) probes.push({
1430
- column: probe.endColumn,
1431
- line: probe.endLine,
1432
- text: `else __cov_b[${probe.branchIndex}][${probe.armIndex}] += 1 `
1433
- });
1434
- for (const probe of result.exprIfProbes) probes.push({
1435
- column: probe.exprLocation.beginColumn,
1436
- line: probe.exprLocation.beginLine,
1437
- text: `__cov_br(${probe.branchIndex}, ${probe.armIndex}, `
1438
- }, {
1439
- column: probe.exprLocation.endColumn,
1440
- line: probe.exprLocation.endLine,
1441
- text: ")"
1442
- });
1443
- probes.sort((a, b) => {
1444
- if (a.line === b.line) return b.column - a.column;
1445
- return b.line - a.line;
1446
- });
1447
- return probes;
1448
- }
1449
- /** Mutates `mutableLines` in place, inserting probe text at each probe's position. */
1450
- function applyProbes(mutableLines, probes) {
1451
- for (const { column, line: probeLine, text } of probes) {
1452
- const lineIndex = probeLine - 1;
1453
- const line = mutableLines[lineIndex];
1454
- assert(line !== void 0, `Invalid probe line number: ${probeLine}`);
1455
- const before = line.slice(0, column - 1);
1456
- const after = line.slice(column - 1);
1457
- mutableLines[lineIndex] = before + (before.length > 0 && !/\s$/.test(before) && /^[a-zA-Z_]/.test(text) ? " " : "") + text + after;
1458
- }
1459
- }
1460
- function extractModeDirective(lines) {
1461
- if (lines.length > 0 && lines[0] !== void 0 && /^--![a-z]+/.test(lines[0])) {
1462
- const directive = `${lines[0]}\n`;
1463
- lines.splice(0, 1);
1464
- return directive;
1465
- }
1466
- return "";
1467
- }
1468
- function splitLines(source) {
1469
- const lines = [];
1470
- let position = 0;
1471
- while (position < source.length) {
1472
- const nlPosition = source.indexOf("\n", position);
1473
- if (nlPosition !== -1) {
1474
- let lineEnd = nlPosition;
1475
- if (lineEnd > position && source[lineEnd - 1] === "\r") lineEnd--;
1476
- lines.push(source.slice(position, lineEnd));
1477
- position = nlPosition + 1;
1478
- } else {
1479
- lines.push(source.slice(position));
1480
- position = source.length;
1481
- }
1482
- }
1483
- if (lines.length === 0) lines.push("");
1484
- return lines;
1485
- }
1486
- function buildPreamble(modeDirective, fileKey, result) {
1487
- const escapedKey = fileKey.replaceAll("\\", "\\\\").replaceAll("\"", "\\\"").replaceAll("\n", "\\n").replaceAll("\r", "\\r").replaceAll("\0", "");
1488
- let preamble = modeDirective;
1489
- preamble += "if _G.__jest_roblox_cov == nil then _G.__jest_roblox_cov = {} end\n";
1490
- preamble += `local __cov_file_key = "${escapedKey}"\n`;
1491
- preamble += "if _G.__jest_roblox_cov[__cov_file_key] == nil then _G.__jest_roblox_cov[__cov_file_key] = {} end\n";
1492
- preamble += "if _G.__jest_roblox_cov[__cov_file_key].s == nil then _G.__jest_roblox_cov[__cov_file_key].s = {} end\n";
1493
- preamble += "local __cov_s = _G.__jest_roblox_cov[__cov_file_key].s\n";
1494
- if (result.statements.length > 0) preamble += `for __i = 1, ${result.statements.length} do if __cov_s[__i] == nil then __cov_s[__i] = 0 end end\n`;
1495
- if (result.functions.length > 0) {
1496
- preamble += "if _G.__jest_roblox_cov[__cov_file_key].f == nil then _G.__jest_roblox_cov[__cov_file_key].f = {} end\n";
1497
- preamble += "local __cov_f = _G.__jest_roblox_cov[__cov_file_key].f\n";
1498
- preamble += `for __i = 1, ${result.functions.length} do if __cov_f[__i] == nil then __cov_f[__i] = 0 end end\n`;
1499
- }
1500
- if (result.branches.length > 0) {
1501
- preamble += "if _G.__jest_roblox_cov[__cov_file_key].b == nil then _G.__jest_roblox_cov[__cov_file_key].b = {} end\n";
1502
- preamble += "local __cov_b = _G.__jest_roblox_cov[__cov_file_key].b\n";
1503
- for (const branch of result.branches) {
1504
- const zeros = branch.arms.map(() => "0").join(", ");
1505
- preamble += `if __cov_b[${branch.index}] == nil then __cov_b[${branch.index}] = {${zeros}} end\n`;
1506
- }
1507
- if (result.exprIfProbes.length > 0) preamble += "local function __cov_br(__bi, __ai, ...) __cov_b[__bi][__ai] += 1; return ... end\n";
1508
- }
1509
- return preamble;
1510
- }
1511
- //#endregion
1512
- //#region src/coverage/instrumenter.ts
1513
- let cachedTemporaryDirectory;
1514
- /**
1515
- * Instrument a single luauRoot directory. Returns the files map without
1516
- * writing a manifest — used by `prepareCoverage()` to merge multiple roots.
1517
- */
1518
- function instrumentRoot(options) {
1519
- const { astOutputDirectory: astOutputDirectoryOption, luauRoot, parseScript, shadowDir, skipFiles } = options;
1520
- const lazyTemporaryDirectory = parseScript === void 0 || astOutputDirectoryOption === void 0 ? getTemporaryDirectory() : "";
1521
- const scriptPath = parseScript ?? path$1.join(lazyTemporaryDirectory, "parse-ast.luau");
1522
- const astOutputDirectory = astOutputDirectoryOption ?? path$1.join(lazyTemporaryDirectory, "asts");
1523
- if (parseScript === void 0) fs$1.writeFileSync(scriptPath, parse_ast_default);
1524
- fs$1.mkdirSync(astOutputDirectory, { recursive: true });
1525
- const luteArgs = [
1526
- "run",
1527
- scriptPath,
1528
- "--",
1529
- path$1.resolve(luauRoot),
1530
- astOutputDirectory
1531
- ];
1532
- if (skipFiles !== void 0 && skipFiles.size > 0) {
1533
- const skipListPath = toPosix(path$1.join(astOutputDirectory, "skip-list.json"));
1534
- fs$1.writeFileSync(skipListPath, JSON.stringify([...skipFiles]));
1535
- luteArgs.push(skipListPath);
1536
- }
1537
- let fileListJson;
1538
- try {
1539
- fileListJson = cp.execFileSync("lute", luteArgs, {
1540
- encoding: "utf-8",
1541
- maxBuffer: 1024 * 1024
1542
- });
1543
- } catch (err) {
1544
- if (err instanceof Error && "code" in err && err.code === "ENOENT") throw new Error("lute is required for instrumentation but was not found on PATH");
1545
- throw new Error("Failed to parse Luau files", { cause: err });
1546
- }
1547
- let fileList;
1548
- try {
1549
- fileList = JSON.parse(fileListJson);
1550
- } catch (err) {
1551
- throw new Error("Failed to parse file list from lute", { cause: err });
1552
- }
1553
- if (!Array.isArray(fileList)) throw new Error("Expected file list array from lute");
1554
- const files = {};
1555
- const posixLuauRoot = toPosix(luauRoot);
1556
- for (const relativePath of fileList) {
1557
- if (shouldSkipFile(relativePath, skipFiles)) continue;
1558
- const astJsonPath = path$1.join(astOutputDirectory, `${relativePath}.json`);
1559
- let astJson;
1560
- try {
1561
- astJson = fs$1.readFileSync(astJsonPath, "utf-8");
1562
- } catch (err) {
1563
- throw new Error(`Failed to read AST for ${relativePath}`, { cause: err });
1564
- }
1565
- const ast = JSON.parse(astJson);
1566
- const fileKey = toPosix(path$1.join(posixLuauRoot, relativePath));
1567
- const originalLuauPath = fileKey;
1568
- const instrumentedLuauPath = toPosix(path$1.join(shadowDir, relativePath));
1569
- const coverageMapOutputPath = path$1.join(shadowDir, relativePath.replace(/\.luau$/, ".cov-map.json"));
1570
- const sourceMapPath = `${originalLuauPath}.map`;
1571
- const outputDirectory = path$1.dirname(path$1.join(shadowDir, relativePath));
1572
- fs$1.mkdirSync(outputDirectory, { recursive: true });
1573
- const sourceBuffer = fs$1.readFileSync(path$1.resolve(originalLuauPath));
1574
- const source = sourceBuffer.toString("utf-8");
1575
- const collectorResult = collectCoverage(ast);
1576
- const instrumentedSource = insertProbes(source, collectorResult, fileKey);
1577
- const coverageMap = buildCoverageMap$1(collectorResult);
1578
- fs$1.writeFileSync(path$1.join(shadowDir, relativePath), instrumentedSource);
1579
- fs$1.writeFileSync(coverageMapOutputPath, JSON.stringify(coverageMap, void 0, " "));
1580
- files[fileKey] = {
1581
- key: fileKey,
1582
- branchCount: collectorResult.branches.length,
1583
- coverageMapPath: toPosix(coverageMapOutputPath),
1584
- functionCount: collectorResult.functions.length,
1585
- instrumentedLuauPath,
1586
- originalLuauPath,
1587
- sourceHash: hashBuffer(sourceBuffer),
1588
- sourceMapPath,
1589
- statementCount: collectorResult.statements.length
1590
- };
1591
- }
1592
- return files;
1593
- }
1594
- function shouldSkipFile(relativePath, skipFiles) {
1595
- if (relativePath.endsWith(".snap.luau") || relativePath.endsWith(".snap.lua")) return true;
1596
- return skipFiles?.has(relativePath) === true;
1597
- }
1598
- function getTemporaryDirectory() {
1599
- if (cachedTemporaryDirectory !== void 0 && fs$1.existsSync(cachedTemporaryDirectory)) return cachedTemporaryDirectory;
1600
- cachedTemporaryDirectory = fs$1.mkdtempSync(path$1.join(os.tmpdir(), "jest-roblox-instrument-"));
1601
- return cachedTemporaryDirectory;
1602
- }
1603
- function toPosix(value) {
1604
- return value.replaceAll("\\", "/");
1605
- }
1606
- //#endregion
1607
- //#region src/coverage/rojo-builder.ts
1608
- function buildWithRojo(projectPath, outputPath) {
1609
- try {
1610
- cp.execFileSync("rojo", [
1611
- "build",
1612
- projectPath,
1613
- "-o",
1614
- outputPath
1615
- ], { stdio: "pipe" });
1616
- } catch (err) {
1617
- if (err instanceof Error && "code" in err && err.code === "ENOENT") throw new Error("rojo is required for --coverage but was not found on PATH");
1618
- const stderr = err instanceof Error && "stderr" in err && Buffer.isBuffer(err.stderr) ? err.stderr.toString().trim() : void 0;
1619
- const message = stderr !== void 0 && stderr.length > 0 ? `rojo build failed: ${stderr}` : "rojo build failed";
1620
- throw new Error(message, { cause: err });
1621
- }
1622
- }
1623
- //#endregion
1624
- //#region src/coverage/rojo-rewriter.ts
1625
- function rewriteRojoProject(project, options) {
1626
- const roots = isMultiRoot(options) ? options.roots.map(buildRootContext) : [buildRootContext(options)];
1627
- const context = {
1628
- relocation: options.projectRelocation?.replaceAll("\\", "/"),
1629
- roots
1630
- };
1631
- return {
1632
- ...project,
1633
- tree: walkTree(project.tree, context)
1634
- };
1635
- }
1636
- function isMultiRoot(options) {
1637
- return "roots" in options;
1638
- }
1639
- function buildRootContext(entry) {
1640
- return {
1641
- luauRoot: entry.luauRoot.replaceAll("\\", "/").replace(/\/$/, ""),
1642
- relocatedShadowDirectory: entry.relocatedShadowDirectory?.replaceAll("\\", "/"),
1643
- shadowDirectory: entry.shadowDir
1644
- };
1645
- }
1646
- function rewritePath(value, context) {
1647
- const normalized = value.replaceAll("\\", "/");
1648
- for (const root of context.roots) if (normalized === root.luauRoot || normalized.startsWith(`${root.luauRoot}/`)) {
1649
- const suffix = normalized.slice(root.luauRoot.length);
1650
- if (context.relocation === void 0 || root.relocatedShadowDirectory === void 0) return root.shadowDirectory + suffix;
1651
- return root.relocatedShadowDirectory + suffix;
1652
- }
1653
- if (context.relocation !== void 0) return `${context.relocation}/${normalized}`;
1654
- return value;
1655
- }
1656
- function walkTree(node, context) {
1657
- const result = {};
1658
- for (const [key, value] of Object.entries(node)) if (key === "$path" && typeof value === "string") result[key] = rewritePath(value, context);
1659
- else if (typeof value === "object" && !Array.isArray(value) && !key.startsWith("$")) result[key] = walkTree(value, context);
1660
- else result[key] = value;
1661
- return result;
1662
- }
1663
- //#endregion
1664
- //#region src/coverage/prepare.ts
1665
- const COVERAGE_DIR = ".jest-roblox-coverage";
1666
- /**
1667
- * Suffixes for files that are not instrumented for coverage but still need
1668
- * syncing to the shadow directory. Matches parse-ast.luau:131-139.
1669
- */
1670
- const NON_INSTRUMENTED_SUFFIXES = [
1671
- ".spec.luau",
1672
- ".test.luau",
1673
- ".spec.lua",
1674
- ".test.lua",
1675
- ".snap.luau",
1676
- ".snap.lua"
1677
- ];
1678
- function isNonInstrumentedFile(filename) {
1679
- return NON_INSTRUMENTED_SUFFIXES.some((suffix) => filename.endsWith(suffix));
1680
- }
1681
- const previousManifestSchema = type({
1682
- "files": type({ "[string]": { sourceHash: "string" } }),
1683
- "instrumenterVersion": "number",
1684
- "luauRoots": "string[]",
1685
- "nonInstrumentedFiles?": type({ "[string]": {
1686
- shadowPath: "string",
1687
- sourceHash: "string",
1688
- sourcePath: "string"
1689
- } }),
1690
- "placeFilePath?": "string",
1691
- "shadowDir": "string",
1692
- "version": "number"
1693
- }).as();
1694
- function collectLuauRootsFromRojo(project, config) {
1695
- const paths = [];
1696
- collectPaths(project.tree, paths);
1697
- const ignorePatterns = config.coveragePathIgnorePatterns;
1698
- const isIgnored = picomatch(ignorePatterns, { contains: true });
1699
- return paths.filter((directoryPath) => {
1700
- if (!fs$1.existsSync(directoryPath)) return false;
1701
- if (!fs$1.statSync(directoryPath).isDirectory()) return false;
1702
- if (isIgnored(directoryPath)) return false;
1703
- return containsLuauFiles(directoryPath);
1704
- });
1705
- }
1706
- /**
1707
- * Fast directory walk to discover instrumentable .luau/.lua files.
1708
- * Must match parse-ast.luau's discoverFiles logic (same skip rules).
1709
- */
1710
- function discoverInstrumentableFiles(luauRoot) {
1711
- const posixRoot = luauRoot.replaceAll("\\", "/");
1712
- const results = [];
1713
- walkLuauDirectory(posixRoot, posixRoot, isInstrumentableFile, results);
1714
- return new Set(results);
1715
- }
1716
- function prepareCoverage(config, beforeBuild) {
1717
- const rojoProjectPath = findRojoProject(config);
1718
- const luauRoots = resolveLuauRootsWithRojo(config, rojoProjectPath);
1719
- validateRelativeRoots(luauRoots);
1720
- const manifestPath = path$1.join(COVERAGE_DIR, "manifest.json");
1721
- const previousManifest = loadPreviousManifest(manifestPath);
1722
- const useIncremental = canUseIncremental(previousManifest, config);
1723
- if (!useIncremental && fs$1.existsSync(COVERAGE_DIR)) fs$1.rmSync(COVERAGE_DIR, { recursive: true });
1724
- const allFiles = {};
1725
- const allNonInstrumented = {};
1726
- const roots = [];
1727
- let hasChanges = !useIncremental;
1728
- for (const luauRoot of luauRoots) {
1729
- const rootResult = instrumentRootWithCache(luauRoot, useIncremental, previousManifest);
1730
- if (rootResult.changed) hasChanges = true;
1731
- Object.assign(allFiles, rootResult.files);
1732
- Object.assign(allNonInstrumented, rootResult.nonInstrumentedFiles);
1733
- roots.push(rootResult.rootEntry);
1734
- }
1735
- if (useIncremental && previousManifest !== void 0) {
1736
- const deleted = detectDeletedFiles(previousManifest, allFiles);
1737
- cleanupDeletedFiles(deleted);
1738
- if (deleted.length > 0) hasChanges = true;
1739
- }
1740
- if (beforeBuild !== void 0) {
1741
- if (beforeBuild(COVERAGE_DIR)) hasChanges = true;
1742
- }
1743
- const placeFile = path$1.join(COVERAGE_DIR, "game.rbxl");
1744
- const manifest = writeManifest({
1745
- allFiles,
1746
- luauRoots,
1747
- manifestPath,
1748
- nonInstrumentedFiles: allNonInstrumented,
1749
- placeFile
1750
- });
1751
- if (!hasChanges && previousManifest?.placeFilePath !== void 0) return {
1752
- manifest,
1753
- placeFile: previousManifest.placeFilePath
1754
- };
1755
- buildRojoProject(rojoProjectPath, roots, placeFile);
1756
- return {
1757
- manifest,
1758
- placeFile
1759
- };
1760
- }
1761
- function containsLuauFiles(directoryPath) {
1762
- return fs$1.readdirSync(directoryPath, { withFileTypes: true }).some((entry) => {
1763
- if (entry.isFile() && entry.name.endsWith(".luau")) return true;
1764
- if (entry.isDirectory()) return containsLuauFiles(path$1.join(directoryPath, entry.name));
1765
- return false;
1766
- });
1767
- }
1768
- function findRojoProject(config) {
1769
- if (config.rojoProject !== void 0) return config.rojoProject;
1770
- const defaultPath = path$1.join(config.rootDir, "default.project.json");
1771
- if (fs$1.existsSync(defaultPath)) return defaultPath;
1772
- const projectFile = fs$1.readdirSync(config.rootDir, "utf-8").find((file) => file.endsWith(".project.json"));
1773
- if (projectFile !== void 0) return path$1.join(config.rootDir, projectFile);
1774
- throw new Error("No Rojo project found. Set rojoProject in config or add a .project.json file.");
1775
- }
1776
- function resolveLuauRootsWithRojo(config, rojoProjectPath) {
1777
- if (config.luauRoots !== void 0 && config.luauRoots.length > 0) return config.luauRoots;
1778
- try {
1779
- const resolvedPath = rojoProjectPath ?? findRojoProject(config);
1780
- const roots = collectLuauRootsFromRojo(JSON.parse(fs$1.readFileSync(resolvedPath, "utf-8")), config);
1781
- if (roots.length > 0) return roots;
1782
- } catch (err) {
1783
- if (err instanceof SyntaxError) throw new Error(`Malformed Rojo project JSON: ${err.message}`, { cause: err });
1784
- }
1785
- const outDirectory = (getTsconfig(config.rootDir) ?? void 0)?.config.compilerOptions?.outDir;
1786
- if (outDirectory !== void 0) return [outDirectory];
1787
- throw new Error("Could not determine luauRoots. Set luauRoots in config or ensure tsconfig has outDir.");
1788
- }
1789
- /**
1790
- * Shared directory walker. Skips node_modules, .jest-roblox-coverage, and
1791
- * dot-prefixed directories — matching parse-ast.luau:113-147.
1792
- * `predicate` receives the entry name and returns true to collect the file.
1793
- */
1794
- function walkLuauDirectory(directory, relativeTo, predicate, results) {
1795
- const entries = fs$1.readdirSync(directory, { withFileTypes: true });
1796
- for (const entry of entries) {
1797
- const fullPath = path$1.join(directory, entry.name).replaceAll("\\", "/");
1798
- if (entry.isDirectory()) {
1799
- if (entry.name === "node_modules" || entry.name === COVERAGE_DIR) continue;
1800
- if (entry.name.startsWith(".")) continue;
1801
- walkLuauDirectory(fullPath, relativeTo, predicate, results);
1802
- } else if (predicate(entry.name)) {
1803
- const relative = fullPath.slice(relativeTo.length + 1);
1804
- results.push(relative);
1805
- }
1806
- }
1807
- }
1808
- function isInstrumentableFile(name) {
1809
- return (name.endsWith(".luau") || name.endsWith(".lua")) && !isNonInstrumentedFile(name);
1810
- }
1811
- function validateRelativeRoots(luauRoots) {
1812
- for (const root of luauRoots) if (path$1.isAbsolute(root)) throw new Error("luauRoots must be relative paths, got absolute path. Set a relative outDir in tsconfig or relative luauRoots in config.");
1813
- }
1814
- function carryForwardRecords(luauRoot, previousManifest, allFiles, skipFiles) {
1815
- const posixRoot = luauRoot.replaceAll("\\", "/");
1816
- for (const relativePath of skipFiles) {
1817
- const fileKey = `${posixRoot}/${relativePath}`;
1818
- Object.assign(allFiles, { [fileKey]: previousManifest.files[fileKey] });
1819
- }
1820
- }
1821
- function discoverNonInstrumentedFiles(directory, relativeTo, results) {
1822
- walkLuauDirectory(directory, relativeTo, isNonInstrumentedFile, results);
1823
- }
1824
- function pruneStaleNonInstrumented(posixRoot, previousNonInstrumented, currentFiles) {
1825
- if (previousNonInstrumented === void 0) return false;
1826
- let changed = false;
1827
- for (const [fileKey, record] of Object.entries(previousNonInstrumented)) {
1828
- if (!fileKey.startsWith(`${posixRoot}/`)) continue;
1829
- if (fileKey in currentFiles) continue;
1830
- try {
1831
- if (fs$1.existsSync(record.shadowPath)) fs$1.unlinkSync(record.shadowPath);
1832
- } catch {}
1833
- changed = true;
1834
- }
1835
- return changed;
1836
- }
1837
- function syncNonInstrumentedFiles(luauRoot, shadowDirectory, previousNonInstrumented) {
1838
- const posixRoot = luauRoot.replaceAll("\\", "/");
1839
- const discovered = [];
1840
- discoverNonInstrumentedFiles(posixRoot, posixRoot, discovered);
1841
- const files = {};
1842
- let changed = false;
1843
- for (const relativePath of discovered) {
1844
- const sourcePath = `${posixRoot}/${relativePath}`;
1845
- const shadowPath = `${shadowDirectory}/${relativePath}`;
1846
- const currentHash = hashBuffer(fs$1.readFileSync(path$1.resolve(sourcePath)));
1847
- const previousRecord = previousNonInstrumented?.[sourcePath];
1848
- if (previousRecord?.sourceHash === currentHash) {
1849
- files[sourcePath] = previousRecord;
1850
- continue;
1851
- }
1852
- const outputDirectory = path$1.dirname(shadowPath);
1853
- fs$1.mkdirSync(outputDirectory, { recursive: true });
1854
- fs$1.copyFileSync(path$1.resolve(sourcePath), shadowPath);
1855
- files[sourcePath] = {
1856
- shadowPath,
1857
- sourceHash: currentHash,
1858
- sourcePath
1859
- };
1860
- changed = true;
1861
- }
1862
- changed = pruneStaleNonInstrumented(posixRoot, previousNonInstrumented, files) || changed;
1863
- return {
1864
- changed,
1865
- files
1866
- };
1867
- }
1868
- function computeSkipFiles(luauRoot, previousManifest) {
1869
- const skipFiles = /* @__PURE__ */ new Set();
1870
- const posixRoot = luauRoot.replaceAll("\\", "/");
1871
- for (const [fileKey, record] of Object.entries(previousManifest.files)) {
1872
- if (!fileKey.startsWith(`${posixRoot}/`)) continue;
1873
- const relativePath = fileKey.slice(posixRoot.length + 1);
1874
- const sourcePath = path$1.resolve(record.originalLuauPath);
1875
- if (!fs$1.existsSync(sourcePath)) continue;
1876
- if (hashBuffer(fs$1.readFileSync(sourcePath)) === record.sourceHash) skipFiles.add(relativePath);
1877
- }
1878
- return skipFiles;
1879
- }
1880
- function countPreviousFilesForRoot(luauRoot, previousManifest) {
1881
- const posixRoot = luauRoot.replaceAll("\\", "/");
1882
- let count = 0;
1883
- for (const fileKey of Object.keys(previousManifest.files)) if (fileKey.startsWith(`${posixRoot}/`)) count++;
1884
- return count;
1885
- }
1886
- /**
1887
- * Check if all files in this root are unchanged (full cache hit).
1888
- *
1889
- * `changed` means previous files were deleted or modified — it does NOT cover
1890
- * new files appearing on disk. When `allCached` is false but `changed` is also
1891
- * false, new files exist and the caller detects them when `instrumentRoot`
1892
- * returns non-empty results.
1893
- */
1894
- function computeIncrementalState(luauRoot, previousManifest) {
1895
- const skipFiles = computeSkipFiles(luauRoot, previousManifest);
1896
- const previousCount = countPreviousFilesForRoot(luauRoot, previousManifest);
1897
- const changed = skipFiles.size !== previousCount;
1898
- if (changed) return {
1899
- allCached: false,
1900
- changed,
1901
- skipFiles
1902
- };
1903
- return {
1904
- allCached: discoverInstrumentableFiles(luauRoot).size === previousCount,
1905
- changed,
1906
- skipFiles
1907
- };
1908
- }
1909
- function buildFullCacheResult(options) {
1910
- const { luauRoot, previousManifest, rootEntry, shadowDirectory, skipFiles } = options;
1911
- const allFiles = {};
1912
- carryForwardRecords(luauRoot, previousManifest, allFiles, skipFiles);
1913
- const syncResult = syncNonInstrumentedFiles(luauRoot, shadowDirectory, previousManifest.nonInstrumentedFiles);
1914
- return {
1915
- changed: syncResult.changed,
1916
- files: allFiles,
1917
- nonInstrumentedFiles: syncResult.files,
1918
- rootEntry
1919
- };
1920
- }
1921
- function instrumentRootWithCache(luauRoot, useIncremental, previousManifest) {
1922
- const shadowDirectory = path$1.join(COVERAGE_DIR, luauRoot).replaceAll("\\", "/");
1923
- let changed = false;
1924
- if (!useIncremental) {
1925
- fs$1.mkdirSync(shadowDirectory, { recursive: true });
1926
- fs$1.cpSync(luauRoot, shadowDirectory, { recursive: true });
1927
- }
1928
- const rootEntry = {
1929
- luauRoot,
1930
- relocatedShadowDirectory: path$1.relative(COVERAGE_DIR, shadowDirectory).replaceAll("\\", "/"),
1931
- shadowDir: shadowDirectory
1932
- };
1933
- let skipFiles;
1934
- if (useIncremental && previousManifest !== void 0) {
1935
- const { allCached, changed: hasChanges, skipFiles: computed } = computeIncrementalState(luauRoot, previousManifest);
1936
- skipFiles = computed;
1937
- changed = hasChanges;
1938
- if (allCached) return buildFullCacheResult({
1939
- luauRoot,
1940
- previousManifest,
1941
- rootEntry,
1942
- shadowDirectory,
1943
- skipFiles
1944
- });
1945
- }
1946
- const files = instrumentRoot({
1947
- luauRoot,
1948
- shadowDir: shadowDirectory,
1949
- skipFiles
1950
- });
1951
- if (Object.keys(files).length > 0) changed = true;
1952
- const allFiles = { ...files };
1953
- if (useIncremental && previousManifest !== void 0 && skipFiles !== void 0) carryForwardRecords(luauRoot, previousManifest, allFiles, skipFiles);
1954
- const syncResult = syncNonInstrumentedFiles(luauRoot, shadowDirectory, previousManifest?.nonInstrumentedFiles);
1955
- if (syncResult.changed) changed = true;
1956
- return {
1957
- changed,
1958
- files: allFiles,
1959
- nonInstrumentedFiles: syncResult.files,
1960
- rootEntry
1961
- };
1962
- }
1963
- function writeManifest(options) {
1964
- const { allFiles, luauRoots, manifestPath, nonInstrumentedFiles, placeFile } = options;
1965
- const manifest = {
1966
- files: allFiles,
1967
- generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
1968
- instrumenterVersion: 2,
1969
- luauRoots,
1970
- nonInstrumentedFiles,
1971
- placeFilePath: placeFile,
1972
- shadowDir: COVERAGE_DIR,
1973
- version: 1
1974
- };
1975
- fs$1.mkdirSync(path$1.dirname(manifestPath), { recursive: true });
1976
- fs$1.writeFileSync(manifestPath, JSON.stringify(manifest, void 0, " "));
1977
- return manifest;
1978
- }
1979
- function buildRojoProject(rojoProjectPath, roots, placeFile) {
1980
- const rojoProjectRaw = rojoProjectSchema(JSON.parse(fs$1.readFileSync(rojoProjectPath, "utf-8")));
1981
- if (rojoProjectRaw instanceof type.errors) throw new Error(`Malformed Rojo project JSON: ${rojoProjectRaw.toString()}`);
1982
- const projectRelocation = path$1.relative(COVERAGE_DIR, path$1.dirname(rojoProjectPath)).replaceAll("\\", "/");
1983
- const rewritten = rewriteRojoProject({
1984
- ...rojoProjectRaw,
1985
- tree: resolveNestedProjects(rojoProjectRaw.tree, path$1.dirname(rojoProjectPath))
1986
- }, {
1987
- projectRelocation,
1988
- roots
1989
- });
1990
- const rewrittenProjectPath = path$1.join(COVERAGE_DIR, path$1.basename(rojoProjectPath));
1991
- fs$1.writeFileSync(rewrittenProjectPath, JSON.stringify(rewritten, void 0, " "));
1992
- buildWithRojo(rewrittenProjectPath, placeFile);
1993
- }
1994
- function loadPreviousManifest(manifestPath) {
1995
- if (!fs$1.existsSync(manifestPath)) return;
1996
- try {
1997
- const result = previousManifestSchema(JSON.parse(fs$1.readFileSync(manifestPath, "utf-8")));
1998
- if (result instanceof type.errors) return;
1999
- return result;
2000
- } catch {
2001
- return;
2002
- }
2003
- }
2004
- function canUseIncremental(previousManifest, config) {
2005
- if (!config.cache) return false;
2006
- if (previousManifest === void 0) return false;
2007
- if (previousManifest.instrumenterVersion !== 2) return false;
2008
- if (previousManifest.nonInstrumentedFiles === void 0) return false;
2009
- return true;
2010
- }
2011
- function detectDeletedFiles(previousManifest, currentFiles) {
2012
- const deleted = [];
2013
- for (const [fileKey, record] of Object.entries(previousManifest.files)) if (!(fileKey in currentFiles)) deleted.push(record);
2014
- return deleted;
2015
- }
2016
- function cleanupDeletedFiles(records) {
2017
- for (const record of records) try {
2018
- if (fs$1.existsSync(record.instrumentedLuauPath)) fs$1.unlinkSync(record.instrumentedLuauPath);
2019
- if (fs$1.existsSync(record.coverageMapPath)) fs$1.unlinkSync(record.coverageMapPath);
2020
- } catch {}
2021
- }
2022
- //#endregion
2023
- //#region src/coverage/reporter.ts
2024
- const VALID_REPORTERS = new Set([
2025
- "clover",
2026
- "cobertura",
2027
- "html",
2028
- "html-spa",
2029
- "json",
2030
- "json-summary",
2031
- "lcov",
2032
- "lcovonly",
2033
- "none",
2034
- "teamcity",
2035
- "text",
2036
- "text-lcov",
2037
- "text-summary"
2038
- ]);
2039
- function printCoverageHeader() {
2040
- const header = ` ${color.blue("%")} ${color.dim("Coverage report from")} ${color.yellow("istanbul")}`;
2041
- process.stdout.write(`\n${header}\n`);
2042
- }
2043
- const TEXT_REPORTERS = new Set(["text", "text-summary"]);
2044
- function generateReports(options) {
2045
- const coverageMap = buildCoverageMap(filterMappedFiles(options.mapped, options.collectCoverageFrom));
2046
- const context = istanbulReport.createContext({
2047
- coverageMap,
2048
- defaultSummarizer: options.agentMode === true ? "flat" : "pkg",
2049
- dir: options.coverageDirectory
2050
- });
2051
- const terminalColumns = getTerminalColumns();
2052
- const allFilesFull = options.agentMode === true && isAllFilesFull(coverageMap);
2053
- for (const reporterName of options.reporters) {
2054
- if (!isValidReporter(reporterName)) throw new Error(`Unknown coverage reporter: ${reporterName}`);
2055
- if (allFilesFull && TEXT_REPORTERS.has(reporterName)) {
2056
- const fileCount = coverageMap.files().length;
2057
- const label = fileCount === 1 ? "file" : "files";
2058
- process.stdout.write(`Coverage: 100% (${fileCount} ${label})\n`);
2059
- continue;
2060
- }
2061
- let reporterOptions = {};
2062
- if (reporterName === "text") reporterOptions = {
2063
- maxCols: terminalColumns,
2064
- skipFull: options.agentMode === true
2065
- };
2066
- else if (TEXT_REPORTERS.has(reporterName)) reporterOptions = { skipFull: options.agentMode === true };
2067
- istanbulReports.create(reporterName, reporterOptions).execute(context);
2068
- }
2069
- }
2070
- function checkThresholds(mapped, thresholds, collectCoverageFrom) {
2071
- const summary = buildCoverageMap(filterMappedFiles(mapped, collectCoverageFrom)).getCoverageSummary();
2072
- const failures = [];
2073
- const checks = [
2074
- {
2075
- metric: "statements",
2076
- threshold: thresholds.statements
2077
- },
2078
- {
2079
- metric: "functions",
2080
- threshold: thresholds.functions
2081
- },
2082
- {
2083
- metric: "branches",
2084
- threshold: thresholds.branches
2085
- },
2086
- {
2087
- metric: "lines",
2088
- threshold: thresholds.lines
2089
- }
2090
- ];
2091
- const summaryData = type({ "[string]": { pct: "number | string" } })(summary.toJSON());
2092
- assert(!(summaryData instanceof type.errors), "Istanbul summary produced invalid data");
2093
- for (const { metric, threshold } of checks) {
2094
- if (threshold === void 0) continue;
2095
- const pct = summaryData[metric]?.pct;
2096
- if (typeof pct !== "number") continue;
2097
- if (pct < threshold) failures.push({
2098
- actual: pct,
2099
- metric,
2100
- threshold
2101
- });
2102
- }
2103
- return {
2104
- failures,
2105
- passed: failures.length === 0
2106
- };
2107
- }
2108
- function getTerminalColumns() {
2109
- if (process.stdout.columns !== void 0) return process.stdout.columns;
2110
- const columnsEnvironment = process.env["COLUMNS"];
2111
- if (columnsEnvironment === void 0) return;
2112
- const parsed = Number(columnsEnvironment);
2113
- return Number.isFinite(parsed) && parsed > 0 ? parsed : void 0;
2114
- }
2115
- function isAllFilesFull(coverageMap) {
2116
- const files = coverageMap.files();
2117
- if (files.length === 0) return false;
2118
- return files.every((file) => {
2119
- const summary = coverageMap.fileCoverageFor(file).toSummary();
2120
- return summary.statements.pct === 100 && summary.branches.pct === 100 && summary.functions.pct === 100 && summary.lines.pct === 100;
2121
- });
2122
- }
2123
- function buildCoverageMap(mapped) {
2124
- const coverageMap = istanbulCoverage.createCoverageMap({});
2125
- for (const [filePath, fileCoverage] of Object.entries(mapped.files)) {
2126
- const fileCoverageData = {
2127
- b: fileCoverage.b,
2128
- branchMap: Object.fromEntries(Object.entries(fileCoverage.branchMap).map(([id, entry]) => {
2129
- return [id, {
2130
- line: entry.loc.start.line,
2131
- loc: entry.loc,
2132
- locations: entry.locations,
2133
- type: entry.type
2134
- }];
2135
- })),
2136
- f: fileCoverage.f,
2137
- fnMap: Object.fromEntries(Object.entries(fileCoverage.fnMap).map(([id, entry]) => {
2138
- return [id, {
2139
- name: entry.name,
2140
- decl: entry.loc,
2141
- line: entry.loc.start.line,
2142
- loc: entry.loc
2143
- }];
2144
- })),
2145
- path: path$1.resolve(filePath),
2146
- s: fileCoverage.s,
2147
- statementMap: fileCoverage.statementMap
2148
- };
2149
- coverageMap.addFileCoverage(fileCoverageData);
2150
- }
2151
- return coverageMap;
2152
- }
2153
- function createGlobMatcher(patterns) {
2154
- const withPath = patterns.filter((pattern) => pattern.includes("/"));
2155
- const withoutPath = patterns.filter((pattern) => !pattern.includes("/"));
2156
- const matchers = [];
2157
- if (withPath.length > 0) matchers.push(picomatch(withPath));
2158
- if (withoutPath.length > 0) matchers.push(picomatch(withoutPath, { matchBase: true }));
2159
- return (filePath) => matchers.some((matcher) => matcher(filePath));
2160
- }
2161
- function filterMappedFiles(mapped, collectCoverageFrom) {
2162
- if (collectCoverageFrom === void 0 || collectCoverageFrom.length === 0) return mapped;
2163
- const includePatterns = collectCoverageFrom.filter((pattern) => !pattern.startsWith("!"));
2164
- const excludePatterns = collectCoverageFrom.filter((pattern) => pattern.startsWith("!")).map((pattern) => pattern.slice(1));
2165
- const isIncluded = includePatterns.length > 0 ? createGlobMatcher(includePatterns) : () => true;
2166
- const isExcluded = excludePatterns.length > 0 ? createGlobMatcher(excludePatterns) : () => false;
2167
- const cwd = process.cwd();
2168
- return { files: Object.fromEntries(Object.entries(mapped.files).filter(([filePath]) => {
2169
- const relativePath = path$1.isAbsolute(filePath) ? path$1.relative(cwd, filePath).replaceAll("\\", "/") : filePath;
2170
- return isIncluded(relativePath) && !isExcluded(relativePath);
2171
- })) };
2172
- }
2173
- function isValidReporter(name) {
2174
- return VALID_REPORTERS.has(name);
2175
- }
2176
- //#endregion
2177
- //#region src/utils/glob.ts
2178
- function globSync(pattern, options = {}) {
2179
- const cwd = options.cwd ?? process.cwd();
2180
- return walkDirectory(cwd, cwd).filter((file) => matchesGlobPattern(file, pattern));
2181
- }
2182
- function matchesGlobPattern(filePath, pattern) {
2183
- const regexPattern = pattern.replace(/\./g, "\\.").replace(/\*\*\//g, "{{DOUBLESTAR_SLASH}}").replace(/\*\*/g, ".*").replace(/\*/g, "[^/]*").replace(/\{\{DOUBLESTAR_SLASH\}\}/g, "(.+/)?");
2184
- return new RegExp(`^${regexPattern}$`).test(filePath);
2185
- }
2186
- function walkDirectory(directoryPath, baseDirectory) {
2187
- const results = [];
2188
- try {
2189
- const entries = fs$1.readdirSync(directoryPath, { withFileTypes: true });
2190
- for (const entry of entries) {
2191
- const fullPath = path$1.join(directoryPath, entry.name);
2192
- const relativePath = path$1.relative(baseDirectory, fullPath).replace(/\\/g, "/");
2193
- if (entry.isDirectory()) {
2194
- if (!entry.name.startsWith(".") && entry.name !== "node_modules") results.push(...walkDirectory(fullPath, baseDirectory));
2195
- } else results.push(relativePath);
2196
- }
2197
- } catch {}
2198
- return results;
2199
- }
2200
28
  //#endregion
2201
29
  //#region src/cli.ts
2202
30
  const VERSION = version;
2203
- const DEFAULT_ROJO_PROJECT = "default.project.json";
2204
- const TYPE_TEST_PATTERN = /\.(test-d|spec-d)\.ts$/;
2205
31
  const HELP_TEXT = `
2206
32
  Usage: jest-roblox [options] [files...]
2207
33
 
@@ -2225,8 +51,12 @@ Options:
2225
51
  --coverageDirectory <path> Directory for coverage output (default: coverage)
2226
52
  --coverageReporters <r...> Coverage reporters (default: text, lcov)
2227
53
  --formatters <name...> Output formatters (default, agent, json, github-actions)
2228
- --no-cache Force re-upload place file (skip cache)
2229
- --pollInterval <ms> Open Cloud poll interval in ms (default: 500)
54
+ --workspace Run tests across all workspace packages
55
+ --packages <names> Comma-separated package names (workspace mode)
56
+ --workspace-root <path> Directory to load the workspace config from
57
+ (use when running outside any package)
58
+ --affected-since <ref> Run only packages affected since git ref via turbo/nx
59
+ --no-coverage-cache Force a clean coverage re-instrumentation (skip incremental cache)
2230
60
  --parallel [n] Open-Cloud-only: number of concurrent sessions
2231
61
  (or "auto" = min(jobs, 3); default: 1 session)
2232
62
  --project <name...> Filter which named projects to run
@@ -2236,13 +66,22 @@ Options:
2236
66
  --typecheck Enable type testing (*.test-d.ts, *.spec-d.ts)
2237
67
  --typecheckOnly Run only type tests, skip runtime tests
2238
68
  --typecheckTsconfig <path> tsconfig for type testing
69
+ --apiKey <key> Roblox Open Cloud API key
70
+ --universeId <id> Target universe ID
71
+ --placeId <id> Target place ID
2239
72
  --help Show this help message
2240
73
  --version Show version number
2241
74
 
2242
- Environment Variables (open-cloud backend only):
2243
- ROBLOX_OPEN_CLOUD_API_KEY API key for Roblox Open Cloud
2244
- ROBLOX_UNIVERSE_ID Target universe ID
2245
- ROBLOX_PLACE_ID Target place ID
75
+ Open Cloud credentials (open-cloud backend only):
76
+ Sources, in precedence order:
77
+ 1. CLI flags (--apiKey, --universeId, --placeId)
78
+ 2. JEST_ROBLOX_* env vars (JEST_ROBLOX_OPEN_CLOUD_API_KEY,
79
+ JEST_ROBLOX_UNIVERSE_ID, JEST_ROBLOX_PLACE_ID)
80
+ 3. ROBLOX_* env vars (ROBLOX_OPEN_CLOUD_API_KEY, ROBLOX_UNIVERSE_ID,
81
+ ROBLOX_PLACE_ID)
82
+ 4. jest.config.ts (universeId, placeId — apiKey is CLI/env only)
83
+
84
+ --apiKey is visible in process listings; prefer env vars in CI.
2246
85
 
2247
86
  Examples:
2248
87
  jest-roblox Run all tests (open-cloud)
@@ -2257,8 +96,9 @@ function parseArgs(args) {
2257
96
  allowPositionals: true,
2258
97
  args: normalizeParallelFlag(args),
2259
98
  options: {
99
+ "affected-since": { type: "string" },
100
+ "apiKey": { type: "string" },
2260
101
  "backend": { type: "string" },
2261
- "cache": { type: "boolean" },
2262
102
  "collectCoverageFrom": {
2263
103
  multiple: true,
2264
104
  type: "string"
@@ -2266,6 +106,7 @@ function parseArgs(args) {
2266
106
  "color": { type: "boolean" },
2267
107
  "config": { type: "string" },
2268
108
  "coverage": { type: "boolean" },
109
+ "coverage-cache": { type: "boolean" },
2269
110
  "coverageDirectory": { type: "string" },
2270
111
  "coverageReporters": {
2271
112
  multiple: true,
@@ -2280,13 +121,14 @@ function parseArgs(args) {
2280
121
  default: false,
2281
122
  type: "boolean"
2282
123
  },
2283
- "no-cache": { type: "boolean" },
2284
124
  "no-color": { type: "boolean" },
125
+ "no-coverage-cache": { type: "boolean" },
2285
126
  "no-show-luau": { type: "boolean" },
2286
127
  "outputFile": { type: "string" },
128
+ "packages": { type: "string" },
2287
129
  "parallel": { type: "string" },
2288
130
  "passWithNoTests": { type: "boolean" },
2289
- "pollInterval": { type: "string" },
131
+ "placeId": { type: "string" },
2290
132
  "port": { type: "string" },
2291
133
  "project": {
2292
134
  multiple: true,
@@ -2313,6 +155,7 @@ function parseArgs(args) {
2313
155
  "typecheck": { type: "boolean" },
2314
156
  "typecheckOnly": { type: "boolean" },
2315
157
  "typecheckTsconfig": { type: "string" },
158
+ "universeId": { type: "string" },
2316
159
  "updateSnapshot": {
2317
160
  short: "u",
2318
161
  type: "boolean"
@@ -2321,20 +164,23 @@ function parseArgs(args) {
2321
164
  "version": {
2322
165
  default: false,
2323
166
  type: "boolean"
2324
- }
167
+ },
168
+ "workspace": { type: "boolean" },
169
+ "workspace-root": { type: "string" }
2325
170
  },
2326
171
  strict: true
2327
172
  });
2328
- const pollInterval = values.pollInterval !== void 0 ? Number.parseInt(values.pollInterval, 10) : void 0;
2329
173
  const port = values.port !== void 0 ? Number.parseInt(values.port, 10) : void 0;
2330
174
  const timeout = values.timeout !== void 0 ? Number.parseInt(values.timeout, 10) : void 0;
2331
175
  return {
176
+ affectedSince: values["affected-since"],
177
+ apiKey: values.apiKey,
2332
178
  backend: validateBackend(values.backend),
2333
- cache: values["no-cache"] === true ? false : values.cache,
2334
179
  collectCoverage: values.coverage,
2335
180
  collectCoverageFrom: values.collectCoverageFrom,
2336
181
  color: values["no-color"] === true ? false : values.color,
2337
182
  config: values.config,
183
+ coverageCache: values["no-coverage-cache"] === true ? false : values["coverage-cache"],
2338
184
  coverageDirectory: values.coverageDirectory,
2339
185
  coverageReporters: values.coverageReporters,
2340
186
  files: positionals.length > 0 ? positionals : void 0,
@@ -2342,9 +188,10 @@ function parseArgs(args) {
2342
188
  gameOutput: values.gameOutput,
2343
189
  help: values.help,
2344
190
  outputFile: values.outputFile,
191
+ packages: values.packages,
2345
192
  parallel: parseParallelValue(values.parallel),
2346
193
  passWithNoTests: values.passWithNoTests,
2347
- pollInterval,
194
+ placeId: values.placeId,
2348
195
  port,
2349
196
  project: values.project,
2350
197
  rojoProject: values.rojoProject,
@@ -2359,77 +206,12 @@ function parseArgs(args) {
2359
206
  typecheck: values.typecheckOnly === true ? true : values.typecheck,
2360
207
  typecheckOnly: values.typecheckOnly,
2361
208
  typecheckTsconfig: values.typecheckTsconfig,
209
+ universeId: values.universeId,
2362
210
  updateSnapshot: values.updateSnapshot,
2363
211
  verbose: values.verbose,
2364
- version: values.version
2365
- };
2366
- }
2367
- function filterByName(projects, names) {
2368
- const available = new Set(projects.map((project) => project.displayName));
2369
- const unknown = names.filter((name) => !available.has(name));
2370
- if (unknown.length > 0) throw new Error(`Unknown project name(s): ${unknown.join(", ")}. Available: ${[...available].join(", ")}`);
2371
- const nameSet = new Set(names);
2372
- return projects.filter((project) => nameSet.has(project.displayName));
2373
- }
2374
- function mergeProjectResults(results) {
2375
- assert(results.length > 0, "mergeProjectResults requires at least one result");
2376
- if (results.length === 1) {
2377
- const [first] = results;
2378
- return first;
2379
- }
2380
- let numberFailedTests = 0;
2381
- let numberPassedTests = 0;
2382
- let numberPendingTests = 0;
2383
- let numberTodoTests = 0;
2384
- let numberTotalTests = 0;
2385
- let startTime = Number.POSITIVE_INFINITY;
2386
- let success = true;
2387
- const testResults = [];
2388
- let testsMs = 0;
2389
- let setupMs = 0;
2390
- let mergedCoverage;
2391
- for (const result of results) {
2392
- numberFailedTests += result.result.numFailedTests;
2393
- numberPassedTests += result.result.numPassedTests;
2394
- numberPendingTests += result.result.numPendingTests;
2395
- numberTodoTests += result.result.numTodoTests ?? 0;
2396
- numberTotalTests += result.result.numTotalTests;
2397
- startTime = Math.min(startTime, result.result.startTime);
2398
- success &&= result.result.success;
2399
- testResults.push(...result.result.testResults);
2400
- testsMs += result.timing.testsMs;
2401
- setupMs += result.timing.setupMs ?? 0;
2402
- if (result.coverageData !== void 0) mergedCoverage = mergeRawCoverage(mergedCoverage, result.coverageData);
2403
- }
2404
- const [sharedTiming] = results;
2405
- const mergedStartTime = Math.min(...results.map((result) => result.timing.startTime));
2406
- const totalMs = Math.max(...results.map((result) => result.timing.totalMs));
2407
- const mergedSourceMapper = combineSourceMappers(results.flatMap((result) => result.sourceMapper !== void 0 ? [result.sourceMapper] : []));
2408
- return {
2409
- coverageData: mergedCoverage,
2410
- exitCode: success ? 0 : 1,
2411
- output: "",
2412
- result: {
2413
- numFailedTests: numberFailedTests,
2414
- numPassedTests: numberPassedTests,
2415
- numPendingTests: numberPendingTests,
2416
- numTodoTests: numberTodoTests,
2417
- numTotalTests: numberTotalTests,
2418
- startTime,
2419
- success,
2420
- testResults
2421
- },
2422
- sourceMapper: mergedSourceMapper,
2423
- timing: {
2424
- coverageMs: sharedTiming.timing.coverageMs,
2425
- executionMs: sharedTiming.timing.executionMs,
2426
- setupMs: setupMs > 0 ? setupMs : void 0,
2427
- startTime: mergedStartTime,
2428
- testsMs,
2429
- totalMs,
2430
- uploadCached: sharedTiming.timing.uploadCached,
2431
- uploadMs: sharedTiming.timing.uploadMs
2432
- }
212
+ version: values.version,
213
+ workspace: values.workspace,
214
+ workspaceRoot: values["workspace-root"]
2433
215
  };
2434
216
  }
2435
217
  async function run(args) {
@@ -2443,12 +225,6 @@ async function run(args) {
2443
225
  async function main() {
2444
226
  process.exitCode = await run(process.argv.slice(2));
2445
227
  }
2446
- /**
2447
- * `--parallel` with no value means `"auto"`. Node's `parseArgs` can't express
2448
- * optional values, so rewrite bare `--parallel` (at the end of argv, or
2449
- * followed by another `--flag`, or followed by a non-numeric, non-"auto" token)
2450
- * into `--parallel auto` before handing it off.
2451
- */
2452
228
  const PARALLEL_FLAG = "--parallel";
2453
229
  function normalizeParallelFlag(args) {
2454
230
  const out = [];
@@ -2479,6 +255,46 @@ function formatGameOutputLines(raw) {
2479
255
  if (entries.length === 0) return;
2480
256
  return entries.map((entry) => entry.message.replace(/^/gm, " ")).join("\n");
2481
257
  }
258
+ const EXIT_CODE_MESSAGE = /^Exited with code: \d+$/;
259
+ function formatLuauErrorBanner(err) {
260
+ const bannerLines = formatGameOutputLines(err.bannerOutput);
261
+ if (EXIT_CODE_MESSAGE.test(err.message) && bannerLines !== void 0) return formatBanner({
262
+ body: [bannerLines, `\n ${color.dim(err.message)}`],
263
+ level: "error",
264
+ title: "Test Run Failed"
265
+ });
266
+ const body = [color.red(err.message)];
267
+ const hint = getLuauErrorHint(err.message);
268
+ if (hint !== void 0) body.push(`\n ${color.dim("Hint:")} ${hint}`);
269
+ if (bannerLines !== void 0) body.push(`\n ${color.dim("Game output:")}\n${bannerLines}`);
270
+ return formatBanner({
271
+ body,
272
+ level: "error",
273
+ title: "Luau Error"
274
+ });
275
+ }
276
+ function formatChainExtras(entry) {
277
+ const pieces = [];
278
+ if (entry.code !== void 0) pieces.push(`code=${entry.code}`);
279
+ if (entry.errno !== void 0) pieces.push(`errno=${entry.errno}`);
280
+ if (entry.syscall !== void 0) pieces.push(`syscall=${entry.syscall}`);
281
+ return pieces.length > 0 ? color.dim(` (${pieces.join(" ")})`) : "";
282
+ }
283
+ function formatBackendErrorBanner(err) {
284
+ const body = [color.red(err.message)];
285
+ const chain = walkErrorChain(err.cause);
286
+ body.push(`\n ${color.dim("Caused by:")}`);
287
+ for (const [index, entry] of chain.entries()) {
288
+ const extras = formatChainExtras(entry);
289
+ const label = color.dim(`[${index.toString()}]`);
290
+ body.push(` ${label} ${entry.name}: ${entry.message}${extras}`);
291
+ }
292
+ return formatBanner({
293
+ body,
294
+ level: "error",
295
+ title: "Backend Error"
296
+ });
297
+ }
2482
298
  function printError(err) {
2483
299
  if (err instanceof ConfigError) {
2484
300
  const body = [color.red(err.message)];
@@ -2488,452 +304,22 @@ function printError(err) {
2488
304
  level: "error",
2489
305
  title: "Config Error"
2490
306
  }));
2491
- } else if (err instanceof LuauScriptError) {
2492
- const body = [color.red(err.message)];
2493
- const hint = getLuauErrorHint(err.message);
2494
- if (hint !== void 0) body.push(`\n ${color.dim("Hint:")} ${hint}`);
2495
- const gameLines = formatGameOutputLines(err.gameOutput);
2496
- if (gameLines !== void 0) body.push(`\n ${color.dim("Game output:")}\n${gameLines}`);
2497
- process.stderr.write(formatBanner({
2498
- body,
2499
- level: "error",
2500
- title: "Luau Error"
2501
- }));
2502
- } else if (err instanceof Error) console.error(`Error: ${err.message}`);
307
+ } else if (err instanceof LuauScriptError) process.stderr.write(formatLuauErrorBanner(err));
308
+ else if (err instanceof Error && err.cause instanceof OpenCloudError) process.stderr.write(formatBackendErrorBanner(err));
309
+ else if (err instanceof Error) console.error(`Error: ${err.message}`);
2503
310
  else console.error("An unknown error occurred");
2504
311
  }
2505
- function writeGameOutputIfConfigured(config, gameOutput, options) {
2506
- if (config.gameOutput === void 0) return;
2507
- const entries = parseGameOutput(gameOutput);
2508
- writeGameOutput(config.gameOutput, entries);
2509
- if (!config.silent && options.hintsShown !== true) {
2510
- const notice = formatGameOutputNotice(config.gameOutput, entries.length);
2511
- if (notice) console.error(notice);
2512
- }
2513
- }
2514
- /**
2515
- * Multi-project variant: `--gameOutput` used to silently drop when a config
2516
- * declared `projects`, because the output path here never called
2517
- * `writeGameOutputIfConfigured`. Aggregate every project's parsed entries into
2518
- * one file so the contract matches the single-project path.
2519
- */
2520
- function writeAggregatedGameOutput(config, projectResults, options) {
2521
- if (config.gameOutput === void 0) return;
2522
- const entries = projectResults.flatMap((project) => parseGameOutput(project.result.gameOutput));
2523
- writeGameOutput(config.gameOutput, entries);
2524
- if (!config.silent && options.hintsShown !== true) {
2525
- const notice = formatGameOutputNotice(config.gameOutput, entries.length);
2526
- if (notice) console.error(notice);
2527
- }
2528
- }
2529
- function printFinalStatus(passed) {
2530
- const badge = passed ? color.bgGreen(color.black(color.bold(" PASS "))) : color.bgRed(color.white(color.bold(" FAIL ")));
2531
- process.stdout.write(`${badge}\n`);
2532
- }
2533
- function hasFormatter(config, name) {
2534
- return config.formatters?.some((entry) => Array.isArray(entry) ? entry[0] === name : entry === name) === true;
2535
- }
2536
- function usesAgentFormatter(config) {
2537
- return hasFormatter(config, "agent") && !config.verbose;
2538
- }
2539
- function processCoverage(config, coverageData) {
2540
- if (!config.collectCoverage) return true;
2541
- if (coverageData === void 0) {
2542
- if (!config.silent) process.stderr.write("Warning: coverage data was empty — the Rojo project may point at uninstrumented source\n");
2543
- return true;
2544
- }
2545
- const manifest = loadCoverageManifest(config.rootDir);
2546
- if (manifest === void 0) {
2547
- if (!config.silent) process.stderr.write("Warning: Coverage manifest not found, skipping TS mapping\n");
2548
- return true;
2549
- }
2550
- const mapped = mapCoverageToTypeScript(coverageData, manifest);
2551
- const coverageDirectory = path$1.resolve(config.rootDir, config.coverageDirectory);
2552
- if (!config.silent) printCoverageHeader();
2553
- generateReports({
2554
- agentMode: usesAgentFormatter(config),
2555
- collectCoverageFrom: config.collectCoverageFrom,
2556
- coverageDirectory,
2557
- mapped,
2558
- reporters: config.coverageReporters
2559
- });
2560
- if (config.coverageThreshold !== void 0) {
2561
- const result = checkThresholds(mapped, config.coverageThreshold, config.collectCoverageFrom);
2562
- if (!result.passed) {
2563
- for (const failure of result.failures) process.stderr.write(`Coverage threshold not met for ${failure.metric}: ${String(failure.actual.toFixed(2))}% < ${String(failure.threshold)}%\n`);
2564
- return false;
2565
- }
2566
- }
2567
- return true;
2568
- }
2569
- function runGitHubActionsFormatter(config, result, sourceMapper) {
2570
- assert(config.formatters !== void 0, "formatters is set by resolveFormatters");
2571
- const userOptions = findFormatterOptions(config.formatters, "github-actions");
2572
- if (userOptions === void 0) return;
2573
- const typedOptions = userOptions;
2574
- const options = resolveGitHubActionsOptions(typedOptions, sourceMapper);
2575
- if (typedOptions.displayAnnotations !== false) {
2576
- const annotations = formatAnnotations(result, options);
2577
- if (annotations !== "") process.stderr.write(`${annotations}\n`);
2578
- }
2579
- const { jobSummary } = typedOptions;
2580
- if (jobSummary?.enabled !== false) {
2581
- const outputPath = jobSummary?.outputPath ?? process.env["GITHUB_STEP_SUMMARY"];
2582
- if (outputPath !== void 0) {
2583
- const summary = formatJobSummary(result, options);
2584
- fs$1.appendFileSync(outputPath, summary);
2585
- }
2586
- }
2587
- }
2588
- function getAgentMaxFailures(config) {
2589
- assert(config.formatters !== void 0, "formatters is set by resolveFormatters");
2590
- const options = findFormatterOptions(config.formatters, "agent");
2591
- if (options !== void 0 && typeof options["maxFailures"] === "number") return options["maxFailures"];
2592
- return 10;
2593
- }
2594
- function usesDefaultFormatter(config) {
2595
- return !hasFormatter(config, "json") && !usesAgentFormatter(config);
2596
- }
2597
- function printOutput(output) {
2598
- if (output !== "") console.log(output);
2599
- }
2600
- function formatRuntimeOutput(config, runtimeResult, timing) {
2601
- return formatExecuteOutput({
2602
- config,
2603
- result: runtimeResult.result,
2604
- sourceMapper: runtimeResult.sourceMapper,
2605
- timing,
2606
- version: VERSION
2607
- });
2608
- }
2609
- function printFormattedOutput(options) {
2610
- const { config, mergedResult, runtimeResult, timing, typecheckResult } = options;
2611
- if (typecheckResult !== void 0 && runtimeResult !== void 0 && timing !== void 0) {
2612
- if (usesDefaultFormatter(config)) printOutput(formatResult(mergedResult, timing, {
2613
- collectCoverage: config.collectCoverage,
2614
- color: config.color,
2615
- rootDir: config.rootDir,
2616
- showLuau: config.showLuau,
2617
- sourceMapper: runtimeResult.sourceMapper,
2618
- typeErrors: typecheckResult.numFailedTests,
2619
- verbose: config.verbose,
2620
- version: VERSION
2621
- }));
2622
- else {
2623
- printOutput(formatRuntimeOutput(config, runtimeResult, timing));
2624
- process.stderr.write(formatTypecheckSummary(typecheckResult));
2625
- }
2626
- return;
2627
- }
2628
- if (typecheckResult !== void 0) {
2629
- process.stdout.write(formatTypecheckSummary(typecheckResult));
2630
- return;
2631
- }
2632
- assert(runtimeResult !== void 0 && timing !== void 0, "runtime result required");
2633
- printOutput(formatRuntimeOutput(config, runtimeResult, timing));
2634
- }
2635
- function addCoverageTiming(timing, coverageMs) {
2636
- return {
2637
- ...timing,
2638
- coverageMs,
2639
- totalMs: timing.totalMs + coverageMs
2640
- };
2641
- }
2642
- async function outputResults(config, typecheckResult, runtimeResult, preCoverageMs) {
2643
- const mergedResult = mergeResults(typecheckResult, runtimeResult?.result);
2644
- if (!config.silent) printFormattedOutput({
2645
- config,
2646
- mergedResult,
2647
- runtimeResult,
2648
- timing: runtimeResult !== void 0 ? addCoverageTiming(runtimeResult.timing, preCoverageMs) : void 0,
2649
- typecheckResult
2650
- });
2651
- const coveragePassed = processCoverage(config, runtimeResult?.coverageData);
2652
- if (config.outputFile !== void 0) await writeJsonFile(mergedResult, config.outputFile);
2653
- if (runtimeResult !== void 0) writeGameOutputIfConfigured(config, runtimeResult.gameOutput, { hintsShown: !mergedResult.success });
2654
- runGitHubActionsFormatter(config, mergedResult, runtimeResult?.sourceMapper);
2655
- const passed = mergedResult.success && coveragePassed;
2656
- if (!config.silent && config.collectCoverage) printFinalStatus(passed);
2657
- return passed ? 0 : 1;
2658
- }
2659
- function toProjectEntries(projectResults) {
2660
- return projectResults.map((pr) => {
2661
- return {
2662
- displayColor: pr.displayColor,
2663
- displayName: pr.displayName,
2664
- result: pr.result.result
2665
- };
2666
- });
2667
- }
2668
- function printMultiProjectOutput(options) {
2669
- const { config, merged, preCoverageMs, projectResults, typecheckResult } = options;
2670
- const timing = addCoverageTiming(merged.timing, preCoverageMs);
2671
- if (usesAgentFormatter(config)) {
2672
- printOutput(formatAgentMultiProject(toProjectEntries(projectResults), {
2673
- gameOutput: config.gameOutput,
2674
- maxFailures: getAgentMaxFailures(config),
2675
- outputFile: config.outputFile,
2676
- rootDir: config.rootDir,
2677
- sourceMapper: merged.sourceMapper,
2678
- typeErrorCount: typecheckResult?.numFailedTests
2679
- }));
2680
- return;
2681
- }
2682
- if (hasFormatter(config, "json")) {
2683
- printOutput(formatRuntimeOutput(config, merged, timing));
2684
- return;
2685
- }
2686
- printOutput(formatMultiProjectResult(toProjectEntries(projectResults), timing, {
2687
- collectCoverage: config.collectCoverage,
2688
- color: config.color,
2689
- rootDir: config.rootDir,
2690
- showLuau: config.showLuau,
2691
- sourceMapper: merged.sourceMapper,
2692
- typeErrors: typecheckResult?.numFailedTests,
2693
- verbose: config.verbose,
2694
- version: VERSION
2695
- }));
2696
- }
2697
- async function outputMultiProjectResults(config, projectResults, typecheckResult, preCoverageMs) {
2698
- const merged = mergeProjectResults(projectResults.map((pr) => pr.result));
2699
- const mergedResult = mergeResults(typecheckResult, merged.result);
2700
- if (!config.silent) {
2701
- printMultiProjectOutput({
2702
- config,
2703
- merged,
2704
- preCoverageMs,
2705
- projectResults,
2706
- typecheckResult
2707
- });
2708
- if (typecheckResult !== void 0 && !usesDefaultFormatter(config)) process.stderr.write(formatTypecheckSummary(typecheckResult));
2709
- }
2710
- const coveragePassed = processCoverage(config, merged.coverageData);
2711
- if (config.outputFile !== void 0) await writeJsonFile(mergedResult, config.outputFile);
2712
- writeAggregatedGameOutput(config, projectResults, { hintsShown: !mergedResult.success });
2713
- runGitHubActionsFormatter(config, mergedResult, merged.sourceMapper);
2714
- const passed = mergedResult.success && coveragePassed;
2715
- if (!config.silent && config.collectCoverage) printFinalStatus(passed);
2716
- return passed ? 0 : 1;
2717
- }
2718
- function loadRojoTree(config) {
2719
- const rojoPath = path$1.resolve(config.rootDir, config.rojoProject ?? DEFAULT_ROJO_PROJECT);
2720
- const content = fs$1.readFileSync(rojoPath, "utf8");
2721
- const validated = rojoProjectSchema(JSON.parse(content));
2722
- if (validated instanceof type.errors) throw new Error(`Invalid Rojo project: ${validated.summary}`);
2723
- return resolveNestedProjects(validated.tree, path$1.dirname(rojoPath));
2724
- }
2725
- const STUB_SKIP_KEYS = new Set([
2726
- "outDir",
2727
- "projects",
2728
- "root"
2729
- ]);
2730
- function buildStubConfig(config) {
2731
- const result = {};
2732
- for (const [key, value] of Object.entries(config)) if (!ROOT_ONLY_KEYS.has(key) && !STUB_SKIP_KEYS.has(key) && value !== void 0) result[key] = value;
2733
- return result;
2734
- }
2735
- function generateProjectStubs(projects, rootDirectory) {
2736
- const entries = [];
2737
- for (const project of projects) {
2738
- assertStubCollisionRule(project, rootDirectory);
2739
- const stubConfig = {
2740
- ...buildStubConfig(project.config),
2741
- displayName: project.displayName,
2742
- include: [],
2743
- testMatch: project.testMatch
2744
- };
2745
- for (const mount of project.rojoMounts) {
2746
- const outputPath = path$1.resolve(rootDirectory, mount.fsPath, "jest.config.lua");
2747
- entries.push({
2748
- config: stubConfig,
2749
- outputPath
2750
- });
2751
- }
2752
- }
2753
- generateProjectConfigs(entries);
2754
- }
2755
- function prepareMultiProjectCoverage(rootConfig, projects) {
2756
- if (!rootConfig.collectCoverage) return {
2757
- effectiveConfig: rootConfig,
2758
- preCoverageMs: 0
2759
- };
2760
- const start = Date.now();
2761
- const { placeFile } = prepareCoverage(rootConfig, (shadowDirectory) => {
2762
- return syncStubsToShadowDirectory(projects, rootConfig.rootDir, shadowDirectory);
2763
- });
2764
- return {
2765
- effectiveConfig: {
2766
- ...rootConfig,
2767
- placeFile
2768
- },
2769
- preCoverageMs: Date.now() - start
2770
- };
2771
- }
2772
- function classifyTestFiles(files, config) {
2773
- const typeTestFiles = config.typecheck ? files.filter((file) => TYPE_TEST_PATTERN.test(file)) : [];
2774
- return {
2775
- runtimeFiles: config.typecheckOnly ? [] : files.filter((file) => !TYPE_TEST_PATTERN.test(file)),
2776
- typeTestFiles
2777
- };
2778
- }
2779
- function applySetupResolver(config, resolve) {
2780
- if (config.setupFiles !== void 0) config.setupFiles = config.setupFiles.map(resolve);
2781
- if (config.setupFilesAfterEnv !== void 0) config.setupFilesAfterEnv = config.setupFilesAfterEnv.map(resolve);
2782
- }
2783
- /**
2784
- * Drop `parallel` on any non-open-cloud backend. Studio has no concept of
2785
- * multi-session, so passing `parallel` there is a silent noop — this lets
2786
- * users keep `parallel: 3` in `jest.config.ts` and still drop to
2787
- * `--backend studio` for debugging without editing config.
2788
- */
2789
- function effectiveParallelForBackend(parallel, backend) {
2790
- return backend.kind === "open-cloud" ? parallel : void 0;
2791
- }
2792
- async function runMultiProject(cli, rootConfig, projectEntries) {
2793
- const allProjects = await resolveAllProjects(projectEntries, rootConfig, loadRojoTree(rootConfig), rootConfig.rootDir);
2794
- const rojoConfigPath = path$1.resolve(rootConfig.rootDir, rootConfig.rojoProject ?? DEFAULT_ROJO_PROJECT);
2795
- const resolveSetup = createSetupResolver({
2796
- configDirectory: rootConfig.rootDir,
2797
- rojoConfigPath
2798
- });
2799
- for (const project of allProjects) applySetupResolver(project.config, resolveSetup);
2800
- const projects = cli.project !== void 0 ? filterByName(allProjects, cli.project) : allProjects;
2801
- generateProjectStubs(projects, rootConfig.rootDir);
2802
- if (!rootConfig.collectCoverage) buildWithRojo(path$1.resolve(rootConfig.rootDir, rootConfig.rojoProject ?? DEFAULT_ROJO_PROJECT), path$1.resolve(rootConfig.rootDir, rootConfig.placeFile));
2803
- const { effectiveConfig, preCoverageMs } = prepareMultiProjectCoverage(rootConfig, projects);
2804
- const backend = await resolveBackend(effectiveConfig);
2805
- const parallel = effectiveParallelForBackend(effectiveConfig.parallel, backend);
2806
- const pendingJobs = [];
2807
- const allTypeTestFiles = [];
2808
- for (const project of projects) {
2809
- const discoveryConfig = {
2810
- ...project.config,
2811
- placeFile: effectiveConfig.placeFile,
2812
- projects: project.projects,
2813
- testMatch: project.include
2814
- };
2815
- const { runtimeFiles, typeTestFiles } = classifyTestFiles(discoverTestFiles(discoveryConfig, cli.files).files, rootConfig);
2816
- const projConfig = {
2817
- ...discoveryConfig,
2818
- testMatch: project.testMatch
2819
- };
2820
- allTypeTestFiles.push(...typeTestFiles);
2821
- if (runtimeFiles.length === 0) continue;
2822
- pendingJobs.push({
2823
- config: projConfig,
2824
- displayColor: project.displayColor,
2825
- displayName: project.displayName,
2826
- runtimeFiles
2827
- });
312
+ async function dispatchResult(config, result) {
313
+ if (result.validationExitCode !== void 0) {
314
+ if ("validationMessage" in result && result.validationMessage !== void 0) process.stderr.write(result.validationMessage);
315
+ return result.validationExitCode;
2828
316
  }
2829
- const jobs = pendingJobs.map((pending) => {
2830
- return buildProjectJob({
2831
- config: pending.config,
2832
- displayColor: pending.displayColor,
2833
- displayName: pending.displayName,
2834
- testFiles: pending.runtimeFiles
2835
- });
2836
- });
2837
- const projectResults = [];
2838
- if (jobs.length > 0) {
2839
- const startTime = Date.now();
2840
- let backendResult;
2841
- try {
2842
- backendResult = await executeBackend(backend, jobs, parallel);
2843
- } finally {
2844
- await backend.close?.();
2845
- }
2846
- const sharedTiming = backendResult.timing;
2847
- for (const [index, entry] of backendResult.results.entries()) {
2848
- const pending = pendingJobs[index];
2849
- const jobConfig = jobs[index].config;
2850
- const executeResult = processProjectResult(entry, {
2851
- backendTiming: sharedTiming,
2852
- config: jobConfig,
2853
- deferFormatting: true,
2854
- startTime,
2855
- version: VERSION
2856
- });
2857
- projectResults.push({
2858
- displayColor: pending.displayColor,
2859
- displayName: pending.displayName,
2860
- result: executeResult
2861
- });
2862
- }
2863
- }
2864
- const uniqueTypeTestFiles = [...new Set(allTypeTestFiles)];
2865
- const typecheckResult = uniqueTypeTestFiles.length > 0 ? runTypecheck({
2866
- files: uniqueTypeTestFiles,
2867
- rootDir: rootConfig.rootDir,
2868
- tsconfig: rootConfig.typecheckTsconfig
2869
- }) : void 0;
2870
- if (projectResults.length === 0 && typecheckResult === void 0) {
2871
- if (rootConfig.passWithNoTests) return 0;
2872
- console.error("No test files found in any project");
2873
- return 2;
2874
- }
2875
- if (projectResults.length === 0) return outputResults(rootConfig, typecheckResult, void 0, preCoverageMs);
2876
- return outputMultiProjectResults({
2877
- ...rootConfig,
2878
- collectCoverageFrom: rootConfig.collectCoverageFrom ?? deriveCoverageFromIncludes(projects)
2879
- }, projectResults, typecheckResult, preCoverageMs);
2880
- }
2881
- async function executeRuntimeTests(config, testFiles, totalFiles) {
2882
- if (!config.silent && !usesAgentFormatter(config) && !hasFormatter(config, "json") && testFiles.length !== totalFiles) process.stderr.write(`Running ${String(testFiles.length)} of ${String(totalFiles)} test files\n`);
2883
- const backend = await resolveBackend(config);
2884
- try {
2885
- return await execute({
2886
- backend,
2887
- config,
2888
- deferFormatting: true,
2889
- testFiles,
2890
- version: VERSION
2891
- });
2892
- } finally {
2893
- await backend.close?.();
2894
- }
2895
- }
2896
- function resolveSetupFilePaths(config) {
2897
- if (config.setupFiles === void 0 && config.setupFilesAfterEnv === void 0) return;
2898
- const rojoConfigPath = path$1.resolve(config.rootDir, config.rojoProject ?? DEFAULT_ROJO_PROJECT);
2899
- applySetupResolver(config, createSetupResolver({
2900
- configDirectory: config.rootDir,
2901
- rojoConfigPath
2902
- }));
2903
- }
2904
- async function runSingleProject(cli, config) {
2905
- resolveSetupFilePaths(config);
2906
- const discovery = discoverTestFiles(config, cli.files);
2907
- if (discovery.files.length === 0) {
2908
- if (config.passWithNoTests) return 0;
2909
- console.error("No test files found");
2910
- return 2;
2911
- }
2912
- const typeTestFiles = config.typecheck ? discovery.files.filter((file) => TYPE_TEST_PATTERN.test(file)) : [];
2913
- const runtimeTestFiles = config.typecheckOnly ? [] : discovery.files.filter((file) => !TYPE_TEST_PATTERN.test(file));
2914
- if (typeTestFiles.length === 0 && runtimeTestFiles.length === 0) {
2915
- if (config.passWithNoTests) return 0;
2916
- console.error("No test files found for the selected mode");
2917
- return 2;
2918
- }
2919
- let preCoverageMs = 0;
2920
- let effectiveConfig = config;
2921
- if (config.collectCoverage && !config.typecheckOnly && runtimeTestFiles.length > 0) {
2922
- const preCoverageStart = Date.now();
2923
- const { placeFile } = prepareCoverage(config);
2924
- preCoverageMs = Date.now() - preCoverageStart;
2925
- effectiveConfig = {
2926
- ...config,
2927
- placeFile
2928
- };
317
+ if (result.mode === "single") {
318
+ if (result.runtimeResult === void 0 && result.typecheckResult === void 0) return 0;
319
+ return outputSingleResult(config, result);
2929
320
  }
2930
- const typecheckResult = typeTestFiles.length > 0 ? runTypecheck({
2931
- files: typeTestFiles,
2932
- rootDir: effectiveConfig.rootDir,
2933
- tsconfig: effectiveConfig.typecheckTsconfig
2934
- }) : void 0;
2935
- const runtimeResult = runtimeTestFiles.length > 0 ? await executeRuntimeTests(effectiveConfig, runtimeTestFiles, discovery.totalFiles) : void 0;
2936
- return outputResults(effectiveConfig, typecheckResult, runtimeResult, preCoverageMs);
321
+ if (result.projectResults.length === 0 && result.typecheckResult === void 0) return 0;
322
+ return outputMultiResult(config, result);
2937
323
  }
2938
324
  async function runInner(args) {
2939
325
  const cli = parseArgs(args);
@@ -2946,47 +332,17 @@ async function runInner(args) {
2946
332
  return 0;
2947
333
  }
2948
334
  if (process.env["JEST_ROBLOX_SEA"] === "true" && cli.typecheck === true) throw new ConfigError("--typecheck is not available in the standalone binary. Install via npm instead.");
2949
- const config = mergeCliWithConfig(cli, await loadConfig$1(cli.config));
2950
- const rawProjects = config.projects;
2951
- if (rawProjects !== void 0 && rawProjects.length > 0) return runMultiProject(cli, config, rawProjects);
2952
- return runSingleProject(cli, config);
335
+ const config = mergeCliWithConfig(cli, await loadConfig(cli.config, cli.workspaceRoot));
336
+ return dispatchResult(config, await runJestRoblox(cli, config));
2953
337
  }
2954
338
  const LUAU_ERROR_HINTS = [
2955
339
  [/Failed to find Jest instance in ReplicatedStorage/, "Set \"jestPath\" in your config to specify the Jest module location, e.g. \"ReplicatedStorage/rbxts_include/node_modules/@rbxts/jest/src\""],
2956
340
  [/Failed to find Jest instance at path/, "The configured jestPath does not resolve to a valid instance. Verify the path matches your Rojo project tree."],
2957
341
  [/Failed to find service/, "The first segment of jestPath must be a valid Roblox service name (e.g. ReplicatedStorage, ServerScriptService)."],
2958
- [/No projects configured/, "Set \"projects\" in jest.config.ts (e.g. [\"ReplicatedStorage/client\", \"ServerScriptService/server\"]) or pass --projects."],
342
+ [/No projects configured/, "Set \"projects\" in jest.config.ts (e.g. [\"ReplicatedStorage/client\", \"ServerScriptService/server\"])."],
2959
343
  [/Infinite yield detected/, "A :WaitForChild() call is waiting for an instance that doesn't exist. Check your DataModel paths and Rojo project configuration."],
2960
344
  [/loadstring\(\) is not available/, "loadstring() must be enabled for Jest to run. Add \"LoadStringEnabled\": true to ServerScriptService.$properties in your project.json."]
2961
345
  ];
2962
- function discoverTestFiles(config, cliFiles) {
2963
- if (cliFiles && cliFiles.length > 0) {
2964
- const files = cliFiles.map((file) => path$1.resolve(config.rootDir, file));
2965
- return {
2966
- files,
2967
- totalFiles: files.length
2968
- };
2969
- }
2970
- const allFiles = [];
2971
- for (const pattern of config.testMatch) {
2972
- const matches = globSync(pattern, { cwd: config.rootDir });
2973
- allFiles.push(...matches);
2974
- }
2975
- const ignoredPatterns = config.testPathIgnorePatterns.map((pat) => new RegExp(pat));
2976
- const baseFiles = allFiles.filter((file) => {
2977
- return !ignoredPatterns.some((pattern) => pattern.test(file));
2978
- });
2979
- const totalFiles = new Set(baseFiles).size;
2980
- let filtered = baseFiles;
2981
- if (config.testPathPattern !== void 0) {
2982
- const pathPattern = new RegExp(config.testPathPattern);
2983
- filtered = filtered.filter((file) => pathPattern.test(file));
2984
- }
2985
- return {
2986
- files: [...new Set(filtered)],
2987
- totalFiles
2988
- };
2989
- }
2990
346
  function validateBackend(value) {
2991
347
  if (value === void 0) return;
2992
348
  if (!isValidBackend(value)) {
@@ -2998,60 +354,5 @@ function validateBackend(value) {
2998
354
  function getLuauErrorHint(message) {
2999
355
  for (const [pattern, hint] of LUAU_ERROR_HINTS) if (pattern.test(message)) return hint;
3000
356
  }
3001
- function resolveFormatters(cli, config) {
3002
- const explicit = cli.formatters ?? config.formatters;
3003
- if (explicit !== void 0) return explicit;
3004
- const defaults = isAgent ? ["agent"] : ["default"];
3005
- if (process.env["GITHUB_ACTIONS"] === "true") defaults.push("github-actions");
3006
- return defaults;
3007
- }
3008
- function mergeCliWithConfig(cli, config) {
3009
- return {
3010
- ...config,
3011
- backend: cli.backend ?? config.backend,
3012
- cache: cli.cache ?? config.cache,
3013
- collectCoverage: cli.collectCoverage ?? config.collectCoverage,
3014
- collectCoverageFrom: cli.collectCoverageFrom ?? config.collectCoverageFrom,
3015
- color: cli.color ?? config.color,
3016
- coverageDirectory: cli.coverageDirectory ?? config.coverageDirectory,
3017
- coverageReporters: cli.coverageReporters ?? config.coverageReporters,
3018
- formatters: resolveFormatters(cli, config),
3019
- gameOutput: cli.gameOutput ?? config.gameOutput,
3020
- outputFile: cli.outputFile ?? config.outputFile,
3021
- parallel: cli.parallel ?? config.parallel,
3022
- passWithNoTests: cli.passWithNoTests ?? config.passWithNoTests,
3023
- pollInterval: cli.pollInterval ?? config.pollInterval,
3024
- port: cli.port ?? config.port,
3025
- rojoProject: cli.rojoProject ?? config.rojoProject,
3026
- setupFiles: cli.setupFiles ?? config.setupFiles,
3027
- setupFilesAfterEnv: cli.setupFilesAfterEnv ?? config.setupFilesAfterEnv,
3028
- showLuau: cli.showLuau ?? config.showLuau,
3029
- silent: cli.silent ?? config.silent,
3030
- sourceMap: cli.sourceMap ?? config.sourceMap,
3031
- testNamePattern: cli.testNamePattern ?? config.testNamePattern,
3032
- testPathPattern: cli.testPathPattern ?? config.testPathPattern,
3033
- timeout: cli.timeout ?? config.timeout,
3034
- typecheck: cli.typecheck ?? config.typecheck,
3035
- typecheckOnly: cli.typecheckOnly ?? config.typecheckOnly,
3036
- typecheckTsconfig: cli.typecheckTsconfig ?? config.typecheckTsconfig,
3037
- updateSnapshot: cli.updateSnapshot ?? config.updateSnapshot,
3038
- verbose: cli.verbose ?? config.verbose
3039
- };
3040
- }
3041
- function mergeResults(typecheck, runtime) {
3042
- if (typecheck !== void 0 && runtime !== void 0) return {
3043
- numFailedTests: typecheck.numFailedTests + runtime.numFailedTests,
3044
- numPassedTests: typecheck.numPassedTests + runtime.numPassedTests,
3045
- numPendingTests: typecheck.numPendingTests + runtime.numPendingTests,
3046
- numTodoTests: (typecheck.numTodoTests ?? 0) + (runtime.numTodoTests ?? 0),
3047
- numTotalTests: typecheck.numTotalTests + runtime.numTotalTests,
3048
- startTime: Math.min(typecheck.startTime, runtime.startTime),
3049
- success: typecheck.success && runtime.success,
3050
- testResults: [...typecheck.testResults, ...runtime.testResults]
3051
- };
3052
- const result = typecheck ?? runtime;
3053
- assert(result !== void 0, "mergeResults requires at least one result");
3054
- return result;
3055
- }
3056
357
  //#endregion
3057
- export { filterByName, main, mergeProjectResults, parseArgs, run };
358
+ export { main, parseArgs, run };