@isentinel/jest-roblox 0.0.8 → 0.1.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,14 +1,20 @@
1
- import { C as createStudioBackend, O as LuauScriptError, T as createOpenCloudBackend, a as formatAnnotations, c as execute, d as writeJsonFile, g as loadConfig, h as rojoProjectSchema, i as runTypecheck, l as loadCoverageManifest, n as parseGameOutput, o as formatJobSummary, r as writeGameOutput, s as resolveGitHubActionsOptions, t as formatGameOutputNotice, x as isValidBackend, y as VALID_BACKENDS } from "./game-output-M8du29nj.mjs";
1
+ import { D as createOpenCloudBackend, I as isValidBackend, L as LuauScriptError, M as ROOT_ONLY_KEYS, N as VALID_BACKENDS, O as hashBuffer, S as loadConfig$1, T as createStudioBackend, _ as formatResult, a as formatAnnotations, b as formatBanner, c as execute, d as findFormatterOptions, g as formatMultiProjectResult, i as runTypecheck, l as formatExecuteOutput, m as formatCompactMultiProject, n as parseGameOutput, o as formatJobSummary, p as writeJsonFile, r as writeGameOutput, s as resolveGitHubActionsOptions, t as formatGameOutputNotice, u as loadCoverageManifest, x as rojoProjectSchema, y as formatTypecheckSummary } from "./game-output-C0_-YIAY.mjs";
2
+ import { createRequire } from "node:module";
3
+ import { type } from "arktype";
2
4
  import assert from "node:assert";
3
- import * as fs from "node:fs";
5
+ import * as fs$1 from "node:fs";
6
+ import fs from "node:fs";
4
7
  import * as path$1 from "node:path";
8
+ import path from "node:path";
5
9
  import process from "node:process";
6
10
  import { parseArgs as parseArgs$1 } from "node:util";
11
+ import { isAgent } from "std-env";
7
12
  import color from "tinyrainbow";
8
13
  import { WebSocketServer } from "ws";
9
- import { type } from "arktype";
14
+ import { loadConfig } from "c12";
10
15
  import * as os from "node:os";
11
16
  import { Buffer } from "node:buffer";
17
+ import { RojoResolver } from "@roblox-ts/rojo-resolver";
12
18
  import { TraceMap, originalPositionFor } from "@jridgewell/trace-mapping";
13
19
  import { getTsconfig } from "get-tsconfig";
14
20
  import picomatch from "picomatch";
@@ -16,10 +22,8 @@ import * as cp from "node:child_process";
16
22
  import istanbulCoverage from "istanbul-lib-coverage";
17
23
  import istanbulReport from "istanbul-lib-report";
18
24
  import istanbulReports from "istanbul-reports";
19
-
20
25
  //#region package.json
21
- var version = "0.0.8";
22
-
26
+ var version = "0.1.1";
23
27
  //#endregion
24
28
  //#region src/backends/auto.ts
