@isentinel/jest-roblox 0.3.7 → 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.
@@ -25,17 +25,18 @@ import typescript from "highlight.js/lib/languages/typescript";
25
25
  import { performance } from "node:perf_hooks";
26
26
  import { Buffer } from "node:buffer";
27
27
  import * as cp from "node:child_process";
28
- import { execFile } from "node:child_process";
28
+ import { execFile, spawn } from "node:child_process";
29
29
  import * as os from "node:os";
30
30
  import { LuauExecutionClient } from "@bedrock-rbx/ocale/luau-execution";
31
31
  import { PlacesClient } from "@bedrock-rbx/ocale/places";
32
32
  import { StorageClient } from "@bedrock-rbx/ocale/storage";
33
33
  import { WebSocketServer } from "ws";
34
34
  import { setTimeout as setTimeout$1 } from "node:timers/promises";
35
+ import { once } from "node:events";
35
36
  import { parseJSONC, parseYAML } from "confbox";
36
37
  import { Visitor, parseSync } from "oxc-parser";
37
38
  //#region package.json
38
- var version = "0.3.7";
39
+ var version = "0.3.9";
39
40
  //#endregion
40
41
  //#region src/config/errors.ts
41
42
  var ConfigError = class extends Error {
@@ -50,11 +51,20 @@ var ConfigError = class extends Error {
50
51
  const VALID_BACKENDS = /* @__PURE__ */ new Set([
51
52
  "auto",
52
53
  "open-cloud",
53
- "studio"
54
+ "studio",
55
+ "studio-cli"
54
56
  ]);
55
57
  function isValidBackend(value) {
56
58
  return VALID_BACKENDS.has(value);
57
59
  }
60
+ /**
61
+ * Resolve a config's `placeFile` against its `rootDir` to an absolute path. The
62
+ * one resolution every backend shares: `placeFile` is rootDir-relative, and a
63
+ * coverage run has the run layer point it at the instrumented place.
64
+ */
65
+ function resolvePlaceFilePath(config) {
66
+ return path$1.resolve(config.rootDir, config.placeFile);
67
+ }
58
68
  const DEFAULT_CONFIG = {
59
69
  backend: "auto",
60
70
  collectCoverage: false,
@@ -215,7 +225,7 @@ const globalTestConfigSchema = type({
215
225
  });
216
226
  const configSchema = type({
217
227
  "+": "reject",
218
- "backend?": type("'auto'|'open-cloud'|'studio'"),
228
+ "backend?": type("'auto'|'open-cloud'|'studio'|'studio-cli'"),
219
229
  "color?": "boolean",
220
230
  "config?": "string",
221
231
  "coverageCache?": "boolean",
@@ -233,6 +243,7 @@ const configSchema = type({
233
243
  "rootDir?": "string",
234
244
  "showLuau?": "boolean",
235
245
  "sourceMap?": "boolean",
246
+ "studioPath?": "string",
236
247
  "test?": globalTestConfigSchema,
237
248
  "timeout?": "number",
238
249
  "universeId?": "string",
@@ -256,6 +267,7 @@ const ROOT_CLI_KEYS_LIST = [
256
267
  "rootDir",
257
268
  "showLuau",
258
269
  "sourceMap",
270
+ "studioPath",
259
271
  "timeout",
260
272
  "universeId",
261
273
  "workspace"
@@ -426,7 +438,11 @@ function resolveConfig(config) {
426
438
  const { test, ...rest } = config;
427
439
  const definedRest = Object.fromEntries(Object.entries(rest).filter(([, value]) => value !== void 0));
428
440
  const definedTest = test === void 0 ? {} : Object.fromEntries(Object.entries(test).filter(([, value]) => value !== void 0));
429
- const resolved = Object.assign({}, DEFAULT_CONFIG, definedTest, definedRest);
441
+ const resolved = {
442
+ ...DEFAULT_CONFIG,
443
+ ...definedTest,
444
+ ...definedRest
445
+ };
430
446
  if (config.gameOutput === true) resolved.gameOutput = path$1.join(resolved.rootDir, "game-output.log");
431
447
  if (config.outputFile === true) resolved.outputFile = path$1.join(resolved.rootDir, "jest-output.log");
432
448
  return resolved;
@@ -600,6 +616,7 @@ function mergeCliWithConfig(cli, config) {
600
616
  showLuau: cli.showLuau ?? config.showLuau,
601
617
  silent: cli.silent ?? config.silent,
602
618
  sourceMap: cli.sourceMap ?? config.sourceMap,
619
+ studioPath: cli.studioPath ?? config.studioPath,
603
620
  testNamePattern: cli.testNamePattern ?? config.testNamePattern,
604
621
  testPathPattern: cli.testPathPattern ?? config.testPathPattern,
605
622
  timeout: cli.timeout ?? config.timeout,
@@ -807,7 +824,7 @@ function passthroughFileBranches(resources, fileCoverage, pendingBranches) {
807
824
  });
808
825
  if (locations.length === 0) continue;
809
826
  const firstLocation = locations[0];
810
- const lastLocation = locations[locations.length - 1];
827
+ const lastLocation = locations.at(-1);
811
828
  assert(firstLocation !== void 0 && lastLocation !== void 0, "Branch locations must not be empty after filtering");
812
829
  fileBranches.push({
813
830
  armHitCounts: entry.locations.map((_, index) => armHitCounts[index] ?? 0),
@@ -1010,7 +1027,7 @@ function mapFileBranches(resources, fileCoverage, pendingBranches) {
1010
1027
  pendingBranches.set(result.tsPath, fileBranches);
1011
1028
  }
1012
1029
  const firstLocation = result.locations[0];
1013
- const lastLocation = result.locations[result.locations.length - 1];
1030
+ const lastLocation = result.locations.at(-1);
1014
1031
  assert(firstLocation !== void 0 && lastLocation !== void 0, "Branch locations must not be empty after successful mapping");
1015
1032
  fileBranches.push({
1016
1033
  armHitCounts: entry.locations.map((_, index) => armHitCounts[index] ?? 0),
@@ -1128,6 +1145,50 @@ function mergeFileCoverage(a, b) {
1128
1145
  return merged;
1129
1146
  }
1130
1147
  //#endregion
1148
+ //#region src/coverage-pipeline/agent-table-filter.ts
1149
+ const TEST_MARKER = /\.(?:test|spec)(?:-d)?(\.[^./]+)$/;
1150
+ /**
1151
+ * Build a predicate that keeps a universe file when it is the **source twin** of
1152
+ * one of `testFiles` — the file under test reached by stripping the
1153
+ * `.test`/`.spec` marker and keeping the same directory. Membership is exact
1154
+ * (not glob), so source paths carrying glob metacharacters (route-group
1155
+ * directories like `(foo)`) compare literally. Each test file is resolved
1156
+ * against `rootDirectory` and normalized so positionals (absolute) and
1157
+ * glob-discovered files (relative) land in the same namespace as the resolved
1158
+ * universe keys.
1159
+ */
1160
+ function sourceTwinFilter(testFiles, rootDirectory) {
1161
+ const twins = new Set(testFiles.map((file) => {
1162
+ return normalizeWindowsPath(path$1.resolve(rootDirectory, file)).replace(TEST_MARKER, "$1");
1163
+ }));
1164
+ return (candidate) => twins.has(candidate);
1165
+ }
1166
+ /**
1167
+ * Build a predicate that keeps a universe file living under one of `roots` — the
1168
+ * static include roots of the selected `--project`(s). Containment is a path
1169
+ * prefix at a directory boundary (so root `src/shared` matches
1170
+ * `src/shared/x.ts` but not `src/shared-extra/x.ts`). Roots are normalized to
1171
+ * the same absolute namespace as the resolved universe keys.
1172
+ */
1173
+ function projectRootFilter(roots) {
1174
+ const normalizedRoots = roots.map((root) => normalizeWindowsPath(root));
1175
+ return (candidate) => {
1176
+ return normalizedRoots.some((root) => candidate === root || candidate.startsWith(`${root}/`));
1177
+ };
1178
+ }
1179
+ /**
1180
+ * Narrow a mapped coverage universe to the files `predicate` keeps. Each key is
1181
+ * resolved to a normalized-absolute path before the predicate runs, mirroring
1182
+ * how `filterCoverageUniverse` canonicalizes paths so the two layers agree. Used
1183
+ * only for the agent text table; every other reporter and the totals line keep
1184
+ * the full universe.
1185
+ */
1186
+ function narrowMappedForAgentTable(mapped, predicate) {
1187
+ return { files: Object.fromEntries(Object.entries(mapped.files).filter(([filePath]) => {
1188
+ return predicate(normalizeWindowsPath(path$1.resolve(filePath)));
1189
+ })) };
1190
+ }
1191
+ //#endregion
1131
1192
  //#region src/coverage-pipeline/coverage-universe.ts
1132
1193
  /**
1133
1194
  * Decides which mapped source files make up the coverage report universe.
@@ -1184,31 +1245,37 @@ function printCoverageHeader(agentMode = false) {
1184
1245
  }
1185
1246
  const TEXT_REPORTERS = /* @__PURE__ */ new Set(["text", "text-summary"]);
1186
1247
  function generateReports(options) {
1187
- const coverageMap = buildCoverageMap$2(filterCoverageUniverse(options.mapped, {
1248
+ const filtered = filterCoverageUniverse(options.mapped, {
1188
1249
  ignore: options.coveragePathIgnorePatterns,
1189
1250
  include: options.collectCoverageFrom
1190
- }));
1251
+ });
1252
+ const coverageMap = buildCoverageMap$2(filtered);
1191
1253
  const agentMode = options.agentMode === true;
1254
+ const defaultSummarizer = agentMode ? "flat" : "pkg";
1192
1255
  const context = istanbulReport.createContext({
1193
1256
  coverageMap,
1194
- defaultSummarizer: agentMode ? "flat" : "pkg",
1257
+ defaultSummarizer,
1195
1258
  dir: options.coverageDirectory
1196
1259
  });
1260
+ const textTable = resolveTextTableView({
1261
+ agentTextFilter: agentMode ? options.agentTextFilter : void 0,
1262
+ coverageDirectory: options.coverageDirectory,
1263
+ defaultSummarizer,
1264
+ fallback: context,
1265
+ filtered
1266
+ });
1197
1267
  const terminalColumns = getTerminalColumns();
1198
1268
  const hasTextReporter = options.reporters.some((name) => TEXT_REPORTERS.has(name));
1199
1269
  const allFilesFull = agentMode && isAllFilesFull(coverageMap);
1200
1270
  if (allFilesFull && hasTextReporter) printCompactFullSummary(coverageMap);
1201
- for (const reporterName of options.reporters) {
1202
- if (!isValidReporter(reporterName)) throw new Error(`Unknown coverage reporter: ${reporterName}`);
1203
- if (allFilesFull && TEXT_REPORTERS.has(reporterName)) continue;
1204
- let reporterOptions = {};
1205
- if (reporterName === "text") reporterOptions = {
1206
- maxCols: terminalColumns,
1207
- skipFull: agentMode
1208
- };
1209
- else if (TEXT_REPORTERS.has(reporterName)) reporterOptions = { skipFull: agentMode };
1210
- istanbulReports.create(reporterName, reporterOptions).execute(context);
1211
- }
1271
+ runReporters({
1272
+ agentMode,
1273
+ allFilesFull,
1274
+ fullContext: context,
1275
+ reporters: options.reporters,
1276
+ terminalColumns,
1277
+ textTable
1278
+ });
1212
1279
  if (agentMode && !allFilesFull && hasTextReporter && coverageMap.files().length > 0) process.stdout.write(formatAgentTotals(coverageMap));
1213
1280
  }
1214
1281
  function checkThresholds(mapped, thresholds, collectCoverageFrom, coveragePathIgnorePatterns) {
@@ -1252,38 +1319,23 @@ function checkThresholds(mapped, thresholds, collectCoverageFrom, coveragePathIg
1252
1319
  passed: failures.length === 0
1253
1320
  };
1254
1321
  }
1255
- function printCompactFullSummary(coverageMap) {
1256
- const fileCount = coverageMap.files().length;
1257
- const label = fileCount === 1 ? "file" : "files";
1258
- process.stdout.write(`Coverage: 100% (${fileCount} ${label})\n`);
1259
- }
1260
- function formatTotalsPart(label, totals) {
1261
- assert(typeof totals.pct === "number", "coverage summary pct must be numeric");
1262
- return `${totals.pct}% ${label} (${totals.covered}/${totals.total})`;
1263
- }
1264
- function formatAgentTotals(coverageMap) {
1265
- const summary = coverageMap.getCoverageSummary();
1266
- return `Coverage: ${[
1267
- formatTotalsPart("stmts", summary.statements),
1268
- formatTotalsPart("branch", summary.branches),
1269
- formatTotalsPart("funcs", summary.functions),
1270
- formatTotalsPart("lines", summary.lines)
1271
- ].join(" | ")}\n`;
1272
- }
1273
- function getTerminalColumns() {
1274
- if (process.stdout.columns !== void 0) return process.stdout.columns;
1275
- const columnsEnvironment = process.env["COLUMNS"];
1276
- if (columnsEnvironment === void 0) return;
1277
- const parsed = Number(columnsEnvironment);
1278
- return Number.isFinite(parsed) && parsed > 0 ? parsed : void 0;
1322
+ function isValidReporter(name) {
1323
+ return VALID_REPORTERS.has(name);
1279
1324
  }
1280
- function isAllFilesFull(coverageMap) {
1281
- const files = coverageMap.files();
1282
- if (files.length === 0) return false;
1283
- return files.every((file) => {
1284
- const summary = coverageMap.fileCoverageFor(file).toSummary();
1285
- return summary.statements.pct === 100 && summary.branches.pct === 100 && summary.functions.pct === 100 && summary.lines.pct === 100;
1286
- });
1325
+ function runReporters(options) {
1326
+ const { agentMode, allFilesFull, fullContext, reporters, terminalColumns, textTable } = options;
1327
+ for (const reporterName of reporters) {
1328
+ if (!isValidReporter(reporterName)) throw new Error(`Unknown coverage reporter: ${reporterName}`);
1329
+ if (allFilesFull && TEXT_REPORTERS.has(reporterName)) continue;
1330
+ if (reporterName === "text" && textTable.isEmpty) continue;
1331
+ let reporterOptions = {};
1332
+ if (reporterName === "text") reporterOptions = {
1333
+ maxCols: terminalColumns,
1334
+ skipFull: agentMode
1335
+ };
1336
+ else if (TEXT_REPORTERS.has(reporterName)) reporterOptions = { skipFull: agentMode };
1337
+ istanbulReports.create(reporterName, reporterOptions).execute(reporterName === "text" ? textTable.context : fullContext);
1338
+ }
1287
1339
  }
1288
1340
  function buildCoverageMap$2(mapped) {
1289
1341
  const coverageMap = istanbulCoverage.createCoverageMap({});
@@ -1315,8 +1367,54 @@ function buildCoverageMap$2(mapped) {
1315
1367
  }
1316
1368
  return coverageMap;
1317
1369
  }
1318
- function isValidReporter(name) {
1319
- return VALID_REPORTERS.has(name);
1370
+ function resolveTextTableView(options) {
1371
+ const { agentTextFilter, coverageDirectory, defaultSummarizer, fallback, filtered } = options;
1372
+ if (agentTextFilter === void 0) return {
1373
+ context: fallback,
1374
+ isEmpty: false
1375
+ };
1376
+ const textCoverageMap = buildCoverageMap$2(narrowMappedForAgentTable(filtered, agentTextFilter));
1377
+ return {
1378
+ context: istanbulReport.createContext({
1379
+ coverageMap: textCoverageMap,
1380
+ defaultSummarizer,
1381
+ dir: coverageDirectory
1382
+ }),
1383
+ isEmpty: textCoverageMap.files().length === 0
1384
+ };
1385
+ }
1386
+ function printCompactFullSummary(coverageMap) {
1387
+ const fileCount = coverageMap.files().length;
1388
+ const label = fileCount === 1 ? "file" : "files";
1389
+ process.stdout.write(`Coverage: 100% (${fileCount} ${label})\n`);
1390
+ }
1391
+ function formatTotalsPart(label, totals) {
1392
+ assert(typeof totals.pct === "number", "coverage summary pct must be numeric");
1393
+ return `${totals.pct}% ${label} (${totals.covered}/${totals.total})`;
1394
+ }
1395
+ function formatAgentTotals(coverageMap) {
1396
+ const summary = coverageMap.getCoverageSummary();
1397
+ return `Coverage: ${[
1398
+ formatTotalsPart("stmts", summary.statements),
1399
+ formatTotalsPart("branch", summary.branches),
1400
+ formatTotalsPart("funcs", summary.functions),
1401
+ formatTotalsPart("lines", summary.lines)
1402
+ ].join(" | ")}\n`;
1403
+ }
1404
+ function getTerminalColumns() {
1405
+ if (process.stdout.columns !== void 0) return process.stdout.columns;
1406
+ const columnsEnvironment = process.env["COLUMNS"];
1407
+ if (columnsEnvironment === void 0) return;
1408
+ const parsed = Number(columnsEnvironment);
1409
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : void 0;
1410
+ }
1411
+ function isAllFilesFull(coverageMap) {
1412
+ const files = coverageMap.files();
1413
+ if (files.length === 0) return false;
1414
+ return files.every((file) => {
1415
+ const summary = coverageMap.fileCoverageFor(file).toSummary();
1416
+ return summary.statements.pct === 100 && summary.branches.pct === 100 && summary.functions.pct === 100 && summary.lines.pct === 100;
1417
+ });
1320
1418
  }
1321
1419
  //#endregion
1322
1420
  //#region packages/rojo-utils/dist/index.mjs
@@ -1436,19 +1534,22 @@ function loadRojoProject(projectPath) {
1436
1534
  tree
1437
1535
  };
1438
1536
  }
1537
+ const TrailingSlashPattern = /\/$/;
1439
1538
  function collectMounts(node, currentDataModelPath, classify) {
1440
1539
  const result = [];
1441
1540
  walk(node, currentDataModelPath, classify, result);
1442
1541
  return result;
1443
1542
  }
1444
1543
  function pruneAncestors(paths) {
1445
- 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
+ });
1446
1547
  }
1447
1548
  function addDirectoryMount(node, dataModelPath, classify, result) {
1448
1549
  const rawPath = node.$path;
1449
1550
  if (typeof rawPath !== "string") return;
1450
1551
  if (rawPath.endsWith(".project.json")) return;
1451
- const fsPath = rawPath.replace(/\/$/, "");
1552
+ const fsPath = rawPath.replace(TrailingSlashPattern, "");
1452
1553
  if (classify(fsPath) === "directory") result.push({
1453
1554
  dataModelPath,
1454
1555
  fsPath
@@ -1465,10 +1566,11 @@ function walk(node, currentDataModelPath, classify, result) {
1465
1566
  walk(value, childDataModelPath, classify, result);
1466
1567
  }
1467
1568
  }
1569
+ const TRAILING_SLASH$1$1 = /\/$/;
1468
1570
  function matchNodePath(childNode, targetPath, childDataModelPath) {
1469
1571
  const nodePath = childNode.$path;
1470
1572
  if (typeof nodePath !== "string") return;
1471
- const normalizedNodePath = nodePath.replace(/\/$/, "");
1573
+ const normalizedNodePath = nodePath.replace(TRAILING_SLASH$1$1, "");
1472
1574
  if (normalizedNodePath === targetPath) return childDataModelPath;
1473
1575
  if (targetPath.startsWith(`${normalizedNodePath}/`)) return `${childDataModelPath}/${targetPath.slice(normalizedNodePath.length + 1)}`;
1474
1576
  }
@@ -1656,7 +1758,7 @@ var RojoResolver = class RojoResolver {
1656
1758
  const relativePath = path.relative(partition.fsPath, stripped);
1657
1759
  const relativeParts = relativePath === "" ? [] : relativePath.split(path.sep);
1658
1760
  if (ROJO_SCRIPT_EXTS.has(extension) && relativeParts.at(-1) === INIT_NAME) relativeParts.pop();
1659
- return partition.rbxPath.concat(relativeParts);
1761
+ return [...partition.rbxPath, ...relativeParts];
1660
1762
  }
1661
1763
  }
1662
1764
  getRbxTypeFromFilePath(filePath) {
@@ -1683,8 +1785,8 @@ var RojoResolver = class RojoResolver {
1683
1785
  rbxPath: partition.rbxPath.slice()
1684
1786
  };
1685
1787
  }),
1686
- walkedConfigFiles: Array.from(this.walkedConfigFilesInternal),
1687
- walkedDirs: Array.from(this.walkedDirectoriesInternal),
1788
+ walkedConfigFiles: [...this.walkedConfigFilesInternal],
1789
+ walkedDirs: [...this.walkedDirectoriesInternal],
1688
1790
  warnings: this.warnings.slice()
1689
1791
  };
1690
1792
  }
@@ -1752,6 +1854,10 @@ var RojoResolver = class RojoResolver {
1752
1854
  }
1753
1855
  parsePath(itemPath) {
1754
1856
  const luauPath = convertToLuau(itemPath);
1857
+ if (ROJO_FILE_REGEX.test(path.basename(luauPath))) {
1858
+ this.parseConfig(luauPath, true);
1859
+ return;
1860
+ }
1755
1861
  const realPath = fs$1.existsSync(luauPath) ? this.cachedRealpath(luauPath) : luauPath;
1756
1862
  const extension = path.extname(luauPath);
1757
1863
  if (ROJO_MODULE_EXTS.has(extension)) this.filePathToRbxPathMap.set(luauPath, [...this.rbxPath]);
@@ -1772,7 +1878,8 @@ var RojoResolver = class RojoResolver {
1772
1878
  if (!doNotPush) this.rbxPath.push(name);
1773
1879
  if (tree.$path !== void 0) this.parsePath(path.resolve(basePath, typeof tree.$path === "string" ? tree.$path : tree.$path.optional));
1774
1880
  if (tree.$className === "DataModel") this.isGame = true;
1775
- 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]);
1776
1883
  if (!doNotPush) this.rbxPath.pop();
1777
1884
  }
1778
1885
  searchChildren(directory, directoryEntries) {
@@ -1816,6 +1923,91 @@ var RojoResolver = class RojoResolver {
1816
1923
  }
1817
1924
  };
1818
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
1819
2011
  //#region src/reporter/parser.ts
1820
2012
  const TASK_SCRIPT_PREFIX = /^TaskScript:\d+:\s*/;
1821
2013
  var LuauScriptError = class extends Error {
@@ -1878,10 +2070,8 @@ function parseJestOutput(output) {
1878
2070
  }
1879
2071
  function countBraces(line) {
1880
2072
  let count = 0;
1881
- for (const character of line) {
1882
- if (character === "{") count++;
1883
- if (character === "}") count--;
1884
- }
2073
+ for (const character of line) if (character === "{") count++;
2074
+ else if (character === "}") count--;
1885
2075
  return count;
1886
2076
  }
1887
2077
  function isValidJson(text) {
@@ -1932,56 +2122,8 @@ function extractLuauTiming(parsed) {
1932
2122
  for (const [key, value] of Object.entries(timing)) if (typeof value === "number") record[key] = value;
1933
2123
  return Object.keys(record).length > 0 ? record : void 0;
1934
2124
  }
1935
- /**
1936
- * Luau 1-based integer-keyed tables serialize as JSON arrays.
1937
- * Convert arrays to string-keyed Records with 1-based keys to match cov-map format.
1938
- */
1939
- function normalizeHitCounts(data) {
1940
- if (Array.isArray(data)) {
1941
- const result = {};
1942
- let index = 0;
1943
- for (const element of data) {
1944
- result[String(index + 1)] = typeof element === "number" ? element : 0;
1945
- index++;
1946
- }
1947
- return result;
1948
- }
1949
- if (typeof data === "object" && data !== null) return data;
1950
- return {};
1951
- }
1952
- /**
1953
- * Normalize branch hit counts from Luau's nested array format.
1954
- * Luau serializes `__cov_b` as an array of arrays: `[[0,0,0], [0,0]]`.
1955
- * Convert outer array to string-keyed Record with 1-based keys,
1956
- * keeping inner arrays as-is.
1957
- */
1958
- function normalizeBranchCounts(data) {
1959
- if (Array.isArray(data)) {
1960
- const result = {};
1961
- let index = 0;
1962
- for (const inner of data) {
1963
- if (Array.isArray(inner)) result[String(index + 1)] = inner.map((value) => typeof value === "number" ? value : 0);
1964
- else result[String(index + 1)] = [];
1965
- index++;
1966
- }
1967
- return result;
1968
- }
1969
- if (typeof data === "object" && data !== null) return data;
1970
- return {};
1971
- }
1972
2125
  function extractCoverageData(parsed) {
1973
- const coverage = parsed["_coverage"];
1974
- if (coverage === void 0 || coverage === null || typeof coverage !== "object") return;
1975
- const record = {};
1976
- for (const [key, value] of Object.entries(coverage)) if (typeof value === "object" && value !== null && "s" in value) {
1977
- const raw = value;
1978
- record[key] = {
1979
- b: raw.b !== void 0 ? normalizeBranchCounts(raw.b) : void 0,
1980
- f: raw.f !== void 0 ? normalizeHitCounts(raw.f) : void 0,
1981
- s: normalizeHitCounts(raw.s)
1982
- };
1983
- }
1984
- return Object.keys(record).length > 0 ? record : void 0;
2126
+ return normalizeRawCoverage(parsed["_coverage"]);
1985
2127
  }
1986
2128
  function extractPerTestCoverage(parsed) {
1987
2129
  const raw = parsed["_perTestCoverage"];
@@ -2409,13 +2551,14 @@ function replacePrefix(filePath, from, to) {
2409
2551
  }
2410
2552
  //#endregion
2411
2553
  //#region src/source-mapper/column-finder.ts
2554
+ const EXPECT_CALL = /\bexpect\s*[.(]/;
2412
2555
  /**
2413
2556
  * Finds the column position of the failing matcher in an expect() call. Returns
2414
2557
  * 1-indexed column position, or undefined if no expect is found.
2415
2558
  */
2416
2559
  function findExpectationColumn(lineText) {
2417
2560
  if (!lineText) return;
2418
- const expectIndex = lineText.search(/\bexpect\s*[.(]/);
2561
+ const expectIndex = lineText.search(EXPECT_CALL);
2419
2562
  if (expectIndex === -1) return;
2420
2563
  const afterExpect = lineText.slice(expectIndex);
2421
2564
  const matcherRegex = /[.:]\s*([A-Za-z_$][\w$]*)\s*(?=\()/g;
@@ -2427,9 +2570,11 @@ function findExpectationColumn(lineText) {
2427
2570
  }
2428
2571
  //#endregion
2429
2572
  //#region src/source-mapper/path-resolver.ts
2573
+ const INIT_SEGMENT = /(^|\/)(init)(\.|\/)/;
2574
+ const LEADING_DOT_SLASH = /^\.\//;
2430
2575
  /** roblox-ts compiles index.ts → init.luau; reverse the rename for TS paths. */
2431
2576
  function luauInitToIndex(filePath) {
2432
- return filePath.replace(/(^|\/)(init)(\.|\/)/, "$1index$3");
2577
+ return filePath.replace(INIT_SEGMENT, "$1index$3");
2433
2578
  }
2434
2579
  function createPathResolver(rojoProject, config) {
2435
2580
  const rojoMappings = /* @__PURE__ */ new Map();
@@ -2444,14 +2589,14 @@ function createPathResolver(rojoProject, config) {
2444
2589
  }
2445
2590
  walkTree(rojoProject.tree, "");
2446
2591
  const tsconfigMappings = config?.mappings ?? [];
2447
- const sortedRojoMappings = [...rojoMappings.entries()].sort(([a], [b]) => b.length - a.length);
2592
+ const sortedRojoMappings = [...rojoMappings].sort(([a], [b]) => b.length - a.length);
2448
2593
  return { resolve(dataModelPath) {
2449
2594
  for (const [prefix, basePath] of sortedRojoMappings) {
2450
2595
  if (dataModelPath !== prefix && !dataModelPath.startsWith(`${prefix}.`)) continue;
2451
2596
  const result = `${basePath}/${convertToFilePath(dataModelPath.slice(prefix.length + 1))}`;
2452
2597
  const mapping = findMapping(result, tsconfigMappings);
2453
2598
  if (mapping !== void 0) return {
2454
- filePath: `${luauInitToIndex(replacePrefix(result, mapping.outDir, mapping.rootDir).replace(/^\.\//, ""))}.ts`,
2599
+ filePath: `${luauInitToIndex(replacePrefix(result, mapping.outDir, mapping.rootDir).replace(LEADING_DOT_SLASH, ""))}.ts`,
2455
2600
  mapping
2456
2601
  };
2457
2602
  return { filePath: findLuaFile(result) };
@@ -2526,6 +2671,8 @@ function getTraceMap(luauPath) {
2526
2671
  }
2527
2672
  //#endregion
2528
2673
  //#region src/source-mapper/index.ts
2674
+ const TS_EXTENSION = /\.ts$/;
2675
+ const LEADING_SLASH = /^\//;
2529
2676
  function createSourceMapper(config) {
2530
2677
  const pathResolver = createPathResolver(config.rojoProject, { mappings: config.mappings });
2531
2678
  function mapFrame(frame) {
@@ -2536,7 +2683,7 @@ function createSourceMapper(config) {
2536
2683
  mapped: void 0
2537
2684
  };
2538
2685
  const { filePath, mapping } = resolved;
2539
- const luauPath = replacePrefix(filePath, mapping.rootDir, mapping.outDir).replace(/\.ts$/, ".luau");
2686
+ const luauPath = replacePrefix(filePath, mapping.rootDir, mapping.outDir).replace(TS_EXTENSION, ".luau");
2540
2687
  const v3Result = mapFromSourceMap(luauPath, frame.line, frame.column);
2541
2688
  if (v3Result !== void 0 && v3Result.source !== null && v3Result.line !== null) {
2542
2689
  const mapDirectory = path$1.dirname(luauPath);
@@ -2617,7 +2764,7 @@ function createSourceMapper(config) {
2617
2764
  resolveTestFilePath
2618
2765
  };
2619
2766
  function resolveTestFilePath(testFilePath) {
2620
- const dataModelPath = testFilePath.replace(/^\//, "").replaceAll("/", ".");
2767
+ const dataModelPath = testFilePath.replace(LEADING_SLASH, "").replaceAll("/", ".");
2621
2768
  return pathResolver.resolve(dataModelPath)?.filePath;
2622
2769
  }
2623
2770
  }
@@ -2862,6 +3009,11 @@ function highlightTypeScript(source) {
2862
3009
  //#endregion
2863
3010
  //#region src/formatters/formatter.ts
2864
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+))?/;
2865
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 }"]];
2866
3018
  function getExecErrorHint(message) {
2867
3019
  for (const [pattern, hint] of EXEC_ERROR_HINTS) if (pattern.test(message)) return hint;
@@ -2877,7 +3029,7 @@ function parseErrorMessage(message) {
2877
3029
  const lines = message.split("\n");
2878
3030
  const firstLine = lines[0];
2879
3031
  assert(firstLine !== void 0, "split always returns ≥1 element");
2880
- const snapshotHeaderIndex = lines.findIndex((line) => /^- Snapshot\s+- \d+/.test(line));
3032
+ const snapshotHeaderIndex = lines.findIndex((line) => SNAPSHOT_HEADER.test(line));
2881
3033
  if (snapshotHeaderIndex !== -1) {
2882
3034
  const diffLines = [];
2883
3035
  for (let index = snapshotHeaderIndex; index < lines.length; index++) {
@@ -2890,8 +3042,8 @@ function parseErrorMessage(message) {
2890
3042
  snapshotDiff: diffLines.join("\n").trimEnd()
2891
3043
  };
2892
3044
  }
2893
- const expectedMatch = message.match(/Expected\b.*?:\s*(.+)/);
2894
- const receivedMatch = message.match(/Received\b.*?:\s*(.+)/);
3045
+ const expectedMatch = message.match(EXPECTED_VALUE);
3046
+ const receivedMatch = message.match(RECEIVED_VALUE);
2895
3047
  return {
2896
3048
  expected: expectedMatch?.[1],
2897
3049
  message: firstLine,
@@ -2920,7 +3072,7 @@ function cleanExecErrorMessage(raw) {
2920
3072
  }
2921
3073
  }
2922
3074
  if (contentLine === void 0) return raw.trim();
2923
- return contentLine.replace(/^(?:[A-Za-z][\w.@-]*:\d+:\s*)+/, "");
3075
+ return contentLine.replace(ROBLOX_PATH_CHAIN, "");
2924
3076
  }
2925
3077
  function formatSourceSnippet(snippet, filePath, options) {
2926
3078
  const useColor = options?.useColor ?? true;
@@ -2949,7 +3101,7 @@ function formatSourceSnippet(snippet, filePath, options) {
2949
3101
  return lines.join("\n");
2950
3102
  }
2951
3103
  function parseSourceLocation(message) {
2952
- const match = message.match(/([^\s:]+\.(?:tsx?|luau?)):(\d+)(?::(\d+))?/);
3104
+ const match = message.match(SOURCE_LOCATION);
2953
3105
  if (match === null) return;
2954
3106
  const [, filePath, lineStr, columnStr] = match;
2955
3107
  assert(filePath !== void 0, "regex group 1 matched");
@@ -3083,15 +3235,20 @@ function formatTypecheckSummary(result, useColor = true) {
3083
3235
  parts.push(`\n${label} ${failedPart}${passedPart}, ${String(total)} total\n`);
3084
3236
  return parts.join("\n");
3085
3237
  }
3086
- function formatTypecheckFailures(result, useColor = true) {
3087
- const styles = createStyles(useColor);
3238
+ function formatTypecheckFileFailures(file, styles) {
3088
3239
  const lines = [];
3089
- for (const file of result.testResults) for (const test of file.testResults) {
3240
+ for (const test of file.testResults) {
3090
3241
  if (test.status !== "failed") continue;
3091
3242
  const badge = styles.failBadge(" FAIL ");
3092
3243
  lines.push(` ${badge} ${styles.status.fail(test.fullName)}`);
3093
3244
  for (const message of test.failureMessages) lines.push(` ${styles.dim(message)}`);
3094
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));
3095
3252
  return lines.join("\n");
3096
3253
  }
3097
3254
  const PROJECT_BADGE_COLORS = [
@@ -3335,7 +3492,8 @@ function computeProjectStats(result) {
3335
3492
  function formatFileFailures(file, options, styles, failureCtx) {
3336
3493
  const lines = [];
3337
3494
  const displayPath = resolveDisplayPath(file.testFilePath, options.sourceMapper);
3338
- for (const testCase of file.testResults) if (testCase.status === "failed") {
3495
+ for (const testCase of file.testResults) {
3496
+ if (testCase.status !== "failed") continue;
3339
3497
  const index = failureCtx.currentIndex;
3340
3498
  failureCtx.currentIndex++;
3341
3499
  lines.push(formatFailure({
@@ -3746,7 +3904,8 @@ function formatFileHeaderFailures(file, options) {
3746
3904
  const totalTests = file.numFailingTests + file.numPassingTests + file.numPendingTests;
3747
3905
  const testWord = totalTests === 1 ? "test" : "tests";
3748
3906
  lines.push(` ❯ ${relativePath} (${totalTests} ${testWord} | ${file.numFailingTests} failed)`);
3749
- for (const test of file.testResults) if (test.status === "failed") {
3907
+ for (const test of file.testResults) {
3908
+ if (test.status !== "failed") continue;
3750
3909
  const duration = test.duration !== void 0 ? ` ${String(test.duration)}ms` : "";
3751
3910
  lines.push(` × ${test.title}${duration}`);
3752
3911
  }
@@ -4113,7 +4272,8 @@ function createTimingCollector(options = {}) {
4113
4272
  node.elapsedMs += elapsedMs;
4114
4273
  }
4115
4274
  function emit(node, depth) {
4116
- 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`);
4117
4277
  for (const child of node.children.values()) emit(child, depth + 1);
4118
4278
  }
4119
4279
  function flushTimingReport() {
@@ -4212,16 +4372,18 @@ function writeJsonFile(filePath, value) {
4212
4372
  }
4213
4373
  //#endregion
4214
4374
  //#region src/executor.ts
4375
+ const TS_SOURCE_EXTENSION$1 = /\.tsx?$/;
4376
+ const TSCONFIG_FILENAME = /^tsconfig.*\.json$/i;
4215
4377
  function isLuauProject(testFiles, tsconfigMappings) {
4216
4378
  if (tsconfigMappings.length > 0) return false;
4217
- if (testFiles.some((file) => /\.tsx?$/.test(file))) return false;
4379
+ if (testFiles.some((file) => TS_SOURCE_EXTENSION$1.test(file))) return false;
4218
4380
  return true;
4219
4381
  }
4220
4382
  function resolveAllTsconfigMappings(projectRoot) {
4221
4383
  const resolvedRoot = path$1.resolve(projectRoot);
4222
4384
  let files;
4223
4385
  try {
4224
- files = fs$1.readdirSync(resolvedRoot).filter((file) => /^tsconfig.*\.json$/i.test(file));
4386
+ files = fs$1.readdirSync(resolvedRoot).filter((file) => TSCONFIG_FILENAME.test(file));
4225
4387
  } catch {
4226
4388
  return [];
4227
4389
  }
@@ -4687,6 +4849,7 @@ function buildProjectJob(parameters, timing) {
4687
4849
  //#endregion
4688
4850
  //#region src/formatters/github-actions.ts
4689
4851
  const SEPARATOR = " · ";
4852
+ const TRAILING_SLASH$4 = /\/$/;
4690
4853
  function escapeData(value) {
4691
4854
  return value.replace(/%/g, "%25").replace(/\r/g, "%0D").replace(/\n/g, "%0A");
4692
4855
  }
@@ -4728,13 +4891,7 @@ function formatJobSummary(result, options) {
4728
4891
  });
4729
4892
  continue;
4730
4893
  }
4731
- for (const test of file.testResults) {
4732
- if (test.status !== "failed") continue;
4733
- failures.push({
4734
- file: makeRelative(file.testFilePath, options.workspace),
4735
- title: test.fullName
4736
- });
4737
- }
4894
+ failures.push(...collectFileFailures(file, options));
4738
4895
  }
4739
4896
  if (failures.length > 0) {
4740
4897
  lines.push("### Failures\n");
@@ -4759,7 +4916,7 @@ function resolveGitHubActionsOptions(userOptions, sourceMapper, environment = pr
4759
4916
  function makeRelative(filePath, workspace) {
4760
4917
  if (workspace === void 0) return filePath;
4761
4918
  const normalized = normalizeWindowsPath(filePath);
4762
- const normalizedWorkspace = normalizeWindowsPath(workspace).replace(/\/$/, "");
4919
+ const normalizedWorkspace = normalizeWindowsPath(workspace).replace(TRAILING_SLASH$4, "");
4763
4920
  if (normalized.startsWith(`${normalizedWorkspace}/`)) return normalized.slice(normalizedWorkspace.length + 1);
4764
4921
  return filePath;
4765
4922
  }
@@ -4797,6 +4954,17 @@ function collectTestFailureAnnotations(annotations, file, options) {
4797
4954
  });
4798
4955
  }
4799
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
+ }
4800
4968
  function noun(count, singular, plural) {
4801
4969
  return count === 1 ? singular : plural;
4802
4970
  }
@@ -4855,22 +5023,24 @@ async function writeResultFile(outputFile, typecheck, runtime) {
4855
5023
  await writeJsonFile$1(mergeResults$1(typecheck, runtime), outputFile);
4856
5024
  }
4857
5025
  async function outputSingleResult(config, result) {
4858
- const { preCoverageMs, runtimeResult, typecheckResult } = result;
5026
+ const { coverageDisplayFilter, preCoverageMs, runtimeResult, typecheckResult } = result;
4859
5027
  const mergedResult = mergeResults$1(typecheckResult, runtimeResult?.result);
5028
+ const coverageData = runtimeResult?.coverageData;
4860
5029
  const coveragePassed = emitResultsAndCoverage({
4861
5030
  config,
4862
5031
  coverageEnabled: config.collectCoverage,
4863
5032
  printResults: () => {
4864
5033
  if (config.silent) return;
5034
+ const timing = runtimeResult !== void 0 ? addCoverageTiming(runtimeResult.timing, preCoverageMs) : void 0;
4865
5035
  printFormattedOutput({
4866
5036
  config,
4867
5037
  mergedResult,
4868
5038
  runtimeResult,
4869
- timing: runtimeResult !== void 0 ? addCoverageTiming(runtimeResult.timing, preCoverageMs) : void 0,
5039
+ timing,
4870
5040
  typecheckResult
4871
5041
  });
4872
5042
  },
4873
- runCoverage: () => processCoverage(config, runtimeResult?.coverageData)
5043
+ runCoverage: () => processCoverage(config, coverageData, void 0, coverageDisplayFilter)
4874
5044
  });
4875
5045
  await writeResultFile(config.outputFile, typecheckResult, runtimeResult?.result);
4876
5046
  if (runtimeResult !== void 0) writeGameOutputIfConfigured(config, runtimeResult.gameOutput, { hintsShown: !mergedResult.success });
@@ -4958,6 +5128,7 @@ async function outputMultiResult(rootConfig, result) {
4958
5128
  const merged = mergeProjectResults(projectResults.map((entry) => entry.result));
4959
5129
  const mergedResult = mergeResults$1(typecheckResult, merged.result);
4960
5130
  const workspaceCoverage = extractWorkspaceCoverageMapped(result);
5131
+ const displayFilter = extractCoverageDisplayFilter(result);
4961
5132
  const coveragePassed = emitResultsAndCoverage({
4962
5133
  config,
4963
5134
  coverageEnabled: config.collectCoverage || workspaceCoverage !== void 0,
@@ -4973,7 +5144,7 @@ async function outputMultiResult(rootConfig, result) {
4973
5144
  });
4974
5145
  if (typecheckResult !== void 0 && !usesDefaultFormatter(config)) process.stderr.write(formatTypecheckSummary(typecheckResult));
4975
5146
  },
4976
- runCoverage: () => processCoverage(config, merged.coverageData, workspaceCoverage)
5147
+ runCoverage: () => processCoverage(config, merged.coverageData, workspaceCoverage, displayFilter)
4977
5148
  });
4978
5149
  if (mode === "multi") {
4979
5150
  await writeResultFile(config.outputFile, typecheckResult, merged.result);
@@ -5067,7 +5238,7 @@ function enforceThresholds(config, mapped) {
5067
5238
  for (const failure of result.failures) process.stderr.write(`Coverage threshold not met for ${failure.metric}: ${String(failure.actual.toFixed(2))}% < ${String(failure.threshold)}%\n`);
5068
5239
  return false;
5069
5240
  }
5070
- function processCoverage(config, coverageData, preMapped) {
5241
+ function processCoverage(config, coverageData, preMapped, agentTextFilter) {
5071
5242
  if (!config.collectCoverage && preMapped === void 0) return true;
5072
5243
  const mapped = resolveMappedCoverage(config, coverageData, preMapped);
5073
5244
  if (mapped === void 0) return true;
@@ -5075,6 +5246,7 @@ function processCoverage(config, coverageData, preMapped) {
5075
5246
  if (!config.silent) printCoverageHeader(agentMode);
5076
5247
  generateReports({
5077
5248
  agentMode,
5249
+ agentTextFilter,
5078
5250
  collectCoverageFrom: config.collectCoverageFrom,
5079
5251
  coverageDirectory: path$1.resolve(config.rootDir, config.coverageDirectory),
5080
5252
  coveragePathIgnorePatterns: config.coveragePathIgnorePatterns,
@@ -5124,6 +5296,9 @@ function buildReportConfig(rootConfig, result) {
5124
5296
  function extractWorkspaceCoverageMapped(result) {
5125
5297
  return "coverageMapped" in result ? result.coverageMapped : void 0;
5126
5298
  }
5299
+ function extractCoverageDisplayFilter(result) {
5300
+ return result.mode === "multi" ? result.coverageDisplayFilter : void 0;
5301
+ }
5127
5302
  function resolveSinkHints(result, config) {
5128
5303
  const gameOutput = result.mode === "workspace" ? result.gameOutput : config.gameOutput;
5129
5304
  const outputFile = result.mode === "workspace" ? result.outputFile : config.outputFile;
@@ -5193,6 +5368,34 @@ function writeAggregatedGameOutput(config, projectResults, options) {
5193
5368
  }
5194
5369
  }
5195
5370
  //#endregion
5371
+ //#region src/config/resolve-typecheck-config.ts
5372
+ /**
5373
+ * Merges the root `test.typecheck`, per-project `test.typecheck`, and CLI
5374
+ * typecheck flags into one resolved typecheck config. Precedence per field is
5375
+ * CLI > project > root > default. `only` implies `enabled` (mirroring the CLI's
5376
+ * `--typecheckOnly`). `include` is never derived here — the caller falls back to
5377
+ * `deriveTypecheckInclude(runtimeInclude)` when it is unset.
5378
+ */
5379
+ function resolveTypecheckConfig(layers) {
5380
+ const { cli = {}, project = {}, root = {} } = layers;
5381
+ const only = cli.only ?? project.only ?? root.only ?? false;
5382
+ const resolved = {
5383
+ enabled: (cli.enabled ?? project.enabled ?? root.enabled ?? false) || only,
5384
+ only
5385
+ };
5386
+ const include = project.include ?? root.include;
5387
+ if (include !== void 0) resolved.include = include;
5388
+ const exclude = project.exclude ?? root.exclude;
5389
+ if (exclude !== void 0) resolved.exclude = exclude;
5390
+ const ignoreSourceErrors = project.ignoreSourceErrors ?? root.ignoreSourceErrors;
5391
+ if (ignoreSourceErrors !== void 0) resolved.ignoreSourceErrors = ignoreSourceErrors;
5392
+ const spawnTimeout = project.spawnTimeout ?? root.spawnTimeout;
5393
+ if (spawnTimeout !== void 0) resolved.spawnTimeout = spawnTimeout;
5394
+ const tsconfig = cli.tsconfig ?? project.tsconfig ?? root.tsconfig;
5395
+ if (tsconfig !== void 0) resolved.tsconfig = tsconfig;
5396
+ return resolved;
5397
+ }
5398
+ //#endregion
5196
5399
  //#region src/coverage-pipeline/build-manifest.ts
5197
5400
  /**
5198
5401
  * On-disk format version for `build-manifest.json`. Independent of
@@ -5356,6 +5559,7 @@ function redirectPathToShadow(target, coverageRoots) {
5356
5559
  }
5357
5560
  //#endregion
5358
5561
  //#region src/staging/synthesizer.ts
5562
+ const TRAILING_SLASH$3 = /\/$/;
5359
5563
  const STUB_INJECTION_KEY = "jest.config";
5360
5564
  const COLLIDING_SOURCE_FILES = ["jest.config.lua", "jest.config.luau"];
5361
5565
  const SERVICE_CLASSES = /* @__PURE__ */ new Set([
@@ -5390,7 +5594,7 @@ const SERVICE_PROPERTIES = /* @__PURE__ */ new Set([
5390
5594
  "LoadStringEnabled"
5391
5595
  ]);
5392
5596
  function synthesize(input) {
5393
- if (input.wrap === false) return synthesizeNoWrap(input.packages);
5597
+ if (input.wrap === false) return synthesizeNoWrap(input.packages, input.loadStringEnabled);
5394
5598
  const stage = { $className: "Folder" };
5395
5599
  for (const descriptor of input.packages) {
5396
5600
  const root = absolutizePaths(transformToFolder(loadRojoProject(descriptor.rojoProjectPath).tree), path$1.dirname(descriptor.rojoProjectPath), {
@@ -5433,7 +5637,7 @@ function resolveDollarPath(value, treeBase, coverageBase, coverageRoots) {
5433
5637
  if (coverageRoots === void 0) return absoluteTarget;
5434
5638
  return redirectPathToShadow(absoluteTarget, coverageRoots.map((root) => {
5435
5639
  return {
5436
- luauRoot: normalizeWindowsPath(path$1.resolve(coverageBase, root.luauRoot)).replace(/\/$/, ""),
5640
+ luauRoot: normalizeWindowsPath(path$1.resolve(coverageBase, root.luauRoot)).replace(TRAILING_SLASH$3, ""),
5437
5641
  shadowDir: normalizeWindowsPath(root.shadowDir)
5438
5642
  };
5439
5643
  })) ?? absoluteTarget;
@@ -5507,7 +5711,27 @@ function injectStubMounts(root, stubMounts) {
5507
5711
  leaf[STUB_INJECTION_KEY] = { $path: normalizeWindowsPath(mount.absStubPath) };
5508
5712
  }
5509
5713
  }
5510
- function synthesizeNoWrap(packages) {
5714
+ function isProperties(value) {
5715
+ return typeof value === "object" && value !== null && !Array.isArray(value);
5716
+ }
5717
+ /**
5718
+ * Force `ServerScriptService.LoadStringEnabled = true` on a no-wrap tree,
5719
+ * creating the service node if the user's project omits it and merging into any
5720
+ * existing `$properties` so a hand-set property is preserved. studio-cli's
5721
+ * Run-mode runner gates on LoadString, so the Clean Place must enable it
5722
+ * regardless of what the user's `.project.json` declares.
5723
+ */
5724
+ function enableLoadString(tree) {
5725
+ const existing = tree["ServerScriptService"];
5726
+ const service = isTreeNode(existing) ? existing : { $className: "ServerScriptService" };
5727
+ service.$className ??= "ServerScriptService";
5728
+ service.$properties = {
5729
+ ...isProperties(service.$properties) ? service.$properties : {},
5730
+ LoadStringEnabled: true
5731
+ };
5732
+ tree["ServerScriptService"] = service;
5733
+ }
5734
+ function synthesizeNoWrap(packages, loadStringEnabled = false) {
5511
5735
  if (packages.length !== 1) throw new ConfigError(`synthesize wrap:false requires exactly one package, got ${String(packages.length)}`);
5512
5736
  const descriptor = packages[0];
5513
5737
  const project = loadRojoProject(descriptor.rojoProjectPath);
@@ -5516,6 +5740,7 @@ function synthesizeNoWrap(packages) {
5516
5740
  coverageRoots: descriptor.coverageRoots
5517
5741
  });
5518
5742
  injectStubMounts(tree, descriptor.stubMounts);
5743
+ if (loadStringEnabled) enableLoadString(tree);
5519
5744
  return stableStringify({
5520
5745
  ...project.raw,
5521
5746
  tree
@@ -5542,9 +5767,6 @@ function filterServiceProperties(props) {
5542
5767
  for (const [propertyKey, propertyValue] of Object.entries(props)) if (!SERVICE_PROPERTIES.has(propertyKey)) filtered[propertyKey] = propertyValue;
5543
5768
  return filtered;
5544
5769
  }
5545
- function isProperties(value) {
5546
- return typeof value === "object" && !Array.isArray(value);
5547
- }
5548
5770
  function transformChildEntry(key, value) {
5549
5771
  if (key === "$className" && typeof value === "string" && SERVICE_CLASSES.has(value)) return "Folder";
5550
5772
  if (key === "$properties" && isProperties(value)) {
@@ -5569,8 +5791,9 @@ function transformValue(key, value) {
5569
5791
  * `coverageRoots`.
5570
5792
  */
5571
5793
  function buildPlace(options) {
5572
- const { packages, placeFile, projectFile, wrap } = options;
5794
+ const { loadStringEnabled, packages, placeFile, projectFile, wrap } = options;
5573
5795
  const projectJson = synthesize({
5796
+ loadStringEnabled,
5574
5797
  packages,
5575
5798
  wrap
5576
5799
  });
@@ -5973,7 +6196,8 @@ function collectCoverage(root) {
5973
6196
  return true;
5974
6197
  },
5975
6198
  visitStatBlock(block) {
5976
- 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;
5977
6201
  statements.push({
5978
6202
  index: statementIndex,
5979
6203
  location: { ...stmt.location }
@@ -6017,16 +6241,14 @@ function collectCoverage(root) {
6017
6241
  location: { ...elseif.thenBlock.location }
6018
6242
  });
6019
6243
  }
6020
- const hasExplicitElse = elseBlock !== void 0 && elseBlock.statements.length > 0;
6021
- if (hasExplicitElse) {
6244
+ if (elseBlock !== void 0 && elseBlock.statements.length > 0) {
6022
6245
  const elseFirst = getBodyFirstStatement(elseBlock);
6023
6246
  branch.arms.push({
6024
6247
  bodyFirstColumn: elseFirst.column,
6025
6248
  bodyFirstLine: elseFirst.line,
6026
6249
  location: { ...elseBlock.location }
6027
6250
  });
6028
- }
6029
- if (!hasExplicitElse) {
6251
+ } else {
6030
6252
  branch.arms.push({
6031
6253
  bodyFirstColumn: 0,
6032
6254
  bodyFirstLine: 0,
@@ -6149,6 +6371,9 @@ const KIND_RANK = {
6149
6371
  open: 0,
6150
6372
  point: 1
6151
6373
  };
6374
+ const TRAILING_WHITESPACE = /\s$/;
6375
+ const IDENTIFIER_START = /^[a-zA-Z_]/;
6376
+ const MODE_DIRECTIVE = /^--![a-z]+/;
6152
6377
  function insertProbes(source, result, fileKey) {
6153
6378
  const lines = splitLines(source);
6154
6379
  applyProbes(lines, collectProbes(result));
@@ -6219,11 +6444,11 @@ function applyProbes(mutableLines, probes) {
6219
6444
  assert(line !== void 0, `Invalid probe line number: ${probeLine}`);
6220
6445
  const before = line.slice(0, column - 1);
6221
6446
  const after = line.slice(column - 1);
6222
- 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;
6223
6448
  }
6224
6449
  }
6225
6450
  function extractModeDirective(lines) {
6226
- 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])) {
6227
6452
  const directive = `${lines[0]}\n`;
6228
6453
  lines.splice(0, 1);
6229
6454
  return directive;
@@ -6275,6 +6500,7 @@ function buildPreamble(modeDirective, fileKey, result) {
6275
6500
  }
6276
6501
  //#endregion
6277
6502
  //#region src/coverage-pipeline/instrumenter.ts
6503
+ const LUAU_EXTENSION = /\.luau?$/;
6278
6504
  let cachedTemporaryDirectory$1;
6279
6505
  /**
6280
6506
  * Instrument a single luauRoot directory. Returns the files map without
@@ -6319,7 +6545,7 @@ function instrumentRoot(options) {
6319
6545
  } catch (err) {
6320
6546
  throw new Error("Failed to parse file list from lute", { cause: err });
6321
6547
  }
6322
- 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");
6323
6549
  const fileList = parsed;
6324
6550
  const files = {};
6325
6551
  const posixLuauRoot = normalizeWindowsPath(luauRoot);
@@ -6336,7 +6562,7 @@ function instrumentRoot(options) {
6336
6562
  const fileKey = normalizeWindowsPath(path$1.join(posixLuauRoot, relativePath));
6337
6563
  const originalLuauPath = fileKey;
6338
6564
  const instrumentedLuauPath = normalizeWindowsPath(path$1.join(shadowDir, relativePath));
6339
- 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"));
6340
6566
  const sourceMapPath = `${originalLuauPath}.map`;
6341
6567
  const outputDirectory = path$1.dirname(path$1.join(shadowDir, relativePath));
6342
6568
  fs$1.mkdirSync(outputDirectory, { recursive: true });
@@ -6363,6 +6589,9 @@ function instrumentRoot(options) {
6363
6589
  }
6364
6590
  return files;
6365
6591
  }
6592
+ function isStringArray(value) {
6593
+ return Array.isArray(value) && value.every((entry) => typeof entry === "string");
6594
+ }
6366
6595
  function shouldSkipFile(relativePath, skipFiles) {
6367
6596
  if (relativePath.endsWith(".snap.luau") || relativePath.endsWith(".snap.lua")) return true;
6368
6597
  return skipFiles?.has(relativePath) === true;
@@ -6436,7 +6665,8 @@ function walkDirectory$1(directory, visitedDirectories, files) {
6436
6665
  const real = toKey(fs$1.realpathSync(directory));
6437
6666
  if (visitedDirectories.has(real)) return;
6438
6667
  visitedDirectories.add(real);
6439
- 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) {
6440
6670
  if (entry.name.startsWith(".")) continue;
6441
6671
  collectInputFiles(path$1.join(directory, entry.name), visitedDirectories, files);
6442
6672
  }
@@ -6827,6 +7057,7 @@ function validateRelativeRoots(luauRoots) {
6827
7057
  }
6828
7058
  function buildRojoProject(rojoProjectPath, packageDirectory, coverageRoots, placeFile) {
6829
7059
  return buildPlace({
7060
+ loadStringEnabled: true,
6830
7061
  packages: [{
6831
7062
  name: "jest-roblox-coverage",
6832
7063
  coverageRoots,
@@ -7104,18 +7335,18 @@ const DEFAULT_BACKOFF_MS = 5e3;
7104
7335
  */
7105
7336
  async function runTaskPool(options) {
7106
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");
7107
7340
  const now = options.now ?? Date.now;
7108
7341
  const sleep = options.sleep ?? setTimeout$1;
7109
- const warn = options.warn ?? ((message) => {
7342
+ const allocations = distributeSlots(places, concurrency, options.warn ?? ((message) => {
7110
7343
  console.warn(message);
7111
- });
7112
- if (concurrency < 1) throw new Error(`runTaskPool concurrency must be >= 1, got ${String(concurrency)}`);
7113
- if (places.length === 0) throw new Error("runTaskPool requires at least one place");
7114
- const allocations = distributeSlots(places, concurrency, warn);
7344
+ }));
7115
7345
  async function backoff(state, retryAfterMs) {
7116
7346
  const genuineMs = retryAfterMs !== void 0 && retryAfterMs > 0 ? retryAfterMs : DEFAULT_BACKOFF_MS;
7117
7347
  const sinceCompletion = now() - state.lastCompletionMs;
7118
- 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);
7119
7350
  }
7120
7351
  async function worker(state) {
7121
7352
  while (!isDone()) {
@@ -7248,10 +7479,33 @@ function toQueueData(value) {
7248
7479
  return value;
7249
7480
  }
7250
7481
  //#endregion
7482
+ //#region src/backends/interface.ts
7483
+ /**
7484
+ * Whether this is a workspace (multi-package) run. Workspace jobs each carry
7485
+ * their owning package name (`pkg`); single-/multi-project jobs never do, and
7486
+ * the run layer builds them all-or-none — so any job with `pkg` means the whole
7487
+ * run is a workspace run. The Studio backends key off this to drive the plugin's
7488
+ * staged-materializer dispatch (`workspace.entries`) instead of the configs
7489
+ * path. `buildWorkspaceEntries` then fails fast if a job is missing `pkg`, so a
7490
+ * malformed (mixed) array surfaces as a clear error rather than a bad payload.
7491
+ */
7492
+ function isWorkspaceRun(jobs) {
7493
+ return jobs.some((job) => job.pkg !== void 0);
7494
+ }
7495
+ /**
7496
+ * A request to shard across multiple sessions: `"auto"` (the backend picks a
7497
+ * count) or an explicit count > 1. The serial backends (studio, studio-cli)
7498
+ * reject this — they drive a single Studio instance.
7499
+ */
7500
+ function isShardedParallel(parallel) {
7501
+ return parallel === "auto" || typeof parallel === "number" && parallel > 1;
7502
+ }
7503
+ //#endregion
7251
7504
  //#region src/test-runner.bundled.luau
7252
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";
7253
7506
  //#endregion
7254
7507
  //#region src/test-script.ts
7508
+ const TS_OR_LUAU_EXTENSION$2 = /\.(tsx?|luau?)$/;
7255
7509
  function buildJestArgv(options) {
7256
7510
  const argv = {};
7257
7511
  for (const [key, value] of Object.entries(options.config)) if (!JEST_ARGV_EXCLUDED_KEYS.has(key) && value !== void 0) argv[key] = value;
@@ -7262,7 +7516,7 @@ function buildJestArgv(options) {
7262
7516
  return {
7263
7517
  ...argv,
7264
7518
  reporters: argv["reporters"] ?? [],
7265
- testMatch: options.config.testMatch.map((pattern) => pattern.replace(/\.(tsx?|luau?)$/, ""))
7519
+ testMatch: options.config.testMatch.map((pattern) => pattern.replace(TS_OR_LUAU_EXTENSION$2, ""))
7266
7520
  };
7267
7521
  }
7268
7522
  function generateTestScript(options) {
@@ -7304,6 +7558,7 @@ const PARALLEL_AUTO_CAP = 3;
7304
7558
  const BASE_URL_ENV = "JEST_ROBLOX_OPEN_CLOUD_BASE_URL";
7305
7559
  const MAX_RETRIES_ENV = "JEST_ROBLOX_OCALE_MAX_RETRIES";
7306
7560
  const DEFAULT_STREAM_POLL_MS = 250;
7561
+ const TrailingSlashesPattern = /\/+$/;
7307
7562
  var OpenCloudBackend = class {
7308
7563
  runner;
7309
7564
  kind = "open-cloud";
@@ -7315,7 +7570,7 @@ var OpenCloudBackend = class {
7315
7570
  if (jobs.length === 0) throw new Error("OpenCloudBackend requires at least one job");
7316
7571
  if (workStealing === true && scriptOverride === void 0) throw new Error("OpenCloudBackend work-stealing mode requires scriptOverride");
7317
7572
  const primary = jobs[0];
7318
- const placeFilePath = path$1.resolve(primary.config.rootDir, primary.config.placeFile);
7573
+ const placeFilePath = resolvePlaceFilePath(primary.config);
7319
7574
  const upload = await this.runner.uploadPlace({ placeFilePath });
7320
7575
  const executionStart = Date.now();
7321
7576
  return {
@@ -7437,7 +7692,7 @@ async function pollStreamingResults(hooks, isDone) {
7437
7692
  function resolveOpenCloudBaseUrl() {
7438
7693
  const override = process.env[BASE_URL_ENV]?.trim();
7439
7694
  if (override === void 0 || override === "") return;
7440
- return override.replace(/\/+$/, "");
7695
+ return override.replace(TrailingSlashesPattern, "");
7441
7696
  }
7442
7697
  /**
7443
7698
  * Reads {@link MAX_RETRIES_ENV} for an Open Cloud retry-budget override. Lets
@@ -7532,34 +7787,501 @@ function parseStealingEnvelope(result) {
7532
7787
  gameOutput: result.outputs[1]
7533
7788
  };
7534
7789
  }
7535
- function aggregateEntriesByKey(taskEnvelopes) {
7536
- const entryByKey = /* @__PURE__ */ new Map();
7537
- 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;
7538
7793
  const key = entryLookupKey(entry.pkg, entry.project);
7539
7794
  if (!entryByKey.has(key)) entryByKey.set(key, {
7540
7795
  entry,
7541
7796
  gameOutput
7542
7797
  });
7543
7798
  }
7799
+ }
7800
+ function aggregateEntriesByKey(taskEnvelopes) {
7801
+ const entryByKey = /* @__PURE__ */ new Map();
7802
+ for (const { entries, gameOutput } of taskEnvelopes) addEntriesToMap(entryByKey, entries, gameOutput);
7544
7803
  return entryByKey;
7545
7804
  }
7546
7805
  //#endregion
7806
+ //#region src/backends/plugin-payload.ts
7807
+ /**
7808
+ * The per-(package, project) entries the plugin's Run-mode runner feeds to its
7809
+ * embedded materializer for a workspace run. Shared by both Studio backends —
7810
+ * studio-cli writes them into the bootstrap payload, the WebSocket studio
7811
+ * backend sends them in the `run_tests` message — so the entry shape can't drift
7812
+ * between the two transports.
7813
+ */
7814
+ function buildWorkspaceEntries(jobs) {
7815
+ return jobs.map((job) => {
7816
+ if (job.pkg === void 0) throw new Error(`studio-cli: workspace entry for project "${job.displayName}" is missing its package name (pkg)`);
7817
+ return {
7818
+ config: buildJestArgv(job),
7819
+ pkg: job.pkg,
7820
+ project: job.displayName
7821
+ };
7822
+ });
7823
+ }
7824
+ /**
7825
+ * The configs + filtered injection mounts the single-/multi-project configs path
7826
+ * consumes (`Runner.runProjects`). `runtimeStubMounts[i]` is parallel to
7827
+ * `configs[i]`: the DataModel paths the runner injects `jest.config` into,
7828
+ * excluding mounts where Rojo already syncs a user-authored config.
7829
+ */
7830
+ function buildConfigEntries(jobs) {
7831
+ return {
7832
+ configs: jobs.map((job) => buildJestArgv(job)),
7833
+ runtimeStubMounts: jobs.map((job) => job.runtimeInjectionPaths ?? [])
7834
+ };
7835
+ }
7836
+ //#endregion
7837
+ //#region src/backends/studio-discovery.ts
7838
+ const WINDOWS_STUDIO_EXECUTABLE = "RobloxStudioBeta.exe";
7839
+ const MACOS_STUDIO_EXECUTABLE = "/Applications/RobloxStudio.app/Contents/MacOS/RobloxStudioBeta";
7840
+ const NOT_FOUND_HINT = "Install Roblox Studio, or set studioPath (config key, --studioPath, or JEST_ROBLOX_STUDIO_PATH).";
7841
+ /**
7842
+ * Resolve the Roblox Studio executable studio-cli should launch. An explicit
7843
+ * `override` wins; otherwise probe the known per-OS install locations and pick
7844
+ * the newest `RobloxStudioBeta.exe`. Throws a clear, actionable error when no
7845
+ * executable can be found so the CLI surfaces "install Studio or set
7846
+ * studioPath" rather than a downstream spawn failure.
7847
+ */
7848
+ function discoverStudioPath(options = {}) {
7849
+ const { environment = process.env, override, platform = process.platform } = options;
7850
+ if (override !== void 0) {
7851
+ const stat = fs$1.statSync(override, { throwIfNoEntry: false });
7852
+ if (stat === void 0) throw new Error(`Roblox Studio not found at studioPath override: ${override}`);
7853
+ if (!stat.isFile()) throw new Error(`studioPath override is not a file: ${override}`);
7854
+ return normalizeWindowsPath(override);
7855
+ }
7856
+ if (platform === "win32") return discoverWindows(environment);
7857
+ if (platform === "darwin") return discoverMacOs();
7858
+ throw new Error(`studio-cli backend has no Studio auto-discovery for platform "${platform}". Set studioPath to point at your Roblox Studio executable.`);
7859
+ }
7860
+ function notFound() {
7861
+ return /* @__PURE__ */ new Error(`Roblox Studio not found. ${NOT_FOUND_HINT}`);
7862
+ }
7863
+ function discoverWindows(environment) {
7864
+ const localAppData = environment["LOCALAPPDATA"];
7865
+ if (localAppData === void 0 || localAppData === "") throw new Error(`Cannot locate Roblox Studio: LOCALAPPDATA is not set. ${NOT_FOUND_HINT}`);
7866
+ const versionsDirectory = path$1.join(localAppData, "Roblox", "Versions");
7867
+ let entries;
7868
+ try {
7869
+ entries = fs$1.readdirSync(versionsDirectory, { withFileTypes: true });
7870
+ } catch {
7871
+ throw notFound();
7872
+ }
7873
+ let newest;
7874
+ for (const entry of entries) {
7875
+ if (!entry.isDirectory()) continue;
7876
+ const executable = path$1.join(versionsDirectory, entry.name, WINDOWS_STUDIO_EXECUTABLE);
7877
+ const stat = fs$1.statSync(executable, { throwIfNoEntry: false });
7878
+ if (stat === void 0) continue;
7879
+ if (newest === void 0 || stat.mtimeMs > newest.mtimeMs) newest = {
7880
+ mtimeMs: stat.mtimeMs,
7881
+ path: normalizeWindowsPath(executable)
7882
+ };
7883
+ }
7884
+ if (newest === void 0) throw notFound();
7885
+ return newest.path;
7886
+ }
7887
+ function discoverMacOs() {
7888
+ if (!fs$1.existsSync(MACOS_STUDIO_EXECUTABLE)) throw notFound();
7889
+ return MACOS_STUDIO_EXECUTABLE;
7890
+ }
7891
+ //#endregion
7892
+ //#region src/backends/studio-cli.ts
7893
+ const DEFAULT_STUDIO_CLI_TIMEOUT = 3e5;
7894
+ /** Lowest-precedence Studio-executable override (below config key / CLI flag). */
7895
+ const STUDIO_PATH_ENV = "JEST_ROBLOX_STUDIO_PATH";
7896
+ /**
7897
+ * Plugin/CLI protocol version, carried in the Run-mode payload. Matches
7898
+ * `STUDIO_PROTOCOL_VERSION` in the WebSocket `studio` backend and
7899
+ * `PROTOCOL_VERSION` in the plugin. The bootstrap echoes the version the
7900
+ * run-mode runner returns; {@link assertProtocolMatch} rejects a plugin that
7901
+ * omits the echo (a stale runner predating the handshake) or returns a
7902
+ * different number, surfacing a clean "update the plugin" error.
7903
+ */
7904
+ const STUDIO_CLI_PROTOCOL_VERSION = 3;
7905
+ /**
7906
+ * Seconds the bootstrap keeps its result socket alive after sending, waiting to
7907
+ * be closed/killed by the host. A backstop only: the host kills Studio the
7908
+ * instant it receives the result, so the bootstrap is normally terminated
7909
+ * mid-wait. Long enough to never truncate a send, short enough that a host that
7910
+ * vanished doesn't wedge Studio open.
7911
+ */
7912
+ const SOCKET_LINGER_SECONDS = 30;
7913
+ /**
7914
+ * Default backstop for the graceful kill-on-lock-release: how long to wait for
7915
+ * Studio to release `<place>.lock` before hard-killing anyway. The lock is
7916
+ * normally freed within ~1–9s of closing the result server, so this only fires
7917
+ * for a pathologically long-yielding edit-mode `BindToClose` — in which case we
7918
+ * fall back to today's instant kill.
7919
+ */
7920
+ const GRACEFUL_SHUTDOWN_CAP_MS = 15e3;
7921
+ /**
7922
+ * How often the real launcher polls `<place>.lock` while waiting for Studio's
7923
+ * graceful `ClosePlace` to release it. Short enough to kill within a frame of
7924
+ * the release (the win is skipping the ~30s telemetry drain that follows), long
7925
+ * enough to be negligible.
7926
+ */
7927
+ const LOCK_POLL_INTERVAL_MS = 50;
7928
+ const BACKEND_NAME = "studio-cli";
7929
+ const WORK_DIR = path$1.join(".jest-roblox", BACKEND_NAME);
7930
+ const PLACE_FILE = "place.rbxl";
7931
+ const PLACE_PROJECT_FILE = "place.project.json";
7932
+ const BOOTSTRAP_FILE = "bootstrap.server.luau";
7933
+ const OUTPUT_FILE = "output.log";
7934
+ /**
7935
+ * The result frame the bootstrap pushes back over the localhost WebSocket. Same
7936
+ * shape the plugin's `init.server.luau` sends the WebSocket `studio` backend
7937
+ * (`type: "results"` + `request_id` correlation), so the two result channels
7938
+ * stay wire-compatible. `protocolVersion` is optional here — a stale run-mode
7939
+ * runner omits it, and {@link assertProtocolMatch} turns that into a clean
7940
+ * "update the plugin" error rather than a schema rejection.
7941
+ */
7942
+ const resultMessageSchema = type({
7943
+ "gameOutput?": "string",
7944
+ "jestOutput": "string",
7945
+ "protocolVersion?": "number",
7946
+ "request_id": "string",
7947
+ "type": "'results'"
7948
+ });
7949
+ var StudioCliBackend = class {
7950
+ buildPlace;
7951
+ createServer;
7952
+ discover;
7953
+ gracefulShutdownTimeout;
7954
+ headed;
7955
+ launch;
7956
+ studioPath;
7957
+ timeout;
7958
+ kind = "studio-cli";
7959
+ constructor(options = {}) {
7960
+ this.buildPlace = options.buildPlace ?? buildPlace;
7961
+ this.createServer = options.createServer ?? (() => new WebSocketServer({
7962
+ host: "127.0.0.1",
7963
+ port: 0
7964
+ }));
7965
+ this.discover = options.discover ?? ((override) => discoverStudioPath({ override: override ?? process.env[STUDIO_PATH_ENV] }));
7966
+ this.gracefulShutdownTimeout = options.gracefulShutdownTimeout ?? GRACEFUL_SHUTDOWN_CAP_MS;
7967
+ this.headed = options.headed ?? false;
7968
+ this.launch = options.launch ?? spawnStudio;
7969
+ this.studioPath = options.studioPath;
7970
+ this.timeout = options.timeout ?? DEFAULT_STUDIO_CLI_TIMEOUT;
7971
+ }
7972
+ async runTests(options) {
7973
+ const { jobs, parallel, workStealing } = options;
7974
+ if (jobs.length === 0) throw new Error("StudioCliBackend requires at least one job");
7975
+ if (workStealing === true) throw new Error("studio-cli backend is serial and does not support work-stealing");
7976
+ if (parallel !== void 0 && parallel !== 1) throw new Error("studio-cli backend is serial (one Studio instance); --parallel > 1 is not supported.");
7977
+ const primary = jobs[0];
7978
+ const rootDirectory = path$1.resolve(primary.config.rootDir);
7979
+ const workDirectory = path$1.join(rootDirectory, WORK_DIR);
7980
+ fs$1.mkdirSync(workDirectory, { recursive: true });
7981
+ const workspace = isWorkspaceRun(jobs);
7982
+ let placeFile;
7983
+ if (workspace) placeFile = path$1.resolve(primary.config.placeFile);
7984
+ else if (primary.config.collectCoverage) placeFile = resolvePlaceFilePath(primary.config);
7985
+ else placeFile = this.buildCleanPlace(primary, rootDirectory, workDirectory);
7986
+ const server = this.createServer();
7987
+ let child;
7988
+ let gracefulTeardownStarted = false;
7989
+ try {
7990
+ const port = await serverPort(server);
7991
+ const requestId = randomUUID();
7992
+ const bootstrapFile = path$1.join(workDirectory, BOOTSTRAP_FILE);
7993
+ const outputFile = path$1.join(workDirectory, OUTPUT_FILE);
7994
+ fs$1.writeFileSync(bootstrapFile, buildBootstrap(workspace ? buildWorkspacePayload(jobs) : buildConfigsPayload(jobs), port, requestId));
7995
+ const studioPath = this.discover(this.studioPath);
7996
+ const args = [
7997
+ "--task",
7998
+ "RunScript",
7999
+ "--localPlaceFile",
8000
+ normalizeWindowsPath(placeFile),
8001
+ "--runScriptFile",
8002
+ normalizeWindowsPath(bootstrapFile),
8003
+ "--outputFile",
8004
+ normalizeWindowsPath(outputFile),
8005
+ "--quitAfterExecution"
8006
+ ];
8007
+ const executionStart = Date.now();
8008
+ child = this.launch({
8009
+ args,
8010
+ headed: this.headed,
8011
+ placeFile,
8012
+ studioPath
8013
+ });
8014
+ const message = await waitForResult(server, child, requestId, this.timeout);
8015
+ const executionMs = Date.now() - executionStart;
8016
+ closeServer(server);
8017
+ child.killOnLockRelease(this.gracefulShutdownTimeout);
8018
+ gracefulTeardownStarted = true;
8019
+ const entries = parseEnvelope(message.jestOutput);
8020
+ assertProtocolMatch(message.protocolVersion);
8021
+ if (entries.length !== jobs.length) throw new Error(`studio-cli backend returned ${entries.length.toString()} entries but request had ${jobs.length.toString()} jobs`);
8022
+ return {
8023
+ rawResults: entries.map((entry) => {
8024
+ return {
8025
+ entry,
8026
+ fallbackGameOutput: message.gameOutput
8027
+ };
8028
+ }),
8029
+ timing: { executionMs }
8030
+ };
8031
+ } finally {
8032
+ if (!gracefulTeardownStarted) {
8033
+ child?.kill();
8034
+ closeServer(server);
8035
+ }
8036
+ }
8037
+ }
8038
+ /**
8039
+ * Build the Clean Place for a normal (non-coverage) run and return its path.
8040
+ * `loadStringEnabled` is forced on so the Run-mode runner's LoadString gate
8041
+ * passes. Coverage runs skip this and open the instrumented place instead.
8042
+ */
8043
+ buildCleanPlace(primary, rootDirectory, workDirectory) {
8044
+ const placeFile = path$1.join(workDirectory, PLACE_FILE);
8045
+ this.buildPlace({
8046
+ loadStringEnabled: true,
8047
+ packages: [{
8048
+ name: BACKEND_NAME,
8049
+ packageDirectory: rootDirectory,
8050
+ rojoProjectPath: path$1.resolve(findRojoProject(primary.config))
8051
+ }],
8052
+ placeFile,
8053
+ projectFile: path$1.join(workDirectory, PLACE_PROJECT_FILE),
8054
+ wrap: false
8055
+ });
8056
+ return placeFile;
8057
+ }
8058
+ };
8059
+ function createStudioCliBackend(options = {}) {
8060
+ return new StudioCliBackend(options);
8061
+ }
8062
+ /**
8063
+ * Single-/multi-project payload: the run-mode runner reads `config.configs` and
8064
+ * drives `Runner.runProjects`.
8065
+ */
8066
+ function buildConfigsPayload(jobs) {
8067
+ const { configs, runtimeStubMounts } = buildConfigEntries(jobs);
8068
+ return {
8069
+ config: { configs },
8070
+ protocolVersion: STUDIO_CLI_PROTOCOL_VERSION,
8071
+ runtimeStubMounts,
8072
+ test: true
8073
+ };
8074
+ }
8075
+ /**
8076
+ * Workspace payload: the run-mode runner sees the `workspace` shape and drives
8077
+ * the staged materializer (`runEmbedded`) — cloning each package from the
8078
+ * mega-place's `__pkg_stage`, running, resetting.
8079
+ */
8080
+ function buildWorkspacePayload(jobs) {
8081
+ return {
8082
+ protocolVersion: STUDIO_CLI_PROTOCOL_VERSION,
8083
+ test: true,
8084
+ workspace: { entries: buildWorkspaceEntries(jobs) }
8085
+ };
8086
+ }
8087
+ /**
8088
+ * Wrap `content` in a Luau long string, escalating the bracket level
8089
+ * (`[=[`, `[==[`, …) until the chosen `]=*]` terminator does not occur in the
8090
+ * content. Without this, a config string carrying the level-1 terminator
8091
+ * `]=]` (e.g. a `testNamePattern`) would close the string early and emit
8092
+ * syntactically invalid Luau — a silent no-result run.
8093
+ */
8094
+ function luauLongString(content) {
8095
+ let level = 1;
8096
+ while (content.includes(`]${"=".repeat(level)}]`)) level += 1;
8097
+ const eq = "=".repeat(level);
8098
+ return `[${eq}[${content}]${eq}]`;
8099
+ }
8100
+ /**
8101
+ * The `--runScriptFile` script. Runs at command-bar level in the edit DataModel,
8102
+ * drives the installed plugin's Run-mode runner via `ExecuteRunModeAsync`, then
8103
+ * pushes the result envelope back to the host over a localhost WebSocket
8104
+ * (`HttpService:CreateWebStreamClient`, the same client API the plugin uses).
8105
+ * `request_id` correlates the frame with this run. A plugin that is absent or
8106
+ * returns nothing sends a `{ success = false }` envelope, so the host surfaces a
8107
+ * clean error rather than hanging.
8108
+ */
8109
+ function buildBootstrap(payload, port, requestId) {
8110
+ return [
8111
+ "local HttpService = game:GetService(\"HttpService\")",
8112
+ "local StudioTestService = game:GetService(\"StudioTestService\")",
8113
+ `local payload = HttpService:JSONDecode(${luauLongString(String(JSON.stringify(payload)))})`,
8114
+ `local URL = "ws://localhost:${port.toString()}"`,
8115
+ `local REQUEST_ID = ${luauLongString(requestId)}`,
8116
+ "local ok, result = pcall(function()",
8117
+ " return StudioTestService:ExecuteRunModeAsync(payload)",
8118
+ "end)",
8119
+ "local message",
8120
+ "if not ok then",
8121
+ " message = { type = \"results\", request_id = REQUEST_ID, gameOutput = \"[]\", jestOutput = HttpService:JSONEncode({ err = tostring(result), success = false }) }",
8122
+ "elseif typeof(result) ~= \"table\" or result.jestOutput == nil then",
8123
+ " message = { type = \"results\", request_id = REQUEST_ID, gameOutput = \"[]\", jestOutput = HttpService:JSONEncode({ err = \"studio-cli: the jest plugin produced no result. Install or update the jest-roblox Studio plugin.\", success = false }) }",
8124
+ "else",
8125
+ " message = { type = \"results\", request_id = REQUEST_ID, protocolVersion = result.protocolVersion, gameOutput = result.gameOutput or \"[]\", jestOutput = result.jestOutput }",
8126
+ "end",
8127
+ "local encoded = HttpService:JSONEncode(message)",
8128
+ "local connected, socket = pcall(function()",
8129
+ " return HttpService:CreateWebStreamClient(Enum.WebStreamClientType.WebSocket, { Url = URL })",
8130
+ "end)",
8131
+ "if not connected then",
8132
+ " print(\"studio-cli: failed to open result socket: \" .. tostring(socket))",
8133
+ " return",
8134
+ "end",
8135
+ "local finished = false",
8136
+ "socket.Opened:Once(function()",
8137
+ " socket:Send(encoded)",
8138
+ "end)",
8139
+ "socket.Error:Once(function(_statusCode, errorMessage)",
8140
+ " print(\"studio-cli: result socket error: \" .. tostring(errorMessage))",
8141
+ " finished = true",
8142
+ "end)",
8143
+ "socket.Closed:Once(function()",
8144
+ " finished = true",
8145
+ "end)",
8146
+ "local start = os.clock()",
8147
+ `while not finished and os.clock() - start < ${SOCKET_LINGER_SECONDS.toString()} do`,
8148
+ " task.wait(0.05)",
8149
+ "end",
8150
+ ""
8151
+ ].join("\n");
8152
+ }
8153
+ /**
8154
+ * Reject a run-mode result whose echoed `protocolVersion` doesn't match the
8155
+ * CLI's. A stale plugin (run-mode runner predating the handshake) omits the
8156
+ * echo entirely (`undefined`); a divergent plugin echoes a different number.
8157
+ * Either way the user must update the plugin. Mirrors the WebSocket backend's
8158
+ * `version_mismatch` path.
8159
+ */
8160
+ function assertProtocolMatch(actual) {
8161
+ if (actual === STUDIO_CLI_PROTOCOL_VERSION) return;
8162
+ const reported = actual === void 0 ? "no version" : `v${actual.toString()}`;
8163
+ throw new Error(`studio-cli: jest-roblox Studio plugin protocol version mismatch (plugin reported ${reported}, CLI expects v${STUDIO_CLI_PROTOCOL_VERSION.toString()}). Update the jest-roblox Studio plugin to match this CLI version.`);
8164
+ }
8165
+ /**
8166
+ * The port the result server bound. A real `ws` server started on port 0 binds
8167
+ * asynchronously, so wait for `listening` and read the assigned port; the test
8168
+ * mock reports its port synchronously and is returned without waiting.
8169
+ */
8170
+ async function serverPort(server) {
8171
+ const address = server.address();
8172
+ if (address !== null && typeof address === "object") return address.port;
8173
+ await once(server, "listening");
8174
+ const bound = server.address();
8175
+ if (bound === null || typeof bound === "string") throw new Error("studio-cli: result WebSocket server failed to bind a port.");
8176
+ return bound.port;
8177
+ }
8178
+ /**
8179
+ * Resolve with the run-mode result frame the bootstrap pushes over the socket,
8180
+ * or reject on timeout / spawn failure. Frames that aren't a `results` message
8181
+ * for this `requestId` are ignored (engine/plugin chatter), so a stray frame
8182
+ * never resolves the run with the wrong payload.
8183
+ */
8184
+ async function waitForResult(server, child, requestId, timeout) {
8185
+ return new Promise((resolve, reject) => {
8186
+ let settled = false;
8187
+ const timer = setTimeout(() => {
8188
+ settle(() => {
8189
+ reject(/* @__PURE__ */ new Error(`studio-cli: Studio run timed out after ${timeout.toString()}ms and was terminated.`));
8190
+ });
8191
+ }, timeout);
8192
+ function settle(action) {
8193
+ if (settled) return;
8194
+ settled = true;
8195
+ clearTimeout(timer);
8196
+ action();
8197
+ }
8198
+ child.onError((error) => {
8199
+ settle(() => {
8200
+ reject(new Error(error.message, { cause: error }));
8201
+ });
8202
+ });
8203
+ server.on("connection", (socket) => {
8204
+ socket.on("message", (data) => {
8205
+ let raw;
8206
+ try {
8207
+ raw = JSON.parse(data.toString());
8208
+ } catch {
8209
+ return;
8210
+ }
8211
+ const message = resultMessageSchema(raw);
8212
+ if (message instanceof type.errors || message.request_id !== requestId) return;
8213
+ settle(() => {
8214
+ resolve(message);
8215
+ });
8216
+ });
8217
+ });
8218
+ server.on("error", (error) => {
8219
+ settle(() => {
8220
+ reject(error);
8221
+ });
8222
+ });
8223
+ });
8224
+ }
8225
+ /**
8226
+ * Terminate any live bootstrap socket and close the result server so a lingering
8227
+ * connection can't keep node's event loop running past the CLI's exitCode-based
8228
+ * shutdown (the same hazard the WebSocket `studio` backend guards against).
8229
+ */
8230
+ function closeServer(server) {
8231
+ for (const client of server.clients) client.terminate();
8232
+ server.close();
8233
+ }
8234
+ /**
8235
+ * Real launcher: clear a stale `<place>.lock` (a previously killed Studio can't
8236
+ * remove its own, and a back-to-back run would otherwise open the place onto it
8237
+ * and crash), then spawn Studio and return the handle the host kills. The result
8238
+ * arrives over the WebSocket, not the process — the host kills this Studio once
8239
+ * it lands (instantly, or after a graceful close; see {@link StudioCliProcess}).
8240
+ *
8241
+ * `stdio: "ignore"` because nothing is read from the pipes — an unconsumed
8242
+ * `stdout` pipe could backpressure-stall a chatty Studio.
8243
+ */
8244
+ function spawnStudio(request) {
8245
+ const lockFile = `${request.placeFile}.lock`;
8246
+ fs$1.rmSync(lockFile, { force: true });
8247
+ const child = spawn(request.studioPath, request.args, {
8248
+ stdio: "ignore",
8249
+ windowsHide: !request.headed
8250
+ });
8251
+ return {
8252
+ kill: () => {
8253
+ child.kill();
8254
+ },
8255
+ killOnLockRelease: (graceCapMs) => {
8256
+ const deadline = Date.now() + graceCapMs;
8257
+ const timer = setInterval(() => {
8258
+ if (fs$1.existsSync(lockFile) && Date.now() < deadline) return;
8259
+ clearInterval(timer);
8260
+ child.kill();
8261
+ }, LOCK_POLL_INTERVAL_MS);
8262
+ },
8263
+ onError: (listener) => {
8264
+ child.on("error", listener);
8265
+ }
8266
+ };
8267
+ }
8268
+ //#endregion
7547
8269
  //#region src/backends/studio.ts
7548
8270
  const DEFAULT_STUDIO_TIMEOUT = 3e5;
7549
8271
  /**
7550
8272
  * Plugin/CLI protocol version. Must match `PROTOCOL_VERSION` in
7551
8273
  * `plugin/src/init.server.luau`. Increment when the runtime contract
7552
- * changes (e.g. runtime-injection payload shape). Stale plugins return
7553
- * `version_mismatch` explicitly OR (legacy plugins predating v2) return a
8274
+ * changes v3 added the run-mode workspace dispatch + version echo. Stale
8275
+ * plugins return `version_mismatch` explicitly OR (older plugins) return a
7554
8276
  * `results` envelope that fails schema validation because the
7555
- * `protocolVersion` echo is missing — either way the CLI surfaces a clean
7556
- * upgrade error instead of running with stale semantics.
8277
+ * `protocolVersion` echo is missing or a lower number — either way the CLI
8278
+ * surfaces a clean upgrade error instead of running with stale semantics.
7557
8279
  */
7558
- const STUDIO_PROTOCOL_VERSION = 2;
8280
+ const STUDIO_PROTOCOL_VERSION = 3;
7559
8281
  const pluginResultSchema = type({
7560
8282
  "gameOutput?": "string",
7561
8283
  "jestOutput": "string",
7562
- "protocolVersion": "number == 2",
8284
+ "protocolVersion": "number == 3",
7563
8285
  "request_id": "string",
7564
8286
  "type": "'results'"
7565
8287
  });
@@ -7599,16 +8321,11 @@ var StudioBackend = class {
7599
8321
  }
7600
8322
  async executeViaPlugin(wss, jobs, existingSocket) {
7601
8323
  const requestId = randomUUID();
7602
- const configs = jobs.map((job) => buildJestArgv(job));
7603
- const runtimeStubMounts = jobs.map((job) => job.runtimeInjectionPaths ?? []);
8324
+ const requestMessage = buildRunTestsMessage(jobs, requestId);
7604
8325
  const executionStart = Date.now();
7605
- const message = await this.waitForResult(wss, {
7606
- configs,
7607
- requestId,
7608
- runtimeStubMounts
7609
- }, existingSocket);
7610
- const executionMs = Date.now() - executionStart;
8326
+ const message = await this.waitForResult(wss, requestMessage, requestId, existingSocket);
7611
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;
7612
8329
  const entries = parseEnvelope(message.jestOutput);
7613
8330
  if (entries.length !== jobs.length) throw new Error(`Studio backend returned ${entries.length.toString()} entries but request had ${jobs.length.toString()} jobs`);
7614
8331
  return {
@@ -7621,22 +8338,16 @@ var StudioBackend = class {
7621
8338
  timing: { executionMs }
7622
8339
  };
7623
8340
  }
7624
- async waitForResult(wss, request, existingSocket) {
7625
- const { configs, requestId, runtimeStubMounts } = request;
8341
+ async waitForResult(wss, requestMessage, requestId, existingSocket) {
7626
8342
  return new Promise((resolve, reject) => {
7627
8343
  const timer = setTimeout(() => {
7628
8344
  reject(/* @__PURE__ */ new Error("Timed out waiting for Studio plugin connection"));
7629
8345
  }, this.timeout);
7630
8346
  function attachSocket(ws) {
7631
- ws.send(JSON.stringify({
7632
- action: "run_tests",
7633
- config: { configs },
7634
- protocolVersion: STUDIO_PROTOCOL_VERSION,
7635
- request_id: requestId,
7636
- runtimeStubMounts
7637
- }));
8347
+ ws.send(String(JSON.stringify(requestMessage)));
7638
8348
  ws.on("message", (data) => {
7639
- const message = pluginMessageSchema(JSON.parse(data.toString()));
8349
+ const raw = JSON.parse(data.toString());
8350
+ const message = pluginMessageSchema(raw);
7640
8351
  if (message instanceof type.errors) {
7641
8352
  clearTimeout(timer);
7642
8353
  reject(/* @__PURE__ */ new Error(`Invalid plugin message: ${message.summary}`));
@@ -7670,9 +8381,35 @@ var StudioBackend = class {
7670
8381
  function createStudioBackend(options) {
7671
8382
  return new StudioBackend(options);
7672
8383
  }
8384
+ /**
8385
+ * Build the `run_tests` WebSocket message the plugin forwards into
8386
+ * `ExecuteRunModeAsync`. A workspace run (jobs carry `pkg`) sends
8387
+ * `workspace.entries` — the staged-materializer shape the plugin's run-mode
8388
+ * runner dispatches on. A single-/multi-project run sends `config.configs` plus
8389
+ * the filtered `runtimeStubMounts` (parallel to `configs`) so the runner injects
8390
+ * `jest.config` only where Rojo doesn't already sync a user-authored one.
8391
+ */
8392
+ function buildRunTestsMessage(jobs, requestId) {
8393
+ const base = {
8394
+ action: "run_tests",
8395
+ protocolVersion: STUDIO_PROTOCOL_VERSION,
8396
+ request_id: requestId
8397
+ };
8398
+ if (isWorkspaceRun(jobs)) return {
8399
+ ...base,
8400
+ workspace: { entries: buildWorkspaceEntries(jobs) }
8401
+ };
8402
+ const { configs, runtimeStubMounts } = buildConfigEntries(jobs);
8403
+ return {
8404
+ ...base,
8405
+ config: { configs },
8406
+ runtimeStubMounts
8407
+ };
8408
+ }
7673
8409
  //#endregion
7674
8410
  //#region src/backends/auto.ts
7675
8411
  const ENV_PREFIX = "JEST_";
8412
+ const StudioBusyPattern = /previous call to start play session/i;
7676
8413
  var StudioWithFallback = class {
7677
8414
  credentials;
7678
8415
  studio;
@@ -7697,7 +8434,7 @@ var StudioWithFallback = class {
7697
8434
  }
7698
8435
  };
7699
8436
  function isStudioBusyError(error) {
7700
- 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);
7701
8438
  return typeof error === "object" && error !== null && "code" in error && error.code === "EADDRINUSE";
7702
8439
  }
7703
8440
  async function probeStudioPlugin(port, timeoutMs, createServer = (wsPort) => {
@@ -7729,6 +8466,14 @@ async function resolveBackend(cli, config, probe = probeStudioPlugin) {
7729
8466
  port: config.port,
7730
8467
  timeout: config.timeout
7731
8468
  });
8469
+ if (config.backend === "studio-cli") {
8470
+ assertStudioCliSerial(config.parallel);
8471
+ return createStudioCliBackend({
8472
+ headed: cli.headed,
8473
+ studioPath: config.studioPath,
8474
+ timeout: config.timeout
8475
+ });
8476
+ }
7732
8477
  if (config.backend === "open-cloud") return createOpenCloudBackend(buildCredentials(cli, config));
7733
8478
  const credentials = tryBuildCredentials(cli, config);
7734
8479
  const probeResult = await probe(config.port, 500);
@@ -7752,6 +8497,9 @@ async function resolveBackend(cli, config, probe = probeStudioPlugin) {
7752
8497
  if (hasUserOverrides(cli)) buildCredentials(cli, config);
7753
8498
  throw new Error("No backend available: Studio plugin not detected and no Open Cloud credentials found. Set ROBLOX_OPEN_CLOUD_API_KEY, ROBLOX_UNIVERSE_ID, and ROBLOX_PLACE_ID (or pass --apiKey, --universeId, --placeId; or set universeId/placeId in jest.config.ts).");
7754
8499
  }
8500
+ function assertStudioCliSerial(parallel) {
8501
+ if (isShardedParallel(parallel)) throw new Error("studio-cli backend is serial (one Studio instance); --parallel > 1 is not supported.");
8502
+ }
7755
8503
  function hasUserOverrides(cli) {
7756
8504
  return cli.apiKey !== void 0 || cli.universeId !== void 0 || cli.placeId !== void 0;
7757
8505
  }
@@ -7811,7 +8559,7 @@ function walkDirectory(directoryPath, baseDirectory) {
7811
8559
  */
7812
8560
  function applyExcludes(files, excludeGlobs) {
7813
8561
  if (excludeGlobs === void 0 || excludeGlobs.length === 0) return files;
7814
- return files.filter((file) => !excludeGlobs.some((pattern) => matchesGlobPattern(file, pattern)));
8562
+ return files.filter((file) => excludeGlobs.every((pattern) => !matchesGlobPattern(file, pattern)));
7815
8563
  }
7816
8564
  //#endregion
7817
8565
  //#region src/config/derive-typecheck-include.ts
@@ -7824,16 +8572,19 @@ function applyExcludes(files, excludeGlobs) {
7824
8572
  * `/\.(test-d|spec-d)\.ts$/`), so patterns ending in any other extension, lacking
7825
8573
  * a trailing `.spec.`/`.test.` marker, or already carrying `-d` are dropped.
7826
8574
  */
8575
+ const SpecTsSuffixPattern = /\.spec\.ts$/;
8576
+ const TestTsSuffixPattern = /\.test\.ts$/;
7827
8577
  function deriveTypecheckInclude(runtimeInclude) {
7828
8578
  const derived = [];
7829
- for (const pattern of runtimeInclude) if (/\.spec\.ts$/.test(pattern)) derived.push(pattern.replace(/\.spec\.ts$/, ".spec-d.ts"));
7830
- 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"));
7831
8581
  return derived;
7832
8582
  }
7833
8583
  //#endregion
7834
8584
  //#region src/utils/extensions.ts
8585
+ const TS_OR_LUAU_EXTENSION$1 = /\.(tsx?|luau?)$/;
7835
8586
  function stripTsExtension(pattern) {
7836
- return pattern.replace(/\.(tsx?|luau?)$/, "");
8587
+ return pattern.replace(TS_OR_LUAU_EXTENSION$1, "");
7837
8588
  }
7838
8589
  //#endregion
7839
8590
  //#region src/luau/eval-literals.ts
@@ -7942,6 +8693,8 @@ function getTemporaryDirectory() {
7942
8693
  }
7943
8694
  //#endregion
7944
8695
  //#region src/config/projects.ts
8696
+ const TRAILING_SLASH$2 = /\/$/;
8697
+ const TS_OR_LUAU_EXTENSION = /\.(tsx?|luau?)$/;
7945
8698
  function extractStaticRoot(pattern) {
7946
8699
  const globChars = /* @__PURE__ */ new Set([
7947
8700
  "*",
@@ -7969,7 +8722,7 @@ function extractStaticRoot(pattern) {
7969
8722
  };
7970
8723
  }
7971
8724
  function mapFsRootToDataModel(outDirectory, rojoTree) {
7972
- const normalized = outDirectory.replace(/\/$/, "");
8725
+ const normalized = outDirectory.replace(TRAILING_SLASH$2, "");
7973
8726
  const result = findInTree(rojoTree, normalized, "");
7974
8727
  if (result === void 0) {
7975
8728
  const available = [];
@@ -7994,7 +8747,7 @@ function extractProjectRoots(include) {
7994
8747
  }
7995
8748
  patterns.push(qualified);
7996
8749
  }
7997
- return [...rootMap.entries()].map(([root, testMatch]) => ({
8750
+ return Array.from(rootMap, ([root, testMatch]) => ({
7998
8751
  root,
7999
8752
  testMatch
8000
8753
  }));
@@ -8028,6 +8781,16 @@ const PROJECT_ONLY_KEYS = /* @__PURE__ */ new Set([
8028
8781
  "outDir",
8029
8782
  "root"
8030
8783
  ]);
8784
+ function dedupeMounts(mounts) {
8785
+ const seen = /* @__PURE__ */ new Set();
8786
+ const result = [];
8787
+ for (const mount of mounts) {
8788
+ if (seen.has(mount.dataModelPath)) continue;
8789
+ seen.add(mount.dataModelPath);
8790
+ result.push(mount);
8791
+ }
8792
+ return result;
8793
+ }
8031
8794
  function resolveProjectConfig(project, rootConfig, rojoTree, classify) {
8032
8795
  const rootPrefixedInclude = applyProjectRoot(project.include, project.root);
8033
8796
  const rootPrefixedExclude = applyProjectRoot(project.exclude ?? [], project.root);
@@ -8092,15 +8855,6 @@ function mergeProjectConfig(rootConfig, project) {
8092
8855
  for (const [key, value] of Object.entries(project)) if (!PROJECT_ONLY_KEYS.has(key) && key !== "typecheck" && value !== void 0) merged[key] = value;
8093
8856
  return merged;
8094
8857
  }
8095
- function dedupeMounts(mounts) {
8096
- const seen = /* @__PURE__ */ new Set();
8097
- const result = [];
8098
- for (const mount of mounts) if (!seen.has(mount.dataModelPath)) {
8099
- seen.add(mount.dataModelPath);
8100
- result.push(mount);
8101
- }
8102
- return result;
8103
- }
8104
8858
  function joinProjectRoot(relativePath, projectRoot) {
8105
8859
  return projectRoot !== void 0 ? path$1.posix.join(projectRoot, relativePath) : relativePath;
8106
8860
  }
@@ -8187,7 +8941,7 @@ function deriveIncludeFromTestMatch(config, configDirectory, tsconfig) {
8187
8941
  if (raw["include"] !== void 0) return;
8188
8942
  if (!Array.isArray(raw["testMatch"])) return;
8189
8943
  config.include = raw["testMatch"].flatMap((pattern) => {
8190
- 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));
8191
8945
  });
8192
8946
  const { outDir, rootDir } = tsconfig;
8193
8947
  if (raw["outDir"] === void 0 && rootDir !== void 0 && outDir !== void 0) {
@@ -8210,6 +8964,14 @@ const LUAU_STRING_ARRAY_KEYS = ["setupFiles", "setupFilesAfterEnv"];
8210
8964
  //#endregion
8211
8965
  //#region src/config/filter-projects-by-files.ts
8212
8966
  const DRIVE_LETTER_ABSOLUTE = /^[A-Za-z]:\//;
8967
+ function collectProjectRoots(project, posixRootDirectory) {
8968
+ const roots = [];
8969
+ for (const pattern of project.include) try {
8970
+ const { root } = extractStaticRoot(normalizeWindowsPath(pattern));
8971
+ roots.push(resolveAgainst(posixRootDirectory, root));
8972
+ } catch {}
8973
+ return roots;
8974
+ }
8213
8975
  /**
8214
8976
  * Pair each project with the subset of cli files whose include roots own them.
8215
8977
  * Used so a positional file arg can auto-pick its owning project without
@@ -8268,19 +9030,12 @@ function buildNoMatchMessage(files, roots) {
8268
9030
  const uniqueRoots = [...new Set(roots)];
8269
9031
  return `No project contains the requested file(s):\n${filesList}\n\nProject roots searched:\n${uniqueRoots.length > 0 ? uniqueRoots.map((root) => ` - ${root}`).join("\n") : " (none — projects use include patterns with no static directory prefix; pass --project explicitly)"}`;
8270
9032
  }
8271
- function collectProjectRoots(project, posixRootDirectory) {
8272
- const roots = [];
8273
- for (const pattern of project.include) try {
8274
- const { root } = extractStaticRoot(normalizeWindowsPath(pattern));
8275
- roots.push(resolveAgainst(posixRootDirectory, root));
8276
- } catch {}
8277
- return roots;
8278
- }
8279
9033
  //#endregion
8280
9034
  //#region src/config/narrow-by-files.ts
8281
9035
  const REGEX_METACHARACTERS = /[.*+?^${}()|[\]\\]/g;
8282
9036
  const TEST_FILE_EXTENSION = /\.(tsx?|luau?)$/;
8283
9037
  const TS_SOURCE_EXTENSION = /\.tsx?$/;
9038
+ const INDEX_STEM = /^index(\.|$)/;
8284
9039
  /**
8285
9040
  * Translate a list of explicit test files (typically from CLI positional args)
8286
9041
  * into a `testPathPattern` regex that constrains Jest on the Luau side. Each
@@ -8338,7 +9093,7 @@ function narrowForLuauRun(config, runtimeFiles, filterActive) {
8338
9093
  * rewritten (else a pure-Luau project's positional arg matches zero tests).
8339
9094
  */
8340
9095
  function indexStemToInit(basename) {
8341
- return basename.replace(/^index(\.|$)/, "init$1");
9096
+ return basename.replace(INDEX_STEM, "init$1");
8342
9097
  }
8343
9098
  function toBasenamePattern(file) {
8344
9099
  const posix = file.replaceAll("\\", "/");
@@ -8348,34 +9103,6 @@ function toBasenamePattern(file) {
8348
9103
  return (TS_SOURCE_EXTENSION.test(basename) ? indexStemToInit(stripped) : stripped).replace(REGEX_METACHARACTERS, "\\$&");
8349
9104
  }
8350
9105
  //#endregion
8351
- //#region src/config/resolve-typecheck-config.ts
8352
- /**
8353
- * Merges the root `test.typecheck`, per-project `test.typecheck`, and CLI
8354
- * typecheck flags into one resolved typecheck config. Precedence per field is
8355
- * CLI > project > root > default. `only` implies `enabled` (mirroring the CLI's
8356
- * `--typecheckOnly`). `include` is never derived here — the caller falls back to
8357
- * `deriveTypecheckInclude(runtimeInclude)` when it is unset.
8358
- */
8359
- function resolveTypecheckConfig(layers) {
8360
- const { cli = {}, project = {}, root = {} } = layers;
8361
- const only = cli.only ?? project.only ?? root.only ?? false;
8362
- const resolved = {
8363
- enabled: (cli.enabled ?? project.enabled ?? root.enabled ?? false) || only,
8364
- only
8365
- };
8366
- const include = project.include ?? root.include;
8367
- if (include !== void 0) resolved.include = include;
8368
- const exclude = project.exclude ?? root.exclude;
8369
- if (exclude !== void 0) resolved.exclude = exclude;
8370
- const ignoreSourceErrors = project.ignoreSourceErrors ?? root.ignoreSourceErrors;
8371
- if (ignoreSourceErrors !== void 0) resolved.ignoreSourceErrors = ignoreSourceErrors;
8372
- const spawnTimeout = project.spawnTimeout ?? root.spawnTimeout;
8373
- if (spawnTimeout !== void 0) resolved.spawnTimeout = spawnTimeout;
8374
- const tsconfig = cli.tsconfig ?? project.tsconfig ?? root.tsconfig;
8375
- if (tsconfig !== void 0) resolved.tsconfig = tsconfig;
8376
- return resolved;
8377
- }
8378
- //#endregion
8379
9106
  //#region src/config/stubs.ts
8380
9107
  const HEADER = "-- Auto-generated by jest-roblox (do not edit)\n";
8381
9108
  const STUB_FILENAME = "jest.config.luau";
@@ -8390,8 +9117,8 @@ function isGeneratedStub(filePath) {
8390
9117
  try {
8391
9118
  const fd = fs.openSync(filePath, "r");
8392
9119
  try {
8393
- const buf = Buffer.alloc(47);
8394
- 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;
8395
9122
  } finally {
8396
9123
  fs.closeSync(fd);
8397
9124
  }
@@ -8498,17 +9225,7 @@ function cleanLeftoverStubs(projects, rootDirectory) {
8498
9225
  realRoot ??= fs.realpathSync(path.resolve(rootDirectory));
8499
9226
  return realRoot;
8500
9227
  }
8501
- for (const project of projects) for (const mount of project.rojoMounts) {
8502
- assertMountContained(project, mount.fsPath, rootDirectory);
8503
- const stubPath = path.resolve(rootDirectory, mount.fsPath, STUB_FILENAME);
8504
- if (!isGeneratedStub(stubPath)) continue;
8505
- const realStubPath = fs.realpathSync(stubPath);
8506
- const root = realRootResolved();
8507
- const relativePath = path.relative(root, realStubPath);
8508
- if (!(relativePath === "" || !relativePath.startsWith("..") && !path.isAbsolute(relativePath))) throw new Error(`Project "${project.displayName}" mount fsPath resolves outside root via symlink: ${mount.fsPath} → ${realStubPath}`);
8509
- fs.unlinkSync(stubPath);
8510
- cleaned.push(stubPath);
8511
- }
9228
+ for (const project of projects) cleaned.push(...cleanLeftoverStubsForProject(project, rootDirectory, realRootResolved));
8512
9229
  return cleaned;
8513
9230
  }
8514
9231
  function generateProjectStubs(projects, rootDirectory, outputRoot) {
@@ -8522,15 +9239,7 @@ function generateProjectStubs(projects, rootDirectory, outputRoot) {
8522
9239
  include: [],
8523
9240
  testMatch: project.testMatch
8524
9241
  };
8525
- for (const mount of project.rojoMounts) {
8526
- assertMountContained(project, mount.fsPath, writeRoot);
8527
- if (hasUserAuthoredConfig(path.resolve(rootDirectory, mount.fsPath))) continue;
8528
- const outputPath = path.resolve(writeRoot, mount.fsPath, STUB_FILENAME);
8529
- entries.push({
8530
- config: stubConfig,
8531
- outputPath
8532
- });
8533
- }
9242
+ entries.push(...buildStubEntriesForProject(project, stubConfig, rootDirectory, writeRoot));
8534
9243
  }
8535
9244
  generateProjectConfigs(entries);
8536
9245
  }
@@ -8551,6 +9260,34 @@ function syncStubsToShadowDirectory(projects, rootDirectory, shadowDirectory) {
8551
9260
  }
8552
9261
  return changed;
8553
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
+ }
8554
9291
  function buildStubConfig(config) {
8555
9292
  const result = {};
8556
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;
@@ -8621,6 +9358,7 @@ function serializeLuauValue(value, indent) {
8621
9358
  }
8622
9359
  //#endregion
8623
9360
  //#region src/coverage-pipeline/derive-coverage-from.ts
9361
+ const SPEC_OR_TEST_EXTENSION = /\.(?:spec|test)(\.\w+)$/;
8624
9362
  /**
8625
9363
  * Derives `collectCoverageFrom` glob patterns from project `include` patterns.
8626
9364
  *
@@ -8653,7 +9391,7 @@ function deriveCoverageFromIncludes(projects) {
8653
9391
  * extension so that misconfigured globs fail loudly.
8654
9392
  */
8655
9393
  function inferSourceExtension(pattern) {
8656
- const match = pattern.match(/\.(?:spec|test)(\.\w+)$/);
9394
+ const match = pattern.match(SPEC_OR_TEST_EXTENSION);
8657
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).`);
8658
9396
  const [, extension] = match;
8659
9397
  return extension;
@@ -8795,9 +9533,10 @@ function extractDefinition(node, source) {
8795
9533
  //#endregion
8796
9534
  //#region src/typecheck/parse.ts
8797
9535
  const errorCodeRegExp = /error TS(?<errorCode>\d+)/;
9536
+ const LINE_SPLIT = /\r?\n/;
8798
9537
  function parseTscOutput(stdout) {
8799
9538
  const map = /* @__PURE__ */ new Map();
8800
- const merged = stdout.split(/\r?\n/).reduce((lines, next) => {
9539
+ const merged = stdout.split(LINE_SPLIT).reduce((lines, next) => {
8801
9540
  if (!next) return lines;
8802
9541
  if (next[0] !== " ") lines.push(next);
8803
9542
  else if (lines.length > 0) lines[lines.length - 1] += `\n${next}`;
@@ -8928,7 +9667,7 @@ async function runTypecheck(options) {
8928
9667
  function buildFileResult(filePath, fileInfo, errors) {
8929
9668
  const indexMap = createLocationsIndexMap(fileInfo.source);
8930
9669
  const testDefinitions = fileInfo.definitions.filter((definition) => definition.type === "test");
8931
- const sortedDefinitions = [...testDefinitions].sort((a, b) => b.start - a.start);
9670
+ const sortedDefinitions = testDefinitions.toSorted((a, b) => b.start - a.start);
8932
9671
  const errorsByTest = /* @__PURE__ */ new Map();
8933
9672
  const fileErrors = [];
8934
9673
  for (const error of errors) {
@@ -9105,7 +9844,7 @@ function discoverTestFiles(config, cliFiles) {
9105
9844
  }
9106
9845
  const ignoredPatterns = config.testPathIgnorePatterns.map((pat) => new RegExp(pat));
9107
9846
  const baseFiles = allFiles.filter((file) => {
9108
- return !ignoredPatterns.some((pattern) => pattern.test(file));
9847
+ return ignoredPatterns.every((pattern) => !pattern.test(file));
9109
9848
  });
9110
9849
  const totalFiles = new Set(baseFiles).size;
9111
9850
  let filtered = baseFiles;
@@ -9221,13 +9960,7 @@ const VERSION$2 = version;
9221
9960
  */
9222
9961
  function collectStubMounts(projects, rootDirectory, cacheRoot) {
9223
9962
  const stubMounts = [];
9224
- for (const project of projects) for (const mount of project.rojoMounts) {
9225
- if (hasUserAuthoredConfig(path$1.resolve(rootDirectory, mount.fsPath))) continue;
9226
- stubMounts.push({
9227
- absStubPath: path$1.resolve(cacheRoot, mount.fsPath, STUB_FILENAME),
9228
- dataModelPath: mount.dataModelPath
9229
- });
9230
- }
9963
+ for (const project of projects) stubMounts.push(...collectStubMountsForProject(project, rootDirectory, cacheRoot));
9231
9964
  return stubMounts;
9232
9965
  }
9233
9966
  function loadRojoTree(config) {
@@ -9237,18 +9970,40 @@ function loadRojoTree(config) {
9237
9970
  if (validated instanceof type.errors) throw new Error(`Invalid Rojo project: ${validated.summary}`);
9238
9971
  return resolveNestedProjects(validated.tree, path$1.dirname(rojoPath));
9239
9972
  }
9240
- async function runMultiProject(options) {
9241
- const { cli, config: rootConfig, rawProjects } = options;
9242
- const timing = options.timing ?? NOOP_TIMING_COLLECTOR;
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
+ /**
9994
+ * Multi-project execution core: stub generation, place build, discovery, and
9995
+ * job dispatch over a set of already-resolved projects. Shared by the
9996
+ * `projects:`-configured path (`runMultiProject`) and the no-`projects` collapse
9997
+ * (`run.ts` synthesizes one project from the config's luau roots and calls this),
9998
+ * so both paths get identical per-root `jest.config` stub injection, place
9999
+ * rebuild, coverage, and result shaping.
10000
+ */
10001
+ async function runResolvedProjects(allProjects, rootConfig, cli, timing) {
9243
10002
  const cliTypecheck = {
9244
10003
  enabled: cli.typecheck,
9245
10004
  only: cli.typecheckOnly,
9246
10005
  tsconfig: cli.typecheckTsconfig
9247
10006
  };
9248
- const rojoTree = timing.profile("loadRojoTree", () => loadRojoTree(rootConfig));
9249
- const allProjects = await timing.profileAsync("resolveAllProjects", async () => {
9250
- return resolveAllProjects(rawProjects, rootConfig, rojoTree, rootConfig.rootDir);
9251
- });
9252
10007
  timing.profile("resolveSetupFilePaths", () => {
9253
10008
  resolveAllSetupFilePaths(allProjects.map((project) => project.config));
9254
10009
  });
@@ -9341,6 +10096,14 @@ async function runMultiProject(options) {
9341
10096
  return {
9342
10097
  collectCoverageFrom: rootConfig.collectCoverage ? rootConfig.collectCoverageFrom ?? deriveCoverageFromIncludes(projects) : rootConfig.collectCoverageFrom,
9343
10098
  coverageArtifacts,
10099
+ coverageDisplayFilter: buildMultiDisplayFilter({
10100
+ cliFiles: cli.files,
10101
+ matchedRuntimeFiles: pendingJobs.flatMap((job) => job.runtimeFiles),
10102
+ projectNames: cli.project,
10103
+ projects,
10104
+ rootDir: rootConfig.rootDir,
10105
+ testPathPattern: rootConfig.testPathPattern
10106
+ }),
9344
10107
  merged: mergeForMultiResult(projectResults),
9345
10108
  mode: "multi",
9346
10109
  preCoverageMs,
@@ -9348,9 +10111,38 @@ async function runMultiProject(options) {
9348
10111
  typecheckResult
9349
10112
  };
9350
10113
  }
10114
+ async function runMultiProject(options) {
10115
+ const { cli, config: rootConfig, rawProjects } = options;
10116
+ const timing = options.timing ?? NOOP_TIMING_COLLECTOR;
10117
+ const rojoTree = timing.profile("loadRojoTree", () => loadRojoTree(rootConfig));
10118
+ return runResolvedProjects(await timing.profileAsync("resolveAllProjects", async () => {
10119
+ return resolveAllProjects(rawProjects, rootConfig, rojoTree, rootConfig.rootDir);
10120
+ }), rootConfig, cli, timing);
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
+ }
10133
+ function buildMultiDisplayFilter(options) {
10134
+ const { cliFiles, matchedRuntimeFiles, projectNames, projects, rootDir, testPathPattern } = options;
10135
+ if (cliFiles !== void 0 && cliFiles.length > 0) return sourceTwinFilter(cliFiles, rootDir);
10136
+ if (testPathPattern !== void 0) return sourceTwinFilter(matchedRuntimeFiles, rootDir);
10137
+ if (projectNames !== void 0) {
10138
+ const posixRootDirectory = normalizeWindowsPath(rootDir);
10139
+ const roots = projects.flatMap((project) => collectProjectRoots(project, posixRootDirectory));
10140
+ return roots.length > 0 ? projectRootFilter(roots) : void 0;
10141
+ }
10142
+ }
9351
10143
  function buildOpenCloudPlace(rootConfig, projects, cacheRoot) {
9352
10144
  const userRojoProjectPath = path$1.resolve(rootConfig.rootDir, rootConfig.rojoProject ?? DEFAULT_ROJO_PROJECT);
9353
- const placeFilePath = path$1.resolve(rootConfig.rootDir, rootConfig.placeFile);
10145
+ const placeFilePath = resolvePlaceFilePath(rootConfig);
9354
10146
  buildPlace({
9355
10147
  packages: [{
9356
10148
  name: "multi-project",
@@ -9510,12 +10302,11 @@ function prepareMultiProjectCoverage(rootConfig, projects, cacheRoot) {
9510
10302
  effectiveConfig: rootConfig,
9511
10303
  preCoverageMs: 0
9512
10304
  };
10305
+ const bakeStubs = rootConfig.backend !== "studio-cli";
9513
10306
  const start = Date.now();
9514
- const coverage = prepareCoverage(rootConfig, (shadowDirectory) => {
9515
- return syncStubsToShadowDirectory(projects, cacheRoot, shadowDirectory);
9516
- });
10307
+ const { artifacts, coverage } = prepareBakedCoverage(rootConfig, projects, cacheRoot, bakeStubs);
9517
10308
  return {
9518
- coverageArtifacts: toCoverageArtifacts(coverage, toBuildManifestProjects(projects)),
10309
+ coverageArtifacts: artifacts,
9519
10310
  effectiveConfig: {
9520
10311
  ...rootConfig,
9521
10312
  placeFile: coverage.placeFile
@@ -9561,6 +10352,63 @@ function selectProjects(allProjects, projectNames, cliFiles, rootDirectory) {
9561
10352
  return { projects: allProjects };
9562
10353
  }
9563
10354
  //#endregion
10355
+ //#region src/run/single-projects.ts
10356
+ const TRAILING_SLASH$1 = /\/$/;
10357
+ /**
10358
+ * Map each compiled-Luau root to its Rojo mount (FS path ↔ DataModel path) via
10359
+ * the Rojo tree. Roots that don't map (a compiled-output dir the Rojo project
10360
+ * doesn't mount) are skipped; mounts are de-duplicated by DataModel path so two
10361
+ * roots resolving to the same mount yield one entry.
10362
+ */
10363
+ function deriveProjectMounts(luauRoots, rojoTree) {
10364
+ return dedupeMounts(luauRoots.flatMap((luauRoot) => {
10365
+ const fsPath = luauRoot.replace(TRAILING_SLASH$1, "");
10366
+ const dataModelPath = findInTree(rojoTree, fsPath, "");
10367
+ return dataModelPath !== void 0 ? [{
10368
+ dataModelPath,
10369
+ fsPath
10370
+ }] : [];
10371
+ }));
10372
+ }
10373
+ /**
10374
+ * Build the single `ResolvedProjectConfig` a no-`projects` config collapses to.
10375
+ *
10376
+ * Single mode carries no explicit `projects`, but the Luau runner resolves
10377
+ * per-project config from a `jest.config` ModuleScript at each project root, so
10378
+ * a bare config must route through the multi pipeline (stub generation + place
10379
+ * rebuild). The project roots are derived from the config's luau roots mapped
10380
+ * through the Rojo tree — the same mounts the coverage manifest uses. Discovery
10381
+ * is preserved by feeding the root `testMatch` straight through as `include`
10382
+ * (this never reaches `resolveProjectConfig`, so a rootless glob is fine).
10383
+ */
10384
+ function buildImplicitProject(config, rojoTree) {
10385
+ const mounts = deriveProjectMounts(resolveLuauRoots(config), rojoTree);
10386
+ if (mounts.length === 0) throw new ConfigError("No test projects could be derived: none of the resolved luauRoots map to a $path mount in your Rojo project.", "Set \"projects\" in your test config (e.g. [\"ReplicatedStorage/shared\"]), or point \"luauRoots\" at a compiled-output directory your Rojo project mounts.");
10387
+ const runtimeGlobs = config.testMatch.filter((glob) => !TYPE_TEST_PATTERN.test(glob));
10388
+ const singleMount = mounts.length === 1 ? mounts[0] : void 0;
10389
+ return {
10390
+ config,
10391
+ displayColor: typeof config.displayName === "string" ? void 0 : config.displayName?.color,
10392
+ displayName: resolveDisplayName(config),
10393
+ exclude: config.exclude ?? [],
10394
+ include: runtimeGlobs,
10395
+ outDir: singleMount?.fsPath,
10396
+ projects: mounts.map((mount) => mount.dataModelPath),
10397
+ rojoMounts: mounts,
10398
+ testMatch: [...new Set(runtimeGlobs.map(toTestMatchPattern))],
10399
+ typecheck: config.typecheck
10400
+ };
10401
+ }
10402
+ function toTestMatchPattern(glob) {
10403
+ const stripped = stripTsExtension(glob);
10404
+ return stripped.includes("/") ? stripped : `**/${stripped}`;
10405
+ }
10406
+ function resolveDisplayName(config) {
10407
+ const { displayName, rootDir } = config;
10408
+ const name = typeof displayName === "string" ? displayName : displayName?.name;
10409
+ return name !== void 0 && name !== "" ? name : path$1.basename(path$1.normalize(rootDir));
10410
+ }
10411
+ //#endregion
9564
10412
  //#region src/run/single.ts
9565
10413
  const VERSION$1 = version;
9566
10414
  async function runSingleProject(options) {
@@ -9651,8 +10499,10 @@ async function runSingleProject(options) {
9651
10499
  }) : Promise.resolve(void 0);
9652
10500
  const [typecheckResult, runtimeResult] = await Promise.all([typecheckPass, runtimePass]);
9653
10501
  if (typeTestFiles.length > 0) timing.record("runTypecheck", typecheckMs);
10502
+ const coverageDisplayFilter = filterActive ? sourceTwinFilter(runtimeFiles, baseConfig.rootDir) : void 0;
9654
10503
  return {
9655
10504
  coverageArtifacts,
10505
+ coverageDisplayFilter,
9656
10506
  mode: "single",
9657
10507
  preCoverageMs,
9658
10508
  runtimeResult,
@@ -9749,6 +10599,11 @@ function buildWorkspaceRunOptions(input) {
9749
10599
  readCli: (entry) => entry.universeId,
9750
10600
  readConfig: (entry) => entry.universeId
9751
10601
  });
10602
+ const studioPath = resolveOptionalField(cli, perPackageConfigs, {
10603
+ name: "studioPath",
10604
+ readCli: (entry) => entry.studioPath,
10605
+ readConfig: (entry) => entry.studioPath
10606
+ });
9752
10607
  const formatters = resolveFormatters(cli, perPackageConfigs);
9753
10608
  const rawGameOutput = resolveOptionalField(cli, perPackageConfigs, {
9754
10609
  name: "gameOutput",
@@ -9791,6 +10646,7 @@ function buildWorkspaceRunOptions(input) {
9791
10646
  if (outputFile !== void 0) runOptions.outputFile = outputFile;
9792
10647
  if (parallel !== void 0) runOptions.parallel = parallel;
9793
10648
  if (placeId !== void 0) runOptions.placeId = placeId;
10649
+ if (studioPath !== void 0) runOptions.studioPath = studioPath;
9794
10650
  if (universeId !== void 0) runOptions.universeId = universeId;
9795
10651
  return runOptions;
9796
10652
  }
@@ -9924,10 +10780,11 @@ function prepareWorkspaceCoverage(options) {
9924
10780
  const timing = options.timing ?? NOOP_TIMING_COLLECTOR;
9925
10781
  const defaultMatcher = createIgnoreMatcher(DEFAULT_CONFIG.coveragePathIgnorePatterns);
9926
10782
  return packages.map((descriptor) => {
9927
- return prepareForPackage(descriptor, workspaceRoot, {
10783
+ const ignore = {
9928
10784
  matcher: descriptor.coveragePathIgnorePatterns !== void 0 ? createIgnoreMatcher(descriptor.coveragePathIgnorePatterns) : defaultMatcher,
9929
10785
  patterns: descriptor.coveragePathIgnorePatterns ?? DEFAULT_CONFIG.coveragePathIgnorePatterns
9930
- }, timing);
10786
+ };
10787
+ return prepareForPackage(descriptor, workspaceRoot, ignore, timing);
9931
10788
  });
9932
10789
  }
9933
10790
  /**
@@ -10114,10 +10971,11 @@ function prepareForPackage(descriptor, workspaceRoot, ignore, timing) {
10114
10971
  shadowDir: shadowDirectory
10115
10972
  });
10116
10973
  }
10974
+ const generatedAtDate = /* @__PURE__ */ new Date();
10117
10975
  const manifest = {
10118
10976
  buildId: crypto.randomUUID(),
10119
10977
  files: allFiles,
10120
- generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
10978
+ generatedAt: generatedAtDate.toISOString(),
10121
10979
  instrumenterVersion: 2,
10122
10980
  luauRoots: coverageRoots.map((entry) => entry.shadowDir),
10123
10981
  nonInstrumentedFiles: allNonInstrumented,
@@ -10318,7 +11176,7 @@ var StreamingAggregator = class {
10318
11176
  };
10319
11177
  //#endregion
10320
11178
  //#region src/materializer.bundled.luau
10321
- var materializer_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 g_module = {}\n \n export type CapturedMessage = { message: string, messageType: number, timestamp: number }\n \n function g_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 _g = g_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 f_module = {}\n \n local f_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 f_PATH_LINE_PREFIX = \"^[%w_%.@%-/]+:%d+:%s*\"\n \n local function f_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, f_PATH_LINE_PREFIX, \"\", 1)\n if replacements > 0 then\n return stripped\n end\n \n return message\n end\n \n function f_module.normalize(err: any): string\n if type(err) ~= \"table\" then\n return f_stripPathLinePrefix(tostring(err))\n end\n \n local current: any = err\n for _ = 1, f_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 f_stripPathLinePrefix(current.error)\n end\n \n return f_stripPathLinePrefix(tostring(err))\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 d_ReplicatedStorage = game:GetService(\"ReplicatedStorage\")\n local d_ServerStorage = game:GetService(\"ServerStorage\")\n local d_StarterPlayer = game:GetService(\"StarterPlayer\")\n \n local d_module = {}\n \n -- Identity-tracked Instances we materialized in the current package's run.\n -- Reset destroys these in reverse order, leaving mega-place root infra\n -- (Test, DevPackages, etc.) untouched. Folder-merge collisions do NOT add\n -- the merged-into folder to this list — only newly-cloned children do.\n local d_materialized: { Instance } = {}\n \n local function d_cloneInto(source: Instance, dest: Instance)\n for _, child in source:GetChildren() do\n local existing = dest:FindFirstChild(child.Name)\n if\n existing\n and (existing:IsA(\"Folder\") or existing:IsA(\"Model\"))\n and (child:IsA(\"Folder\") or child:IsA(\"Model\"))\n then\n d_cloneInto(child, existing)\n else\n local cloned = child:Clone()\n cloned.Parent = dest\n table.insert(d_materialized, cloned)\n end\n end\n end\n \n function d_module.reset()\n for index = #d_materialized, 1, -1 do\n local instance = d_materialized[index]\n if instance and instance.Parent then\n instance:Destroy()\n end\n end\n table.clear(d_materialized)\n end\n \n function d_module.materialize(pkgName: string)\n local stage = d_ServerStorage:FindFirstChild(\"__pkg_stage\")\n assert(stage, \"ServerStorage.__pkg_stage missing\")\n \n local staged = stage:FindFirstChild(pkgName)\n assert(staged, \"no stage for \" .. pkgName)\n \n if staged:IsA(\"ModuleScript\") then\n local cloned = staged:Clone()\n cloned.Parent = d_ReplicatedStorage\n table.insert(d_materialized, cloned)\n return\n end\n \n for _, stagedSvc in staged:GetChildren() do\n local svcName = stagedSvc.Name\n if svcName == \"StarterPlayer\" then\n for _, stagedSub in stagedSvc:GetChildren() do\n local liveSub = d_StarterPlayer:FindFirstChild(stagedSub.Name)\n if liveSub then\n d_cloneInto(stagedSub, liveSub)\n end\n end\n else\n local liveSvc = game:FindFirstChild(svcName)\n if liveSvc then\n d_cloneInto(stagedSvc, liveSvc)\n end\n end\n end\n end\n \n local _d = d_module\n \n function __WELD_MODULES.b() return _b 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.d() return _d end\nend\n\n--!strict\nlocal HttpService = game:GetService(\"HttpService\")\nlocal LogService = game:GetService(\"LogService\")\nlocal MemoryStoreService = game:GetService(\"MemoryStoreService\")\n\nlocal InstanceResolver = __WELD_MODULES.b()\nlocal InterceptWriteable = __WELD_MODULES.g()\nlocal PromiseError = __WELD_MODULES.f()\nlocal SnapshotPatch = __WELD_MODULES.e()\nlocal Materializer = __WELD_MODULES.d()\n\ntype CapturedMessage = InterceptWriteable.CapturedMessage\n\ntype SnapshotWrites = { [string]: string }\n\ntype EntryPayload = {\n pkg: string,\n project: string,\n config: {\n jestPath: string?,\n projects: { string }?,\n setupFiles: { string }?,\n setupFilesAfterEnv: { string }?,\n _coverage: boolean?,\n [string]: any,\n },\n}\n\ntype Payload = {\n entries: { EntryPayload },\n queueId: string?,\n invisibilityWindowSeconds: number?,\n sortedMapId: string?,\n streamingTtlSeconds: number?,\n}\n\ntype QueueItem = {\n pkg: string,\n project: string,\n}\n\ntype ResultEntry = {\n pkg: string,\n project: string,\n elapsedMs: number,\n jestOutput: string,\n snapshotWrites: SnapshotWrites?,\n gameOutput: string?,\n bannerOutput: string?,\n}\n\ntype StreamingSummary = {\n pkg: string,\n project: string,\n elapsedMs: number,\n numFailedTests: number,\n numPassedTests: number,\n numPendingTests: number,\n success: boolean,\n}\n\ntype JestResultPayload = {\n success: boolean,\n value: any?,\n err: any?,\n _coverage: { [string]: any }?,\n}\n\nlocal function resolveSetupPaths(paths: { string }?): { Instance }?\n if paths == nil or #paths == 0 then\n return paths :: any\n end\n\n local resolved = {}\n for _, setupPath in paths do\n table.insert(resolved, InstanceResolver.findInstance(setupPath))\n end\n\n return resolved\nend\n\n-- Returns: (jestSuccess, jestOutputOrErrorMessage, snapshotWrites, gameOutput, bannerOutput).\n-- `jestSuccess=false` means Jest itself rejected; `jestOutputOrErrorMessage`\n-- carries the normalized cause string in that case, otherwise the encoded\n-- success envelope.\n--\n-- `gameOutput` is the JSON-encoded LogService.MessageOut dump for the entry\n-- (everything Studio's Output would show — native print/warn, engine\n-- warnings, Jest stdout). Surfaced to users via `--gameOutput <path>`.\n--\n-- `bannerOutput` is the narrower InterceptWriteable capture of Jest's\n-- own process.stdout/stderr writes, used by the CLI error banner to\n-- surface exit-time messages like \"No tests found, exiting with code 1\".\nlocal function runEntry(\n entry: EntryPayload\n): (boolean, string, SnapshotWrites?, string?, string?)\n local JestModule = InstanceResolver.getJest(entry.config)\n\n -- Per-entry Game Output capture. Connected before SnapshotPatch.patch\n -- so any patch-time warning (e.g. RobloxShared shape changes) lands in\n -- the dump. Disconnected last during teardown, after restoreStdout/\n -- restoreStderr and SnapshotPatch.unpatch, to catch late warns that\n -- fire as Promise:expect resumes the coroutine.\n local logMessages: { CapturedMessage } = {}\n local logConnection = 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 -- Patch BEFORE require(Jest) so the snapshot mock is in place when\n -- Jest's internal modules capture their FileSystemService reference.\n -- Patch/unpatch per-entry isolates state across packages: SnapshotPatch\n -- saves the live getDataModelService each call, so re-patching without\n -- unpatch would freeze the previous mock as the restoration target.\n local snapshotWrites: SnapshotWrites = {}\n local patchState = SnapshotPatch.patch(JestModule, snapshotWrites)\n\n -- Intercept Jest's stdout/stderr around runCLI so per-pkg failure messages\n -- (e.g. \"No tests found, exiting with code 1\") are captured into the\n -- BANNER OUTPUT buffer. Single-package mode (runner.luau) does the same.\n local capturedMessages: { CapturedMessage } = {}\n local restoreStdout: (() -> ())?\n local restoreStderr: (() -> ())?\n pcall(function()\n local nodeModules = JestModule.Parent.Parent.Parent :: any\n local RobloxShared = (require :: any)(nodeModules[\"@rbxts-js\"].RobloxShared)\n local process = RobloxShared.nodeUtils.process\n\n restoreStdout = InterceptWriteable.intercept(process.stdout, capturedMessages, 0)\n restoreStderr = InterceptWriteable.intercept(process.stderr, capturedMessages, 1)\n end)\n\n -- ALL post-patch work (require, project/setup resolution, runCLI) must\n -- be inside this pcall so SnapshotPatch.unpatch always runs. If require\n -- or instance resolution threw outside the pcall, the patched mock\n -- would persist on shared RobloxShared/JestRuntime modules and the\n -- next package's patch would save the stale mock as \"original\",\n -- causing cross-package contamination.\n local ok, jestResultOrError = pcall(function()\n local Jest = (require :: any)(JestModule)\n\n local projectInstances = {}\n if entry.config.projects then\n for _, projectPath in entry.config.projects do\n table.insert(projectInstances, InstanceResolver.findInstance(projectPath))\n end\n end\n entry.config.projects = {}\n\n -- Resolve setupFiles / setupFilesAfterEnv from DataModel-path\n -- strings to ModuleScript Instances. Jest expects Instances;\n -- without this the run crashes when setup files are configured\n -- at the package level.\n entry.config.setupFiles = resolveSetupPaths(entry.config.setupFiles) :: any\n entry.config.setupFilesAfterEnv = resolveSetupPaths(entry.config.setupFilesAfterEnv) :: any\n\n local coverageEnabled = entry.config._coverage == true\n if coverageEnabled then\n -- Per-package isolation: reset the global probe sink\n -- immediately before Jest.runCLI so the captured map only\n -- contains hits from this package's instrumented sources.\n _G.__jest_roblox_cov = {}\n entry.config._coverage = nil :: any\n end\n\n local result = Jest.runCLI(script, entry.config, projectInstances):expect()\n\n local payload: JestResultPayload = {\n success = true,\n value = result.results or result,\n }\n if coverageEnabled then\n payload._coverage = _G.__jest_roblox_cov\n end\n\n return HttpService:JSONEncode(payload)\n end)\n\n if restoreStdout then\n restoreStdout()\n end\n if restoreStderr then\n restoreStderr()\n end\n\n SnapshotPatch.unpatch(patchState)\n\n -- Disconnect MessageOut last so any teardown-time warns still land in\n -- gameOutput.\n logConnection:Disconnect()\n\n local gameOutput: string?\n if #logMessages > 0 then\n local encodeOk, encoded = pcall(function()\n return HttpService:JSONEncode(logMessages)\n end)\n if encodeOk then\n gameOutput = encoded :: string\n end\n end\n\n local bannerOutput: string?\n if #capturedMessages > 0 then\n local encodeOk, encoded = pcall(function()\n return HttpService:JSONEncode(capturedMessages)\n end)\n if encodeOk then\n bannerOutput = encoded :: string\n end\n end\n\n local capturedWrites: SnapshotWrites? = if next(snapshotWrites) then snapshotWrites else nil\n\n if not ok then\n return false, PromiseError.normalize(jestResultOrError), nil, gameOutput, bannerOutput\n end\n\n return true, jestResultOrError, capturedWrites, gameOutput, bannerOutput\nend\n\nlocal function buildSummary(result: ResultEntry): StreamingSummary\n -- Default to a \"failed to run\" shape; if jestOutput parses as the\n -- materializer's envelope we'll fill in the real counts.\n --\n -- Edge case: a JSONDecode failure or unexpected inner shape leaves the\n -- defaults intact, which streams as \"▶ <pkg> 0 tests\" — visually\n -- indistinguishable from a package that legitimately ran zero tests.\n -- runEntry wraps Jest.runCLI in pcall and encodes ALL Jest-level\n -- failures as { success = false, err = ... }, so this branch is only\n -- reached when the captured jestOutput itself is malformed JSON.\n local summary: StreamingSummary = {\n pkg = result.pkg,\n project = result.project,\n elapsedMs = result.elapsedMs,\n numFailedTests = 0,\n numPassedTests = 0,\n numPendingTests = 0,\n success = false,\n }\n\n local parseOk, parsed = pcall(function()\n return HttpService:JSONDecode(result.jestOutput)\n end)\n if not parseOk or type(parsed) ~= \"table\" or parsed.success ~= true then\n return summary\n end\n\n local inner = parsed.value\n if type(inner) ~= \"table\" then\n return summary\n end\n\n summary.numFailedTests = tonumber(inner.numFailedTests) or 0\n summary.numPassedTests = tonumber(inner.numPassedTests) or 0\n summary.numPendingTests = tonumber(inner.numPendingTests) or 0\n summary.success = inner.success == true\n return summary\nend\n\nlocal function streamResult(result: ResultEntry, sortedMapId: string?, ttlSeconds: number?)\n if sortedMapId == nil or sortedMapId == \"\" then\n return\n end\n\n -- Slim payload: SortedMap items cap at 32 KB and a single package's\n -- jestOutput JSON already pushes that limit. Streaming only needs the\n -- summary fields to render a one-line progress message; the CLI keeps\n -- the full Jest result via the task envelope returned at task end.\n local summary = buildSummary(result)\n pcall(function()\n local map = MemoryStoreService:GetSortedMap(sortedMapId)\n local ttl = ttlSeconds or 600\n map:SetAsync(result.pkg .. \"::\" .. result.project, summary, ttl)\n end)\nend\n\nlocal function executeEntry(\n entry: EntryPayload,\n prevPkg: string?,\n sortedMapId: string?,\n streamingTtlSeconds: number?\n): (ResultEntry, string)\n local t0 = os.clock()\n\n -- Wrap the staging hand-off so a missing/torn-down `__pkg_stage` (or any\n -- materialize-side assertion) surfaces as a per-pkg envelope failure\n -- instead of escaping as `TaskScript:NNN: ...` and aborting the whole\n -- task script.\n local stageOk, stageErr = pcall(function()\n if entry.pkg ~= prevPkg then\n if prevPkg ~= nil then\n Materializer.reset()\n end\n Materializer.materialize(entry.pkg)\n end\n end)\n\n if not stageOk then\n local elapsedMs = math.floor((os.clock() - t0) * 1000)\n local result: ResultEntry = {\n pkg = entry.pkg,\n project = entry.project,\n elapsedMs = elapsedMs,\n jestOutput = HttpService:JSONEncode({\n success = false,\n err = PromiseError.normalize(stageErr),\n }),\n }\n streamResult(result, sortedMapId, streamingTtlSeconds)\n return result, entry.pkg\n end\n\n local pcallOk, jestSuccess, primary, snapshotWrites, gameOutput, bannerOutput =\n pcall(runEntry, entry)\n local elapsedMs = math.floor((os.clock() - t0) * 1000)\n\n local result: ResultEntry\n if not pcallOk then\n -- runEntry itself errored (e.g. InstanceResolver/getJest failure\n -- before interception was installed). `jestSuccess` here holds the\n -- raw error value pcall returned in slot 2 — normalize it the same\n -- way the Jest-rejection path does.\n result = {\n pkg = entry.pkg,\n project = entry.project,\n elapsedMs = elapsedMs,\n jestOutput = HttpService:JSONEncode({\n success = false,\n err = PromiseError.normalize(jestSuccess),\n }),\n }\n elseif jestSuccess then\n result = {\n pkg = entry.pkg,\n project = entry.project,\n elapsedMs = elapsedMs,\n jestOutput = primary,\n snapshotWrites = snapshotWrites,\n gameOutput = gameOutput,\n bannerOutput = bannerOutput,\n }\n else\n result = {\n pkg = entry.pkg,\n project = entry.project,\n elapsedMs = elapsedMs,\n jestOutput = HttpService:JSONEncode({\n success = false,\n err = primary,\n }),\n gameOutput = gameOutput,\n bannerOutput = bannerOutput,\n }\n end\n\n -- Publish the entry immediately so the CLI can stream this package's\n -- output without waiting for the whole task to finish. Best-effort\n -- (pcall): if the SortedMap write fails the entry still lands in the\n -- envelope returned at task end, so streaming failure degrades to the\n -- existing batched behavior rather than losing the result.\n streamResult(result, sortedMapId, streamingTtlSeconds)\n\n return result, entry.pkg\nend\n\nlocal function runEmbedded(payload: Payload): { ResultEntry }\n local results: { ResultEntry } = {}\n local prevPkg: string? = nil\n\n for _, entry in payload.entries do\n local result, pkg = executeEntry(entry, prevPkg, payload.sortedMapId, payload.streamingTtlSeconds)\n table.insert(results, result)\n prevPkg = pkg\n end\n\n if prevPkg ~= nil then\n Materializer.reset()\n end\n\n return results\nend\n\nlocal function runWorkStealing(payload: Payload): { ResultEntry }\n local queueId = payload.queueId :: string\n local invisibility = payload.invisibilityWindowSeconds or 90\n\n local entryByKey: { [string]: EntryPayload } = {}\n for _, entry in payload.entries do\n entryByKey[entry.pkg .. \"::\" .. entry.project] = entry\n end\n\n local queue = MemoryStoreService:GetQueue(queueId, invisibility)\n local results: { ResultEntry } = {}\n local prevPkg: string? = nil\n\n while true do\n -- waitTimeout=0: poll non-blocking. Invisibility is already set at\n -- GetQueue time above; using the full window here would stall the\n -- loop for ~invisibility seconds on the final empty read, adding\n -- that as tail latency across every parallel worker.\n local readOk, items, removeId = pcall(function()\n return queue:ReadAsync(1, false, 0)\n end)\n\n if not readOk then\n -- Surface the transient API error in game output instead of\n -- silently breaking; the outer \"no entries for N packages\"\n -- message wouldn't otherwise hint at the cause.\n warn(\"[work-stealing] ReadAsync failed: \" .. tostring(items))\n break\n end\n\n if items == nil or #items == 0 then\n break\n end\n\n local item = items[1] :: QueueItem\n local key = item.pkg .. \"::\" .. item.project\n local entry = entryByKey[key]\n if entry == nil then\n -- Foreign / stale queue item — discard and keep popping.\n pcall(function()\n queue:RemoveAsync(removeId)\n end)\n continue\n end\n\n local result, pkg = executeEntry(entry, prevPkg, payload.sortedMapId, payload.streamingTtlSeconds)\n table.insert(results, result)\n prevPkg = pkg\n\n pcall(function()\n queue:RemoveAsync(removeId)\n end)\n end\n\n if prevPkg ~= nil then\n Materializer.reset()\n end\n\n return results\nend\n\nlocal payload: Payload = HttpService:JSONDecode([==[__CONFIG_JSON__]==]) :: Payload\n\nlocal results: { ResultEntry }\nif payload.queueId ~= nil and payload.queueId ~= \"\" then\n results = runWorkStealing(payload)\nelse\n results = runEmbedded(payload)\nend\n\nlocal gameOutputs = {}\nfor _ = 1, #results do\n table.insert(gameOutputs, \"[]\")\nend\n\nreturn HttpService:JSONEncode({ entries = results }), table.concat(gameOutputs, \"\")\n";
11179
+ var materializer_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 g_module = {}\n \n export type CapturedMessage = { message: string, messageType: number, timestamp: number }\n \n function g_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 _g = g_module\n \n --!strict\n local f_ReplicatedStorage = game:GetService(\"ReplicatedStorage\")\n local f_ServerStorage = game:GetService(\"ServerStorage\")\n local f_StarterPlayer = game:GetService(\"StarterPlayer\")\n \n local f_module = {}\n \n -- Identity-tracked Instances we materialized in the current package's run.\n -- Reset destroys these in reverse order, leaving mega-place root infra\n -- (Test, DevPackages, etc.) untouched. Folder-merge collisions do NOT add\n -- the merged-into folder to this list — only newly-cloned children do.\n local f_materialized: { Instance } = {}\n \n local function f_cloneInto(source: Instance, dest: Instance)\n for _, child in source:GetChildren() do\n local existing = dest:FindFirstChild(child.Name)\n if\n existing\n and (existing:IsA(\"Folder\") or existing:IsA(\"Model\"))\n and (child:IsA(\"Folder\") or child:IsA(\"Model\"))\n then\n f_cloneInto(child, existing)\n else\n local cloned = child:Clone()\n cloned.Parent = dest\n table.insert(f_materialized, cloned)\n end\n end\n end\n \n function f_module.reset()\n for index = #f_materialized, 1, -1 do\n local instance = f_materialized[index]\n if instance and instance.Parent then\n instance:Destroy()\n end\n end\n table.clear(f_materialized)\n end\n \n function f_module.materialize(pkgName: string)\n local stage = f_ServerStorage:FindFirstChild(\"__pkg_stage\")\n assert(stage, \"ServerStorage.__pkg_stage missing\")\n \n local staged = stage:FindFirstChild(pkgName)\n assert(staged, \"no stage for \" .. pkgName)\n \n if staged:IsA(\"ModuleScript\") then\n local cloned = staged:Clone()\n cloned.Parent = f_ReplicatedStorage\n table.insert(f_materialized, cloned)\n return\n end\n \n for _, stagedSvc in staged:GetChildren() do\n local svcName = stagedSvc.Name\n if svcName == \"StarterPlayer\" then\n for _, stagedSub in stagedSvc:GetChildren() do\n local liveSub = f_StarterPlayer:FindFirstChild(stagedSub.Name)\n if liveSub then\n f_cloneInto(stagedSub, liveSub)\n end\n end\n else\n local liveSvc = game:FindFirstChild(svcName)\n if liveSvc then\n f_cloneInto(stagedSvc, liveSvc)\n end\n end\n end\n end\n \n local _f = f_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 e_module = {}\n \n local e_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 e_PATH_LINE_PREFIX = \"^[%w_%.@%-/]+:%d+:%s*\"\n \n local function e_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, e_PATH_LINE_PREFIX, \"\", 1)\n if replacements > 0 then\n return stripped\n end\n \n return message\n end\n \n function e_module.normalize(err: any): string\n if type(err) ~= \"table\" then\n return e_stripPathLinePrefix(tostring(err))\n end\n \n local current: any = err\n for _ = 1, e_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 e_stripPathLinePrefix(current.error)\n end\n \n return e_stripPathLinePrefix(tostring(err))\n end\n \n local _e = e_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 d_CoreScriptSyncService = _c\n local d_InstanceResolver = _b\n \n local d_module = {}\n \n function d_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 d_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 d_module.patch(jestModule: ModuleScript, snapshotWrites: { [string]: string }): PatchState?\n local mockGetDataModelService = d_module.createMockGetDataModelService(snapshotWrites)\n \n local robloxSharedInstance = d_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 = d_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 d_module.unpatch(partialState)\n error(err, 0)\n end\n \n return partialState\n end\n \n function d_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 _d = d_module\n \n --!strict\n -- Single-process workspace runner: clone each package from the staged\n -- mega-place's `ServerStorage.__pkg_stage`, run Jest, reset, repeat. Shared by\n -- two drivers: the OCALE task script (`staging/entry.luau`, which adds the\n -- MemoryStore work-stealing variant) and the Studio plugin's Run-mode runner\n -- (`plugin/src/test-in-run-mode.server.luau`, single-process only). Both pass\n -- their own calling script so `Jest.runCLI` resolves against the live DataModel.\n local h_HttpService = game:GetService(\"HttpService\")\n local h_LogService = game:GetService(\"LogService\")\n local h_MemoryStoreService = game:GetService(\"MemoryStoreService\")\n \n local h_InstanceResolver = _b\n local h_InterceptWriteable = _g\n local h_Materializer = _f\n local h_PromiseError = _e\n local h_SnapshotPatch = _d\n \n type CapturedMessage = InterceptWriteable.CapturedMessage\n \n type SnapshotWrites = { [string]: string }\n \n export type EntryPayload = {\n pkg: string,\n project: string,\n config: {\n jestPath: string?,\n projects: { string }?,\n setupFiles: { string }?,\n setupFilesAfterEnv: { string }?,\n _coverage: boolean?,\n [string]: any,\n },\n }\n \n export type Payload = {\n entries: { EntryPayload },\n queueId: string?,\n invisibilityWindowSeconds: number?,\n sortedMapId: string?,\n streamingTtlSeconds: number?,\n }\n \n type QueueItem = {\n pkg: string,\n project: string,\n }\n \n export type ResultEntry = {\n pkg: string,\n project: string,\n elapsedMs: number,\n jestOutput: string,\n snapshotWrites: SnapshotWrites?,\n gameOutput: string?,\n bannerOutput: string?,\n }\n \n type StreamingSummary = {\n pkg: string,\n project: string,\n elapsedMs: number,\n numFailedTests: number,\n numPassedTests: number,\n numPendingTests: number,\n success: boolean,\n }\n \n type JestResultPayload = {\n success: boolean,\n value: any?,\n err: any?,\n _coverage: { [string]: any }?,\n }\n \n local function h_resolveSetupPaths(paths: { string }?): { Instance }?\n if paths == nil or #paths == 0 then\n return paths :: any\n end\n \n local resolved = {}\n for _, setupPath in paths do\n table.insert(resolved, h_InstanceResolver.findInstance(setupPath))\n end\n \n return resolved\n end\n \n -- Returns: (jestSuccess, jestOutputOrErrorMessage, snapshotWrites, gameOutput, bannerOutput).\n -- `jestSuccess=false` means Jest itself rejected; `jestOutputOrErrorMessage`\n -- carries the normalized cause string in that case, otherwise the encoded\n -- success envelope.\n --\n -- `gameOutput` is the JSON-encoded LogService.MessageOut dump for the entry\n -- (everything Studio's Output would show — native print/warn, engine\n -- warnings, Jest stdout). Surfaced to users via `--gameOutput <path>`.\n --\n -- `bannerOutput` is the narrower InterceptWriteable capture of Jest's\n -- own process.stdout/stderr writes, used by the CLI error banner to\n -- surface exit-time messages like \"No tests found, exiting with code 1\".\n local function h_runEntry(\n callingScript: LuaSourceContainer,\n entry: EntryPayload\n ): (boolean, string, SnapshotWrites?, string?, string?)\n local JestModule = h_InstanceResolver.getJest(entry.config)\n \n -- Per-entry Game Output capture. Connected before SnapshotPatch.patch\n -- so any patch-time warning (e.g. RobloxShared shape changes) lands in\n -- the dump. Disconnected last during teardown, after restoreStdout/\n -- restoreStderr and SnapshotPatch.unpatch, to catch late warns that\n -- fire as Promise:expect resumes the coroutine.\n local logMessages: { CapturedMessage } = {}\n local logConnection = h_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 -- Patch BEFORE require(Jest) so the snapshot mock is in place when\n -- Jest's internal modules capture their FileSystemService reference.\n -- Patch/unpatch per-entry isolates state across packages: SnapshotPatch\n -- saves the live getDataModelService each call, so re-patching without\n -- unpatch would freeze the previous mock as the restoration target.\n local snapshotWrites: SnapshotWrites = {}\n local patchState = h_SnapshotPatch.patch(JestModule, snapshotWrites)\n \n -- Intercept Jest's stdout/stderr around runCLI so per-pkg failure messages\n -- (e.g. \"No tests found, exiting with code 1\") are captured into the\n -- BANNER OUTPUT buffer. Single-package mode (runner.luau) does the same.\n local capturedMessages: { CapturedMessage } = {}\n local restoreStdout: (() -> ())?\n local restoreStderr: (() -> ())?\n pcall(function()\n local nodeModules = JestModule.Parent.Parent.Parent :: any\n local RobloxShared = (require :: any)(nodeModules[\"@rbxts-js\"].RobloxShared)\n local process = RobloxShared.nodeUtils.process\n \n restoreStdout = h_InterceptWriteable.intercept(process.stdout, capturedMessages, 0)\n restoreStderr = h_InterceptWriteable.intercept(process.stderr, capturedMessages, 1)\n end)\n \n -- ALL post-patch work (require, project/setup resolution, runCLI) must\n -- be inside this pcall so SnapshotPatch.unpatch always runs. If require\n -- or instance resolution threw outside the pcall, the patched mock\n -- would persist on shared RobloxShared/JestRuntime modules and the\n -- next package's patch would save the stale mock as \"original\",\n -- causing cross-package contamination.\n local ok, jestResultOrError = pcall(function()\n local Jest = (require :: any)(JestModule)\n \n local projectInstances = {}\n if entry.config.projects then\n for _, projectPath in entry.config.projects do\n table.insert(projectInstances, h_InstanceResolver.findInstance(projectPath))\n end\n end\n entry.config.projects = {}\n \n -- Resolve setupFiles / setupFilesAfterEnv from DataModel-path\n -- strings to ModuleScript Instances. Jest expects Instances;\n -- without this the run crashes when setup files are configured\n -- at the package level.\n entry.config.setupFiles = h_resolveSetupPaths(entry.config.setupFiles) :: any\n entry.config.setupFilesAfterEnv = h_resolveSetupPaths(entry.config.setupFilesAfterEnv) :: any\n \n local coverageEnabled = entry.config._coverage == true\n if coverageEnabled then\n -- Per-package isolation: reset the global probe sink\n -- immediately before Jest.runCLI so the captured map only\n -- contains hits from this package's instrumented sources.\n _G.__jest_roblox_cov = {}\n entry.config._coverage = nil :: any\n end\n \n local result = Jest.runCLI(callingScript, entry.config, projectInstances):expect()\n \n local payload: JestResultPayload = {\n success = true,\n value = result.results or result,\n }\n if coverageEnabled then\n payload._coverage = _G.__jest_roblox_cov\n end\n \n return h_HttpService:JSONEncode(payload)\n end)\n \n if restoreStdout then\n restoreStdout()\n end\n if restoreStderr then\n restoreStderr()\n end\n \n h_SnapshotPatch.unpatch(patchState)\n \n -- Disconnect MessageOut last so any teardown-time warns still land in\n -- gameOutput.\n logConnection:Disconnect()\n \n local gameOutput: string?\n if #logMessages > 0 then\n local encodeOk, encoded = pcall(function()\n return h_HttpService:JSONEncode(logMessages)\n end)\n if encodeOk then\n gameOutput = encoded :: string\n end\n end\n \n local bannerOutput: string?\n if #capturedMessages > 0 then\n local encodeOk, encoded = pcall(function()\n return h_HttpService:JSONEncode(capturedMessages)\n end)\n if encodeOk then\n bannerOutput = encoded :: string\n end\n end\n \n local capturedWrites: SnapshotWrites? = if next(snapshotWrites) then snapshotWrites else nil\n \n if not ok then\n return false, h_PromiseError.normalize(jestResultOrError), nil, gameOutput, bannerOutput\n end\n \n return true, jestResultOrError, capturedWrites, gameOutput, bannerOutput\n end\n \n local function h_buildSummary(result: ResultEntry): StreamingSummary\n -- Default to a \"failed to run\" shape; if jestOutput parses as the\n -- materializer's envelope we'll fill in the real counts.\n --\n -- Edge case: a JSONDecode failure or unexpected inner shape leaves the\n -- defaults intact, which streams as \"▶ <pkg> 0 tests\" — visually\n -- indistinguishable from a package that legitimately ran zero tests.\n -- runEntry wraps Jest.runCLI in pcall and encodes ALL Jest-level\n -- failures as { success = false, err = ... }, so this branch is only\n -- reached when the captured jestOutput itself is malformed JSON.\n local summary: StreamingSummary = {\n pkg = result.pkg,\n project = result.project,\n elapsedMs = result.elapsedMs,\n numFailedTests = 0,\n numPassedTests = 0,\n numPendingTests = 0,\n success = false,\n }\n \n local parseOk, parsed = pcall(function()\n return h_HttpService:JSONDecode(result.jestOutput)\n end)\n if not parseOk or type(parsed) ~= \"table\" or parsed.success ~= true then\n return summary\n end\n \n local inner = parsed.value\n if type(inner) ~= \"table\" then\n return summary\n end\n \n summary.numFailedTests = tonumber(inner.numFailedTests) or 0\n summary.numPassedTests = tonumber(inner.numPassedTests) or 0\n summary.numPendingTests = tonumber(inner.numPendingTests) or 0\n summary.success = inner.success == true\n return summary\n end\n \n local function h_streamResult(result: ResultEntry, sortedMapId: string?, ttlSeconds: number?)\n if sortedMapId == nil or sortedMapId == \"\" then\n return\n end\n \n -- Slim payload: SortedMap items cap at 32 KB and a single package's\n -- jestOutput JSON already pushes that limit. Streaming only needs the\n -- summary fields to render a one-line progress message; the CLI keeps\n -- the full Jest result via the task envelope returned at task end.\n local summary = h_buildSummary(result)\n pcall(function()\n local map = h_MemoryStoreService:GetSortedMap(sortedMapId)\n local ttl = ttlSeconds or 600\n map:SetAsync(result.pkg .. \"::\" .. result.project, summary, ttl)\n end)\n end\n \n local function h_executeEntry(\n callingScript: LuaSourceContainer,\n entry: EntryPayload,\n prevPkg: string?,\n sortedMapId: string?,\n streamingTtlSeconds: number?\n ): (ResultEntry, string)\n local t0 = os.clock()\n \n -- Wrap the staging hand-off so a missing/torn-down `__pkg_stage` (or any\n -- materialize-side assertion) surfaces as a per-pkg envelope failure\n -- instead of escaping as `TaskScript:NNN: ...` and aborting the whole\n -- task script.\n local stageOk, stageErr = pcall(function()\n if entry.pkg ~= prevPkg then\n if prevPkg ~= nil then\n h_Materializer.reset()\n end\n h_Materializer.materialize(entry.pkg)\n end\n end)\n \n if not stageOk then\n local elapsedMs = math.floor((os.clock() - t0) * 1000)\n local result: ResultEntry = {\n pkg = entry.pkg,\n project = entry.project,\n elapsedMs = elapsedMs,\n jestOutput = h_HttpService:JSONEncode({\n success = false,\n err = h_PromiseError.normalize(stageErr),\n }),\n }\n h_streamResult(result, sortedMapId, streamingTtlSeconds)\n return result, entry.pkg\n end\n \n local pcallOk, jestSuccess, primary, snapshotWrites, gameOutput, bannerOutput =\n pcall(h_runEntry, callingScript, entry)\n local elapsedMs = math.floor((os.clock() - t0) * 1000)\n \n local result: ResultEntry\n if not pcallOk then\n -- runEntry itself errored (e.g. InstanceResolver/getJest failure\n -- before interception was installed). `jestSuccess` here holds the\n -- raw error value pcall returned in slot 2 — normalize it the same\n -- way the Jest-rejection path does.\n result = {\n pkg = entry.pkg,\n project = entry.project,\n elapsedMs = elapsedMs,\n jestOutput = h_HttpService:JSONEncode({\n success = false,\n err = h_PromiseError.normalize(jestSuccess),\n }),\n }\n elseif jestSuccess then\n result = {\n pkg = entry.pkg,\n project = entry.project,\n elapsedMs = elapsedMs,\n jestOutput = primary,\n snapshotWrites = snapshotWrites,\n gameOutput = gameOutput,\n bannerOutput = bannerOutput,\n }\n else\n result = {\n pkg = entry.pkg,\n project = entry.project,\n elapsedMs = elapsedMs,\n jestOutput = h_HttpService:JSONEncode({\n success = false,\n err = primary,\n }),\n gameOutput = gameOutput,\n bannerOutput = bannerOutput,\n }\n end\n \n -- Publish the entry immediately so the CLI can stream this package's\n -- output without waiting for the whole task to finish. Best-effort\n -- (pcall): if the SortedMap write fails the entry still lands in the\n -- envelope returned at task end, so streaming failure degrades to the\n -- existing batched behavior rather than losing the result.\n h_streamResult(result, sortedMapId, streamingTtlSeconds)\n \n return result, entry.pkg\n end\n \n local h_module = {}\n \n function h_module.runEmbedded(callingScript: LuaSourceContainer, payload: Payload): { ResultEntry }\n local results: { ResultEntry } = {}\n local prevPkg: string? = nil\n \n for _, entry in payload.entries do\n local result, pkg = h_executeEntry(\n callingScript,\n entry,\n prevPkg,\n payload.sortedMapId,\n payload.streamingTtlSeconds\n )\n table.insert(results, result)\n prevPkg = pkg\n end\n \n if prevPkg ~= nil then\n h_Materializer.reset()\n end\n \n return results\n end\n \n function h_module.runWorkStealing(\n callingScript: LuaSourceContainer,\n payload: Payload\n ): { ResultEntry }\n local queueId = payload.queueId :: string\n local invisibility = payload.invisibilityWindowSeconds or 90\n \n local entryByKey: { [string]: EntryPayload } = {}\n for _, entry in payload.entries do\n entryByKey[entry.pkg .. \"::\" .. entry.project] = entry\n end\n \n local queue = h_MemoryStoreService:GetQueue(queueId, invisibility)\n local results: { ResultEntry } = {}\n local prevPkg: string? = nil\n \n while true do\n -- waitTimeout=0: poll non-blocking. Invisibility is already set at\n -- GetQueue time above; using the full window here would stall the\n -- loop for ~invisibility seconds on the final empty read, adding\n -- that as tail latency across every parallel worker.\n local readOk, items, removeId = pcall(function()\n return queue:ReadAsync(1, false, 0)\n end)\n \n if not readOk then\n -- Surface the transient API error in game output instead of\n -- silently breaking; the outer \"no entries for N packages\"\n -- message wouldn't otherwise hint at the cause.\n warn(\"[work-stealing] ReadAsync failed: \" .. tostring(items))\n break\n end\n \n if items == nil or #items == 0 then\n break\n end\n \n local item = items[1] :: QueueItem\n local key = item.pkg .. \"::\" .. item.project\n local entry = entryByKey[key]\n if entry == nil then\n -- Foreign / stale queue item — discard and keep popping.\n pcall(function()\n queue:RemoveAsync(removeId)\n end)\n continue\n end\n \n local result, pkg = h_executeEntry(\n callingScript,\n entry,\n prevPkg,\n payload.sortedMapId,\n payload.streamingTtlSeconds\n )\n table.insert(results, result)\n prevPkg = pkg\n \n pcall(function()\n queue:RemoveAsync(removeId)\n end)\n end\n \n if prevPkg ~= nil then\n h_Materializer.reset()\n end\n \n return results\n end\n \n local _h = h_module\n \n function __WELD_MODULES.b() return _b end\n function __WELD_MODULES.g() return _g end\n function __WELD_MODULES.f() return _f end\n function __WELD_MODULES.e() return _e end\n function __WELD_MODULES.c() return _c end\n function __WELD_MODULES.a() return _a end\n function __WELD_MODULES.d() return _d end\n function __WELD_MODULES.h() return _h end\nend\n\n--!strict\n-- OCALE task script for workspace runs. Decodes the injected payload and\n-- dispatches to the shared embedded runner: a `queueId` selects the MemoryStore\n-- work-stealing variant (parallel OCALE tasks), otherwise the single-task\n-- embedded walk. The Studio plugin's Run-mode runner drives `runEmbedded` from\n-- the same module (single-process only).\nlocal HttpService = game:GetService(\"HttpService\")\n\nlocal EmbeddedRunner = __WELD_MODULES.h()\n\ntype Payload = EmbeddedRunner.Payload\ntype ResultEntry = EmbeddedRunner.ResultEntry\n\nlocal payload: Payload = HttpService:JSONDecode([==[__CONFIG_JSON__]==]) :: Payload\n\nlocal results: { ResultEntry }\nif payload.queueId ~= nil and payload.queueId ~= \"\" then\n results = EmbeddedRunner.runWorkStealing(script, payload)\nelse\n results = EmbeddedRunner.runEmbedded(script, payload)\nend\n\nlocal gameOutputs = {}\nfor _ = 1, #results do\n table.insert(gameOutputs, \"[]\")\nend\n\nreturn HttpService:JSONEncode({ entries = results }), table.concat(gameOutputs, \"\")\n";
10322
11180
  //#endregion
10323
11181
  //#region src/staging/test-script-staged.ts
10324
11182
  function generateMaterializerScript(inputs, options = {}) {
@@ -10478,7 +11336,7 @@ async function runWorkspaceProfiled(options, timing) {
10478
11336
  const { emptyPackageErrors, filteredContexts, pending, typecheckByDirectory, typeTestEntries, typeTestProjects } = timing.profile("discoverTests", () => {
10479
11337
  const discoveredContexts = applyProjectFilter(contexts, cli.project);
10480
11338
  const discovered = collectPendingEntries(discoveredContexts, cli);
10481
- const typeTestPackages = new Set([...discovered.typecheckByDirectory.values()].map((entry) => entry.pkg));
11339
+ const typeTestPackages = new Set(Array.from(discovered.typecheckByDirectory.values(), (entry) => entry.pkg));
10482
11340
  const policy = applyEmptyPackagePolicy(discovered.pending, discoveredContexts, typeTestPackages);
10483
11341
  return {
10484
11342
  emptyPackageErrors: policy.emptyPackageErrors,
@@ -10585,7 +11443,7 @@ async function runWorkspaceProfiled(options, timing) {
10585
11443
  pending,
10586
11444
  results,
10587
11445
  runOptions,
10588
- verbose: options.cli.verbose,
11446
+ verbose: cli.verbose,
10589
11447
  workspaceRoot
10590
11448
  });
10591
11449
  return attachTypecheck(attachCoverageManifests(results, pending, coverageByPackage), typecheckPass.outcome, timing);
@@ -10815,6 +11673,43 @@ function applyEmptyPackagePolicy(allEntries, contexts, typeTestPackages) {
10815
11673
  pending
10816
11674
  };
10817
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
+ }
10818
11713
  function collectPendingEntries(contexts, cli) {
10819
11714
  const cliTypecheck = {
10820
11715
  enabled: cli.typecheck,
@@ -10825,46 +11720,15 @@ function collectPendingEntries(contexts, cli) {
10825
11720
  const typeTestEntries = [];
10826
11721
  const typeTestProjects = [];
10827
11722
  const typecheckByDirectory = /* @__PURE__ */ new Map();
10828
- for (const ctx of contexts) {
10829
- const { packageDirectory } = ctx.info;
10830
- const packageTypecheck = resolveTypecheckConfig({
10831
- cli: cliTypecheck,
10832
- root: ctx.pkgConfig.typecheck
10833
- });
10834
- for (const project of ctx.projects) {
10835
- const typecheck = resolveTypecheckConfig({
10836
- cli: cliTypecheck,
10837
- project: project.typecheck,
10838
- root: ctx.pkgConfig.typecheck
10839
- });
10840
- const projectConfig = buildProjectExecutionConfig(ctx.pkgConfig, project);
10841
- const testFiles = typecheck.only ? [] : discoverProjectTestFiles(project, packageDirectory);
10842
- pending.push({
10843
- pkg: ctx.info.name,
10844
- project,
10845
- projectConfig,
10846
- testFiles
10847
- });
10848
- if (!typecheck.enabled) continue;
10849
- const typeTestFiles = discoverProjectTypeTests(project, typecheck, packageDirectory);
10850
- if (typeTestFiles.length === 0) continue;
10851
- typeTestProjects.push({
10852
- pkg: ctx.info.name,
10853
- project: project.displayName
10854
- });
10855
- typeTestEntries.push({
10856
- cwd: packageDirectory,
10857
- files: typeTestFiles,
10858
- ...typecheck.tsconfig !== void 0 ? { tsconfig: typecheck.tsconfig } : {}
10859
- });
10860
- typecheckByDirectory.set(packageDirectory, {
10861
- ignoreSourceErrors: packageTypecheck.ignoreSourceErrors,
10862
- pkg: ctx.info.name,
10863
- spawnTimeout: packageTypecheck.spawnTimeout,
10864
- timeout: ctx.pkgConfig.timeout
10865
- });
10866
- }
10867
- }
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
+ });
10868
11732
  return {
10869
11733
  pending,
10870
11734
  typecheckByDirectory,
@@ -11020,19 +11884,24 @@ function liveProjectsByPackage(pending) {
11020
11884
  }
11021
11885
  return live;
11022
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
+ }
11023
11898
  function writeStubsAndBuildDescriptors(contexts, liveProjects) {
11024
11899
  return contexts.map((ctx) => {
11025
11900
  const live = liveProjects.get(ctx.info.name) ?? /* @__PURE__ */ new Set();
11026
11901
  const liveProjectsForPackage = ctx.projects.filter((project) => live.has(project.displayName));
11027
11902
  generateProjectStubs(liveProjectsForPackage, ctx.info.packageDirectory, ctx.cacheRoot);
11028
11903
  const stubMounts = [];
11029
- for (const project of liveProjectsForPackage) for (const mount of project.rojoMounts) {
11030
- if (hasUserAuthoredConfig(path$1.resolve(ctx.info.packageDirectory, mount.fsPath))) continue;
11031
- stubMounts.push({
11032
- absStubPath: path$1.resolve(ctx.cacheRoot, mount.fsPath, STUB_FILENAME),
11033
- dataModelPath: mount.dataModelPath
11034
- });
11035
- }
11904
+ for (const project of liveProjectsForPackage) stubMounts.push(...collectLiveProjectStubMounts(project, ctx));
11036
11905
  return {
11037
11906
  ...ctx.descriptor,
11038
11907
  stubMounts
@@ -11056,6 +11925,7 @@ function discoverWorkspaceRoot(cwd) {
11056
11925
  //#endregion
11057
11926
  //#region src/workspace/package-resolver.ts
11058
11927
  const JEST_CONFIG_MARKER$1 = /^jest\.config\.[^.]+$/;
11928
+ const TRAILING_SLASH = /\/$/;
11059
11929
  function readPackageJsonName(packageJsonPath) {
11060
11930
  if (!fs$1.existsSync(packageJsonPath)) return;
11061
11931
  const raw = parsePackageJson(packageJsonPath);
@@ -11093,7 +11963,7 @@ function listPnpmPackages(workspaceRoot) {
11093
11963
  const patterns = parseYAML(fs$1.readFileSync(yamlPath, "utf-8")).packages ?? [];
11094
11964
  const packages = [];
11095
11965
  for (const pattern of patterns) {
11096
- const matches = globSync(`${pattern.replace(/\/$/, "")}/package.json`, { cwd: workspaceRoot });
11966
+ const matches = globSync(`${pattern.replace(TRAILING_SLASH, "")}/package.json`, { cwd: workspaceRoot });
11097
11967
  for (const match of matches) {
11098
11968
  const packageJsonPath = path$1.join(workspaceRoot, match);
11099
11969
  const name = readPackageJsonName(packageJsonPath);
@@ -11105,9 +11975,6 @@ function listPnpmPackages(workspaceRoot) {
11105
11975
  }
11106
11976
  return packages;
11107
11977
  }
11108
- function inferPackageName(packageDirectory) {
11109
- return readPackageJsonName(path$1.join(packageDirectory, "package.json")) ?? path$1.basename(packageDirectory);
11110
- }
11111
11978
  function assertNoDuplicateNames(packages, workspaceRoot) {
11112
11979
  const byName = /* @__PURE__ */ new Map();
11113
11980
  for (const package_ of packages) {
@@ -11117,26 +11984,29 @@ function assertNoDuplicateNames(packages, workspaceRoot) {
11117
11984
  byName.set(package_.name, list);
11118
11985
  }
11119
11986
  for (const [name, paths] of byName) if (paths.length > 1) {
11120
- const sorted = [...paths].sort();
11987
+ const sorted = paths.toSorted();
11121
11988
  throw new Error(`Duplicate package name "${name}" from ${sorted.join(" and ")}. Add a package.json with a unique \`name\`, or rename a directory.`);
11122
11989
  }
11123
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
+ }
11124
12006
  function enumerateFromGlobs(workspaceRoot, patterns) {
11125
12007
  const seenDirectories = /* @__PURE__ */ new Set();
11126
12008
  const packages = [];
11127
- for (const pattern of patterns) {
11128
- const matches = globSync(`${pattern.replace(/\/$/, "")}/jest.config.*`, { cwd: workspaceRoot });
11129
- for (const match of matches) {
11130
- if (!JEST_CONFIG_MARKER$1.test(path$1.basename(match))) continue;
11131
- const packageDirectory = path$1.dirname(path$1.join(workspaceRoot, match));
11132
- if (seenDirectories.has(packageDirectory)) continue;
11133
- seenDirectories.add(packageDirectory);
11134
- packages.push({
11135
- name: inferPackageName(packageDirectory),
11136
- packageDirectory
11137
- });
11138
- }
11139
- }
12009
+ for (const pattern of patterns) collectPackagesFromMatches(globSync(`${pattern.replace(TRAILING_SLASH, "")}/jest.config.*`, { cwd: workspaceRoot }), workspaceRoot, seenDirectories, packages);
11140
12010
  assertNoDuplicateNames(packages, workspaceRoot);
11141
12011
  return packages;
11142
12012
  }
@@ -11147,7 +12017,7 @@ function resolvePosixShim(binDirectory, command) {
11147
12017
  const candidate = path$1.join(binDirectory, command);
11148
12018
  return fs$1.existsSync(candidate) ? candidate : command;
11149
12019
  }
11150
- function buildCmdExeArgs(command, args) {
12020
+ function buildCommandExeArgs(command, args) {
11151
12021
  return [
11152
12022
  "/d",
11153
12023
  "/s",
@@ -11209,7 +12079,7 @@ function runTool(command, args, cwd) {
11209
12079
  PATH: `${binDirectory}${path$1.delimiter}${process.env["PATH"]}`
11210
12080
  } : process.env;
11211
12081
  const file = isWindows ? "cmd.exe" : resolvePosixShim(binDirectory, command);
11212
- const spawnArgs = isWindows ? buildCmdExeArgs(command, args) : args;
12082
+ const spawnArgs = isWindows ? buildCommandExeArgs(command, args) : args;
11213
12083
  try {
11214
12084
  return cp.execFileSync(file, spawnArgs, {
11215
12085
  cwd,
@@ -11293,11 +12163,17 @@ function validateBasicWorkspaceFlags(cli) {
11293
12163
  /**
11294
12164
  * Checks the resolved WorkspaceRunOptions for invariants that depend on the
11295
12165
  * fully resolved values (CLI > per-package consensus > defaults).
12166
+ *
12167
+ * Every backend now runs workspace (studio-cli launches its own mega-place;
12168
+ * the attached `studio` backend runs against an open Studio for debugging),
12169
+ * so the only resolved-value invariant left is studio-cli's serial constraint:
12170
+ * it drives one Studio instance and cannot shard.
11296
12171
  */
11297
12172
  function assertWorkspaceRunOptions(runOptions) {
11298
- if (runOptions.backend === "studio") return {
12173
+ const { backend, parallel } = runOptions;
12174
+ if (backend === "studio-cli" && isShardedParallel(parallel)) return {
11299
12175
  exitCode: 2,
11300
- message: "Error: --workspace requires --backend open-cloud (Studio not supported).\n",
12176
+ message: "Error: studio-cli backend is serial (one Studio instance) and cannot shard; drop --parallel or set it to 1 for a --workspace run.\n",
11301
12177
  ok: false
11302
12178
  };
11303
12179
  return { ok: true };
@@ -11424,6 +12300,11 @@ async function runWorkspaceMode(cli, workspace, timing) {
11424
12300
  }
11425
12301
  function resolveWorkspaceBackend(cli, runOptions) {
11426
12302
  if (cli.typecheckOnly === true) return {};
12303
+ if (runOptions.backend === "studio-cli") return { backend: createStudioCliBackend({
12304
+ headed: cli.headed,
12305
+ ...runOptions.studioPath !== void 0 ? { studioPath: runOptions.studioPath } : {}
12306
+ }) };
12307
+ if (runOptions.backend === "studio") return { backend: createStudioBackend({ port: runOptions.port }) };
11427
12308
  try {
11428
12309
  const credentials = buildWorkspaceCredentials(cli, runOptions);
11429
12310
  const backend = createOpenCloudBackend(credentials);
@@ -11566,11 +12447,19 @@ async function runSingleOrMulti(cli, merged, timing) {
11566
12447
  rawProjects,
11567
12448
  timing
11568
12449
  });
11569
- return runSingleProject({
12450
+ if (resolveTypecheckConfig({
12451
+ cli: {
12452
+ enabled: cli.typecheck,
12453
+ only: cli.typecheckOnly,
12454
+ tsconfig: cli.typecheckTsconfig
12455
+ },
12456
+ root: merged.typecheck
12457
+ }).only) return runSingleProject({
11570
12458
  cli,
11571
12459
  config: merged,
11572
12460
  timing
11573
12461
  });
12462
+ return runResolvedProjects([buildImplicitProject(merged, timing.profile("loadRojoTree", () => loadRojoTree(merged)))], merged, cli, timing);
11574
12463
  }
11575
12464
  async function runJestRoblox(cli, config) {
11576
12465
  const timing = createTimingCollector();
@@ -11584,4 +12473,4 @@ async function runJestRoblox(cli, config) {
11584
12473
  }
11585
12474
  }
11586
12475
  //#endregion
11587
- export { loadConfig$1 as $, formatJobSummary as A, formatResult as B, BUILD_MANIFEST_VERSION as C, outputMultiResult as D, readBuildManifest as E, writeGameOutput as F, manifestSchema as G, formatBanner as H, createTimingCollector as I, applyAttribution as J, readManifest as K, formatJson as L, runProjects as M, formatGameOutputNotice as N, outputSingleResult as O, parseGameOutput as P, mergeCliWithConfig as Q, writeJsonFile$1 as R, buildPlace as S, emitBuildManifest as T, hashFile as U, formatTestSummary as V, MANIFEST_VERSION as W, extractJsonFromOutput as X, LuauScriptError as Y, parseJestOutput as Z, COVERAGE_MANIFEST_PATH as _, loadRojoTree as a, SHARED_TEST_KEYS as at, visitExpression as b, StudioBackend as c, defineProject as ct, createOpenCloudBackend as d, version as dt, resolveConfig as et, formatMissingScopes as f, COVERAGE_BUILD_MANIFEST_PATH as g, generateTestScript as h, collectStubMounts as i, ROOT_CLI_KEYS as it, formatExecuteOutput as j, formatAnnotations as k, createStudioBackend as l, isValidBackend as lt, buildJestArgv as m, runJestRoblox as n, GLOBAL_TEST_KEYS as nt, runTypecheck as o, VALID_BACKENDS as ot, walkErrorChain as p, writeManifest as q, runSingleOrMulti as r, JEST_ARGV_EXCLUDED_KEYS as rt, resolveAllProjects as s, defineConfig as st, getRawProjects as t, DEFAULT_CONFIG as tt, OpenCloudBackend as u, ConfigError as ut, findRojoProject as v, buildManifestSchema as w, visitStatement as x, visitBlock as y, formatFailure 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 };