@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.
- package/README.md +80 -14
- package/bin/jest-roblox.js +4 -2
- package/dist/cli.d.mts +1 -1
- package/dist/cli.mjs +11 -2
- package/dist/index.d.mts +207 -9
- package/dist/index.mjs +2 -2
- package/dist/{run-C4GuftZH.mjs → run-Cirdb_CB.mjs} +1374 -252
- package/dist/{schema-BCTnsaiC.d.mts → schema-i-pZhLCV.d.mts} +240 -8
- package/loaders/luau-raw.d.mts +4 -4
- package/loaders/luau-raw.mjs +5 -5
- package/package.json +3 -3
- package/plugin/JestRobloxRunner.rbxm +0 -0
- package/plugin/src/init.server.luau +11 -4
- package/plugin/src/test-in-run-mode.server.luau +73 -20
|
@@ -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.
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
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
|
|
1234
|
-
|
|
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
|
|
1259
|
-
const
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
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
|
|
1297
|
-
|
|
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
|
|
2083
|
-
|
|
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
|
-
|
|
4958
|
+
const coverageData = runtimeResult?.coverageData;
|
|
4959
|
+
const coveragePassed = emitResultsAndCoverage({
|
|
4827
4960
|
config,
|
|
4828
|
-
|
|
4829
|
-
|
|
4830
|
-
|
|
4831
|
-
|
|
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
|
-
|
|
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
|
-
|
|
4920
|
-
|
|
4921
|
-
|
|
4922
|
-
|
|
4923
|
-
|
|
4924
|
-
|
|
4925
|
-
|
|
4926
|
-
|
|
4927
|
-
|
|
4928
|
-
|
|
4929
|
-
|
|
4930
|
-
|
|
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
|
-
|
|
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
|
-
|
|
5174
|
+
const agentMode = usesAgentFormatter(config.formatters, config.verbose);
|
|
5175
|
+
if (!config.silent) printCoverageHeader(agentMode);
|
|
5018
5176
|
generateReports({
|
|
5019
|
-
agentMode
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
6092
|
-
|
|
6093
|
-
|
|
6094
|
-
|
|
6095
|
-
|
|
6096
|
-
|
|
6097
|
-
|
|
6098
|
-
|
|
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
|
|
6102
|
-
return b.
|
|
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.
|
|
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 =
|
|
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
|
|
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
|
-
|
|
7172
|
-
if (
|
|
7173
|
-
const
|
|
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
|
-
|
|
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/
|
|
7289
|
-
const DEFAULT_STUDIO_TIMEOUT = 3e5;
|
|
7721
|
+
//#region src/backends/plugin-payload.ts
|
|
7290
7722
|
/**
|
|
7291
|
-
*
|
|
7292
|
-
*
|
|
7293
|
-
*
|
|
7294
|
-
* `
|
|
7295
|
-
*
|
|
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
|
-
|
|
7300
|
-
|
|
7301
|
-
|
|
7302
|
-
|
|
7303
|
-
|
|
7304
|
-
|
|
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
|
|
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,
|
|
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
|
-
/**
|
|
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
|
-
|
|
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:
|
|
9648
|
+
timeout: runTimeout,
|
|
8737
9649
|
windowsHide: true
|
|
8738
9650
|
}, (error, stdout, stderr) => {
|
|
8739
9651
|
if (error === null) {
|
|
8740
|
-
|
|
9652
|
+
finish(() => {
|
|
9653
|
+
resolve(stdout);
|
|
9654
|
+
});
|
|
8741
9655
|
return;
|
|
8742
9656
|
}
|
|
8743
9657
|
if (error.killed === true) {
|
|
8744
|
-
|
|
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
|
-
|
|
9664
|
+
finish(() => {
|
|
9665
|
+
resolve(stdout !== "" ? stdout : stderr);
|
|
9666
|
+
});
|
|
8749
9667
|
return;
|
|
8750
9668
|
}
|
|
8751
|
-
|
|
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
|
-
|
|
8945
|
-
|
|
8946
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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) =>
|
|
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)).
|
|
10818
|
-
|
|
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
|
-
|
|
12029
|
+
const { backend, parallel } = runOptions;
|
|
12030
|
+
if (backend === "studio-cli" && isShardedParallel(parallel)) return {
|
|
10928
12031
|
exitCode: 2,
|
|
10929
|
-
message: "Error:
|
|
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
|
-
|
|
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
|
|
11135
|
-
if (
|
|
12248
|
+
const packageInfos = resolveWorkspacePackages(cli, workspaceRoot, patterns);
|
|
12249
|
+
if (packageInfos.length === 0) return { noAffected: true };
|
|
11136
12250
|
return {
|
|
11137
|
-
packageInfos
|
|
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
|
-
|
|
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 {
|
|
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 };
|