@isentinel/jest-roblox 0.2.0 → 0.2.2

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,4 +1,4 @@
1
- import { A as createOpenCloudBackend, B as LuauScriptError, C as formatTypecheckSummary, F as ROOT_ONLY_KEYS, I as VALID_BACKENDS, O as createStudioBackend, T as loadConfig$1, _ as writeJsonFile, a as formatAnnotations, b as formatMultiProjectResult, c as execute, d as resolveTsconfigDirectories, f as collectPaths, h as findFormatterOptions, i as runTypecheck, j as hashBuffer, l as formatExecuteOutput, m as rojoProjectSchema, n as parseGameOutput, o as formatJobSummary, p as resolveNestedProjects, r as writeGameOutput, s as resolveGitHubActionsOptions, t as formatGameOutputNotice, u as loadCoverageManifest, v as formatAgentMultiProject, w as formatBanner, x as formatResult, z as isValidBackend } from "./game-output-C0KykXIi.mjs";
1
+ import { A as formatBanner, C as writeJsonFile, D as formatResult, E as formatMultiProjectResult, F as createStudioBackend, G as isValidBackend, H as VALID_BACKENDS, K as LuauScriptError, L as createOpenCloudBackend, M as loadConfig$1, V as ROOT_ONLY_KEYS, _ as resolveTsconfigDirectories, a as formatAnnotations, b as rojoProjectSchema, c as visitBlock, d as buildProjectJob, f as execute, g as processProjectResult, h as loadCoverageManifest, i as runTypecheck, j as combineSourceMappers, k as formatTypecheckSummary, m as formatExecuteOutput, n as parseGameOutput, o as formatJobSummary, p as executeBackend, r as writeGameOutput, s as resolveGitHubActionsOptions, t as formatGameOutputNotice, v as collectPaths$1, w as formatAgentMultiProject, x as findFormatterOptions, y as resolveNestedProjects } from "./game-output-CwmtpYhn.mjs";
2
2
  import { createRequire } from "node:module";
3
3
  import { type } from "arktype";
4
4
  import assert from "node:assert";
@@ -11,26 +11,32 @@ import { parseArgs as parseArgs$1 } from "node:util";
11
11
  import { isAgent } from "std-env";
12
12
  import color from "tinyrainbow";
13
13
  import { WebSocketServer } from "ws";
14
+ import { hashBuffer as hashBuffer$1 } from "@isentinel/roblox-runner";
14
15
  import { loadConfig } from "c12";
15
- import * as os from "node:os";
16
- import { Buffer } from "node:buffer";
16
+ import { collectPaths, findInTree } from "@isentinel/rojo-utils";
17
17
  import { getTsconfig } from "get-tsconfig";
18
18
  import { TraceMap, originalPositionFor } from "@jridgewell/trace-mapping";
19
19
  import * as cp from "node:child_process";
20
+ import * as os from "node:os";
20
21
  import { RojoResolver } from "@roblox-ts/rojo-resolver";
22
+ import { Buffer } from "node:buffer";
21
23
  import picomatch from "picomatch";
22
24
  import istanbulCoverage from "istanbul-lib-coverage";
23
25
  import istanbulReport from "istanbul-lib-report";
24
26
  import istanbulReports from "istanbul-reports";
25
27
  //#region package.json
26
- var version = "0.2.0";
28
+ var version = "0.2.2";
27
29
  //#endregion
28
30
  //#region src/backends/auto.ts
