@isentinel/jest-roblox 0.3.7 → 0.3.8

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.8";
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"
@@ -600,6 +612,7 @@ function mergeCliWithConfig(cli, config) {
600
612
  showLuau: cli.showLuau ?? config.showLuau,
601
613
  silent: cli.silent ?? config.silent,
602
614
  sourceMap: cli.sourceMap ?? config.sourceMap,
615
+ studioPath: cli.studioPath ?? config.studioPath,
603
616
  testNamePattern: cli.testNamePattern ?? config.testNamePattern,
604
617
  testPathPattern: cli.testPathPattern ?? config.testPathPattern,
605
618
  timeout: cli.timeout ?? config.timeout,
@@ -1128,6 +1141,50 @@ function mergeFileCoverage(a, b) {
1128
1141
  return merged;
1129
1142
  }
1130
1143
  //#endregion
1144
+ //#region src/coverage-pipeline/agent-table-filter.ts
1145
+ const TEST_MARKER = /\.(?:test|spec)(?:-d)?(\.[^./]+)$/;
1146
+ /**
1147
+ * Build a predicate that keeps a universe file when it is the **source twin** of
1148
+ * one of `testFiles` — the file under test reached by stripping the
1149
+ * `.test`/`.spec` marker and keeping the same directory. Membership is exact
1150
+ * (not glob), so source paths carrying glob metacharacters (route-group
1151
+ * directories like `(foo)`) compare literally. Each test file is resolved
1152
+ * against `rootDirectory` and normalized so positionals (absolute) and
1153
+ * glob-discovered files (relative) land in the same namespace as the resolved
1154
+ * universe keys.
1155
+ */
1156
+ function sourceTwinFilter(testFiles, rootDirectory) {
1157
+ const twins = new Set(testFiles.map((file) => {
1158
+ return normalizeWindowsPath(path$1.resolve(rootDirectory, file)).replace(TEST_MARKER, "$1");
1159
+ }));
1160
+ return (candidate) => twins.has(candidate);
1161
+ }
1162
+ /**
1163
+ * Build a predicate that keeps a universe file living under one of `roots` — the
1164
+ * static include roots of the selected `--project`(s). Containment is a path
1165
+ * prefix at a directory boundary (so root `src/shared` matches
1166
+ * `src/shared/x.ts` but not `src/shared-extra/x.ts`). Roots are normalized to
1167
+ * the same absolute namespace as the resolved universe keys.
1168
+ */
1169
+ function projectRootFilter(roots) {
1170
+ const normalizedRoots = roots.map((root) => normalizeWindowsPath(root));
1171
+ return (candidate) => {
1172
+ return normalizedRoots.some((root) => candidate === root || candidate.startsWith(`${root}/`));
1173
+ };
1174
+ }
1175
+ /**
1176
+ * Narrow a mapped coverage universe to the files `predicate` keeps. Each key is
1177
+ * resolved to a normalized-absolute path before the predicate runs, mirroring
1178
+ * how `filterCoverageUniverse` canonicalizes paths so the two layers agree. Used
1179
+ * only for the agent text table; every other reporter and the totals line keep
1180
+ * the full universe.
1181
+ */
1182
+ function narrowMappedForAgentTable(mapped, predicate) {
1183
+ return { files: Object.fromEntries(Object.entries(mapped.files).filter(([filePath]) => {
1184
+ return predicate(normalizeWindowsPath(path$1.resolve(filePath)));
1185
+ })) };
1186
+ }
1187
+ //#endregion
1131
1188
  //#region src/coverage-pipeline/coverage-universe.ts
1132
1189
  /**
1133
1190
  * Decides which mapped source files make up the coverage report universe.
@@ -1184,31 +1241,37 @@ function printCoverageHeader(agentMode = false) {
1184
1241
  }
1185
1242
  const TEXT_REPORTERS = /* @__PURE__ */ new Set(["text", "text-summary"]);
1186
1243
  function generateReports(options) {
1187
- const coverageMap = buildCoverageMap$2(filterCoverageUniverse(options.mapped, {
1244
+ const filtered = filterCoverageUniverse(options.mapped, {
1188
1245
  ignore: options.coveragePathIgnorePatterns,
1189
1246
  include: options.collectCoverageFrom
1190
- }));
1247
+ });
1248
+ const coverageMap = buildCoverageMap$2(filtered);
1191
1249
  const agentMode = options.agentMode === true;
1250
+ const defaultSummarizer = agentMode ? "flat" : "pkg";
1192
1251
  const context = istanbulReport.createContext({
1193
1252
  coverageMap,
1194
- defaultSummarizer: agentMode ? "flat" : "pkg",
1253
+ defaultSummarizer,
1195
1254
  dir: options.coverageDirectory
1196
1255
  });
1256
+ const textTable = resolveTextTableView({
1257
+ agentTextFilter: agentMode ? options.agentTextFilter : void 0,
1258
+ coverageDirectory: options.coverageDirectory,
1259
+ defaultSummarizer,
1260
+ fallback: context,
1261
+ filtered
1262
+ });
1197
1263
  const terminalColumns = getTerminalColumns();
1198
1264
  const hasTextReporter = options.reporters.some((name) => TEXT_REPORTERS.has(name));
1199
1265
  const allFilesFull = agentMode && isAllFilesFull(coverageMap);
1200
1266
  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
- }
1267
+ runReporters({
1268
+ agentMode,
1269
+ allFilesFull,
1270
+ fullContext: context,
1271
+ reporters: options.reporters,
1272
+ terminalColumns,
1273
+ textTable
1274
+ });
1212
1275
  if (agentMode && !allFilesFull && hasTextReporter && coverageMap.files().length > 0) process.stdout.write(formatAgentTotals(coverageMap));
1213
1276
  }
1214
1277
  function checkThresholds(mapped, thresholds, collectCoverageFrom, coveragePathIgnorePatterns) {
@@ -1252,38 +1315,23 @@ function checkThresholds(mapped, thresholds, collectCoverageFrom, coveragePathIg
1252
1315
  passed: failures.length === 0
1253
1316
  };
1254
1317
  }
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;
1318
+ function isValidReporter(name) {
1319
+ return VALID_REPORTERS.has(name);
1279
1320
  }
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
- });
1321
+ function runReporters(options) {
1322
+ const { agentMode, allFilesFull, fullContext, reporters, terminalColumns, textTable } = options;
1323
+ for (const reporterName of reporters) {
1324
+ if (!isValidReporter(reporterName)) throw new Error(`Unknown coverage reporter: ${reporterName}`);
1325
+ if (allFilesFull && TEXT_REPORTERS.has(reporterName)) continue;
1326
+ if (reporterName === "text" && textTable.isEmpty) continue;
1327
+ let reporterOptions = {};
1328
+ if (reporterName === "text") reporterOptions = {
1329
+ maxCols: terminalColumns,
1330
+ skipFull: agentMode
1331
+ };
1332
+ else if (TEXT_REPORTERS.has(reporterName)) reporterOptions = { skipFull: agentMode };
1333
+ istanbulReports.create(reporterName, reporterOptions).execute(reporterName === "text" ? textTable.context : fullContext);
1334
+ }
1287
1335
  }
1288
1336
  function buildCoverageMap$2(mapped) {
1289
1337
  const coverageMap = istanbulCoverage.createCoverageMap({});
@@ -1315,8 +1363,54 @@ function buildCoverageMap$2(mapped) {
1315
1363
  }
1316
1364
  return coverageMap;
1317
1365
  }
