@isentinel/jest-roblox 0.3.6 → 0.3.7

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.
@@ -1,5 +1,5 @@
1
1
  import { createRequire } from "node:module";
2
- import { PermissionError, PollTimeoutError, TRANSIENT_TRANSPORT_CODES } from "@bedrock-rbx/ocale";
2
+ import { ApiError, PermissionError, PollTimeoutError, RateLimitError, TRANSIENT_TRANSPORT_CODES } from "@bedrock-rbx/ocale";
3
3
  import process from "node:process";
4
4
  import { isDeepStrictEqual } from "node:util";
5
5
  import color from "tinyrainbow";
@@ -29,12 +29,13 @@ import { execFile } from "node:child_process";
29
29
  import * as os from "node:os";
30
30
  import { LuauExecutionClient } from "@bedrock-rbx/ocale/luau-execution";
31
31
  import { PlacesClient } from "@bedrock-rbx/ocale/places";
32
- import { WebSocketServer } from "ws";
33
32
  import { StorageClient } from "@bedrock-rbx/ocale/storage";
33
+ import { WebSocketServer } from "ws";
34
+ import { setTimeout as setTimeout$1 } from "node:timers/promises";
34
35
  import { parseJSONC, parseYAML } from "confbox";
35
36
  import { Visitor, parseSync } from "oxc-parser";
36
37
  //#region package.json
37
- var version = "0.3.6";
38
+ var version = "0.3.7";
38
39
  //#endregion
39
40
  //#region src/config/errors.ts
40
41
  var ConfigError = class extends Error {
@@ -385,7 +386,28 @@ function validateConfig(raw) {
385
386
  if (workspace !== void 0 && workspace.root === void 0 !== (workspace.packages === void 0)) throw new Error("workspace.root and workspace.packages must be declared together.");
386
387
  return result;
387
388
  }
389
+ /**
390
+ * Identity helper for authoring a typed `jest.config.*` file. Returns its
391
+ * input unchanged; it exists purely to give editors autocompletion and
392
+ * type-checking for the root config shape (`Config` plus the c12 layer props
393
+ * on `ConfigInput`).
394
+ *
395
+ * Use it as the default export of a config file discovered by c12 (`.ts`,
396
+ * `.js`, `.mjs`, `.cjs`, `.json`, `.yaml`, `.toml`). All jest-passthrough
397
+ * options live under the `test:` block; root keys are CLI/runner-level.
398
+ * Configs may extend a shared base via `extends`, and any `Mergeable` array
399
+ * field (e.g. `test.testMatch`) accepts a function that receives the inherited
400
+ * defaults and returns the merged value. Precedence is CLI flags > config file
401
+ * > extended config > defaults.
402
+ */
388
403
  const defineConfig = createDefineConfig();
404
+ /**
405
+ * Identity helper for a single entry inside `test.projects`, mirroring
406
+ * {@link defineConfig} for per-project overrides. Returns its input unchanged
407
+ * and exists only for editor autocompletion and type-checking of the
408
+ * `InlineProjectConfig` shape — a `test:` block carrying the project's
409
+ * `include`/`displayName` plus any shared per-project jest options.
410
+ */
389
411
  const defineProject = createDefineConfig();
390
412
  //#endregion
391
413
  //#region src/config/loader.ts
@@ -445,7 +467,7 @@ async function seaImport(id) {
445
467
  const content = readFileSync(id, "utf-8");
446
468
  return JSON.parse(content);
447
469
  }
448
- return import(id);
470
+ return createRequire(id)(id);
449
471
  }