25
29
  var StudioWithFallback = class {
@@ -96,7 +100,311 @@ async function resolveBackend(config, probe = probeStudioPlugin) {
96
100
  function hasOpenCloudCredentials() {
97
101
  return process.env["ROBLOX_OPEN_CLOUD_API_KEY"] !== void 0 && process.env["ROBLOX_UNIVERSE_ID"] !== void 0 && process.env["ROBLOX_PLACE_ID"] !== void 0;
98
102
  }
99
-
103
+ //#endregion
104
+ //#region src/config/errors.ts
105
+ var ConfigError = class extends Error {
106
+ hint;
107
+ constructor(message, hint) {
108
+ super(message);
109
+ this.hint = hint;
110
+ }
111
+ };
112
+ //#endregion
113
+ //#region src/utils/extensions.ts
114
+ function stripTsExtension(pattern) {
115
+ return pattern.replace(/\.(tsx?|luau?)$/, "");
116
+ }
117
+ //#endregion
118
+ //#region src/utils/rojo-tree.ts
119
+ function collectPaths(node, result) {
120
+ for (const [key, value] of Object.entries(node)) if (key === "$path" && typeof value === "string") result.push(value.replaceAll("\\", "/"));
121
+ else if (typeof value === "object" && !Array.isArray(value) && !key.startsWith("$")) collectPaths(value, result);
122
+ }
123
+ //#endregion
124
+ //#region src/config/projects.ts
125
+ function extractStaticRoot(pattern) {
126
+ const globChars = new Set([
127
+ "*",
128
+ "?",
129
+ "[",
130
+ "{"
131
+ ]);
132
+ let firstGlobIndex = -1;
133
+ for (const [index, char] of [...pattern].entries()) if (globChars.has(char)) {
134
+ firstGlobIndex = index;
135
+ break;
136
+ }
137
+ if (firstGlobIndex === -1) {
138
+ const directory = path$1.posix.dirname(pattern);
139
+ return {
140
+ glob: path$1.posix.basename(pattern),
141
+ root: directory
142
+ };
143
+ }
144
+ const lastSlash = pattern.slice(0, firstGlobIndex).lastIndexOf("/");
145
+ if (lastSlash === -1) throw new Error("Include pattern must have a static directory prefix");
146
+ return {
147
+ glob: pattern.slice(lastSlash + 1),
148
+ root: pattern.slice(0, lastSlash)
149
+ };
150
+ }
151
+ function extractProjectRoots(include) {
152
+ const rootMap = /* @__PURE__ */ new Map();
153
+ for (const pattern of include) {
154
+ const { glob, root } = extractStaticRoot(pattern);
155
+ const stripped = stripTsExtension(glob);
156
+ const qualified = stripped.includes("/") ? stripped : `**/${stripped}`;
157
+ let patterns = rootMap.get(root);
158
+ if (patterns === void 0) {
159
+ patterns = [];
160
+ rootMap.set(root, patterns);
161
+ }
162
+ patterns.push(qualified);
163
+ }
164
+ return [...rootMap.entries()].map(([root, testMatch]) => ({
165
+ root,
166
+ testMatch
167
+ }));
168
+ }
169
+ function mapFsRootToDataModel(outDirectory, rojoTree) {
170
+ const normalized = outDirectory.replace(/\/$/, "");
171
+ const result = findInTree(rojoTree, normalized, "");
172
+ if (result === void 0) {
173
+ const available = [];
174
+ collectPaths(rojoTree, available);
175
+ let message = `No Rojo tree mapping found for path: ${normalized}`;
176
+ if (available.length > 0) message += `\n\nAvailable $path entries: ${available.join(", ")}`;
177
+ 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;
178
+ throw new ConfigError(message, hint);
179
+ }
180
+ return result;
181
+ }
182
+ function validateProjects(projects) {
183
+ const names = /* @__PURE__ */ new Set();
184
+ for (const project of projects) {
185
+ const name = typeof project.displayName === "string" ? project.displayName : project.displayName.name;
186
+ if (name === "") throw new Error("Project must have a non-empty displayName");
187
+ if (names.has(name)) throw new Error(`Duplicate project displayName: ${name}`);
188
+ names.add(name);
189
+ if (project.include.length === 0) throw new Error(`Project "${name}" must have at least one include pattern`);
190
+ }
191
+ }
192
+ const PROJECT_ONLY_KEYS = new Set([
193
+ "displayName",
194
+ "exclude",
195
+ "include",
196
+ "outDir",
197
+ "root"
198
+ ]);
199
+ function resolveProjectConfig(project, rootConfig, rojoTree) {
200
+ const roots = extractProjectRoots(project.include);
201
+ const testMatch = roots.flatMap((entry) => entry.testMatch);
202
+ const projectRoot = project.root;
203
+ if (roots.length > 1 && project.outDir === void 0) {
204
+ const name = typeof project.displayName === "string" ? project.displayName : project.displayName.name;
205
+ throw new Error(`Project "${name}" has multiple include roots but no outDir. Set outDir or split into separate projects.`);
206
+ }
207
+ const resolvedOutDirectory = resolveOutDirectory(project.outDir, projectRoot, roots[0]?.root);
208
+ const dataModelPath = resolvedOutDirectory !== void 0 ? mapFsRootToDataModel(resolvedOutDirectory, rojoTree) : void 0;
209
+ const resolvedInclude = projectRoot === void 0 ? project.include : project.include.map((pattern) => path$1.posix.join(projectRoot, pattern));
210
+ const config = mergeProjectConfig(rootConfig, project);
211
+ const displayName = typeof project.displayName === "string" ? project.displayName : project.displayName.name;
212
+ return {
213
+ config,
214
+ displayColor: typeof project.displayName === "string" ? void 0 : project.displayName.color,
215
+ displayName,
216
+ include: resolvedInclude,
217
+ outDir: resolvedOutDirectory,
218
+ projects: dataModelPath !== void 0 ? [dataModelPath] : [],
219
+ testMatch
220
+ };
221
+ }
222
+ async function loadProjectConfigFile(filePath, cwd) {
223
+ let result;
224
+ try {
225
+ result = await loadConfig({
226
+ name: "jest-project",
227
+ configFile: filePath,
228
+ configFileRequired: true,
229
+ cwd,
230
+ dotenv: false,
231
+ globalRc: false,
232
+ omit$Keys: true,
233
+ packageJson: false,
234
+ rcFile: false
235
+ });
236
+ } catch (err) {
237
+ const message = err instanceof Error ? err.message : String(err);
238
+ throw new Error(`Failed to load project config file ${filePath}: ${message}`, { cause: err });
239
+ }
240
+ const { config } = result;
241
+ if ((typeof config.displayName === "string" ? config.displayName : config.displayName.name) === "") throw new Error(`Project config file "${filePath}" must have a displayName`);
242
+ return config;
243
+ }
244
+ async function resolveAllProjects(entries, rootConfig, rojoTree, cwd) {
245
+ const projects = [];
246
+ for (const entry of entries) if (typeof entry === "string") {
247
+ const loaded = await loadProjectConfigFile(entry, cwd);
248
+ projects.push(loaded);
249
+ } else projects.push(entry.test);
250
+ validateProjects(projects);
251
+ return projects.map((project) => resolveProjectConfig(project, rootConfig, rojoTree));
252
+ }
253
+ /** When outDir is omitted (pure Luau), falls back to include pattern's static root. */
254
+ function resolveOutDirectory(projectOutDirectory, projectRoot, fallbackRoot) {
255
+ const base = projectOutDirectory ?? fallbackRoot;
256
+ if (base === void 0) return;
257
+ return projectRoot !== void 0 ? path$1.posix.join(projectRoot, base) : base;
258
+ }
259
+ function mergeProjectConfig(rootConfig, project) {
260
+ const merged = { ...rootConfig };
261
+ for (const [key, value] of Object.entries(project)) if (!PROJECT_ONLY_KEYS.has(key) && value !== void 0) merged[key] = value;
262
+ return merged;
263
+ }
264
+ function matchNodePath(childNode, targetPath, childDataModelPath) {
265
+ const nodePath = childNode.$path;
266
+ if (typeof nodePath !== "string") return;
267
+ const normalizedNodePath = nodePath.replace(/\/$/, "");
268
+ if (normalizedNodePath === targetPath) return childDataModelPath;
269
+ if (targetPath.startsWith(`${normalizedNodePath}/`)) return `${childDataModelPath}/${targetPath.slice(normalizedNodePath.length + 1)}`;
270
+ }
271
+ function findInTree(node, targetPath, currentDataModelPath) {
272
+ for (const [key, value] of Object.entries(node)) {
273
+ if (key.startsWith("$") || typeof value !== "object") continue;
274
+ const childNode = value;
275
+ const childDataModelPath = currentDataModelPath === "" ? key : `${currentDataModelPath}/${key}`;
276
+ const pathMatch = matchNodePath(childNode, targetPath, childDataModelPath);
277
+ if (pathMatch !== void 0) return pathMatch;
278
+ const found = findInTree(childNode, targetPath, childDataModelPath);
279
+ if (found !== void 0) return found;
280
+ }
281
+ }
282
+ //#endregion
283
+ //#region src/config/setup-resolver.ts
284
+ const PROBE_EXTENSIONS = [
285
+ ".ts",
286
+ ".tsx",
287
+ ".lua",
288
+ ".luau"
289
+ ];
290
+ function createSetupResolver(options) {
291
+ const { configDirectory, resolveModule, rojoConfigPath } = options;
292
+ const resolve = resolveModule ?? createRequire(path$1.join(configDirectory, "noop.js")).resolve;
293
+ const rojoResolver = RojoResolver.fromPath(rojoConfigPath);
294
+ return (input) => {
295
+ let absolutePath;
296
+ if (isRelativePath(input)) absolutePath = path$1.resolve(configDirectory, input);
297
+ else {
298
+ resolvePackageSpecifier(resolve, input);
299
+ absolutePath = path$1.resolve(configDirectory, "node_modules", input);
300
+ }
301
+ const rbxPath = rojoResolver.getRbxPathFromFilePath(absolutePath);
302
+ if (rbxPath === void 0) throw new Error(`No matching path found in rojo project tree for "${input}" (resolved to: ${absolutePath})`);
303
+ return rbxPath.join("/");
304
+ };
305
+ }
306
+ function isRelativePath(input) {
307
+ return input.startsWith("./") || input.startsWith("../");
308
+ }
309
+ function resolvePackageSpecifier(resolve, input) {
310
+ try {
311
+ resolve(input);
312
+ return;
313
+ } catch {}
314
+ for (const extension of PROBE_EXTENSIONS) try {
315
+ resolve(`${input}${extension}`);
316
+ return;
317
+ } catch {}
318
+ throw new Error(`Could not resolve module "${input}". Ensure the package is installed.`);
319
+ }
320
+ //#endregion
321
+ //#region src/config/stubs.ts
322
+ const HEADER = "-- Auto-generated by jest-roblox (do not edit)\n";
323
+ const SKIP_FIELDS = new Set(["exclude", "include"]);
324
+ function serializeToLuau(config) {
325
+ let output = `${HEADER}return {\n`;
326
+ for (const [key, value] of Object.entries(config)) {
327
+ if (SKIP_FIELDS.has(key)) continue;
328
+ if (value === void 0) continue;
329
+ let serialized;
330
+ if (key === "testMatch" && Array.isArray(value)) serialized = serializeLuauValue(value.map((pattern) => stripTsExtension(pattern)), " ");
331
+ else serialized = serializeLuauValue(value, " ");
332
+ output += `\t${key} = ${serialized},\n`;
333
+ }
334
+ output += "}\n";
335
+ return output;
336
+ }
337
+ function generateProjectConfigs(projects) {
338
+ for (const project of projects) {
339
+ const content = serializeToLuau(project.config);
340
+ const directory = path.dirname(project.outputPath);
341
+ fs.mkdirSync(directory, { recursive: true });
342
+ fs.writeFileSync(project.outputPath, content, "utf8");
343
+ }
344
+ }
345
+ function syncStubsToShadowDirectory(projects, rootDirectory, shadowDirectory) {
346
+ let changed = false;
347
+ const expectedPaths = /* @__PURE__ */ new Set();
348
+ for (const project of projects) {
349
+ if (project.outDir === void 0) continue;
350
+ if (path.isAbsolute(project.outDir)) throw new Error(`Project "${project.displayName}" outDir must be a relative path, got: ${project.outDir}`);
351
+ const stubName = "jest.config.lua";
352
+ const sourcePath = path.resolve(rootDirectory, project.outDir, stubName);
353
+ if (!fs.existsSync(sourcePath)) continue;
354
+ const targetPath = path.resolve(shadowDirectory, project.outDir, stubName);
355
+ expectedPaths.add(targetPath);
356
+ const sourceContent = fs.readFileSync(sourcePath);
357
+ if (fs.existsSync(targetPath)) {
358
+ const targetContent = fs.readFileSync(targetPath);
359
+ if (Buffer.compare(sourceContent, targetContent) === 0) continue;
360
+ }
361
+ fs.mkdirSync(path.dirname(targetPath), { recursive: true });
362
+ fs.copyFileSync(sourcePath, targetPath);
363
+ changed = true;
364
+ }
365
+ for (const existing of findShadowStubs(shadowDirectory)) if (!expectedPaths.has(existing)) {
366
+ fs.unlinkSync(existing);
367
+ removeEmptyParents(path.dirname(existing), shadowDirectory);
368
+ changed = true;
369
+ }
370
+ return changed;
371
+ }
372
+ function escapeString(value) {
373
+ return value.replace(/\\/g, "\\\\").replace(/"/g, "\\\"");
374
+ }
375
+ function serializeLuauValue(value, indent) {
376
+ if (typeof value === "string") return `"${escapeString(value)}"`;
377
+ if (typeof value === "boolean") return value ? "true" : "false";
378
+ if (typeof value === "number") return String(value);
379
+ if (Array.isArray(value)) return `{ ${value.map((item) => serializeLuauValue(item, indent)).join(", ")} }`;
380
+ if (typeof value === "object" && value !== null) {
381
+ const nextIndent = `${indent}\t`;
382
+ return `{ ${Object.entries(value).filter(([, value_]) => value_ !== void 0).map(([key, value_]) => `${key} = ${serializeLuauValue(value_, nextIndent)}`).join(", ")} }`;
383
+ }
384
+ return String(value);
385
+ }
386
+ function removeEmptyParents(directory, stopAt) {
387
+ const resolved = path.resolve(directory);
388
+ const resolvedStop = path.resolve(stopAt);
389
+ if (resolved === resolvedStop || !resolved.startsWith(resolvedStop)) return;
390
+ try {
391
+ if (fs.readdirSync(resolved).length === 0) {
392
+ fs.rmdirSync(resolved);
393
+ removeEmptyParents(path.dirname(resolved), stopAt);
394
+ }
395
+ } catch {}
396
+ }
397
+ function findShadowStubs(directory) {
398
+ const results = [];
399
+ if (!fs.existsSync(directory)) return results;
400
+ const entries = fs.readdirSync(directory, { withFileTypes: true });
401
+ for (const entry of entries) {
402
+ const fullPath = path.resolve(directory, entry.name);
403
+ if (entry.isDirectory()) results.push(...findShadowStubs(fullPath));
404
+ else if (entry.name === "jest.config.lua") results.push(fullPath);
405
+ }
406
+ return results;
407
+ }
100
408
  //#endregion
101
409
  //#region src/coverage/mapper.ts
102
410
  const positionSchema = type({
@@ -142,8 +450,8 @@ function loadFileResources(record) {
142
450
  let coverageMapRaw;
143
451
  let sourceMapRaw;
144
452
  try {
145
- coverageMapRaw = fs.readFileSync(record.coverageMapPath, "utf-8");
146
- sourceMapRaw = fs.readFileSync(record.sourceMapPath, "utf-8");
453
+ coverageMapRaw = fs$1.readFileSync(record.coverageMapPath, "utf-8");
454
+ sourceMapRaw = fs$1.readFileSync(record.sourceMapPath, "utf-8");
147
455
  } catch {
148
456
  return;
149
457
  }
@@ -393,7 +701,6 @@ function buildResult(pending, pendingFunctions, pendingBranches) {
393
701
  }
394
702
  return { files };
395
703
  }
396
-
397
704
  //#endregion
398
705
  //#region src/types/luau-ast.ts
399
706
  const INSTRUMENTABLE_STATEMENT_TAGS = new Set([
@@ -413,7 +720,6 @@ const INSTRUMENTABLE_STATEMENT_TAGS = new Set([
413
720
  "return",
414
721
  "while"
415
722
  ]);
416
-
417
723
  //#endregion
418
724
  //#region src/coverage/luau-visitor.ts
419
725
  function visitExpression(expression, visitor) {
@@ -684,7 +990,6 @@ function visitExprInstantiate(node, visitor) {
684
990
  if (visitor.visitExprInstantiate?.(node) === false) return;
685
991
  visitExpression(node.expr, visitor);
686
992
  }
687
-
688
993
  //#endregion
689
994
  //#region src/coverage/coverage-collector.ts
690
995
  const END_KEYWORD_LENGTH = 3;
@@ -875,7 +1180,6 @@ function extractExprName(expr) {
875
1180
  function extractFunctionName(node) {
876
1181
  return extractExprName(node.name);
877
1182
  }
878
-
879
1183
  //#endregion
880
1184
  //#region src/coverage/coverage-map-builder.ts
881
1185
  function buildCoverageMap$1(result) {
@@ -926,11 +1230,9 @@ function buildCoverageMap$1(result) {
926
1230
  statementMap
927
1231
  };
928
1232
  }
929
-
930
1233
  //#endregion
931
1234
  //#region src/coverage/parse-ast.luau
932
- 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]\nlocal outputDir = userArgs[2]\nif not luauRoot or not outputDir then\n error(\"Usage: lute run parse-ast.luau -- <luau-root> <output-dir>\")\nend\n\nluauRoot = string.gsub(luauRoot, \"\\\\\", \"/\")\noutputDir = string.gsub(outputDir, \"\\\\\", \"/\")\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 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 [\"repeat\"] = { \"body\", \"condition\" },\n [\"return\"] = { \"expressions\" },\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-- 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 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\nlocal files: { string } = {}\ndiscoverFiles(luauRoot, luauRoot, files)\n\n-- Parse, strip, and write per-file AST JSON\nfor _, relativePath in files do\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";
933
-
1235
+ 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]\nlocal outputDir = userArgs[2]\nif not luauRoot or not outputDir then\n error(\"Usage: lute run parse-ast.luau -- <luau-root> <output-dir>\")\nend\n\nluauRoot = string.gsub(luauRoot, \"\\\\\", \"/\")\noutputDir = string.gsub(outputDir, \"\\\\\", \"/\")\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 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 [\"repeat\"] = { \"body\", \"condition\" },\n [\"return\"] = { \"expressions\" },\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-- 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\nlocal files: { string } = {}\ndiscoverFiles(luauRoot, luauRoot, files)\n\n-- Parse, strip, and write per-file AST JSON\nfor _, relativePath in files do\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";
934
1236
  //#endregion
935
1237
  //#region src/coverage/probe-inserter.ts
936
1238
  function insertProbes(source, result, fileKey) {
@@ -1040,7 +1342,6 @@ function buildPreamble(modeDirective, fileKey, result) {
1040
1342
  }
1041
1343
  return preamble;
1042
1344
  }
1043
-
1044
1345
  //#endregion
1045
1346
  //#region src/coverage/instrumenter.ts
1046
1347
  let cachedTemporaryDirectory;
@@ -1049,12 +1350,12 @@ let cachedTemporaryDirectory;
1049
1350
  * writing a manifest — used by `prepareCoverage()` to merge multiple roots.
1050
1351
  */
1051
1352
  function instrumentRoot(options) {
1052
- const { luauRoot, parseScript, shadowDir } = options;
1053
- const lazyTemporaryDirectory = parseScript === void 0 || options.astOutputDirectory === void 0 ? getTemporaryDirectory() : "";
1353
+ const { astOutputDirectory: astOutputDirectoryOption, luauRoot, parseScript, shadowDir, skipFiles } = options;
1354
+ const lazyTemporaryDirectory = parseScript === void 0 || astOutputDirectoryOption === void 0 ? getTemporaryDirectory() : "";
1054
1355
  const scriptPath = parseScript ?? path$1.join(lazyTemporaryDirectory, "parse-ast.luau");
1055
- const astOutputDirectory = options.astOutputDirectory ?? path$1.join(lazyTemporaryDirectory, "asts");
1056
- if (parseScript === void 0) fs.writeFileSync(scriptPath, parse_ast_default);
1057
- fs.mkdirSync(astOutputDirectory, { recursive: true });
1356
+ const astOutputDirectory = astOutputDirectoryOption ?? path$1.join(lazyTemporaryDirectory, "asts");
1357
+ if (parseScript === void 0) fs$1.writeFileSync(scriptPath, parse_ast_default);
1358
+ fs$1.mkdirSync(astOutputDirectory, { recursive: true });
1058
1359
  let fileListJson;
1059
1360
  try {
1060
1361
  fileListJson = cp.execFileSync("lute", [
@@ -1081,10 +1382,11 @@ function instrumentRoot(options) {
1081
1382
  const files = {};
1082
1383
  const posixLuauRoot = toPosix(luauRoot);
1083
1384
  for (const relativePath of fileList) {
1385
+ if (shouldSkipFile(relativePath, skipFiles)) continue;
1084
1386
  const astJsonPath = path$1.join(astOutputDirectory, `${relativePath}.json`);
1085
1387
  let astJson;
1086
1388
  try {
1087
- astJson = fs.readFileSync(astJsonPath, "utf-8");
1389
+ astJson = fs$1.readFileSync(astJsonPath, "utf-8");
1088
1390
  } catch (err) {
1089
1391
  throw new Error(`Failed to read AST for ${relativePath}`, { cause: err });
1090
1392
  }
@@ -1095,13 +1397,14 @@ function instrumentRoot(options) {
1095
1397
  const coverageMapOutputPath = path$1.join(shadowDir, relativePath.replace(/\.luau$/, ".cov-map.json"));
1096
1398
  const sourceMapPath = `${originalLuauPath}.map`;
1097
1399
  const outputDirectory = path$1.dirname(path$1.join(shadowDir, relativePath));
1098
- fs.mkdirSync(outputDirectory, { recursive: true });
1099
- const source = fs.readFileSync(path$1.resolve(originalLuauPath), "utf-8");
1400
+ fs$1.mkdirSync(outputDirectory, { recursive: true });
1401
+ const sourceBuffer = fs$1.readFileSync(path$1.resolve(originalLuauPath));
1402
+ const source = sourceBuffer.toString("utf-8");
1100
1403
  const collectorResult = collectCoverage(ast);
1101
1404
  const instrumentedSource = insertProbes(source, collectorResult, fileKey);
1102
1405
  const coverageMap = buildCoverageMap$1(collectorResult);
1103
- fs.writeFileSync(path$1.join(shadowDir, relativePath), instrumentedSource);
1104
- fs.writeFileSync(coverageMapOutputPath, JSON.stringify(coverageMap, void 0, " "));
1406
+ fs$1.writeFileSync(path$1.join(shadowDir, relativePath), instrumentedSource);
1407
+ fs$1.writeFileSync(coverageMapOutputPath, JSON.stringify(coverageMap, void 0, " "));
1105
1408
  files[fileKey] = {
1106
1409
  key: fileKey,
1107
1410
  branchCount: collectorResult.branches.length,
@@ -1109,21 +1412,25 @@ function instrumentRoot(options) {
1109
1412
  functionCount: collectorResult.functions.length,
1110
1413
  instrumentedLuauPath,
1111
1414
  originalLuauPath,
1415
+ sourceHash: hashBuffer(sourceBuffer),
1112
1416
  sourceMapPath,
1113
1417
  statementCount: collectorResult.statements.length
1114
1418
  };
1115
1419
  }
1116
1420
  return files;
1117
1421
  }
1422
+ function shouldSkipFile(relativePath, skipFiles) {
1423
+ if (relativePath.endsWith(".snap.luau") || relativePath.endsWith(".snap.lua")) return true;
1424
+ return skipFiles?.has(relativePath) === true;
1425
+ }
1118
1426
  function getTemporaryDirectory() {
1119
- if (cachedTemporaryDirectory !== void 0 && fs.existsSync(cachedTemporaryDirectory)) return cachedTemporaryDirectory;
1120
- cachedTemporaryDirectory = fs.mkdtempSync(path$1.join(os.tmpdir(), "jest-roblox-instrument-"));
1427
+ if (cachedTemporaryDirectory !== void 0 && fs$1.existsSync(cachedTemporaryDirectory)) return cachedTemporaryDirectory;
1428
+ cachedTemporaryDirectory = fs$1.mkdtempSync(path$1.join(os.tmpdir(), "jest-roblox-instrument-"));
1121
1429
  return cachedTemporaryDirectory;
1122
1430
  }
1123
1431
  function toPosix(value) {
1124
1432
  return value.replaceAll("\\", "/");
1125
1433
  }
1126
-
1127
1434
  //#endregion
1128
1435
  //#region src/coverage/rojo-builder.ts
1129
1436
  function buildWithRojo(projectPath, outputPath) {
@@ -1141,7 +1448,6 @@ function buildWithRojo(projectPath, outputPath) {
1141
1448
  throw new Error(message, { cause: err });
1142
1449
  }
1143
1450
  }
1144
-
1145
1451
  //#endregion
1146
1452
  //#region src/coverage/rojo-rewriter.ts
1147
1453
  function rewriteRojoProject(project, options) {
@@ -1182,76 +1488,68 @@ function walkTree(node, context) {
1182
1488
  else result[key] = value;
1183
1489
  return result;
1184
1490
  }
1185
-
1186
1491
  //#endregion
1187
1492
  //#region src/coverage/prepare.ts
1188
1493
  const COVERAGE_DIR = ".jest-roblox-coverage";
1494
+ const previousManifestSchema = type({
1495
+ "files": type({ "[string]": { sourceHash: "string" } }),
1496
+ "instrumenterVersion": "number",
1497
+ "luauRoots": "string[]",
1498
+ "placeFilePath?": "string",
1499
+ "shadowDir": "string",
1500
+ "version": "number"
1501
+ }).as();
1189
1502
  function collectLuauRootsFromRojo(project, config) {
1190
1503
  const paths = [];
1191
1504
  collectPaths(project.tree, paths);
1192
1505
  const ignorePatterns = config.coveragePathIgnorePatterns;
1193
1506
  const isIgnored = picomatch(ignorePatterns, { contains: true });
1194
1507
  return paths.filter((directoryPath) => {
1195
- if (!fs.existsSync(directoryPath)) return false;
1196
- if (!fs.statSync(directoryPath).isDirectory()) return false;
1508
+ if (!fs$1.existsSync(directoryPath)) return false;
1509
+ if (!fs$1.statSync(directoryPath).isDirectory()) return false;
1197
1510
  if (isIgnored(directoryPath)) return false;
1198
1511
  return containsLuauFiles(directoryPath);
1199
1512
  });
1200
1513
  }
1201
- function prepareCoverage(config) {
1514
+ function prepareCoverage(config, beforeBuild) {
1202
1515
  const rojoProjectPath = findRojoProject(config);
1203
1516
  const luauRoots = resolveLuauRootsWithRojo(config, rojoProjectPath);
1204
- 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.");
1517
+ validateRelativeRoots(luauRoots);
1205
1518
  const manifestPath = path$1.join(COVERAGE_DIR, "manifest.json");
1206
- if (fs.existsSync(COVERAGE_DIR)) fs.rmSync(COVERAGE_DIR, { recursive: true });
1519
+ const previousManifest = loadPreviousManifest(manifestPath);
1520
+ const useIncremental = canUseIncremental(previousManifest, config);
1521
+ if (!useIncremental && fs$1.existsSync(COVERAGE_DIR)) fs$1.rmSync(COVERAGE_DIR, { recursive: true });
1207
1522
  const allFiles = {};
1208
1523
  const roots = [];
1524
+ let hasChanges = !useIncremental;
1209
1525
  for (const luauRoot of luauRoots) {
1210
- const shadowDirectory = path$1.join(COVERAGE_DIR, luauRoot).replaceAll("\\", "/");
1211
- fs.mkdirSync(shadowDirectory, { recursive: true });
1212
- fs.cpSync(luauRoot, shadowDirectory, { recursive: true });
1213
- const files = instrumentRoot({
1214
- luauRoot,
1215
- shadowDir: shadowDirectory
1216
- });
1217
- Object.assign(allFiles, files);
1218
- const relocatedShadowDirectory = path$1.relative(COVERAGE_DIR, shadowDirectory).replaceAll("\\", "/");
1219
- roots.push({
1220
- luauRoot,
1221
- relocatedShadowDirectory,
1222
- shadowDir: shadowDirectory
1223
- });
1526
+ const rootResult = instrumentRootWithCache(luauRoot, useIncremental, previousManifest);
1527
+ if (rootResult.changed) hasChanges = true;
1528
+ Object.assign(allFiles, rootResult.files);
1529
+ roots.push(rootResult.rootEntry);
1530
+ }
1531
+ if (useIncremental && previousManifest !== void 0) {
1532
+ const deleted = detectDeletedFiles(previousManifest, allFiles);
1533
+ cleanupDeletedFiles(deleted);
1534
+ if (deleted.length > 0) hasChanges = true;
1535
+ }
1536
+ if (beforeBuild !== void 0) {
1537
+ if (beforeBuild(COVERAGE_DIR)) hasChanges = true;
1224
1538
  }
1225
- const manifest = {
1226
- files: allFiles,
1227
- generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
1228
- luauRoots,
1229
- shadowDir: COVERAGE_DIR,
1230
- version: 1
1231
- };
1232
- fs.mkdirSync(path$1.dirname(manifestPath), { recursive: true });
1233
- fs.writeFileSync(manifestPath, JSON.stringify(manifest, void 0, " "));
1234
- const rojoProjectRaw = rojoProjectSchema(JSON.parse(fs.readFileSync(rojoProjectPath, "utf-8")));
1235
- if (rojoProjectRaw instanceof type.errors) throw new Error(`Malformed Rojo project JSON: ${rojoProjectRaw.toString()}`);
1236
- const rewritten = rewriteRojoProject(rojoProjectRaw, {
1237
- projectRelocation: path$1.relative(COVERAGE_DIR, path$1.dirname(rojoProjectPath)).replaceAll("\\", "/"),
1238
- roots
1239
- });
1240
- const rewrittenProjectPath = path$1.join(COVERAGE_DIR, path$1.basename(rojoProjectPath));
1241
- fs.writeFileSync(rewrittenProjectPath, JSON.stringify(rewritten, void 0, " "));
1242
1539
  const placeFile = path$1.join(COVERAGE_DIR, "game.rbxl");
1243
- buildWithRojo(rewrittenProjectPath, placeFile);
1540
+ const manifest = writeManifest(manifestPath, allFiles, luauRoots, placeFile);
1541
+ if (!hasChanges && previousManifest?.placeFilePath !== void 0) return {
1542
+ manifest,
1543
+ placeFile: previousManifest.placeFilePath
1544
+ };
1545
+ buildRojoProject(rojoProjectPath, roots, placeFile);
1244
1546
  return {
1245
1547
  manifest,
1246
1548
  placeFile
1247
1549
  };
1248
1550
  }
1249
- function collectPaths(node, result) {
1250
- for (const [key, value] of Object.entries(node)) if (key === "$path" && typeof value === "string") result.push(value.replaceAll("\\", "/"));
1251
- else if (typeof value === "object" && !Array.isArray(value) && !key.startsWith("$")) collectPaths(value, result);
1252
- }
1253
1551
  function containsLuauFiles(directoryPath) {
1254
- return fs.readdirSync(directoryPath, { withFileTypes: true }).some((entry) => {
1552
+ return fs$1.readdirSync(directoryPath, { withFileTypes: true }).some((entry) => {
1255
1553
  if (entry.isFile() && entry.name.endsWith(".luau")) return true;
1256
1554
  if (entry.isDirectory()) return containsLuauFiles(path$1.join(directoryPath, entry.name));
1257
1555
  return false;
@@ -1260,8 +1558,8 @@ function containsLuauFiles(directoryPath) {
1260
1558
  function findRojoProject(config) {
1261
1559
  if (config.rojoProject !== void 0) return config.rojoProject;
1262
1560
  const defaultPath = path$1.join(config.rootDir, "default.project.json");
1263
- if (fs.existsSync(defaultPath)) return defaultPath;
1264
- const projectFile = fs.readdirSync(config.rootDir, "utf-8").find((file) => file.endsWith(".project.json"));
1561
+ if (fs$1.existsSync(defaultPath)) return defaultPath;
1562
+ const projectFile = fs$1.readdirSync(config.rootDir, "utf-8").find((file) => file.endsWith(".project.json"));
1265
1563
  if (projectFile !== void 0) return path$1.join(config.rootDir, projectFile);
1266
1564
  throw new Error("No Rojo project found. Set rojoProject in config or add a .project.json file.");
1267
1565
  }
@@ -1269,7 +1567,7 @@ function resolveLuauRootsWithRojo(config, rojoProjectPath) {
1269
1567
  if (config.luauRoots !== void 0 && config.luauRoots.length > 0) return config.luauRoots;
1270
1568
  try {
1271
1569
  const resolvedPath = rojoProjectPath ?? findRojoProject(config);
1272
- const roots = collectLuauRootsFromRojo(JSON.parse(fs.readFileSync(resolvedPath, "utf-8")), config);
1570
+ const roots = collectLuauRootsFromRojo(JSON.parse(fs$1.readFileSync(resolvedPath, "utf-8")), config);
1273
1571
  if (roots.length > 0) return roots;
1274
1572
  } catch (err) {
1275
1573
  if (err instanceof SyntaxError) throw new Error(`Malformed Rojo project JSON: ${err.message}`, { cause: err });
@@ -1278,7 +1576,118 @@ function resolveLuauRootsWithRojo(config, rojoProjectPath) {
1278
1576
  if (outDirectory !== void 0) return [outDirectory];
1279
1577
  throw new Error("Could not determine luauRoots. Set luauRoots in config or ensure tsconfig has outDir.");
1280
1578
  }
1281
-
1579
+ function validateRelativeRoots(luauRoots) {
1580
+ 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.");
1581
+ }
1582
+ function computeSkipFiles(luauRoot, previousManifest) {
1583
+ const skipFiles = /* @__PURE__ */ new Set();
1584
+ const posixRoot = luauRoot.replaceAll("\\", "/");
1585
+ for (const [fileKey, record] of Object.entries(previousManifest.files)) {
1586
+ if (!fileKey.startsWith(`${posixRoot}/`)) continue;
1587
+ const relativePath = fileKey.slice(posixRoot.length + 1);
1588
+ const sourcePath = path$1.resolve(record.originalLuauPath);
1589
+ if (!fs$1.existsSync(sourcePath)) continue;
1590
+ if (hashBuffer(fs$1.readFileSync(sourcePath)) === record.sourceHash) skipFiles.add(relativePath);
1591
+ }
1592
+ return skipFiles;
1593
+ }
1594
+ function countPreviousFilesForRoot(luauRoot, previousManifest) {
1595
+ const posixRoot = luauRoot.replaceAll("\\", "/");
1596
+ let count = 0;
1597
+ for (const fileKey of Object.keys(previousManifest.files)) if (fileKey.startsWith(`${posixRoot}/`)) count++;
1598
+ return count;
1599
+ }
1600
+ function carryForwardRecords(luauRoot, previousManifest, allFiles, skipFiles) {
1601
+ const posixRoot = luauRoot.replaceAll("\\", "/");
1602
+ for (const relativePath of skipFiles) {
1603
+ const fileKey = `${posixRoot}/${relativePath}`;
1604
+ Object.assign(allFiles, { [fileKey]: previousManifest.files[fileKey] });
1605
+ }
1606
+ }
1607
+ function instrumentRootWithCache(luauRoot, useIncremental, previousManifest) {
1608
+ const shadowDirectory = path$1.join(COVERAGE_DIR, luauRoot).replaceAll("\\", "/");
1609
+ let changed = false;
1610
+ if (!useIncremental) {
1611
+ fs$1.mkdirSync(shadowDirectory, { recursive: true });
1612
+ fs$1.cpSync(luauRoot, shadowDirectory, { recursive: true });
1613
+ }
1614
+ let skipFiles;
1615
+ if (useIncremental && previousManifest !== void 0) {
1616
+ skipFiles = computeSkipFiles(luauRoot, previousManifest);
1617
+ const previousCount = countPreviousFilesForRoot(luauRoot, previousManifest);
1618
+ if (skipFiles.size !== previousCount) changed = true;
1619
+ }
1620
+ const files = instrumentRoot({
1621
+ luauRoot,
1622
+ shadowDir: shadowDirectory,
1623
+ skipFiles
1624
+ });
1625
+ if (Object.keys(files).length > 0) changed = true;
1626
+ const allFiles = { ...files };
1627
+ if (useIncremental && previousManifest !== void 0 && skipFiles !== void 0) carryForwardRecords(luauRoot, previousManifest, allFiles, skipFiles);
1628
+ const relocatedShadowDirectory = path$1.relative(COVERAGE_DIR, shadowDirectory).replaceAll("\\", "/");
1629
+ return {
1630
+ changed,
1631
+ files: allFiles,
1632
+ rootEntry: {
1633
+ luauRoot,
1634
+ relocatedShadowDirectory,
1635
+ shadowDir: shadowDirectory
1636
+ }
1637
+ };
1638
+ }
1639
+ function writeManifest(manifestPath, allFiles, luauRoots, placeFile) {
1640
+ const manifest = {
1641
+ files: allFiles,
1642
+ generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
1643
+ instrumenterVersion: 2,
1644
+ luauRoots,
1645
+ placeFilePath: placeFile,
1646
+ shadowDir: COVERAGE_DIR,
1647
+ version: 1
1648
+ };
1649
+ fs$1.mkdirSync(path$1.dirname(manifestPath), { recursive: true });
1650
+ fs$1.writeFileSync(manifestPath, JSON.stringify(manifest, void 0, " "));
1651
+ return manifest;
1652
+ }
1653
+ function buildRojoProject(rojoProjectPath, roots, placeFile) {
1654
+ const rojoProjectRaw = rojoProjectSchema(JSON.parse(fs$1.readFileSync(rojoProjectPath, "utf-8")));
1655
+ if (rojoProjectRaw instanceof type.errors) throw new Error(`Malformed Rojo project JSON: ${rojoProjectRaw.toString()}`);
1656
+ const rewritten = rewriteRojoProject(rojoProjectRaw, {
1657
+ projectRelocation: path$1.relative(COVERAGE_DIR, path$1.dirname(rojoProjectPath)).replaceAll("\\", "/"),
1658
+ roots
1659
+ });
1660
+ const rewrittenProjectPath = path$1.join(COVERAGE_DIR, path$1.basename(rojoProjectPath));
1661
+ fs$1.writeFileSync(rewrittenProjectPath, JSON.stringify(rewritten, void 0, " "));
1662
+ buildWithRojo(rewrittenProjectPath, placeFile);
1663
+ }
1664
+ function loadPreviousManifest(manifestPath) {
1665
+ if (!fs$1.existsSync(manifestPath)) return;
1666
+ try {
1667
+ const result = previousManifestSchema(JSON.parse(fs$1.readFileSync(manifestPath, "utf-8")));
1668
+ if (result instanceof type.errors) return;
1669
+ return result;
1670
+ } catch {
1671
+ return;
1672
+ }
1673
+ }
1674
+ function canUseIncremental(previousManifest, config) {
1675
+ if (!config.cache) return false;
1676
+ if (previousManifest === void 0) return false;
1677
+ if (previousManifest.instrumenterVersion !== 2) return false;
1678
+ return true;
1679
+ }
1680
+ function detectDeletedFiles(previousManifest, currentFiles) {
1681
+ const deleted = [];
1682
+ for (const [fileKey, record] of Object.entries(previousManifest.files)) if (!(fileKey in currentFiles)) deleted.push(record);
1683
+ return deleted;
1684
+ }
1685
+ function cleanupDeletedFiles(records) {
1686
+ for (const record of records) try {
1687
+ if (fs$1.existsSync(record.instrumentedLuauPath)) fs$1.unlinkSync(record.instrumentedLuauPath);
1688
+ if (fs$1.existsSync(record.coverageMapPath)) fs$1.unlinkSync(record.coverageMapPath);
1689
+ } catch {}
1690
+ }
1282
1691
  //#endregion
1283
1692
  //#region src/coverage/reporter.ts
1284
1693
  const VALID_REPORTERS = new Set([
@@ -1382,7 +1791,6 @@ function buildCoverageMap(mapped) {
1382
1791
  function isValidReporter(name) {
1383
1792
  return VALID_REPORTERS.has(name);
1384
1793
  }
1385
-
1386
1794
  //#endregion
1387
1795
  //#region src/utils/glob.ts
1388
1796
  function globSync(pattern, options = {}) {
@@ -1396,7 +1804,7 @@ function matchesGlobPattern(filePath, pattern) {
1396
1804
  function walkDirectory(directoryPath, baseDirectory) {
1397
1805
  const results = [];
1398
1806
  try {
1399
- const entries = fs.readdirSync(directoryPath, { withFileTypes: true });
1807
+ const entries = fs$1.readdirSync(directoryPath, { withFileTypes: true });
1400
1808
  for (const entry of entries) {
1401
1809
  const fullPath = path$1.join(directoryPath, entry.name);
1402
1810
  const relativePath = path$1.relative(baseDirectory, fullPath).replace(/\\/g, "/");
@@ -1407,45 +1815,43 @@ function walkDirectory(directoryPath, baseDirectory) {
1407
1815
  } catch {}
1408
1816
  return results;
1409
1817
  }
1410
-
1411
1818
  //#endregion
1412
1819
  //#region src/cli.ts
1820
+ const VERSION = version;
1821
+ const DEFAULT_ROJO_PROJECT = "default.project.json";
1413
1822
  const TYPE_TEST_PATTERN = /\.(test-d|spec-d)\.ts$/;
1414
1823
  const HELP_TEXT = `
1415
1824
  Usage: jest-roblox [options] [files...]
1416
1825
 
1417
1826
  Options:
1418
- --backend <type> Backend: "auto", "open-cloud", or "studio" (default: auto)
1419
- --port <number> WebSocket port for studio backend (default: 3001)
1420
- --config <path> Path to config file
1421
- --testPathPattern <regex> Filter test files by path pattern
1422
- -t, --testNamePattern <regex> Filter tests by name pattern
1423
- --json Output results as JSON
1424
- --compact Token-efficient output for AI consumption
1425
- --compactMaxFailures <n> Max failures in compact mode (default: 10)
1426
- --outputFile <path> Write results to file
1427
- --gameOutput <path> Write game output (print/warn/error) to file
1428
- --sourceMap Map Luau stack traces to TypeScript source
1429
- --rojoProject <path> Path to rojo project file (auto-detected if not set)
1430
- --verbose Show individual test results
1431
- --silent Suppress output
1432
- --no-color Disable colored output
1433
- -u, --updateSnapshot Update snapshot files
1434
- --coverage Enable coverage collection
1435
- --coverageDirectory <path> Directory for coverage output (default: coverage)
1436
- --coverageReporters <r...> Coverage reporters (default: text, lcov)
1437
- --formatters <name...> Output formatters (default: default; auto: github-actions)
1438
- --no-cache Force re-upload place file (skip cache)
1439
- --pollInterval <ms> Open Cloud poll interval in ms (default: 500)
1440
- --projects <path...> DataModel paths to search for tests
1441
- --setupFiles <path...> DataModel paths to setup scripts
1442
- --setupFilesAfterEnv <path...> DataModel paths to post-env setup scripts
1443
- --no-show-luau Hide Luau code in failure output
1444
- --typecheck Enable type testing (*.test-d.ts, *.spec-d.ts)
1445
- --typecheckOnly Run only type tests, skip runtime tests
1446
- --typecheckTsconfig <path> tsconfig for type testing
1447
- --help Show this help message
1448
- --version Show version number
1827
+ --backend <type> Backend: "auto", "open-cloud", or "studio" (default: auto)
1828
+ --port <number> WebSocket port for studio backend (default: 3001)
1829
+ --config <path> Path to config file
1830
+ --testPathPattern <regex> Filter test files by path pattern
1831
+ -t, --testNamePattern <regex> Filter tests by name pattern
1832
+ --outputFile <path> Write results to file
1833
+ --gameOutput <path> Write game output (print/warn/error) to file
1834
+ --sourceMap Map Luau stack traces to TypeScript source
1835
+ --rojoProject <path> Path to rojo project file (auto-detected if not set)
1836
+ --verbose Show individual test results
1837
+ --silent Suppress output
1838
+ --no-color Disable colored output
1839
+ -u, --updateSnapshot Update snapshot files
1840
+ --coverage Enable coverage collection
1841
+ --coverageDirectory <path> Directory for coverage output (default: coverage)
1842
+ --coverageReporters <r...> Coverage reporters (default: text, lcov)
1843
+ --formatters <name...> Output formatters (default, agent, json, github-actions)
1844
+ --no-cache Force re-upload place file (skip cache)
1845
+ --pollInterval <ms> Open Cloud poll interval in ms (default: 500)
1846
+ --project <name...> Filter which named projects to run
1847
+ --setupFiles <path...> Setup scripts (package specifiers or relative paths)
1848
+ --setupFilesAfterEnv <path...> Post-env setup scripts (package specifiers or relative paths)
1849
+ --no-show-luau Hide Luau code in failure output
1850
+ --typecheck Enable type testing (*.test-d.ts, *.spec-d.ts)
1851
+ --typecheckOnly Run only type tests, skip runtime tests
1852
+ --typecheckTsconfig <path> tsconfig for type testing
1853
+ --help Show this help message
1854
+ --version Show version number
1449
1855
 
1450
1856
  Environment Variables (open-cloud backend only):
1451
1857
  ROBLOX_OPEN_CLOUD_API_KEY API key for Roblox Open Cloud
@@ -1457,7 +1863,7 @@ Examples:
1457
1863
  jest-roblox --backend studio Run tests via Studio plugin
1458
1864
  jest-roblox src/player.spec.ts Run specific test file
1459
1865
  jest-roblox -t "should spawn" Run tests matching pattern
1460
- jest-roblox --json --outputFile results.json
1866
+ jest-roblox --formatters json Output JSON to file
1461
1867
  jest-roblox --coverage Run tests with coverage instrumentation
1462
1868
  `;
1463
1869
  function parseArgs(args) {
@@ -1468,8 +1874,6 @@ function parseArgs(args) {
1468
1874
  "backend": { type: "string" },
1469
1875
  "cache": { type: "boolean" },
1470
1876
  "color": { type: "boolean" },
1471
- "compact": { type: "boolean" },
1472
- "compactMaxFailures": { type: "string" },
1473
1877
  "config": { type: "string" },
1474
1878
  "coverage": { type: "boolean" },
1475
1879
  "coverageDirectory": { type: "string" },
@@ -1486,14 +1890,13 @@ function parseArgs(args) {
1486
1890
  default: false,
1487
1891
  type: "boolean"
1488
1892
  },
1489
- "json": { type: "boolean" },
1490
1893
  "no-cache": { type: "boolean" },
1491
1894
  "no-color": { type: "boolean" },
1492
1895
  "no-show-luau": { type: "boolean" },
1493
1896
  "outputFile": { type: "string" },
1494
1897
  "pollInterval": { type: "string" },
1495
1898
  "port": { type: "string" },
1496
- "projects": {
1899
+ "project": {
1497
1900
  multiple: true,
1498
1901
  type: "string"
1499
1902
  },
@@ -1530,7 +1933,6 @@ function parseArgs(args) {
1530
1933
  },
1531
1934
  strict: true
1532
1935
  });
1533
- const compactMaxFailures = values.compactMaxFailures !== void 0 ? Number.parseInt(values.compactMaxFailures, 10) : void 0;
1534
1936
  const pollInterval = values.pollInterval !== void 0 ? Number.parseInt(values.pollInterval, 10) : void 0;
1535
1937
  const port = values.port !== void 0 ? Number.parseInt(values.port, 10) : void 0;
1536
1938
  const timeout = values.timeout !== void 0 ? Number.parseInt(values.timeout, 10) : void 0;
@@ -1539,8 +1941,6 @@ function parseArgs(args) {
1539
1941
  cache: values["no-cache"] === true ? false : values.cache,
1540
1942
  collectCoverage: values.coverage,
1541
1943
  color: values["no-color"] === true ? false : values.color,
1542
- compact: values.compact,
1543
- compactMaxFailures,
1544
1944
  config: values.config,
1545
1945
  coverageDirectory: values.coverageDirectory,
1546
1946
  coverageReporters: values.coverageReporters,
@@ -1548,11 +1948,10 @@ function parseArgs(args) {
1548
1948
  formatters: values.formatters,
1549
1949
  gameOutput: values.gameOutput,
1550
1950
  help: values.help,
1551
- json: values.json,
1552
1951
  outputFile: values.outputFile,
1553
1952
  pollInterval,
1554
1953
  port,
1555
- projects: values.projects,
1954
+ project: values.project,
1556
1955
  rojoProject: values.rojoProject,
1557
1956
  setupFiles: values.setupFiles,
1558
1957
  setupFilesAfterEnv: values.setupFilesAfterEnv,
@@ -1570,6 +1969,77 @@ function parseArgs(args) {
1570
1969
  version: values.version
1571
1970
  };
1572
1971
  }
1972
+ function filterByName(projects, names) {
1973
+ const available = new Set(projects.map((project) => project.displayName));
1974
+ const unknown = names.filter((name) => !available.has(name));
1975
+ if (unknown.length > 0) throw new Error(`Unknown project name(s): ${unknown.join(", ")}. Available: ${[...available].join(", ")}`);
1976
+ const nameSet = new Set(names);
1977
+ return projects.filter((project) => nameSet.has(project.displayName));
1978
+ }
1979
+ function mergeProjectResults(results) {
1980
+ assert(results.length > 0, "mergeProjectResults requires at least one result");
1981
+ if (results.length === 1) {
1982
+ const [first] = results;
1983
+ return first;
1984
+ }
1985
+ let numberFailedTests = 0;
1986
+ let numberPassedTests = 0;
1987
+ let numberPendingTests = 0;
1988
+ let numberTodoTests = 0;
1989
+ let numberTotalTests = 0;
1990
+ let startTime = Number.POSITIVE_INFINITY;
1991
+ let success = true;
1992
+ const testResults = [];
1993
+ let executionMs = 0;
1994
+ let testsMs = 0;
1995
+ let totalMs = 0;
1996
+ let uploadMs = 0;
1997
+ let coverageMs = 0;
1998
+ let mergedCoverage;
1999
+ for (const result of results) {
2000
+ numberFailedTests += result.result.numFailedTests;
2001
+ numberPassedTests += result.result.numPassedTests;
2002
+ numberPendingTests += result.result.numPendingTests;
2003
+ numberTodoTests += result.result.numTodoTests ?? 0;
2004
+ numberTotalTests += result.result.numTotalTests;
2005
+ startTime = Math.min(startTime, result.result.startTime);
2006
+ success &&= result.result.success;
2007
+ testResults.push(...result.result.testResults);
2008
+ executionMs += result.timing.executionMs;
2009
+ testsMs += result.timing.testsMs;
2010
+ totalMs += result.timing.totalMs;
2011
+ uploadMs += result.timing.uploadMs ?? 0;
2012
+ coverageMs += result.timing.coverageMs ?? 0;
2013
+ if (result.coverageData !== void 0) mergedCoverage = {
2014
+ ...mergedCoverage,
2015
+ ...result.coverageData
2016
+ };
2017
+ }
2018
+ return {
2019
+ coverageData: mergedCoverage,
2020
+ exitCode: success ? 0 : 1,
2021
+ output: "",
2022
+ result: {
2023
+ numFailedTests: numberFailedTests,
2024
+ numPassedTests: numberPassedTests,
2025
+ numPendingTests: numberPendingTests,
2026
+ numTodoTests: numberTodoTests,
2027
+ numTotalTests: numberTotalTests,
2028
+ startTime,
2029
+ success,
2030
+ testResults
2031
+ },
2032
+ timing: {
2033
+ coverageMs: coverageMs > 0 ? coverageMs : void 0,
2034
+ executionMs,
2035
+ startTime: Math.min(...results.map((result) => result.timing.startTime)),
2036
+ testsMs,
2037
+ totalMs,
2038
+ uploadCached: results.every((result) => result.timing.uploadCached === true),
2039
+ uploadMs
2040
+ }
2041
+ };
2042
+ }
1573
2043
  async function run(args) {
1574
2044
  try {
1575
2045
  return await runInner(args);
@@ -1582,24 +2052,35 @@ async function main() {
1582
2052
  const exitCode = await run(process.argv.slice(2));
1583
2053
  process.exit(exitCode);
1584
2054
  }
2055
+ function formatGameOutputLines(raw) {
2056
+ if (raw === void 0) return;
2057
+ const entries = parseGameOutput(raw);
2058
+ if (entries.length === 0) return;
2059
+ return entries.map((entry) => entry.message.replace(/^/gm, " ")).join("\n");
2060
+ }
1585
2061
  function printError(err) {
1586
- if (err instanceof LuauScriptError) {
2062
+ if (err instanceof ConfigError) {
2063
+ const body = [color.red(err.message)];
2064
+ if (err.hint !== void 0) body.push(`\n ${color.dim("Hint:")} ${err.hint}`);
2065
+ process.stderr.write(formatBanner({
2066
+ body,
2067
+ level: "error",
2068
+ title: "Config Error"
2069
+ }));
2070
+ } else if (err instanceof LuauScriptError) {
2071
+ const body = [color.red(err.message)];
1587
2072
  const hint = getLuauErrorHint(err.message);
1588
- console.error(`\n Luau script error: ${err.message}`);
1589
- if (hint !== void 0) console.error(` Hint: ${hint}`);
1590
- console.error();
2073
+ if (hint !== void 0) body.push(`\n ${color.dim("Hint:")} ${hint}`);
2074
+ const gameLines = formatGameOutputLines(err.gameOutput);
2075
+ if (gameLines !== void 0) body.push(`\n ${color.dim("Game output:")}\n${gameLines}`);
2076
+ process.stderr.write(formatBanner({
2077
+ body,
2078
+ level: "error",
2079
+ title: "Luau Error"
2080
+ }));
1591
2081
  } else if (err instanceof Error) console.error(`Error: ${err.message}`);
1592
2082
  else console.error("An unknown error occurred");
1593
2083
  }
1594
- async function executeRuntimeTests(config, testFiles, totalFiles) {
1595
- if (!config.silent && (!config.compact || config.verbose) && !config.json && testFiles.length !== totalFiles) process.stderr.write(`Running ${String(testFiles.length)} of ${String(totalFiles)} test files\n`);
1596
- return execute({
1597
- backend: await resolveBackend(config),
1598
- config,
1599
- testFiles,
1600
- version
1601
- });
1602
- }
1603
2084
  function writeGameOutputIfConfigured(config, gameOutput, options) {
1604
2085
  if (config.gameOutput === void 0) return;
1605
2086
  const entries = parseGameOutput(gameOutput);
@@ -1637,13 +2118,8 @@ function processCoverage(config, coverageData) {
1637
2118
  }
1638
2119
  return true;
1639
2120
  }
1640
- function findFormatterOptions(formatters, name) {
1641
- for (const entry of formatters) {
1642
- if (entry === name) return {};
1643
- if (Array.isArray(entry) && entry[0] === name) return entry[1];
1644
- }
1645
- }
1646
2121
  function runGitHubActionsFormatter(config, result, sourceMapper) {
2122
+ assert(config.formatters !== void 0, "formatters is set by resolveFormatters");
1647
2123
  const userOptions = findFormatterOptions(config.formatters, "github-actions");
1648
2124
  if (userOptions === void 0) return;
1649
2125
  const typedOptions = userOptions;
@@ -1657,15 +2133,80 @@ function runGitHubActionsFormatter(config, result, sourceMapper) {
1657
2133
  const outputPath = jobSummary?.outputPath ?? process.env["GITHUB_STEP_SUMMARY"];
1658
2134
  if (outputPath !== void 0) {
1659
2135
  const summary = formatJobSummary(result, options);
1660
- fs.appendFileSync(outputPath, summary);
2136
+ fs$1.appendFileSync(outputPath, summary);
2137
+ }
2138
+ }
2139
+ }
2140
+ function hasFormatter(config, name) {
2141
+ return config.formatters?.some((entry) => Array.isArray(entry) ? entry[0] === name : entry === name) === true;
2142
+ }
2143
+ function getCompactMaxFailures(config) {
2144
+ assert(config.formatters !== void 0, "formatters is set by resolveFormatters");
2145
+ const options = findFormatterOptions(config.formatters, "agent");
2146
+ if (options !== void 0 && typeof options["maxFailures"] === "number") return options["maxFailures"];
2147
+ return 10;
2148
+ }
2149
+ function usesAgentFormatter(config) {
2150
+ return hasFormatter(config, "agent") && !config.verbose;
2151
+ }
2152
+ function usesDefaultFormatter(config) {
2153
+ return !hasFormatter(config, "json") && !usesAgentFormatter(config);
2154
+ }
2155
+ function printOutput(output) {
2156
+ if (output !== "") console.log(output);
2157
+ }
2158
+ function formatRuntimeOutput(config, runtimeResult, timing) {
2159
+ return formatExecuteOutput({
2160
+ config,
2161
+ result: runtimeResult.result,
2162
+ sourceMapper: runtimeResult.sourceMapper,
2163
+ timing,
2164
+ version: VERSION
2165
+ });
2166
+ }
2167
+ function printFormattedOutput(options) {
2168
+ const { config, mergedResult, runtimeResult, timing, typecheckResult } = options;
2169
+ if (typecheckResult !== void 0 && runtimeResult !== void 0 && timing !== void 0) {
2170
+ if (usesDefaultFormatter(config)) printOutput(formatResult(mergedResult, timing, {
2171
+ collectCoverage: config.collectCoverage,
2172
+ color: config.color,
2173
+ rootDir: config.rootDir,
2174
+ showLuau: config.showLuau,
2175
+ sourceMapper: runtimeResult.sourceMapper,
2176
+ typeErrors: typecheckResult.numFailedTests,
2177
+ verbose: config.verbose,
2178
+ version: VERSION
2179
+ }));
2180
+ else {
2181
+ printOutput(formatRuntimeOutput(config, runtimeResult, timing));
2182
+ process.stderr.write(formatTypecheckSummary(typecheckResult));
1661
2183
  }
2184
+ return;
2185
+ }
2186
+ if (typecheckResult !== void 0) {
2187
+ process.stdout.write(formatTypecheckSummary(typecheckResult));
2188
+ return;
1662
2189
  }
2190
+ assert(runtimeResult !== void 0 && timing !== void 0, "runtime result required");
2191
+ printOutput(formatRuntimeOutput(config, runtimeResult, timing));
1663
2192
  }
1664
- async function outputResults(config, typecheckResult, runtimeResult) {
2193
+ function addCoverageTiming(timing, coverageMs) {
2194
+ return {
2195
+ ...timing,
2196
+ coverageMs,
2197
+ totalMs: timing.totalMs + coverageMs
2198
+ };
2199
+ }
2200
+ async function outputResults(config, typecheckResult, runtimeResult, preCoverageMs) {
1665
2201
  const mergedResult = mergeResults(typecheckResult, runtimeResult?.result);
1666
- if (runtimeResult !== void 0 && runtimeResult.output !== "") console.log(runtimeResult.output);
2202
+ if (!config.silent) printFormattedOutput({
2203
+ config,
2204
+ mergedResult,
2205
+ runtimeResult,
2206
+ timing: runtimeResult !== void 0 ? addCoverageTiming(runtimeResult.timing, preCoverageMs) : void 0,
2207
+ typecheckResult
2208
+ });
1667
2209
  const coveragePassed = processCoverage(config, runtimeResult?.coverageData);
1668
- if (typecheckResult !== void 0 && !config.silent) printTypecheckSummary(typecheckResult);
1669
2210
  if (config.outputFile !== void 0) await writeJsonFile(mergedResult, config.outputFile);
1670
2211
  if (runtimeResult !== void 0) writeGameOutputIfConfigured(config, runtimeResult.gameOutput, { hintsShown: !mergedResult.success });
1671
2212
  runGitHubActionsFormatter(config, mergedResult, runtimeResult?.sourceMapper);
@@ -1673,18 +2214,205 @@ async function outputResults(config, typecheckResult, runtimeResult) {
1673
2214
  if (!config.silent && config.collectCoverage) printFinalStatus(passed);
1674
2215
  return passed ? 0 : 1;
1675
2216
  }
1676
- async function runInner(args) {
1677
- const cli = parseArgs(args);
1678
- if (cli.help === true) {
1679
- console.log(HELP_TEXT);
1680
- return 0;
2217
+ function toProjectEntries(projectResults) {
2218
+ return projectResults.map((pr) => {
2219
+ return {
2220
+ displayColor: pr.displayColor,
2221
+ displayName: pr.displayName,
2222
+ result: pr.result.result
2223
+ };
2224
+ });
2225
+ }
2226
+ function printMultiProjectOutput(options) {
2227
+ const { config, merged, preCoverageMs, projectResults, typecheckResult } = options;
2228
+ const timing = addCoverageTiming(merged.timing, preCoverageMs);
2229
+ if (usesAgentFormatter(config)) {
2230
+ printOutput(formatCompactMultiProject(toProjectEntries(projectResults), {
2231
+ gameOutput: config.gameOutput,
2232
+ maxFailures: getCompactMaxFailures(config),
2233
+ outputFile: config.outputFile,
2234
+ rootDir: config.rootDir,
2235
+ sourceMapper: merged.sourceMapper,
2236
+ typeErrorCount: typecheckResult?.numFailedTests
2237
+ }));
2238
+ return;
1681
2239
  }
1682
- if (cli.version === true) {
1683
- console.log(version);
1684
- return 0;
2240
+ if (hasFormatter(config, "json")) {
2241
+ printOutput(formatRuntimeOutput(config, merged, timing));
2242
+ return;
2243
+ }
2244
+ printOutput(formatMultiProjectResult(toProjectEntries(projectResults), timing, {
2245
+ collectCoverage: config.collectCoverage,
2246
+ color: config.color,
2247
+ rootDir: config.rootDir,
2248
+ showLuau: config.showLuau,
2249
+ sourceMapper: merged.sourceMapper,
2250
+ typeErrors: typecheckResult?.numFailedTests,
2251
+ verbose: config.verbose,
2252
+ version: VERSION
2253
+ }));
2254
+ }
2255
+ async function outputMultiProjectResults(config, projectResults, typecheckResult, preCoverageMs) {
2256
+ const merged = mergeProjectResults(projectResults.map((pr) => pr.result));
2257
+ const mergedResult = mergeResults(typecheckResult, merged.result);
2258
+ if (!config.silent) {
2259
+ printMultiProjectOutput({
2260
+ config,
2261
+ merged,
2262
+ preCoverageMs,
2263
+ projectResults,
2264
+ typecheckResult
2265
+ });
2266
+ if (typecheckResult !== void 0 && !usesDefaultFormatter(config)) process.stderr.write(formatTypecheckSummary(typecheckResult));
2267
+ }
2268
+ const coveragePassed = processCoverage(config, merged.coverageData);
2269
+ if (config.outputFile !== void 0) await writeJsonFile(mergedResult, config.outputFile);
2270
+ runGitHubActionsFormatter(config, mergedResult, merged.sourceMapper);
2271
+ const passed = mergedResult.success && coveragePassed;
2272
+ if (!config.silent && config.collectCoverage) printFinalStatus(passed);
2273
+ return passed ? 0 : 1;
2274
+ }
2275
+ function loadRojoTree(config) {
2276
+ const rojoPath = path$1.resolve(config.rootDir, config.rojoProject ?? DEFAULT_ROJO_PROJECT);
2277
+ const content = fs$1.readFileSync(rojoPath, "utf8");
2278
+ const validated = rojoProjectSchema(JSON.parse(content));
2279
+ if (validated instanceof type.errors) throw new Error(`Invalid Rojo project: ${validated.summary}`);
2280
+ return validated.tree;
2281
+ }
2282
+ const STUB_SKIP_KEYS = new Set([
2283
+ "outDir",
2284
+ "projects",
2285
+ "root"
2286
+ ]);
2287
+ function buildStubConfig(config) {
2288
+ const result = {};
2289
+ 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;
2290
+ return result;
2291
+ }
2292
+ function generateProjectStubs(projects, rootDirectory) {
2293
+ const entries = [];
2294
+ for (const project of projects) {
2295
+ if (project.outDir === void 0) continue;
2296
+ const outputPath = path$1.resolve(rootDirectory, project.outDir, "jest.config.lua");
2297
+ const stubConfig = {
2298
+ ...buildStubConfig(project.config),
2299
+ displayName: project.displayName,
2300
+ include: [],
2301
+ testMatch: project.testMatch
2302
+ };
2303
+ entries.push({
2304
+ config: stubConfig,
2305
+ outputPath
2306
+ });
1685
2307
  }
1686
- let config = mergeCliWithConfig(cli, await loadConfig(cli.config));
1687
- const discovery = discoverTestFiles(config, cli.files);
2308
+ generateProjectConfigs(entries);
2309
+ }
2310
+ function prepareMultiProjectCoverage(rootConfig, projects) {
2311
+ if (!rootConfig.collectCoverage) return {
2312
+ effectiveConfig: rootConfig,
2313
+ preCoverageMs: 0
2314
+ };
2315
+ const start = Date.now();
2316
+ const { placeFile } = prepareCoverage(rootConfig, (shadowDirectory) => {
2317
+ return syncStubsToShadowDirectory(projects, rootConfig.rootDir, shadowDirectory);
2318
+ });
2319
+ return {
2320
+ effectiveConfig: {
2321
+ ...rootConfig,
2322
+ placeFile
2323
+ },
2324
+ preCoverageMs: Date.now() - start
2325
+ };
2326
+ }
2327
+ function classifyTestFiles(files, config) {
2328
+ const typeTestFiles = config.typecheck ? files.filter((file) => TYPE_TEST_PATTERN.test(file)) : [];
2329
+ return {
2330
+ runtimeFiles: config.typecheckOnly ? [] : files.filter((file) => !TYPE_TEST_PATTERN.test(file)),
2331
+ typeTestFiles
2332
+ };
2333
+ }
2334
+ function applySetupResolver(config, resolve) {
2335
+ if (config.setupFiles !== void 0) config.setupFiles = config.setupFiles.map(resolve);
2336
+ if (config.setupFilesAfterEnv !== void 0) config.setupFilesAfterEnv = config.setupFilesAfterEnv.map(resolve);
2337
+ }
2338
+ async function runMultiProject(cli, rootConfig, projectEntries) {
2339
+ const allProjects = await resolveAllProjects(projectEntries, rootConfig, loadRojoTree(rootConfig), rootConfig.rootDir);
2340
+ const rojoConfigPath = path$1.resolve(rootConfig.rootDir, rootConfig.rojoProject ?? DEFAULT_ROJO_PROJECT);
2341
+ const resolveSetup = createSetupResolver({
2342
+ configDirectory: rootConfig.rootDir,
2343
+ rojoConfigPath
2344
+ });
2345
+ for (const project of allProjects) applySetupResolver(project.config, resolveSetup);
2346
+ const projects = cli.project !== void 0 ? filterByName(allProjects, cli.project) : allProjects;
2347
+ generateProjectStubs(projects, rootConfig.rootDir);
2348
+ if (!rootConfig.collectCoverage) buildWithRojo(path$1.resolve(rootConfig.rootDir, rootConfig.rojoProject ?? DEFAULT_ROJO_PROJECT), path$1.resolve(rootConfig.rootDir, rootConfig.placeFile));
2349
+ const { effectiveConfig, preCoverageMs } = prepareMultiProjectCoverage(rootConfig, projects);
2350
+ const backend = await resolveBackend(effectiveConfig);
2351
+ const projectResults = [];
2352
+ const allTypeTestFiles = [];
2353
+ for (const project of projects) {
2354
+ const discoveryConfig = {
2355
+ ...project.config,
2356
+ placeFile: effectiveConfig.placeFile,
2357
+ projects: project.projects,
2358
+ testMatch: project.include
2359
+ };
2360
+ const { runtimeFiles, typeTestFiles } = classifyTestFiles(discoverTestFiles(discoveryConfig, cli.files).files, rootConfig);
2361
+ const projConfig = {
2362
+ ...discoveryConfig,
2363
+ testMatch: project.testMatch
2364
+ };
2365
+ allTypeTestFiles.push(...typeTestFiles);
2366
+ if (runtimeFiles.length === 0 && typeTestFiles.length === 0) continue;
2367
+ if (runtimeFiles.length > 0) {
2368
+ const result = await execute({
2369
+ backend,
2370
+ config: projConfig,
2371
+ deferFormatting: true,
2372
+ testFiles: runtimeFiles,
2373
+ version: VERSION
2374
+ });
2375
+ projectResults.push({
2376
+ displayColor: project.displayColor,
2377
+ displayName: project.displayName,
2378
+ result
2379
+ });
2380
+ }
2381
+ }
2382
+ const uniqueTypeTestFiles = [...new Set(allTypeTestFiles)];
2383
+ const typecheckResult = uniqueTypeTestFiles.length > 0 ? runTypecheck({
2384
+ files: uniqueTypeTestFiles,
2385
+ rootDir: rootConfig.rootDir,
2386
+ tsconfig: rootConfig.typecheckTsconfig
2387
+ }) : void 0;
2388
+ if (projectResults.length === 0 && typecheckResult === void 0) {
2389
+ console.error("No test files found in any project");
2390
+ return 2;
2391
+ }
2392
+ if (projectResults.length === 0) return outputResults(rootConfig, typecheckResult, void 0, preCoverageMs);
2393
+ return outputMultiProjectResults(rootConfig, projectResults, typecheckResult, preCoverageMs);
2394
+ }
2395
+ async function executeRuntimeTests(config, testFiles, totalFiles) {
2396
+ if (!config.silent && !usesAgentFormatter(config) && !hasFormatter(config, "json") && testFiles.length !== totalFiles) process.stderr.write(`Running ${String(testFiles.length)} of ${String(totalFiles)} test files\n`);
2397
+ return execute({
2398
+ backend: await resolveBackend(config),
2399
+ config,
2400
+ deferFormatting: true,
2401
+ testFiles,
2402
+ version: VERSION
2403
+ });
2404
+ }
2405
+ function resolveSetupFilePaths(config) {
2406
+ if (config.setupFiles === void 0 && config.setupFilesAfterEnv === void 0) return;
2407
+ const rojoConfigPath = path$1.resolve(config.rootDir, config.rojoProject ?? DEFAULT_ROJO_PROJECT);
2408
+ applySetupResolver(config, createSetupResolver({
2409
+ configDirectory: config.rootDir,
2410
+ rojoConfigPath
2411
+ }));
2412
+ }
2413
+ async function runSingleProject(config, cliFiles) {
2414
+ resolveSetupFilePaths(config);
2415
+ const discovery = discoverTestFiles(config, cliFiles);
1688
2416
  if (discovery.files.length === 0) {
1689
2417
  console.error("No test files found");
1690
2418
  return 2;
@@ -1695,20 +2423,40 @@ async function runInner(args) {
1695
2423
  console.error("No test files found for the selected mode");
1696
2424
  return 2;
1697
2425
  }
2426
+ let preCoverageMs = 0;
2427
+ let effectiveConfig = config;
1698
2428
  if (config.collectCoverage && !config.typecheckOnly && runtimeTestFiles.length > 0) {
2429
+ const preCoverageStart = Date.now();
1699
2430
  const { placeFile } = prepareCoverage(config);
1700
- config = {
2431
+ preCoverageMs = Date.now() - preCoverageStart;
2432
+ effectiveConfig = {
1701
2433
  ...config,
1702
2434
  placeFile
1703
2435
  };
1704
2436
  }
1705
2437
  const typecheckResult = typeTestFiles.length > 0 ? runTypecheck({
1706
2438
  files: typeTestFiles,
1707
- rootDir: config.rootDir,
1708
- tsconfig: config.typecheckTsconfig
2439
+ rootDir: effectiveConfig.rootDir,
2440
+ tsconfig: effectiveConfig.typecheckTsconfig
1709
2441
  }) : void 0;
1710
- const runtimeResult = runtimeTestFiles.length > 0 ? await executeRuntimeTests(config, runtimeTestFiles, discovery.totalFiles) : void 0;
1711
- return outputResults(config, typecheckResult, runtimeResult);
2442
+ const runtimeResult = runtimeTestFiles.length > 0 ? await executeRuntimeTests(effectiveConfig, runtimeTestFiles, discovery.totalFiles) : void 0;
2443
+ return outputResults(effectiveConfig, typecheckResult, runtimeResult, preCoverageMs);
2444
+ }
2445
+ async function runInner(args) {
2446
+ const cli = parseArgs(args);
2447
+ if (cli.help === true) {
2448
+ console.log(HELP_TEXT);
2449
+ return 0;
2450
+ }
2451
+ if (cli.version === true) {
2452
+ console.log(VERSION);
2453
+ return 0;
2454
+ }
2455
+ 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.");
2456
+ const config = mergeCliWithConfig(cli, await loadConfig$1(cli.config));
2457
+ const rawProjects = config.projects;
2458
+ if (rawProjects !== void 0 && rawProjects.length > 0) return runMultiProject(cli, config, rawProjects);
2459
+ return runSingleProject(config, cli.files);
1712
2460
  }
1713
2461
  const LUAU_ERROR_HINTS = [
1714
2462
  [/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\""],
@@ -1757,10 +2505,19 @@ function validateBackend(value) {
1757
2505
  function getLuauErrorHint(message) {
1758
2506
  for (const [pattern, hint] of LUAU_ERROR_HINTS) if (pattern.test(message)) return hint;
1759
2507
  }
2508
+ function normalizeFormatterName(name) {
2509
+ return name === "compact" ? "agent" : name;
2510
+ }
2511
+ function normalizeFormatterEntry(entry) {
2512
+ if (Array.isArray(entry)) return [normalizeFormatterName(entry[0]), entry[1]];
2513
+ return normalizeFormatterName(entry);
2514
+ }
1760
2515
  function resolveFormatters(cli, config) {
1761
2516
  const explicit = cli.formatters ?? config.formatters;
1762
- if (explicit !== void 0) return explicit;
1763
- return process.env["GITHUB_ACTIONS"] === "true" ? ["default", "github-actions"] : ["default"];
2517
+ if (explicit !== void 0) return explicit.map(normalizeFormatterEntry);
2518
+ const defaults = isAgent ? ["agent"] : ["default"];
2519
+ if (process.env["GITHUB_ACTIONS"] === "true") defaults.push("github-actions");
2520
+ return defaults;
1764
2521
  }
1765
2522
  function mergeCliWithConfig(cli, config) {
1766
2523
  return {
@@ -1769,17 +2526,13 @@ function mergeCliWithConfig(cli, config) {
1769
2526
  cache: cli.cache ?? config.cache,
1770
2527
  collectCoverage: cli.collectCoverage ?? config.collectCoverage,
1771
2528
  color: cli.color ?? config.color,
1772
- compact: cli.compact ?? config.compact,
1773
- compactMaxFailures: cli.compactMaxFailures ?? config.compactMaxFailures,
1774
2529
  coverageDirectory: cli.coverageDirectory ?? config.coverageDirectory,
1775
2530
  coverageReporters: cli.coverageReporters ?? config.coverageReporters,
1776
2531
  formatters: resolveFormatters(cli, config),
1777
2532
  gameOutput: cli.gameOutput ?? config.gameOutput,
1778
- json: cli.json ?? config.json,
1779
2533
  outputFile: cli.outputFile ?? config.outputFile,
1780
2534
  pollInterval: cli.pollInterval ?? config.pollInterval,
1781
2535
  port: cli.port ?? config.port,
1782
- projects: cli.projects ?? config.projects,
1783
2536
  rojoProject: cli.rojoProject ?? config.rojoProject,
1784
2537
  setupFiles: cli.setupFiles ?? config.setupFiles,
1785
2538
  setupFilesAfterEnv: cli.setupFilesAfterEnv ?? config.setupFilesAfterEnv,
@@ -1811,21 +2564,5 @@ function mergeResults(typecheck, runtime) {
1811
2564
  assert(result !== void 0, "mergeResults requires at least one result");
1812
2565
  return result;
1813
2566
  }
1814
- function printTypecheckFailures(result) {
1815
- for (const file of result.testResults) for (const test of file.testResults) {
1816
- if (test.status !== "failed") continue;
1817
- process.stderr.write(` FAIL ${test.fullName}\n`);
1818
- for (const message of test.failureMessages) process.stderr.write(` ${message}\n`);
1819
- }
1820
- }
1821
- function printTypecheckSummary(result) {
1822
- const passed = result.numPassedTests;
1823
- const failed = result.numFailedTests;
1824
- const total = result.numTotalTests;
1825
- if (failed > 0) printTypecheckFailures(result);
1826
- const failedPart = failed > 0 ? `${String(failed)} failed, ` : "";
1827
- process.stderr.write(`\nType Tests: ${failedPart}${String(passed)} passed, ${String(total)} total\n`);
1828
- }
1829
-
1830
2567
  //#endregion
1831
- export { main, parseArgs, run };
2568
+ export { filterByName, main, mergeProjectResults, parseArgs, run };