1318
- function isValidReporter(name) {
1319
- return VALID_REPORTERS.has(name);
1366
+ function resolveTextTableView(options) {
1367
+ const { agentTextFilter, coverageDirectory, defaultSummarizer, fallback, filtered } = options;
1368
+ if (agentTextFilter === void 0) return {
1369
+ context: fallback,
1370
+ isEmpty: false
1371
+ };
1372
+ const textCoverageMap = buildCoverageMap$2(narrowMappedForAgentTable(filtered, agentTextFilter));
1373
+ return {
1374
+ context: istanbulReport.createContext({
1375
+ coverageMap: textCoverageMap,
1376
+ defaultSummarizer,
1377
+ dir: coverageDirectory
1378
+ }),
1379
+ isEmpty: textCoverageMap.files().length === 0
1380
+ };
1381
+ }
1382
+ function printCompactFullSummary(coverageMap) {
1383
+ const fileCount = coverageMap.files().length;
1384
+ const label = fileCount === 1 ? "file" : "files";
1385
+ process.stdout.write(`Coverage: 100% (${fileCount} ${label})\n`);
1386
+ }
1387
+ function formatTotalsPart(label, totals) {
1388
+ assert(typeof totals.pct === "number", "coverage summary pct must be numeric");
1389
+ return `${totals.pct}% ${label} (${totals.covered}/${totals.total})`;
1390
+ }
1391
+ function formatAgentTotals(coverageMap) {
1392
+ const summary = coverageMap.getCoverageSummary();
1393
+ return `Coverage: ${[
1394
+ formatTotalsPart("stmts", summary.statements),
1395
+ formatTotalsPart("branch", summary.branches),
1396
+ formatTotalsPart("funcs", summary.functions),
1397
+ formatTotalsPart("lines", summary.lines)
1398
+ ].join(" | ")}\n`;
1399
+ }
1400
+ function getTerminalColumns() {
1401
+ if (process.stdout.columns !== void 0) return process.stdout.columns;
1402
+ const columnsEnvironment = process.env["COLUMNS"];
1403
+ if (columnsEnvironment === void 0) return;
1404
+ const parsed = Number(columnsEnvironment);
1405
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : void 0;
1406
+ }
1407
+ function isAllFilesFull(coverageMap) {
1408
+ const files = coverageMap.files();
1409
+ if (files.length === 0) return false;
1410
+ return files.every((file) => {
1411
+ const summary = coverageMap.fileCoverageFor(file).toSummary();
1412
+ return summary.statements.pct === 100 && summary.branches.pct === 100 && summary.functions.pct === 100 && summary.lines.pct === 100;
1413
+ });
1320
1414
  }
1321
1415
  //#endregion
1322
1416
  //#region packages/rojo-utils/dist/index.mjs
@@ -1752,6 +1846,10 @@ var RojoResolver = class RojoResolver {
1752
1846
  }
1753
1847
  parsePath(itemPath) {
1754
1848
  const luauPath = convertToLuau(itemPath);
1849
+ if (ROJO_FILE_REGEX.test(path.basename(luauPath))) {
1850
+ this.parseConfig(luauPath, true);
1851
+ return;
1852
+ }
1755
1853
  const realPath = fs$1.existsSync(luauPath) ? this.cachedRealpath(luauPath) : luauPath;
1756
1854
  const extension = path.extname(luauPath);
1757
1855
  if (ROJO_MODULE_EXTS.has(extension)) this.filePathToRbxPathMap.set(luauPath, [...this.rbxPath]);
@@ -4855,8 +4953,9 @@ async function writeResultFile(outputFile, typecheck, runtime) {
4855
4953
  await writeJsonFile$1(mergeResults$1(typecheck, runtime), outputFile);
4856
4954
  }
4857
4955
  async function outputSingleResult(config, result) {
4858
- const { preCoverageMs, runtimeResult, typecheckResult } = result;
4956
+ const { coverageDisplayFilter, preCoverageMs, runtimeResult, typecheckResult } = result;
4859
4957
  const mergedResult = mergeResults$1(typecheckResult, runtimeResult?.result);
4958
+ const coverageData = runtimeResult?.coverageData;
4860
4959
  const coveragePassed = emitResultsAndCoverage({
4861
4960
  config,
4862
4961
  coverageEnabled: config.collectCoverage,
@@ -4870,7 +4969,7 @@ async function outputSingleResult(config, result) {
4870
4969
  typecheckResult
4871
4970
  });
4872
4971
  },
4873
- runCoverage: () => processCoverage(config, runtimeResult?.coverageData)
4972
+ runCoverage: () => processCoverage(config, coverageData, void 0, coverageDisplayFilter)
4874
4973
  });
4875
4974
  await writeResultFile(config.outputFile, typecheckResult, runtimeResult?.result);
4876
4975
  if (runtimeResult !== void 0) writeGameOutputIfConfigured(config, runtimeResult.gameOutput, { hintsShown: !mergedResult.success });
@@ -4958,6 +5057,7 @@ async function outputMultiResult(rootConfig, result) {
4958
5057
  const merged = mergeProjectResults(projectResults.map((entry) => entry.result));
4959
5058
  const mergedResult = mergeResults$1(typecheckResult, merged.result);
4960
5059
  const workspaceCoverage = extractWorkspaceCoverageMapped(result);
5060
+ const displayFilter = extractCoverageDisplayFilter(result);
4961
5061
  const coveragePassed = emitResultsAndCoverage({
4962
5062
  config,
4963
5063
  coverageEnabled: config.collectCoverage || workspaceCoverage !== void 0,
@@ -4973,7 +5073,7 @@ async function outputMultiResult(rootConfig, result) {
4973
5073
  });
4974
5074
  if (typecheckResult !== void 0 && !usesDefaultFormatter(config)) process.stderr.write(formatTypecheckSummary(typecheckResult));
4975
5075
  },
4976
- runCoverage: () => processCoverage(config, merged.coverageData, workspaceCoverage)
5076
+ runCoverage: () => processCoverage(config, merged.coverageData, workspaceCoverage, displayFilter)
4977
5077
  });
