@isentinel/jest-roblox 0.3.6 → 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.
@@ -1,5 +1,5 @@
1
1
  import { createRequire } from "node:module";
2
- import { PermissionError, PollTimeoutError, TRANSIENT_TRANSPORT_CODES } from "@bedrock-rbx/ocale";
2
+ import { ApiError, PermissionError, PollTimeoutError, RateLimitError, TRANSIENT_TRANSPORT_CODES } from "@bedrock-rbx/ocale";
3
3
  import process from "node:process";
4
4
  import { isDeepStrictEqual } from "node:util";
5
5
  import color from "tinyrainbow";
@@ -25,16 +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
- import { WebSocketServer } from "ws";
33
32
  import { StorageClient } from "@bedrock-rbx/ocale/storage";
33
+ import { WebSocketServer } from "ws";
34
+ import { setTimeout as setTimeout$1 } from "node:timers/promises";
35
+ import { once } from "node:events";
34
36
  import { parseJSONC, parseYAML } from "confbox";
35
37
  import { Visitor, parseSync } from "oxc-parser";
36
38
  //#region package.json
37
- var version = "0.3.6";
39
+ var version = "0.3.8";
38
40
  //#endregion
39
41
  //#region src/config/errors.ts
40
42
  var ConfigError = class extends Error {
@@ -49,11 +51,20 @@ var ConfigError = class extends Error {
49
51
  const VALID_BACKENDS = /* @__PURE__ */ new Set([
50
52
  "auto",
51
53
  "open-cloud",
52
- "studio"
54
+ "studio",
55
+ "studio-cli"
53
56
  ]);
54
57
  function isValidBackend(value) {
55
58
  return VALID_BACKENDS.has(value);
56
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
+ }
57
68
  const DEFAULT_CONFIG = {
58
69
  backend: "auto",
59
70
  collectCoverage: false,
@@ -214,7 +225,7 @@ const globalTestConfigSchema = type({
214
225
  });
215
226
  const configSchema = type({
216
227
  "+": "reject",
217
- "backend?": type("'auto'|'open-cloud'|'studio'"),
228
+ "backend?": type("'auto'|'open-cloud'|'studio'|'studio-cli'"),
218
229
  "color?": "boolean",
219
230
  "config?": "string",
220
231
  "coverageCache?": "boolean",
@@ -232,6 +243,7 @@ const configSchema = type({
232
243
  "rootDir?": "string",
233
244
  "showLuau?": "boolean",
234
245
  "sourceMap?": "boolean",
246
+ "studioPath?": "string",
235
247
  "test?": globalTestConfigSchema,
236
248
  "timeout?": "number",
237
249
  "universeId?": "string",
@@ -255,6 +267,7 @@ const ROOT_CLI_KEYS_LIST = [
255
267
  "rootDir",
256
268
  "showLuau",
257
269
  "sourceMap",
270
+ "studioPath",
258
271
  "timeout",
259
272
  "universeId",
260
273
  "workspace"
@@ -385,7 +398,28 @@ function validateConfig(raw) {
385
398
  if (workspace !== void 0 && workspace.root === void 0 !== (workspace.packages === void 0)) throw new Error("workspace.root and workspace.packages must be declared together.");
386
399
  return result;
387
400
  }
401
+ /**
402
+ * Identity helper for authoring a typed `jest.config.*` file. Returns its
403
+ * input unchanged; it exists purely to give editors autocompletion and
404
+ * type-checking for the root config shape (`Config` plus the c12 layer props
405
+ * on `ConfigInput`).
406
+ *
407
+ * Use it as the default export of a config file discovered by c12 (`.ts`,
408
+ * `.js`, `.mjs`, `.cjs`, `.json`, `.yaml`, `.toml`). All jest-passthrough
409
+ * options live under the `test:` block; root keys are CLI/runner-level.
410
+ * Configs may extend a shared base via `extends`, and any `Mergeable` array
411
+ * field (e.g. `test.testMatch`) accepts a function that receives the inherited
412
+ * defaults and returns the merged value. Precedence is CLI flags > config file
413
+ * > extended config > defaults.
414
+ */
388
415
  const defineConfig = createDefineConfig();
416
+ /**
417
+ * Identity helper for a single entry inside `test.projects`, mirroring
418
+ * {@link defineConfig} for per-project overrides. Returns its input unchanged
419
+ * and exists only for editor autocompletion and type-checking of the
420
+ * `InlineProjectConfig` shape — a `test:` block carrying the project's
421
+ * `include`/`displayName` plus any shared per-project jest options.
422
+ */
389
423
  const defineProject = createDefineConfig();
390
424
  //#endregion
391
425
  //#region src/config/loader.ts
@@ -445,7 +479,7 @@ async function seaImport(id) {
445
479
  const content = readFileSync(id, "utf-8");
446
480
  return JSON.parse(content);
447
481
  }
448
- return import(id);
482
+ return createRequire(id)(id);
449
483
  }
450
484
  function merger(...sources) {
451
485
  return defuFn(...sources.filter(Boolean));
@@ -578,6 +612,7 @@ function mergeCliWithConfig(cli, config) {
578
612
  showLuau: cli.showLuau ?? config.showLuau,
579
613
  silent: cli.silent ?? config.silent,
580
614
  sourceMap: cli.sourceMap ?? config.sourceMap,
615
+ studioPath: cli.studioPath ?? config.studioPath,
581
616
  testNamePattern: cli.testNamePattern ?? config.testNamePattern,
582
617
  testPathPattern: cli.testPathPattern ?? config.testPathPattern,
583
618
  timeout: cli.timeout ?? config.timeout,
@@ -1106,6 +1141,50 @@ function mergeFileCoverage(a, b) {
1106
1141
  return merged;
1107
1142
  }
1108
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
1109
1188
  //#region src/coverage-pipeline/coverage-universe.ts
1110
1189
  /**
1111
1190
  * Decides which mapped source files make up the coverage report universe.
@@ -1156,37 +1235,43 @@ const VALID_REPORTERS = /* @__PURE__ */ new Set([
1156
1235
  "text-lcov",
1157
1236
  "text-summary"
1158
1237
  ]);
1159
- function printCoverageHeader() {
1160
- const header = ` ${color.blue("%")} ${color.dim("Coverage report from")} ${color.yellow("istanbul")}`;
1238
+ function printCoverageHeader(agentMode = false) {
1239
+ const header = agentMode ? " % Coverage report from istanbul" : ` ${color.blue("%")} ${color.dim("Coverage report from")} ${color.yellow("istanbul")}`;
1161
1240
  process.stdout.write(`\n${header}\n`);
1162
1241
  }
1163
1242
  const TEXT_REPORTERS = /* @__PURE__ */ new Set(["text", "text-summary"]);
1164
1243
  function generateReports(options) {
1165
- const coverageMap = buildCoverageMap$2(filterCoverageUniverse(options.mapped, {
1244
+ const filtered = filterCoverageUniverse(options.mapped, {
1166
1245
  ignore: options.coveragePathIgnorePatterns,
1167
1246
  include: options.collectCoverageFrom
1168
- }));
1247
+ });
1248
+ const coverageMap = buildCoverageMap$2(filtered);
1169
1249
  const agentMode = options.agentMode === true;
1250
+ const defaultSummarizer = agentMode ? "flat" : "pkg";
1170
1251
  const context = istanbulReport.createContext({
1171
1252
  coverageMap,
1172
- defaultSummarizer: agentMode ? "flat" : "pkg",
1253
+ defaultSummarizer,
1173
1254
  dir: options.coverageDirectory
1174
1255
  });
1256
+ const textTable = resolveTextTableView({
1257
+ agentTextFilter: agentMode ? options.agentTextFilter : void 0,
1258
+ coverageDirectory: options.coverageDirectory,
1259
+ defaultSummarizer,
1260
+ fallback: context,
1261
+ filtered
1262
+ });
1175
1263
  const terminalColumns = getTerminalColumns();
1176
1264
  const hasTextReporter = options.reporters.some((name) => TEXT_REPORTERS.has(name));
1177
1265
  const allFilesFull = agentMode && isAllFilesFull(coverageMap);
1178
1266
  if (allFilesFull && hasTextReporter) printCompactFullSummary(coverageMap);
1179
- for (const reporterName of options.reporters) {
1180
- if (!isValidReporter(reporterName)) throw new Error(`Unknown coverage reporter: ${reporterName}`);
1181
- if (allFilesFull && TEXT_REPORTERS.has(reporterName)) continue;
1182
- let reporterOptions = {};
1183
- if (reporterName === "text") reporterOptions = {
1184
- maxCols: terminalColumns,
1185
- skipFull: agentMode
1186
- };
1187
- else if (TEXT_REPORTERS.has(reporterName)) reporterOptions = { skipFull: agentMode };
1188
- istanbulReports.create(reporterName, reporterOptions).execute(context);
1189
- }
1267
+ runReporters({
1268
+ agentMode,
1269
+ allFilesFull,
1270
+ fullContext: context,
1271
+ reporters: options.reporters,
1272
+ terminalColumns,
1273
+ textTable
1274
+ });
1190
1275
  if (agentMode && !allFilesFull && hasTextReporter && coverageMap.files().length > 0) process.stdout.write(formatAgentTotals(coverageMap));
1191
1276
  }
1192
1277
  function checkThresholds(mapped, thresholds, collectCoverageFrom, coveragePathIgnorePatterns) {
@@ -1230,38 +1315,23 @@ function checkThresholds(mapped, thresholds, collectCoverageFrom, coveragePathIg
1230
1315
  passed: failures.length === 0
1231
1316
  };
1232
1317
  }
1233
- function printCompactFullSummary(coverageMap) {
1234
- const fileCount = coverageMap.files().length;
1235
- const label = fileCount === 1 ? "file" : "files";
1236
- process.stdout.write(`Coverage: 100% (${fileCount} ${label})\n`);
1237
- }
1238
- function formatTotalsPart(label, totals) {
1239
- assert(typeof totals.pct === "number", "coverage summary pct must be numeric");
1240
- return `${totals.pct}% ${label} (${totals.covered}/${totals.total})`;
1241
- }
1242
- function formatAgentTotals(coverageMap) {
1243
- const summary = coverageMap.getCoverageSummary();
1244
- return `Coverage: ${[
1245
- formatTotalsPart("stmts", summary.statements),
1246
- formatTotalsPart("branch", summary.branches),
1247
- formatTotalsPart("funcs", summary.functions),
1248
- formatTotalsPart("lines", summary.lines)
1249
- ].join(" | ")}\n`;
1250
- }
1251
- function getTerminalColumns() {
1252
- if (process.stdout.columns !== void 0) return process.stdout.columns;
1253
- const columnsEnvironment = process.env["COLUMNS"];
1254
- if (columnsEnvironment === void 0) return;
1255
- const parsed = Number(columnsEnvironment);
1256
- return Number.isFinite(parsed) && parsed > 0 ? parsed : void 0;
1318
+ function isValidReporter(name) {
1319
+ return VALID_REPORTERS.has(name);
1257
1320
  }
1258
- function isAllFilesFull(coverageMap) {
1259
- const files = coverageMap.files();
1260
- if (files.length === 0) return false;
1261
- return files.every((file) => {
1262
- const summary = coverageMap.fileCoverageFor(file).toSummary();
1263
- return summary.statements.pct === 100 && summary.branches.pct === 100 && summary.functions.pct === 100 && summary.lines.pct === 100;
1264
- });
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
+ }
1265
1335
  }
1266
1336
  function buildCoverageMap$2(mapped) {
1267
1337
  const coverageMap = istanbulCoverage.createCoverageMap({});
@@ -1293,8 +1363,54 @@ function buildCoverageMap$2(mapped) {
1293
1363
  }
1294
1364
  return coverageMap;
1295
1365
  }
1296
- function isValidReporter(name) {
1297
- 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
+ });
1298
1414
  }
1299
1415
  //#endregion
1300
1416
  //#region packages/rojo-utils/dist/index.mjs
@@ -1730,6 +1846,10 @@ var RojoResolver = class RojoResolver {
1730
1846
  }
1731
1847
  parsePath(itemPath) {
1732
1848
  const luauPath = convertToLuau(itemPath);
1849
+ if (ROJO_FILE_REGEX.test(path.basename(luauPath))) {
1850
+ this.parseConfig(luauPath, true);
1851
+ return;
1852
+ }
1733
1853
  const realPath = fs$1.existsSync(luauPath) ? this.cachedRealpath(luauPath) : luauPath;
1734
1854
  const extension = path.extname(luauPath);
1735
1855
  if (ROJO_MODULE_EXTS.has(extension)) this.filePathToRbxPathMap.set(luauPath, [...this.rbxPath]);
@@ -2069,6 +2189,10 @@ function parseParsedOutput(parsed) {
2069
2189
  }
2070
2190
  //#endregion
2071
2191
  //#region src/backends/envelope.ts
2192
+ const wholeRunErrorSchema = type({
2193
+ err: "unknown",
2194
+ success: "false"
2195
+ });
2072
2196
  const envelopeSchema = type({ entries: type({
2073
2197
  "bannerOutput?": "string",
2074
2198
  "elapsedMs?": "number",
@@ -2079,8 +2203,12 @@ const envelopeSchema = type({ entries: type({
2079
2203
  "snapshotWrites?": { "[string]": "string" }
2080
2204
  }).array() });
2081
2205
  function parseEnvelope(jestOutput) {
2082
- const envelope = envelopeSchema(JSON.parse(jestOutput));
2083
- if (envelope instanceof type.errors) return [{ jestOutput }];
2206
+ const raw = JSON.parse(jestOutput);
2207
+ const envelope = envelopeSchema(raw);
2208
+ if (envelope instanceof type.errors) {
2209
+ if (!(wholeRunErrorSchema(raw) instanceof type.errors)) parseJestOutput(jestOutput);
2210
+ return [{ jestOutput }];
2211
+ }
2084
2212
  return envelope.entries;
2085
2213
  }
2086
2214
  function buildProjectResult(entry, job, fallbackGameOutput) {
@@ -4820,18 +4948,30 @@ function mergeResults$1(typecheck, runtime) {
4820
4948
  assert(result !== void 0, "mergeResults requires at least one result");
4821
4949
  return result;
4822
4950
  }
4951
+ async function writeResultFile(outputFile, typecheck, runtime) {
4952
+ if (outputFile === void 0) return;
4953
+ await writeJsonFile$1(mergeResults$1(typecheck, runtime), outputFile);
4954
+ }
4823
4955
  async function outputSingleResult(config, result) {
4824
- const { preCoverageMs, runtimeResult, typecheckResult } = result;
4956
+ const { coverageDisplayFilter, preCoverageMs, runtimeResult, typecheckResult } = result;
4825
4957
  const mergedResult = mergeResults$1(typecheckResult, runtimeResult?.result);
4826
- if (!config.silent) printFormattedOutput({
4958
+ const coverageData = runtimeResult?.coverageData;
4959
+ const coveragePassed = emitResultsAndCoverage({
4827
4960
  config,
4828
- mergedResult,
4829
- runtimeResult,
4830
- timing: runtimeResult !== void 0 ? addCoverageTiming(runtimeResult.timing, preCoverageMs) : void 0,
4831
- typecheckResult
4961
+ coverageEnabled: config.collectCoverage,
4962
+ printResults: () => {
4963
+ if (config.silent) return;
4964
+ printFormattedOutput({
4965
+ config,
4966
+ mergedResult,
4967
+ runtimeResult,
4968
+ timing: runtimeResult !== void 0 ? addCoverageTiming(runtimeResult.timing, preCoverageMs) : void 0,
4969
+ typecheckResult
4970
+ });
4971
+ },
4972
+ runCoverage: () => processCoverage(config, coverageData, void 0, coverageDisplayFilter)
4832
4973
  });
4833
- const coveragePassed = processCoverage(config, runtimeResult?.coverageData);
4834
- if (config.outputFile !== void 0) await writeJsonFile$1(mergedResult, config.outputFile);
4974
+ await writeResultFile(config.outputFile, typecheckResult, runtimeResult?.result);
4835
4975
  if (runtimeResult !== void 0) writeGameOutputIfConfigured(config, runtimeResult.gameOutput, { hintsShown: !mergedResult.success });
4836
4976
  runGitHubActionsFormatter(config, mergedResult, runtimeResult?.sourceMapper);
4837
4977
  const snapshotsPersisted = (runtimeResult?.snapshotWriteFailures ?? 0) === 0;
@@ -4916,20 +5056,27 @@ async function outputMultiResult(rootConfig, result) {
4916
5056
  });
4917
5057
  const merged = mergeProjectResults(projectResults.map((entry) => entry.result));
4918
5058
  const mergedResult = mergeResults$1(typecheckResult, merged.result);
4919
- if (!config.silent) {
4920
- printMultiProjectOutput({
4921
- config,
4922
- ...resolveSinkHints(result, config),
4923
- merged,
4924
- preCoverageMs,
4925
- projectResults,
4926
- typecheckResult
4927
- });
4928
- if (typecheckResult !== void 0 && !usesDefaultFormatter(config)) process.stderr.write(formatTypecheckSummary(typecheckResult));
4929
- }
4930
- const coveragePassed = processCoverage(config, merged.coverageData, extractWorkspaceCoverageMapped(result));
5059
+ const workspaceCoverage = extractWorkspaceCoverageMapped(result);
5060
+ const displayFilter = extractCoverageDisplayFilter(result);
5061
+ const coveragePassed = emitResultsAndCoverage({
5062
+ config,
5063
+ coverageEnabled: config.collectCoverage || workspaceCoverage !== void 0,
5064
+ printResults: () => {
5065
+ if (config.silent) return;
5066
+ printMultiProjectOutput({
5067
+ config,
5068
+ ...resolveSinkHints(result, config),
5069
+ merged,
5070
+ preCoverageMs,
5071
+ projectResults,
5072
+ typecheckResult
5073
+ });
5074
+ if (typecheckResult !== void 0 && !usesDefaultFormatter(config)) process.stderr.write(formatTypecheckSummary(typecheckResult));
5075
+ },
5076
+ runCoverage: () => processCoverage(config, merged.coverageData, workspaceCoverage, displayFilter)
5077
+ });
4931
5078
  if (mode === "multi") {
4932
- if (config.outputFile !== void 0) await writeJsonFile$1(mergedResult, config.outputFile);
5079
+ await writeResultFile(config.outputFile, typecheckResult, merged.result);
4933
5080
  writeAggregatedGameOutput(config, projectResults, { hintsShown: !mergedResult.success });
4934
5081
  }
4935
5082
  runGitHubActionsFormatter(config, mergedResult, merged.sourceMapper);
@@ -4946,6 +5093,16 @@ function addCoverageTiming(timing, coverageMs) {
4946
5093
  totalMs: timing.totalMs + coverageMs
4947
5094
  };
4948
5095
  }
5096
+ function emitResultsAndCoverage(options) {
5097
+ const { config, coverageEnabled, printResults, runCoverage } = options;
5098
+ const deferResults = coverageEnabled && usesAgentFormatter(config.formatters, config.verbose);
5099
+ if (!deferResults) printResults();
5100
+ try {
5101
+ return runCoverage();
5102
+ } finally {
5103
+ if (deferResults) printResults();
5104
+ }
5105
+ }
4949
5106
  function usesDefaultFormatter(config) {
4950
5107
  return !hasFormatter(config.formatters, "json") && !usesAgentFormatter(config.formatters, config.verbose);
4951
5108
  }
@@ -5010,13 +5167,15 @@ function enforceThresholds(config, mapped) {
5010
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`);
5011
5168
  return false;
5012
5169
  }
5013
- function processCoverage(config, coverageData, preMapped) {
5170
+ function processCoverage(config, coverageData, preMapped, agentTextFilter) {
5014
5171
  if (!config.collectCoverage && preMapped === void 0) return true;
5015
5172
  const mapped = resolveMappedCoverage(config, coverageData, preMapped);
5016
5173
  if (mapped === void 0) return true;
5017
- if (!config.silent) printCoverageHeader();
5174
+ const agentMode = usesAgentFormatter(config.formatters, config.verbose);
5175
+ if (!config.silent) printCoverageHeader(agentMode);
5018
5176
  generateReports({
5019
- agentMode: usesAgentFormatter(config.formatters, config.verbose),
5177
+ agentMode,
5178
+ agentTextFilter,
5020
5179
  collectCoverageFrom: config.collectCoverageFrom,
5021
5180
  coverageDirectory: path$1.resolve(config.rootDir, config.coverageDirectory),
5022
5181
  coveragePathIgnorePatterns: config.coveragePathIgnorePatterns,
@@ -5066,6 +5225,9 @@ function buildReportConfig(rootConfig, result) {
5066
5225
  function extractWorkspaceCoverageMapped(result) {
5067
5226
  return "coverageMapped" in result ? result.coverageMapped : void 0;
5068
5227
  }
5228
+ function extractCoverageDisplayFilter(result) {
5229
+ return result.mode === "multi" ? result.coverageDisplayFilter : void 0;
5230
+ }
5069
5231
  function resolveSinkHints(result, config) {
5070
5232
  const gameOutput = result.mode === "workspace" ? result.gameOutput : config.gameOutput;
5071
5233
  const outputFile = result.mode === "workspace" ? result.outputFile : config.outputFile;
@@ -5135,6 +5297,34 @@ function writeAggregatedGameOutput(config, projectResults, options) {
5135
5297
  }
5136
5298
  }
5137
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
5138
5328
  //#region src/coverage-pipeline/build-manifest.ts
5139
5329
  /**
5140
5330
  * On-disk format version for `build-manifest.json`. Independent of
@@ -5332,7 +5522,7 @@ const SERVICE_PROPERTIES = /* @__PURE__ */ new Set([
5332
5522
  "LoadStringEnabled"
5333
5523
  ]);
5334
5524
  function synthesize(input) {
5335
- if (input.wrap === false) return synthesizeNoWrap(input.packages);
5525
+ if (input.wrap === false) return synthesizeNoWrap(input.packages, input.loadStringEnabled);
5336
5526
  const stage = { $className: "Folder" };
5337
5527
  for (const descriptor of input.packages) {
5338
5528
  const root = absolutizePaths(transformToFolder(loadRojoProject(descriptor.rojoProjectPath).tree), path$1.dirname(descriptor.rojoProjectPath), {
@@ -5449,7 +5639,27 @@ function injectStubMounts(root, stubMounts) {
5449
5639
  leaf[STUB_INJECTION_KEY] = { $path: normalizeWindowsPath(mount.absStubPath) };
5450
5640
  }
5451
5641
  }
5452
- 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) {
5453
5663
  if (packages.length !== 1) throw new ConfigError(`synthesize wrap:false requires exactly one package, got ${String(packages.length)}`);
5454
5664
  const descriptor = packages[0];
5455
5665
  const project = loadRojoProject(descriptor.rojoProjectPath);
@@ -5458,6 +5668,7 @@ function synthesizeNoWrap(packages) {
5458
5668
  coverageRoots: descriptor.coverageRoots
5459
5669
  });
5460
5670
  injectStubMounts(tree, descriptor.stubMounts);
5671
+ if (loadStringEnabled) enableLoadString(tree);
5461
5672
  return stableStringify({
5462
5673
  ...project.raw,
5463
5674
  tree
@@ -5484,9 +5695,6 @@ function filterServiceProperties(props) {
5484
5695
  for (const [propertyKey, propertyValue] of Object.entries(props)) if (!SERVICE_PROPERTIES.has(propertyKey)) filtered[propertyKey] = propertyValue;
5485
5696
  return filtered;
5486
5697
  }
5487
- function isProperties(value) {
5488
- return typeof value === "object" && !Array.isArray(value);
5489
- }
5490
5698
  function transformChildEntry(key, value) {
5491
5699
  if (key === "$className" && typeof value === "string" && SERVICE_CLASSES.has(value)) return "Folder";
5492
5700
  if (key === "$properties" && isProperties(value)) {
@@ -5511,8 +5719,9 @@ function transformValue(key, value) {
5511
5719
  * `coverageRoots`.
5512
5720
  */
5513
5721
  function buildPlace(options) {
5514
- const { packages, placeFile, projectFile, wrap } = options;
5722
+ const { loadStringEnabled, packages, placeFile, projectFile, wrap } = options;
5515
5723
  const projectJson = synthesize({
5724
+ loadStringEnabled,
5516
5725
  packages,
5517
5726
  wrap
5518
5727
  });
@@ -5527,7 +5736,7 @@ function buildPlace(options) {
5527
5736
  }
5528
5737
  //#endregion
5529
5738
  //#region src/luau/parse-ast.luau
5530
- var parse_ast_default = "local fs = require(\"@std/fs\")\nlocal json = require(\"@std/json\")\nlocal process = require(\"@std/process\")\nlocal syntax = require(\"@std/syntax\")\n\nlocal rawArgs = process.args\nlocal userArgs: { string } = {}\nlocal pastSeparator = false\n\nfor _, arg in rawArgs do\n if pastSeparator then\n table.insert(userArgs, arg)\n elseif arg == \"--\" then\n pastSeparator = true\n end\nend\n\nlocal luauRoot = userArgs[1]\nif not luauRoot then\n error(\"Usage: lute run parse-ast.luau -- <file.luau | luau-root> [output-dir]\")\nend\n\nluauRoot = string.gsub(luauRoot, \"\\\\\", \"/\")\n\n-- Fields to keep per AST tag (beyond tag/kind/location which are always kept).\n-- Tags shared by stat/expr variants are merged — nil fields are harmless.\nlocal KEEP: { [string]: { string } } = {\n assign = { \"values\", \"variables\" },\n binary = { \"lhsOperand\", \"rhsOperand\" },\n block = { \"statements\" },\n boolean = { \"value\" },\n call = { \"arguments\", \"func\" },\n cast = { \"operand\" },\n compoundassign = { \"value\", \"variable\" },\n conditional = { \"condition\", \"thenBlock\", \"elseifs\", \"elseBlock\", \"thenExpr\", \"elseExpr\" },\n [\"do\"] = { \"body\" },\n expression = { \"expression\" },\n [\"for\"] = { \"body\", \"from\", \"to\", \"step\" },\n forin = { \"body\", \"values\" },\n [\"function\"] = { \"body\", \"name\", \"func\" },\n global = { \"name\" },\n group = { \"expression\" },\n index = { \"expression\", \"index\" },\n indexname = { \"expression\", \"accessor\", \"index\" },\n instantiate = { \"expr\" },\n interpolatedstring = { \"expressions\" },\n [\"local\"] = { \"values\", \"variables\" },\n localfunction = { \"name\", \"func\" },\n number = { \"value\" },\n [\"repeat\"] = { \"body\", \"condition\" },\n [\"return\"] = { \"expressions\" },\n -- `string` is intentionally absent: the literal value lives at\n -- `value.token.text` and is flattened by the special-case branch in\n -- `strip` below. A flat `{ \"text\" }` entry here can't reach it.\n table = { \"entries\" },\n unary = { \"operand\" },\n [\"while\"] = { \"body\", \"condition\" },\n}\n\nlocal function strip(value: any): any\n if type(value) ~= \"table\" then\n return value\n end\n\n -- LuauSpan — has beginLine, no tag\n if value.beginLine ~= nil then\n return value\n end\n\n -- Token — has text but no tag, reduce to {text}\n if value.text ~= nil and value.tag == nil then\n return { text = value.text }\n end\n\n -- AST node — has tag, keep only allowlisted fields\n if value.tag ~= nil then\n local result = { tag = value.tag, kind = value.kind, location = value.location }\n\n -- Lute nests the string literal inside `.token.text`, so the KEEP\n -- entry can't reach it via flat field lookup. Flatten it here so\n -- downstream consumers read `node.text` like every other literal.\n if value.tag == \"string\" and type(value.token) == \"table\" then\n result.text = value.token.text\n end\n\n local fields = KEEP[value.tag :: string]\n if fields then\n for _, field in fields do\n if value[field] ~= nil then\n result[field] = strip(value[field])\n end\n end\n end\n\n return result\n end\n\n -- Other tables (arrays, Pairs, ElseIf structs) — recurse all fields\n local result: any = {}\n for k, v in value do\n result[k] = strip(v)\n end\n\n return result\nend\n\n-- Single-file mode: parse one file, print stripped AST to stdout\nif string.sub(luauRoot, -5) == \".luau\" or string.sub(luauRoot, -4) == \".lua\" then\n local source = fs.readFileToString(luauRoot)\n local parseResult = syntax.parse(source)\n print(json.serialize(strip(parseResult.root)))\n return\nend\n\nlocal outputDir = userArgs[2]\nif not outputDir then\n error(\"Usage: lute run parse-ast.luau -- <luau-root> <output-dir>\")\nend\n\noutputDir = string.gsub(outputDir, \"\\\\\", \"/\")\n\n-- Discover .luau files recursively, skipping node_modules, dot dirs, spec/test files\nlocal function discoverFiles(directory: string, relativeTo: string, results: { string })\n local entries = fs.listDirectory(directory)\n for _, entry in entries do\n local fullPath = directory .. \"/\" .. entry.name\n if entry.type == \"dir\" then\n if entry.name == \"node_modules\" or entry.name == \".jest-roblox\" then\n continue\n end\n\n if string.sub(entry.name, 1, 1) == \".\" then\n continue\n end\n\n discoverFiles(fullPath, relativeTo, results)\n elseif\n entry.type == \"file\"\n and (string.sub(entry.name, -5) == \".luau\" or string.sub(entry.name, -4) == \".lua\")\n then\n if\n string.sub(entry.name, -10) == \".spec.luau\"\n or string.sub(entry.name, -10) == \".test.luau\"\n or string.sub(entry.name, -9) == \".spec.lua\"\n or string.sub(entry.name, -9) == \".test.lua\"\n or string.sub(entry.name, -10) == \".snap.luau\"\n or string.sub(entry.name, -9) == \".snap.lua\"\n then\n continue\n end\n\n -- Compute relative path\n local relative = string.sub(fullPath, #relativeTo + 2)\n table.insert(results, relative)\n end\n end\nend\n\nlocal function dirname(filepath: string): string\n local pos = string.find(filepath, \"/[^/]*$\")\n if pos then\n return string.sub(filepath, 1, pos - 1)\n end\n\n return \"\"\nend\n\n-- Optional 3rd arg: path to skip list JSON file.\n-- Skipped files are still included in the file list but not parsed.\nlocal skipListPath = userArgs[3]\nlocal skipSet: { [string]: boolean } = {}\nif skipListPath then\n local skipJson = fs.readFileToString(skipListPath)\n local skipList = json.deserialize(skipJson) :: { string }\n for _, entry in skipList do\n skipSet[entry] = true\n end\nend\n\nlocal files: { string } = {}\ndiscoverFiles(luauRoot, luauRoot, files)\n\n-- Parse, strip, and write per-file AST JSON\nfor _, relativePath in files do\n if skipSet[relativePath] then\n continue\n end\n\n local fullPath = luauRoot .. \"/\" .. relativePath\n local source = fs.readFileToString(fullPath)\n local parseResult = syntax.parse(source)\n local stripped = strip(parseResult.root)\n\n local outPath = outputDir .. \"/\" .. relativePath .. \".json\"\n local dir = dirname(outPath)\n if dir ~= \"\" then\n fs.createDirectory(dir, { makeParents = true })\n end\n\n fs.writeStringToFile(outPath, json.serialize(stripped))\nend\n\n-- Print file list to stdout (tiny — just paths)\nprint(json.serialize(files :: json.Array))\n";
5739
+ var parse_ast_default = "local fs = require(\"@std/fs\")\nlocal json = require(\"@std/json\")\nlocal process = require(\"@std/process\")\nlocal syntax = require(\"@std/syntax\")\n\nlocal rawArgs = process.args\nlocal userArgs: { string } = {}\nlocal pastSeparator = false\n\nfor _, arg in rawArgs do\n if pastSeparator then\n table.insert(userArgs, arg)\n elseif arg == \"--\" then\n pastSeparator = true\n end\nend\n\nlocal luauRoot = userArgs[1]\nif not luauRoot then\n error(\"Usage: lute run parse-ast.luau -- <file.luau | luau-root> [output-dir]\")\nend\n\nluauRoot = string.gsub(luauRoot, \"\\\\\", \"/\")\n\n-- Fields to keep per AST tag (beyond tag/kind/location which are always kept).\n-- Tags shared by stat/expr variants are merged — nil fields are harmless.\nlocal KEEP: { [string]: { string } } = {\n assign = { \"values\", \"variables\" },\n -- `operator` (reduced to its `.text` by the token branch in `strip`) lets\n -- the collector tell `and`/`or` short-circuit branches from other binaries.\n binary = { \"lhsOperand\", \"rhsOperand\", \"operator\" },\n block = { \"statements\" },\n boolean = { \"value\" },\n call = { \"arguments\", \"func\" },\n cast = { \"operand\" },\n compoundassign = { \"value\", \"variable\" },\n conditional = { \"condition\", \"thenBlock\", \"elseifs\", \"elseBlock\", \"thenExpr\", \"elseExpr\" },\n [\"do\"] = { \"body\" },\n expression = { \"expression\" },\n [\"for\"] = { \"body\", \"from\", \"to\", \"step\" },\n forin = { \"body\", \"values\" },\n [\"function\"] = { \"body\", \"name\", \"func\" },\n global = { \"name\" },\n group = { \"expression\" },\n index = { \"expression\", \"index\" },\n indexname = { \"expression\", \"accessor\", \"index\" },\n instantiate = { \"expr\" },\n interpolatedstring = { \"expressions\" },\n [\"local\"] = { \"values\", \"variables\" },\n localfunction = { \"name\", \"func\" },\n number = { \"value\" },\n [\"repeat\"] = { \"body\", \"condition\" },\n [\"return\"] = { \"expressions\" },\n -- `string` is intentionally absent: the literal value lives at\n -- `value.token.text` and is flattened by the special-case branch in\n -- `strip` below. A flat `{ \"text\" }` entry here can't reach it.\n table = { \"entries\" },\n unary = { \"operand\" },\n [\"while\"] = { \"body\", \"condition\" },\n}\n\nlocal function strip(value: any): any\n if type(value) ~= \"table\" then\n return value\n end\n\n -- LuauSpan — has beginLine, no tag\n if value.beginLine ~= nil then\n return value\n end\n\n -- Token — has text but no tag, reduce to {text}\n if value.text ~= nil and value.tag == nil then\n return { text = value.text }\n end\n\n -- AST node — has tag, keep only allowlisted fields\n if value.tag ~= nil then\n local result = { tag = value.tag, kind = value.kind, location = value.location }\n\n -- Lute nests the string literal inside `.token.text`, so the KEEP\n -- entry can't reach it via flat field lookup. Flatten it here so\n -- downstream consumers read `node.text` like every other literal.\n if value.tag == \"string\" and type(value.token) == \"table\" then\n result.text = value.token.text\n end\n\n local fields = KEEP[value.tag :: string]\n if fields then\n for _, field in fields do\n if value[field] ~= nil then\n result[field] = strip(value[field])\n end\n end\n end\n\n return result\n end\n\n -- Other tables (arrays, Pairs, ElseIf structs) — recurse all fields\n local result: any = {}\n for k, v in value do\n result[k] = strip(v)\n end\n\n return result\nend\n\n-- Single-file mode: parse one file, print stripped AST to stdout\nif string.sub(luauRoot, -5) == \".luau\" or string.sub(luauRoot, -4) == \".lua\" then\n local source = fs.readFileToString(luauRoot)\n local parseResult = syntax.parse(source)\n print(json.serialize(strip(parseResult.root)))\n return\nend\n\nlocal outputDir = userArgs[2]\nif not outputDir then\n error(\"Usage: lute run parse-ast.luau -- <luau-root> <output-dir>\")\nend\n\noutputDir = string.gsub(outputDir, \"\\\\\", \"/\")\n\n-- Discover .luau files recursively, skipping node_modules, dot dirs, spec/test files\nlocal function discoverFiles(directory: string, relativeTo: string, results: { string })\n local entries = fs.listDirectory(directory)\n for _, entry in entries do\n local fullPath = directory .. \"/\" .. entry.name\n if entry.type == \"dir\" then\n if entry.name == \"node_modules\" or entry.name == \".jest-roblox\" then\n continue\n end\n\n if string.sub(entry.name, 1, 1) == \".\" then\n continue\n end\n\n discoverFiles(fullPath, relativeTo, results)\n elseif\n entry.type == \"file\"\n and (string.sub(entry.name, -5) == \".luau\" or string.sub(entry.name, -4) == \".lua\")\n then\n if\n string.sub(entry.name, -10) == \".spec.luau\"\n or string.sub(entry.name, -10) == \".test.luau\"\n or string.sub(entry.name, -9) == \".spec.lua\"\n or string.sub(entry.name, -9) == \".test.lua\"\n or string.sub(entry.name, -10) == \".snap.luau\"\n or string.sub(entry.name, -9) == \".snap.lua\"\n then\n continue\n end\n\n -- Compute relative path\n local relative = string.sub(fullPath, #relativeTo + 2)\n table.insert(results, relative)\n end\n end\nend\n\nlocal function dirname(filepath: string): string\n local pos = string.find(filepath, \"/[^/]*$\")\n if pos then\n return string.sub(filepath, 1, pos - 1)\n end\n\n return \"\"\nend\n\n-- Optional 3rd arg: path to skip list JSON file.\n-- Skipped files are still included in the file list but not parsed.\nlocal skipListPath = userArgs[3]\nlocal skipSet: { [string]: boolean } = {}\nif skipListPath then\n local skipJson = fs.readFileToString(skipListPath)\n local skipList = json.deserialize(skipJson) :: { string }\n for _, entry in skipList do\n skipSet[entry] = true\n end\nend\n\nlocal files: { string } = {}\ndiscoverFiles(luauRoot, luauRoot, files)\n\n-- Parse, strip, and write per-file AST JSON\nfor _, relativePath in files do\n if skipSet[relativePath] then\n continue\n end\n\n local fullPath = luauRoot .. \"/\" .. relativePath\n local source = fs.readFileToString(fullPath)\n local parseResult = syntax.parse(source)\n local stripped = strip(parseResult.root)\n\n local outPath = outputDir .. \"/\" .. relativePath .. \".json\"\n local dir = dirname(outPath)\n if dir ~= \"\" then\n fs.createDirectory(dir, { makeParents = true })\n end\n\n fs.writeStringToFile(outPath, json.serialize(stripped))\nend\n\n-- Print file list to stdout (tiny — just paths)\nprint(json.serialize(files :: json.Array))\n";
5531
5740
  //#endregion
5532
5741
  //#region packages/luau-ast/dist/index.mjs
5533
5742
  function visitExpression(expression, visitor) {
@@ -5825,9 +6034,37 @@ function collectCoverage(root) {
5825
6034
  const functions = [];
5826
6035
  const branches = [];
5827
6036
  const implicitElseProbes = [];
5828
- const exprIfProbes = [];
6037
+ const wrapProbes = [];
5829
6038
  const namedFunctions = /* @__PURE__ */ new Set();
5830
6039
  visitBlock(root, {
6040
+ visitExprBinary(node) {
6041
+ const operator = node.operator?.text;
6042
+ if (operator !== "and" && operator !== "or") return true;
6043
+ branches.push({
6044
+ arms: [{
6045
+ bodyFirstColumn: 0,
6046
+ bodyFirstLine: 0,
6047
+ location: { ...node.lhsOperand.location }
6048
+ }, {
6049
+ bodyFirstColumn: 0,
6050
+ bodyFirstLine: 0,
6051
+ location: { ...node.rhsOperand.location }
6052
+ }],
6053
+ branchType: "binary-expr",
6054
+ index: branchIndex
6055
+ });
6056
+ wrapProbes.push({
6057
+ armIndex: 1,
6058
+ branchIndex,
6059
+ exprLocation: { ...node.lhsOperand.location }
6060
+ }, {
6061
+ armIndex: 2,
6062
+ branchIndex,
6063
+ exprLocation: { ...node.rhsOperand.location }
6064
+ });
6065
+ branchIndex++;
6066
+ return true;
6067
+ },
5831
6068
  visitExprFunction(node) {
5832
6069
  if (namedFunctions.has(node)) return true;
5833
6070
  const first = getBodyFirstStatement(node.body);
@@ -5853,7 +6090,7 @@ function collectCoverage(root) {
5853
6090
  bodyFirstLine: 0,
5854
6091
  location: { ...node.thenExpr.location }
5855
6092
  });
5856
- exprIfProbes.push({
6093
+ wrapProbes.push({
5857
6094
  armIndex,
5858
6095
  branchIndex,
5859
6096
  exprLocation: { ...node.thenExpr.location }
@@ -5865,7 +6102,7 @@ function collectCoverage(root) {
5865
6102
  bodyFirstLine: 0,
5866
6103
  location: { ...elseif.thenExpr.location }
5867
6104
  });
5868
- exprIfProbes.push({
6105
+ wrapProbes.push({
5869
6106
  armIndex,
5870
6107
  branchIndex,
5871
6108
  exprLocation: { ...elseif.thenExpr.location }
@@ -5877,7 +6114,7 @@ function collectCoverage(root) {
5877
6114
  bodyFirstLine: 0,
5878
6115
  location: { ...node.elseExpr.location }
5879
6116
  });
5880
- exprIfProbes.push({
6117
+ wrapProbes.push({
5881
6118
  armIndex,
5882
6119
  branchIndex,
5883
6120
  exprLocation: { ...node.elseExpr.location }
@@ -5981,10 +6218,10 @@ function collectCoverage(root) {
5981
6218
  });
5982
6219
  return {
5983
6220
  branches,
5984
- exprIfProbes,
5985
6221
  functions,
5986
6222
  implicitElseProbes,
5987
- statements
6223
+ statements,
6224
+ wrapProbes
5988
6225
  };
5989
6226
  }
5990
6227
  function getBodyFirstStatement(block) {
@@ -6058,6 +6295,11 @@ function buildCoverageMap$1(result) {
6058
6295
  }
6059
6296
  //#endregion
6060
6297
  //#region src/coverage-pipeline/probe-inserter.ts
6298
+ const KIND_RANK = {
6299
+ close: 2,
6300
+ open: 0,
6301
+ point: 1
6302
+ };
6061
6303
  function insertProbes(source, result, fileKey) {
6062
6304
  const lines = splitLines(source);
6063
6305
  applyProbes(lines, collectProbes(result));
@@ -6067,11 +6309,13 @@ function collectProbes(result) {
6067
6309
  const probes = [];
6068
6310
  for (const stmt of result.statements) probes.push({
6069
6311
  column: stmt.location.beginColumn,
6312
+ kind: "point",
6070
6313
  line: stmt.location.beginLine,
6071
6314
  text: `__cov_s[${stmt.index}] += 1; `
6072
6315
  });
6073
6316
  for (const func of result.functions) if (func.bodyFirstLine > 0) probes.push({
6074
6317
  column: func.bodyFirstColumn,
6318
+ kind: "point",
6075
6319
  line: func.bodyFirstLine,
6076
6320
  text: `__cov_f[${func.index}] += 1; `
6077
6321
  });
@@ -6079,27 +6323,42 @@ function collectProbes(result) {
6079
6323
  const arm = branch.arms[armIndex];
6080
6324
  if (arm !== void 0 && arm.bodyFirstLine > 0) probes.push({
6081
6325
  column: arm.bodyFirstColumn,
6326
+ kind: "point",
6082
6327
  line: arm.bodyFirstLine,
6083
6328
  text: `__cov_b[${branch.index}][${armIndex + 1}] += 1; `
6084
6329
  });
6085
6330
  }
6086
6331
  for (const probe of result.implicitElseProbes) probes.push({
6087
6332
  column: probe.endColumn,
6333
+ kind: "point",
6088
6334
  line: probe.endLine,
6089
6335
  text: `else __cov_b[${probe.branchIndex}][${probe.armIndex}] += 1 `
6090
6336
  });
6091
- for (const probe of result.exprIfProbes) probes.push({
6092
- column: probe.exprLocation.beginColumn,
6093
- line: probe.exprLocation.beginLine,
6094
- text: `__cov_br(${probe.branchIndex}, ${probe.armIndex}, `
6095
- }, {
6096
- column: probe.exprLocation.endColumn,
6097
- line: probe.exprLocation.endLine,
6098
- text: ")"
6099
- });
6337
+ for (const probe of result.wrapProbes) {
6338
+ const { beginColumn, beginLine, endColumn, endLine } = probe.exprLocation;
6339
+ probes.push({
6340
+ column: beginColumn,
6341
+ kind: "open",
6342
+ line: beginLine,
6343
+ spanColumn: endColumn,
6344
+ spanLine: endLine,
6345
+ text: `__cov_br(${probe.branchIndex}, ${probe.armIndex}, `
6346
+ }, {
6347
+ column: endColumn,
6348
+ kind: "close",
6349
+ line: endLine,
6350
+ spanColumn: beginColumn,
6351
+ spanLine: beginLine,
6352
+ text: ")"
6353
+ });
6354
+ }
6100
6355
  probes.sort((a, b) => {
6101
- if (a.line === b.line) return b.column - a.column;
6102
- return b.line - a.line;
6356
+ if (a.line !== b.line) return b.line - a.line;
6357
+ if (a.column !== b.column) return b.column - a.column;
6358
+ if (KIND_RANK[a.kind] !== KIND_RANK[b.kind]) return KIND_RANK[a.kind] - KIND_RANK[b.kind];
6359
+ if (a.spanLine !== void 0 && b.spanLine !== void 0 && a.spanLine !== b.spanLine) return a.spanLine - b.spanLine;
6360
+ if (a.spanColumn !== void 0 && b.spanColumn !== void 0) return a.spanColumn - b.spanColumn;
6361
+ return 0;
6103
6362
  });
6104
6363
  return probes;
6105
6364
  }
@@ -6161,7 +6420,7 @@ function buildPreamble(modeDirective, fileKey, result) {
6161
6420
  const zeros = branch.arms.map(() => "0").join(", ");
6162
6421
  preamble += `if __cov_b[${branch.index}] == nil then __cov_b[${branch.index}] = {${zeros}} end\n`;
6163
6422
  }
6164
- if (result.exprIfProbes.length > 0) preamble += "local function __cov_br(__bi, __ai, ...) __cov_b[__bi][__ai] += 1; return ... end\n";
6423
+ if (result.wrapProbes.length > 0) preamble += "local function __cov_br(__bi, __ai, ...) __cov_b[__bi][__ai] += 1; return ... end\n";
6165
6424
  }
6166
6425
  return preamble;
6167
6426
  }
@@ -6719,6 +6978,7 @@ function validateRelativeRoots(luauRoots) {
6719
6978
  }
6720
6979
  function buildRojoProject(rojoProjectPath, packageDirectory, coverageRoots, placeFile) {
6721
6980
  return buildPlace({
6981
+ loadStringEnabled: true,
6722
6982
  packages: [{
6723
6983
  name: "jest-roblox-coverage",
6724
6984
  coverageRoots,
@@ -6890,6 +7150,7 @@ var OcaleRunner = class {
6890
7150
  apiKey: credentials.apiKey,
6891
7151
  ...options?.baseUrl !== void 0 ? { baseUrl: options.baseUrl } : {},
6892
7152
  ...options?.httpClient !== void 0 ? { httpClient: options.httpClient } : {},
7153
+ ...options?.maxRetries !== void 0 ? { maxRetries: options.maxRetries } : {},
6893
7154
  ...options?.sleep !== void 0 ? { sleep: options.sleep } : {}
6894
7155
  };
6895
7156
  this.luau = new LuauExecutionClient(clientOptions);
@@ -6897,7 +7158,7 @@ var OcaleRunner = class {
6897
7158
  this.readFileFn = options?.readFile ?? ((filePath) => fs$1.readFileSync(filePath));
6898
7159
  }
6899
7160
  async executeScript(options) {
6900
- const { script, timeout } = options;
7161
+ const { placeVersion, script, timeout } = options;
6901
7162
  if (timeout <= 0) throw new Error("Timeout must be a positive number");
6902
7163
  const startTime = Date.now();
6903
7164
  const timeoutSeconds = Math.min(Math.floor(timeout / 1e3), MAX_TASK_TIMEOUT_SECONDS);
@@ -6905,7 +7166,8 @@ var OcaleRunner = class {
6905
7166
  placeId: this.credentials.placeId,
6906
7167
  script,
6907
7168
  timeoutSeconds,
6908
- universeId: this.credentials.universeId
7169
+ universeId: this.credentials.universeId,
7170
+ ...placeVersion !== void 0 ? { versionId: String(placeVersion) } : {}
6909
7171
  }, {
6910
7172
  retryableTransportCodes: TRANSIENT_TRANSPORT_CODES,
6911
7173
  timeoutMs: timeout
@@ -6957,6 +7219,115 @@ type({
6957
7219
  request_id: "string",
6958
7220
  type: "'results'"
6959
7221
  });
7222
+ /**
7223
+ * Measured Open Cloud per-place active-task ceiling — a platform constant, not a
7224
+ * tuning knob. `P` places offer `10·P` concurrent task slots, so the pool caps
7225
+ * each place's slot share here and warns when the requested total exceeds the
7226
+ * aggregate ceiling.
7227
+ */
7228
+ const MAX_TASKS_PER_PLACE = 10;
7229
+ /**
7230
+ * A freed slot needs ~10s before its place will accept a new task. A backoff
7231
+ * signal inside this window after a completion on that place is slot-recycle
7232
+ * lag, not a genuinely-full place, so the pool waits out the remainder of the
7233
+ * window rather than treating it as a hard place-full signal (and burning the
7234
+ * shared creation budget on an immediate retry storm).
7235
+ */
7236
+ const RECYCLE_LAG_MS = 1e4;
7237
+ /** Backoff when a place pushes back with no server-supplied retry delay. */
7238
+ const DEFAULT_BACKOFF_MS = 5e3;
7239
+ /**
7240
+ * Drive a replenishing pool of long-lived tasks across a configurable list of
7241
+ * places: spread `concurrency` slots across the places (capped at
7242
+ * {@link MAX_TASKS_PER_PLACE} each), keep every slot full, relaunch a slot when
7243
+ * its task returns while work remains (`!isDone()`), and resolve once every slot
7244
+ * has drained and the done-signal has fired. All places drain the consumer's one
7245
+ * shared queue, so a slot that frees on a fast place transparently re-runs a
7246
+ * sibling's lost work. The pool is jest/mutation-agnostic — chunk sizing, result
7247
+ * de-duplication, and bail live in the consumer.
7248
+ *
7249
+ * A task that throws a recognised platform backoff signal (a genuinely-full
7250
+ * place's `RESOURCE_EXHAUSTED`, the ~10s slot-recycle lag, or a rate-limit 429)
7251
+ * waits the slot out and retries without surfacing an error; any other throw
7252
+ * frees the slot (its in-flight work re-surfaces via the queue invisibility
7253
+ * window), reports through `onError`, and is relaunched while work remains — so
7254
+ * neither a transient failure nor platform backoff aborts the run or corrupts
7255
+ * results.
7256
+ */
7257
+ async function runTaskPool(options) {
7258
+ const { concurrency, isDone, onError, onResult, places } = options;
7259
+ const now = options.now ?? Date.now;
7260
+ const sleep = options.sleep ?? setTimeout$1;
7261
+ const warn = options.warn ?? ((message) => {
7262
+ console.warn(message);
7263
+ });
7264
+ if (concurrency < 1) throw new Error(`runTaskPool concurrency must be >= 1, got ${String(concurrency)}`);
7265
+ if (places.length === 0) throw new Error("runTaskPool requires at least one place");
7266
+ const allocations = distributeSlots(places, concurrency, warn);
7267
+ async function backoff(state, retryAfterMs) {
7268
+ const genuineMs = retryAfterMs !== void 0 && retryAfterMs > 0 ? retryAfterMs : DEFAULT_BACKOFF_MS;
7269
+ const sinceCompletion = now() - state.lastCompletionMs;
7270
+ await sleep(sinceCompletion < RECYCLE_LAG_MS ? RECYCLE_LAG_MS - sinceCompletion : genuineMs);
7271
+ }
7272
+ async function worker(state) {
7273
+ while (!isDone()) {
7274
+ let result;
7275
+ try {
7276
+ result = await state.place.runTask();
7277
+ } catch (err) {
7278
+ const signal = classifyBackoff(err);
7279
+ if (signal !== void 0) {
7280
+ await backoff(state, signal.retryAfterMs);
7281
+ continue;
7282
+ }
7283
+ onError?.(err);
7284
+ continue;
7285
+ }
7286
+ state.lastCompletionMs = now();
7287
+ onResult(result);
7288
+ }
7289
+ }
7290
+ const workers = [];
7291
+ for (const { place, slots } of allocations) {
7292
+ const state = {
7293
+ lastCompletionMs: Number.NEGATIVE_INFINITY,
7294
+ place
7295
+ };
7296
+ for (let slot = 0; slot < slots; slot += 1) workers.push(worker(state));
7297
+ }
7298
+ await Promise.all(workers);
7299
+ }
7300
+ /**
7301
+ * Spread `concurrency` task-slots across the places as evenly as possible, capped
7302
+ * at {@link MAX_TASKS_PER_PLACE} per place. A total above the aggregate ceiling is
7303
+ * clamped and reported once through `warn` so the run still proceeds at the real
7304
+ * capacity instead of silently over-subscribing.
7305
+ */
7306
+ function distributeSlots(places, concurrency, warn) {
7307
+ const placeCount = places.length;
7308
+ const capacity = placeCount * MAX_TASKS_PER_PLACE;
7309
+ if (concurrency > capacity) warn(`runTaskPool concurrency ${String(concurrency)} exceeds ${String(placeCount)} place(s) × ${String(MAX_TASKS_PER_PLACE)} = ${String(capacity)}; clamping to ${String(capacity)}`);
7310
+ const effective = Math.min(concurrency, capacity);
7311
+ const base = Math.floor(effective / placeCount);
7312
+ const remainder = effective % placeCount;
7313
+ return places.map((place, index) => ({
7314
+ place,
7315
+ slots: base + (index < remainder ? 1 : 0)
7316
+ }));
7317
+ }
7318
+ /**
7319
+ * Classify the platform backoff signal the pool waits out rather than failing:
7320
+ * a rate-limit 429 ({@link RateLimitError}, carrying the server retry delay) or a
7321
+ * genuinely-full place ({@link ApiError} with the `RESOURCE_EXHAUSTED` code).
7322
+ * Walks the `cause` chain because {@link RemoteRunner.executeScript} wraps the
7323
+ * transport error in a plain `Error`. Any other error is not a backoff signal.
7324
+ */
7325
+ function classifyBackoff(error) {
7326
+ for (let current = error; current instanceof Error; current = current.cause) {
7327
+ if (current instanceof RateLimitError) return { retryAfterMs: current.retryAfterSeconds * 1e3 };
7328
+ if (current instanceof ApiError && current.code === "RESOURCE_EXHAUSTED") return {};
7329
+ }
7330
+ }
6960
7331
  var WorkQueue = class {
6961
7332
  decode;
6962
7333
  encode;
@@ -7029,6 +7400,28 @@ function toQueueData(value) {
7029
7400
  return value;
7030
7401
  }
7031
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
7032
7425
  //#region src/test-runner.bundled.luau
7033
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";
7034
7427
  //#endregion
@@ -7083,6 +7476,7 @@ function readStringProperty(err, key) {
7083
7476
  //#region src/backends/open-cloud.ts
7084
7477
  const PARALLEL_AUTO_CAP = 3;
7085
7478
  const BASE_URL_ENV = "JEST_ROBLOX_OPEN_CLOUD_BASE_URL";
7479
+ const MAX_RETRIES_ENV = "JEST_ROBLOX_OCALE_MAX_RETRIES";
7086
7480
  const DEFAULT_STREAM_POLL_MS = 250;
7087
7481
  var OpenCloudBackend = class {
7088
7482
  runner;
@@ -7095,24 +7489,25 @@ var OpenCloudBackend = class {
7095
7489
  if (jobs.length === 0) throw new Error("OpenCloudBackend requires at least one job");
7096
7490
  if (workStealing === true && scriptOverride === void 0) throw new Error("OpenCloudBackend work-stealing mode requires scriptOverride");
7097
7491
  const primary = jobs[0];
7098
- const placeFilePath = path$1.resolve(primary.config.rootDir, primary.config.placeFile);
7492
+ const placeFilePath = resolvePlaceFilePath(primary.config);
7099
7493
  const upload = await this.runner.uploadPlace({ placeFilePath });
7100
7494
  const executionStart = Date.now();
7101
7495
  return {
7102
7496
  rawResults: workStealing === true ? await this.runWorkStealing({
7103
7497
  jobs,
7104
7498
  parallel,
7499
+ placeVersion: upload.versionNumber,
7105
7500
  primaryConfig: primary.config,
7106
7501
  scriptOverride,
7107
7502
  streaming
7108
- }) : await this.runStaticBuckets(jobs, parallel, scriptOverride),
7503
+ }) : await this.runStaticBuckets(jobs, parallel, upload.versionNumber, scriptOverride),
7109
7504
  timing: {
7110
7505
  executionMs: Date.now() - executionStart,
7111
7506
  uploadMs: upload.uploadMs
7112
7507
  }
7113
7508
  };
7114
7509
  }
7115
- async runBucket(bucket, scriptOverride) {
7510
+ async runBucket(bucket, placeVersion, scriptOverride) {
7116
7511
  const { indices, jobs } = bucket;
7117
7512
  const primary = jobs[0];
7118
7513
  const inputs = jobs.map((job) => {
@@ -7123,6 +7518,7 @@ var OpenCloudBackend = class {
7123
7518
  });
7124
7519
  const script = scriptOverride ?? generateTestScript(inputs);
7125
7520
  const scriptResult = await this.runner.executeScript({
7521
+ placeVersion,
7126
7522
  script,
7127
7523
  timeout: primary.config.timeout
7128
7524
  });
@@ -7141,37 +7537,44 @@ var OpenCloudBackend = class {
7141
7537
  })
7142
7538
  };
7143
7539
  }
7144
- async runStaticBuckets(jobs, parallel, scriptOverride) {
7540
+ async runStaticBuckets(jobs, parallel, placeVersion, scriptOverride) {
7145
7541
  const buckets = bucketJobs(jobs, parallel);
7146
- const bucketResults = await Promise.all(buckets.map(async (bucket) => this.runBucket(bucket, scriptOverride)));
7542
+ const bucketResults = await Promise.all(buckets.map(async (bucket) => this.runBucket(bucket, placeVersion, scriptOverride)));
7147
7543
  const flattened = Array.from({ length: jobs.length });
7148
7544
  for (const { indices, rawResults } of bucketResults) for (const [positionInBucket, originalIndex] of indices.entries()) flattened[originalIndex] = rawResults[positionInBucket];
7149
7545
  return flattened;
7150
7546
  }
7151
- async runStealingTask(script, primaryConfig) {
7152
- const result = await this.runner.executeScript({
7153
- script,
7154
- timeout: primaryConfig.timeout
7155
- });
7156
- const jestOutput = result.outputs[0];
7157
- if (jestOutput === void 0) throw new Error(`No test results in output. Got: ${JSON.stringify(result.outputs)}`);
7158
- return {
7159
- entries: parseEnvelope(jestOutput),
7160
- gameOutput: result.outputs[1]
7161
- };
7162
- }
7163
7547
  async runWorkStealing(args) {
7164
- const { jobs, parallel, primaryConfig, scriptOverride, streaming } = args;
7548
+ const { jobs, parallel, placeVersion, primaryConfig, scriptOverride, streaming } = args;
7165
7549
  const taskCount = resolveBucketCount(parallel, jobs.length);
7166
7550
  const tasksDone = { value: false };
7167
- const taskEnvelopesPromise = Promise.all(Array.from({ length: taskCount }, async () => this.runStealingTask(scriptOverride, primaryConfig))).finally(() => {
7551
+ const taskResults = [];
7552
+ let launched = 0;
7553
+ let taskFailure;
7554
+ const poolPromise = runTaskPool({
7555
+ concurrency: taskCount,
7556
+ isDone: () => launched >= taskCount,
7557
+ onError: (error) => {
7558
+ taskFailure = { error };
7559
+ },
7560
+ onResult: (result) => {
7561
+ taskResults.push(result);
7562
+ },
7563
+ places: [{ runTask: async () => {
7564
+ launched += 1;
7565
+ return this.runner.executeScript({
7566
+ placeVersion,
7567
+ script: scriptOverride,
7568
+ timeout: primaryConfig.timeout
7569
+ });
7570
+ } }]
7571
+ }).finally(() => {
7168
7572
  tasksDone.value = true;
7169
7573
  });
7170
7574
  const pollPromise = streaming !== void 0 ? pollStreamingResults(streaming, () => tasksDone.value) : Promise.resolve();
7171
- const [taskSettlement] = await Promise.allSettled([taskEnvelopesPromise, pollPromise]);
7172
- if (taskSettlement.status === "rejected") throw taskSettlement.reason;
7173
- const taskEnvelopes = taskSettlement.value;
7174
- const entryByKey = aggregateEntriesByKey(taskEnvelopes);
7575
+ await Promise.all([poolPromise, pollPromise]);
7576
+ if (taskFailure !== void 0) throw taskFailure.error;
7577
+ const entryByKey = aggregateEntriesByKey(taskResults.map(parseStealingEnvelope));
7175
7578
  const missing = [];
7176
7579
  const rawResults = [];
7177
7580
  for (const job of jobs) {
@@ -7210,6 +7613,19 @@ function resolveOpenCloudBaseUrl() {
7210
7613
  if (override === void 0 || override === "") return;
7211
7614
  return override.replace(/\/+$/, "");
7212
7615
  }
7616
+ /**
7617
+ * Reads {@link MAX_RETRIES_ENV} for an Open Cloud retry-budget override. Lets
7618
+ * the live e2e suite raise the per-request retry count so concurrent place
7619
+ * uploads (which share one per-minute quota across processes) ride out a
7620
+ * transient 429 instead of failing. Returns undefined for unset, empty, or
7621
+ * non-integer values so the client keeps its own default.
7622
+ */
7623
+ function resolveOcaleMaxRetries() {
7624
+ const raw = process.env[MAX_RETRIES_ENV]?.trim();
7625
+ if (raw === void 0 || raw === "") return;
7626
+ const parsed = Number(raw);
7627
+ return Number.isInteger(parsed) && parsed >= 0 ? parsed : void 0;
7628
+ }
7213
7629
  function createOpenCloudBackend(credentials) {
7214
7630
  return new OpenCloudBackend(credentials);
7215
7631
  }
@@ -7248,7 +7664,11 @@ async function sleep(ms) {
7248
7664
  }
7249
7665
  function resolveRunnerOptions() {
7250
7666
  const baseUrl = resolveOpenCloudBaseUrl();
7251
- return baseUrl === void 0 ? {} : { baseUrl };
7667
+ const maxRetries = resolveOcaleMaxRetries();
7668
+ return {
7669
+ ...baseUrl === void 0 ? {} : { baseUrl },
7670
+ ...maxRetries === void 0 ? {} : { maxRetries }
7671
+ };
7252
7672
  }
7253
7673
  function resolveBucketCount(parallel, jobCount) {
7254
7674
  if (parallel === void 0) return 1;
@@ -7273,6 +7693,19 @@ function bucketJobs(jobs, parallel) {
7273
7693
  function entryLookupKey(package_, project) {
7274
7694
  return project === void 0 || project === package_ ? package_ : `${package_}::${project}`;
7275
7695
  }
7696
+ /**
7697
+ * Decode one work-stealing task's return envelope. Throws when the task
7698
+ * produced no Jest output so a broken task surfaces as a run failure rather
7699
+ * than a silently-missing package.
7700
+ */
7701
+ function parseStealingEnvelope(result) {
7702
+ const jestOutput = result.outputs[0];
7703
+ if (jestOutput === void 0) throw new Error(`No test results in output. Got: ${JSON.stringify(result.outputs)}`);
7704
+ return {
7705
+ entries: parseEnvelope(jestOutput),
7706
+ gameOutput: result.outputs[1]
7707
+ };
7708
+ }
7276
7709
  function aggregateEntriesByKey(taskEnvelopes) {
7277
7710
  const entryByKey = /* @__PURE__ */ new Map();
7278
7711
  for (const { entries, gameOutput } of taskEnvelopes) for (const entry of entries) if (entry.pkg !== void 0) {
@@ -7285,23 +7718,486 @@ function aggregateEntriesByKey(taskEnvelopes) {
7285
7718
  return entryByKey;
7286
7719
  }
7287
7720
  //#endregion
7288
- //#region src/backends/studio.ts
7289
- const DEFAULT_STUDIO_TIMEOUT = 3e5;
7721
+ //#region src/backends/plugin-payload.ts
7290
7722
  /**
7291
- * Plugin/CLI protocol version. Must match `PROTOCOL_VERSION` in
7292
- * `plugin/src/init.server.luau`. Increment when the runtime contract
7293
- * changes (e.g. runtime-injection payload shape). Stale plugins return
7294
- * `version_mismatch` explicitly OR (legacy plugins predating v2) return a
7295
- * `results` envelope that fails schema validation because the
7296
- * `protocolVersion` echo is missing — either way the CLI surfaces a clean
7297
- * upgrade error instead of running with stale semantics.
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.
7298
7728
  */
7299
- const STUDIO_PROTOCOL_VERSION = 2;
7300
- const pluginResultSchema = type({
7301
- "gameOutput?": "string",
7302
- "jestOutput": "string",
7303
- "protocolVersion": "number == 2",
7304
- "request_id": "string",
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
8184
+ //#region src/backends/studio.ts
8185
+ const DEFAULT_STUDIO_TIMEOUT = 3e5;
8186
+ /**
8187
+ * Plugin/CLI protocol version. Must match `PROTOCOL_VERSION` in
8188
+ * `plugin/src/init.server.luau`. Increment when the runtime contract
8189
+ * changes — v3 added the run-mode workspace dispatch + version echo. Stale
8190
+ * plugins return `version_mismatch` explicitly OR (older plugins) return a
8191
+ * `results` envelope that fails schema validation because the
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.
8194
+ */
8195
+ const STUDIO_PROTOCOL_VERSION = 3;
8196
+ const pluginResultSchema = type({
8197
+ "gameOutput?": "string",
8198
+ "jestOutput": "string",
8199
+ "protocolVersion": "number == 3",
8200
+ "request_id": "string",
7305
8201
  "type": "'results'"
7306
8202
  });
7307
8203
  const pluginVersionMismatchSchema = type({
@@ -7340,14 +8236,9 @@ var StudioBackend = class {
7340
8236
  }
7341
8237
  async executeViaPlugin(wss, jobs, existingSocket) {
7342
8238
  const requestId = randomUUID();
7343
- const configs = jobs.map((job) => buildJestArgv(job));
7344
- const runtimeStubMounts = jobs.map((job) => job.runtimeInjectionPaths ?? []);
8239
+ const requestMessage = buildRunTestsMessage(jobs, requestId);
7345
8240
  const executionStart = Date.now();
7346
- const message = await this.waitForResult(wss, {
7347
- configs,
7348
- requestId,
7349
- runtimeStubMounts
7350
- }, existingSocket);
8241
+ const message = await this.waitForResult(wss, requestMessage, requestId, existingSocket);
7351
8242
  const executionMs = Date.now() - executionStart;
7352
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.`);
7353
8244
  const entries = parseEnvelope(message.jestOutput);
@@ -7362,20 +8253,13 @@ var StudioBackend = class {
7362
8253
  timing: { executionMs }
7363
8254
  };
7364
8255
  }
7365
- async waitForResult(wss, request, existingSocket) {
7366
- const { configs, requestId, runtimeStubMounts } = request;
8256
+ async waitForResult(wss, requestMessage, requestId, existingSocket) {
7367
8257
  return new Promise((resolve, reject) => {
7368
8258
  const timer = setTimeout(() => {
7369
8259
  reject(/* @__PURE__ */ new Error("Timed out waiting for Studio plugin connection"));
7370
8260
  }, this.timeout);
7371
8261
  function attachSocket(ws) {
7372
- ws.send(JSON.stringify({
7373
- action: "run_tests",
7374
- config: { configs },
7375
- protocolVersion: STUDIO_PROTOCOL_VERSION,
7376
- request_id: requestId,
7377
- runtimeStubMounts
7378
- }));
8262
+ ws.send(String(JSON.stringify(requestMessage)));
7379
8263
  ws.on("message", (data) => {
7380
8264
  const message = pluginMessageSchema(JSON.parse(data.toString()));
7381
8265
  if (message instanceof type.errors) {
@@ -7411,6 +8295,31 @@ var StudioBackend = class {
7411
8295
  function createStudioBackend(options) {
7412
8296
  return new StudioBackend(options);
7413
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
+ }
7414
8323
  //#endregion
7415
8324
  //#region src/backends/auto.ts
7416
8325
  const ENV_PREFIX = "JEST_";
@@ -7470,6 +8379,14 @@ async function resolveBackend(cli, config, probe = probeStudioPlugin) {
7470
8379
  port: config.port,
7471
8380
  timeout: config.timeout
7472
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
+ }
7473
8390
  if (config.backend === "open-cloud") return createOpenCloudBackend(buildCredentials(cli, config));
7474
8391
  const credentials = tryBuildCredentials(cli, config);
7475
8392
  const probeResult = await probe(config.port, 500);
@@ -7493,6 +8410,9 @@ async function resolveBackend(cli, config, probe = probeStudioPlugin) {
7493
8410
  if (hasUserOverrides(cli)) buildCredentials(cli, config);
7494
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).");
7495
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
+ }
7496
8416
  function hasUserOverrides(cli) {
7497
8417
  return cli.apiKey !== void 0 || cli.universeId !== void 0 || cli.placeId !== void 0;
7498
8418
  }
@@ -7769,6 +8689,15 @@ const PROJECT_ONLY_KEYS = /* @__PURE__ */ new Set([
7769
8689
  "outDir",
7770
8690
  "root"
7771
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
+ }
7772
8701
  function resolveProjectConfig(project, rootConfig, rojoTree, classify) {
7773
8702
  const rootPrefixedInclude = applyProjectRoot(project.include, project.root);
7774
8703
  const rootPrefixedExclude = applyProjectRoot(project.exclude ?? [], project.root);
@@ -7833,15 +8762,6 @@ function mergeProjectConfig(rootConfig, project) {
7833
8762
  for (const [key, value] of Object.entries(project)) if (!PROJECT_ONLY_KEYS.has(key) && key !== "typecheck" && value !== void 0) merged[key] = value;
7834
8763
  return merged;
7835
8764
  }
7836
- function dedupeMounts(mounts) {
7837
- const seen = /* @__PURE__ */ new Set();
7838
- const result = [];
7839
- for (const mount of mounts) if (!seen.has(mount.dataModelPath)) {
7840
- seen.add(mount.dataModelPath);
7841
- result.push(mount);
7842
- }
7843
- return result;
7844
- }
7845
8765
  function joinProjectRoot(relativePath, projectRoot) {
7846
8766
  return projectRoot !== void 0 ? path$1.posix.join(projectRoot, relativePath) : relativePath;
7847
8767
  }
@@ -7951,6 +8871,14 @@ const LUAU_STRING_ARRAY_KEYS = ["setupFiles", "setupFilesAfterEnv"];
7951
8871
  //#endregion
7952
8872
  //#region src/config/filter-projects-by-files.ts
7953
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
+ }
7954
8882
  /**
7955
8883
  * Pair each project with the subset of cli files whose include roots own them.
7956
8884
  * Used so a positional file arg can auto-pick its owning project without
@@ -8009,14 +8937,6 @@ function buildNoMatchMessage(files, roots) {
8009
8937
  const uniqueRoots = [...new Set(roots)];
8010
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)"}`;
8011
8939
  }
8012
- function collectProjectRoots(project, posixRootDirectory) {
8013
- const roots = [];
8014
- for (const pattern of project.include) try {
8015
- const { root } = extractStaticRoot(normalizeWindowsPath(pattern));
8016
- roots.push(resolveAgainst(posixRootDirectory, root));
8017
- } catch {}
8018
- return roots;
8019
- }
8020
8940
  //#endregion
8021
8941
  //#region src/config/narrow-by-files.ts
8022
8942
  const REGEX_METACHARACTERS = /[.*+?^${}()|[\]\\]/g;
@@ -8089,34 +9009,6 @@ function toBasenamePattern(file) {
8089
9009
  return (TS_SOURCE_EXTENSION.test(basename) ? indexStemToInit(stripped) : stripped).replace(REGEX_METACHARACTERS, "\\$&");
8090
9010
  }
8091
9011
  //#endregion
8092
- //#region src/config/resolve-typecheck-config.ts
8093
- /**
8094
- * Merges the root `test.typecheck`, per-project `test.typecheck`, and CLI
8095
- * typecheck flags into one resolved typecheck config. Precedence per field is
8096
- * CLI > project > root > default. `only` implies `enabled` (mirroring the CLI's
8097
- * `--typecheckOnly`). `include` is never derived here — the caller falls back to
8098
- * `deriveTypecheckInclude(runtimeInclude)` when it is unset.
8099
- */
8100
- function resolveTypecheckConfig(layers) {
8101
- const { cli = {}, project = {}, root = {} } = layers;
8102
- const only = cli.only ?? project.only ?? root.only ?? false;
8103
- const resolved = {
8104
- enabled: (cli.enabled ?? project.enabled ?? root.enabled ?? false) || only,
8105
- only
8106
- };
8107
- const include = project.include ?? root.include;
8108
- if (include !== void 0) resolved.include = include;
8109
- const exclude = project.exclude ?? root.exclude;
8110
- if (exclude !== void 0) resolved.exclude = exclude;
8111
- const ignoreSourceErrors = project.ignoreSourceErrors ?? root.ignoreSourceErrors;
8112
- if (ignoreSourceErrors !== void 0) resolved.ignoreSourceErrors = ignoreSourceErrors;
8113
- const spawnTimeout = project.spawnTimeout ?? root.spawnTimeout;
8114
- if (spawnTimeout !== void 0) resolved.spawnTimeout = spawnTimeout;
8115
- const tsconfig = cli.tsconfig ?? project.tsconfig ?? root.tsconfig;
8116
- if (tsconfig !== void 0) resolved.tsconfig = tsconfig;
8117
- return resolved;
8118
- }
8119
- //#endregion
8120
9012
  //#region src/config/stubs.ts
8121
9013
  const HEADER = "-- Auto-generated by jest-roblox (do not edit)\n";
8122
9014
  const STUB_FILENAME = "jest.config.luau";
@@ -8578,8 +9470,19 @@ function parseTscErrorLine(line) {
8578
9470
  }
8579
9471
  //#endregion
8580
9472
  //#region src/typecheck/runner.ts
8581
- /** Milliseconds the tsgo spawn may run before it is killed and the pass fails. */
9473
+ /**
9474
+ * Milliseconds to wait for tsgo to launch (the child `spawn` event) before the
9475
+ * pass fails. Bounds only process startup, so a slow *compile* never trips it;
9476
+ * the compile itself is governed by {@link TypecheckOptions.timeout}.
9477
+ */
8582
9478
  const DEFAULT_SPAWN_TIMEOUT = 1e4;
9479
+ /**
9480
+ * Milliseconds the tsgo compile may run before it is killed and the pass
9481
+ * throws. Mirrors the run-level `timeout` default (`config.timeout`); callers
9482
+ * pass the resolved run timeout so a wedged compile dies on the same deadline
9483
+ * as the Roblox run instead of a tight typecheck-only number.
9484
+ */
9485
+ const DEFAULT_RUN_TIMEOUT = 3e5;
8583
9486
  function createLocationsIndexMap(source) {
8584
9487
  const map = /* @__PURE__ */ new Map();
8585
9488
  let index = 0;
@@ -8729,27 +9632,53 @@ async function spawnTsgo(options) {
8729
9632
  }
8730
9633
  const tsgoScript = resolveTsgoScript();
8731
9634
  const spawnTimeout = options.spawnTimeout ?? DEFAULT_SPAWN_TIMEOUT;
9635
+ const runTimeout = options.timeout ?? DEFAULT_RUN_TIMEOUT;
8732
9636
  return new Promise((resolve, reject) => {
8733
- execFile(process.execPath, [tsgoScript, ...args], {
9637
+ let launchTimer;
9638
+ const state = { settled: false };
9639
+ function finish(action) {
9640
+ if (state.settled) return;
9641
+ state.settled = true;
9642
+ if (launchTimer !== void 0) clearTimeout(launchTimer);
9643
+ action();
9644
+ }
9645
+ const child = execFile(process.execPath, [tsgoScript, ...args], {
8734
9646
  cwd: options.rootDir,
8735
9647
  encoding: "utf-8",
8736
- timeout: spawnTimeout,
9648
+ timeout: runTimeout,
8737
9649
  windowsHide: true
8738
9650
  }, (error, stdout, stderr) => {
8739
9651
  if (error === null) {
8740
- resolve(stdout);
9652
+ finish(() => {
9653
+ resolve(stdout);
9654
+ });
8741
9655
  return;
8742
9656
  }
8743
9657
  if (error.killed === true) {
8744
- reject(/* @__PURE__ */ new Error(`tsgo typecheck timed out after ${String(spawnTimeout)}ms (spawnTimeout)`));
9658
+ finish(() => {
9659
+ reject(/* @__PURE__ */ new Error(`tsgo typecheck timed out after ${String(runTimeout)}ms (timeout)`));
9660
+ });
8745
9661
  return;
8746
9662
  }
8747
9663
  if (typeof error.code === "number") {
8748
- resolve(stdout !== "" ? stdout : stderr);
9664
+ finish(() => {
9665
+ resolve(stdout !== "" ? stdout : stderr);
9666
+ });
8749
9667
  return;
8750
9668
  }
8751
- reject(new Error(error.message, { cause: error }));
9669
+ finish(() => {
9670
+ reject(new Error(error.message, { cause: error }));
9671
+ });
9672
+ });
9673
+ child.once("spawn", () => {
9674
+ clearTimeout(launchTimer);
8752
9675
  });
9676
+ if (!state.settled) launchTimer = setTimeout(() => {
9677
+ child.kill();
9678
+ finish(() => {
9679
+ reject(/* @__PURE__ */ new Error(`tsgo spawn timed out after ${String(spawnTimeout)}ms (spawnTimeout)`));
9680
+ });
9681
+ }, spawnTimeout);
8753
9682
  });
8754
9683
  }
8755
9684
  //#endregion
@@ -8941,24 +9870,40 @@ function loadRojoTree(config) {
8941
9870
  if (validated instanceof type.errors) throw new Error(`Invalid Rojo project: ${validated.summary}`);
8942
9871
  return resolveNestedProjects(validated.tree, path$1.dirname(rojoPath));
8943
9872
  }
8944
- async function runMultiProject(options) {
8945
- const { cli, config: rootConfig, rawProjects } = options;
8946
- 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) {
8947
9882
  const cliTypecheck = {
8948
9883
  enabled: cli.typecheck,
8949
9884
  only: cli.typecheckOnly,
8950
9885
  tsconfig: cli.typecheckTsconfig
8951
9886
  };
8952
- const rojoTree = timing.profile("loadRojoTree", () => loadRojoTree(rootConfig));
8953
- const allProjects = await timing.profileAsync("resolveAllProjects", async () => {
8954
- return resolveAllProjects(rawProjects, rootConfig, rojoTree, rootConfig.rootDir);
8955
- });
8956
9887
  timing.profile("resolveSetupFilePaths", () => {
8957
9888
  resolveAllSetupFilePaths(allProjects.map((project) => project.config));
8958
9889
  });
8959
9890
  const { filesByProject, projects } = timing.profile("selectProjects", () => {
8960
9891
  return selectProjects(allProjects, cli.project, cli.files, rootConfig.rootDir);
8961
9892
  });
9893
+ if (projects.every((project) => {
9894
+ return resolveTypecheckConfig({
9895
+ cli: cliTypecheck,
9896
+ project: project.typecheck,
9897
+ root: rootConfig.typecheck
9898
+ }).only;
9899
+ })) return runMultiTypecheckOnly({
9900
+ cliFiles: cli.files,
9901
+ cliTypecheck,
9902
+ filesByProject,
9903
+ projects,
9904
+ rootConfig,
9905
+ timing
9906
+ });
8962
9907
  const cacheRoot = path$1.resolve(rootConfig.rootDir, ".jest-roblox", "cache");
8963
9908
  const cleaned = timing.profile("cleanLeftoverStubs", () => {
8964
9909
  return cleanLeftoverStubs(projects, rootConfig.rootDir);
@@ -9004,6 +9949,7 @@ async function runMultiProject(options) {
9004
9949
  ignoreSourceErrors: rootTypecheck.ignoreSourceErrors,
9005
9950
  rootDir: group.cwd,
9006
9951
  spawnTimeout: rootTypecheck.spawnTimeout,
9952
+ timeout: rootConfig.timeout,
9007
9953
  tsconfig: group.tsconfig
9008
9954
  });
9009
9955
  })]);
@@ -9030,6 +9976,14 @@ async function runMultiProject(options) {
9030
9976
  return {
9031
9977
  collectCoverageFrom: rootConfig.collectCoverage ? rootConfig.collectCoverageFrom ?? deriveCoverageFromIncludes(projects) : rootConfig.collectCoverageFrom,
9032
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
+ }),
9033
9987
  merged: mergeForMultiResult(projectResults),
9034
9988
  mode: "multi",
9035
9989
  preCoverageMs,
@@ -9037,9 +9991,27 @@ async function runMultiProject(options) {
9037
9991
  typecheckResult
9038
9992
  };
9039
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
+ }
9040
10012
  function buildOpenCloudPlace(rootConfig, projects, cacheRoot) {
9041
10013
  const userRojoProjectPath = path$1.resolve(rootConfig.rootDir, rootConfig.rojoProject ?? DEFAULT_ROJO_PROJECT);
9042
- const placeFilePath = path$1.resolve(rootConfig.rootDir, rootConfig.placeFile);
10014
+ const placeFilePath = resolvePlaceFilePath(rootConfig);
9043
10015
  buildPlace({
9044
10016
  packages: [{
9045
10017
  name: "multi-project",
@@ -9106,6 +10078,56 @@ function collectPendingJobs(arguments_) {
9106
10078
  typeTestEntries
9107
10079
  };
9108
10080
  }
10081
+ async function runMultiTypecheckOnly(arguments_) {
10082
+ const { cliFiles, cliTypecheck, filesByProject, projects, rootConfig, timing } = arguments_;
10083
+ const { typeTestEntries } = timing.profile("collectPendingJobs", () => {
10084
+ return collectPendingJobs({
10085
+ cliFiles,
10086
+ cliTypecheck,
10087
+ effectivePlaceFile: rootConfig.placeFile,
10088
+ filesByProject,
10089
+ projects,
10090
+ rootConfig
10091
+ });
10092
+ });
10093
+ if (typeTestEntries.length === 0) {
10094
+ if (rootConfig.passWithNoTests) return {
10095
+ merged: {},
10096
+ mode: "multi",
10097
+ preCoverageMs: 0,
10098
+ projectResults: []
10099
+ };
10100
+ return {
10101
+ merged: {},
10102
+ mode: "multi",
10103
+ preCoverageMs: 0,
10104
+ projectResults: [],
10105
+ validationExitCode: 2,
10106
+ validationMessage: "No test files found in any project\n"
10107
+ };
10108
+ }
10109
+ const rootTypecheck = resolveTypecheckConfig({
10110
+ cli: cliTypecheck,
10111
+ root: rootConfig.typecheck
10112
+ });
10113
+ const typecheckPass = await runTypecheckPass(typeTestEntries, async (group) => {
10114
+ return runTypecheck({
10115
+ files: group.files,
10116
+ ignoreSourceErrors: rootTypecheck.ignoreSourceErrors,
10117
+ rootDir: group.cwd,
10118
+ spawnTimeout: rootTypecheck.spawnTimeout,
10119
+ tsconfig: group.tsconfig
10120
+ });
10121
+ });
10122
+ timing.record("runTypecheck", typecheckPass.elapsedMs);
10123
+ return {
10124
+ merged: {},
10125
+ mode: "multi",
10126
+ preCoverageMs: 0,
10127
+ projectResults: [],
10128
+ typecheckResult: typecheckPass.result
10129
+ };
10130
+ }
9109
10131
  async function runJobs(backend, pendingJobs, parallel, timing) {
9110
10132
  if (pendingJobs.length === 0) {
9111
10133
  await backend.close?.();
@@ -9149,10 +10171,11 @@ function prepareMultiProjectCoverage(rootConfig, projects, cacheRoot) {
9149
10171
  effectiveConfig: rootConfig,
9150
10172
  preCoverageMs: 0
9151
10173
  };
10174
+ const bakeStubs = rootConfig.backend !== "studio-cli";
9152
10175
  const start = Date.now();
9153
- const coverage = prepareCoverage(rootConfig, (shadowDirectory) => {
10176
+ const coverage = prepareCoverage(rootConfig, bakeStubs ? (shadowDirectory) => {
9154
10177
  return syncStubsToShadowDirectory(projects, cacheRoot, shadowDirectory);
9155
- });
10178
+ } : void 0);
9156
10179
  return {
9157
10180
  coverageArtifacts: toCoverageArtifacts(coverage, toBuildManifestProjects(projects)),
9158
10181
  effectiveConfig: {
@@ -9200,6 +10223,62 @@ function selectProjects(allProjects, projectNames, cliFiles, rootDirectory) {
9200
10223
  return { projects: allProjects };
9201
10224
  }
9202
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
9203
10282
  //#region src/run/single.ts
9204
10283
  const VERSION$1 = version;
9205
10284
  async function runSingleProject(options) {
@@ -9274,6 +10353,7 @@ async function runSingleProject(options) {
9274
10353
  ignoreSourceErrors: typecheck.ignoreSourceErrors,
9275
10354
  rootDir: effectiveConfig.rootDir,
9276
10355
  spawnTimeout: typecheck.spawnTimeout,
10356
+ timeout: effectiveConfig.timeout,
9277
10357
  tsconfig: typecheck.tsconfig
9278
10358
  });
9279
10359
  } finally {
@@ -9289,8 +10369,10 @@ async function runSingleProject(options) {
9289
10369
  }) : Promise.resolve(void 0);
9290
10370
  const [typecheckResult, runtimeResult] = await Promise.all([typecheckPass, runtimePass]);
9291
10371
  if (typeTestFiles.length > 0) timing.record("runTypecheck", typecheckMs);
10372
+ const coverageDisplayFilter = filterActive ? sourceTwinFilter(runtimeFiles, baseConfig.rootDir) : void 0;
9292
10373
  return {
9293
10374
  coverageArtifacts,
10375
+ coverageDisplayFilter,
9294
10376
  mode: "single",
9295
10377
  preCoverageMs,
9296
10378
  runtimeResult,
@@ -9387,6 +10469,11 @@ function buildWorkspaceRunOptions(input) {
9387
10469
  readCli: (entry) => entry.universeId,
9388
10470
  readConfig: (entry) => entry.universeId
9389
10471
  });
10472
+ const studioPath = resolveOptionalField(cli, perPackageConfigs, {
10473
+ name: "studioPath",
10474
+ readCli: (entry) => entry.studioPath,
10475
+ readConfig: (entry) => entry.studioPath
10476
+ });
9390
10477
  const formatters = resolveFormatters(cli, perPackageConfigs);
9391
10478
  const rawGameOutput = resolveOptionalField(cli, perPackageConfigs, {
9392
10479
  name: "gameOutput",
@@ -9429,6 +10516,7 @@ function buildWorkspaceRunOptions(input) {
9429
10516
  if (outputFile !== void 0) runOptions.outputFile = outputFile;
9430
10517
  if (parallel !== void 0) runOptions.parallel = parallel;
9431
10518
  if (placeId !== void 0) runOptions.placeId = placeId;
10519
+ if (studioPath !== void 0) runOptions.studioPath = studioPath;
9432
10520
  if (universeId !== void 0) runOptions.universeId = universeId;
9433
10521
  return runOptions;
9434
10522
  }
@@ -9956,7 +11044,7 @@ var StreamingAggregator = class {
9956
11044
  };
9957
11045
  //#endregion
9958
11046
  //#region src/materializer.bundled.luau
9959
- 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";
9960
11048
  //#endregion
9961
11049
  //#region src/staging/test-script-staged.ts
9962
11050
  function generateMaterializerScript(inputs, options = {}) {
@@ -10218,7 +11306,7 @@ async function runWorkspaceProfiled(options, timing) {
10218
11306
  });
10219
11307
  }), runWorkspaceTypecheckPass(typeTestEntries, typecheckByDirectory)]);
10220
11308
  if (runOptions.workspaceOutputFile) writePerPackageOutputFiles(workspaceRoot, buildPerPackageResults(pending, results, typecheckPass.byPackage, typeTestProjects));
10221
- if (runOptions.outputFile !== void 0) await writeWorkspaceOutputFile(runOptions.outputFile, mergeProjectResults(results).result, typecheckPass.outcome.result);
11309
+ await writeResultFile(runOptions.outputFile, typecheckPass.outcome.result, runOptions.outputFile !== void 0 ? mergeProjectResults(results).result : void 0);
10222
11310
  emitWorkspaceGameOutput({
10223
11311
  pending,
10224
11312
  results,
@@ -10498,7 +11586,8 @@ function collectPendingEntries(contexts, cli) {
10498
11586
  typecheckByDirectory.set(packageDirectory, {
10499
11587
  ignoreSourceErrors: packageTypecheck.ignoreSourceErrors,
10500
11588
  pkg: ctx.info.name,
10501
- spawnTimeout: packageTypecheck.spawnTimeout
11589
+ spawnTimeout: packageTypecheck.spawnTimeout,
11590
+ timeout: ctx.pkgConfig.timeout
10502
11591
  });
10503
11592
  }
10504
11593
  }
@@ -10531,6 +11620,7 @@ async function runWorkspaceTypecheckPass(entries, typecheckByDirectory) {
10531
11620
  ignoreSourceErrors: policy.ignoreSourceErrors,
10532
11621
  rootDir: group.cwd,
10533
11622
  spawnTimeout: policy.spawnTimeout,
11623
+ timeout: policy.timeout,
10534
11624
  ...group.tsconfig !== void 0 ? { tsconfig: group.tsconfig } : {}
10535
11625
  }), policy.pkg);
10536
11626
  byPackage.set(policy.pkg, mergeResults$1(stamped, byPackage.get(policy.pkg)));
@@ -10545,12 +11635,9 @@ function attachTypecheck(results, pass, timing) {
10545
11635
  ...pass.result !== void 0 ? { typecheckResult: pass.result } : {}
10546
11636
  };
10547
11637
  }
10548
- async function writeWorkspaceOutputFile(outputFile, runtime, typecheck) {
10549
- await writeJsonFile$1(mergeResults$1(typecheck, runtime), outputFile);
10550
- }
10551
11638
  async function runTypecheckOnlyWorkspace(input) {
10552
11639
  const typecheckPass = await runWorkspaceTypecheckPass(input.typeTestEntries, input.typecheckByDirectory);
10553
- if (input.runOptions.outputFile !== void 0) await writeWorkspaceOutputFile(input.runOptions.outputFile, void 0, typecheckPass.outcome.result);
11640
+ await writeResultFile(input.runOptions.outputFile, typecheckPass.outcome.result, void 0);
10554
11641
  if (input.runOptions.workspaceOutputFile) writePerPackageOutputFiles(input.workspaceRoot, buildPerPackageResults([], [], typecheckPass.byPackage, input.typeTestProjects));
10555
11642
  return attachTypecheck([], typecheckPass.outcome, input.timing);
10556
11643
  }
@@ -10695,6 +11782,13 @@ function discoverWorkspaceRoot(cwd) {
10695
11782
  //#endregion
10696
11783
  //#region src/workspace/package-resolver.ts
10697
11784
  const JEST_CONFIG_MARKER$1 = /^jest\.config\.[^.]+$/;
11785
+ function readPackageJsonName(packageJsonPath) {
11786
+ if (!fs$1.existsSync(packageJsonPath)) return;
11787
+ const raw = parsePackageJson(packageJsonPath);
11788
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return;
11789
+ const nameValue = Reflect.get(raw, "name");
11790
+ return typeof nameValue === "string" ? nameValue : void 0;
11791
+ }
10698
11792
  /**
10699
11793
  * Enumerate workspace packages. With `patterns` (from `workspace.packages`),
10700
11794
  * resolve directories by globbing for a `jest.config.*` — works in any repo,
@@ -10719,13 +11813,6 @@ function parsePackageJson(packageJsonPath) {
10719
11813
  throw new Error(`Failed to parse ${packageJsonPath}.`, { cause: err });
10720
11814
  }
10721
11815
  }
10722
- function readPackageJsonName(packageJsonPath) {
10723
- if (!fs$1.existsSync(packageJsonPath)) return;
10724
- const raw = parsePackageJson(packageJsonPath);
10725
- if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return;
10726
- const nameValue = Reflect.get(raw, "name");
10727
- return typeof nameValue === "string" ? nameValue : void 0;
10728
- }
10729
11816
  function listPnpmPackages(workspaceRoot) {
10730
11817
  const yamlPath = path$1.join(workspaceRoot, "pnpm-workspace.yaml");
10731
11818
  if (!fs$1.existsSync(yamlPath)) throw new Error("Workspace mode requires either a `workspace.packages` glob list in your jest config or a pnpm-workspace.yaml at the workspace root. Use `workspace.packages` (with `--workspace-root` to run from outside a package) for Luau-only, npm, or yarn repos.");
@@ -10807,15 +11894,25 @@ function getAffectedPackages(workspaceRoot, ref) {
10807
11894
  "ls",
10808
11895
  `--filter=...[${ref}]`,
10809
11896
  "--output=json"
10810
- ], workspaceRoot)).filter((item) => hasJestConfig(path$1.join(workspaceRoot, item.relativePath))).map((item) => item.name);
11897
+ ], workspaceRoot)).filter((item) => hasJestConfig(path$1.join(workspaceRoot, item.relativePath))).map((item) => {
11898
+ return {
11899
+ name: item.name,
11900
+ packageDirectory: path$1.join(workspaceRoot, item.relativePath)
11901
+ };
11902
+ });
10811
11903
  if (fs$1.existsSync(path$1.join(workspaceRoot, "nx.json"))) return parseNxOutput(runTool("nx", [
10812
11904
  "show",
10813
11905
  "projects",
10814
11906
  "--affected",
10815
11907
  `--base=${ref}`,
10816
11908
  "--json"
10817
- ], workspaceRoot)).filter((name) => {
10818
- return hasJestConfig(path$1.join(workspaceRoot, nxProjectRoot(workspaceRoot, name)));
11909
+ ], workspaceRoot)).flatMap((nxName) => {
11910
+ const packageDirectory = path$1.join(workspaceRoot, nxProjectRoot(workspaceRoot, nxName));
11911
+ if (!hasJestConfig(packageDirectory)) return [];
11912
+ return [{
11913
+ name: readPackageJsonName(path$1.join(packageDirectory, "package.json")) ?? nxName,
11914
+ packageDirectory
11915
+ }];
10819
11916
  });
10820
11917
  throw new Error("--affected-since requires turbo or nx at the workspace root. Use --packages to specify packages explicitly.");
10821
11918
  }
@@ -10922,18 +12019,30 @@ function validateBasicWorkspaceFlags(cli) {
10922
12019
  /**
10923
12020
  * Checks the resolved WorkspaceRunOptions for invariants that depend on the
10924
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.
10925
12027
  */
10926
12028
  function assertWorkspaceRunOptions(runOptions) {
10927
- if (runOptions.backend === "studio") return {
12029
+ const { backend, parallel } = runOptions;
12030
+ if (backend === "studio-cli" && isShardedParallel(parallel)) return {
10928
12031
  exitCode: 2,
10929
- 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",
10930
12033
  ok: false
10931
12034
  };
10932
12035
  return { ok: true };
10933
12036
  }
10934
- function resolveWorkspacePackageNames(cli, workspaceRoot) {
12037
+ /**
12038
+ * Resolve the affected/requested packages to full `PackageInfo`. The
12039
+ * `--affected-since` branch already carries directory + `package.json#name`
12040
+ * from turbo/nx, so it skips the `resolvePackage` round-trip; the `--packages`
12041
+ * branch resolves each comma-separated name against the workspace.
12042
+ */
12043
+ function resolveWorkspacePackages(cli, workspaceRoot, patterns) {
10935
12044
  if (cli.affectedSince !== void 0) return getAffectedPackages(workspaceRoot, cli.affectedSince);
10936
- return cli.packages.split(",").map((name) => name.trim()).filter((name) => name.length > 0);
12045
+ return cli.packages.split(",").map((name) => name.trim()).filter((name) => name.length > 0).map((name) => resolvePackage(workspaceRoot, name, patterns));
10937
12046
  }
10938
12047
  function buildWorkspaceCredentials(cli, runOptions) {
10939
12048
  return resolveCredentials({
@@ -11047,6 +12156,11 @@ async function runWorkspaceMode(cli, workspace, timing) {
11047
12156
  }
11048
12157
  function resolveWorkspaceBackend(cli, runOptions) {
11049
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 }) };
11050
12164
  try {
11051
12165
  const credentials = buildWorkspaceCredentials(cli, runOptions);
11052
12166
  const backend = createOpenCloudBackend(credentials);
@@ -11131,10 +12245,10 @@ function resolveEnumerationRoot(workspace) {
11131
12245
  function resolvePackages(cli, workspace) {
11132
12246
  try {
11133
12247
  const { patterns, workspaceRoot } = resolveEnumerationRoot(workspace);
11134
- const packageNames = resolveWorkspacePackageNames(cli, workspaceRoot);
11135
- if (packageNames.length === 0) return { noAffected: true };
12248
+ const packageInfos = resolveWorkspacePackages(cli, workspaceRoot, patterns);
12249
+ if (packageInfos.length === 0) return { noAffected: true };
11136
12250
  return {
11137
- packageInfos: packageNames.map((name) => resolvePackage(workspaceRoot, name, patterns)),
12251
+ packageInfos,
11138
12252
  workspaceRoot
11139
12253
  };
11140
12254
  } catch (err) {
@@ -11189,11 +12303,19 @@ async function runSingleOrMulti(cli, merged, timing) {
11189
12303
  rawProjects,
11190
12304
  timing
11191
12305
  });
11192
- 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({
11193
12314
  cli,
11194
12315
  config: merged,
11195
12316
  timing
11196
12317
  });
12318
+ return runResolvedProjects([buildImplicitProject(merged, timing.profile("loadRojoTree", () => loadRojoTree(merged)))], merged, cli, timing);
11197
12319
  }
11198
12320
  async function runJestRoblox(cli, config) {
11199
12321
  const timing = createTimingCollector();
@@ -11207,4 +12329,4 @@ async function runJestRoblox(cli, config) {
11207
12329
  }
11208
12330
  }
11209
12331
  //#endregion
11210
- 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 };