450
472
  function merger(...sources) {
451
473
  return defuFn(...sources.filter(Boolean));
@@ -1156,8 +1178,8 @@ const VALID_REPORTERS = /* @__PURE__ */ new Set([
1156
1178
  "text-lcov",
1157
1179
  "text-summary"
1158
1180
  ]);
1159
- function printCoverageHeader() {
1160
- const header = ` ${color.blue("%")} ${color.dim("Coverage report from")} ${color.yellow("istanbul")}`;
1181
+ function printCoverageHeader(agentMode = false) {
1182
+ const header = agentMode ? " % Coverage report from istanbul" : ` ${color.blue("%")} ${color.dim("Coverage report from")} ${color.yellow("istanbul")}`;
1161
1183
  process.stdout.write(`\n${header}\n`);
1162
1184
  }
1163
1185
  const TEXT_REPORTERS = /* @__PURE__ */ new Set(["text", "text-summary"]);
@@ -2069,6 +2091,10 @@ function parseParsedOutput(parsed) {
2069
2091
  }
2070
2092
  //#endregion
2071
2093
  //#region src/backends/envelope.ts
2094
+ const wholeRunErrorSchema = type({
2095
+ err: "unknown",
2096
+ success: "false"
2097
+ });
2072
2098
  const envelopeSchema = type({ entries: type({
2073
2099
  "bannerOutput?": "string",
2074
2100
  "elapsedMs?": "number",
@@ -2079,8 +2105,12 @@ const envelopeSchema = type({ entries: type({
2079
2105
  "snapshotWrites?": { "[string]": "string" }
2080
2106
  }).array() });
2081
2107
  function parseEnvelope(jestOutput) {
2082
- const envelope = envelopeSchema(JSON.parse(jestOutput));
2083
- if (envelope instanceof type.errors) return [{ jestOutput }];
2108
+ const raw = JSON.parse(jestOutput);
2109
+ const envelope = envelopeSchema(raw);
2110
+ if (envelope instanceof type.errors) {
2111
+ if (!(wholeRunErrorSchema(raw) instanceof type.errors)) parseJestOutput(jestOutput);
2112
+ return [{ jestOutput }];
2113
+ }
2084
2114
  return envelope.entries;
2085
2115
  }
2086
2116
  function buildProjectResult(entry, job, fallbackGameOutput) {
@@ -4820,18 +4850,29 @@ function mergeResults$1(typecheck, runtime) {
4820
4850
  assert(result !== void 0, "mergeResults requires at least one result");
4821
4851
  return result;
4822
4852
  }
4853
+ async function writeResultFile(outputFile, typecheck, runtime) {
4854
+ if (outputFile === void 0) return;
4855
+ await writeJsonFile$1(mergeResults$1(typecheck, runtime), outputFile);
4856
+ }
4823
4857
  async function outputSingleResult(config, result) {
4824
4858
  const { preCoverageMs, runtimeResult, typecheckResult } = result;
4825
4859
  const mergedResult = mergeResults$1(typecheckResult, runtimeResult?.result);
4826
- if (!config.silent) printFormattedOutput({
4860
+ const coveragePassed = emitResultsAndCoverage({
4827
4861
  config,
4828
- mergedResult,
4829
- runtimeResult,
4830
- timing: runtimeResult !== void 0 ? addCoverageTiming(runtimeResult.timing, preCoverageMs) : void 0,
4831
- typecheckResult
4862
+ coverageEnabled: config.collectCoverage,
4863
+ printResults: () => {
4864
+ if (config.silent) return;
4865
+ printFormattedOutput({
4866
+ config,
4867
+ mergedResult,
4868
+ runtimeResult,
4869
+ timing: runtimeResult !== void 0 ? addCoverageTiming(runtimeResult.timing, preCoverageMs) : void 0,
4870
+ typecheckResult
4871
+ });
4872
+ },
4873
+ runCoverage: () => processCoverage(config, runtimeResult?.coverageData)
4832
4874
  });
4833
- const coveragePassed = processCoverage(config, runtimeResult?.coverageData);
4834
- if (config.outputFile !== void 0) await writeJsonFile$1(mergedResult, config.outputFile);
4875
+ await writeResultFile(config.outputFile, typecheckResult, runtimeResult?.result);
4835
4876
  if (runtimeResult !== void 0) writeGameOutputIfConfigured(config, runtimeResult.gameOutput, { hintsShown: !mergedResult.success });
4836
4877
  runGitHubActionsFormatter(config, mergedResult, runtimeResult?.sourceMapper);
4837
4878
  const snapshotsPersisted = (runtimeResult?.snapshotWriteFailures ?? 0) === 0;
@@ -4916,20 +4957,26 @@ async function outputMultiResult(rootConfig, result) {
4916
4957
  });
4917
4958
  const merged = mergeProjectResults(projectResults.map((entry) => entry.result));
4918
4959
  const mergedResult = mergeResults$1(typecheckResult, merged.result);
4919
- if (!config.silent) {
4920
- printMultiProjectOutput({
4921
- config,
4922
- ...resolveSinkHints(result, config),
4923
- merged,
4924
- preCoverageMs,
4925
- projectResults,
4926
- typecheckResult
4927
- });
4928
- if (typecheckResult !== void 0 && !usesDefaultFormatter(config)) process.stderr.write(formatTypecheckSummary(typecheckResult));
4929
- }
4930
- const coveragePassed = processCoverage(config, merged.coverageData, extractWorkspaceCoverageMapped(result));
4960
+ const workspaceCoverage = extractWorkspaceCoverageMapped(result);
4961
+ const coveragePassed = emitResultsAndCoverage({
4962
+ config,
4963
+ coverageEnabled: config.collectCoverage || workspaceCoverage !== void 0,
4964
+ printResults: () => {
4965
+ if (config.silent) return;
4966
+ printMultiProjectOutput({
4967
+ config,
4968
+ ...resolveSinkHints(result, config),
4969
+ merged,
4970
+ preCoverageMs,
4971
+ projectResults,
4972
+ typecheckResult
4973
+ });
4974
+ if (typecheckResult !== void 0 && !usesDefaultFormatter(config)) process.stderr.write(formatTypecheckSummary(typecheckResult));
4975
+ },
4976
+ runCoverage: () => processCoverage(config, merged.coverageData, workspaceCoverage)
4977
+ });
4931
4978
  if (mode === "multi") {
4932
- if (config.outputFile !== void 0) await writeJsonFile$1(mergedResult, config.outputFile);
4979
+ await writeResultFile(config.outputFile, typecheckResult, merged.result);
4933
4980
  writeAggregatedGameOutput(config, projectResults, { hintsShown: !mergedResult.success });
4934
4981
  }
4935
4982
  runGitHubActionsFormatter(config, mergedResult, merged.sourceMapper);
@@ -4946,6 +4993,16 @@ function addCoverageTiming(timing, coverageMs) {
4946
4993
  totalMs: timing.totalMs + coverageMs
4947
4994
  };
4948
4995
  }
4996
+ function emitResultsAndCoverage(options) {
4997
+ const { config, coverageEnabled, printResults, runCoverage } = options;
4998
+ const deferResults = coverageEnabled && usesAgentFormatter(config.formatters, config.verbose);
4999
+ if (!deferResults) printResults();
5000
+ try {
5001
+ return runCoverage();
5002
+ } finally {
5003
+ if (deferResults) printResults();
5004
+ }
5005
+ }
4949
5006
  function usesDefaultFormatter(config) {
4950
5007
  return !hasFormatter(config.formatters, "json") && !usesAgentFormatter(config.formatters, config.verbose);
4951
5008
  }
@@ -5014,9 +5071,10 @@ function processCoverage(config, coverageData, preMapped) {
5014
5071
  if (!config.collectCoverage && preMapped === void 0) return true;
5015
5072
  const mapped = resolveMappedCoverage(config, coverageData, preMapped);
5016
5073
  if (mapped === void 0) return true;
5017
- if (!config.silent) printCoverageHeader();
5074
+ const agentMode = usesAgentFormatter(config.formatters, config.verbose);
5075
+ if (!config.silent) printCoverageHeader(agentMode);
5018
5076
  generateReports({
5019
- agentMode: usesAgentFormatter(config.formatters, config.verbose),
5077
+ agentMode,
5020
5078
  collectCoverageFrom: config.collectCoverageFrom,
5021
5079
  coverageDirectory: path$1.resolve(config.rootDir, config.coverageDirectory),
5022
5080
  coveragePathIgnorePatterns: config.coveragePathIgnorePatterns,
@@ -5527,7 +5585,7 @@ function buildPlace(options) {
5527
5585
  }
5528
5586
  //#endregion
5529
5587
  //#region src/luau/parse-ast.luau
5530
- 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` is intentionally absent: the literal value lives at\n -- `value.token.text` and is flattened by the special-case branch in\n -- `strip` below. A flat `{ \"text\" }` entry here can't reach it.\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\n -- Lute nests the string literal inside `.token.text`, so the KEEP\n -- entry can't reach it via flat field lookup. Flatten it here so\n -- downstream consumers read `node.text` like every other literal.\n if value.tag == \"string\" and type(value.token) == \"table\" then\n result.text = value.token.text\n end\n\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\" 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";
5588
+ 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 -- `operator` (reduced to its `.text` by the token branch in `strip`) lets\n -- the collector tell `and`/`or` short-circuit branches from other binaries.\n binary = { \"lhsOperand\", \"rhsOperand\", \"operator\" },\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` is intentionally absent: the literal value lives at\n -- `value.token.text` and is flattened by the special-case branch in\n -- `strip` below. A flat `{ \"text\" }` entry here can't reach it.\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\n -- Lute nests the string literal inside `.token.text`, so the KEEP\n -- entry can't reach it via flat field lookup. Flatten it here so\n -- downstream consumers read `node.text` like every other literal.\n if value.tag == \"string\" and type(value.token) == \"table\" then\n result.text = value.token.text\n end\n\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\" 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";
5531
5589
  //#endregion
5532
5590
  //#region packages/luau-ast/dist/index.mjs
5533
5591
  function visitExpression(expression, visitor) {
@@ -5825,9 +5883,37 @@ function collectCoverage(root) {
5825
5883
  const functions = [];
5826
5884
  const branches = [];
5827
5885
  const implicitElseProbes = [];
5828
- const exprIfProbes = [];
5886
+ const wrapProbes = [];
5829
5887
  const namedFunctions = /* @__PURE__ */ new Set();
5830
5888
  visitBlock(root, {
5889
+ visitExprBinary(node) {
5890
+ const operator = node.operator?.text;
5891
+ if (operator !== "and" && operator !== "or") return true;
5892
+ branches.push({
5893
+ arms: [{
5894
+ bodyFirstColumn: 0,
5895
+ bodyFirstLine: 0,
5896
+ location: { ...node.lhsOperand.location }
5897
+ }, {
5898
+ bodyFirstColumn: 0,
5899
+ bodyFirstLine: 0,
5900
+ location: { ...node.rhsOperand.location }
5901
+ }],
5902
+ branchType: "binary-expr",
5903
+ index: branchIndex
5904
+ });
5905
+ wrapProbes.push({
5906
+ armIndex: 1,
5907
+ branchIndex,
5908
+ exprLocation: { ...node.lhsOperand.location }
5909
+ }, {
5910
+ armIndex: 2,
5911
+ branchIndex,
5912
+ exprLocation: { ...node.rhsOperand.location }
5913
+ });
5914
+ branchIndex++;
5915
+ return true;
5916
+ },
5831
5917
  visitExprFunction(node) {
5832
5918
  if (namedFunctions.has(node)) return true;
5833
5919
  const first = getBodyFirstStatement(node.body);
@@ -5853,7 +5939,7 @@ function collectCoverage(root) {
5853
5939
  bodyFirstLine: 0,
5854
5940
  location: { ...node.thenExpr.location }
5855
5941
  });
5856
- exprIfProbes.push({
5942
+ wrapProbes.push({
5857
5943
  armIndex,
5858
5944
  branchIndex,
5859
5945
  exprLocation: { ...node.thenExpr.location }
@@ -5865,7 +5951,7 @@ function collectCoverage(root) {
5865
5951
  bodyFirstLine: 0,
5866
5952
  location: { ...elseif.thenExpr.location }
5867
5953
  });
5868
- exprIfProbes.push({
5954
+ wrapProbes.push({
5869
5955
  armIndex,
5870
5956
  branchIndex,
5871
5957
  exprLocation: { ...elseif.thenExpr.location }
@@ -5877,7 +5963,7 @@ function collectCoverage(root) {
5877
5963
  bodyFirstLine: 0,
5878
5964
  location: { ...node.elseExpr.location }
5879
5965
  });
5880
- exprIfProbes.push({
5966
+ wrapProbes.push({
5881
5967
  armIndex,
5882
5968
  branchIndex,
5883
5969
  exprLocation: { ...node.elseExpr.location }
@@ -5981,10 +6067,10 @@ function collectCoverage(root) {
5981
6067
  });
5982
6068
  return {
5983
6069
  branches,
5984
- exprIfProbes,
5985
6070
  functions,
5986
6071
  implicitElseProbes,
5987
- statements
6072
+ statements,
6073
+ wrapProbes
5988
6074
  };
5989
6075
  }
5990
6076
  function getBodyFirstStatement(block) {
@@ -6058,6 +6144,11 @@ function buildCoverageMap$1(result) {
6058
6144
  }
6059
6145
  //#endregion
6060
6146
  //#region src/coverage-pipeline/probe-inserter.ts
6147
+ const KIND_RANK = {
6148
+ close: 2,
6149
+ open: 0,
6150
+ point: 1
6151
+ };
6061
6152
  function insertProbes(source, result, fileKey) {
6062
6153
  const lines = splitLines(source);
6063
6154
  applyProbes(lines, collectProbes(result));
@@ -6067,11 +6158,13 @@ function collectProbes(result) {
6067
6158
  const probes = [];
6068
6159
  for (const stmt of result.statements) probes.push({
6069
6160
  column: stmt.location.beginColumn,
6161
+ kind: "point",
6070
6162
  line: stmt.location.beginLine,
6071
6163
  text: `__cov_s[${stmt.index}] += 1; `
6072
6164
  });
6073
6165
  for (const func of result.functions) if (func.bodyFirstLine > 0) probes.push({
6074
6166
  column: func.bodyFirstColumn,
6167
+ kind: "point",
6075
6168
  line: func.bodyFirstLine,
6076
6169
  text: `__cov_f[${func.index}] += 1; `
6077
6170
  });
@@ -6079,27 +6172,42 @@ function collectProbes(result) {
6079
6172
  const arm = branch.arms[armIndex];
6080
6173
  if (arm !== void 0 && arm.bodyFirstLine > 0) probes.push({
6081
6174
  column: arm.bodyFirstColumn,
6175
+ kind: "point",
6082
6176
  line: arm.bodyFirstLine,
6083
6177
  text: `__cov_b[${branch.index}][${armIndex + 1}] += 1; `
6084
6178
  });
6085
6179
  }
6086
6180
  for (const probe of result.implicitElseProbes) probes.push({
6087
6181
  column: probe.endColumn,
6182
+ kind: "point",
6088
6183
  line: probe.endLine,
6089
6184
  text: `else __cov_b[${probe.branchIndex}][${probe.armIndex}] += 1 `
6090
6185
  });
6091
- for (const probe of result.exprIfProbes) probes.push({
6092
- column: probe.exprLocation.beginColumn,
6093
- line: probe.exprLocation.beginLine,
6094
- text: `__cov_br(${probe.branchIndex}, ${probe.armIndex}, `
6095
- }, {
6096
- column: probe.exprLocation.endColumn,
6097
- line: probe.exprLocation.endLine,
6098
- text: ")"
6099
- });
6186
+ for (const probe of result.wrapProbes) {
6187
+ const { beginColumn, beginLine, endColumn, endLine } = probe.exprLocation;
6188
+ probes.push({
6189
+ column: beginColumn,
6190
+ kind: "open",
6191
+ line: beginLine,
6192
+ spanColumn: endColumn,
6193
+ spanLine: endLine,
6194
+ text: `__cov_br(${probe.branchIndex}, ${probe.armIndex}, `
6195
+ }, {
6196
+ column: endColumn,
6197
+ kind: "close",
6198
+ line: endLine,
6199
+ spanColumn: beginColumn,
6200
+ spanLine: beginLine,
6201
+ text: ")"
6202
+ });
6203
+ }
6100
6204
  probes.sort((a, b) => {
6101
- if (a.line === b.line) return b.column - a.column;
6102
- return b.line - a.line;
6205
+ if (a.line !== b.line) return b.line - a.line;
6206
+ if (a.column !== b.column) return b.column - a.column;
6207
+ if (KIND_RANK[a.kind] !== KIND_RANK[b.kind]) return KIND_RANK[a.kind] - KIND_RANK[b.kind];
6208
+ if (a.spanLine !== void 0 && b.spanLine !== void 0 && a.spanLine !== b.spanLine) return a.spanLine - b.spanLine;
6209
+ if (a.spanColumn !== void 0 && b.spanColumn !== void 0) return a.spanColumn - b.spanColumn;
6210
+ return 0;
6103
6211
  });
6104
6212
  return probes;
6105
6213
  }
@@ -6161,7 +6269,7 @@ function buildPreamble(modeDirective, fileKey, result) {
6161
6269
  const zeros = branch.arms.map(() => "0").join(", ");
6162
6270
  preamble += `if __cov_b[${branch.index}] == nil then __cov_b[${branch.index}] = {${zeros}} end\n`;
6163
6271
  }
6164
- if (result.exprIfProbes.length > 0) preamble += "local function __cov_br(__bi, __ai, ...) __cov_b[__bi][__ai] += 1; return ... end\n";
6272
+ if (result.wrapProbes.length > 0) preamble += "local function __cov_br(__bi, __ai, ...) __cov_b[__bi][__ai] += 1; return ... end\n";
6165
6273
  }
6166
6274
  return preamble;
6167
6275
  }
@@ -6890,6 +6998,7 @@ var OcaleRunner = class {
6890
6998
  apiKey: credentials.apiKey,
6891
6999
  ...options?.baseUrl !== void 0 ? { baseUrl: options.baseUrl } : {},
6892
7000
  ...options?.httpClient !== void 0 ? { httpClient: options.httpClient } : {},
7001
+ ...options?.maxRetries !== void 0 ? { maxRetries: options.maxRetries } : {},
6893
7002
  ...options?.sleep !== void 0 ? { sleep: options.sleep } : {}
6894
7003
  };
6895
7004
  this.luau = new LuauExecutionClient(clientOptions);
@@ -6897,7 +7006,7 @@ var OcaleRunner = class {
6897
7006
  this.readFileFn = options?.readFile ?? ((filePath) => fs$1.readFileSync(filePath));
6898
7007
  }
6899
7008
  async executeScript(options) {
6900
- const { script, timeout } = options;
7009
+ const { placeVersion, script, timeout } = options;
6901
7010
  if (timeout <= 0) throw new Error("Timeout must be a positive number");
6902
7011
  const startTime = Date.now();
6903
7012
  const timeoutSeconds = Math.min(Math.floor(timeout / 1e3), MAX_TASK_TIMEOUT_SECONDS);
@@ -6905,7 +7014,8 @@ var OcaleRunner = class {
6905
7014
  placeId: this.credentials.placeId,
6906
7015
  script,
6907
7016
  timeoutSeconds,
6908
- universeId: this.credentials.universeId
7017
+ universeId: this.credentials.universeId,
7018
+ ...placeVersion !== void 0 ? { versionId: String(placeVersion) } : {}
6909
7019
  }, {
6910
7020
  retryableTransportCodes: TRANSIENT_TRANSPORT_CODES,
6911
7021
  timeoutMs: timeout
@@ -6957,6 +7067,115 @@ type({
6957
7067
  request_id: "string",
6958
7068
  type: "'results'"
6959
7069
  });
7070
+ /**
7071
+ * Measured Open Cloud per-place active-task ceiling — a platform constant, not a
7072
+ * tuning knob. `P` places offer `10·P` concurrent task slots, so the pool caps
7073
+ * each place's slot share here and warns when the requested total exceeds the
7074
+ * aggregate ceiling.
7075
+ */
7076
+ const MAX_TASKS_PER_PLACE = 10;
7077
+ /**
7078
+ * A freed slot needs ~10s before its place will accept a new task. A backoff
7079
+ * signal inside this window after a completion on that place is slot-recycle
7080
+ * lag, not a genuinely-full place, so the pool waits out the remainder of the
7081
+ * window rather than treating it as a hard place-full signal (and burning the
7082
+ * shared creation budget on an immediate retry storm).
7083
+ */
7084
+ const RECYCLE_LAG_MS = 1e4;
7085
+ /** Backoff when a place pushes back with no server-supplied retry delay. */
7086
+ const DEFAULT_BACKOFF_MS = 5e3;
7087
+ /**
7088
+ * Drive a replenishing pool of long-lived tasks across a configurable list of
7089
+ * places: spread `concurrency` slots across the places (capped at
7090
+ * {@link MAX_TASKS_PER_PLACE} each), keep every slot full, relaunch a slot when
7091
+ * its task returns while work remains (`!isDone()`), and resolve once every slot
7092
+ * has drained and the done-signal has fired. All places drain the consumer's one
7093
+ * shared queue, so a slot that frees on a fast place transparently re-runs a
7094
+ * sibling's lost work. The pool is jest/mutation-agnostic — chunk sizing, result
7095
+ * de-duplication, and bail live in the consumer.
7096
+ *
7097
+ * A task that throws a recognised platform backoff signal (a genuinely-full
7098
+ * place's `RESOURCE_EXHAUSTED`, the ~10s slot-recycle lag, or a rate-limit 429)
7099
+ * waits the slot out and retries without surfacing an error; any other throw
7100
+ * frees the slot (its in-flight work re-surfaces via the queue invisibility
7101
+ * window), reports through `onError`, and is relaunched while work remains — so
7102
+ * neither a transient failure nor platform backoff aborts the run or corrupts
7103
+ * results.
7104
+ */
7105
+ async function runTaskPool(options) {
7106
+ const { concurrency, isDone, onError, onResult, places } = options;
7107
+ const now = options.now ?? Date.now;
7108
+ const sleep = options.sleep ?? setTimeout$1;
7109
+ const warn = options.warn ?? ((message) => {
7110
+ console.warn(message);
7111
+ });
7112
+ if (concurrency < 1) throw new Error(`runTaskPool concurrency must be >= 1, got ${String(concurrency)}`);
7113
+ if (places.length === 0) throw new Error("runTaskPool requires at least one place");
7114
+ const allocations = distributeSlots(places, concurrency, warn);
7115
+ async function backoff(state, retryAfterMs) {
7116
+ const genuineMs = retryAfterMs !== void 0 && retryAfterMs > 0 ? retryAfterMs : DEFAULT_BACKOFF_MS;
7117
+ const sinceCompletion = now() - state.lastCompletionMs;
7118
+ await sleep(sinceCompletion < RECYCLE_LAG_MS ? RECYCLE_LAG_MS - sinceCompletion : genuineMs);
7119
+ }
7120
+ async function worker(state) {
7121
+ while (!isDone()) {
7122
+ let result;
7123
+ try {
7124
+ result = await state.place.runTask();
7125
+ } catch (err) {
7126
+ const signal = classifyBackoff(err);
7127
+ if (signal !== void 0) {
7128
+ await backoff(state, signal.retryAfterMs);
7129
+ continue;
7130
+ }
7131
+ onError?.(err);
7132
+ continue;
7133
+ }
7134
+ state.lastCompletionMs = now();
7135
+ onResult(result);
7136
+ }
7137
+ }
7138
+ const workers = [];
7139
+ for (const { place, slots } of allocations) {
7140
+ const state = {
7141
+ lastCompletionMs: Number.NEGATIVE_INFINITY,
7142
+ place
7143
+ };
7144
+ for (let slot = 0; slot < slots; slot += 1) workers.push(worker(state));
7145
+ }
7146
+ await Promise.all(workers);
7147
+ }
7148
+ /**
7149
+ * Spread `concurrency` task-slots across the places as evenly as possible, capped
7150
+ * at {@link MAX_TASKS_PER_PLACE} per place. A total above the aggregate ceiling is
7151
+ * clamped and reported once through `warn` so the run still proceeds at the real
7152
+ * capacity instead of silently over-subscribing.
7153
+ */
7154
+ function distributeSlots(places, concurrency, warn) {
7155
+ const placeCount = places.length;
7156
+ const capacity = placeCount * MAX_TASKS_PER_PLACE;
7157
+ if (concurrency > capacity) warn(`runTaskPool concurrency ${String(concurrency)} exceeds ${String(placeCount)} place(s) × ${String(MAX_TASKS_PER_PLACE)} = ${String(capacity)}; clamping to ${String(capacity)}`);
7158
+ const effective = Math.min(concurrency, capacity);
7159
+ const base = Math.floor(effective / placeCount);
7160
+ const remainder = effective % placeCount;
7161
+ return places.map((place, index) => ({
7162
+ place,
7163
+ slots: base + (index < remainder ? 1 : 0)
7164
+ }));
7165
+ }
7166
+ /**
7167
+ * Classify the platform backoff signal the pool waits out rather than failing:
7168
+ * a rate-limit 429 ({@link RateLimitError}, carrying the server retry delay) or a
7169
+ * genuinely-full place ({@link ApiError} with the `RESOURCE_EXHAUSTED` code).
7170
+ * Walks the `cause` chain because {@link RemoteRunner.executeScript} wraps the
7171
+ * transport error in a plain `Error`. Any other error is not a backoff signal.
7172
+ */
7173
+ function classifyBackoff(error) {
7174
+ for (let current = error; current instanceof Error; current = current.cause) {
7175
+ if (current instanceof RateLimitError) return { retryAfterMs: current.retryAfterSeconds * 1e3 };
7176
+ if (current instanceof ApiError && current.code === "RESOURCE_EXHAUSTED") return {};
7177
+ }
7178
+ }
6960
7179
  var WorkQueue = class {
6961
7180
  decode;
6962
7181
  encode;
@@ -7083,6 +7302,7 @@ function readStringProperty(err, key) {
7083
7302
  //#region src/backends/open-cloud.ts
7084
7303
  const PARALLEL_AUTO_CAP = 3;
7085
7304
  const BASE_URL_ENV = "JEST_ROBLOX_OPEN_CLOUD_BASE_URL";
7305
+ const MAX_RETRIES_ENV = "JEST_ROBLOX_OCALE_MAX_RETRIES";
7086
7306
  const DEFAULT_STREAM_POLL_MS = 250;
7087
7307
  var OpenCloudBackend = class {
7088
7308
  runner;
@@ -7102,17 +7322,18 @@ var OpenCloudBackend = class {
7102
7322
  rawResults: workStealing === true ? await this.runWorkStealing({
7103
7323
  jobs,
7104
7324
  parallel,
7325
+ placeVersion: upload.versionNumber,
7105
7326
  primaryConfig: primary.config,
7106
7327
  scriptOverride,
7107
7328
  streaming
7108
- }) : await this.runStaticBuckets(jobs, parallel, scriptOverride),
7329
+ }) : await this.runStaticBuckets(jobs, parallel, upload.versionNumber, scriptOverride),
7109
7330
  timing: {
7110
7331
  executionMs: Date.now() - executionStart,
7111
7332
  uploadMs: upload.uploadMs
7112
7333
  }
7113
7334
  };
7114
7335
  }
7115
- async runBucket(bucket, scriptOverride) {
7336
+ async runBucket(bucket, placeVersion, scriptOverride) {
7116
7337
  const { indices, jobs } = bucket;
7117
7338
  const primary = jobs[0];
7118
7339
  const inputs = jobs.map((job) => {
@@ -7123,6 +7344,7 @@ var OpenCloudBackend = class {
7123
7344
  });
7124
7345
  const script = scriptOverride ?? generateTestScript(inputs);
7125
7346
  const scriptResult = await this.runner.executeScript({
7347
+ placeVersion,
7126
7348
  script,
7127
7349
  timeout: primary.config.timeout
7128
7350
  });
@@ -7141,37 +7363,44 @@ var OpenCloudBackend = class {
7141
7363
  })
7142
7364
  };
7143
7365
  }
7144
- async runStaticBuckets(jobs, parallel, scriptOverride) {
7366
+ async runStaticBuckets(jobs, parallel, placeVersion, scriptOverride) {
7145
7367
  const buckets = bucketJobs(jobs, parallel);
7146
- const bucketResults = await Promise.all(buckets.map(async (bucket) => this.runBucket(bucket, scriptOverride)));
7368
+ const bucketResults = await Promise.all(buckets.map(async (bucket) => this.runBucket(bucket, placeVersion, scriptOverride)));
7147
7369
  const flattened = Array.from({ length: jobs.length });
7148
7370
  for (const { indices, rawResults } of bucketResults) for (const [positionInBucket, originalIndex] of indices.entries()) flattened[originalIndex] = rawResults[positionInBucket];
7149
7371
  return flattened;
7150
7372
  }
7151
- async runStealingTask(script, primaryConfig) {
7152
- const result = await this.runner.executeScript({
7153
- script,
7154
- timeout: primaryConfig.timeout
7155
- });
7156
- const jestOutput = result.outputs[0];
7157
- if (jestOutput === void 0) throw new Error(`No test results in output. Got: ${JSON.stringify(result.outputs)}`);
7158
- return {
7159
- entries: parseEnvelope(jestOutput),
7160
- gameOutput: result.outputs[1]
7161
- };
7162
- }
7163
7373
  async runWorkStealing(args) {
7164
- const { jobs, parallel, primaryConfig, scriptOverride, streaming } = args;
7374
+ const { jobs, parallel, placeVersion, primaryConfig, scriptOverride, streaming } = args;
7165
7375
  const taskCount = resolveBucketCount(parallel, jobs.length);
7166
7376
  const tasksDone = { value: false };
7167
- const taskEnvelopesPromise = Promise.all(Array.from({ length: taskCount }, async () => this.runStealingTask(scriptOverride, primaryConfig))).finally(() => {
7377
+ const taskResults = [];
7378
+ let launched = 0;
7379
+ let taskFailure;
7380
+ const poolPromise = runTaskPool({
7381
+ concurrency: taskCount,
7382
+ isDone: () => launched >= taskCount,
7383
+ onError: (error) => {
7384
+ taskFailure = { error };
7385
+ },
7386
+ onResult: (result) => {
7387
+ taskResults.push(result);
7388
+ },
7389
+ places: [{ runTask: async () => {
7390
+ launched += 1;
7391
+ return this.runner.executeScript({
7392
+ placeVersion,
7393
+ script: scriptOverride,
7394
+ timeout: primaryConfig.timeout
7395
+ });
7396
+ } }]
7397
+ }).finally(() => {
7168
7398
  tasksDone.value = true;
7169
7399
  });
7170
7400
  const pollPromise = streaming !== void 0 ? pollStreamingResults(streaming, () => tasksDone.value) : Promise.resolve();
7171
- const [taskSettlement] = await Promise.allSettled([taskEnvelopesPromise, pollPromise]);
7172
- if (taskSettlement.status === "rejected") throw taskSettlement.reason;
7173
- const taskEnvelopes = taskSettlement.value;
7174
- const entryByKey = aggregateEntriesByKey(taskEnvelopes);
7401
+ await Promise.all([poolPromise, pollPromise]);
7402
+ if (taskFailure !== void 0) throw taskFailure.error;
7403
+ const entryByKey = aggregateEntriesByKey(taskResults.map(parseStealingEnvelope));
7175
7404
  const missing = [];
7176
7405
  const rawResults = [];
7177
7406
  for (const job of jobs) {
@@ -7210,6 +7439,19 @@ function resolveOpenCloudBaseUrl() {
7210
7439
  if (override === void 0 || override === "") return;
7211
7440
  return override.replace(/\/+$/, "");
7212
7441
  }
7442
+ /**
7443
+ * Reads {@link MAX_RETRIES_ENV} for an Open Cloud retry-budget override. Lets
7444
+ * the live e2e suite raise the per-request retry count so concurrent place
7445
+ * uploads (which share one per-minute quota across processes) ride out a
7446
+ * transient 429 instead of failing. Returns undefined for unset, empty, or
7447
+ * non-integer values so the client keeps its own default.
7448
+ */
7449
+ function resolveOcaleMaxRetries() {
7450
+ const raw = process.env[MAX_RETRIES_ENV]?.trim();
7451
+ if (raw === void 0 || raw === "") return;
7452
+ const parsed = Number(raw);
7453
+ return Number.isInteger(parsed) && parsed >= 0 ? parsed : void 0;
7454
+ }
7213
7455
  function createOpenCloudBackend(credentials) {
7214
7456
  return new OpenCloudBackend(credentials);
7215
7457
  }
@@ -7248,7 +7490,11 @@ async function sleep(ms) {
7248
7490
  }
7249
7491
  function resolveRunnerOptions() {
7250
7492
  const baseUrl = resolveOpenCloudBaseUrl();
7251
- return baseUrl === void 0 ? {} : { baseUrl };
7493
+ const maxRetries = resolveOcaleMaxRetries();
7494
+ return {
7495
+ ...baseUrl === void 0 ? {} : { baseUrl },
7496
+ ...maxRetries === void 0 ? {} : { maxRetries }
7497
+ };
7252
7498
  }
7253
7499
  function resolveBucketCount(parallel, jobCount) {
7254
7500
  if (parallel === void 0) return 1;
@@ -7273,6 +7519,19 @@ function bucketJobs(jobs, parallel) {
7273
7519
  function entryLookupKey(package_, project) {
7274
7520
  return project === void 0 || project === package_ ? package_ : `${package_}::${project}`;
7275
7521
  }
7522
+ /**
7523
+ * Decode one work-stealing task's return envelope. Throws when the task
7524
+ * produced no Jest output so a broken task surfaces as a run failure rather
7525
+ * than a silently-missing package.
7526
+ */
7527
+ function parseStealingEnvelope(result) {
7528
+ const jestOutput = result.outputs[0];
7529
+ if (jestOutput === void 0) throw new Error(`No test results in output. Got: ${JSON.stringify(result.outputs)}`);
7530
+ return {
7531
+ entries: parseEnvelope(jestOutput),
7532
+ gameOutput: result.outputs[1]
7533
+ };
7534
+ }
7276
7535
  function aggregateEntriesByKey(taskEnvelopes) {
7277
7536
  const entryByKey = /* @__PURE__ */ new Map();
7278
7537
  for (const { entries, gameOutput } of taskEnvelopes) for (const entry of entries) if (entry.pkg !== void 0) {
@@ -8578,8 +8837,19 @@ function parseTscErrorLine(line) {
8578
8837
  }
8579
8838
  //#endregion
8580
8839
  //#region src/typecheck/runner.ts
8581
- /** Milliseconds the tsgo spawn may run before it is killed and the pass fails. */
8840
+ /**
8841
+ * Milliseconds to wait for tsgo to launch (the child `spawn` event) before the
8842
+ * pass fails. Bounds only process startup, so a slow *compile* never trips it;
8843
+ * the compile itself is governed by {@link TypecheckOptions.timeout}.
8844
+ */
8582
8845
  const DEFAULT_SPAWN_TIMEOUT = 1e4;
8846
+ /**
8847
+ * Milliseconds the tsgo compile may run before it is killed and the pass
8848
+ * throws. Mirrors the run-level `timeout` default (`config.timeout`); callers
8849
+ * pass the resolved run timeout so a wedged compile dies on the same deadline
8850
+ * as the Roblox run instead of a tight typecheck-only number.
8851
+ */
8852
+ const DEFAULT_RUN_TIMEOUT = 3e5;
8583
8853
  function createLocationsIndexMap(source) {
8584
8854
  const map = /* @__PURE__ */ new Map();
8585
8855
  let index = 0;
@@ -8729,27 +8999,53 @@ async function spawnTsgo(options) {
8729
8999
  }
8730
9000
  const tsgoScript = resolveTsgoScript();
8731
9001
  const spawnTimeout = options.spawnTimeout ?? DEFAULT_SPAWN_TIMEOUT;
9002
+ const runTimeout = options.timeout ?? DEFAULT_RUN_TIMEOUT;
8732
9003
  return new Promise((resolve, reject) => {
8733
- execFile(process.execPath, [tsgoScript, ...args], {
9004
+ let launchTimer;
9005
+ const state = { settled: false };
9006
+ function finish(action) {
9007
+ if (state.settled) return;
9008
+ state.settled = true;
9009
+ if (launchTimer !== void 0) clearTimeout(launchTimer);
9010
+ action();
9011
+ }
9012
+ const child = execFile(process.execPath, [tsgoScript, ...args], {
8734
9013
  cwd: options.rootDir,
8735
9014
  encoding: "utf-8",
8736
- timeout: spawnTimeout,
9015
+ timeout: runTimeout,
8737
9016
  windowsHide: true
8738
9017
  }, (error, stdout, stderr) => {
8739
9018
  if (error === null) {
8740
- resolve(stdout);
9019
+ finish(() => {
9020
+ resolve(stdout);
9021
+ });
8741
9022
  return;
8742
9023
  }
8743
9024
  if (error.killed === true) {
8744
- reject(/* @__PURE__ */ new Error(`tsgo typecheck timed out after ${String(spawnTimeout)}ms (spawnTimeout)`));
9025
+ finish(() => {
9026
+ reject(/* @__PURE__ */ new Error(`tsgo typecheck timed out after ${String(runTimeout)}ms (timeout)`));
9027
+ });
8745
9028
  return;
8746
9029
  }
8747
9030
  if (typeof error.code === "number") {
8748
- resolve(stdout !== "" ? stdout : stderr);
9031
+ finish(() => {
9032
+ resolve(stdout !== "" ? stdout : stderr);
9033
+ });
8749
9034
  return;
8750
9035
  }
8751
- reject(new Error(error.message, { cause: error }));
9036
+ finish(() => {
9037
+ reject(new Error(error.message, { cause: error }));
9038
+ });
9039
+ });
9040
+ child.once("spawn", () => {
9041
+ clearTimeout(launchTimer);
8752
9042
  });
9043
+ if (!state.settled) launchTimer = setTimeout(() => {
9044
+ child.kill();
9045
+ finish(() => {
9046
+ reject(/* @__PURE__ */ new Error(`tsgo spawn timed out after ${String(spawnTimeout)}ms (spawnTimeout)`));
9047
+ });
9048
+ }, spawnTimeout);
8753
9049
  });
8754
9050
  }
8755
9051
  //#endregion
@@ -8959,6 +9255,20 @@ async function runMultiProject(options) {
8959
9255
  const { filesByProject, projects } = timing.profile("selectProjects", () => {
8960
9256
  return selectProjects(allProjects, cli.project, cli.files, rootConfig.rootDir);
8961
9257
  });
9258
+ if (projects.every((project) => {
9259
+ return resolveTypecheckConfig({
9260
+ cli: cliTypecheck,
9261
+ project: project.typecheck,
9262
+ root: rootConfig.typecheck
9263
+ }).only;
9264
+ })) return runMultiTypecheckOnly({
9265
+ cliFiles: cli.files,
9266
+ cliTypecheck,
9267
+ filesByProject,
9268
+ projects,
9269
+ rootConfig,
9270
+ timing
9271
+ });
8962
9272
  const cacheRoot = path$1.resolve(rootConfig.rootDir, ".jest-roblox", "cache");
8963
9273
  const cleaned = timing.profile("cleanLeftoverStubs", () => {
8964
9274
  return cleanLeftoverStubs(projects, rootConfig.rootDir);
@@ -9004,6 +9314,7 @@ async function runMultiProject(options) {
9004
9314
  ignoreSourceErrors: rootTypecheck.ignoreSourceErrors,
9005
9315
  rootDir: group.cwd,
9006
9316
  spawnTimeout: rootTypecheck.spawnTimeout,
9317
+ timeout: rootConfig.timeout,
9007
9318
  tsconfig: group.tsconfig
9008
9319
  });
9009
9320
  })]);
@@ -9106,6 +9417,56 @@ function collectPendingJobs(arguments_) {
9106
9417
  typeTestEntries
9107
9418
  };
9108
9419
  }
9420
+ async function runMultiTypecheckOnly(arguments_) {
9421
+ const { cliFiles, cliTypecheck, filesByProject, projects, rootConfig, timing } = arguments_;
9422
+ const { typeTestEntries } = timing.profile("collectPendingJobs", () => {
9423
+ return collectPendingJobs({
9424
+ cliFiles,
9425
+ cliTypecheck,
9426
+ effectivePlaceFile: rootConfig.placeFile,
9427
+ filesByProject,
9428
+ projects,
9429
+ rootConfig
9430
+ });
9431
+ });
9432
+ if (typeTestEntries.length === 0) {
9433
+ if (rootConfig.passWithNoTests) return {
9434
+ merged: {},
9435
+ mode: "multi",
9436
+ preCoverageMs: 0,
9437
+ projectResults: []
9438
+ };
9439
+ return {
9440
+ merged: {},
9441
+ mode: "multi",
9442
+ preCoverageMs: 0,
9443
+ projectResults: [],
9444
+ validationExitCode: 2,
9445
+ validationMessage: "No test files found in any project\n"
9446
+ };
9447
+ }
9448
+ const rootTypecheck = resolveTypecheckConfig({
9449
+ cli: cliTypecheck,
9450
+ root: rootConfig.typecheck
9451
+ });
9452
+ const typecheckPass = await runTypecheckPass(typeTestEntries, async (group) => {
9453
+ return runTypecheck({
9454
+ files: group.files,
9455
+ ignoreSourceErrors: rootTypecheck.ignoreSourceErrors,
9456
+ rootDir: group.cwd,
9457
+ spawnTimeout: rootTypecheck.spawnTimeout,
9458
+ tsconfig: group.tsconfig
9459
+ });
9460
+ });
9461
+ timing.record("runTypecheck", typecheckPass.elapsedMs);
9462
+ return {
9463
+ merged: {},
9464
+ mode: "multi",
9465
+ preCoverageMs: 0,
9466
+ projectResults: [],
9467
+ typecheckResult: typecheckPass.result
9468
+ };
9469
+ }
9109
9470
  async function runJobs(backend, pendingJobs, parallel, timing) {
9110
9471
  if (pendingJobs.length === 0) {
9111
9472
  await backend.close?.();
@@ -9274,6 +9635,7 @@ async function runSingleProject(options) {
9274
9635
  ignoreSourceErrors: typecheck.ignoreSourceErrors,
9275
9636
  rootDir: effectiveConfig.rootDir,
9276
9637
  spawnTimeout: typecheck.spawnTimeout,
9638
+ timeout: effectiveConfig.timeout,
9277
9639
  tsconfig: typecheck.tsconfig
9278
9640
  });
9279
9641
  } finally {
@@ -10218,7 +10580,7 @@ async function runWorkspaceProfiled(options, timing) {
10218
10580
  });
10219
10581
  }), runWorkspaceTypecheckPass(typeTestEntries, typecheckByDirectory)]);
10220
10582
  if (runOptions.workspaceOutputFile) writePerPackageOutputFiles(workspaceRoot, buildPerPackageResults(pending, results, typecheckPass.byPackage, typeTestProjects));
10221
- if (runOptions.outputFile !== void 0) await writeWorkspaceOutputFile(runOptions.outputFile, mergeProjectResults(results).result, typecheckPass.outcome.result);
10583
+ await writeResultFile(runOptions.outputFile, typecheckPass.outcome.result, runOptions.outputFile !== void 0 ? mergeProjectResults(results).result : void 0);
10222
10584
  emitWorkspaceGameOutput({
10223
10585
  pending,
10224
10586
  results,
@@ -10498,7 +10860,8 @@ function collectPendingEntries(contexts, cli) {
10498
10860
  typecheckByDirectory.set(packageDirectory, {
10499
10861
  ignoreSourceErrors: packageTypecheck.ignoreSourceErrors,
10500
10862
  pkg: ctx.info.name,
10501
- spawnTimeout: packageTypecheck.spawnTimeout
10863
+ spawnTimeout: packageTypecheck.spawnTimeout,
10864
+ timeout: ctx.pkgConfig.timeout
10502
10865
  });
10503
10866
  }
10504
10867
  }
@@ -10531,6 +10894,7 @@ async function runWorkspaceTypecheckPass(entries, typecheckByDirectory) {
10531
10894
  ignoreSourceErrors: policy.ignoreSourceErrors,
10532
10895
  rootDir: group.cwd,
10533
10896
  spawnTimeout: policy.spawnTimeout,
10897
+ timeout: policy.timeout,
10534
10898
  ...group.tsconfig !== void 0 ? { tsconfig: group.tsconfig } : {}
10535
10899
  }), policy.pkg);
10536
10900
  byPackage.set(policy.pkg, mergeResults$1(stamped, byPackage.get(policy.pkg)));
@@ -10545,12 +10909,9 @@ function attachTypecheck(results, pass, timing) {
10545
10909
  ...pass.result !== void 0 ? { typecheckResult: pass.result } : {}
10546
10910
  };
10547
10911
  }
10548
- async function writeWorkspaceOutputFile(outputFile, runtime, typecheck) {
10549
- await writeJsonFile$1(mergeResults$1(typecheck, runtime), outputFile);
10550
- }
10551
10912
  async function runTypecheckOnlyWorkspace(input) {
10552
10913
  const typecheckPass = await runWorkspaceTypecheckPass(input.typeTestEntries, input.typecheckByDirectory);
10553
- if (input.runOptions.outputFile !== void 0) await writeWorkspaceOutputFile(input.runOptions.outputFile, void 0, typecheckPass.outcome.result);
10914
+ await writeResultFile(input.runOptions.outputFile, typecheckPass.outcome.result, void 0);
10554
10915
  if (input.runOptions.workspaceOutputFile) writePerPackageOutputFiles(input.workspaceRoot, buildPerPackageResults([], [], typecheckPass.byPackage, input.typeTestProjects));
10555
10916
  return attachTypecheck([], typecheckPass.outcome, input.timing);
10556
10917
  }
@@ -10695,6 +11056,13 @@ function discoverWorkspaceRoot(cwd) {
10695
11056
  //#endregion
10696
11057
  //#region src/workspace/package-resolver.ts
10697
11058
  const JEST_CONFIG_MARKER$1 = /^jest\.config\.[^.]+$/;
11059
+ function readPackageJsonName(packageJsonPath) {
11060
+ if (!fs$1.existsSync(packageJsonPath)) return;
11061
+ const raw = parsePackageJson(packageJsonPath);
11062
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return;
11063
+ const nameValue = Reflect.get(raw, "name");
11064
+ return typeof nameValue === "string" ? nameValue : void 0;
11065
+ }
10698
11066
  /**
10699
11067
  * Enumerate workspace packages. With `patterns` (from `workspace.packages`),
10700
11068
  * resolve directories by globbing for a `jest.config.*` — works in any repo,
@@ -10719,13 +11087,6 @@ function parsePackageJson(packageJsonPath) {
10719
11087
  throw new Error(`Failed to parse ${packageJsonPath}.`, { cause: err });
10720
11088
  }
10721
11089
  }
10722
- function readPackageJsonName(packageJsonPath) {
10723
- if (!fs$1.existsSync(packageJsonPath)) return;
10724
- const raw = parsePackageJson(packageJsonPath);
10725
- if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return;
10726
- const nameValue = Reflect.get(raw, "name");
10727
- return typeof nameValue === "string" ? nameValue : void 0;
10728
- }
10729
11090
  function listPnpmPackages(workspaceRoot) {
10730
11091
  const yamlPath = path$1.join(workspaceRoot, "pnpm-workspace.yaml");
10731
11092
  if (!fs$1.existsSync(yamlPath)) throw new Error("Workspace mode requires either a `workspace.packages` glob list in your jest config or a pnpm-workspace.yaml at the workspace root. Use `workspace.packages` (with `--workspace-root` to run from outside a package) for Luau-only, npm, or yarn repos.");
@@ -10807,15 +11168,25 @@ function getAffectedPackages(workspaceRoot, ref) {
10807
11168
  "ls",
10808
11169
  `--filter=...[${ref}]`,
10809
11170
  "--output=json"
10810
- ], workspaceRoot)).filter((item) => hasJestConfig(path$1.join(workspaceRoot, item.relativePath))).map((item) => item.name);
11171
+ ], workspaceRoot)).filter((item) => hasJestConfig(path$1.join(workspaceRoot, item.relativePath))).map((item) => {
11172
+ return {
11173
+ name: item.name,
11174
+ packageDirectory: path$1.join(workspaceRoot, item.relativePath)
11175
+ };
11176
+ });
10811
11177
  if (fs$1.existsSync(path$1.join(workspaceRoot, "nx.json"))) return parseNxOutput(runTool("nx", [
10812
11178
  "show",
10813
11179
  "projects",
10814
11180
  "--affected",
10815
11181
  `--base=${ref}`,
10816
11182
  "--json"
10817
- ], workspaceRoot)).filter((name) => {
10818
- return hasJestConfig(path$1.join(workspaceRoot, nxProjectRoot(workspaceRoot, name)));
11183
+ ], workspaceRoot)).flatMap((nxName) => {
11184
+ const packageDirectory = path$1.join(workspaceRoot, nxProjectRoot(workspaceRoot, nxName));
11185
+ if (!hasJestConfig(packageDirectory)) return [];
11186
+ return [{
11187
+ name: readPackageJsonName(path$1.join(packageDirectory, "package.json")) ?? nxName,
11188
+ packageDirectory
11189
+ }];
10819
11190
  });
10820
11191
  throw new Error("--affected-since requires turbo or nx at the workspace root. Use --packages to specify packages explicitly.");
10821
11192
  }
@@ -10931,9 +11302,15 @@ function assertWorkspaceRunOptions(runOptions) {
10931
11302
  };
10932
11303
  return { ok: true };
10933
11304
  }
10934
- function resolveWorkspacePackageNames(cli, workspaceRoot) {
11305
+ /**
11306
+ * Resolve the affected/requested packages to full `PackageInfo`. The
11307
+ * `--affected-since` branch already carries directory + `package.json#name`
11308
+ * from turbo/nx, so it skips the `resolvePackage` round-trip; the `--packages`
11309
+ * branch resolves each comma-separated name against the workspace.
11310
+ */
11311
+ function resolveWorkspacePackages(cli, workspaceRoot, patterns) {
10935
11312
  if (cli.affectedSince !== void 0) return getAffectedPackages(workspaceRoot, cli.affectedSince);
10936
- return cli.packages.split(",").map((name) => name.trim()).filter((name) => name.length > 0);
11313
+ return cli.packages.split(",").map((name) => name.trim()).filter((name) => name.length > 0).map((name) => resolvePackage(workspaceRoot, name, patterns));
10937
11314
  }
10938
11315
  function buildWorkspaceCredentials(cli, runOptions) {
10939
11316
  return resolveCredentials({
@@ -11131,10 +11508,10 @@ function resolveEnumerationRoot(workspace) {
11131
11508
  function resolvePackages(cli, workspace) {
11132
11509
  try {
11133
11510
  const { patterns, workspaceRoot } = resolveEnumerationRoot(workspace);
11134
- const packageNames = resolveWorkspacePackageNames(cli, workspaceRoot);
11135
- if (packageNames.length === 0) return { noAffected: true };
11511
+ const packageInfos = resolveWorkspacePackages(cli, workspaceRoot, patterns);
11512
+ if (packageInfos.length === 0) return { noAffected: true };
11136
11513
  return {
11137
- packageInfos: packageNames.map((name) => resolvePackage(workspaceRoot, name, patterns)),
11514
+ packageInfos,
11138
11515
  workspaceRoot
11139
11516
  };
11140
11517
  } catch (err) {