4978
5078
  if (mode === "multi") {
4979
5079
  await writeResultFile(config.outputFile, typecheckResult, merged.result);
@@ -5067,7 +5167,7 @@ function enforceThresholds(config, mapped) {
5067
5167
  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
5168
  return false;
5069
5169
  }
5070
- function processCoverage(config, coverageData, preMapped) {
5170
+ function processCoverage(config, coverageData, preMapped, agentTextFilter) {
5071
5171
  if (!config.collectCoverage && preMapped === void 0) return true;
5072
5172
  const mapped = resolveMappedCoverage(config, coverageData, preMapped);
5073
5173
  if (mapped === void 0) return true;
@@ -5075,6 +5175,7 @@ function processCoverage(config, coverageData, preMapped) {
5075
5175
  if (!config.silent) printCoverageHeader(agentMode);
5076
5176
  generateReports({
5077
5177
  agentMode,
5178
+ agentTextFilter,
5078
5179
  collectCoverageFrom: config.collectCoverageFrom,
5079
5180
  coverageDirectory: path$1.resolve(config.rootDir, config.coverageDirectory),
5080
5181
  coveragePathIgnorePatterns: config.coveragePathIgnorePatterns,
@@ -5124,6 +5225,9 @@ function buildReportConfig(rootConfig, result) {
5124
5225
  function extractWorkspaceCoverageMapped(result) {
5125
5226
  return "coverageMapped" in result ? result.coverageMapped : void 0;
5126
5227
  }
5228
+ function extractCoverageDisplayFilter(result) {
5229
+ return result.mode === "multi" ? result.coverageDisplayFilter : void 0;
5230
+ }
5127
5231
  function resolveSinkHints(result, config) {
5128
5232
  const gameOutput = result.mode === "workspace" ? result.gameOutput : config.gameOutput;
5129
5233
  const outputFile = result.mode === "workspace" ? result.outputFile : config.outputFile;
@@ -5193,6 +5297,34 @@ function writeAggregatedGameOutput(config, projectResults, options) {
5193
5297
  }
5194
5298
  }
5195
5299
  //#endregion
5300
+ //#region src/config/resolve-typecheck-config.ts
5301
+ /**
5302
+ * Merges the root `test.typecheck`, per-project `test.typecheck`, and CLI
5303
+ * typecheck flags into one resolved typecheck config. Precedence per field is
5304
+ * CLI > project > root > default. `only` implies `enabled` (mirroring the CLI's
5305
+ * `--typecheckOnly`). `include` is never derived here — the caller falls back to
5306
+ * `deriveTypecheckInclude(runtimeInclude)` when it is unset.
5307
+ */
5308
+ function resolveTypecheckConfig(layers) {
5309
+ const { cli = {}, project = {}, root = {} } = layers;
5310
+ const only = cli.only ?? project.only ?? root.only ?? false;
5311
+ const resolved = {
5312
+ enabled: (cli.enabled ?? project.enabled ?? root.enabled ?? false) || only,
5313
+ only
5314
+ };
5315
+ const include = project.include ?? root.include;
5316
+ if (include !== void 0) resolved.include = include;
5317
+ const exclude = project.exclude ?? root.exclude;
5318
+ if (exclude !== void 0) resolved.exclude = exclude;
5319
+ const ignoreSourceErrors = project.ignoreSourceErrors ?? root.ignoreSourceErrors;
5320
+ if (ignoreSourceErrors !== void 0) resolved.ignoreSourceErrors = ignoreSourceErrors;
5321
+ const spawnTimeout = project.spawnTimeout ?? root.spawnTimeout;
5322
+ if (spawnTimeout !== void 0) resolved.spawnTimeout = spawnTimeout;
5323
+ const tsconfig = cli.tsconfig ?? project.tsconfig ?? root.tsconfig;
5324
+ if (tsconfig !== void 0) resolved.tsconfig = tsconfig;
5325
+ return resolved;
5326
+ }
5327
+ //#endregion
5196
5328
  //#region src/coverage-pipeline/build-manifest.ts
5197
5329
  /**
5198
5330
  * On-disk format version for `build-manifest.json`. Independent of
@@ -5390,7 +5522,7 @@ const SERVICE_PROPERTIES = /* @__PURE__ */ new Set([
5390
5522
  "LoadStringEnabled"
5391
5523
  ]);
5392
5524
  function synthesize(input) {
5393
- if (input.wrap === false) return synthesizeNoWrap(input.packages);
5525
+ if (input.wrap === false) return synthesizeNoWrap(input.packages, input.loadStringEnabled);
5394
5526
  const stage = { $className: "Folder" };
5395
5527
  for (const descriptor of input.packages) {
5396
5528
  const root = absolutizePaths(transformToFolder(loadRojoProject(descriptor.rojoProjectPath).tree), path$1.dirname(descriptor.rojoProjectPath), {
@@ -5507,7 +5639,27 @@ function injectStubMounts(root, stubMounts) {
5507
5639
  leaf[STUB_INJECTION_KEY] = { $path: normalizeWindowsPath(mount.absStubPath) };
5508
5640
  }
5509
5641
  }
5510
- function synthesizeNoWrap(packages) {
5642
+ function isProperties(value) {
5643
+ return typeof value === "object" && value !== null && !Array.isArray(value);
5644
+ }
5645
+ /**
5646
+ * Force `ServerScriptService.LoadStringEnabled = true` on a no-wrap tree,
5647
+ * creating the service node if the user's project omits it and merging into any
5648
+ * existing `$properties` so a hand-set property is preserved. studio-cli's
5649
+ * Run-mode runner gates on LoadString, so the Clean Place must enable it
5650
+ * regardless of what the user's `.project.json` declares.
5651
+ */
5652
+ function enableLoadString(tree) {
5653
+ const existing = tree["ServerScriptService"];
5654
+ const service = isTreeNode(existing) ? existing : { $className: "ServerScriptService" };
5655
+ service.$className ??= "ServerScriptService";
5656
+ service.$properties = {
5657
+ ...isProperties(service.$properties) ? service.$properties : {},
5658
+ LoadStringEnabled: true
5659
+ };
5660
+ tree["ServerScriptService"] = service;
5661
+ }
5662
+ function synthesizeNoWrap(packages, loadStringEnabled = false) {
5511
5663
  if (packages.length !== 1) throw new ConfigError(`synthesize wrap:false requires exactly one package, got ${String(packages.length)}`);
5512
5664
  const descriptor = packages[0];
5513
5665
  const project = loadRojoProject(descriptor.rojoProjectPath);
@@ -5516,6 +5668,7 @@ function synthesizeNoWrap(packages) {
5516
5668
  coverageRoots: descriptor.coverageRoots
5517
5669
  });
5518
5670
  injectStubMounts(tree, descriptor.stubMounts);
5671
+ if (loadStringEnabled) enableLoadString(tree);
5519
5672
  return stableStringify({
5520
5673
  ...project.raw,
5521
5674
  tree
@@ -5542,9 +5695,6 @@ function filterServiceProperties(props) {
5542
5695
  for (const [propertyKey, propertyValue] of Object.entries(props)) if (!SERVICE_PROPERTIES.has(propertyKey)) filtered[propertyKey] = propertyValue;
5543
5696
  return filtered;
5544
5697
  }
5545
- function isProperties(value) {
5546
- return typeof value === "object" && !Array.isArray(value);
5547
- }
5548
5698
  function transformChildEntry(key, value) {
5549
5699
  if (key === "$className" && typeof value === "string" && SERVICE_CLASSES.has(value)) return "Folder";
5550
5700
  if (key === "$properties" && isProperties(value)) {
@@ -5569,8 +5719,9 @@ function transformValue(key, value) {
5569
5719
  * `coverageRoots`.
5570
5720
  */
5571
5721
  function buildPlace(options) {
5572
- const { packages, placeFile, projectFile, wrap } = options;
5722
+ const { loadStringEnabled, packages, placeFile, projectFile, wrap } = options;
5573
5723
  const projectJson = synthesize({
5724
+ loadStringEnabled,
5574
5725
  packages,
5575
5726
  wrap
5576
5727
  });
@@ -6827,6 +6978,7 @@ function validateRelativeRoots(luauRoots) {
6827
6978
  }
6828
6979
  function buildRojoProject(rojoProjectPath, packageDirectory, coverageRoots, placeFile) {
6829
6980
  return buildPlace({
6981
+ loadStringEnabled: true,
6830
6982
  packages: [{
6831
6983
  name: "jest-roblox-coverage",
6832
6984
  coverageRoots,
@@ -7248,6 +7400,28 @@ function toQueueData(value) {
7248
7400
  return value;
7249
7401
  }
7250
7402
  //#endregion
7403
+ //#region src/backends/interface.ts
7404
+ /**
7405
+ * Whether this is a workspace (multi-package) run. Workspace jobs each carry
7406
+ * their owning package name (`pkg`); single-/multi-project jobs never do, and
7407
+ * the run layer builds them all-or-none — so any job with `pkg` means the whole
7408
+ * run is a workspace run. The Studio backends key off this to drive the plugin's
7409
+ * staged-materializer dispatch (`workspace.entries`) instead of the configs
7410
+ * path. `buildWorkspaceEntries` then fails fast if a job is missing `pkg`, so a
7411
+ * malformed (mixed) array surfaces as a clear error rather than a bad payload.
7412
+ */
7413
+ function isWorkspaceRun(jobs) {
7414
+ return jobs.some((job) => job.pkg !== void 0);
7415
+ }
7416
+ /**
7417
+ * A request to shard across multiple sessions: `"auto"` (the backend picks a
7418
+ * count) or an explicit count > 1. The serial backends (studio, studio-cli)
7419
+ * reject this — they drive a single Studio instance.
7420
+ */
7421
+ function isShardedParallel(parallel) {
7422
+ return parallel === "auto" || typeof parallel === "number" && parallel > 1;
7423
+ }
7424
+ //#endregion
7251
7425
  //#region src/test-runner.bundled.luau
7252
7426
  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
7427
  //#endregion
@@ -7315,7 +7489,7 @@ var OpenCloudBackend = class {
7315
7489
  if (jobs.length === 0) throw new Error("OpenCloudBackend requires at least one job");
7316
7490
  if (workStealing === true && scriptOverride === void 0) throw new Error("OpenCloudBackend work-stealing mode requires scriptOverride");
7317
7491
  const primary = jobs[0];
7318
- const placeFilePath = path$1.resolve(primary.config.rootDir, primary.config.placeFile);
7492
+ const placeFilePath = resolvePlaceFilePath(primary.config);
7319
7493
  const upload = await this.runner.uploadPlace({ placeFilePath });
7320
7494
  const executionStart = Date.now();
7321
7495
  return {
@@ -7544,22 +7718,485 @@ function aggregateEntriesByKey(taskEnvelopes) {
7544
7718
  return entryByKey;
7545
7719
  }
7546
7720
  //#endregion
7721
+ //#region src/backends/plugin-payload.ts
7722
+ /**
7723
+ * The per-(package, project) entries the plugin's Run-mode runner feeds to its
7724
+ * embedded materializer for a workspace run. Shared by both Studio backends —
7725
+ * studio-cli writes them into the bootstrap payload, the WebSocket studio
7726
+ * backend sends them in the `run_tests` message — so the entry shape can't drift
7727
+ * between the two transports.
7728
+ */
7729
+ function buildWorkspaceEntries(jobs) {
7730
+ return jobs.map((job) => {
7731
+ if (job.pkg === void 0) throw new Error(`studio-cli: workspace entry for project "${job.displayName}" is missing its package name (pkg)`);
7732
+ return {
7733
+ config: buildJestArgv(job),
7734
+ pkg: job.pkg,
7735
+ project: job.displayName
7736
+ };
7737
+ });
7738
+ }
7739
+ /**
7740
+ * The configs + filtered injection mounts the single-/multi-project configs path
7741
+ * consumes (`Runner.runProjects`). `runtimeStubMounts[i]` is parallel to
7742
+ * `configs[i]`: the DataModel paths the runner injects `jest.config` into,
7743
+ * excluding mounts where Rojo already syncs a user-authored config.
7744
+ */
7745
+ function buildConfigEntries(jobs) {
7746
+ return {
7747
+ configs: jobs.map((job) => buildJestArgv(job)),
7748
+ runtimeStubMounts: jobs.map((job) => job.runtimeInjectionPaths ?? [])
7749
+ };
7750
+ }
7751
+ //#endregion
7752
+ //#region src/backends/studio-discovery.ts
7753
+ const WINDOWS_STUDIO_EXECUTABLE = "RobloxStudioBeta.exe";
7754
+ const MACOS_STUDIO_EXECUTABLE = "/Applications/RobloxStudio.app/Contents/MacOS/RobloxStudioBeta";
7755
+ const NOT_FOUND_HINT = "Install Roblox Studio, or set studioPath (config key, --studioPath, or JEST_ROBLOX_STUDIO_PATH).";
7756
+ /**
7757
+ * Resolve the Roblox Studio executable studio-cli should launch. An explicit
7758
+ * `override` wins; otherwise probe the known per-OS install locations and pick
7759
+ * the newest `RobloxStudioBeta.exe`. Throws a clear, actionable error when no
7760
+ * executable can be found so the CLI surfaces "install Studio or set
7761
+ * studioPath" rather than a downstream spawn failure.
7762
+ */
7763
+ function discoverStudioPath(options = {}) {
7764
+ const { environment = process.env, override, platform = process.platform } = options;
7765
+ if (override !== void 0) {
7766
+ const stat = fs$1.statSync(override, { throwIfNoEntry: false });
7767
+ if (stat === void 0) throw new Error(`Roblox Studio not found at studioPath override: ${override}`);
7768
+ if (!stat.isFile()) throw new Error(`studioPath override is not a file: ${override}`);
7769
+ return normalizeWindowsPath(override);
7770
+ }
7771
+ if (platform === "win32") return discoverWindows(environment);
7772
+ if (platform === "darwin") return discoverMacOs();
7773
+ throw new Error(`studio-cli backend has no Studio auto-discovery for platform "${platform}". Set studioPath to point at your Roblox Studio executable.`);
7774
+ }
7775
+ function notFound() {
7776
+ return /* @__PURE__ */ new Error(`Roblox Studio not found. ${NOT_FOUND_HINT}`);
7777
+ }
7778
+ function discoverWindows(environment) {
7779
+ const localAppData = environment["LOCALAPPDATA"];
7780
+ if (localAppData === void 0 || localAppData === "") throw new Error(`Cannot locate Roblox Studio: LOCALAPPDATA is not set. ${NOT_FOUND_HINT}`);
7781
+ const versionsDirectory = path$1.join(localAppData, "Roblox", "Versions");
7782
+ let entries;
7783
+ try {
7784
+ entries = fs$1.readdirSync(versionsDirectory, { withFileTypes: true });
7785
+ } catch {
7786
+ throw notFound();
7787
+ }
7788
+ let newest;
7789
+ for (const entry of entries) {
7790
+ if (!entry.isDirectory()) continue;
7791
+ const executable = path$1.join(versionsDirectory, entry.name, WINDOWS_STUDIO_EXECUTABLE);
7792
+ const stat = fs$1.statSync(executable, { throwIfNoEntry: false });
7793
+ if (stat === void 0) continue;
7794
+ if (newest === void 0 || stat.mtimeMs > newest.mtimeMs) newest = {
7795
+ mtimeMs: stat.mtimeMs,
7796
+ path: normalizeWindowsPath(executable)
7797
+ };
7798
+ }
7799
+ if (newest === void 0) throw notFound();
7800
+ return newest.path;
7801
+ }
7802
+ function discoverMacOs() {
7803
+ if (!fs$1.existsSync(MACOS_STUDIO_EXECUTABLE)) throw notFound();
7804
+ return MACOS_STUDIO_EXECUTABLE;
7805
+ }
7806
+ //#endregion
7807
+ //#region src/backends/studio-cli.ts
7808
+ const DEFAULT_STUDIO_CLI_TIMEOUT = 3e5;
7809
+ /** Lowest-precedence Studio-executable override (below config key / CLI flag). */
7810
+ const STUDIO_PATH_ENV = "JEST_ROBLOX_STUDIO_PATH";
7811
+ /**
7812
+ * Plugin/CLI protocol version, carried in the Run-mode payload. Matches
7813
+ * `STUDIO_PROTOCOL_VERSION` in the WebSocket `studio` backend and
7814
+ * `PROTOCOL_VERSION` in the plugin. The bootstrap echoes the version the
7815
+ * run-mode runner returns; {@link assertProtocolMatch} rejects a plugin that
7816
+ * omits the echo (a stale runner predating the handshake) or returns a
7817
+ * different number, surfacing a clean "update the plugin" error.
7818
+ */
7819
+ const STUDIO_CLI_PROTOCOL_VERSION = 3;
7820
+ /**
7821
+ * Seconds the bootstrap keeps its result socket alive after sending, waiting to
7822
+ * be closed/killed by the host. A backstop only: the host kills Studio the
7823
+ * instant it receives the result, so the bootstrap is normally terminated
7824
+ * mid-wait. Long enough to never truncate a send, short enough that a host that
7825
+ * vanished doesn't wedge Studio open.
7826
+ */
7827
+ const SOCKET_LINGER_SECONDS = 30;
7828
+ /**
7829
+ * Default backstop for the graceful kill-on-lock-release: how long to wait for
7830
+ * Studio to release `<place>.lock` before hard-killing anyway. The lock is
7831
+ * normally freed within ~1–9s of closing the result server, so this only fires
7832
+ * for a pathologically long-yielding edit-mode `BindToClose` — in which case we
7833
+ * fall back to today's instant kill.
7834
+ */
7835
+ const GRACEFUL_SHUTDOWN_CAP_MS = 15e3;
7836
+ /**
7837
+ * How often the real launcher polls `<place>.lock` while waiting for Studio's
7838
+ * graceful `ClosePlace` to release it. Short enough to kill within a frame of
7839
+ * the release (the win is skipping the ~30s telemetry drain that follows), long
7840
+ * enough to be negligible.
7841
+ */
7842
+ const LOCK_POLL_INTERVAL_MS = 50;
7843
+ const BACKEND_NAME = "studio-cli";
7844
+ const WORK_DIR = path$1.join(".jest-roblox", BACKEND_NAME);
7845
+ const PLACE_FILE = "place.rbxl";
7846
+ const PLACE_PROJECT_FILE = "place.project.json";
7847
+ const BOOTSTRAP_FILE = "bootstrap.server.luau";
7848
+ const OUTPUT_FILE = "output.log";
7849
+ /**
7850
+ * The result frame the bootstrap pushes back over the localhost WebSocket. Same
7851
+ * shape the plugin's `init.server.luau` sends the WebSocket `studio` backend
7852
+ * (`type: "results"` + `request_id` correlation), so the two result channels
7853
+ * stay wire-compatible. `protocolVersion` is optional here — a stale run-mode
7854
+ * runner omits it, and {@link assertProtocolMatch} turns that into a clean
7855
+ * "update the plugin" error rather than a schema rejection.
7856
+ */
7857
+ const resultMessageSchema = type({
7858
+ "gameOutput?": "string",
7859
+ "jestOutput": "string",
7860
+ "protocolVersion?": "number",
7861
+ "request_id": "string",
7862
+ "type": "'results'"
7863
+ });
7864
+ var StudioCliBackend = class {
7865
+ buildPlace;
7866
+ createServer;
7867
+ discover;
7868
+ gracefulShutdownTimeout;
7869
+ headed;
7870
+ launch;
7871
+ studioPath;
7872
+ timeout;
7873
+ kind = "studio-cli";
7874
+ constructor(options = {}) {
7875
+ this.buildPlace = options.buildPlace ?? buildPlace;
7876
+ this.createServer = options.createServer ?? (() => new WebSocketServer({
7877
+ host: "127.0.0.1",
7878
+ port: 0
7879
+ }));
7880
+ this.discover = options.discover ?? ((override) => discoverStudioPath({ override: override ?? process.env[STUDIO_PATH_ENV] }));
7881
+ this.gracefulShutdownTimeout = options.gracefulShutdownTimeout ?? GRACEFUL_SHUTDOWN_CAP_MS;
7882
+ this.headed = options.headed ?? false;
7883
+ this.launch = options.launch ?? spawnStudio;
7884
+ this.studioPath = options.studioPath;
7885
+ this.timeout = options.timeout ?? DEFAULT_STUDIO_CLI_TIMEOUT;
7886
+ }
7887
+ async runTests(options) {
7888
+ const { jobs, parallel, workStealing } = options;
7889
+ if (jobs.length === 0) throw new Error("StudioCliBackend requires at least one job");
7890
+ if (workStealing === true) throw new Error("studio-cli backend is serial and does not support work-stealing");
7891
+ if (parallel !== void 0 && parallel !== 1) throw new Error("studio-cli backend is serial (one Studio instance); --parallel > 1 is not supported.");
7892
+ const primary = jobs[0];
7893
+ const rootDirectory = path$1.resolve(primary.config.rootDir);
7894
+ const workDirectory = path$1.join(rootDirectory, WORK_DIR);
7895
+ fs$1.mkdirSync(workDirectory, { recursive: true });
7896
+ const workspace = isWorkspaceRun(jobs);
7897
+ let placeFile;
7898
+ if (workspace) placeFile = path$1.resolve(primary.config.placeFile);
7899
+ else if (primary.config.collectCoverage) placeFile = resolvePlaceFilePath(primary.config);
7900
+ else placeFile = this.buildCleanPlace(primary, rootDirectory, workDirectory);
7901
+ const server = this.createServer();
7902
+ let child;
7903
+ let gracefulTeardownStarted = false;
7904
+ try {
7905
+ const port = await serverPort(server);
7906
+ const requestId = randomUUID();
7907
+ const bootstrapFile = path$1.join(workDirectory, BOOTSTRAP_FILE);
7908
+ const outputFile = path$1.join(workDirectory, OUTPUT_FILE);
7909
+ fs$1.writeFileSync(bootstrapFile, buildBootstrap(workspace ? buildWorkspacePayload(jobs) : buildConfigsPayload(jobs), port, requestId));
7910
+ const studioPath = this.discover(this.studioPath);
7911
+ const args = [
7912
+ "--task",
7913
+ "RunScript",
7914
+ "--localPlaceFile",
7915
+ normalizeWindowsPath(placeFile),
7916
+ "--runScriptFile",
7917
+ normalizeWindowsPath(bootstrapFile),
7918
+ "--outputFile",
7919
+ normalizeWindowsPath(outputFile),
7920
+ "--quitAfterExecution"
7921
+ ];
7922
+ const executionStart = Date.now();
7923
+ child = this.launch({
7924
+ args,
7925
+ headed: this.headed,
7926
+ placeFile,
7927
+ studioPath
7928
+ });
7929
+ const message = await waitForResult(server, child, requestId, this.timeout);
7930
+ const executionMs = Date.now() - executionStart;
7931
+ closeServer(server);
7932
+ child.killOnLockRelease(this.gracefulShutdownTimeout);
7933
+ gracefulTeardownStarted = true;
7934
+ const entries = parseEnvelope(message.jestOutput);
7935
+ assertProtocolMatch(message.protocolVersion);
7936
+ if (entries.length !== jobs.length) throw new Error(`studio-cli backend returned ${entries.length.toString()} entries but request had ${jobs.length.toString()} jobs`);
7937
+ return {
7938
+ rawResults: entries.map((entry) => {
7939
+ return {
7940
+ entry,
7941
+ fallbackGameOutput: message.gameOutput
7942
+ };
7943
+ }),
7944
+ timing: { executionMs }
7945
+ };
7946
+ } finally {
7947
+ if (!gracefulTeardownStarted) {
7948
+ child?.kill();
7949
+ closeServer(server);
7950
+ }
7951
+ }
7952
+ }
7953
+ /**
7954
+ * Build the Clean Place for a normal (non-coverage) run and return its path.
7955
+ * `loadStringEnabled` is forced on so the Run-mode runner's LoadString gate
7956
+ * passes. Coverage runs skip this and open the instrumented place instead.
7957
+ */
7958
+ buildCleanPlace(primary, rootDirectory, workDirectory) {
7959
+ const placeFile = path$1.join(workDirectory, PLACE_FILE);
7960
+ this.buildPlace({
7961
+ loadStringEnabled: true,
7962
+ packages: [{
7963
+ name: BACKEND_NAME,
7964
+ packageDirectory: rootDirectory,
7965
+ rojoProjectPath: path$1.resolve(findRojoProject(primary.config))
7966
+ }],
7967
+ placeFile,
7968
+ projectFile: path$1.join(workDirectory, PLACE_PROJECT_FILE),
7969
+ wrap: false
7970
+ });
7971
+ return placeFile;
7972
+ }
7973
+ };
7974
+ function createStudioCliBackend(options = {}) {
7975
+ return new StudioCliBackend(options);
7976
+ }
7977
+ /**
7978
+ * Single-/multi-project payload: the run-mode runner reads `config.configs` and
7979
+ * drives `Runner.runProjects`.
7980
+ */
7981
+ function buildConfigsPayload(jobs) {
7982
+ const { configs, runtimeStubMounts } = buildConfigEntries(jobs);
7983
+ return {
7984
+ config: { configs },
7985
+ protocolVersion: STUDIO_CLI_PROTOCOL_VERSION,
7986
+ runtimeStubMounts,
7987
+ test: true
7988
+ };
7989
+ }
7990
+ /**
7991
+ * Workspace payload: the run-mode runner sees the `workspace` shape and drives
7992
+ * the staged materializer (`runEmbedded`) — cloning each package from the
7993
+ * mega-place's `__pkg_stage`, running, resetting.
7994
+ */
7995
+ function buildWorkspacePayload(jobs) {
7996
+ return {
7997
+ protocolVersion: STUDIO_CLI_PROTOCOL_VERSION,
7998
+ test: true,
7999
+ workspace: { entries: buildWorkspaceEntries(jobs) }
8000
+ };
8001
+ }
8002
+ /**
8003
+ * Wrap `content` in a Luau long string, escalating the bracket level
8004
+ * (`[=[`, `[==[`, …) until the chosen `]=*]` terminator does not occur in the
8005
+ * content. Without this, a config string carrying the level-1 terminator
8006
+ * `]=]` (e.g. a `testNamePattern`) would close the string early and emit
8007
+ * syntactically invalid Luau — a silent no-result run.
8008
+ */
8009
+ function luauLongString(content) {
8010
+ let level = 1;
8011
+ while (content.includes(`]${"=".repeat(level)}]`)) level += 1;
8012
+ const eq = "=".repeat(level);
8013
+ return `[${eq}[${content}]${eq}]`;
8014
+ }
8015
+ /**
8016
+ * The `--runScriptFile` script. Runs at command-bar level in the edit DataModel,
8017
+ * drives the installed plugin's Run-mode runner via `ExecuteRunModeAsync`, then
8018
+ * pushes the result envelope back to the host over a localhost WebSocket
8019
+ * (`HttpService:CreateWebStreamClient`, the same client API the plugin uses).
8020
+ * `request_id` correlates the frame with this run. A plugin that is absent or
8021
+ * returns nothing sends a `{ success = false }` envelope, so the host surfaces a
8022
+ * clean error rather than hanging.
8023
+ */
8024
+ function buildBootstrap(payload, port, requestId) {
8025
+ return [
8026
+ "local HttpService = game:GetService(\"HttpService\")",
8027
+ "local StudioTestService = game:GetService(\"StudioTestService\")",
8028
+ `local payload = HttpService:JSONDecode(${luauLongString(String(JSON.stringify(payload)))})`,
8029
+ `local URL = "ws://localhost:${port.toString()}"`,
8030
+ `local REQUEST_ID = ${luauLongString(requestId)}`,
8031
+ "local ok, result = pcall(function()",
8032
+ " return StudioTestService:ExecuteRunModeAsync(payload)",
8033
+ "end)",
8034
+ "local message",
8035
+ "if not ok then",
8036
+ " message = { type = \"results\", request_id = REQUEST_ID, gameOutput = \"[]\", jestOutput = HttpService:JSONEncode({ err = tostring(result), success = false }) }",
8037
+ "elseif typeof(result) ~= \"table\" or result.jestOutput == nil then",
8038
+ " 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 }) }",
8039
+ "else",
8040
+ " message = { type = \"results\", request_id = REQUEST_ID, protocolVersion = result.protocolVersion, gameOutput = result.gameOutput or \"[]\", jestOutput = result.jestOutput }",
8041
+ "end",
8042
+ "local encoded = HttpService:JSONEncode(message)",
8043
+ "local connected, socket = pcall(function()",
8044
+ " return HttpService:CreateWebStreamClient(Enum.WebStreamClientType.WebSocket, { Url = URL })",
8045
+ "end)",
8046
+ "if not connected then",
8047
+ " print(\"studio-cli: failed to open result socket: \" .. tostring(socket))",
8048
+ " return",
8049
+ "end",
8050
+ "local finished = false",
8051
+ "socket.Opened:Once(function()",
8052
+ " socket:Send(encoded)",
8053
+ "end)",
8054
+ "socket.Error:Once(function(_statusCode, errorMessage)",
8055
+ " print(\"studio-cli: result socket error: \" .. tostring(errorMessage))",
8056
+ " finished = true",
8057
+ "end)",
8058
+ "socket.Closed:Once(function()",
8059
+ " finished = true",
8060
+ "end)",
8061
+ "local start = os.clock()",
8062
+ `while not finished and os.clock() - start < ${SOCKET_LINGER_SECONDS.toString()} do`,
8063
+ " task.wait(0.05)",
8064
+ "end",
8065
+ ""
8066
+ ].join("\n");
8067
+ }
8068
+ /**
8069
+ * Reject a run-mode result whose echoed `protocolVersion` doesn't match the
8070
+ * CLI's. A stale plugin (run-mode runner predating the handshake) omits the
8071
+ * echo entirely (`undefined`); a divergent plugin echoes a different number.
8072
+ * Either way the user must update the plugin. Mirrors the WebSocket backend's
8073
+ * `version_mismatch` path.
8074
+ */
8075
+ function assertProtocolMatch(actual) {
8076
+ if (actual === STUDIO_CLI_PROTOCOL_VERSION) return;
8077
+ const reported = actual === void 0 ? "no version" : `v${actual.toString()}`;
8078
+ 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.`);
8079
+ }
8080
+ /**
8081
+ * The port the result server bound. A real `ws` server started on port 0 binds
8082
+ * asynchronously, so wait for `listening` and read the assigned port; the test
8083
+ * mock reports its port synchronously and is returned without waiting.
8084
+ */
8085
+ async function serverPort(server) {
8086
+ const address = server.address();
8087
+ if (address !== null && typeof address === "object") return address.port;
8088
+ await once(server, "listening");
8089
+ const bound = server.address();
8090
+ if (bound === null || typeof bound === "string") throw new Error("studio-cli: result WebSocket server failed to bind a port.");
8091
+ return bound.port;
8092
+ }
8093
+ /**
8094
+ * Resolve with the run-mode result frame the bootstrap pushes over the socket,
8095
+ * or reject on timeout / spawn failure. Frames that aren't a `results` message
8096
+ * for this `requestId` are ignored (engine/plugin chatter), so a stray frame
8097
+ * never resolves the run with the wrong payload.
8098
+ */
8099
+ async function waitForResult(server, child, requestId, timeout) {
8100
+ return new Promise((resolve, reject) => {
8101
+ let settled = false;
8102
+ const timer = setTimeout(() => {
8103
+ settle(() => {
8104
+ reject(/* @__PURE__ */ new Error(`studio-cli: Studio run timed out after ${timeout.toString()}ms and was terminated.`));
8105
+ });
8106
+ }, timeout);
8107
+ function settle(action) {
8108
+ if (settled) return;
8109
+ settled = true;
8110
+ clearTimeout(timer);
8111
+ action();
8112
+ }
8113
+ child.onError((error) => {
8114
+ settle(() => {
8115
+ reject(new Error(error.message, { cause: error }));
8116
+ });
8117
+ });
8118
+ server.on("connection", (socket) => {
8119
+ socket.on("message", (data) => {
8120
+ let raw;
8121
+ try {
8122
+ raw = JSON.parse(data.toString());
8123
+ } catch {
8124
+ return;
8125
+ }
8126
+ const message = resultMessageSchema(raw);
8127
+ if (message instanceof type.errors || message.request_id !== requestId) return;
8128
+ settle(() => {
8129
+ resolve(message);
8130
+ });
8131
+ });
8132
+ });
8133
+ server.on("error", (error) => {
8134
+ settle(() => {
8135
+ reject(error);
8136
+ });
8137
+ });
8138
+ });
8139
+ }
8140
+ /**
8141
+ * Terminate any live bootstrap socket and close the result server so a lingering
8142
+ * connection can't keep node's event loop running past the CLI's exitCode-based
8143
+ * shutdown (the same hazard the WebSocket `studio` backend guards against).
8144
+ */
8145
+ function closeServer(server) {
8146
+ for (const client of server.clients) client.terminate();
8147
+ server.close();
8148
+ }
8149
+ /**
8150
+ * Real launcher: clear a stale `<place>.lock` (a previously killed Studio can't
8151
+ * remove its own, and a back-to-back run would otherwise open the place onto it
8152
+ * and crash), then spawn Studio and return the handle the host kills. The result
8153
+ * arrives over the WebSocket, not the process — the host kills this Studio once
8154
+ * it lands (instantly, or after a graceful close; see {@link StudioCliProcess}).
8155
+ *
8156
+ * `stdio: "ignore"` because nothing is read from the pipes — an unconsumed
8157
+ * `stdout` pipe could backpressure-stall a chatty Studio.
8158
+ */
8159
+ function spawnStudio(request) {
8160
+ const lockFile = `${request.placeFile}.lock`;
8161
+ fs$1.rmSync(lockFile, { force: true });
8162
+ const child = spawn(request.studioPath, request.args, {
8163
+ stdio: "ignore",
8164
+ windowsHide: !request.headed
8165
+ });
8166
+ return {
8167
+ kill: () => {
8168
+ child.kill();
8169
+ },
8170
+ killOnLockRelease: (graceCapMs) => {
8171
+ const deadline = Date.now() + graceCapMs;
8172
+ const timer = setInterval(() => {
8173
+ if (fs$1.existsSync(lockFile) && Date.now() < deadline) return;
8174
+ clearInterval(timer);
8175
+ child.kill();
8176
+ }, LOCK_POLL_INTERVAL_MS);
8177
+ },
8178
+ onError: (listener) => {
8179
+ child.on("error", listener);
8180
+ }
8181
+ };
8182
+ }
8183
+ //#endregion
7547
8184
  //#region src/backends/studio.ts
7548
8185
  const DEFAULT_STUDIO_TIMEOUT = 3e5;
7549
8186
  /**
7550
8187
  * Plugin/CLI protocol version. Must match `PROTOCOL_VERSION` in
7551
8188
  * `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
8189
+ * changes v3 added the run-mode workspace dispatch + version echo. Stale
8190
+ * plugins return `version_mismatch` explicitly OR (older plugins) return a
7554
8191
  * `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.
8192
+ * `protocolVersion` echo is missing or a lower number — either way the CLI
8193
+ * surfaces a clean upgrade error instead of running with stale semantics.
7557
8194
  */
7558
- const STUDIO_PROTOCOL_VERSION = 2;
8195
+ const STUDIO_PROTOCOL_VERSION = 3;
7559
8196
  const pluginResultSchema = type({
7560
8197
  "gameOutput?": "string",
7561
8198
  "jestOutput": "string",
7562
- "protocolVersion": "number == 2",
8199
+ "protocolVersion": "number == 3",
7563
8200
  "request_id": "string",
7564
8201
  "type": "'results'"
7565
8202
  });
@@ -7599,14 +8236,9 @@ var StudioBackend = class {
7599
8236
  }
7600
8237
  async executeViaPlugin(wss, jobs, existingSocket) {
7601
8238
  const requestId = randomUUID();
7602
- const configs = jobs.map((job) => buildJestArgv(job));
7603
- const runtimeStubMounts = jobs.map((job) => job.runtimeInjectionPaths ?? []);
8239
+ const requestMessage = buildRunTestsMessage(jobs, requestId);
7604
8240
  const executionStart = Date.now();
7605
- const message = await this.waitForResult(wss, {
7606
- configs,
7607
- requestId,
7608
- runtimeStubMounts
7609
- }, existingSocket);
8241
+ const message = await this.waitForResult(wss, requestMessage, requestId, existingSocket);
7610
8242
  const executionMs = Date.now() - executionStart;
7611
8243
  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.`);
7612
8244
  const entries = parseEnvelope(message.jestOutput);
@@ -7621,20 +8253,13 @@ var StudioBackend = class {
7621
8253
  timing: { executionMs }
7622
8254
  };
7623
8255
  }
7624
- async waitForResult(wss, request, existingSocket) {
7625
- const { configs, requestId, runtimeStubMounts } = request;
8256
+ async waitForResult(wss, requestMessage, requestId, existingSocket) {
7626
8257
  return new Promise((resolve, reject) => {
7627
8258
  const timer = setTimeout(() => {
7628
8259
  reject(/* @__PURE__ */ new Error("Timed out waiting for Studio plugin connection"));
7629
8260
  }, this.timeout);
7630
8261
  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
- }));
8262
+ ws.send(String(JSON.stringify(requestMessage)));
7638
8263
  ws.on("message", (data) => {
7639
8264
  const message = pluginMessageSchema(JSON.parse(data.toString()));
7640
8265
  if (message instanceof type.errors) {
@@ -7670,6 +8295,31 @@ var StudioBackend = class {
7670
8295
  function createStudioBackend(options) {
7671
8296
  return new StudioBackend(options);
7672
8297
  }
8298
+ /**
8299
+ * Build the `run_tests` WebSocket message the plugin forwards into
8300
+ * `ExecuteRunModeAsync`. A workspace run (jobs carry `pkg`) sends
8301
+ * `workspace.entries` — the staged-materializer shape the plugin's run-mode
8302
+ * runner dispatches on. A single-/multi-project run sends `config.configs` plus
8303
+ * the filtered `runtimeStubMounts` (parallel to `configs`) so the runner injects
8304
+ * `jest.config` only where Rojo doesn't already sync a user-authored one.
8305
+ */
8306
+ function buildRunTestsMessage(jobs, requestId) {
8307
+ const base = {
8308
+ action: "run_tests",
8309
+ protocolVersion: STUDIO_PROTOCOL_VERSION,
8310
+ request_id: requestId
8311
+ };
8312
+ if (isWorkspaceRun(jobs)) return {
8313
+ ...base,
8314
+ workspace: { entries: buildWorkspaceEntries(jobs) }
8315
+ };
8316
+ const { configs, runtimeStubMounts } = buildConfigEntries(jobs);
8317
+ return {
8318
+ ...base,
8319
+ config: { configs },
8320
+ runtimeStubMounts
8321
+ };
8322
+ }
7673
8323
  //#endregion
7674
8324
  //#region src/backends/auto.ts
7675
8325
  const ENV_PREFIX = "JEST_";
@@ -7729,6 +8379,14 @@ async function resolveBackend(cli, config, probe = probeStudioPlugin) {
7729
8379
  port: config.port,
7730
8380
  timeout: config.timeout
7731
8381
  });
8382
+ if (config.backend === "studio-cli") {
8383
+ assertStudioCliSerial(config.parallel);
8384
+ return createStudioCliBackend({
8385
+ headed: cli.headed,
8386
+ studioPath: config.studioPath,
8387
+ timeout: config.timeout
8388
+ });
8389
+ }
7732
8390
  if (config.backend === "open-cloud") return createOpenCloudBackend(buildCredentials(cli, config));
7733
8391
  const credentials = tryBuildCredentials(cli, config);
7734
8392
  const probeResult = await probe(config.port, 500);
@@ -7752,6 +8410,9 @@ async function resolveBackend(cli, config, probe = probeStudioPlugin) {
7752
8410
  if (hasUserOverrides(cli)) buildCredentials(cli, config);
7753
8411
  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
8412
  }
8413
+ function assertStudioCliSerial(parallel) {
8414
+ if (isShardedParallel(parallel)) throw new Error("studio-cli backend is serial (one Studio instance); --parallel > 1 is not supported.");
8415
+ }
7755
8416
  function hasUserOverrides(cli) {
7756
8417
  return cli.apiKey !== void 0 || cli.universeId !== void 0 || cli.placeId !== void 0;
7757
8418
  }
@@ -8028,6 +8689,15 @@ const PROJECT_ONLY_KEYS = /* @__PURE__ */ new Set([
8028
8689
  "outDir",
8029
8690
  "root"
8030
8691
  ]);
8692
+ function dedupeMounts(mounts) {
8693
+ const seen = /* @__PURE__ */ new Set();
8694
+ const result = [];
8695
+ for (const mount of mounts) if (!seen.has(mount.dataModelPath)) {
8696
+ seen.add(mount.dataModelPath);
8697
+ result.push(mount);
8698
+ }
8699
+ return result;
8700
+ }
8031
8701
  function resolveProjectConfig(project, rootConfig, rojoTree, classify) {
8032
8702
  const rootPrefixedInclude = applyProjectRoot(project.include, project.root);
8033
8703
  const rootPrefixedExclude = applyProjectRoot(project.exclude ?? [], project.root);
@@ -8092,15 +8762,6 @@ function mergeProjectConfig(rootConfig, project) {
8092
8762
  for (const [key, value] of Object.entries(project)) if (!PROJECT_ONLY_KEYS.has(key) && key !== "typecheck" && value !== void 0) merged[key] = value;
8093
8763
  return merged;
8094
8764
  }
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
8765
  function joinProjectRoot(relativePath, projectRoot) {
8105
8766
  return projectRoot !== void 0 ? path$1.posix.join(projectRoot, relativePath) : relativePath;
8106
8767
  }
@@ -8210,6 +8871,14 @@ const LUAU_STRING_ARRAY_KEYS = ["setupFiles", "setupFilesAfterEnv"];
8210
8871
  //#endregion
8211
8872
  //#region src/config/filter-projects-by-files.ts
8212
8873
  const DRIVE_LETTER_ABSOLUTE = /^[A-Za-z]:\//;
8874
+ function collectProjectRoots(project, posixRootDirectory) {
8875
+ const roots = [];
8876
+ for (const pattern of project.include) try {
8877
+ const { root } = extractStaticRoot(normalizeWindowsPath(pattern));
8878
+ roots.push(resolveAgainst(posixRootDirectory, root));
8879
+ } catch {}
8880
+ return roots;
8881
+ }
8213
8882
  /**
8214
8883
  * Pair each project with the subset of cli files whose include roots own them.
8215
8884
  * Used so a positional file arg can auto-pick its owning project without
@@ -8268,14 +8937,6 @@ function buildNoMatchMessage(files, roots) {
8268
8937
  const uniqueRoots = [...new Set(roots)];
8269
8938
  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
8939
  }
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
8940
  //#endregion
8280
8941
  //#region src/config/narrow-by-files.ts
8281
8942
  const REGEX_METACHARACTERS = /[.*+?^${}()|[\]\\]/g;
@@ -8348,34 +9009,6 @@ function toBasenamePattern(file) {
8348
9009
  return (TS_SOURCE_EXTENSION.test(basename) ? indexStemToInit(stripped) : stripped).replace(REGEX_METACHARACTERS, "\\$&");
8349
9010
  }
8350
9011
  //#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
9012
  //#region src/config/stubs.ts
8380
9013
  const HEADER = "-- Auto-generated by jest-roblox (do not edit)\n";
8381
9014
  const STUB_FILENAME = "jest.config.luau";
@@ -9237,18 +9870,20 @@ function loadRojoTree(config) {
9237
9870
  if (validated instanceof type.errors) throw new Error(`Invalid Rojo project: ${validated.summary}`);
9238
9871
  return resolveNestedProjects(validated.tree, path$1.dirname(rojoPath));
9239
9872
  }
9240
- async function runMultiProject(options) {
9241
- const { cli, config: rootConfig, rawProjects } = options;
9242
- const timing = options.timing ?? NOOP_TIMING_COLLECTOR;
9873
+ /**
9874
+ * Multi-project execution core: stub generation, place build, discovery, and
9875
+ * job dispatch over a set of already-resolved projects. Shared by the
9876
+ * `projects:`-configured path (`runMultiProject`) and the no-`projects` collapse
9877
+ * (`run.ts` synthesizes one project from the config's luau roots and calls this),
9878
+ * so both paths get identical per-root `jest.config` stub injection, place
9879
+ * rebuild, coverage, and result shaping.
9880
+ */
9881
+ async function runResolvedProjects(allProjects, rootConfig, cli, timing) {
9243
9882
  const cliTypecheck = {
9244
9883
  enabled: cli.typecheck,
9245
9884
  only: cli.typecheckOnly,
9246
9885
  tsconfig: cli.typecheckTsconfig
9247
9886
  };
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
9887
  timing.profile("resolveSetupFilePaths", () => {
9253
9888
  resolveAllSetupFilePaths(allProjects.map((project) => project.config));
9254
9889
  });
@@ -9341,6 +9976,14 @@ async function runMultiProject(options) {
9341
9976
  return {
9342
9977
  collectCoverageFrom: rootConfig.collectCoverage ? rootConfig.collectCoverageFrom ?? deriveCoverageFromIncludes(projects) : rootConfig.collectCoverageFrom,
9343
9978
  coverageArtifacts,
9979
+ coverageDisplayFilter: buildMultiDisplayFilter({
9980
+ cliFiles: cli.files,
9981
+ matchedRuntimeFiles: pendingJobs.flatMap((job) => job.runtimeFiles),
9982
+ projectNames: cli.project,
9983
+ projects,
9984
+ rootDir: rootConfig.rootDir,
9985
+ testPathPattern: rootConfig.testPathPattern
9986
+ }),
9344
9987
  merged: mergeForMultiResult(projectResults),
9345
9988
  mode: "multi",
9346
9989
  preCoverageMs,
@@ -9348,9 +9991,27 @@ async function runMultiProject(options) {
9348
9991
  typecheckResult
9349
9992
  };
9350
9993
  }
9994
+ async function runMultiProject(options) {
9995
+ const { cli, config: rootConfig, rawProjects } = options;
9996
+ const timing = options.timing ?? NOOP_TIMING_COLLECTOR;
9997
+ const rojoTree = timing.profile("loadRojoTree", () => loadRojoTree(rootConfig));
9998
+ return runResolvedProjects(await timing.profileAsync("resolveAllProjects", async () => {
9999
+ return resolveAllProjects(rawProjects, rootConfig, rojoTree, rootConfig.rootDir);
10000
+ }), rootConfig, cli, timing);
10001
+ }
10002
+ function buildMultiDisplayFilter(options) {
10003
+ const { cliFiles, matchedRuntimeFiles, projectNames, projects, rootDir, testPathPattern } = options;
10004
+ if (cliFiles !== void 0 && cliFiles.length > 0) return sourceTwinFilter(cliFiles, rootDir);
10005
+ if (testPathPattern !== void 0) return sourceTwinFilter(matchedRuntimeFiles, rootDir);
10006
+ if (projectNames !== void 0) {
10007
+ const posixRootDirectory = normalizeWindowsPath(rootDir);
10008
+ const roots = projects.flatMap((project) => collectProjectRoots(project, posixRootDirectory));
10009
+ return roots.length > 0 ? projectRootFilter(roots) : void 0;
10010
+ }
10011
+ }
9351
10012
  function buildOpenCloudPlace(rootConfig, projects, cacheRoot) {
9352
10013
  const userRojoProjectPath = path$1.resolve(rootConfig.rootDir, rootConfig.rojoProject ?? DEFAULT_ROJO_PROJECT);
9353
- const placeFilePath = path$1.resolve(rootConfig.rootDir, rootConfig.placeFile);
10014
+ const placeFilePath = resolvePlaceFilePath(rootConfig);
9354
10015
  buildPlace({
9355
10016
  packages: [{
9356
10017
  name: "multi-project",
@@ -9510,10 +10171,11 @@ function prepareMultiProjectCoverage(rootConfig, projects, cacheRoot) {
9510
10171
  effectiveConfig: rootConfig,
9511
10172
  preCoverageMs: 0
9512
10173
  };
10174
+ const bakeStubs = rootConfig.backend !== "studio-cli";
9513
10175
  const start = Date.now();
9514
- const coverage = prepareCoverage(rootConfig, (shadowDirectory) => {
10176
+ const coverage = prepareCoverage(rootConfig, bakeStubs ? (shadowDirectory) => {
9515
10177
  return syncStubsToShadowDirectory(projects, cacheRoot, shadowDirectory);
9516
- });
10178
+ } : void 0);
9517
10179
  return {
9518
10180
  coverageArtifacts: toCoverageArtifacts(coverage, toBuildManifestProjects(projects)),
9519
10181
  effectiveConfig: {
@@ -9561,6 +10223,62 @@ function selectProjects(allProjects, projectNames, cliFiles, rootDirectory) {
9561
10223
  return { projects: allProjects };
9562
10224
  }
9563
10225
  //#endregion
10226
+ //#region src/run/single-projects.ts
10227
+ /**
10228
+ * Map each compiled-Luau root to its Rojo mount (FS path ↔ DataModel path) via
10229
+ * the Rojo tree. Roots that don't map (a compiled-output dir the Rojo project
10230
+ * doesn't mount) are skipped; mounts are de-duplicated by DataModel path so two
10231
+ * roots resolving to the same mount yield one entry.
10232
+ */
10233
+ function deriveProjectMounts(luauRoots, rojoTree) {
10234
+ return dedupeMounts(luauRoots.flatMap((luauRoot) => {
10235
+ const fsPath = luauRoot.replace(/\/$/, "");
10236
+ const dataModelPath = findInTree(rojoTree, fsPath, "");
10237
+ return dataModelPath !== void 0 ? [{
10238
+ dataModelPath,
10239
+ fsPath
10240
+ }] : [];
10241
+ }));
10242
+ }
10243
+ /**
10244
+ * Build the single `ResolvedProjectConfig` a no-`projects` config collapses to.
10245
+ *
10246
+ * Single mode carries no explicit `projects`, but the Luau runner resolves
10247
+ * per-project config from a `jest.config` ModuleScript at each project root, so
10248
+ * a bare config must route through the multi pipeline (stub generation + place
10249
+ * rebuild). The project roots are derived from the config's luau roots mapped
10250
+ * through the Rojo tree — the same mounts the coverage manifest uses. Discovery
10251
+ * is preserved by feeding the root `testMatch` straight through as `include`
10252
+ * (this never reaches `resolveProjectConfig`, so a rootless glob is fine).
10253
+ */
10254
+ function buildImplicitProject(config, rojoTree) {
10255
+ const mounts = deriveProjectMounts(resolveLuauRoots(config), rojoTree);
10256
+ 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.");
10257
+ const runtimeGlobs = config.testMatch.filter((glob) => !TYPE_TEST_PATTERN.test(glob));
10258
+ const singleMount = mounts.length === 1 ? mounts[0] : void 0;
10259
+ return {
10260
+ config,
10261
+ displayColor: typeof config.displayName === "string" ? void 0 : config.displayName?.color,
10262
+ displayName: resolveDisplayName(config),
10263
+ exclude: config.exclude ?? [],
10264
+ include: runtimeGlobs,
10265
+ outDir: singleMount?.fsPath,
10266
+ projects: mounts.map((mount) => mount.dataModelPath),
10267
+ rojoMounts: mounts,
10268
+ testMatch: [...new Set(runtimeGlobs.map(toTestMatchPattern))],
10269
+ typecheck: config.typecheck
10270
+ };
10271
+ }
10272
+ function toTestMatchPattern(glob) {
10273
+ const stripped = stripTsExtension(glob);
10274
+ return stripped.includes("/") ? stripped : `**/${stripped}`;
10275
+ }
10276
+ function resolveDisplayName(config) {
10277
+ const { displayName, rootDir } = config;
10278
+ const name = typeof displayName === "string" ? displayName : displayName?.name;
10279
+ return name !== void 0 && name !== "" ? name : path$1.basename(path$1.normalize(rootDir));
10280
+ }
10281
+ //#endregion
9564
10282
  //#region src/run/single.ts
9565
10283
  const VERSION$1 = version;
9566
10284
  async function runSingleProject(options) {
@@ -9651,8 +10369,10 @@ async function runSingleProject(options) {
9651
10369
  }) : Promise.resolve(void 0);
9652
10370
  const [typecheckResult, runtimeResult] = await Promise.all([typecheckPass, runtimePass]);
9653
10371
  if (typeTestFiles.length > 0) timing.record("runTypecheck", typecheckMs);
10372
+ const coverageDisplayFilter = filterActive ? sourceTwinFilter(runtimeFiles, baseConfig.rootDir) : void 0;
9654
10373
  return {
9655
10374
  coverageArtifacts,
10375
+ coverageDisplayFilter,
9656
10376
  mode: "single",
9657
10377
  preCoverageMs,
9658
10378
  runtimeResult,
@@ -9749,6 +10469,11 @@ function buildWorkspaceRunOptions(input) {
9749
10469
  readCli: (entry) => entry.universeId,
9750
10470
  readConfig: (entry) => entry.universeId
9751
10471
  });
10472
+ const studioPath = resolveOptionalField(cli, perPackageConfigs, {
10473
+ name: "studioPath",
10474
+ readCli: (entry) => entry.studioPath,
10475
+ readConfig: (entry) => entry.studioPath
10476
+ });
9752
10477
  const formatters = resolveFormatters(cli, perPackageConfigs);
9753
10478
  const rawGameOutput = resolveOptionalField(cli, perPackageConfigs, {
9754
10479
  name: "gameOutput",
@@ -9791,6 +10516,7 @@ function buildWorkspaceRunOptions(input) {
9791
10516
  if (outputFile !== void 0) runOptions.outputFile = outputFile;
9792
10517
  if (parallel !== void 0) runOptions.parallel = parallel;
9793
10518
  if (placeId !== void 0) runOptions.placeId = placeId;
10519
+ if (studioPath !== void 0) runOptions.studioPath = studioPath;
9794
10520
  if (universeId !== void 0) runOptions.universeId = universeId;
9795
10521
  return runOptions;
9796
10522
  }
@@ -10318,7 +11044,7 @@ var StreamingAggregator = class {
10318
11044
  };
10319
11045
  //#endregion
10320
11046
  //#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";
11047
+ 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
11048
  //#endregion
10323
11049
  //#region src/staging/test-script-staged.ts
10324
11050
  function generateMaterializerScript(inputs, options = {}) {
@@ -11293,11 +12019,17 @@ function validateBasicWorkspaceFlags(cli) {
11293
12019
  /**
11294
12020
  * Checks the resolved WorkspaceRunOptions for invariants that depend on the
11295
12021
  * fully resolved values (CLI > per-package consensus > defaults).
12022
+ *
12023
+ * Every backend now runs workspace (studio-cli launches its own mega-place;
12024
+ * the attached `studio` backend runs against an open Studio for debugging),
12025
+ * so the only resolved-value invariant left is studio-cli's serial constraint:
12026
+ * it drives one Studio instance and cannot shard.
11296
12027
  */
11297
12028
  function assertWorkspaceRunOptions(runOptions) {
11298
- if (runOptions.backend === "studio") return {
12029
+ const { backend, parallel } = runOptions;
12030
+ if (backend === "studio-cli" && isShardedParallel(parallel)) return {
11299
12031
  exitCode: 2,
11300
- message: "Error: --workspace requires --backend open-cloud (Studio not supported).\n",
12032
+ 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
12033
  ok: false
11302
12034
  };
11303
12035
  return { ok: true };
@@ -11424,6 +12156,11 @@ async function runWorkspaceMode(cli, workspace, timing) {
11424
12156
  }
11425
12157
  function resolveWorkspaceBackend(cli, runOptions) {
11426
12158
  if (cli.typecheckOnly === true) return {};
12159
+ if (runOptions.backend === "studio-cli") return { backend: createStudioCliBackend({
12160
+ headed: cli.headed,
12161
+ ...runOptions.studioPath !== void 0 ? { studioPath: runOptions.studioPath } : {}
12162
+ }) };
12163
+ if (runOptions.backend === "studio") return { backend: createStudioBackend({ port: runOptions.port }) };
11427
12164
  try {
11428
12165
  const credentials = buildWorkspaceCredentials(cli, runOptions);
11429
12166
  const backend = createOpenCloudBackend(credentials);
@@ -11566,11 +12303,19 @@ async function runSingleOrMulti(cli, merged, timing) {
11566
12303
  rawProjects,
11567
12304
  timing
11568
12305
  });
11569
- return runSingleProject({
12306
+ if (resolveTypecheckConfig({
12307
+ cli: {
12308
+ enabled: cli.typecheck,
12309
+ only: cli.typecheckOnly,
12310
+ tsconfig: cli.typecheckTsconfig
12311
+ },
12312
+ root: merged.typecheck
12313
+ }).only) return runSingleProject({
11570
12314
  cli,
11571
12315
  config: merged,
11572
12316
  timing
11573
12317
  });
12318
+ return runResolvedProjects([buildImplicitProject(merged, timing.profile("loadRojoTree", () => loadRojoTree(merged)))], merged, cli, timing);
11574
12319
  }
11575
12320
  async function runJestRoblox(cli, config) {
11576
12321
  const timing = createTimingCollector();
@@ -11584,4 +12329,4 @@ async function runJestRoblox(cli, config) {
11584
12329
  }
11585
12330
  }
11586
12331
  //#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 };
12332
+ export { parseJestOutput as $, outputSingleResult as A, writeJsonFile$1 as B, visitStatement as C, emitBuildManifest as D, buildManifestSchema as E, formatGameOutputNotice as F, hashFile as G, formatResult as H, parseGameOutput as I, readManifest as J, MANIFEST_VERSION as K, writeGameOutput as L, formatJobSummary as M, formatExecuteOutput as N, readBuildManifest as O, runProjects as P, extractJsonFromOutput as Q, createTimingCollector as R, visitExpression as S, BUILD_MANIFEST_VERSION as T, formatTestSummary as U, formatFailure as V, formatBanner as W, applyAttribution as X, writeManifest as Y, LuauScriptError as Z, generateTestScript as _, loadRojoTree as a, JEST_ARGV_EXCLUDED_KEYS as at, findRojoProject as b, StudioBackend as c, VALID_BACKENDS as ct, createStudioCliBackend as d, isValidBackend as dt, mergeCliWithConfig as et, OpenCloudBackend as f, ConfigError as ft, buildJestArgv as g, walkErrorChain as h, collectStubMounts as i, GLOBAL_TEST_KEYS as it, formatAnnotations as j, outputMultiResult as k, createStudioBackend as l, defineConfig as lt, formatMissingScopes as m, runJestRoblox as n, resolveConfig as nt, runTypecheck as o, ROOT_CLI_KEYS as ot, createOpenCloudBackend as p, version as pt, manifestSchema as q, runSingleOrMulti as r, DEFAULT_CONFIG as rt, resolveAllProjects as s, SHARED_TEST_KEYS as st, getRawProjects as t, loadConfig$1 as tt, StudioCliBackend as u, defineProject as ut, COVERAGE_BUILD_MANIFEST_PATH as v, buildPlace as w, visitBlock as x, COVERAGE_MANIFEST_PATH as y, formatJson as z };