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