29
31
  var StudioWithFallback = class {
30
32
  studio;
33
+ kind = "studio";
31
34
  constructor(studio) {
32
35
  this.studio = studio;
33
36
  }
37
+ async close() {
38
+ await this.studio.close?.();
39
+ }
34
40
  async runTests(options) {
35
41
  try {
36
42
  return await this.studio.runTests(options);
@@ -156,7 +162,7 @@ function evalTable(entries) {
156
162
  }
157
163
  //#endregion
158
164
  //#region src/luau/parse-ast.luau
159
- 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\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";
165
+ 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";
160
166
  //#endregion
161
167
  //#region src/config/luau-config-loader.ts
162
168
  let cachedTemporaryDirectory$1;
@@ -400,24 +406,6 @@ function buildProjectConfigFromLuau(luauConfigPath, directoryPath) {
400
406
  copyLuauOptionalFields(raw, config);
401
407
  return config;
402
408
  }
403
- function matchNodePath(childNode, targetPath, childDataModelPath) {
404
- const nodePath = childNode.$path;
405
- if (typeof nodePath !== "string") return;
406
- const normalizedNodePath = nodePath.replace(/\/$/, "");
407
- if (normalizedNodePath === targetPath) return childDataModelPath;
408
- if (targetPath.startsWith(`${normalizedNodePath}/`)) return `${childDataModelPath}/${targetPath.slice(normalizedNodePath.length + 1)}`;
409
- }
410
- function findInTree(node, targetPath, currentDataModelPath) {
411
- for (const [key, value] of Object.entries(node)) {
412
- if (key.startsWith("$") || typeof value !== "object") continue;
413
- const childNode = value;
414
- const childDataModelPath = currentDataModelPath === "" ? key : `${currentDataModelPath}/${key}`;
415
- const pathMatch = matchNodePath(childNode, targetPath, childDataModelPath);
416
- if (pathMatch !== void 0) return pathMatch;
417
- const found = findInTree(childNode, targetPath, childDataModelPath);
418
- if (found !== void 0) return found;
419
- }
420
- }
421
409
  //#endregion
422
410
  //#region src/config/setup-resolver.ts
423
411
  const PROBE_EXTENSIONS = [
@@ -552,22 +540,39 @@ function findShadowStubs(directory) {
552
540
  * Derives `collectCoverageFrom` glob patterns from project `include` patterns.
553
541
  *
554
542
  * Extracts the static root directory from each include pattern and generates
555
- * coverage globs that match all `.ts` source files within those roots, excluding
556
- * test files. Returns `undefined` when no roots can be extracted (preserving
557
- * default all-files behavior).
543
+ * coverage globs that match source files within those roots, excluding test
544
+ * files. The source extension (`.ts`, `.tsx`, `.luau`, `.lua`) is inferred from
545
+ * each include pattern. Returns `undefined` when no roots can be extracted
546
+ * (preserving default all-files behavior).
558
547
  */
559
548
  function deriveCoverageFromIncludes(projects) {
560
- const roots = /* @__PURE__ */ new Set();
561
- for (const project of projects) for (const pattern of project.include) try {
562
- const { root } = extractStaticRoot(pattern);
563
- roots.add(root);
564
- } catch {}
565
- if (roots.size === 0) return;
549
+ const rootsByExtension = /* @__PURE__ */ new Map();
550
+ for (const project of projects) for (const pattern of project.include) {
551
+ const extension = inferSourceExtension(pattern);
552
+ try {
553
+ const { root } = extractStaticRoot(pattern);
554
+ const roots = rootsByExtension.get(extension) ?? /* @__PURE__ */ new Set();
555
+ roots.add(root);
556
+ rootsByExtension.set(extension, roots);
557
+ } catch {}
558
+ }
559
+ if (rootsByExtension.size === 0) return;
566
560
  const patterns = [];
567
- for (const root of roots) patterns.push(`${root}/**/*.ts`);
568
- patterns.push("!**/*.spec.ts", "!**/*.test.ts");
561
+ for (const [extension, roots] of rootsByExtension) for (const root of roots) patterns.push(`${root}/**/*${extension}`);
562
+ for (const extension of rootsByExtension.keys()) patterns.push(`!**/*.spec${extension}`, `!**/*.test${extension}`);
569
563
  return patterns;
570
564
  }
565
+ /**
566
+ * Infers the source file extension from an include pattern by stripping the
567
+ * `.spec` or `.test` suffix. Throws when the pattern has no recognizable test
568
+ * extension so that misconfigured globs fail loudly.
569
+ */
570
+ function inferSourceExtension(pattern) {
571
+ const match = pattern.match(/\.(?:spec|test)(\.\w+)$/);
572
+ 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).`);
573
+ const [, extension] = match;
574
+ return extension;
575
+ }
571
576
  //#endregion
572
577
  //#region src/coverage/mapper.ts
573
578
  const positionSchema = type({
@@ -1027,276 +1032,6 @@ function mergeFileCoverage(a, b) {
1027
1032
  return merged;
1028
1033
  }
1029
1034
  //#endregion
1030
- //#region src/luau/visitor.ts
1031
- function visitExpression(expression, visitor) {
1032
- if (visitor.visitExpr?.(expression) === false) return;
1033
- const { tag } = expression;
1034
- switch (tag) {
1035
- case "binary":
1036
- visitExprBinary(expression, visitor);
1037
- break;
1038
- case "boolean":
1039
- visitor.visitExprConstantBool?.(expression);
1040
- break;
1041
- case "call":
1042
- visitExprCall(expression, visitor);
1043
- break;
1044
- case "cast":
1045
- visitExprTypeAssertion(expression, visitor);
1046
- break;
1047
- case "conditional":
1048
- visitExprIfElse(expression, visitor);
1049
- break;
1050
- case "function":
1051
- visitExprFunction(expression, visitor);
1052
- break;
1053
- case "global":
1054
- visitor.visitExprGlobal?.(expression);
1055
- break;
1056
- case "group":
1057
- visitExprGroup(expression, visitor);
1058
- break;
1059
- case "index":
1060
- visitExprIndexExpr(expression, visitor);
1061
- break;
1062
- case "indexname":
1063
- visitExprIndexName(expression, visitor);
1064
- break;
1065
- case "instantiate":
1066
- visitExprInstantiate(expression, visitor);
1067
- break;
1068
- case "interpolatedstring":
1069
- visitExprInterpString(expression, visitor);
1070
- break;
1071
- case "local":
1072
- visitor.visitExprLocal?.(expression);
1073
- break;
1074
- case "nil":
1075
- visitor.visitExprConstantNil?.(expression);
1076
- break;
1077
- case "number":
1078
- visitor.visitExprConstantNumber?.(expression);
1079
- break;
1080
- case "string":
1081
- visitor.visitExprConstantString?.(expression);
1082
- break;
1083
- case "table":
1084
- visitExprTable(expression, visitor);
1085
- break;
1086
- case "unary":
1087
- visitExprUnary(expression, visitor);
1088
- break;
1089
- case "vararg":
1090
- visitor.visitExprVarargs?.(expression);
1091
- break;
1092
- default: break;
1093
- }
1094
- visitor.visitExprEnd?.(expression);
1095
- }
1096
- function visitStatement(statement, visitor) {
1097
- const { tag } = statement;
1098
- switch (tag) {
1099
- case "assign":
1100
- visitStatAssign(statement, visitor);
1101
- break;
1102
- case "block":
1103
- visitStatBlock(statement, visitor);
1104
- break;
1105
- case "break":
1106
- visitor.visitStatBreak?.(statement);
1107
- break;
1108
- case "compoundassign":
1109
- visitStatCompoundAssign(statement, visitor);
1110
- break;
1111
- case "conditional":
1112
- visitStatIf(statement, visitor);
1113
- break;
1114
- case "continue":
1115
- visitor.visitStatContinue?.(statement);
1116
- break;
1117
- case "do":
1118
- visitStatDo(statement, visitor);
1119
- break;
1120
- case "expression":
1121
- visitStatExpr(statement, visitor);
1122
- break;
1123
- case "for":
1124
- visitStatFor(statement, visitor);
1125
- break;
1126
- case "forin":
1127
- visitStatForIn(statement, visitor);
1128
- break;
1129
- case "function":
1130
- visitStatFunction(statement, visitor);
1131
- break;
1132
- case "local":
1133
- visitStatLocal(statement, visitor);
1134
- break;
1135
- case "localfunction":
1136
- visitStatLocalFunction(statement, visitor);
1137
- break;
1138
- case "repeat":
1139
- visitStatRepeat(statement, visitor);
1140
- break;
1141
- case "return":
1142
- visitStatReturn(statement, visitor);
1143
- break;
1144
- case "typealias":
1145
- visitor.visitStatTypeAlias?.(statement);
1146
- break;
1147
- case "typefunction":
1148
- visitor.visitStatTypeFunction?.(statement);
1149
- break;
1150
- case "while":
1151
- visitStatWhile(statement, visitor);
1152
- break;
1153
- default: break;
1154
- }
1155
- }
1156
- function visitBlock(block, visitor) {
1157
- visitStatBlock(block, visitor);
1158
- }
1159
- function visitPunctuated(list, visitor, apply) {
1160
- for (const item of list) apply(item.node, visitor);
1161
- }
1162
- function visitStatBlock(block, visitor) {
1163
- if (visitor.visitStatBlock?.(block) === false) return;
1164
- for (const statement of block.statements) visitStatement(statement, visitor);
1165
- visitor.visitStatBlockEnd?.(block);
1166
- }
1167
- function visitStatDo(node, visitor) {
1168
- if (visitor.visitStatDo?.(node) === false) return;
1169
- visitStatBlock(node.body, visitor);
1170
- }
1171
- function visitStatIf(node, visitor) {
1172
- if (visitor.visitStatIf?.(node) === false) return;
1173
- visitExpression(node.condition, visitor);
1174
- visitStatBlock(node.thenblock, visitor);
1175
- for (const elseif of node.elseifs) visitElseIfStat(elseif, visitor);
1176
- if (node.elseblock) visitStatBlock(node.elseblock, visitor);
1177
- }
1178
- function visitElseIfStat(node, visitor) {
1179
- visitExpression(node.condition, visitor);
1180
- visitStatBlock(node.thenblock, visitor);
1181
- }
1182
- function visitStatWhile(node, visitor) {
1183
- if (visitor.visitStatWhile?.(node) === false) return;
1184
- visitExpression(node.condition, visitor);
1185
- visitStatBlock(node.body, visitor);
1186
- }
1187
- function visitStatRepeat(node, visitor) {
1188
- if (visitor.visitStatRepeat?.(node) === false) return;
1189
- visitStatBlock(node.body, visitor);
1190
- visitExpression(node.condition, visitor);
1191
- }
1192
- function visitStatReturn(node, visitor) {
1193
- if (visitor.visitStatReturn?.(node) === false) return;
1194
- visitPunctuated(node.expressions, visitor, visitExpression);
1195
- }
1196
- function visitStatLocal(node, visitor) {
1197
- if (visitor.visitStatLocal?.(node) === false) return;
1198
- visitPunctuated(node.values, visitor, visitExpression);
1199
- }
1200
- function visitStatFor(node, visitor) {
1201
- if (visitor.visitStatFor?.(node) === false) return;
1202
- visitExpression(node.from, visitor);
1203
- visitExpression(node.to, visitor);
1204
- if (node.step) visitExpression(node.step, visitor);
1205
- visitStatBlock(node.body, visitor);
1206
- }
1207
- function visitStatForIn(node, visitor) {
1208
- if (visitor.visitStatForIn?.(node) === false) return;
1209
- visitPunctuated(node.values, visitor, visitExpression);
1210
- visitStatBlock(node.body, visitor);
1211
- }
1212
- function visitStatAssign(node, visitor) {
1213
- if (visitor.visitStatAssign?.(node) === false) return;
1214
- visitPunctuated(node.variables, visitor, visitExpression);
1215
- visitPunctuated(node.values, visitor, visitExpression);
1216
- }
1217
- function visitStatCompoundAssign(node, visitor) {
1218
- if (visitor.visitStatCompoundAssign?.(node) === false) return;
1219
- visitExpression(node.variable, visitor);
1220
- visitExpression(node.value, visitor);
1221
- }
1222
- function visitStatExpr(node, visitor) {
1223
- if (visitor.visitStatExpr?.(node) === false) return;
1224
- visitExpression(node.expression, visitor);
1225
- }
1226
- function visitStatFunction(node, visitor) {
1227
- if (visitor.visitStatFunction?.(node) === false) return;
1228
- visitExpression(node.name, visitor);
1229
- visitExprFunction(node.func, visitor);
1230
- }
1231
- function visitStatLocalFunction(node, visitor) {
1232
- if (visitor.visitStatLocalFunction?.(node) === false) return;
1233
- visitExprFunction(node.func, visitor);
1234
- }
1235
- function visitExprFunction(node, visitor) {
1236
- if (visitor.visitExprFunction?.(node) === false) return;
1237
- visitStatBlock(node.body, visitor);
1238
- visitor.visitExprFunctionEnd?.(node);
1239
- }
1240
- function visitExprCall(node, visitor) {
1241
- if (visitor.visitExprCall?.(node) === false) return;
1242
- visitExpression(node.func, visitor);
1243
- visitPunctuated(node.arguments, visitor, visitExpression);
1244
- }
1245
- function visitExprUnary(node, visitor) {
1246
- if (visitor.visitExprUnary?.(node) === false) return;
1247
- visitExpression(node.operand, visitor);
1248
- }
1249
- function visitExprBinary(node, visitor) {
1250
- if (visitor.visitExprBinary?.(node) === false) return;
1251
- visitExpression(node.lhsoperand, visitor);
1252
- visitExpression(node.rhsoperand, visitor);
1253
- }
1254
- function visitExprTable(node, visitor) {
1255
- if (visitor.visitExprTable?.(node) === false) return;
1256
- for (const item of node.entries) visitTableExprItem(item, visitor);
1257
- }
1258
- function visitTableExprItem(node, visitor) {
1259
- if (visitor.visitTableExprItem?.(node) === false) return;
1260
- visitExpression(node.value, visitor);
1261
- if (node.kind === "general") visitExpression(node.key, visitor);
1262
- }
1263
- function visitExprIndexName(node, visitor) {
1264
- if (visitor.visitExprIndexName?.(node) === false) return;
1265
- visitExpression(node.expression, visitor);
1266
- }
1267
- function visitExprIndexExpr(node, visitor) {
1268
- if (visitor.visitExprIndexExpr?.(node) === false) return;
1269
- visitExpression(node.expression, visitor);
1270
- visitExpression(node.index, visitor);
1271
- }
1272
- function visitExprGroup(node, visitor) {
1273
- if (visitor.visitExprGroup?.(node) === false) return;
1274
- visitExpression(node.expression, visitor);
1275
- }
1276
- function visitExprInterpString(node, visitor) {
1277
- if (visitor.visitExprInterpString?.(node) === false) return;
1278
- for (const expr of node.expressions) visitExpression(expr, visitor);
1279
- }
1280
- function visitExprTypeAssertion(node, visitor) {
1281
- if (visitor.visitExprTypeAssertion?.(node) === false) return;
1282
- visitExpression(node.operand, visitor);
1283
- }
1284
- function visitExprIfElse(node, visitor) {
1285
- if (visitor.visitExprIfElse?.(node) === false) return;
1286
- visitExpression(node.condition, visitor);
1287
- visitExpression(node.thenexpr, visitor);
1288
- for (const elseif of node.elseifs) visitElseIfExpr(elseif, visitor);
1289
- visitExpression(node.elseexpr, visitor);
1290
- }
1291
- function visitElseIfExpr(node, visitor) {
1292
- visitExpression(node.condition, visitor);
1293
- visitExpression(node.thenexpr, visitor);
1294
- }
1295
- function visitExprInstantiate(node, visitor) {
1296
- if (visitor.visitExprInstantiate?.(node) === false) return;
1297
- visitExpression(node.expr, visitor);
1298
- }
1299
- //#endregion
1300
1035
  //#region src/coverage/coverage-collector.ts
1301
1036
  const INSTRUMENTABLE_STATEMENT_TAGS = new Set([
1302
1037
  "assign",
@@ -1350,36 +1085,36 @@ function collectCoverage(root) {
1350
1085
  branch.arms.push({
1351
1086
  bodyFirstColumn: 0,
1352
1087
  bodyFirstLine: 0,
1353
- location: { ...node.thenexpr.location }
1088
+ location: { ...node.thenExpr.location }
1354
1089
  });
1355
1090
  exprIfProbes.push({
1356
1091
  armIndex,
1357
1092
  branchIndex,
1358
- exprLocation: { ...node.thenexpr.location }
1093
+ exprLocation: { ...node.thenExpr.location }
1359
1094
  });
1360
1095
  armIndex++;
1361
1096
  for (const elseif of node.elseifs) {
1362
1097
  branch.arms.push({
1363
1098
  bodyFirstColumn: 0,
1364
1099
  bodyFirstLine: 0,
1365
- location: { ...elseif.thenexpr.location }
1100
+ location: { ...elseif.thenExpr.location }
1366
1101
  });
1367
1102
  exprIfProbes.push({
1368
1103
  armIndex,
1369
1104
  branchIndex,
1370
- exprLocation: { ...elseif.thenexpr.location }
1105
+ exprLocation: { ...elseif.thenExpr.location }
1371
1106
  });
1372
1107
  armIndex++;
1373
1108
  }
1374
1109
  branch.arms.push({
1375
1110
  bodyFirstColumn: 0,
1376
1111
  bodyFirstLine: 0,
1377
- location: { ...node.elseexpr.location }
1112
+ location: { ...node.elseExpr.location }
1378
1113
  });
1379
1114
  exprIfProbes.push({
1380
1115
  armIndex,
1381
1116
  branchIndex,
1382
- exprLocation: { ...node.elseexpr.location }
1117
+ exprLocation: { ...node.elseExpr.location }
1383
1118
  });
1384
1119
  branches.push(branch);
1385
1120
  branchIndex++;
@@ -1415,21 +1150,21 @@ function collectCoverage(root) {
1415
1150
  branchType: "if",
1416
1151
  index: branchIndex
1417
1152
  };
1418
- const thenFirst = getBodyFirstStatement(node.thenblock);
1153
+ const thenFirst = getBodyFirstStatement(node.thenBlock);
1419
1154
  branch.arms.push({
1420
1155
  bodyFirstColumn: thenFirst.column,
1421
1156
  bodyFirstLine: thenFirst.line,
1422
- location: { ...node.thenblock.location }
1157
+ location: { ...node.thenBlock.location }
1423
1158
  });
1424
1159
  for (const elseif of node.elseifs) {
1425
- const elseifFirst = getBodyFirstStatement(elseif.thenblock);
1160
+ const elseifFirst = getBodyFirstStatement(elseif.thenBlock);
1426
1161
  branch.arms.push({
1427
1162
  bodyFirstColumn: elseifFirst.column,
1428
1163
  bodyFirstLine: elseifFirst.line,
1429
- location: { ...elseif.thenblock.location }
1164
+ location: { ...elseif.thenBlock.location }
1430
1165
  });
1431
1166
  }
1432
- const elseBlock = node.elseblock;
1167
+ const { elseBlock } = node;
1433
1168
  const hasExplicitElse = elseBlock !== void 0 && elseBlock.statements.length > 0;
1434
1169
  if (hasExplicitElse) {
1435
1170
  const elseFirst = getBodyFirstStatement(elseBlock);
@@ -1676,15 +1411,21 @@ function instrumentRoot(options) {
1676
1411
  const astOutputDirectory = astOutputDirectoryOption ?? path$1.join(lazyTemporaryDirectory, "asts");
1677
1412
  if (parseScript === void 0) fs$1.writeFileSync(scriptPath, parse_ast_default);
1678
1413
  fs$1.mkdirSync(astOutputDirectory, { recursive: true });
1414
+ const luteArgs = [
1415
+ "run",
1416
+ scriptPath,
1417
+ "--",
1418
+ path$1.resolve(luauRoot),
1419
+ astOutputDirectory
1420
+ ];
1421
+ if (skipFiles !== void 0 && skipFiles.size > 0) {
1422
+ const skipListPath = toPosix(path$1.join(astOutputDirectory, "skip-list.json"));
1423
+ fs$1.writeFileSync(skipListPath, JSON.stringify([...skipFiles]));
1424
+ luteArgs.push(skipListPath);
1425
+ }
1679
1426
  let fileListJson;
1680
1427
  try {
1681
- fileListJson = cp.execFileSync("lute", [
1682
- "run",
1683
- scriptPath,
1684
- "--",
1685
- path$1.resolve(luauRoot),
1686
- astOutputDirectory
1687
- ], {
1428
+ fileListJson = cp.execFileSync("lute", luteArgs, {
1688
1429
  encoding: "utf-8",
1689
1430
  maxBuffer: 1024 * 1024
1690
1431
  });
@@ -1732,7 +1473,7 @@ function instrumentRoot(options) {
1732
1473
  functionCount: collectorResult.functions.length,
1733
1474
  instrumentedLuauPath,
1734
1475
  originalLuauPath,
1735
- sourceHash: hashBuffer(sourceBuffer),
1476
+ sourceHash: hashBuffer$1(sourceBuffer),
1736
1477
  sourceMapPath,
1737
1478
  statementCount: collectorResult.statements.length
1738
1479
  };
@@ -1841,7 +1582,7 @@ const previousManifestSchema = type({
1841
1582
  }).as();
1842
1583
  function collectLuauRootsFromRojo(project, config) {
1843
1584
  const paths = [];
1844
- collectPaths(project.tree, paths);
1585
+ collectPaths$1(project.tree, paths);
1845
1586
  const ignorePatterns = config.coveragePathIgnorePatterns;
1846
1587
  const isIgnored = picomatch(ignorePatterns, { contains: true });
1847
1588
  return paths.filter((directoryPath) => {
@@ -1851,6 +1592,16 @@ function collectLuauRootsFromRojo(project, config) {
1851
1592
  return containsLuauFiles(directoryPath);
1852
1593
  });
1853
1594
  }
1595
+ /**
1596
+ * Fast directory walk to discover instrumentable .luau/.lua files.
1597
+ * Must match parse-ast.luau's discoverFiles logic (same skip rules).
1598
+ */
1599
+ function discoverInstrumentableFiles(luauRoot) {
1600
+ const posixRoot = luauRoot.replaceAll("\\", "/");
1601
+ const results = [];
1602
+ walkLuauDirectory(posixRoot, posixRoot, isInstrumentableFile, results);
1603
+ return new Set(results);
1604
+ }
1854
1605
  function prepareCoverage(config, beforeBuild) {
1855
1606
  const rojoProjectPath = findRojoProject(config);
1856
1607
  const luauRoots = resolveLuauRootsWithRojo(config, rojoProjectPath);
@@ -1924,26 +1675,30 @@ function resolveLuauRootsWithRojo(config, rojoProjectPath) {
1924
1675
  if (outDirectory !== void 0) return [outDirectory];
1925
1676
  throw new Error("Could not determine luauRoots. Set luauRoots in config or ensure tsconfig has outDir.");
1926
1677
  }
1927
- function validateRelativeRoots(luauRoots) {
1928
- 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.");
1929
- }
1930
- function computeSkipFiles(luauRoot, previousManifest) {
1931
- const skipFiles = /* @__PURE__ */ new Set();
1932
- const posixRoot = luauRoot.replaceAll("\\", "/");
1933
- for (const [fileKey, record] of Object.entries(previousManifest.files)) {
1934
- if (!fileKey.startsWith(`${posixRoot}/`)) continue;
1935
- const relativePath = fileKey.slice(posixRoot.length + 1);
1936
- const sourcePath = path$1.resolve(record.originalLuauPath);
1937
- if (!fs$1.existsSync(sourcePath)) continue;
1938
- if (hashBuffer(fs$1.readFileSync(sourcePath)) === record.sourceHash) skipFiles.add(relativePath);
1678
+ /**
1679
+ * Shared directory walker. Skips node_modules, .jest-roblox-coverage, and
1680
+ * dot-prefixed directories — matching parse-ast.luau:113-147.
1681
+ * `predicate` receives the entry name and returns true to collect the file.
1682
+ */
1683
+ function walkLuauDirectory(directory, relativeTo, predicate, results) {
1684
+ const entries = fs$1.readdirSync(directory, { withFileTypes: true });
1685
+ for (const entry of entries) {
1686
+ const fullPath = path$1.join(directory, entry.name).replaceAll("\\", "/");
1687
+ if (entry.isDirectory()) {
1688
+ if (entry.name === "node_modules" || entry.name === COVERAGE_DIR) continue;
1689
+ if (entry.name.startsWith(".")) continue;
1690
+ walkLuauDirectory(fullPath, relativeTo, predicate, results);
1691
+ } else if (predicate(entry.name)) {
1692
+ const relative = fullPath.slice(relativeTo.length + 1);
1693
+ results.push(relative);
1694
+ }
1939
1695
  }
1940
- return skipFiles;
1941
1696
  }
1942
- function countPreviousFilesForRoot(luauRoot, previousManifest) {
1943
- const posixRoot = luauRoot.replaceAll("\\", "/");
1944
- let count = 0;
1945
- for (const fileKey of Object.keys(previousManifest.files)) if (fileKey.startsWith(`${posixRoot}/`)) count++;
1946
- return count;
1697
+ function isInstrumentableFile(name) {
1698
+ return (name.endsWith(".luau") || name.endsWith(".lua")) && !isNonInstrumentedFile(name);
1699
+ }
1700
+ function validateRelativeRoots(luauRoots) {
1701
+ 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.");
1947
1702
  }
1948
1703
  function carryForwardRecords(luauRoot, previousManifest, allFiles, skipFiles) {
1949
1704
  const posixRoot = luauRoot.replaceAll("\\", "/");
@@ -1953,18 +1708,7 @@ function carryForwardRecords(luauRoot, previousManifest, allFiles, skipFiles) {
1953
1708
  }
1954
1709
  }
1955
1710
  function discoverNonInstrumentedFiles(directory, relativeTo, results) {
1956
- const entries = fs$1.readdirSync(directory, { withFileTypes: true });
1957
- for (const entry of entries) {
1958
- const fullPath = path$1.join(directory, entry.name).replaceAll("\\", "/");
1959
- if (entry.isDirectory()) {
1960
- if (entry.name === "node_modules" || entry.name === COVERAGE_DIR) continue;
1961
- if (entry.name.startsWith(".")) continue;
1962
- discoverNonInstrumentedFiles(fullPath, relativeTo, results);
1963
- } else if (isNonInstrumentedFile(entry.name)) {
1964
- const relative = fullPath.slice(relativeTo.length + 1);
1965
- results.push(relative);
1966
- }
1967
- }
1711
+ walkLuauDirectory(directory, relativeTo, isNonInstrumentedFile, results);
1968
1712
  }
1969
1713
  function pruneStaleNonInstrumented(posixRoot, previousNonInstrumented, currentFiles) {
1970
1714
  if (previousNonInstrumented === void 0) return false;
@@ -1988,7 +1732,7 @@ function syncNonInstrumentedFiles(luauRoot, shadowDirectory, previousNonInstrume
1988
1732
  for (const relativePath of discovered) {
1989
1733
  const sourcePath = `${posixRoot}/${relativePath}`;
1990
1734
  const shadowPath = `${shadowDirectory}/${relativePath}`;
1991
- const currentHash = hashBuffer(fs$1.readFileSync(path$1.resolve(sourcePath)));
1735
+ const currentHash = hashBuffer$1(fs$1.readFileSync(path$1.resolve(sourcePath)));
1992
1736
  const previousRecord = previousNonInstrumented?.[sourcePath];
1993
1737
  if (previousRecord?.sourceHash === currentHash) {
1994
1738
  files[sourcePath] = previousRecord;
@@ -2010,6 +1754,59 @@ function syncNonInstrumentedFiles(luauRoot, shadowDirectory, previousNonInstrume
2010
1754
  files
2011
1755
  };
2012
1756
  }
1757
+ function computeSkipFiles(luauRoot, previousManifest) {
1758
+ const skipFiles = /* @__PURE__ */ new Set();
1759
+ const posixRoot = luauRoot.replaceAll("\\", "/");
1760
+ for (const [fileKey, record] of Object.entries(previousManifest.files)) {
1761
+ if (!fileKey.startsWith(`${posixRoot}/`)) continue;
1762
+ const relativePath = fileKey.slice(posixRoot.length + 1);
1763
+ const sourcePath = path$1.resolve(record.originalLuauPath);
1764
+ if (!fs$1.existsSync(sourcePath)) continue;
1765
+ if (hashBuffer$1(fs$1.readFileSync(sourcePath)) === record.sourceHash) skipFiles.add(relativePath);
1766
+ }
1767
+ return skipFiles;
1768
+ }
1769
+ function countPreviousFilesForRoot(luauRoot, previousManifest) {
1770
+ const posixRoot = luauRoot.replaceAll("\\", "/");
1771
+ let count = 0;
1772
+ for (const fileKey of Object.keys(previousManifest.files)) if (fileKey.startsWith(`${posixRoot}/`)) count++;
1773
+ return count;
1774
+ }
1775
+ /**
1776
+ * Check if all files in this root are unchanged (full cache hit).
1777
+ *
1778
+ * `changed` means previous files were deleted or modified — it does NOT cover
1779
+ * new files appearing on disk. When `allCached` is false but `changed` is also
1780
+ * false, new files exist and the caller detects them when `instrumentRoot`
1781
+ * returns non-empty results.
1782
+ */
1783
+ function computeIncrementalState(luauRoot, previousManifest) {
1784
+ const skipFiles = computeSkipFiles(luauRoot, previousManifest);
1785
+ const previousCount = countPreviousFilesForRoot(luauRoot, previousManifest);
1786
+ const changed = skipFiles.size !== previousCount;
1787
+ if (changed) return {
1788
+ allCached: false,
1789
+ changed,
1790
+ skipFiles
1791
+ };
1792
+ return {
1793
+ allCached: discoverInstrumentableFiles(luauRoot).size === previousCount,
1794
+ changed,
1795
+ skipFiles
1796
+ };
1797
+ }
1798
+ function buildFullCacheResult(options) {
1799
+ const { luauRoot, previousManifest, rootEntry, shadowDirectory, skipFiles } = options;
1800
+ const allFiles = {};
1801
+ carryForwardRecords(luauRoot, previousManifest, allFiles, skipFiles);
1802
+ const syncResult = syncNonInstrumentedFiles(luauRoot, shadowDirectory, previousManifest.nonInstrumentedFiles);
1803
+ return {
1804
+ changed: syncResult.changed,
1805
+ files: allFiles,
1806
+ nonInstrumentedFiles: syncResult.files,
1807
+ rootEntry
1808
+ };
1809
+ }
2013
1810
  function instrumentRootWithCache(luauRoot, useIncremental, previousManifest) {
2014
1811
  const shadowDirectory = path$1.join(COVERAGE_DIR, luauRoot).replaceAll("\\", "/");
2015
1812
  let changed = false;
@@ -2017,11 +1814,23 @@ function instrumentRootWithCache(luauRoot, useIncremental, previousManifest) {
2017
1814
  fs$1.mkdirSync(shadowDirectory, { recursive: true });
2018
1815
  fs$1.cpSync(luauRoot, shadowDirectory, { recursive: true });
2019
1816
  }
1817
+ const rootEntry = {
1818
+ luauRoot,
1819
+ relocatedShadowDirectory: path$1.relative(COVERAGE_DIR, shadowDirectory).replaceAll("\\", "/"),
1820
+ shadowDir: shadowDirectory
1821
+ };
2020
1822
  let skipFiles;
2021
1823
  if (useIncremental && previousManifest !== void 0) {
2022
- skipFiles = computeSkipFiles(luauRoot, previousManifest);
2023
- const previousCount = countPreviousFilesForRoot(luauRoot, previousManifest);
2024
- if (skipFiles.size !== previousCount) changed = true;
1824
+ const { allCached, changed: hasChanges, skipFiles: computed } = computeIncrementalState(luauRoot, previousManifest);
1825
+ skipFiles = computed;
1826
+ changed = hasChanges;
1827
+ if (allCached) return buildFullCacheResult({
1828
+ luauRoot,
1829
+ previousManifest,
1830
+ rootEntry,
1831
+ shadowDirectory,
1832
+ skipFiles
1833
+ });
2025
1834
  }
2026
1835
  const files = instrumentRoot({
2027
1836
  luauRoot,
@@ -2033,16 +1842,11 @@ function instrumentRootWithCache(luauRoot, useIncremental, previousManifest) {
2033
1842
  if (useIncremental && previousManifest !== void 0 && skipFiles !== void 0) carryForwardRecords(luauRoot, previousManifest, allFiles, skipFiles);
2034
1843
  const syncResult = syncNonInstrumentedFiles(luauRoot, shadowDirectory, previousManifest?.nonInstrumentedFiles);
2035
1844
  if (syncResult.changed) changed = true;
2036
- const relocatedShadowDirectory = path$1.relative(COVERAGE_DIR, shadowDirectory).replaceAll("\\", "/");
2037
1845
  return {
2038
1846
  changed,
2039
1847
  files: allFiles,
2040
1848
  nonInstrumentedFiles: syncResult.files,
2041
- rootEntry: {
2042
- luauRoot,
2043
- relocatedShadowDirectory,
2044
- shadowDir: shadowDirectory
2045
- }
1849
+ rootEntry
2046
1850
  };
2047
1851
  }
2048
1852
  function writeManifest(options) {
@@ -2312,6 +2116,8 @@ Options:
2312
2116
  --formatters <name...> Output formatters (default, agent, json, github-actions)
2313
2117
  --no-cache Force re-upload place file (skip cache)
2314
2118
  --pollInterval <ms> Open Cloud poll interval in ms (default: 500)
2119
+ --parallel [n] Open-Cloud-only: number of concurrent sessions
2120
+ (or "auto" = min(jobs, 3); default: 1 session)
2315
2121
  --project <name...> Filter which named projects to run
2316
2122
  --setupFiles <path...> Setup scripts (package specifiers or relative paths)
2317
2123
  --setupFilesAfterEnv <path...> Post-env setup scripts (package specifiers or relative paths)
@@ -2338,7 +2144,7 @@ Examples:
2338
2144
  function parseArgs(args) {
2339
2145
  const { positionals, values } = parseArgs$1({
2340
2146
  allowPositionals: true,
2341
- args,
2147
+ args: normalizeParallelFlag(args),
2342
2148
  options: {
2343
2149
  "backend": { type: "string" },
2344
2150
  "cache": { type: "boolean" },
@@ -2367,6 +2173,7 @@ function parseArgs(args) {
2367
2173
  "no-color": { type: "boolean" },
2368
2174
  "no-show-luau": { type: "boolean" },
2369
2175
  "outputFile": { type: "string" },
2176
+ "parallel": { type: "string" },
2370
2177
  "passWithNoTests": { type: "boolean" },
2371
2178
  "pollInterval": { type: "string" },
2372
2179
  "port": { type: "string" },
@@ -2424,6 +2231,7 @@ function parseArgs(args) {
2424
2231
  gameOutput: values.gameOutput,
2425
2232
  help: values.help,
2426
2233
  outputFile: values.outputFile,
2234
+ parallel: parseParallelValue(values.parallel),
2427
2235
  passWithNoTests: values.passWithNoTests,
2428
2236
  pollInterval,
2429
2237
  port,
@@ -2466,11 +2274,8 @@ function mergeProjectResults(results) {
2466
2274
  let startTime = Number.POSITIVE_INFINITY;
2467
2275
  let success = true;
2468
2276
  const testResults = [];
2469
- let executionMs = 0;
2470
2277
  let testsMs = 0;
2471
- let totalMs = 0;
2472
- let uploadMs = 0;
2473
- let coverageMs = 0;
2278
+ let setupMs = 0;
2474
2279
  let mergedCoverage;
2475
2280
  for (const result of results) {
2476
2281
  numberFailedTests += result.result.numFailedTests;
@@ -2481,13 +2286,14 @@ function mergeProjectResults(results) {
2481
2286
  startTime = Math.min(startTime, result.result.startTime);
2482
2287
  success &&= result.result.success;
2483
2288
  testResults.push(...result.result.testResults);
2484
- executionMs += result.timing.executionMs;
2485
2289
  testsMs += result.timing.testsMs;
2486
- totalMs += result.timing.totalMs;
2487
- uploadMs += result.timing.uploadMs ?? 0;
2488
- coverageMs += result.timing.coverageMs ?? 0;
2290
+ setupMs += result.timing.setupMs ?? 0;
2489
2291
  if (result.coverageData !== void 0) mergedCoverage = mergeRawCoverage(mergedCoverage, result.coverageData);
2490
2292
  }
2293
+ const [sharedTiming] = results;
2294
+ const mergedStartTime = Math.min(...results.map((result) => result.timing.startTime));
2295
+ const totalMs = Math.max(...results.map((result) => result.timing.totalMs));
2296
+ const mergedSourceMapper = combineSourceMappers(results.flatMap((result) => result.sourceMapper !== void 0 ? [result.sourceMapper] : []));
2491
2297
  return {
2492
2298
  coverageData: mergedCoverage,
2493
2299
  exitCode: success ? 0 : 1,
@@ -2502,14 +2308,16 @@ function mergeProjectResults(results) {
2502
2308
  success,
2503
2309
  testResults
2504
2310
  },
2311
+ sourceMapper: mergedSourceMapper,
2505
2312
  timing: {
2506
- coverageMs: coverageMs > 0 ? coverageMs : void 0,
2507
- executionMs,
2508
- startTime: Math.min(...results.map((result) => result.timing.startTime)),
2313
+ coverageMs: sharedTiming.timing.coverageMs,
2314
+ executionMs: sharedTiming.timing.executionMs,
2315
+ setupMs: setupMs > 0 ? setupMs : void 0,
2316
+ startTime: mergedStartTime,
2509
2317
  testsMs,
2510
2318
  totalMs,
2511
- uploadCached: results.every((result) => result.timing.uploadCached === true),
2512
- uploadMs
2319
+ uploadCached: sharedTiming.timing.uploadCached,
2320
+ uploadMs: sharedTiming.timing.uploadMs
2513
2321
  }
2514
2322
  };
2515
2323
  }
@@ -2525,6 +2333,36 @@ async function main() {
2525
2333
  const exitCode = await run(process.argv.slice(2));
2526
2334
  process.exit(exitCode);
2527
2335
  }
2336
+ /**
2337
+ * `--parallel` with no value means `"auto"`. Node's `parseArgs` can't express
2338
+ * optional values, so rewrite bare `--parallel` (at the end of argv, or
2339
+ * followed by another `--flag`, or followed by a non-numeric, non-"auto" token)
2340
+ * into `--parallel auto` before handing it off.
2341
+ */
2342
+ const PARALLEL_FLAG = "--parallel";
2343
+ function normalizeParallelFlag(args) {
2344
+ const out = [];
2345
+ for (let index = 0; index < args.length; index++) {
2346
+ const current = args[index];
2347
+ if (current !== PARALLEL_FLAG) {
2348
+ out.push(current);
2349
+ continue;
2350
+ }
2351
+ const next = args[index + 1];
2352
+ if (next !== void 0 && !next.startsWith("-") && (next === "auto" || /^-?\d+$/.test(next))) {
2353
+ out.push(PARALLEL_FLAG, next);
2354
+ index += 1;
2355
+ } else out.push(PARALLEL_FLAG, "auto");
2356
+ }
2357
+ return out;
2358
+ }
2359
+ function parseParallelValue(raw) {
2360
+ if (raw === void 0) return;
2361
+ if (raw === "auto") return "auto";
2362
+ const parsed = Number.parseInt(raw, 10);
2363
+ if (Number.isNaN(parsed) || parsed < 1) throw new Error(`Invalid --parallel value "${raw}". Must be a positive integer or "auto".`);
2364
+ return parsed;
2365
+ }
2528
2366
  function formatGameOutputLines(raw) {
2529
2367
  if (raw === void 0) return;
2530
2368
  const entries = parseGameOutput(raw);
@@ -2563,6 +2401,21 @@ function writeGameOutputIfConfigured(config, gameOutput, options) {
2563
2401
  if (notice) console.error(notice);
2564
2402
  }
2565
2403
  }
2404
+ /**
2405
+ * Multi-project variant: `--gameOutput` used to silently drop when a config
2406
+ * declared `projects`, because the output path here never called
2407
+ * `writeGameOutputIfConfigured`. Aggregate every project's parsed entries into
2408
+ * one file so the contract matches the single-project path.
2409
+ */
2410
+ function writeAggregatedGameOutput(config, projectResults, options) {
2411
+ if (config.gameOutput === void 0) return;
2412
+ const entries = projectResults.flatMap((project) => parseGameOutput(project.result.gameOutput));
2413
+ writeGameOutput(config.gameOutput, entries);
2414
+ if (!config.silent && options.hintsShown !== true) {
2415
+ const notice = formatGameOutputNotice(config.gameOutput, entries.length);
2416
+ if (notice) console.error(notice);
2417
+ }
2418
+ }
2566
2419
  function printFinalStatus(passed) {
2567
2420
  const badge = passed ? color.bgGreen(color.black(color.bold(" PASS "))) : color.bgRed(color.white(color.bold(" FAIL ")));
2568
2421
  process.stdout.write(`${badge}\n`);
@@ -2746,6 +2599,7 @@ async function outputMultiProjectResults(config, projectResults, typecheckResult
2746
2599
  }
2747
2600
  const coveragePassed = processCoverage(config, merged.coverageData);
2748
2601
  if (config.outputFile !== void 0) await writeJsonFile(mergedResult, config.outputFile);
2602
+ writeAggregatedGameOutput(config, projectResults, { hintsShown: !mergedResult.success });
2749
2603
  runGitHubActionsFormatter(config, mergedResult, merged.sourceMapper);
2750
2604
  const passed = mergedResult.success && coveragePassed;
2751
2605
  if (!config.silent && config.collectCoverage) printFinalStatus(passed);
@@ -2814,6 +2668,15 @@ function applySetupResolver(config, resolve) {
2814
2668
  if (config.setupFiles !== void 0) config.setupFiles = config.setupFiles.map(resolve);
2815
2669
  if (config.setupFilesAfterEnv !== void 0) config.setupFilesAfterEnv = config.setupFilesAfterEnv.map(resolve);
2816
2670
  }
2671
+ /**
2672
+ * Drop `parallel` on any non-open-cloud backend. Studio has no concept of
2673
+ * multi-session, so passing `parallel` there is a silent noop — this lets
2674
+ * users keep `parallel: 3` in `jest.config.ts` and still drop to
2675
+ * `--backend studio` for debugging without editing config.
2676
+ */
2677
+ function effectiveParallelForBackend(parallel, backend) {
2678
+ return backend.kind === "open-cloud" ? parallel : void 0;
2679
+ }
2817
2680
  async function runMultiProject(cli, rootConfig, projectEntries) {
2818
2681
  const allProjects = await resolveAllProjects(projectEntries, rootConfig, loadRojoTree(rootConfig), rootConfig.rootDir);
2819
2682
  const rojoConfigPath = path$1.resolve(rootConfig.rootDir, rootConfig.rojoProject ?? DEFAULT_ROJO_PROJECT);
@@ -2827,7 +2690,8 @@ async function runMultiProject(cli, rootConfig, projectEntries) {
2827
2690
  if (!rootConfig.collectCoverage) buildWithRojo(path$1.resolve(rootConfig.rootDir, rootConfig.rojoProject ?? DEFAULT_ROJO_PROJECT), path$1.resolve(rootConfig.rootDir, rootConfig.placeFile));
2828
2691
  const { effectiveConfig, preCoverageMs } = prepareMultiProjectCoverage(rootConfig, projects);
2829
2692
  const backend = await resolveBackend(effectiveConfig);
2830
- const projectResults = [];
2693
+ const parallel = effectiveParallelForBackend(effectiveConfig.parallel, backend);
2694
+ const pendingJobs = [];
2831
2695
  const allTypeTestFiles = [];
2832
2696
  for (const project of projects) {
2833
2697
  const discoveryConfig = {
@@ -2842,19 +2706,46 @@ async function runMultiProject(cli, rootConfig, projectEntries) {
2842
2706
  testMatch: project.testMatch
2843
2707
  };
2844
2708
  allTypeTestFiles.push(...typeTestFiles);
2845
- if (runtimeFiles.length === 0 && typeTestFiles.length === 0) continue;
2846
- if (runtimeFiles.length > 0) {
2847
- const result = await execute({
2848
- backend,
2849
- config: projConfig,
2709
+ if (runtimeFiles.length === 0) continue;
2710
+ pendingJobs.push({
2711
+ config: projConfig,
2712
+ displayColor: project.displayColor,
2713
+ displayName: project.displayName,
2714
+ runtimeFiles
2715
+ });
2716
+ }
2717
+ const jobs = pendingJobs.map((pending) => {
2718
+ return buildProjectJob({
2719
+ config: pending.config,
2720
+ displayColor: pending.displayColor,
2721
+ displayName: pending.displayName,
2722
+ testFiles: pending.runtimeFiles
2723
+ });
2724
+ });
2725
+ const projectResults = [];
2726
+ if (jobs.length > 0) {
2727
+ const startTime = Date.now();
2728
+ let backendResult;
2729
+ try {
2730
+ backendResult = await executeBackend(backend, jobs, parallel);
2731
+ } finally {
2732
+ await backend.close?.();
2733
+ }
2734
+ const sharedTiming = backendResult.timing;
2735
+ for (const [index, entry] of backendResult.results.entries()) {
2736
+ const pending = pendingJobs[index];
2737
+ const jobConfig = jobs[index].config;
2738
+ const executeResult = processProjectResult(entry, {
2739
+ backendTiming: sharedTiming,
2740
+ config: jobConfig,
2850
2741
  deferFormatting: true,
2851
- testFiles: runtimeFiles,
2742
+ startTime,
2852
2743
  version: VERSION
2853
2744
  });
2854
2745
  projectResults.push({
2855
- displayColor: project.displayColor,
2856
- displayName: project.displayName,
2857
- result
2746
+ displayColor: pending.displayColor,
2747
+ displayName: pending.displayName,
2748
+ result: executeResult
2858
2749
  });
2859
2750
  }
2860
2751
  }
@@ -2877,13 +2768,18 @@ async function runMultiProject(cli, rootConfig, projectEntries) {
2877
2768
  }
2878
2769
  async function executeRuntimeTests(config, testFiles, totalFiles) {
2879
2770
  if (!config.silent && !usesAgentFormatter(config) && !hasFormatter(config, "json") && testFiles.length !== totalFiles) process.stderr.write(`Running ${String(testFiles.length)} of ${String(totalFiles)} test files\n`);
2880
- return execute({
2881
- backend: await resolveBackend(config),
2882
- config,
2883
- deferFormatting: true,
2884
- testFiles,
2885
- version: VERSION
2886
- });
2771
+ const backend = await resolveBackend(config);
2772
+ try {
2773
+ return await execute({
2774
+ backend,
2775
+ config,
2776
+ deferFormatting: true,
2777
+ testFiles,
2778
+ version: VERSION
2779
+ });
2780
+ } finally {
2781
+ await backend.close?.();
2782
+ }
2887
2783
  }
2888
2784
  function resolveSetupFilePaths(config) {
2889
2785
  if (config.setupFiles === void 0 && config.setupFilesAfterEnv === void 0) return;
@@ -2893,9 +2789,9 @@ function resolveSetupFilePaths(config) {
2893
2789
  rojoConfigPath
2894
2790
  }));
2895
2791
  }
2896
- async function runSingleProject(config, cliFiles) {
2792
+ async function runSingleProject(cli, config) {
2897
2793
  resolveSetupFilePaths(config);
2898
- const discovery = discoverTestFiles(config, cliFiles);
2794
+ const discovery = discoverTestFiles(config, cli.files);
2899
2795
  if (discovery.files.length === 0) {
2900
2796
  if (config.passWithNoTests) return 0;
2901
2797
  console.error("No test files found");
@@ -2941,7 +2837,7 @@ async function runInner(args) {
2941
2837
  const config = mergeCliWithConfig(cli, await loadConfig$1(cli.config));
2942
2838
  const rawProjects = config.projects;
2943
2839
  if (rawProjects !== void 0 && rawProjects.length > 0) return runMultiProject(cli, config, rawProjects);
2944
- return runSingleProject(config, cli.files);
2840
+ return runSingleProject(cli, config);
2945
2841
  }
2946
2842
  const LUAU_ERROR_HINTS = [
2947
2843
  [/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\""],
@@ -3010,6 +2906,7 @@ function mergeCliWithConfig(cli, config) {
3010
2906
  formatters: resolveFormatters(cli, config),
3011
2907
  gameOutput: cli.gameOutput ?? config.gameOutput,
3012
2908
  outputFile: cli.outputFile ?? config.outputFile,
2909
+ parallel: cli.parallel ?? config.parallel,
3013
2910
  passWithNoTests: cli.passWithNoTests ?? config.passWithNoTests,
3014
2911
  pollInterval: cli.pollInterval ?? config.pollInterval,
3015
2912
  port: cli.port ?? config.port,