@isentinel/jest-roblox 0.3.8 → 0.3.9

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.
@@ -36,7 +36,7 @@ import { once } from "node:events";
36
36
  import { parseJSONC, parseYAML } from "confbox";
37
37
  import { Visitor, parseSync } from "oxc-parser";
38
38
  //#region package.json
39
- var version = "0.3.8";
39
+ var version = "0.3.9";
40
40
  //#endregion
41
41
  //#region src/config/errors.ts
42
42
  var ConfigError = class extends Error {
@@ -438,7 +438,11 @@ function resolveConfig(config) {
438
438
  const { test, ...rest } = config;
439
439
  const definedRest = Object.fromEntries(Object.entries(rest).filter(([, value]) => value !== void 0));
440
440
  const definedTest = test === void 0 ? {} : Object.fromEntries(Object.entries(test).filter(([, value]) => value !== void 0));
441
- const resolved = Object.assign({}, DEFAULT_CONFIG, definedTest, definedRest);
441
+ const resolved = {
442
+ ...DEFAULT_CONFIG,
443
+ ...definedTest,
444
+ ...definedRest
445
+ };
442
446
  if (config.gameOutput === true) resolved.gameOutput = path$1.join(resolved.rootDir, "game-output.log");
443
447
  if (config.outputFile === true) resolved.outputFile = path$1.join(resolved.rootDir, "jest-output.log");
444
448
  return resolved;
@@ -820,7 +824,7 @@ function passthroughFileBranches(resources, fileCoverage, pendingBranches) {
820
824
  });
821
825
  if (locations.length === 0) continue;
822
826
  const firstLocation = locations[0];
823
- const lastLocation = locations[locations.length - 1];
827
+ const lastLocation = locations.at(-1);
824
828
  assert(firstLocation !== void 0 && lastLocation !== void 0, "Branch locations must not be empty after filtering");
825
829
  fileBranches.push({
826
830
  armHitCounts: entry.locations.map((_, index) => armHitCounts[index] ?? 0),
@@ -1023,7 +1027,7 @@ function mapFileBranches(resources, fileCoverage, pendingBranches) {
1023
1027
  pendingBranches.set(result.tsPath, fileBranches);
1024
1028
  }
1025
1029
  const firstLocation = result.locations[0];
1026
- const lastLocation = result.locations[result.locations.length - 1];
1030
+ const lastLocation = result.locations.at(-1);
1027
1031
  assert(firstLocation !== void 0 && lastLocation !== void 0, "Branch locations must not be empty after successful mapping");
1028
1032
  fileBranches.push({
1029
1033
  armHitCounts: entry.locations.map((_, index) => armHitCounts[index] ?? 0),
@@ -1530,19 +1534,22 @@ function loadRojoProject(projectPath) {
1530
1534
  tree
1531
1535
  };
1532
1536
  }
1537
+ const TrailingSlashPattern = /\/$/;
1533
1538
  function collectMounts(node, currentDataModelPath, classify) {
1534
1539
  const result = [];
1535
1540
  walk(node, currentDataModelPath, classify, result);
1536
1541
  return result;
1537
1542
  }
1538
1543
  function pruneAncestors(paths) {
1539
- return paths.filter((candidate) => !paths.some((other) => other !== candidate && candidate.startsWith(`${other}/`)));
1544
+ return paths.filter((candidate) => {
1545
+ return paths.every((other) => other === candidate || !candidate.startsWith(`${other}/`));
1546
+ });
1540
1547
  }
1541
1548
  function addDirectoryMount(node, dataModelPath, classify, result) {
1542
1549
  const rawPath = node.$path;
1543
1550
  if (typeof rawPath !== "string") return;
1544
1551
  if (rawPath.endsWith(".project.json")) return;
1545
- const fsPath = rawPath.replace(/\/$/, "");
1552
+ const fsPath = rawPath.replace(TrailingSlashPattern, "");
1546
1553
  if (classify(fsPath) === "directory") result.push({
1547
1554
  dataModelPath,
1548
1555
  fsPath
@@ -1559,10 +1566,11 @@ function walk(node, currentDataModelPath, classify, result) {
1559
1566
  walk(value, childDataModelPath, classify, result);
1560
1567
  }
1561
1568
  }
1569
+ const TRAILING_SLASH$1$1 = /\/$/;
1562
1570
  function matchNodePath(childNode, targetPath, childDataModelPath) {
1563
1571
  const nodePath = childNode.$path;
1564
1572
  if (typeof nodePath !== "string") return;
1565
- const normalizedNodePath = nodePath.replace(/\/$/, "");
1573
+ const normalizedNodePath = nodePath.replace(TRAILING_SLASH$1$1, "");
1566
1574
  if (normalizedNodePath === targetPath) return childDataModelPath;
1567
1575
  if (targetPath.startsWith(`${normalizedNodePath}/`)) return `${childDataModelPath}/${targetPath.slice(normalizedNodePath.length + 1)}`;
1568
1576
  }
@@ -1750,7 +1758,7 @@ var RojoResolver = class RojoResolver {
1750
1758
  const relativePath = path.relative(partition.fsPath, stripped);
1751
1759
  const relativeParts = relativePath === "" ? [] : relativePath.split(path.sep);
1752
1760
  if (ROJO_SCRIPT_EXTS.has(extension) && relativeParts.at(-1) === INIT_NAME) relativeParts.pop();
1753
- return partition.rbxPath.concat(relativeParts);
1761
+ return [...partition.rbxPath, ...relativeParts];
1754
1762
  }
1755
1763
  }
1756
1764
  getRbxTypeFromFilePath(filePath) {
@@ -1777,8 +1785,8 @@ var RojoResolver = class RojoResolver {
1777
1785
  rbxPath: partition.rbxPath.slice()
1778
1786
  };
1779
1787
  }),
1780
- walkedConfigFiles: Array.from(this.walkedConfigFilesInternal),
1781
- walkedDirs: Array.from(this.walkedDirectoriesInternal),
1788
+ walkedConfigFiles: [...this.walkedConfigFilesInternal],
1789
+ walkedDirs: [...this.walkedDirectoriesInternal],
1782
1790
  warnings: this.warnings.slice()
1783
1791
  };
1784
1792
  }
@@ -1870,7 +1878,8 @@ var RojoResolver = class RojoResolver {
1870
1878
  if (!doNotPush) this.rbxPath.push(name);
1871
1879
  if (tree.$path !== void 0) this.parsePath(path.resolve(basePath, typeof tree.$path === "string" ? tree.$path : tree.$path.optional));
1872
1880
  if (tree.$className === "DataModel") this.isGame = true;
1873
- for (const childName of Object.keys(tree).filter((value) => !value.startsWith("$"))) this.parseTree(basePath, childName, tree[childName]);
1881
+ const childNames = Object.keys(tree).filter((value) => !value.startsWith("$"));
1882
+ for (const childName of childNames) this.parseTree(basePath, childName, tree[childName]);
1874
1883
  if (!doNotPush) this.rbxPath.pop();
1875
1884
  }
1876
1885
  searchChildren(directory, directoryEntries) {
@@ -1914,6 +1923,91 @@ var RojoResolver = class RojoResolver {
1914
1923
  }
1915
1924
  };
1916
1925
  //#endregion
1926
+ //#region src/coverage-pipeline/raw-coverage.ts
1927
+ /**
1928
+ * Normalize a raw coverage table into typed {@link RawCoverageData}. The input
1929
+ * is the per-file hit table the coverage probes accumulate at runtime — the
1930
+ * `_G.__jest_roblox_cov` global, or the `_coverage` field of a run envelope —
1931
+ * keyed by the stable per-file join key (`fileKey`). Luau serializes the
1932
+ * `s`/`f` counters as 1-based arrays and `b` as an array of arrays; this
1933
+ * canonicalizes them to string-keyed records while leaving the fileKey verbatim
1934
+ * (it is the byte-identical join key the static maps are also keyed to). Returns
1935
+ * `undefined` when the input is not an object or carries no file with a
1936
+ * statement map.
1937
+ */
1938
+ function normalizeRawCoverage(coverage) {
1939
+ if (coverage === void 0 || coverage === null || typeof coverage !== "object") return;
1940
+ const record = {};
1941
+ for (const [key, value] of Object.entries(coverage)) {
1942
+ if (typeof value !== "object" || value === null || !("s" in value)) continue;
1943
+ const raw = value;
1944
+ const file = { s: normalizeHitCounts(raw.s) };
1945
+ if (raw.f !== void 0) file.f = normalizeHitCounts(raw.f);
1946
+ if (raw.b !== void 0) file.b = normalizeBranchCounts(raw.b);
1947
+ record[key] = file;
1948
+ }
1949
+ return Object.keys(record).length > 0 ? record : void 0;
1950
+ }
1951
+ /**
1952
+ * Extract raw coverage from a completed run's result envelope — the companion
1953
+ * seam for a run this CLI did not launch. Accepts the plugin's `jestOutput`
1954
+ * (a JSON string or an already-parsed object), or the bare `_G.__jest_roblox_cov`
1955
+ * table read straight off the run. When an object carries a `_coverage` field it
1956
+ * is used; otherwise the object is treated as the hit table itself. Returns
1957
+ * `undefined` for malformed JSON or an envelope with no coverage.
1958
+ *
1959
+ * A multi-project result (`{ entries: [{ jestOutput }, …] }`) carries one
1960
+ * envelope per project; parse each `entries[i].jestOutput` and combine with
1961
+ * `mergeRawCoverage`.
1962
+ */
1963
+ function parseCoverageEnvelope(output) {
1964
+ let parsed = output;
1965
+ if (typeof output === "string") try {
1966
+ parsed = JSON.parse(output);
1967
+ } catch {
1968
+ return;
1969
+ }
1970
+ if (parsed === null || typeof parsed !== "object") return;
1971
+ return normalizeRawCoverage("_coverage" in parsed ? parsed._coverage : parsed);
1972
+ }
1973
+ function coerceCount(value) {
1974
+ return typeof value === "number" ? value : 0;
1975
+ }
1976
+ function normalizeHitCounts(data) {
1977
+ if (Array.isArray(data)) {
1978
+ const result = {};
1979
+ for (const [index, element] of data.entries()) result[String(index + 1)] = coerceCount(element);
1980
+ return result;
1981
+ }
1982
+ if (typeof data === "object" && data !== null) {
1983
+ const result = {};
1984
+ for (const [key, value] of Object.entries(data)) result[key] = coerceCount(value);
1985
+ return result;
1986
+ }
1987
+ return {};
1988
+ }
1989
+ function coerceArms(value) {
1990
+ return Array.isArray(value) ? value.map(coerceCount) : [];
1991
+ }
1992
+ /**
1993
+ * Normalize branch hit counts from Luau's nested array format. Luau serializes
1994
+ * `__cov_b` as an array of arrays: `[[0,0,0], [0,0]]`. Convert the outer array
1995
+ * to a string-keyed Record with 1-based keys, coercing each arm.
1996
+ */
1997
+ function normalizeBranchCounts(data) {
1998
+ if (Array.isArray(data)) {
1999
+ const result = {};
2000
+ for (const [index, inner] of data.entries()) result[String(index + 1)] = coerceArms(inner);
2001
+ return result;
2002
+ }
2003
+ if (typeof data === "object" && data !== null) {
2004
+ const result = {};
2005
+ for (const [key, value] of Object.entries(data)) result[key] = coerceArms(value);
2006
+ return result;
2007
+ }
2008
+ return {};
2009
+ }
2010
+ //#endregion
1917
2011
  //#region src/reporter/parser.ts
1918
2012
  const TASK_SCRIPT_PREFIX = /^TaskScript:\d+:\s*/;
1919
2013
  var LuauScriptError = class extends Error {
@@ -1976,10 +2070,8 @@ function parseJestOutput(output) {
1976
2070
  }
1977
2071
  function countBraces(line) {
1978
2072
  let count = 0;
1979
- for (const character of line) {
1980
- if (character === "{") count++;
1981
- if (character === "}") count--;
1982
- }
2073
+ for (const character of line) if (character === "{") count++;
2074
+ else if (character === "}") count--;
1983
2075
  return count;
1984
2076
  }
1985
2077
  function isValidJson(text) {
@@ -2030,56 +2122,8 @@ function extractLuauTiming(parsed) {
2030
2122
  for (const [key, value] of Object.entries(timing)) if (typeof value === "number") record[key] = value;
2031
2123
  return Object.keys(record).length > 0 ? record : void 0;
2032
2124
  }
2033
- /**
2034
- * Luau 1-based integer-keyed tables serialize as JSON arrays.
2035
- * Convert arrays to string-keyed Records with 1-based keys to match cov-map format.
2036
- */
2037
- function normalizeHitCounts(data) {
2038
- if (Array.isArray(data)) {
2039
- const result = {};
2040
- let index = 0;
2041
- for (const element of data) {
2042
- result[String(index + 1)] = typeof element === "number" ? element : 0;
2043
- index++;
2044
- }
2045
- return result;
2046
- }
2047
- if (typeof data === "object" && data !== null) return data;
2048
- return {};
2049
- }
2050
- /**
2051
- * Normalize branch hit counts from Luau's nested array format.
2052
- * Luau serializes `__cov_b` as an array of arrays: `[[0,0,0], [0,0]]`.
2053
- * Convert outer array to string-keyed Record with 1-based keys,
2054
- * keeping inner arrays as-is.
2055
- */
2056
- function normalizeBranchCounts(data) {
2057
- if (Array.isArray(data)) {
2058
- const result = {};
2059
- let index = 0;
2060
- for (const inner of data) {
2061
- if (Array.isArray(inner)) result[String(index + 1)] = inner.map((value) => typeof value === "number" ? value : 0);
2062
- else result[String(index + 1)] = [];
2063
- index++;
2064
- }
2065
- return result;
2066
- }
2067
- if (typeof data === "object" && data !== null) return data;
2068
- return {};
2069
- }
2070
2125
  function extractCoverageData(parsed) {
2071
- const coverage = parsed["_coverage"];
2072
- if (coverage === void 0 || coverage === null || typeof coverage !== "object") return;
2073
- const record = {};
2074
- for (const [key, value] of Object.entries(coverage)) if (typeof value === "object" && value !== null && "s" in value) {
2075
- const raw = value;
2076
- record[key] = {
2077
- b: raw.b !== void 0 ? normalizeBranchCounts(raw.b) : void 0,
2078
- f: raw.f !== void 0 ? normalizeHitCounts(raw.f) : void 0,
2079
- s: normalizeHitCounts(raw.s)
2080
- };
2081
- }
2082
- return Object.keys(record).length > 0 ? record : void 0;
2126
+ return normalizeRawCoverage(parsed["_coverage"]);
2083
2127
  }
2084
2128
  function extractPerTestCoverage(parsed) {
2085
2129
  const raw = parsed["_perTestCoverage"];
@@ -2507,13 +2551,14 @@ function replacePrefix(filePath, from, to) {
2507
2551
  }
2508
2552
  //#endregion
2509
2553
  //#region src/source-mapper/column-finder.ts
2554
+ const EXPECT_CALL = /\bexpect\s*[.(]/;
2510
2555
  /**
2511
2556
  * Finds the column position of the failing matcher in an expect() call. Returns
2512
2557
  * 1-indexed column position, or undefined if no expect is found.
2513
2558
  */
2514
2559
  function findExpectationColumn(lineText) {
2515
2560
  if (!lineText) return;
2516
- const expectIndex = lineText.search(/\bexpect\s*[.(]/);
2561
+ const expectIndex = lineText.search(EXPECT_CALL);
2517
2562
  if (expectIndex === -1) return;
2518
2563
  const afterExpect = lineText.slice(expectIndex);
2519
2564
  const matcherRegex = /[.:]\s*([A-Za-z_$][\w$]*)\s*(?=\()/g;
@@ -2525,9 +2570,11 @@ function findExpectationColumn(lineText) {
2525
2570
  }
2526
2571
  //#endregion
2527
2572
  //#region src/source-mapper/path-resolver.ts
2573
+ const INIT_SEGMENT = /(^|\/)(init)(\.|\/)/;
2574
+ const LEADING_DOT_SLASH = /^\.\//;
2528
2575
  /** roblox-ts compiles index.ts → init.luau; reverse the rename for TS paths. */
2529
2576
  function luauInitToIndex(filePath) {
2530
- return filePath.replace(/(^|\/)(init)(\.|\/)/, "$1index$3");
2577
+ return filePath.replace(INIT_SEGMENT, "$1index$3");
2531
2578
  }
2532
2579
  function createPathResolver(rojoProject, config) {
2533
2580
  const rojoMappings = /* @__PURE__ */ new Map();
@@ -2542,14 +2589,14 @@ function createPathResolver(rojoProject, config) {
2542
2589
  }
2543
2590
  walkTree(rojoProject.tree, "");
2544
2591
  const tsconfigMappings = config?.mappings ?? [];
2545
- const sortedRojoMappings = [...rojoMappings.entries()].sort(([a], [b]) => b.length - a.length);
2592
+ const sortedRojoMappings = [...rojoMappings].sort(([a], [b]) => b.length - a.length);
2546
2593
  return { resolve(dataModelPath) {
2547
2594
  for (const [prefix, basePath] of sortedRojoMappings) {
2548
2595
  if (dataModelPath !== prefix && !dataModelPath.startsWith(`${prefix}.`)) continue;
2549
2596
  const result = `${basePath}/${convertToFilePath(dataModelPath.slice(prefix.length + 1))}`;
2550
2597
  const mapping = findMapping(result, tsconfigMappings);
2551
2598
  if (mapping !== void 0) return {
2552
- filePath: `${luauInitToIndex(replacePrefix(result, mapping.outDir, mapping.rootDir).replace(/^\.\//, ""))}.ts`,
2599
+ filePath: `${luauInitToIndex(replacePrefix(result, mapping.outDir, mapping.rootDir).replace(LEADING_DOT_SLASH, ""))}.ts`,
2553
2600
  mapping
2554
2601
  };
2555
2602
  return { filePath: findLuaFile(result) };
@@ -2624,6 +2671,8 @@ function getTraceMap(luauPath) {
2624
2671
  }
2625
2672
  //#endregion
2626
2673
  //#region src/source-mapper/index.ts
2674
+ const TS_EXTENSION = /\.ts$/;
2675
+ const LEADING_SLASH = /^\//;
2627
2676
  function createSourceMapper(config) {
2628
2677
  const pathResolver = createPathResolver(config.rojoProject, { mappings: config.mappings });
2629
2678
  function mapFrame(frame) {
@@ -2634,7 +2683,7 @@ function createSourceMapper(config) {
2634
2683
  mapped: void 0
2635
2684
  };
2636
2685
  const { filePath, mapping } = resolved;
2637
- const luauPath = replacePrefix(filePath, mapping.rootDir, mapping.outDir).replace(/\.ts$/, ".luau");
2686
+ const luauPath = replacePrefix(filePath, mapping.rootDir, mapping.outDir).replace(TS_EXTENSION, ".luau");
2638
2687
  const v3Result = mapFromSourceMap(luauPath, frame.line, frame.column);
2639
2688
  if (v3Result !== void 0 && v3Result.source !== null && v3Result.line !== null) {
2640
2689
  const mapDirectory = path$1.dirname(luauPath);
@@ -2715,7 +2764,7 @@ function createSourceMapper(config) {
2715
2764
  resolveTestFilePath
2716
2765
  };
2717
2766
  function resolveTestFilePath(testFilePath) {
2718
- const dataModelPath = testFilePath.replace(/^\//, "").replaceAll("/", ".");
2767
+ const dataModelPath = testFilePath.replace(LEADING_SLASH, "").replaceAll("/", ".");
2719
2768
  return pathResolver.resolve(dataModelPath)?.filePath;
2720
2769
  }
2721
2770
  }
@@ -2960,6 +3009,11 @@ function highlightTypeScript(source) {
2960
3009
  //#endregion
2961
3010
  //#region src/formatters/formatter.ts
2962
3011
  const DEFAULT_SLOW_TEST_THRESHOLD_MS = 300;
3012
+ const SNAPSHOT_HEADER = /^- Snapshot\s+- \d+/;
3013
+ const EXPECTED_VALUE = /Expected\b.*?:\s*(.+)/;
3014
+ const RECEIVED_VALUE = /Received\b.*?:\s*(.+)/;
3015
+ const ROBLOX_PATH_CHAIN = /^(?:[A-Za-z][\w.@-]*:\d+:\s*)+/;
3016
+ const SOURCE_LOCATION = /([^\s:]+\.(?:tsx?|luau?)):(\d+)(?::(\d+))?/;
2963
3017
  const EXEC_ERROR_HINTS = [[/loadstring\(\) is not available/, "loadstring() must be enabled for Jest to run. Add to your project.json:\n\n \"ServerScriptService\": {\n \"$properties\": {\n \"LoadStringEnabled\": true\n }\n }"]];
2964
3018
  function getExecErrorHint(message) {
2965
3019
  for (const [pattern, hint] of EXEC_ERROR_HINTS) if (pattern.test(message)) return hint;
@@ -2975,7 +3029,7 @@ function parseErrorMessage(message) {
2975
3029
  const lines = message.split("\n");
2976
3030
  const firstLine = lines[0];
2977
3031
  assert(firstLine !== void 0, "split always returns ≥1 element");
2978
- const snapshotHeaderIndex = lines.findIndex((line) => /^- Snapshot\s+- \d+/.test(line));
3032
+ const snapshotHeaderIndex = lines.findIndex((line) => SNAPSHOT_HEADER.test(line));
2979
3033
  if (snapshotHeaderIndex !== -1) {
2980
3034
  const diffLines = [];
2981
3035
  for (let index = snapshotHeaderIndex; index < lines.length; index++) {
@@ -2988,8 +3042,8 @@ function parseErrorMessage(message) {
2988
3042
  snapshotDiff: diffLines.join("\n").trimEnd()
2989
3043
  };
2990
3044
  }
2991
- const expectedMatch = message.match(/Expected\b.*?:\s*(.+)/);
2992
- const receivedMatch = message.match(/Received\b.*?:\s*(.+)/);
3045
+ const expectedMatch = message.match(EXPECTED_VALUE);
3046
+ const receivedMatch = message.match(RECEIVED_VALUE);
2993
3047
  return {
2994
3048
  expected: expectedMatch?.[1],
2995
3049
  message: firstLine,
@@ -3018,7 +3072,7 @@ function cleanExecErrorMessage(raw) {
3018
3072
  }
3019
3073
  }
3020
3074
  if (contentLine === void 0) return raw.trim();
3021
- return contentLine.replace(/^(?:[A-Za-z][\w.@-]*:\d+:\s*)+/, "");
3075
+ return contentLine.replace(ROBLOX_PATH_CHAIN, "");
3022
3076
  }
3023
3077
  function formatSourceSnippet(snippet, filePath, options) {
3024
3078
  const useColor = options?.useColor ?? true;
@@ -3047,7 +3101,7 @@ function formatSourceSnippet(snippet, filePath, options) {
3047
3101
  return lines.join("\n");
3048
3102
  }
3049
3103
  function parseSourceLocation(message) {
3050
- const match = message.match(/([^\s:]+\.(?:tsx?|luau?)):(\d+)(?::(\d+))?/);
3104
+ const match = message.match(SOURCE_LOCATION);
3051
3105
  if (match === null) return;
3052
3106
  const [, filePath, lineStr, columnStr] = match;
3053
3107
  assert(filePath !== void 0, "regex group 1 matched");
@@ -3181,15 +3235,20 @@ function formatTypecheckSummary(result, useColor = true) {
3181
3235
  parts.push(`\n${label} ${failedPart}${passedPart}, ${String(total)} total\n`);
3182
3236
  return parts.join("\n");
3183
3237
  }
3184
- function formatTypecheckFailures(result, useColor = true) {
3185
- const styles = createStyles(useColor);
3238
+ function formatTypecheckFileFailures(file, styles) {
3186
3239
  const lines = [];
3187
- for (const file of result.testResults) for (const test of file.testResults) {
3240
+ for (const test of file.testResults) {
3188
3241
  if (test.status !== "failed") continue;
3189
3242
  const badge = styles.failBadge(" FAIL ");
3190
3243
  lines.push(` ${badge} ${styles.status.fail(test.fullName)}`);
3191
3244
  for (const message of test.failureMessages) lines.push(` ${styles.dim(message)}`);
3192
3245
  }
3246
+ return lines;
3247
+ }
3248
+ function formatTypecheckFailures(result, useColor = true) {
3249
+ const styles = createStyles(useColor);
3250
+ const lines = [];
3251
+ for (const file of result.testResults) lines.push(...formatTypecheckFileFailures(file, styles));
3193
3252
  return lines.join("\n");
3194
3253
  }
3195
3254
  const PROJECT_BADGE_COLORS = [
@@ -3433,7 +3492,8 @@ function computeProjectStats(result) {
3433
3492
  function formatFileFailures(file, options, styles, failureCtx) {
3434
3493
  const lines = [];
3435
3494
  const displayPath = resolveDisplayPath(file.testFilePath, options.sourceMapper);
3436
- for (const testCase of file.testResults) if (testCase.status === "failed") {
3495
+ for (const testCase of file.testResults) {
3496
+ if (testCase.status !== "failed") continue;
3437
3497
  const index = failureCtx.currentIndex;
3438
3498
  failureCtx.currentIndex++;
3439
3499
  lines.push(formatFailure({
@@ -3844,7 +3904,8 @@ function formatFileHeaderFailures(file, options) {
3844
3904
  const totalTests = file.numFailingTests + file.numPassingTests + file.numPendingTests;
3845
3905
  const testWord = totalTests === 1 ? "test" : "tests";
3846
3906
  lines.push(` ❯ ${relativePath} (${totalTests} ${testWord} | ${file.numFailingTests} failed)`);
3847
- for (const test of file.testResults) if (test.status === "failed") {
3907
+ for (const test of file.testResults) {
3908
+ if (test.status !== "failed") continue;
3848
3909
  const duration = test.duration !== void 0 ? ` ${String(test.duration)}ms` : "";
3849
3910
  lines.push(` × ${test.title}${duration}`);
3850
3911
  }
@@ -4211,7 +4272,8 @@ function createTimingCollector(options = {}) {
4211
4272
  node.elapsedMs += elapsedMs;
4212
4273
  }
4213
4274
  function emit(node, depth) {
4214
- sink(`[TIMING] ${" ".repeat(depth)}${node.name}: ${String(Math.round(node.elapsedMs))}ms`);
4275
+ const indent = " ".repeat(depth);
4276
+ sink(`[TIMING] ${indent}${node.name}: ${String(Math.round(node.elapsedMs))}ms`);
4215
4277
  for (const child of node.children.values()) emit(child, depth + 1);
4216
4278
  }
4217
4279
  function flushTimingReport() {
@@ -4310,16 +4372,18 @@ function writeJsonFile(filePath, value) {
4310
4372
  }
4311
4373
  //#endregion
4312
4374
  //#region src/executor.ts
4375
+ const TS_SOURCE_EXTENSION$1 = /\.tsx?$/;
4376
+ const TSCONFIG_FILENAME = /^tsconfig.*\.json$/i;
4313
4377
  function isLuauProject(testFiles, tsconfigMappings) {
4314
4378
  if (tsconfigMappings.length > 0) return false;
4315
- if (testFiles.some((file) => /\.tsx?$/.test(file))) return false;
4379
+ if (testFiles.some((file) => TS_SOURCE_EXTENSION$1.test(file))) return false;
4316
4380
  return true;
4317
4381
  }
4318
4382
  function resolveAllTsconfigMappings(projectRoot) {
4319
4383
  const resolvedRoot = path$1.resolve(projectRoot);
4320
4384
  let files;
4321
4385
  try {
4322
- files = fs$1.readdirSync(resolvedRoot).filter((file) => /^tsconfig.*\.json$/i.test(file));
4386
+ files = fs$1.readdirSync(resolvedRoot).filter((file) => TSCONFIG_FILENAME.test(file));
4323
4387
  } catch {
4324
4388
  return [];
4325
4389
  }
@@ -4785,6 +4849,7 @@ function buildProjectJob(parameters, timing) {
4785
4849
  //#endregion
4786
4850
  //#region src/formatters/github-actions.ts
4787
4851
  const SEPARATOR = " · ";
4852
+ const TRAILING_SLASH$4 = /\/$/;
4788
4853
  function escapeData(value) {
4789
4854
  return value.replace(/%/g, "%25").replace(/\r/g, "%0D").replace(/\n/g, "%0A");
4790
4855
  }
@@ -4826,13 +4891,7 @@ function formatJobSummary(result, options) {
4826
4891
  });
4827
4892
  continue;
4828
4893
  }
4829
- for (const test of file.testResults) {
4830
- if (test.status !== "failed") continue;
4831
- failures.push({
4832
- file: makeRelative(file.testFilePath, options.workspace),
4833
- title: test.fullName
4834
- });
4835
- }
4894
+ failures.push(...collectFileFailures(file, options));
4836
4895
  }
4837
4896
  if (failures.length > 0) {
4838
4897
  lines.push("### Failures\n");
@@ -4857,7 +4916,7 @@ function resolveGitHubActionsOptions(userOptions, sourceMapper, environment = pr
4857
4916
  function makeRelative(filePath, workspace) {
4858
4917
  if (workspace === void 0) return filePath;
4859
4918
  const normalized = normalizeWindowsPath(filePath);
4860
- const normalizedWorkspace = normalizeWindowsPath(workspace).replace(/\/$/, "");
4919
+ const normalizedWorkspace = normalizeWindowsPath(workspace).replace(TRAILING_SLASH$4, "");
4861
4920
  if (normalized.startsWith(`${normalizedWorkspace}/`)) return normalized.slice(normalizedWorkspace.length + 1);
4862
4921
  return filePath;
4863
4922
  }
@@ -4895,6 +4954,17 @@ function collectTestFailureAnnotations(annotations, file, options) {
4895
4954
  });
4896
4955
  }
4897
4956
  }
4957
+ function collectFileFailures(file, options) {
4958
+ const failures = [];
4959
+ for (const test of file.testResults) {
4960
+ if (test.status !== "failed") continue;
4961
+ failures.push({
4962
+ file: makeRelative(file.testFilePath, options.workspace),
4963
+ title: test.fullName
4964
+ });
4965
+ }
4966
+ return failures;
4967
+ }
4898
4968
  function noun(count, singular, plural) {
4899
4969
  return count === 1 ? singular : plural;
4900
4970
  }
@@ -4961,11 +5031,12 @@ async function outputSingleResult(config, result) {
4961
5031
  coverageEnabled: config.collectCoverage,
4962
5032
  printResults: () => {
4963
5033
  if (config.silent) return;
5034
+ const timing = runtimeResult !== void 0 ? addCoverageTiming(runtimeResult.timing, preCoverageMs) : void 0;
4964
5035
  printFormattedOutput({
4965
5036
  config,
4966
5037
  mergedResult,
4967
5038
  runtimeResult,
4968
- timing: runtimeResult !== void 0 ? addCoverageTiming(runtimeResult.timing, preCoverageMs) : void 0,
5039
+ timing,
4969
5040
  typecheckResult
4970
5041
  });
4971
5042
  },
@@ -5488,6 +5559,7 @@ function redirectPathToShadow(target, coverageRoots) {
5488
5559
  }
5489
5560
  //#endregion
5490
5561
  //#region src/staging/synthesizer.ts
5562
+ const TRAILING_SLASH$3 = /\/$/;
5491
5563
  const STUB_INJECTION_KEY = "jest.config";
5492
5564
  const COLLIDING_SOURCE_FILES = ["jest.config.lua", "jest.config.luau"];
5493
5565
  const SERVICE_CLASSES = /* @__PURE__ */ new Set([
@@ -5565,7 +5637,7 @@ function resolveDollarPath(value, treeBase, coverageBase, coverageRoots) {
5565
5637
  if (coverageRoots === void 0) return absoluteTarget;
5566
5638
  return redirectPathToShadow(absoluteTarget, coverageRoots.map((root) => {
5567
5639
  return {
5568
- luauRoot: normalizeWindowsPath(path$1.resolve(coverageBase, root.luauRoot)).replace(/\/$/, ""),
5640
+ luauRoot: normalizeWindowsPath(path$1.resolve(coverageBase, root.luauRoot)).replace(TRAILING_SLASH$3, ""),
5569
5641
  shadowDir: normalizeWindowsPath(root.shadowDir)
5570
5642
  };
5571
5643
  })) ?? absoluteTarget;
@@ -6124,7 +6196,8 @@ function collectCoverage(root) {
6124
6196
  return true;
6125
6197
  },
6126
6198
  visitStatBlock(block) {
6127
- for (const stmt of block.statements) if (INSTRUMENTABLE_STATEMENT_TAGS.has(stmt.tag)) {
6199
+ for (const stmt of block.statements) {
6200
+ if (!INSTRUMENTABLE_STATEMENT_TAGS.has(stmt.tag)) continue;
6128
6201
  statements.push({
6129
6202
  index: statementIndex,
6130
6203
  location: { ...stmt.location }
@@ -6168,16 +6241,14 @@ function collectCoverage(root) {
6168
6241
  location: { ...elseif.thenBlock.location }
6169
6242
  });
6170
6243
  }
6171
- const hasExplicitElse = elseBlock !== void 0 && elseBlock.statements.length > 0;
6172
- if (hasExplicitElse) {
6244
+ if (elseBlock !== void 0 && elseBlock.statements.length > 0) {
6173
6245
  const elseFirst = getBodyFirstStatement(elseBlock);
6174
6246
  branch.arms.push({
6175
6247
  bodyFirstColumn: elseFirst.column,
6176
6248
  bodyFirstLine: elseFirst.line,
6177
6249
  location: { ...elseBlock.location }
6178
6250
  });
6179
- }
6180
- if (!hasExplicitElse) {
6251
+ } else {
6181
6252
  branch.arms.push({
6182
6253
  bodyFirstColumn: 0,
6183
6254
  bodyFirstLine: 0,
@@ -6300,6 +6371,9 @@ const KIND_RANK = {
6300
6371
  open: 0,
6301
6372
  point: 1
6302
6373
  };
6374
+ const TRAILING_WHITESPACE = /\s$/;
6375
+ const IDENTIFIER_START = /^[a-zA-Z_]/;
6376
+ const MODE_DIRECTIVE = /^--![a-z]+/;
6303
6377
  function insertProbes(source, result, fileKey) {
6304
6378
  const lines = splitLines(source);
6305
6379
  applyProbes(lines, collectProbes(result));
@@ -6370,11 +6444,11 @@ function applyProbes(mutableLines, probes) {
6370
6444
  assert(line !== void 0, `Invalid probe line number: ${probeLine}`);
6371
6445
  const before = line.slice(0, column - 1);
6372
6446
  const after = line.slice(column - 1);
6373
- mutableLines[lineIndex] = before + (before.length > 0 && !/\s$/.test(before) && /^[a-zA-Z_]/.test(text) ? " " : "") + text + after;
6447
+ mutableLines[lineIndex] = before + (before.length > 0 && !TRAILING_WHITESPACE.test(before) && IDENTIFIER_START.test(text) ? " " : "") + text + after;
6374
6448
  }
6375
6449
  }
6376
6450
  function extractModeDirective(lines) {
6377
- if (lines.length > 0 && lines[0] !== void 0 && /^--![a-z]+/.test(lines[0])) {
6451
+ if (lines.length > 0 && lines[0] !== void 0 && MODE_DIRECTIVE.test(lines[0])) {
6378
6452
  const directive = `${lines[0]}\n`;
6379
6453
  lines.splice(0, 1);
6380
6454
  return directive;
@@ -6426,6 +6500,7 @@ function buildPreamble(modeDirective, fileKey, result) {
6426
6500
  }
6427
6501
  //#endregion
6428
6502
  //#region src/coverage-pipeline/instrumenter.ts
6503
+ const LUAU_EXTENSION = /\.luau?$/;
6429
6504
  let cachedTemporaryDirectory$1;
6430
6505
  /**
6431
6506
  * Instrument a single luauRoot directory. Returns the files map without
@@ -6470,7 +6545,7 @@ function instrumentRoot(options) {
6470
6545
  } catch (err) {
6471
6546
  throw new Error("Failed to parse file list from lute", { cause: err });
6472
6547
  }
6473
- if (!Array.isArray(parsed) || !parsed.every((entry) => typeof entry === "string")) throw new Error("Expected file list array from lute");
6548
+ if (!isStringArray(parsed)) throw new Error("Expected file list array from lute");
6474
6549
  const fileList = parsed;
6475
6550
  const files = {};
6476
6551
  const posixLuauRoot = normalizeWindowsPath(luauRoot);
@@ -6487,7 +6562,7 @@ function instrumentRoot(options) {
6487
6562
  const fileKey = normalizeWindowsPath(path$1.join(posixLuauRoot, relativePath));
6488
6563
  const originalLuauPath = fileKey;
6489
6564
  const instrumentedLuauPath = normalizeWindowsPath(path$1.join(shadowDir, relativePath));
6490
- const coverageMapOutputPath = path$1.join(shadowDir, relativePath.replace(/\.luau?$/, ".cov-map.json"));
6565
+ const coverageMapOutputPath = path$1.join(shadowDir, relativePath.replace(LUAU_EXTENSION, ".cov-map.json"));
6491
6566
  const sourceMapPath = `${originalLuauPath}.map`;
6492
6567
  const outputDirectory = path$1.dirname(path$1.join(shadowDir, relativePath));
6493
6568
  fs$1.mkdirSync(outputDirectory, { recursive: true });
@@ -6514,6 +6589,9 @@ function instrumentRoot(options) {
6514
6589
  }
6515
6590
  return files;
6516
6591
  }
6592
+ function isStringArray(value) {
6593
+ return Array.isArray(value) && value.every((entry) => typeof entry === "string");
6594
+ }
6517
6595
  function shouldSkipFile(relativePath, skipFiles) {
6518
6596
  if (relativePath.endsWith(".snap.luau") || relativePath.endsWith(".snap.lua")) return true;
6519
6597
  return skipFiles?.has(relativePath) === true;
@@ -6587,7 +6665,8 @@ function walkDirectory$1(directory, visitedDirectories, files) {
6587
6665
  const real = toKey(fs$1.realpathSync(directory));
6588
6666
  if (visitedDirectories.has(real)) return;
6589
6667
  visitedDirectories.add(real);
6590
- for (const entry of fs$1.readdirSync(directory, { withFileTypes: true })) {
6668
+ const entries = fs$1.readdirSync(directory, { withFileTypes: true });
6669
+ for (const entry of entries) {
6591
6670
  if (entry.name.startsWith(".")) continue;
6592
6671
  collectInputFiles(path$1.join(directory, entry.name), visitedDirectories, files);
6593
6672
  }
@@ -7256,18 +7335,18 @@ const DEFAULT_BACKOFF_MS = 5e3;
7256
7335
  */
7257
7336
  async function runTaskPool(options) {
7258
7337
  const { concurrency, isDone, onError, onResult, places } = options;
7338
+ if (concurrency < 1) throw new Error(`runTaskPool concurrency must be >= 1, got ${String(concurrency)}`);
7339
+ if (places.length === 0) throw new Error("runTaskPool requires at least one place");
7259
7340
  const now = options.now ?? Date.now;
7260
7341
  const sleep = options.sleep ?? setTimeout$1;
7261
- const warn = options.warn ?? ((message) => {
7342
+ const allocations = distributeSlots(places, concurrency, options.warn ?? ((message) => {
7262
7343
  console.warn(message);
7263
- });
7264
- if (concurrency < 1) throw new Error(`runTaskPool concurrency must be >= 1, got ${String(concurrency)}`);
7265
- if (places.length === 0) throw new Error("runTaskPool requires at least one place");
7266
- const allocations = distributeSlots(places, concurrency, warn);
7344
+ }));
7267
7345
  async function backoff(state, retryAfterMs) {
7268
7346
  const genuineMs = retryAfterMs !== void 0 && retryAfterMs > 0 ? retryAfterMs : DEFAULT_BACKOFF_MS;
7269
7347
  const sinceCompletion = now() - state.lastCompletionMs;
7270
- await sleep(sinceCompletion < RECYCLE_LAG_MS ? RECYCLE_LAG_MS - sinceCompletion : genuineMs);
7348
+ const waitMs = sinceCompletion < RECYCLE_LAG_MS ? RECYCLE_LAG_MS - sinceCompletion : genuineMs;
7349
+ await sleep(waitMs);
7271
7350
  }
7272
7351
  async function worker(state) {
7273
7352
  while (!isDone()) {
@@ -7426,6 +7505,7 @@ function isShardedParallel(parallel) {
7426
7505
  var test_runner_bundled_default = "local __WELD_MODULES = {\n cache = {} :: any,\n}\n\ndo\n --!strict\n local b_ReplicatedStorage = game:GetService(\"ReplicatedStorage\")\n \n local b_module = {}\n \n function b_module.findInstance(path: string): Instance\n local parts = string.split(path, \"/\")\n \n local success, current = pcall(function()\n return game:FindService(parts[1])\n end)\n assert(success, `Failed to find service {parts[1]}: {current}`)\n \n for i = 2, #parts do\n assert(current, `Failed to find '{parts[i - 1]}' in path {path}`)\n current = current:FindFirstChild(parts[i])\n end\n \n assert(current, `Failed to find instance at path {path}`)\n \n return current\n end\n \n function b_module.getJest(config: { jestPath: string? }): ModuleScript\n local jestPath = config.jestPath\n if jestPath then\n local instance = b_module.findInstance(jestPath)\n assert(instance, `Failed to find Jest instance at path {jestPath}`)\n assert(instance:IsA(\"ModuleScript\"), `Instance at path {jestPath} is not a ModuleScript`)\n return instance :: ModuleScript\n end\n \n local jestInstance = b_ReplicatedStorage:FindFirstChild(\"Jest\", true)\n assert(jestInstance, \"Failed to find Jest instance in ReplicatedStorage\")\n assert(jestInstance:IsA(\"ModuleScript\"), \"Jest instance in ReplicatedStorage is not a ModuleScript\")\n return jestInstance\n end\n \n function b_module.findRobloxShared(jestModule: ModuleScript): Instance?\n local parent = jestModule.Parent\n if parent then\n local found = parent:FindFirstChild(\"RobloxShared\") or parent:FindFirstChild(\"jest-roblox-shared\")\n if found then\n return found\n end\n \n if parent.Parent then\n found = parent.Parent:FindFirstChild(\"RobloxShared\") or parent.Parent:FindFirstChild(\"jest-roblox-shared\")\n if found then\n return found\n end\n end\n end\n \n return game:FindFirstChild(\"RobloxShared\", true)\n end\n \n function b_module.findSiblingPackage(jestModule: ModuleScript, ...: string): Instance?\n local parent = jestModule.Parent\n if parent then\n for _, name in { ... } do\n local found = parent:FindFirstChild(name)\n if found then\n return found\n end\n end\n \n if parent.Parent then\n for _, name in { ... } do\n local found = parent.Parent:FindFirstChild(name)\n if found then\n return found\n end\n end\n end\n end\n \n for _, name in { ... } do\n local found = game:FindFirstChild(name, true)\n if found then\n return found\n end\n end\n \n return nil\n end\n \n local _b = b_module\n \n --!strict\n -- Patches a Writeable's `_writeFn` to push every write into `buffer` (tagged\n -- with `messageType` and a monotonic timestamp) before delegating to the\n -- original. Returns a restore thunk; callers must invoke it to undo the patch,\n -- otherwise the buffer leaks across runs and the original write side-effect\n -- stays hidden behind our tap.\n --\n -- Used by both runner.luau (single-pkg) and staging/entry.luau (workspace) to\n -- capture per-task stdout/stderr around Jest.runCLI so failure messages like\n -- \"No tests found, exiting with code 1\" can be surfaced in the CLI banner.\n \n local i_module = {}\n \n export type CapturedMessage = { message: string, messageType: number, timestamp: number }\n \n function i_module.intercept(\n writeable: any,\n buffer: { CapturedMessage },\n messageType: number\n ): () -> ()\n local original = writeable._writeFn\n if typeof(original) ~= \"function\" then\n return function() end\n end\n \n writeable._writeFn = function(data: string)\n table.insert(buffer, {\n message = data,\n messageType = messageType,\n timestamp = os.clock(),\n })\n original(data)\n end\n \n return function()\n writeable._writeFn = original\n end\n end\n \n local _i = i_module\n \n --!strict\n -- Per-test coverage attribution: pure snapshot-and-diff over the runtime hit\n -- table (`_G.__jest_roblox_cov`). The runner snapshots before each Jest test\n -- case and diffs after, attributing the newly-covered statements to that test.\n --\n -- Coverage-primitive-agnostic: the input is whatever accumulated hit table the\n -- collector exposes (Lute probes today, native ScriptContext stats later), so\n -- the diff is identical regardless of how the counts were produced.\n \n -- `s` is the per-statement hit-count map, keyed by statement id. The single\n -- letter is the wire key the coverage probes emit (`__cov_s`, serialized as `s`\n -- under `_G.__jest_roblox_cov[file]`), matching `RawFileCoverage` on the\n -- TypeScript side.\n type FileHitCounts = { s: { number } }\n type HitTableByFile = { [string]: FileHitCounts }\n \n -- A test's delta: the ids of the statements it newly covered, per file. `s`\n -- again mirrors the serialized envelope the TypeScript parser validates.\n type FileStatementDelta = { s: { number } }\n type CoverageDeltaByFile = { [string]: FileStatementDelta }\n \n local d_module = {}\n \n -- Copy the statement hit counts so a later mutation of the live table does not\n -- alias the snapshot. Probes only ever increment, so the copied counts are the\n -- pre-test baseline the diff subtracts against.\n function d_module.snapshot(coverage: HitTableByFile): HitTableByFile\n local baseline: HitTableByFile = {}\n for fileKey, fileHitCounts in coverage do\n local statementCounts: { number } = {}\n for statementId, hitCount in fileHitCounts.s do\n statementCounts[statementId] = hitCount\n end\n \n baseline[fileKey] = { s = statementCounts }\n end\n \n return baseline\n end\n \n -- Statements whose count rose between `before` and `after` were executed during\n -- the test, so they are attributed to it. Files with no newly-covered statement\n -- are omitted.\n function d_module.diff(before: HitTableByFile, after: HitTableByFile): CoverageDeltaByFile\n local delta: CoverageDeltaByFile = {}\n for fileKey, afterCounts in after do\n local beforeCounts = before[fileKey]\n local coveredStatementIds: { number } = {}\n \n for statementId, afterHitCount in afterCounts.s do\n local beforeHitCount = if beforeCounts then beforeCounts.s[statementId] or 0 else 0\n if afterHitCount > beforeHitCount then\n table.insert(coveredStatementIds, statementId)\n end\n end\n \n if #coveredStatementIds > 0 then\n delta[fileKey] = { s = coveredStatementIds }\n end\n end\n \n return delta\n end\n \n local _d = d_module\n \n --!strict\n -- Per-test coverage hook. Installs a single jest-circus event handler that\n -- snapshots the runtime hit table on `test_start` and diffs it on `test_done`,\n -- attributing the newly-covered statements to the test that just ran. The test's\n -- identity (file + full name) is read from the Expect matcher state, which jest\n -- sets per-file (`testPath`) and per-test (`currentTestName`).\n --\n -- `addEventHandler`, `getMatcherState`, and `getCoverage` are injected so the\n -- orchestration is testable under lute without a real circus or Roblox\n -- DataModel. The runner passes `_G.__jest_roblox_cov` as the coverage source.\n \n local h_Attribution = _d\n \n type MatcherState = { currentTestName: string?, testPath: Instance? }\n type Record = { delta: any, testCaseId: string, testFilePath: string }\n \n local h_module = {}\n \n -- Service-rooted DataModel path (the `game` root, whose parent is nil, is\n -- excluded), slash-joined to match the form jest-roblox-cli's source mapper\n -- resolves.\n local function h_dataModelPath(instance: Instance): string\n local parts: { string } = {}\n local current: Instance? = instance\n while current and current.Parent ~= nil do\n table.insert(parts, 1, current.Name)\n current = current.Parent\n end\n \n return table.concat(parts, \"/\")\n end\n \n function h_module.install(\n addEventHandler: (handler: any) -> (),\n getMatcherState: () -> MatcherState,\n getCoverage: () -> any\n ): { Record }\n local records: { Record } = {}\n local before: any = nil\n \n -- Circus does not guard event handlers (a throw rejects the run's dispatch\n -- chain), so the whole body is pcall-wrapped: attribution is best-effort and\n -- must never break the coverage run it observes.\n addEventHandler(function(_self: any, event: { name: string })\n pcall(function()\n if event.name == \"test_start\" then\n before = h_Attribution.snapshot(getCoverage())\n elseif event.name == \"test_done\" then\n if before == nil then\n return\n end\n \n local delta = h_Attribution.diff(before, getCoverage())\n before = nil\n if next(delta) == nil then\n return\n end\n \n local state = getMatcherState()\n table.insert(records, {\n delta = delta,\n testCaseId = state.currentTestName or \"\",\n testFilePath = if state.testPath then dataModelPath(state.testPath) else \"\",\n })\n end\n end)\n end)\n \n return records\n end\n \n local _h = h_module\n \n --!strict\n -- Mirrors @rbxts-js/roblox-shared/src/normalizePromiseError.lua: when Jest's\n -- internal Promise chain rejects (e.g. process.exit fires via nodeUtils:25),\n -- the rejection value is a Promise.Error table whose `.error` field at every\n -- non-leaf level is `tostring(parent)` — the upstream Promise.Error\n -- __tostring blob. Walking to the deepest parent recovers the original cause\n -- string (\"Exited with code: 1\") instead of a multi-frame trace. Depth-capped\n -- to defend against future cycles (a Promise.Error pointing at itself would\n -- otherwise hang the test task until the infinite-yield watchdog fires).\n \n local g_module = {}\n \n local g_MAX_PROMISE_ERROR_DEPTH = 64\n \n -- Single-line Luau path:line prefix, e.g. `Module.Path:25: <msg>`. We strip\n -- this so the CLI banner's exit-code branch (^Exited with code: \\d+$) can\n -- match the surfaced message and demote the transport line to a footer.\n local g_PATH_LINE_PREFIX = \"^[%w_%.@%-/]+:%d+:%s*\"\n \n local function g_stripPathLinePrefix(message: string): string\n -- Multi-line strings keep their full content — only strip when the\n -- entire string is the single `<path>:<line>: <msg>` shape so we don't\n -- swallow useful context from layered messages.\n if string.find(message, \"\\n\", 1, true) ~= nil then\n return message\n end\n \n local stripped, replacements = string.gsub(message, g_PATH_LINE_PREFIX, \"\", 1)\n if replacements > 0 then\n return stripped\n end\n \n return message\n end\n \n function g_module.normalize(err: any): string\n if type(err) ~= \"table\" then\n return g_stripPathLinePrefix(tostring(err))\n end\n \n local current: any = err\n for _ = 1, g_MAX_PROMISE_ERROR_DEPTH do\n if type(current.parent) ~= \"table\" then\n break\n end\n \n current = current.parent\n end\n \n if type(current.error) == \"string\" then\n return g_stripPathLinePrefix(current.error)\n end\n \n return g_stripPathLinePrefix(tostring(err))\n end\n \n local _g = g_module\n \n --!strict\n local f_InstanceResolver = _b\n \n local f_module = {}\n \n export type PatchState = {\n Runtime: any,\n originalRequireModule: any,\n accumulatedSeconds: { value: number },\n }\n \n function f_module.patch(\n jestModule: ModuleScript,\n setupFiles: { Instance }?,\n setupFilesAfterEnv: { Instance }?\n ): PatchState?\n local setupSet: { [Instance]: boolean } = {}\n \n if setupFiles then\n for _, inst in setupFiles do\n setupSet[inst] = true\n end\n end\n \n if setupFilesAfterEnv then\n for _, inst in setupFilesAfterEnv do\n setupSet[inst] = true\n end\n end\n \n if not next(setupSet) then\n return nil\n end\n \n local jestRuntimeModule = f_InstanceResolver.findSiblingPackage(jestModule, \"JestRuntime\", \"jest-runtime\")\n if not jestRuntimeModule then\n warn(\"Could not find JestRuntime; setup timing unavailable\")\n return nil\n end\n \n local Runtime = (require :: any)(jestRuntimeModule)\n local originalRequireModule = Runtime.requireModule\n local accumulated = { value = 0 }\n local insideSetupRequire = false\n \n Runtime.requireModule = function(self: any, moduleName: any, ...): any\n if not insideSetupRequire and typeof(moduleName) == \"Instance\" and setupSet[moduleName] then\n insideSetupRequire = true\n local t0 = os.clock()\n local results = table.pack(pcall(originalRequireModule, self, moduleName, ...))\n accumulated.value += os.clock() - t0\n insideSetupRequire = false\n \n if not results[1] then\n error(results[2], 0)\n end\n \n return table.unpack(results, 2, results.n)\n end\n \n return originalRequireModule(self, moduleName, ...)\n end\n \n return {\n Runtime = Runtime,\n originalRequireModule = originalRequireModule,\n accumulatedSeconds = accumulated,\n }\n end\n \n function f_module.unpatch(state: PatchState?)\n if not state then\n return\n end\n \n if state.Runtime and state.originalRequireModule then\n state.Runtime.requireModule = state.originalRequireModule\n end\n end\n \n function f_module.getSeconds(state: PatchState?): number\n if not state then\n return 0\n end\n \n return state.accumulatedSeconds.value\n end\n \n local _f = f_module\n \n --!strict\n local function c_getInstancePath(instance: Instance): string\n local parts = {} :: { string }\n local current: Instance? = instance\n while current and current ~= game do\n table.insert(parts, 1, current.Name)\n current = current.Parent\n end\n \n return table.concat(parts, \"/\")\n end\n \n local c_CoreScriptSyncService = {}\n \n function c_CoreScriptSyncService:GetScriptFilePath(instance: Instance): string\n return c_getInstancePath(instance)\n end\n \n local _c = c_CoreScriptSyncService\n \n --!strict\n local function a_normalizeSnapPath(path: string): string\n return (string.gsub(path, \"%.snap%.lua$\", \".snap.luau\"))\n end\n \n local function a_create(snapshotWrites: { [string]: string })\n local FileSystemService = {}\n \n function FileSystemService:WriteFile(path: string, contents: string)\n snapshotWrites[a_normalizeSnapPath(path)] = contents\n end\n \n function FileSystemService:CreateDirectories(_path: string) end\n \n function FileSystemService:Exists(path: string): boolean\n return snapshotWrites[a_normalizeSnapPath(path)] ~= nil\n end\n \n function FileSystemService:Remove(path: string)\n snapshotWrites[a_normalizeSnapPath(path)] = nil\n end\n \n function FileSystemService:IsRegularFile(path: string): boolean\n return snapshotWrites[a_normalizeSnapPath(path)] ~= nil\n end\n \n return FileSystemService\n end\n \n local _a = a_create\n \n --!strict\n local e_CoreScriptSyncService = _c\n local e_InstanceResolver = _b\n \n local e_module = {}\n \n function e_module.createMockGetDataModelService(snapshotWrites: { [string]: string })\n local FileSystemService = _a(snapshotWrites)\n \n return function(service: string): any\n if service == \"FileSystemService\" then\n return FileSystemService\n elseif service == \"CoreScriptSyncService\" then\n return e_CoreScriptSyncService\n end\n \n local success, result = pcall(function()\n local service_ = game:GetService(service)\n local _ = service_.Name\n return service_\n end)\n \n return if success then result else nil\n end\n end\n \n export type PatchState = {\n robloxSharedExports: any,\n originalGetDataModelService: any,\n Runtime: any,\n originalRequireInternalModule: any,\n }\n \n function e_module.patch(jestModule: ModuleScript, snapshotWrites: { [string]: string }): PatchState?\n local mockGetDataModelService = e_module.createMockGetDataModelService(snapshotWrites)\n \n local robloxSharedInstance = e_InstanceResolver.findRobloxShared(jestModule)\n if not robloxSharedInstance then\n warn(\"Could not find RobloxShared; snapshot support unavailable\")\n return nil\n end\n \n local robloxSharedExports = (require :: any)(robloxSharedInstance)\n local originalGetDataModelService = robloxSharedExports.getDataModelService\n robloxSharedExports.getDataModelService = mockGetDataModelService\n \n -- Snapshot the partial state immediately so any subsequent throw can\n -- be rolled back. Without this, workspace mode (where multiple patch\n -- cycles share the same RobloxShared module) would leak the mock if\n -- the JestRuntime acquisition below threw — the next package's patch\n -- would then save the leaked mock as \"original\".\n local partialState: PatchState = {\n robloxSharedExports = robloxSharedExports,\n originalGetDataModelService = originalGetDataModelService,\n Runtime = nil,\n originalRequireInternalModule = nil,\n }\n \n local ok, err = pcall(function()\n local getDataModelServiceChild = robloxSharedInstance:FindFirstChild(\"getDataModelService\")\n \n local jestRuntimeModule = e_InstanceResolver.findSiblingPackage(jestModule, \"JestRuntime\", \"jest-runtime\")\n if not jestRuntimeModule then\n warn(\"Could not find JestRuntime; snapshot interception unavailable\")\n return\n end\n \n local Runtime = (require :: any)(jestRuntimeModule)\n local originalRequireInternalModule = Runtime.requireInternalModule\n \n Runtime.requireInternalModule = function(self: any, from: any, to: any, ...): any\n local target = if to ~= nil then to else from\n if getDataModelServiceChild and typeof(target) == \"Instance\" and target == getDataModelServiceChild then\n return mockGetDataModelService\n end\n \n return originalRequireInternalModule(self, from, to, ...)\n end\n \n partialState.Runtime = Runtime\n partialState.originalRequireInternalModule = originalRequireInternalModule\n end)\n \n if not ok then\n e_module.unpatch(partialState)\n error(err, 0)\n end\n \n return partialState\n end\n \n function e_module.unpatch(state: PatchState?)\n if not state then\n return\n end\n \n if state.robloxSharedExports and state.originalGetDataModelService then\n state.robloxSharedExports.getDataModelService = state.originalGetDataModelService\n end\n \n if state.Runtime and state.originalRequireInternalModule then\n state.Runtime.requireInternalModule = state.originalRequireInternalModule\n end\n end\n \n local _e = e_module\n \n --!strict\n local j_HttpService = game:GetService(\"HttpService\")\n local j_LogService = game:GetService(\"LogService\")\n \n local j_InstanceResolver = _b\n local j_InterceptWriteable = _i\n local j_PerTestCoverage = _h\n local j_PromiseError = _g\n local j_SetupTiming = _f\n local j_SnapshotPatch = _e\n \n type CapturedMessage = InterceptWriteable.CapturedMessage\n \n type Config = {\n jestPath: string?,\n projects: { string }?,\n setupFiles: { string }?,\n setupFilesAfterEnv: { string }?,\n _coverage: boolean?,\n _perTestCoverage: boolean?,\n _timing: boolean?,\n }\n \n local function j_fail(err: string)\n return {\n success = false,\n err = err,\n }\n end\n \n -- Install the per-test coverage hook against the same jest-circus and Expect\n -- instances Jest itself uses. All resolution is pcall-guarded so a missing or\n -- renamed package degrades to no attribution rather than failing the run; the\n -- returned accumulator fills as tests run. The matcher state carries the current\n -- file (`testPath`) and full test name (`currentTestName`).\n local function j_installPerTestCoverage(jestModule: ModuleScript): { any }?\n local ok, recordsOrErr = pcall(function()\n local circusModule = j_InstanceResolver.findSiblingPackage(jestModule, \"JestCircus\", \"jest-circus\")\n local expectModule = j_InstanceResolver.findSiblingPackage(jestModule, \"Expect\", \"expect\")\n assert(circusModule and expectModule, \"could not resolve jest-circus / Expect packages\")\n \n local Circus = (require :: any)(circusModule)\n local Expect = (require :: any)(expectModule)\n \n return j_PerTestCoverage.install(Circus.addEventHandler, Expect.getState, function()\n return _G.__jest_roblox_cov or {}\n end)\n end)\n \n if ok then\n return recordsOrErr\n end\n \n warn(\"Per-test coverage attribution unavailable: \" .. tostring(recordsOrErr))\n return nil\n end\n \n local j_module = {}\n \n function j_module.run(callingScript: LuaSourceContainer, config: Config): (string, string, string)\n local t0 = os.clock()\n local timingEnabled = config._timing\n local coverageEnabled = config._coverage\n local perTestEnabled = config._perTestCoverage\n \n -- Game Output capture: subscribe to LogService.MessageOut at the top of\n -- the run so engine warnings during findJest/require(Jest) (broken\n -- jestPath, missing dep) also land in the user-facing `--gameOutput`\n -- dump. Disconnected last during teardown so late warns that fire after\n -- Promise:expect resumes the coroutine still reach us — GetLogHistory\n -- has a known gap here (plans/SNAPSHOT-SUPPORT.md).\n local logMessages: { CapturedMessage } = {}\n local logConnection = j_LogService.MessageOut:Connect(\n function(message: string, messageType: Enum.MessageType)\n table.insert(logMessages, {\n message = message,\n messageType = messageType.Value,\n timestamp = os.clock(),\n })\n end\n )\n \n local function encodeGameOutput(): string\n local ok, encoded = pcall(function()\n return j_HttpService:JSONEncode(logMessages)\n end)\n return if ok then encoded else \"[]\"\n end\n \n local t_findJest0 = os.clock()\n local findSuccess, findValue = pcall(j_InstanceResolver.getJest, config)\n local t_findJest = os.clock()\n \n if not findSuccess then\n logConnection:Disconnect()\n return j_HttpService:JSONEncode(j_fail(findValue :: any)), encodeGameOutput(), \"[]\"\n end\n \n local snapshotWrites: { [string]: string } = {}\n \n local t_patchSnapshot0 = os.clock()\n local patchState = j_SnapshotPatch.patch(findValue, snapshotWrites)\n local t_patchSnapshot = os.clock()\n \n local t_requireJest0 = os.clock()\n local Jest = (require :: any)(findValue)\n local t_requireJest = os.clock()\n \n -- Intercept Jest's stdout/stderr to capture output synchronously into\n -- the BANNER OUTPUT buffer. Jest writes via process.stdout/stderr\n -- (Writeable objects whose _writeFn defaults to print); wrapping\n -- _writeFn captures messages like \"No tests found\" that are printed\n -- just before exit(1) throws. Distinct from the LogService-sourced\n -- Game Output above: this narrower buffer is what the CLI's error\n -- banner reads on Luau errors (see cli.ts:formatLuauErrorBanner).\n local capturedMessages: { CapturedMessage } = {}\n local restoreStdout: (() -> ())?\n local restoreStderr: (() -> ())?\n \n local interceptOk = pcall(function()\n local nodeModules = findValue.Parent.Parent.Parent :: any\n local RobloxShared = (require :: any)(nodeModules[\"@rbxts-js\"].RobloxShared)\n local process = RobloxShared.nodeUtils.process\n \n restoreStdout = j_InterceptWriteable.intercept(process.stdout, capturedMessages, 0)\n restoreStderr = j_InterceptWriteable.intercept(process.stderr, capturedMessages, 1)\n end)\n \n if not interceptOk then\n restoreStdout = nil\n restoreStderr = nil\n end\n \n local function runTests()\n local t_resolveProjects0 = os.clock()\n local projects = {}\n \n assert(\n config.projects and #config.projects > 0,\n \"No projects configured. Set 'projects' in jest.config.ts.\"\n )\n \n for _, projectPath in config.projects do\n table.insert(projects, j_InstanceResolver.findInstance(projectPath))\n end\n \n config.projects = {}\n local t_resolveProjects = os.clock()\n \n local t_resolveSetupFiles0 = os.clock()\n if config.setupFiles and #config.setupFiles > 0 then\n local resolved = {}\n \n for _, setupPath in config.setupFiles do\n table.insert(resolved, j_InstanceResolver.findInstance(setupPath))\n end\n \n config.setupFiles = resolved :: any\n end\n if config.setupFilesAfterEnv and #config.setupFilesAfterEnv > 0 then\n local resolved = {}\n \n for _, setupPath in config.setupFilesAfterEnv do\n table.insert(resolved, j_InstanceResolver.findInstance(setupPath))\n end\n \n config.setupFilesAfterEnv = resolved :: any\n end\n local t_resolveSetupFiles = os.clock()\n \n local setupTimingState = j_SetupTiming.patch(\n findValue,\n config.setupFiles :: any,\n config.setupFilesAfterEnv :: any\n )\n \n -- Strip private keys before Jest.runCLI (safe: single-task execution per VM)\n config._timing = nil :: any\n config._coverage = nil :: any\n config._perTestCoverage = nil :: any\n \n local perTestRecords: { any }? = nil\n if coverageEnabled then\n _G.__jest_roblox_cov = {}\n if perTestEnabled then\n perTestRecords = j_installPerTestCoverage(findValue)\n end\n end\n \n local t_jestRunCLI0 = os.clock()\n local runCLIOk, runCLIValue = pcall(function()\n return Jest.runCLI(callingScript, config, projects):expect()\n end)\n local t_jestRunCLI = os.clock()\n \n local setupSeconds = j_SetupTiming.getSeconds(setupTimingState)\n j_SetupTiming.unpatch(setupTimingState)\n \n if not runCLIOk then\n error(j_PromiseError.normalize(runCLIValue), 0)\n end\n \n local jestResult = runCLIValue\n \n local result: { [string]: any } = {\n success = true,\n value = jestResult,\n }\n \n if setupSeconds > 0 then\n result._setup = setupSeconds\n end\n \n if timingEnabled then\n result._timing = {\n findJest = t_findJest - t_findJest0,\n patchSnapshot = t_patchSnapshot - t_patchSnapshot0,\n requireJest = t_requireJest - t_requireJest0,\n resolveProjects = t_resolveProjects - t_resolveProjects0,\n resolveSetupFiles = t_resolveSetupFiles - t_resolveSetupFiles0,\n jestRunCLI = t_jestRunCLI - t_jestRunCLI0,\n total = os.clock() - t0,\n }\n end\n \n if next(snapshotWrites) then\n result._snapshotWrites = snapshotWrites\n end\n \n if coverageEnabled then\n result._coverage = _G.__jest_roblox_cov\n if perTestRecords and #perTestRecords > 0 then\n result._perTestCoverage = perTestRecords\n end\n end\n \n return result\n end\n \n local jestDone = false\n local runSuccess = false\n local runValue: any = nil\n \n task.spawn(function()\n local ok, val = pcall(runTests)\n jestDone = true\n runSuccess = ok\n runValue = val\n end)\n \n local infiniteYieldMessage: string? = nil\n local watchdogConnection = j_LogService.MessageOut:Connect(function(message: string, messageType: Enum.MessageType)\n if\n messageType == Enum.MessageType.MessageWarning\n and string.find(message, \"Infinite yield possible\")\n and not infiniteYieldMessage\n then\n infiniteYieldMessage = message\n end\n end)\n \n while not jestDone and not infiniteYieldMessage do\n task.wait(0.1)\n end\n \n watchdogConnection:Disconnect()\n \n if restoreStdout then\n restoreStdout()\n end\n \n if restoreStderr then\n restoreStderr()\n end\n \n if not jestDone and infiniteYieldMessage then\n runSuccess = false\n runValue = \"Infinite yield detected, aborting tests: \" .. infiniteYieldMessage\n end\n \n j_SnapshotPatch.unpatch(patchState)\n \n -- MessageOut listener stays connected through SnapshotPatch.unpatch and\n -- the encode below, so any disconnect-time warns still land in\n -- gameOutput.\n logConnection:Disconnect()\n \n local jestResult\n if not runSuccess then\n jestResult = j_HttpService:JSONEncode(j_fail(runValue :: any))\n else\n jestResult = j_HttpService:JSONEncode(runValue)\n end\n \n local bannerEncodeOk, bannerEncoded = pcall(function()\n return j_HttpService:JSONEncode(capturedMessages)\n end)\n \n return jestResult, encodeGameOutput(), if bannerEncodeOk then bannerEncoded else \"[]\"\n end\n \n type ProjectEntry = {\n jestOutput: string,\n gameOutput: string,\n bannerOutput: string,\n elapsedMs: number,\n }\n \n local function j_encodeExecutionError(err: any): string\n return j_HttpService:JSONEncode({\n success = true,\n value = {\n kind = \"ExecutionError\",\n error = tostring(err),\n },\n })\n end\n \n -- Per-config hooks. Studio passes `beforeConfig` to inject the current\n -- config's `jest.config` ModuleScripts and `afterConfig` to destroy them\n -- before the next config runs. Per-config (not upfront-all) avoids\n -- nested-project DataModel contamination where configs at e.g.\n -- `ReplicatedStorage` and `ReplicatedStorage/Foo` would both have stubs\n -- present simultaneously and Jest's parent-traversal config lookup could\n -- resolve the wrong one. Open-cloud passes no hooks — its stubs are baked\n -- into the place file by the synthesizer.\n type RunHooks = {\n beforeConfig: ((cfg: Config, index: number) -> ())?,\n afterConfig: ((cfg: Config, index: number) -> ())?,\n }\n \n -- TODO(runner-tests): dogfood harness for Runner.runProjects\n function j_module.runProjects(\n callingScript: LuaSourceContainer,\n configs: { Config },\n hooks: RunHooks?\n ): { ProjectEntry }\n local entries: { ProjectEntry } = {}\n \n for index, cfg in configs do\n local start = os.clock()\n local beforeOk: boolean, beforeErr: any = true, nil\n if hooks and hooks.beforeConfig then\n -- beforeConfig runs inside pcall too: a failed injection\n -- mid-mount must still trigger afterConfig so partially-\n -- parented stubs get destroyed. Without pcall, a throw would\n -- propagate out and afterConfig would never fire.\n beforeOk, beforeErr = pcall(hooks.beforeConfig, cfg, index)\n end\n \n local ok: boolean, jestOutput: any, gameOutput: any, bannerOutput: any =\n false, nil, nil, nil\n if beforeOk then\n ok, jestOutput, gameOutput, bannerOutput = pcall(j_module.run, callingScript, cfg)\n else\n jestOutput = beforeErr\n end\n local elapsedMs = math.floor((os.clock() - start) * 1000)\n \n -- Always run afterConfig, even on failure — cleanup must be\n -- unconditional so injected ModuleScripts don't leak into the next\n -- iteration when Jest (or the beforeConfig itself) throws partway\n -- through.\n if hooks and hooks.afterConfig then\n local cleanupOk, cleanupErr = pcall(hooks.afterConfig, cfg, index)\n if not cleanupOk then\n warn(\"Runner afterConfig hook threw: \" .. tostring(cleanupErr))\n end\n end\n \n if ok then\n entries[index] = {\n jestOutput = jestOutput :: string,\n gameOutput = gameOutput :: string,\n bannerOutput = bannerOutput :: string,\n elapsedMs = elapsedMs,\n }\n else\n entries[index] = {\n jestOutput = j_encodeExecutionError(jestOutput),\n gameOutput = \"[]\",\n bannerOutput = \"[]\",\n elapsedMs = elapsedMs,\n }\n end\n end\n \n return entries\n end\n \n local _j = j_module\n \n function __WELD_MODULES.b() return _b end\n function __WELD_MODULES.i() return _i end\n function __WELD_MODULES.d() return _d end\n function __WELD_MODULES.h() return _h end\n function __WELD_MODULES.g() return _g end\n function __WELD_MODULES.f() return _f end\n function __WELD_MODULES.c() return _c end\n function __WELD_MODULES.a() return _a end\n function __WELD_MODULES.e() return _e end\n function __WELD_MODULES.j() return _j end\nend\n\n--!strict\nlocal HttpService = game:GetService(\"HttpService\")\n\nlocal Runner = __WELD_MODULES.j()\n\nlocal payload = HttpService:JSONDecode([=[__CONFIG_JSON__]=])\nlocal entries = Runner.runProjects(script, payload.configs)\n\nreturn HttpService:JSONEncode({ entries = entries }), \"[]\"\n";
7427
7506
  //#endregion
7428
7507
  //#region src/test-script.ts
7508
+ const TS_OR_LUAU_EXTENSION$2 = /\.(tsx?|luau?)$/;
7429
7509
  function buildJestArgv(options) {
7430
7510
  const argv = {};
7431
7511
  for (const [key, value] of Object.entries(options.config)) if (!JEST_ARGV_EXCLUDED_KEYS.has(key) && value !== void 0) argv[key] = value;
@@ -7436,7 +7516,7 @@ function buildJestArgv(options) {
7436
7516
  return {
7437
7517
  ...argv,
7438
7518
  reporters: argv["reporters"] ?? [],
7439
- testMatch: options.config.testMatch.map((pattern) => pattern.replace(/\.(tsx?|luau?)$/, ""))
7519
+ testMatch: options.config.testMatch.map((pattern) => pattern.replace(TS_OR_LUAU_EXTENSION$2, ""))
7440
7520
  };
7441
7521
  }
7442
7522
  function generateTestScript(options) {
@@ -7478,6 +7558,7 @@ const PARALLEL_AUTO_CAP = 3;
7478
7558
  const BASE_URL_ENV = "JEST_ROBLOX_OPEN_CLOUD_BASE_URL";
7479
7559
  const MAX_RETRIES_ENV = "JEST_ROBLOX_OCALE_MAX_RETRIES";
7480
7560
  const DEFAULT_STREAM_POLL_MS = 250;
7561
+ const TrailingSlashesPattern = /\/+$/;
7481
7562
  var OpenCloudBackend = class {
7482
7563
  runner;
7483
7564
  kind = "open-cloud";
@@ -7611,7 +7692,7 @@ async function pollStreamingResults(hooks, isDone) {
7611
7692
  function resolveOpenCloudBaseUrl() {
7612
7693
  const override = process.env[BASE_URL_ENV]?.trim();
7613
7694
  if (override === void 0 || override === "") return;
7614
- return override.replace(/\/+$/, "");
7695
+ return override.replace(TrailingSlashesPattern, "");
7615
7696
  }
7616
7697
  /**
7617
7698
  * Reads {@link MAX_RETRIES_ENV} for an Open Cloud retry-budget override. Lets
@@ -7706,15 +7787,19 @@ function parseStealingEnvelope(result) {
7706
7787
  gameOutput: result.outputs[1]
7707
7788
  };
7708
7789
  }
7709
- function aggregateEntriesByKey(taskEnvelopes) {
7710
- const entryByKey = /* @__PURE__ */ new Map();
7711
- for (const { entries, gameOutput } of taskEnvelopes) for (const entry of entries) if (entry.pkg !== void 0) {
7790
+ function addEntriesToMap(entryByKey, entries, gameOutput) {
7791
+ for (const entry of entries) {
7792
+ if (entry.pkg === void 0) continue;
7712
7793
  const key = entryLookupKey(entry.pkg, entry.project);
7713
7794
  if (!entryByKey.has(key)) entryByKey.set(key, {
7714
7795
  entry,
7715
7796
  gameOutput
7716
7797
  });
7717
7798
  }
7799
+ }
7800
+ function aggregateEntriesByKey(taskEnvelopes) {
7801
+ const entryByKey = /* @__PURE__ */ new Map();
7802
+ for (const { entries, gameOutput } of taskEnvelopes) addEntriesToMap(entryByKey, entries, gameOutput);
7718
7803
  return entryByKey;
7719
7804
  }
7720
7805
  //#endregion
@@ -8239,8 +8324,8 @@ var StudioBackend = class {
8239
8324
  const requestMessage = buildRunTestsMessage(jobs, requestId);
8240
8325
  const executionStart = Date.now();
8241
8326
  const message = await this.waitForResult(wss, requestMessage, requestId, existingSocket);
8242
- const executionMs = Date.now() - executionStart;
8243
8327
  if (message.type === "version_mismatch") throw new Error(`Studio plugin protocol version mismatch: plugin reported v${message.actualVersion.toString()}, CLI expected v${message.expectedVersion.toString()}. Update the jest-roblox Studio plugin to match this CLI version.`);
8328
+ const executionMs = Date.now() - executionStart;
8244
8329
  const entries = parseEnvelope(message.jestOutput);
8245
8330
  if (entries.length !== jobs.length) throw new Error(`Studio backend returned ${entries.length.toString()} entries but request had ${jobs.length.toString()} jobs`);
8246
8331
  return {
@@ -8261,7 +8346,8 @@ var StudioBackend = class {
8261
8346
  function attachSocket(ws) {
8262
8347
  ws.send(String(JSON.stringify(requestMessage)));
8263
8348
  ws.on("message", (data) => {
8264
- const message = pluginMessageSchema(JSON.parse(data.toString()));
8349
+ const raw = JSON.parse(data.toString());
8350
+ const message = pluginMessageSchema(raw);
8265
8351
  if (message instanceof type.errors) {
8266
8352
  clearTimeout(timer);
8267
8353
  reject(/* @__PURE__ */ new Error(`Invalid plugin message: ${message.summary}`));
@@ -8323,6 +8409,7 @@ function buildRunTestsMessage(jobs, requestId) {
8323
8409
  //#endregion
8324
8410
  //#region src/backends/auto.ts
8325
8411
  const ENV_PREFIX = "JEST_";
8412
+ const StudioBusyPattern = /previous call to start play session/i;
8326
8413
  var StudioWithFallback = class {
8327
8414
  credentials;
8328
8415
  studio;
@@ -8347,7 +8434,7 @@ var StudioWithFallback = class {
8347
8434
  }
8348
8435
  };
8349
8436
  function isStudioBusyError(error) {
8350
- if (error instanceof LuauScriptError) return /previous call to start play session/i.test(error.message);
8437
+ if (error instanceof LuauScriptError) return StudioBusyPattern.test(error.message);
8351
8438
  return typeof error === "object" && error !== null && "code" in error && error.code === "EADDRINUSE";
8352
8439
  }
8353
8440
  async function probeStudioPlugin(port, timeoutMs, createServer = (wsPort) => {
@@ -8472,7 +8559,7 @@ function walkDirectory(directoryPath, baseDirectory) {
8472
8559
  */
8473
8560
  function applyExcludes(files, excludeGlobs) {
8474
8561
  if (excludeGlobs === void 0 || excludeGlobs.length === 0) return files;
8475
- return files.filter((file) => !excludeGlobs.some((pattern) => matchesGlobPattern(file, pattern)));
8562
+ return files.filter((file) => excludeGlobs.every((pattern) => !matchesGlobPattern(file, pattern)));
8476
8563
  }
8477
8564
  //#endregion
8478
8565
  //#region src/config/derive-typecheck-include.ts
@@ -8485,16 +8572,19 @@ function applyExcludes(files, excludeGlobs) {
8485
8572
  * `/\.(test-d|spec-d)\.ts$/`), so patterns ending in any other extension, lacking
8486
8573
  * a trailing `.spec.`/`.test.` marker, or already carrying `-d` are dropped.
8487
8574
  */
8575
+ const SpecTsSuffixPattern = /\.spec\.ts$/;
8576
+ const TestTsSuffixPattern = /\.test\.ts$/;
8488
8577
  function deriveTypecheckInclude(runtimeInclude) {
8489
8578
  const derived = [];
8490
- for (const pattern of runtimeInclude) if (/\.spec\.ts$/.test(pattern)) derived.push(pattern.replace(/\.spec\.ts$/, ".spec-d.ts"));
8491
- else if (/\.test\.ts$/.test(pattern)) derived.push(pattern.replace(/\.test\.ts$/, ".test-d.ts"));
8579
+ for (const pattern of runtimeInclude) if (SpecTsSuffixPattern.test(pattern)) derived.push(pattern.replace(SpecTsSuffixPattern, ".spec-d.ts"));
8580
+ else if (TestTsSuffixPattern.test(pattern)) derived.push(pattern.replace(TestTsSuffixPattern, ".test-d.ts"));
8492
8581
  return derived;
8493
8582
  }
8494
8583
  //#endregion
8495
8584
  //#region src/utils/extensions.ts
8585
+ const TS_OR_LUAU_EXTENSION$1 = /\.(tsx?|luau?)$/;
8496
8586
  function stripTsExtension(pattern) {
8497
- return pattern.replace(/\.(tsx?|luau?)$/, "");
8587
+ return pattern.replace(TS_OR_LUAU_EXTENSION$1, "");
8498
8588
  }
8499
8589
  //#endregion
8500
8590
  //#region src/luau/eval-literals.ts
@@ -8603,6 +8693,8 @@ function getTemporaryDirectory() {
8603
8693
  }
8604
8694
  //#endregion
8605
8695
  //#region src/config/projects.ts
8696
+ const TRAILING_SLASH$2 = /\/$/;
8697
+ const TS_OR_LUAU_EXTENSION = /\.(tsx?|luau?)$/;
8606
8698
  function extractStaticRoot(pattern) {
8607
8699
  const globChars = /* @__PURE__ */ new Set([
8608
8700
  "*",
@@ -8630,7 +8722,7 @@ function extractStaticRoot(pattern) {
8630
8722
  };
8631
8723
  }
8632
8724
  function mapFsRootToDataModel(outDirectory, rojoTree) {
8633
- const normalized = outDirectory.replace(/\/$/, "");
8725
+ const normalized = outDirectory.replace(TRAILING_SLASH$2, "");
8634
8726
  const result = findInTree(rojoTree, normalized, "");
8635
8727
  if (result === void 0) {
8636
8728
  const available = [];
@@ -8655,7 +8747,7 @@ function extractProjectRoots(include) {
8655
8747
  }
8656
8748
  patterns.push(qualified);
8657
8749
  }
8658
- return [...rootMap.entries()].map(([root, testMatch]) => ({
8750
+ return Array.from(rootMap, ([root, testMatch]) => ({
8659
8751
  root,
8660
8752
  testMatch
8661
8753
  }));
@@ -8692,7 +8784,8 @@ const PROJECT_ONLY_KEYS = /* @__PURE__ */ new Set([
8692
8784
  function dedupeMounts(mounts) {
8693
8785
  const seen = /* @__PURE__ */ new Set();
8694
8786
  const result = [];
8695
- for (const mount of mounts) if (!seen.has(mount.dataModelPath)) {
8787
+ for (const mount of mounts) {
8788
+ if (seen.has(mount.dataModelPath)) continue;
8696
8789
  seen.add(mount.dataModelPath);
8697
8790
  result.push(mount);
8698
8791
  }
@@ -8848,7 +8941,7 @@ function deriveIncludeFromTestMatch(config, configDirectory, tsconfig) {
8848
8941
  if (raw["include"] !== void 0) return;
8849
8942
  if (!Array.isArray(raw["testMatch"])) return;
8850
8943
  config.include = raw["testMatch"].flatMap((pattern) => {
8851
- return (/\.(tsx?|luau?)$/.test(pattern) ? [pattern] : [`${pattern}.ts`, `${pattern}.tsx`]).map((extension) => path$1.posix.join(configDirectory, extension));
8944
+ return (TS_OR_LUAU_EXTENSION.test(pattern) ? [pattern] : [`${pattern}.ts`, `${pattern}.tsx`]).map((extension) => path$1.posix.join(configDirectory, extension));
8852
8945
  });
8853
8946
  const { outDir, rootDir } = tsconfig;
8854
8947
  if (raw["outDir"] === void 0 && rootDir !== void 0 && outDir !== void 0) {
@@ -8942,6 +9035,7 @@ function buildNoMatchMessage(files, roots) {
8942
9035
  const REGEX_METACHARACTERS = /[.*+?^${}()|[\]\\]/g;
8943
9036
  const TEST_FILE_EXTENSION = /\.(tsx?|luau?)$/;
8944
9037
  const TS_SOURCE_EXTENSION = /\.tsx?$/;
9038
+ const INDEX_STEM = /^index(\.|$)/;
8945
9039
  /**
8946
9040
  * Translate a list of explicit test files (typically from CLI positional args)
8947
9041
  * into a `testPathPattern` regex that constrains Jest on the Luau side. Each
@@ -8999,7 +9093,7 @@ function narrowForLuauRun(config, runtimeFiles, filterActive) {
8999
9093
  * rewritten (else a pure-Luau project's positional arg matches zero tests).
9000
9094
  */
9001
9095
  function indexStemToInit(basename) {
9002
- return basename.replace(/^index(\.|$)/, "init$1");
9096
+ return basename.replace(INDEX_STEM, "init$1");
9003
9097
  }
9004
9098
  function toBasenamePattern(file) {
9005
9099
  const posix = file.replaceAll("\\", "/");
@@ -9023,8 +9117,8 @@ function isGeneratedStub(filePath) {
9023
9117
  try {
9024
9118
  const fd = fs.openSync(filePath, "r");
9025
9119
  try {
9026
- const buf = Buffer.alloc(47);
9027
- return fs.readSync(fd, buf, 0, 47, 0) === 47 && buf.toString("utf8") === HEADER;
9120
+ const buffer = Buffer.alloc(47);
9121
+ return fs.readSync(fd, buffer, 0, 47, 0) === 47 && buffer.toString("utf8") === HEADER;
9028
9122
  } finally {
9029
9123
  fs.closeSync(fd);
9030
9124
  }
@@ -9131,17 +9225,7 @@ function cleanLeftoverStubs(projects, rootDirectory) {
9131
9225
  realRoot ??= fs.realpathSync(path.resolve(rootDirectory));
9132
9226
  return realRoot;
9133
9227
  }
9134
- for (const project of projects) for (const mount of project.rojoMounts) {
9135
- assertMountContained(project, mount.fsPath, rootDirectory);
9136
- const stubPath = path.resolve(rootDirectory, mount.fsPath, STUB_FILENAME);
9137
- if (!isGeneratedStub(stubPath)) continue;
9138
- const realStubPath = fs.realpathSync(stubPath);
9139
- const root = realRootResolved();
9140
- const relativePath = path.relative(root, realStubPath);
9141
- if (!(relativePath === "" || !relativePath.startsWith("..") && !path.isAbsolute(relativePath))) throw new Error(`Project "${project.displayName}" mount fsPath resolves outside root via symlink: ${mount.fsPath} → ${realStubPath}`);
9142
- fs.unlinkSync(stubPath);
9143
- cleaned.push(stubPath);
9144
- }
9228
+ for (const project of projects) cleaned.push(...cleanLeftoverStubsForProject(project, rootDirectory, realRootResolved));
9145
9229
  return cleaned;
9146
9230
  }
9147
9231
  function generateProjectStubs(projects, rootDirectory, outputRoot) {
@@ -9155,15 +9239,7 @@ function generateProjectStubs(projects, rootDirectory, outputRoot) {
9155
9239
  include: [],
9156
9240
  testMatch: project.testMatch
9157
9241
  };
9158
- for (const mount of project.rojoMounts) {
9159
- assertMountContained(project, mount.fsPath, writeRoot);
9160
- if (hasUserAuthoredConfig(path.resolve(rootDirectory, mount.fsPath))) continue;
9161
- const outputPath = path.resolve(writeRoot, mount.fsPath, STUB_FILENAME);
9162
- entries.push({
9163
- config: stubConfig,
9164
- outputPath
9165
- });
9166
- }
9242
+ entries.push(...buildStubEntriesForProject(project, stubConfig, rootDirectory, writeRoot));
9167
9243
  }
9168
9244
  generateProjectConfigs(entries);
9169
9245
  }
@@ -9184,6 +9260,34 @@ function syncStubsToShadowDirectory(projects, rootDirectory, shadowDirectory) {
9184
9260
  }
9185
9261
  return changed;
9186
9262
  }
9263
+ function cleanLeftoverStubsForProject(project, rootDirectory, realRootResolved) {
9264
+ const cleaned = [];
9265
+ for (const mount of project.rojoMounts) {
9266
+ assertMountContained(project, mount.fsPath, rootDirectory);
9267
+ const stubPath = path.resolve(rootDirectory, mount.fsPath, STUB_FILENAME);
9268
+ if (!isGeneratedStub(stubPath)) continue;
9269
+ const realStubPath = fs.realpathSync(stubPath);
9270
+ const root = realRootResolved();
9271
+ const relativePath = path.relative(root, realStubPath);
9272
+ if (!(relativePath === "" || !relativePath.startsWith("..") && !path.isAbsolute(relativePath))) throw new Error(`Project "${project.displayName}" mount fsPath resolves outside root via symlink: ${mount.fsPath} → ${realStubPath}`);
9273
+ fs.unlinkSync(stubPath);
9274
+ cleaned.push(stubPath);
9275
+ }
9276
+ return cleaned;
9277
+ }
9278
+ function buildStubEntriesForProject(project, stubConfig, rootDirectory, writeRoot) {
9279
+ const entries = [];
9280
+ for (const mount of project.rojoMounts) {
9281
+ assertMountContained(project, mount.fsPath, writeRoot);
9282
+ if (hasUserAuthoredConfig(path.resolve(rootDirectory, mount.fsPath))) continue;
9283
+ const outputPath = path.resolve(writeRoot, mount.fsPath, STUB_FILENAME);
9284
+ entries.push({
9285
+ config: stubConfig,
9286
+ outputPath
9287
+ });
9288
+ }
9289
+ return entries;
9290
+ }
9187
9291
  function buildStubConfig(config) {
9188
9292
  const result = {};
9189
9293
  for (const [key, value] of Object.entries(config)) if (PROJECT_TEST_KEYS.has(key) && !STUB_SKIP_KEYS.has(key) && value !== void 0) result[key] = value;
@@ -9254,6 +9358,7 @@ function serializeLuauValue(value, indent) {
9254
9358
  }
9255
9359
  //#endregion
9256
9360
  //#region src/coverage-pipeline/derive-coverage-from.ts
9361
+ const SPEC_OR_TEST_EXTENSION = /\.(?:spec|test)(\.\w+)$/;
9257
9362
  /**
9258
9363
  * Derives `collectCoverageFrom` glob patterns from project `include` patterns.
9259
9364
  *
@@ -9286,7 +9391,7 @@ function deriveCoverageFromIncludes(projects) {
9286
9391
  * extension so that misconfigured globs fail loudly.
9287
9392
  */
9288
9393
  function inferSourceExtension(pattern) {
9289
- const match = pattern.match(/\.(?:spec|test)(\.\w+)$/);
9394
+ const match = pattern.match(SPEC_OR_TEST_EXTENSION);
9290
9395
  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).`);
9291
9396
  const [, extension] = match;
9292
9397
  return extension;
@@ -9428,9 +9533,10 @@ function extractDefinition(node, source) {
9428
9533
  //#endregion
9429
9534
  //#region src/typecheck/parse.ts
9430
9535
  const errorCodeRegExp = /error TS(?<errorCode>\d+)/;
9536
+ const LINE_SPLIT = /\r?\n/;
9431
9537
  function parseTscOutput(stdout) {
9432
9538
  const map = /* @__PURE__ */ new Map();
9433
- const merged = stdout.split(/\r?\n/).reduce((lines, next) => {
9539
+ const merged = stdout.split(LINE_SPLIT).reduce((lines, next) => {
9434
9540
  if (!next) return lines;
9435
9541
  if (next[0] !== " ") lines.push(next);
9436
9542
  else if (lines.length > 0) lines[lines.length - 1] += `\n${next}`;
@@ -9561,7 +9667,7 @@ async function runTypecheck(options) {
9561
9667
  function buildFileResult(filePath, fileInfo, errors) {
9562
9668
  const indexMap = createLocationsIndexMap(fileInfo.source);
9563
9669
  const testDefinitions = fileInfo.definitions.filter((definition) => definition.type === "test");
9564
- const sortedDefinitions = [...testDefinitions].sort((a, b) => b.start - a.start);
9670
+ const sortedDefinitions = testDefinitions.toSorted((a, b) => b.start - a.start);
9565
9671
  const errorsByTest = /* @__PURE__ */ new Map();
9566
9672
  const fileErrors = [];
9567
9673
  for (const error of errors) {
@@ -9738,7 +9844,7 @@ function discoverTestFiles(config, cliFiles) {
9738
9844
  }
9739
9845
  const ignoredPatterns = config.testPathIgnorePatterns.map((pat) => new RegExp(pat));
9740
9846
  const baseFiles = allFiles.filter((file) => {
9741
- return !ignoredPatterns.some((pattern) => pattern.test(file));
9847
+ return ignoredPatterns.every((pattern) => !pattern.test(file));
9742
9848
  });
9743
9849
  const totalFiles = new Set(baseFiles).size;
9744
9850
  let filtered = baseFiles;
@@ -9854,13 +9960,7 @@ const VERSION$2 = version;
9854
9960
  */
9855
9961
  function collectStubMounts(projects, rootDirectory, cacheRoot) {
9856
9962
  const stubMounts = [];
9857
- for (const project of projects) for (const mount of project.rojoMounts) {
9858
- if (hasUserAuthoredConfig(path$1.resolve(rootDirectory, mount.fsPath))) continue;
9859
- stubMounts.push({
9860
- absStubPath: path$1.resolve(cacheRoot, mount.fsPath, STUB_FILENAME),
9861
- dataModelPath: mount.dataModelPath
9862
- });
9863
- }
9963
+ for (const project of projects) stubMounts.push(...collectStubMountsForProject(project, rootDirectory, cacheRoot));
9864
9964
  return stubMounts;
9865
9965
  }
9866
9966
  function loadRojoTree(config) {
@@ -9871,6 +9971,26 @@ function loadRojoTree(config) {
9871
9971
  return resolveNestedProjects(validated.tree, path$1.dirname(rojoPath));
9872
9972
  }
9873
9973
  /**
9974
+ * Instrument + rojo-build the coverage place and project it to a
9975
+ * `CoverageArtifacts`, optionally baking each project's `jest.config` stub into
9976
+ * the place. The single seam the run path (`prepareMultiProjectCoverage`) and
9977
+ * the offline build path (`buildCoveragePlace`) both drive, so the
9978
+ * prepare-and-bake mechanism can't drift between them. The caller owns the
9979
+ * `bakeStubs` decision (the run path skips baking for studio-cli, which injects
9980
+ * `jest.config` at runtime; the build path always bakes so a place opened by a
9981
+ * foreign runner is self-contained). Stubs must already be generated into
9982
+ * `cacheRoot`. Baking mirrors those cache stubs into the shadow tree — the
9983
+ * source tree is clean (stubs land in `cacheRoot`, not `rootDir`), so without it
9984
+ * the coverage place would build with no `jest.config` ModuleScripts.
9985
+ */
9986
+ function prepareBakedCoverage(config, projects, cacheRoot, bakeStubs) {
9987
+ const coverage = prepareCoverage(config, bakeStubs ? (shadowDirectory) => syncStubsToShadowDirectory(projects, cacheRoot, shadowDirectory) : void 0);
9988
+ return {
9989
+ artifacts: toCoverageArtifacts(coverage, toBuildManifestProjects(projects)),
9990
+ coverage
9991
+ };
9992
+ }
9993
+ /**
9874
9994
  * Multi-project execution core: stub generation, place build, discovery, and
9875
9995
  * job dispatch over a set of already-resolved projects. Shared by the
9876
9996
  * `projects:`-configured path (`runMultiProject`) and the no-`projects` collapse
@@ -9999,6 +10119,17 @@ async function runMultiProject(options) {
9999
10119
  return resolveAllProjects(rawProjects, rootConfig, rojoTree, rootConfig.rootDir);
10000
10120
  }), rootConfig, cli, timing);
10001
10121
  }
10122
+ function collectStubMountsForProject(project, rootDirectory, cacheRoot) {
10123
+ const stubMounts = [];
10124
+ for (const mount of project.rojoMounts) {
10125
+ if (hasUserAuthoredConfig(path$1.resolve(rootDirectory, mount.fsPath))) continue;
10126
+ stubMounts.push({
10127
+ absStubPath: path$1.resolve(cacheRoot, mount.fsPath, STUB_FILENAME),
10128
+ dataModelPath: mount.dataModelPath
10129
+ });
10130
+ }
10131
+ return stubMounts;
10132
+ }
10002
10133
  function buildMultiDisplayFilter(options) {
10003
10134
  const { cliFiles, matchedRuntimeFiles, projectNames, projects, rootDir, testPathPattern } = options;
10004
10135
  if (cliFiles !== void 0 && cliFiles.length > 0) return sourceTwinFilter(cliFiles, rootDir);
@@ -10173,11 +10304,9 @@ function prepareMultiProjectCoverage(rootConfig, projects, cacheRoot) {
10173
10304
  };
10174
10305
  const bakeStubs = rootConfig.backend !== "studio-cli";
10175
10306
  const start = Date.now();
10176
- const coverage = prepareCoverage(rootConfig, bakeStubs ? (shadowDirectory) => {
10177
- return syncStubsToShadowDirectory(projects, cacheRoot, shadowDirectory);
10178
- } : void 0);
10307
+ const { artifacts, coverage } = prepareBakedCoverage(rootConfig, projects, cacheRoot, bakeStubs);
10179
10308
  return {
10180
- coverageArtifacts: toCoverageArtifacts(coverage, toBuildManifestProjects(projects)),
10309
+ coverageArtifacts: artifacts,
10181
10310
  effectiveConfig: {
10182
10311
  ...rootConfig,
10183
10312
  placeFile: coverage.placeFile
@@ -10224,6 +10353,7 @@ function selectProjects(allProjects, projectNames, cliFiles, rootDirectory) {
10224
10353
  }
10225
10354
  //#endregion
10226
10355
  //#region src/run/single-projects.ts
10356
+ const TRAILING_SLASH$1 = /\/$/;
10227
10357
  /**
10228
10358
  * Map each compiled-Luau root to its Rojo mount (FS path ↔ DataModel path) via
10229
10359
  * the Rojo tree. Roots that don't map (a compiled-output dir the Rojo project
@@ -10232,7 +10362,7 @@ function selectProjects(allProjects, projectNames, cliFiles, rootDirectory) {
10232
10362
  */
10233
10363
  function deriveProjectMounts(luauRoots, rojoTree) {
10234
10364
  return dedupeMounts(luauRoots.flatMap((luauRoot) => {
10235
- const fsPath = luauRoot.replace(/\/$/, "");
10365
+ const fsPath = luauRoot.replace(TRAILING_SLASH$1, "");
10236
10366
  const dataModelPath = findInTree(rojoTree, fsPath, "");
10237
10367
  return dataModelPath !== void 0 ? [{
10238
10368
  dataModelPath,
@@ -10650,10 +10780,11 @@ function prepareWorkspaceCoverage(options) {
10650
10780
  const timing = options.timing ?? NOOP_TIMING_COLLECTOR;
10651
10781
  const defaultMatcher = createIgnoreMatcher(DEFAULT_CONFIG.coveragePathIgnorePatterns);
10652
10782
  return packages.map((descriptor) => {
10653
- return prepareForPackage(descriptor, workspaceRoot, {
10783
+ const ignore = {
10654
10784
  matcher: descriptor.coveragePathIgnorePatterns !== void 0 ? createIgnoreMatcher(descriptor.coveragePathIgnorePatterns) : defaultMatcher,
10655
10785
  patterns: descriptor.coveragePathIgnorePatterns ?? DEFAULT_CONFIG.coveragePathIgnorePatterns
10656
- }, timing);
10786
+ };
10787
+ return prepareForPackage(descriptor, workspaceRoot, ignore, timing);
10657
10788
  });
10658
10789
  }
10659
10790
  /**
@@ -10840,10 +10971,11 @@ function prepareForPackage(descriptor, workspaceRoot, ignore, timing) {
10840
10971
  shadowDir: shadowDirectory
10841
10972
  });
10842
10973
  }
10974
+ const generatedAtDate = /* @__PURE__ */ new Date();
10843
10975
  const manifest = {
10844
10976
  buildId: crypto.randomUUID(),
10845
10977
  files: allFiles,
10846
- generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
10978
+ generatedAt: generatedAtDate.toISOString(),
10847
10979
  instrumenterVersion: 2,
10848
10980
  luauRoots: coverageRoots.map((entry) => entry.shadowDir),
10849
10981
  nonInstrumentedFiles: allNonInstrumented,
@@ -11204,7 +11336,7 @@ async function runWorkspaceProfiled(options, timing) {
11204
11336
  const { emptyPackageErrors, filteredContexts, pending, typecheckByDirectory, typeTestEntries, typeTestProjects } = timing.profile("discoverTests", () => {
11205
11337
  const discoveredContexts = applyProjectFilter(contexts, cli.project);
11206
11338
  const discovered = collectPendingEntries(discoveredContexts, cli);
11207
- const typeTestPackages = new Set([...discovered.typecheckByDirectory.values()].map((entry) => entry.pkg));
11339
+ const typeTestPackages = new Set(Array.from(discovered.typecheckByDirectory.values(), (entry) => entry.pkg));
11208
11340
  const policy = applyEmptyPackagePolicy(discovered.pending, discoveredContexts, typeTestPackages);
11209
11341
  return {
11210
11342
  emptyPackageErrors: policy.emptyPackageErrors,
@@ -11311,7 +11443,7 @@ async function runWorkspaceProfiled(options, timing) {
11311
11443
  pending,
11312
11444
  results,
11313
11445
  runOptions,
11314
- verbose: options.cli.verbose,
11446
+ verbose: cli.verbose,
11315
11447
  workspaceRoot
11316
11448
  });
11317
11449
  return attachTypecheck(attachCoverageManifests(results, pending, coverageByPackage), typecheckPass.outcome, timing);
@@ -11541,6 +11673,43 @@ function applyEmptyPackagePolicy(allEntries, contexts, typeTestPackages) {
11541
11673
  pending
11542
11674
  };
11543
11675
  }
11676
+ function collectPackageProjectEntries(ctx, cliTypecheck, packageTypecheck, accumulators) {
11677
+ const { pending, typecheckByDirectory, typeTestEntries, typeTestProjects } = accumulators;
11678
+ const { packageDirectory } = ctx.info;
11679
+ for (const project of ctx.projects) {
11680
+ const typecheck = resolveTypecheckConfig({
11681
+ cli: cliTypecheck,
11682
+ project: project.typecheck,
11683
+ root: ctx.pkgConfig.typecheck
11684
+ });
11685
+ const projectConfig = buildProjectExecutionConfig(ctx.pkgConfig, project);
11686
+ const testFiles = typecheck.only ? [] : discoverProjectTestFiles(project, packageDirectory);
11687
+ pending.push({
11688
+ pkg: ctx.info.name,
11689
+ project,
11690
+ projectConfig,
11691
+ testFiles
11692
+ });
11693
+ if (!typecheck.enabled) continue;
11694
+ const typeTestFiles = discoverProjectTypeTests(project, typecheck, packageDirectory);
11695
+ if (typeTestFiles.length === 0) continue;
11696
+ typeTestProjects.push({
11697
+ pkg: ctx.info.name,
11698
+ project: project.displayName
11699
+ });
11700
+ typeTestEntries.push({
11701
+ cwd: packageDirectory,
11702
+ files: typeTestFiles,
11703
+ ...typecheck.tsconfig !== void 0 ? { tsconfig: typecheck.tsconfig } : {}
11704
+ });
11705
+ typecheckByDirectory.set(packageDirectory, {
11706
+ ignoreSourceErrors: packageTypecheck.ignoreSourceErrors,
11707
+ pkg: ctx.info.name,
11708
+ spawnTimeout: packageTypecheck.spawnTimeout,
11709
+ timeout: ctx.pkgConfig.timeout
11710
+ });
11711
+ }
11712
+ }
11544
11713
  function collectPendingEntries(contexts, cli) {
11545
11714
  const cliTypecheck = {
11546
11715
  enabled: cli.typecheck,
@@ -11551,46 +11720,15 @@ function collectPendingEntries(contexts, cli) {
11551
11720
  const typeTestEntries = [];
11552
11721
  const typeTestProjects = [];
11553
11722
  const typecheckByDirectory = /* @__PURE__ */ new Map();
11554
- for (const ctx of contexts) {
11555
- const { packageDirectory } = ctx.info;
11556
- const packageTypecheck = resolveTypecheckConfig({
11557
- cli: cliTypecheck,
11558
- root: ctx.pkgConfig.typecheck
11559
- });
11560
- for (const project of ctx.projects) {
11561
- const typecheck = resolveTypecheckConfig({
11562
- cli: cliTypecheck,
11563
- project: project.typecheck,
11564
- root: ctx.pkgConfig.typecheck
11565
- });
11566
- const projectConfig = buildProjectExecutionConfig(ctx.pkgConfig, project);
11567
- const testFiles = typecheck.only ? [] : discoverProjectTestFiles(project, packageDirectory);
11568
- pending.push({
11569
- pkg: ctx.info.name,
11570
- project,
11571
- projectConfig,
11572
- testFiles
11573
- });
11574
- if (!typecheck.enabled) continue;
11575
- const typeTestFiles = discoverProjectTypeTests(project, typecheck, packageDirectory);
11576
- if (typeTestFiles.length === 0) continue;
11577
- typeTestProjects.push({
11578
- pkg: ctx.info.name,
11579
- project: project.displayName
11580
- });
11581
- typeTestEntries.push({
11582
- cwd: packageDirectory,
11583
- files: typeTestFiles,
11584
- ...typecheck.tsconfig !== void 0 ? { tsconfig: typecheck.tsconfig } : {}
11585
- });
11586
- typecheckByDirectory.set(packageDirectory, {
11587
- ignoreSourceErrors: packageTypecheck.ignoreSourceErrors,
11588
- pkg: ctx.info.name,
11589
- spawnTimeout: packageTypecheck.spawnTimeout,
11590
- timeout: ctx.pkgConfig.timeout
11591
- });
11592
- }
11593
- }
11723
+ for (const ctx of contexts) collectPackageProjectEntries(ctx, cliTypecheck, resolveTypecheckConfig({
11724
+ cli: cliTypecheck,
11725
+ root: ctx.pkgConfig.typecheck
11726
+ }), {
11727
+ pending,
11728
+ typecheckByDirectory,
11729
+ typeTestEntries,
11730
+ typeTestProjects
11731
+ });
11594
11732
  return {
11595
11733
  pending,
11596
11734
  typecheckByDirectory,
@@ -11746,19 +11884,24 @@ function liveProjectsByPackage(pending) {
11746
11884
  }
11747
11885
  return live;
11748
11886
  }
11887
+ function collectLiveProjectStubMounts(project, ctx) {
11888
+ const stubMounts = [];
11889
+ for (const mount of project.rojoMounts) {
11890
+ if (hasUserAuthoredConfig(path$1.resolve(ctx.info.packageDirectory, mount.fsPath))) continue;
11891
+ stubMounts.push({
11892
+ absStubPath: path$1.resolve(ctx.cacheRoot, mount.fsPath, STUB_FILENAME),
11893
+ dataModelPath: mount.dataModelPath
11894
+ });
11895
+ }
11896
+ return stubMounts;
11897
+ }
11749
11898
  function writeStubsAndBuildDescriptors(contexts, liveProjects) {
11750
11899
  return contexts.map((ctx) => {
11751
11900
  const live = liveProjects.get(ctx.info.name) ?? /* @__PURE__ */ new Set();
11752
11901
  const liveProjectsForPackage = ctx.projects.filter((project) => live.has(project.displayName));
11753
11902
  generateProjectStubs(liveProjectsForPackage, ctx.info.packageDirectory, ctx.cacheRoot);
11754
11903
  const stubMounts = [];
11755
- for (const project of liveProjectsForPackage) for (const mount of project.rojoMounts) {
11756
- if (hasUserAuthoredConfig(path$1.resolve(ctx.info.packageDirectory, mount.fsPath))) continue;
11757
- stubMounts.push({
11758
- absStubPath: path$1.resolve(ctx.cacheRoot, mount.fsPath, STUB_FILENAME),
11759
- dataModelPath: mount.dataModelPath
11760
- });
11761
- }
11904
+ for (const project of liveProjectsForPackage) stubMounts.push(...collectLiveProjectStubMounts(project, ctx));
11762
11905
  return {
11763
11906
  ...ctx.descriptor,
11764
11907
  stubMounts
@@ -11782,6 +11925,7 @@ function discoverWorkspaceRoot(cwd) {
11782
11925
  //#endregion
11783
11926
  //#region src/workspace/package-resolver.ts
11784
11927
  const JEST_CONFIG_MARKER$1 = /^jest\.config\.[^.]+$/;
11928
+ const TRAILING_SLASH = /\/$/;
11785
11929
  function readPackageJsonName(packageJsonPath) {
11786
11930
  if (!fs$1.existsSync(packageJsonPath)) return;
11787
11931
  const raw = parsePackageJson(packageJsonPath);
@@ -11819,7 +11963,7 @@ function listPnpmPackages(workspaceRoot) {
11819
11963
  const patterns = parseYAML(fs$1.readFileSync(yamlPath, "utf-8")).packages ?? [];
11820
11964
  const packages = [];
11821
11965
  for (const pattern of patterns) {
11822
- const matches = globSync(`${pattern.replace(/\/$/, "")}/package.json`, { cwd: workspaceRoot });
11966
+ const matches = globSync(`${pattern.replace(TRAILING_SLASH, "")}/package.json`, { cwd: workspaceRoot });
11823
11967
  for (const match of matches) {
11824
11968
  const packageJsonPath = path$1.join(workspaceRoot, match);
11825
11969
  const name = readPackageJsonName(packageJsonPath);
@@ -11831,9 +11975,6 @@ function listPnpmPackages(workspaceRoot) {
11831
11975
  }
11832
11976
  return packages;
11833
11977
  }
11834
- function inferPackageName(packageDirectory) {
11835
- return readPackageJsonName(path$1.join(packageDirectory, "package.json")) ?? path$1.basename(packageDirectory);
11836
- }
11837
11978
  function assertNoDuplicateNames(packages, workspaceRoot) {
11838
11979
  const byName = /* @__PURE__ */ new Map();
11839
11980
  for (const package_ of packages) {
@@ -11843,26 +11984,29 @@ function assertNoDuplicateNames(packages, workspaceRoot) {
11843
11984
  byName.set(package_.name, list);
11844
11985
  }
11845
11986
  for (const [name, paths] of byName) if (paths.length > 1) {
11846
- const sorted = [...paths].sort();
11987
+ const sorted = paths.toSorted();
11847
11988
  throw new Error(`Duplicate package name "${name}" from ${sorted.join(" and ")}. Add a package.json with a unique \`name\`, or rename a directory.`);
11848
11989
  }
11849
11990
  }
11991
+ function inferPackageName(packageDirectory) {
11992
+ return readPackageJsonName(path$1.join(packageDirectory, "package.json")) ?? path$1.basename(packageDirectory);
11993
+ }
11994
+ function collectPackagesFromMatches(matches, workspaceRoot, seenDirectories, packages) {
11995
+ for (const match of matches) {
11996
+ if (!JEST_CONFIG_MARKER$1.test(path$1.basename(match))) continue;
11997
+ const packageDirectory = path$1.dirname(path$1.join(workspaceRoot, match));
11998
+ if (seenDirectories.has(packageDirectory)) continue;
11999
+ seenDirectories.add(packageDirectory);
12000
+ packages.push({
12001
+ name: inferPackageName(packageDirectory),
12002
+ packageDirectory
12003
+ });
12004
+ }
12005
+ }
11850
12006
  function enumerateFromGlobs(workspaceRoot, patterns) {
11851
12007
  const seenDirectories = /* @__PURE__ */ new Set();
11852
12008
  const packages = [];
11853
- for (const pattern of patterns) {
11854
- const matches = globSync(`${pattern.replace(/\/$/, "")}/jest.config.*`, { cwd: workspaceRoot });
11855
- for (const match of matches) {
11856
- if (!JEST_CONFIG_MARKER$1.test(path$1.basename(match))) continue;
11857
- const packageDirectory = path$1.dirname(path$1.join(workspaceRoot, match));
11858
- if (seenDirectories.has(packageDirectory)) continue;
11859
- seenDirectories.add(packageDirectory);
11860
- packages.push({
11861
- name: inferPackageName(packageDirectory),
11862
- packageDirectory
11863
- });
11864
- }
11865
- }
12009
+ for (const pattern of patterns) collectPackagesFromMatches(globSync(`${pattern.replace(TRAILING_SLASH, "")}/jest.config.*`, { cwd: workspaceRoot }), workspaceRoot, seenDirectories, packages);
11866
12010
  assertNoDuplicateNames(packages, workspaceRoot);
11867
12011
  return packages;
11868
12012
  }
@@ -11873,7 +12017,7 @@ function resolvePosixShim(binDirectory, command) {
11873
12017
  const candidate = path$1.join(binDirectory, command);
11874
12018
  return fs$1.existsSync(candidate) ? candidate : command;
11875
12019
  }
11876
- function buildCmdExeArgs(command, args) {
12020
+ function buildCommandExeArgs(command, args) {
11877
12021
  return [
11878
12022
  "/d",
11879
12023
  "/s",
@@ -11935,7 +12079,7 @@ function runTool(command, args, cwd) {
11935
12079
  PATH: `${binDirectory}${path$1.delimiter}${process.env["PATH"]}`
11936
12080
  } : process.env;
11937
12081
  const file = isWindows ? "cmd.exe" : resolvePosixShim(binDirectory, command);
11938
- const spawnArgs = isWindows ? buildCmdExeArgs(command, args) : args;
12082
+ const spawnArgs = isWindows ? buildCommandExeArgs(command, args) : args;
11939
12083
  try {
11940
12084
  return cp.execFileSync(file, spawnArgs, {
11941
12085
  cwd,
@@ -12329,4 +12473,4 @@ async function runJestRoblox(cli, config) {
12329
12473
  }
12330
12474
  }
12331
12475
  //#endregion
12332
- export { parseJestOutput as $, outputSingleResult as A, writeJsonFile$1 as B, visitStatement as C, emitBuildManifest as D, buildManifestSchema as E, formatGameOutputNotice as F, hashFile as G, formatResult as H, parseGameOutput as I, readManifest as J, MANIFEST_VERSION as K, writeGameOutput as L, formatJobSummary as M, formatExecuteOutput as N, readBuildManifest as O, runProjects as P, extractJsonFromOutput as Q, createTimingCollector as R, visitExpression as S, BUILD_MANIFEST_VERSION as T, formatTestSummary as U, formatFailure as V, formatBanner as W, applyAttribution as X, writeManifest as Y, LuauScriptError as Z, generateTestScript as _, loadRojoTree as a, JEST_ARGV_EXCLUDED_KEYS as at, findRojoProject as b, StudioBackend as c, VALID_BACKENDS as ct, createStudioCliBackend as d, isValidBackend as dt, mergeCliWithConfig as et, OpenCloudBackend as f, ConfigError as ft, buildJestArgv as g, walkErrorChain as h, collectStubMounts as i, GLOBAL_TEST_KEYS as it, formatAnnotations as j, outputMultiResult as k, createStudioBackend as l, defineConfig as lt, formatMissingScopes as m, runJestRoblox as n, resolveConfig as nt, runTypecheck as o, ROOT_CLI_KEYS as ot, createOpenCloudBackend as p, version as pt, manifestSchema as q, runSingleOrMulti as r, DEFAULT_CONFIG as rt, resolveAllProjects as s, SHARED_TEST_KEYS as st, getRawProjects as t, loadConfig$1 as tt, StudioCliBackend as u, defineProject as ut, COVERAGE_BUILD_MANIFEST_PATH as v, buildPlace as w, visitBlock as x, COVERAGE_MANIFEST_PATH as y, formatJson as z };
12476
+ export { writeManifest as $, buildManifestSchema as A, parseGameOutput as B, COVERAGE_MANIFEST_PATH as C, visitStatement as D, visitExpression as E, formatAnnotations as F, formatFailure as G, createTimingCollector as H, formatJobSummary as I, formatBanner as J, formatResult as K, formatExecuteOutput as L, readBuildManifest as M, outputMultiResult as N, buildPlace as O, outputSingleResult as P, readManifest as Q, runProjects as R, COVERAGE_BUILD_MANIFEST_PATH as S, visitBlock as T, formatJson as U, writeGameOutput as V, writeJsonFile$1 as W, MANIFEST_VERSION as X, hashFile as Y, manifestSchema as Z, createOpenCloudBackend as _, defineProject as _t, collectStubMounts as a, parseCoverageEnvelope as at, buildJestArgv as b, version as bt, runTypecheck as c, loadConfig$1 as ct, resolveAllProjects as d, GLOBAL_TEST_KEYS as dt, applyAttribution as et, StudioBackend as f, JEST_ARGV_EXCLUDED_KEYS as ft, OpenCloudBackend as g, defineConfig as gt, createStudioCliBackend as h, VALID_BACKENDS as ht, buildImplicitProject as i, normalizeRawCoverage as it, emitBuildManifest as j, BUILD_MANIFEST_VERSION as k, cleanLeftoverStubs as l, resolveConfig as lt, StudioCliBackend as m, SHARED_TEST_KEYS as mt, runJestRoblox as n, extractJsonFromOutput as nt, loadRojoTree as o, mergeRawCoverage as ot, createStudioBackend as p, ROOT_CLI_KEYS as pt, formatTestSummary as q, runSingleOrMulti as r, parseJestOutput as rt, prepareBakedCoverage as s, mergeCliWithConfig as st, getRawProjects as t, LuauScriptError as tt, generateProjectStubs as u, DEFAULT_CONFIG as ut, formatMissingScopes as v, isValidBackend as vt, findRojoProject as w, generateTestScript as x, walkErrorChain as y, ConfigError as yt, formatGameOutputNotice as z };